Files
bindery/tests/js/frontend.test.mjs
T
tim 2f4271314b Add the web interface
Plain HTML, CSS and JavaScript modules, with no build step. Hash routes
and relative URLs make it work under any path. The job list is polled:
every 2 seconds while a job runs, and less often otherwise.
2026-09-27 23:38:28 +02:00

236 lines
10 KiB
JavaScript

// 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, update_interval: 0 };
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)],
['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 id="bindery-app"><\/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}`);
}
}
});