// DOM and stylesheet checks without a browser. import assert from 'node:assert/strict'; import { readFileSync, readdirSync } from 'node:fs'; import { beforeEach, test } from 'node:test'; class Element { constructor(tag) { this.tagName = tag; this.attributes = {}; this.children = []; this.listeners = {}; this.className = ''; } append(...children) { this.children.push(...children); } replaceChildren(...children) { this.children = children; } setAttribute(name, value) { this.attributes[name] = String(value); } getAttribute(name) { return this.attributes[name] ?? null; } removeAttribute(name) { delete this.attributes[name]; } addEventListener(name, listener) { this.listeners[name] = listener; } } const toasts = new Element('div'); globalThis.document = { createElement: (tag) => new Element(tag), addEventListener() {}, getElementById: () => toasts, querySelectorAll: () => [], }; const { button } = await import('../../bindery/static/js/lib.js'); const { settingsPage } = await import('../../bindery/static/js/settings.js'); const { storiesPage } = await import('../../bindery/static/js/stories.js'); const { storyPage } = await import('../../bindery/static/js/story.js'); const { chapterPage } = await import('../../bindery/static/js/chapter.js'); const { jobPanel, stopPolling } = await import('../../bindery/static/js/jobs.js'); const obsolete = new Set([ 'btn', 'btn-primary', 'btn-danger', 'btn-small', 'btn-link', 'input', 'input-number', 'row', 'spread', 'stack', 'push', 'grow', 'wide', 'block', 'plain', 'muted', 'small', 'mono', 'nowrap', 'wrap', 'faded', ]); const elements = (node) => node instanceof Element ? [node, ...node.children.flatMap(elements)] : []; const text = (node) => node instanceof Element ? node.children.map(text).join('') : String(node); const find = (root, tag, label) => elements(root).find((el) => el.tagName === tag && text(el) === label); const settle = () => new Promise((resolve) => setImmediate(resolve)); const config = { metadata_fields: { title: 'Title' }, mode_labels: {}, library_available: true, cwa_enabled: true, update_interval: 0, job_list_max_hours: 720, job_list_max_count: 500, }; const story = { id: 1, title: 'Story', author: 'Author', url: 'https://example.com/story', site: 'example.com', locked_fields: [], pinned: { title: 'Edited title' }, site_values: { title: 'Site title' }, }; const chapter = { id: 2, title: 'Chapter', url: 'https://example.com/chapter', included: false, on_site: true, current_rev: 2, revisions: 2, }; const rule = { pattern: 'before', replacement: 'after', flags: 'i', note: 'A note', enabled: true }; beforeEach(() => { toasts.replaceChildren(); globalThis.fetch = async (url) => { const replies = { 'api/auth/check': { authenticated: true, mode: 'cwa', user: 'Reader' }, 'api/config': config, 'api/jobs': [], 'api/settings': { recent_chapters: 3, personal_ini: '[defaults]' }, 'api/rules': { rules: [rule], paragraphs: false, global_paragraphs: false }, 'api/stories/1/rules': { rules: [rule], paragraphs: null, global_paragraphs: false }, 'api/stories': [story], 'api/stories/1': { story, chapters: [chapter] }, 'api/chapters/2/diff?': { chapter, a: 1, b: 2, identical: true, revisions: [{ rev: 1, source: 'download', size: 100 }, { rev: 2, source: 'recheck', size: 100 }], }, }; assert.ok(url in replies, `Unexpected request: ${url}`); return { ok: true, json: async () => replies[url] }; }; }); test('plain buttons need no class and stay disabled until the action finishes', async () => { let finish; const pending = new Promise((resolve) => { finish = resolve; }); const control = button('Save', () => pending); assert.equal(control.tagName, 'button'); assert.equal(control.attributes.type, 'button'); assert.equal(control.className, ''); let prevented = false; const clicked = control.listeners.click({ preventDefault() { prevented = true; } }); assert.equal(prevented, true); assert.equal(control.disabled, true); finish(); await clicked; assert.equal(control.disabled, false); }); test('button variants and attributes do not need a base class', () => { const control = button('Delete', () => {}, { class: 'danger', disabled: true, 'aria-label': 'Delete revision' }); assert.equal(control.className, 'danger'); assert.equal(control.disabled, true); assert.equal(control.attributes['aria-label'], 'Delete revision'); }); for (const [name, render] of [ ['settings', (main) => settingsPage(main, {}, config)], ['stories', (main) => storiesPage(main, {}, config)], ['story', (main) => storyPage(main, { storyId: '1' }, config)], ['chapter', (main) => chapterPage(main, { storyId: '1', chapterId: '2' })], ]) { test(`${name} renders native controls without utility classes`, async () => { const main = new Element('main'); const cleanup = render(main); try { await settle(); assert.equal(toasts.children.length, 0); const nodes = elements(main); assert.ok(nodes.some((el) => el.tagName === 'button')); assert.ok(nodes.some((el) => el.tagName === 'input' || el.tagName === 'select')); for (const el of nodes) { for (const cls of el.className.split(/\s+/)) assert.ok(!obsolete.has(cls), `${el.tagName}.${cls}`); } if (name === 'settings') { assert.equal(nodes.find((el) => el.attributes.id === 'recent-chapters').value, 3); assert.equal(nodes.find((el) => el.tagName === 'textarea').className, 'ini'); const pattern = nodes.find((el) => el.attributes['aria-label'] === 'Pattern'); assert.equal(pattern.value, 'before'); assert.equal(pattern.className, ''); assert.equal(nodes.find((el) => el.attributes['aria-label'] === 'Note').className, 'rule-note'); } if (name === 'story') { const download = find(main, 'a', 'Download ePub'); assert.equal(download.attributes.href, 'api/stories/1/epub'); assert.equal(download.attributes.download, ''); assert.equal(download.className, ''); assert.equal(find(main, 'button', 'Use the site value').className, ''); assert.ok(nodes.some((el) => el.className === 'chapter-excluded')); } if (name === 'chapter') { assert.equal(find(main, 'button', 'Delete').className, 'danger'); assert.equal(find(main, 'button', 'Use this revision').className, ''); } } finally { cleanup?.(); } }); } test('jobs use native secondary text and timestamps', () => { const updated = '2026-01-02T03:04:05Z'; const { el: list, render } = jobPanel(); render([{ id: 'job', status: 'queued', title: 'Story', mode_label: 'Update', updated, message: 'Waiting' }]); const nodes = elements(list); assert.ok(find(list, 'small', 'Update')); assert.equal(nodes.find((el) => el.tagName === 'time').attributes.datetime, updated); assert.equal(find(list, 'button', 'Cancel').className, ''); assert.ok(find(list, 'p', 'Waiting')); }); test('a job update keeps the scroll box of the job list', () => { const { el, render } = jobPanel(); const job = { id: 'job', status: 'running', title: 'Story', mode_label: 'Update', progress: 0.1 }; render([job]); const [box] = el.children; assert.equal(box.className, 'jobs'); render([{ ...job, progress: 0.5 }, { ...job, id: 'next', status: 'queued' }]); assert.deepEqual(el.children, [box]); assert.equal(box.children.length, 2); const [first] = box.children; render([{ ...job, progress: 0.5 }, { ...job, id: 'next', status: 'queued' }]); assert.equal(box.children[0], first, 'A poll without changes leaves the list alone'); render([]); assert.deepEqual(el.children[0].children, ['No jobs.']); render([job]); assert.deepEqual(el.children, [box]); }); test('header navigation follows account controls and marks the current page', async (t) => { const html = readFileSync(new URL('../../bindery/static/index.html', import.meta.url), 'utf8'); assert.match(html, /
<\/div>/); assert.doesNotMatch(html, /id="app"/); const app = new Element('div'); const previousWindow = globalThis.window; const listeners = {}; globalThis.window = { location: { hash: '#/' }, addEventListener: (name, listener) => { listeners[name] = listener; }, scrollTo() {}, }; t.mock.method(document, 'getElementById', (id) => { if (id === 'bindery-app') return app; if (id === 'main') return elements(app).find((el) => el.attributes.id === 'main'); return toasts; }); t.mock.method(document, 'querySelectorAll', (selector) => { assert.equal(selector, 'header nav a'); const header = app.children[0]; const nav = header.children.find((el) => el.tagName === 'nav'); return nav.children; }); try { await import('../../bindery/static/js/main.js'); await settle(); assert.equal(toasts.children.length, 0); const header = app.children[0]; assert.deepEqual(header.children.map((el) => el.tagName), ['a', 'div', 'nav']); assert.ok(find(header.children[1], 'button', 'Log out')); const nav = header.children[2]; assert.equal(nav.attributes['aria-label'], 'Main navigation'); assert.equal(find(nav, 'a', 'Stories').attributes['aria-current'], 'page'); assert.equal(find(nav, 'a', 'Settings').attributes['aria-current'], undefined); window.location.hash = '#/settings'; listeners.hashchange(); await settle(); assert.equal(find(nav, 'a', 'Stories').attributes['aria-current'], undefined); assert.equal(find(nav, 'a', 'Settings').attributes['aria-current'], 'page'); assert.equal(toasts.children.length, 0); } finally { stopPolling(); if (previousWindow === undefined) delete globalThis.window; else globalThis.window = previousWindow; } }); test('CSS and HTML generators contain no obsolete utility classes', () => { const root = new URL('../../bindery/static/', import.meta.url); const css = readFileSync(new URL('style.css', root), 'utf8'); for (const [, cls] of css.matchAll(/\.([a-z][\w-]*)/gi)) assert.ok(!obsolete.has(cls), `CSS: .${cls}`); const files = ['index.html', ...readdirSync(new URL('js/', root)).filter((file) => file.endsWith('.js')).map((file) => `js/${file}`)]; for (const file of files) { const source = readFileSync(new URL(file, root), 'utf8'); for (const [, , classes] of source.matchAll(/\bclass\s*[:=]\s*(['"`])([^'"`]+)\1/g)) { for (const cls of classes.split(/\s+/)) assert.ok(!obsolete.has(cls), `${file}: ${cls}`); } } });