239 lines
10 KiB
JavaScript
239 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,
|
|
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 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}`);
|
|
}
|
|
}
|
|
});
|