import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import os from 'bare-os'
import http from 'bare-http1'
import { DriveManager } from '../../lib/drive-manager.js'
import { NoteManager } from '../../lib/note-manager.js'
import { Router } from '../../lib/routes/index.js'
import { registerNotebookRoutes } from '../../lib/routes/notebooks.js'
import { registerWebRoutes } from '../../lib/routes/web.js'
// ==================== HELPERS ====================
function tmpSetup () {
const dir = path.join(os.tmpdir(), 'hn-web-' + Date.now() + '-' + Math.random().toString(36).slice(2))
fs.mkdirSync(dir, { recursive: true })
const store = new Corestore(dir)
const registry = new Hyperbee(store.get({ name: 'registry' }), {
keyEncoding: 'utf-8',
valueEncoding: 'json'
})
const dm = new DriveManager(store, registry)
return { store, registry, dm, dir }
}
function rmrf (dir) {
try {
for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f)
const stat = fs.statSync(fp)
if (stat.isDirectory()) rmrf(fp)
else fs.unlinkSync(fp)
}
fs.rmdirSync(dir)
} catch {}
}
/**
* Create a minimal mock server + router with web routes registered
*/
function createMockServer () {
const server = {
pathPrefix: '',
authManager: {
authenticate: async () => ({ userId: 'test', permissions: ['admin'] })
}
}
const router = new Router(server)
registerWebRoutes(router, server)
return { server, router }
}
/**
* Mock HTTP request
*/
function mockReq (method, url) {
return { method, url, params: {} }
}
/**
* Mock HTTP response that captures output
*/
function mockRes () {
const res = {
statusCode: 200,
headers: {},
body: null,
writableEnded: false,
setHeader (name, value) { res.headers[name.toLowerCase()] = value },
end (data) { res.body = data; res.writableEnded = true },
write (data) { if (!res.body) res.body = ''; res.body += data }
}
return res
}
// ==================== WEB FILES EXIST ====================
test('web — index.html exists', (t) => {
const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
const indexPath = path.join(webRoot, 'index.html')
t.ok(fs.existsSync(indexPath), 'index.html exists')
const content = fs.readFileSync(indexPath, 'utf-8')
t.ok(content.includes('<!DOCTYPE html>'), 'is valid HTML')
t.ok(content.includes('Hypernote'), 'contains app name')
t.ok(content.includes('app.css'), 'references stylesheet')
t.ok(content.includes('api.js'), 'references API client')
t.ok(content.includes('markdown.js'), 'references markdown renderer')
t.ok(content.includes('app.js'), 'references app script')
})
test('web — app.css exists and has theme variables', (t) => {
const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
const cssPath = path.join(webRoot, 'css', 'app.css')
t.ok(fs.existsSync(cssPath), 'app.css exists')
const content = fs.readFileSync(cssPath, 'utf-8')
t.ok(content.includes(':root'), 'has CSS variables root')
t.ok(content.includes('--bg:'), 'has background variable')
t.ok(content.includes('--accent:'), 'has accent color variable')
t.ok(content.includes('.markdown-body'), 'has markdown body styles')
t.ok(content.includes('@media'), 'has responsive breakpoints')
})
test('web — api.js exists and defines HnApi', (t) => {
const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
const apiPath = path.join(webRoot, 'js', 'api.js')
t.ok(fs.existsSync(apiPath), 'api.js exists')
const content = fs.readFileSync(apiPath, 'utf-8')
t.ok(content.includes('window.HnApi'), 'defines HnApi global')
t.ok(content.includes('listNotebooks'), 'has listNotebooks method')
t.ok(content.includes('getNote'), 'has getNote method')
t.ok(content.includes('createNote'), 'has createNote method')
t.ok(content.includes('search'), 'has search method')
t.ok(content.includes('getProfile'), 'has getProfile method')
})
test('web — markdown.js exists and defines renderMarkdown', (t) => {
const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
const mdPath = path.join(webRoot, 'js', 'markdown.js')
t.ok(fs.existsSync(mdPath), 'markdown.js exists')
const content = fs.readFileSync(mdPath, 'utf-8')
t.ok(content.includes('window.renderMarkdown'), 'defines renderMarkdown global')
t.ok(content.includes('wikilink'), 'handles wiki-links')
t.ok(content.includes('<pre><code>'), 'handles code blocks')
t.ok(content.includes('<strong>'), 'handles bold')
t.ok(content.includes('<em>'), 'handles italic')
})
test('web — app.js exists and has SPA router', (t) => {
const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
const appPath = path.join(webRoot, 'js', 'app.js')
t.ok(fs.existsSync(appPath), 'app.js exists')
const content = fs.readFileSync(appPath, 'utf-8')
t.ok(content.includes('hashchange'), 'listens to hash changes')
t.ok(content.includes('parseRoute'), 'has route parser')
t.ok(content.includes('showView'), 'has view management')
t.ok(content.includes('loadNotebooks'), 'has notebook loading')
t.ok(content.includes('saveNote'), 'has note saving')
t.ok(content.includes('tuiEditor'), 'has WYSIWYG editor')
})
// ==================== STATIC FILE SERVING ====================
test('web routes — serves index.html at /', async (t) => {
const { router } = createMockServer()
const req = mockReq('GET', '/')
const res = mockRes()
const matched = await router.dispatch(req, res)
t.ok(matched, 'route matched')
t.is(res.statusCode, 200)
t.ok(res.headers['content-type'].includes('text/html'), 'serves HTML')
t.ok(res.body.toString().includes('<!DOCTYPE html>'), 'serves index.html content')
t.ok(res.body.toString().includes('Hypernote'), 'contains app name')
})
test('web routes — serves CSS files', async (t) => {
const { router } = createMockServer()
const req = mockReq('GET', '/web/css/app.css')
const res = mockRes()
const matched = await router.dispatch(req, res)
t.ok(matched, 'route matched')
t.is(res.statusCode, 200)
t.ok(res.headers['content-type'].includes('text/css'), 'correct MIME type')
t.ok(res.body.toString().includes(':root'), 'serves actual CSS content')
})
test('web routes — serves JS files', async (t) => {
const { router } = createMockServer()
const req = mockReq('GET', '/web/js/api.js')
const res = mockRes()
const matched = await router.dispatch(req, res)
t.ok(matched, 'route matched')
t.is(res.statusCode, 200)
t.ok(res.headers['content-type'].includes('javascript'), 'correct MIME type')
t.ok(res.body.toString().includes('HnApi'), 'serves actual JS content')
})
test('web routes — 404 for missing files', async (t) => {
const { router } = createMockServer()
const req = mockReq('GET', '/web/nonexistent.js')
const res = mockRes()
const matched = await router.dispatch(req, res)
t.ok(matched, 'route matched (handler ran)')
t.is(res.statusCode, 404, 'returns 404')
})
test('web routes — blocks directory traversal', async (t) => {
const { router } = createMockServer()
const req = mockReq('GET', '/web/../hypernote-server.js')
const res = mockRes()
const matched = await router.dispatch(req, res)
t.ok(matched, 'route matched')
t.is(res.statusCode, 403, 'returns 403 Forbidden')
})
test('web routes — sets security headers', async (t) => {
const { router } = createMockServer()
const req = mockReq('GET', '/')
const res = mockRes()
await router.dispatch(req, res)
t.ok(res.headers['x-content-type-options'], 'has nosniff header')
t.ok(res.headers['x-frame-options'], 'has frame options header')
t.ok(res.headers['referrer-policy'], 'has referrer policy')
})
test('web routes — sets cache headers for static assets', async (t) => {
const { router } = createMockServer()
const req = mockReq('GET', '/web/css/app.css')
const res = mockRes()
await router.dispatch(req, res)
t.ok(res.headers['cache-control'], 'has cache-control header')
t.ok(res.headers['cache-control'].includes('max-age'), 'sets max-age')
})
// ==================== API ENDPOINT COEXISTENCE ====================
test('web routes — API routes still work alongside web routes', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const server = {
pathPrefix: '',
noteManager: nm,
authManager: {
authenticate: async () => ({ userId: 'test', permissions: ['admin'] }),
authorizeRequest: async () => ({
authorized: true,
auth: { userId: 'test', permissions: ['admin'] },
reason: null
})
}
}
const router = new Router(server)
registerWebRoutes(router, server)
registerNotebookRoutes(router, server)
// Web route works
const webReq = mockReq('GET', '/')
const webRes = mockRes()
await router.dispatch(webReq, webRes)
t.is(webRes.statusCode, 200)
t.ok(webRes.headers['content-type'].includes('text/html'))
// API route still works
const apiReq = mockReq('GET', '/api/notebooks')
const apiRes = mockRes()
await router.dispatch(apiReq, apiRes)
t.is(apiRes.statusCode, 200)
t.ok(apiRes.headers['content-type'].includes('application/json'))
await store.close()
rmrf(dir)
})
// ==================== HTML STRUCTURE ====================
test('web — index.html has required view elements', (t) => {
const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
const content = fs.readFileSync(path.join(webRoot, 'index.html'), 'utf-8')
// Views
t.ok(content.includes('id="view-home"'), 'has home view')
t.ok(content.includes('id="view-notebook"'), 'has notebook view')
t.ok(content.includes('id="view-note"'), 'has note view')
t.ok(content.includes('id="view-editor"'), 'has editor view')
t.ok(content.includes('id="view-search"'), 'has search view')
// Sidebar
t.ok(content.includes('id="notebook-list"'), 'has notebook list')
// Editor
t.ok(content.includes('id="editor-container"'), 'has editor container')
// Auth
t.ok(content.includes('id="auth-setup"'), 'has auth setup')
t.ok(content.includes('id="auth-token-input"'), 'has token input')
})
test('web — CSS has essential component styles', (t) => {
const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
const content = fs.readFileSync(path.join(webRoot, 'css', 'app.css'), 'utf-8')
t.ok(content.includes('#sidebar'), 'has sidebar styles')
t.ok(content.includes('#topbar'), 'has topbar styles')
t.ok(content.includes('#editor-container'), 'has editor container styles')
t.ok(content.includes('.note-card'), 'has note card styles')
t.ok(content.includes('.modal'), 'has modal styles')
t.ok(content.includes('.auth-card'), 'has auth card styles')
})