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.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 09d6b76a17
commit 2f4271314b
30 changed files with 4442 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { install, server, nodes, find, field, emit, settle } from './flow-helpers.mjs';
const ui = install();
const { api, setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js');
const { stopPolling } = await import('../../bindery/static/js/jobs.js');
test('login retry, navigation, session expiry and logout preserve the authentication lifecycle', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const backend = server(t);
let auth = { authenticated: false, mode: 'cwa', user: null };
backend.on('GET', 'auth/check', () => auth);
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories', []);
backend.on('GET', 'rules', { rules: [], paragraphs: false });
backend.on('GET', 'settings', { recent_chapters: 3, personal_ini: '' });
backend.on('GET', 'stories/1', { error: 'Story unavailable' }, 404);
backend.on('GET', 'stories/1/rules', { rules: [], paragraphs: null });
backend.on('GET', 'chapters/2/diff?', { error: 'Chapter unavailable' }, 404);
backend.on('GET', 'expired', { error: 'Session ended' }, 401);
backend.on('POST', 'auth/logout', () => { auth = { ...auth, authenticated: false }; return {}; });
t.after(() => { stopPolling(); setUnauthorizedHandler(() => {}); });
await import('../../bindery/static/js/main.js');
await settle();
assert.equal(document.activeElement, field(ui.app, 'username'));
assert.equal(backend.sent('GET', 'config').length, 0);
ui.windowEvents.hashchange();
assert.equal(backend.sent('GET', 'stories').length, 0);
const form = () => nodes(ui.app).find((el) => el.tagName === 'form');
field(ui.app, 'username').value = 'reader';
field(ui.app, 'password').value = 'wrong';
nodes(form()).find((el) => el.attributes.type === 'checkbox').checked = false;
backend.on('POST', 'auth/login', { error: 'Wrong password' }, 401);
await emit(form(), 'submit');
assert.match(ui.app.textContent, /Wrong password/);
assert.equal(find(ui.app, 'button', 'Log in').disabled, false);
assert.deepEqual(backend.sent('POST', 'auth/login')[0].body, { username: 'reader', password: 'wrong', remember: false });
field(ui.app, 'password').value = 'correct';
backend.on('POST', 'auth/login', () => { auth = { authenticated: true, mode: 'cwa', user: 'reader' }; return {}; });
await emit(form(), 'submit');
await settle();
assert.ok(find(ui.app, 'button', 'Log out'));
assert.equal(backend.sent('GET', 'config').length, 1);
for (const path of ['', '#/settings', '#/story/1', '#/story/1/chapter/2', '#/missing']) {
window.location.hash = path;
ui.windowEvents.hashchange();
await settle();
}
assert.match(ui.app.textContent, /Page not found/);
assert.equal(backend.sent('GET', 'stories/1/rules').length, 1);
assert.equal(backend.sent('GET', 'chapters/2/diff?').length, 1);
auth = { authenticated: false, mode: 'cwa' };
await assert.rejects(api('expired'), /Session ended/);
await settle();
assert.ok(find(ui.app, 'button', 'Log in'));
const polls = backend.sent('GET', 'jobs').length;
t.mock.timers.tick(60000);
await settle();
assert.equal(backend.sent('GET', 'jobs').length, polls);
const checks = backend.sent('GET', 'auth/check').length;
await assert.rejects(api('expired'), /Session ended/);
assert.equal(backend.sent('GET', 'auth/check').length, checks);
await emit(form(), 'submit');
await settle();
await emit(find(ui.app, 'button', 'Log out'), 'click');
await settle();
assert.ok(find(ui.app, 'button', 'Log in'));
assert.equal(backend.sent('POST', 'auth/logout').length, 1);
backend.on('POST', 'auth/login', () => {
auth = { authenticated: true, mode: 'proxy', user: 'reader', logout_url: 'https://login.example/logout' };
return {};
});
await emit(form(), 'submit');
await settle();
await emit(find(ui.app, 'button', 'Log out'), 'click');
assert.equal(window.location.href, 'https://login.example/logout');
});
+100
View File
@@ -0,0 +1,100 @@
// Small event harness for testing requests and state transitions, not layout.
import assert from 'node:assert/strict';
export class Element {
constructor(tag) {
this.tagName = tag;
this.attributes = {};
this.children = [];
this.listeners = {};
this.className = '';
this.value = '';
this.checked = false;
this.disabled = false;
this.files = [];
}
append(...children) {
for (const child of children) {
if (child instanceof Element) child.parent = this;
this.children.push(child);
}
}
replaceChildren(...children) {
for (const child of this.children) if (child instanceof Element) child.parent = null;
this.children = [];
this.append(...children);
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return this.attributes[name] ?? null; }
removeAttribute(name) { delete this.attributes[name]; }
addEventListener(name, fn) { this.listeners[name] = fn; }
focus() { document.activeElement = this; }
remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); }
get firstElementChild() { return this.children.find((child) => child instanceof Element); }
get textContent() { return this.children.map((child) => child instanceof Element ? child.textContent : String(child)).join(''); }
set textContent(value) { this.replaceChildren(value); }
}
export const nodes = (root) => root instanceof Element ? [root, ...root.children.flatMap(nodes)] : [];
export const find = (root, tag, text) => nodes(root).find((el) => el.tagName === tag && el.textContent === text);
export const field = (root, label) => nodes(root).find((el) => el.attributes['aria-label'] === label || el.attributes.id === label || el.attributes.name === label);
export const section = (root, title) => nodes(root).find((el) => ['section', 'details'].includes(el.tagName) && (find(el, 'h2', title) || find(el, 'h1', title)));
export const emit = (el, event) => {
assert.ok(el, `Missing control for ${event}`);
assert.ok(el.listeners[event], `No ${event} handler on ${el.tagName}`);
return el.listeners[event]({ target: el, preventDefault() {} });
};
export const click = (root, label) => emit(find(root, 'button', label), 'click');
export const input = (el, value) => { el.value = value; return emit(el, 'input'); };
export const change = (el, value) => {
if (el.attributes.type === 'checkbox') el.checked = value;
else el.value = value;
return emit(el, 'change');
};
export const settle = async () => { for (let i = 0; i < 4; i++) await new Promise((resolve) => setImmediate(resolve)); };
export function install() {
const app = new Element('div');
const toasts = new Element('div');
const documentEvents = {};
const windowEvents = {};
globalThis.document = {
hidden: false,
createElement: (tag) => new Element(tag),
getElementById: (id) => id === 'bindery-app' ? app : id === 'toasts' ? toasts : nodes(app).find((el) => el.attributes.id === id),
addEventListener: (name, fn) => { documentEvents[name] = fn; },
querySelectorAll: () => nodes(app).filter((el) => el.tagName === 'nav').flatMap((el) => nodes(el).filter((child) => child.tagName === 'a')),
};
globalThis.window = {
location: { hash: '#/', reload() {} }, confirm: () => true, scrollTo() {},
addEventListener: (name, fn) => { windowEvents[name] = fn; },
};
globalThis.DOMParser = class {
parseFromString(html) {
return { body: { textContent: html.replace(/<[^>]*>/g, ''), querySelectorAll: () => [] } };
}
};
return { app, toasts, documentEvents, windowEvents };
}
export function server(t) {
const routes = new Map();
const requests = [];
const unexpected = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
const request = { method: options.method, path: url.replace(/^api\//, ''),
body: typeof options.body === 'string' ? JSON.parse(options.body) : options.body, headers: options.headers };
requests.push(request);
const key = `${request.method} ${request.path}`;
const route = routes.get(key);
if (!route) { unexpected.push(key); throw new Error(`Unexpected request: ${key}`); }
const body = typeof route.body === 'function' ? await route.body(request) : route.body;
return { ok: route.status < 400, status: route.status, statusText: 'Error', json: async () => structuredClone(body) };
});
t.after(() => assert.deepEqual(unexpected, [], 'No unexpected API calls'));
return {
on(method, path, body, status = 200) { routes.set(`${method} ${path}`, { body, status }); },
requests,
sent: (method, path) => requests.filter((r) => r.method === method && r.path === path),
};
}
+444
View File
@@ -0,0 +1,444 @@
import assert from 'node:assert/strict';
import { beforeEach, afterEach, test } from 'node:test';
import { Element, install, server, nodes, find, field, section, emit, click, input, change, settle } from './flow-helpers.mjs';
let ui = install();
const { settingsPage } = await import('../../bindery/static/js/settings.js');
const { storiesPage, epubImport } = 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 { rulesEditor, rulesPreview } = await import('../../bindery/static/js/rules.js');
const { startPolling, stopPolling, refreshJobs, jobPanel } = await import('../../bindery/static/js/jobs.js');
const { setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js');
let api, main, details, cleanups;
const config = {
metadata_fields: { title: 'Title', authors: 'Authors', comments: 'Description', series: 'Series', pubdate: 'Published', tags: 'Tags', languages: 'Language' },
mode_labels: { new: 'New chapters' }, library_available: true, update_interval: 2,
};
const rule = { pattern: 'before', replacement: 'after', flags: 'i', note: '', enabled: true };
const emptyRules = { rules: [], paragraphs: null, global_paragraphs: true };
beforeEach((t) => {
ui = install();
t.mock.timers.enable({ apis: ['setTimeout'] });
setUnauthorizedHandler(() => {});
api = server(t);
main = new Element('main');
cleanups = [];
details = {
story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com',
locked_fields: [], pinned: { title: 'Edited title' }, site_values: { title: 'Site title', authors: ['Writer'],
comments: '<p>Description</p>', series: 'Saga', series_index: 2, pubdate: '2024-01-02T00:00:00', tags: ['Fantasy'], languages: 'en' },
auto_update: true, recheck_days: 3, last_full_recheck: '2026-01-01', last_checked: '2026-01-02', status: 'ok' },
chapters: [{ id: 2, title: 'One', url: 'chapter-1', included: true, on_site: true, current_rev: 1, revisions: 2 },
{ id: 3, title: 'Two', url: 'chapter-2', included: false, on_site: false, current_rev: 1, revisions: 1 }],
recent_chapters: 3,
};
api.on('GET', 'jobs', []);
api.on('GET', 'stories', () => [details.story]);
api.on('GET', 'stories/1', () => details);
api.on('GET', 'rules', emptyRules);
api.on('GET', 'stories/1/rules', emptyRules);
api.on('GET', 'settings', { recent_chapters: 3, personal_ini: '[defaults]' });
});
afterEach(() => { cleanups.forEach((fn) => fn?.()); stopPolling(); });
const mountStory = async (cfg = config) => { cleanups.push(storyPage(main, { storyId: '1' }, cfg)); await settle(); };
const mountStories = async (cfg = config) => { cleanups.push(storiesPage(main, {}, cfg)); await settle(); };
const writes = () => api.requests.filter((r) => r.method !== 'GET');
for (const value of ['7', '']) {
test(`settings save converts ${JSON.stringify(value)} to a count`, async () => {
settingsPage(main);
await settle();
api.on('PUT', 'settings', {});
field(main, 'recent-chapters').value = value;
await click(section(main, 'Settings'), 'Save');
assert.deepEqual(api.sent('PUT', 'settings')[0].body, { recent_chapters: value === '' ? 0 : 7 });
assert.match(ui.toasts.textContent, /Saved/);
});
}
test('personal.ini save reports warnings and clears them after a clean save', async () => {
settingsPage(main);
await settle();
const card = section(main, 'FanFicFare settings (personal.ini)');
field(main, 'personal.ini').value = '[defaults]\nis_adult: true';
api.on('PUT', 'settings', { warnings: ['Unknown setting'] });
await click(card, 'Save');
assert.deepEqual(api.sent('PUT', 'settings')[0].body, { personal_ini: '[defaults]\nis_adult: true' });
assert.match(card.textContent, /Unknown setting/);
api.on('PUT', 'settings', { warnings: [] });
await click(card, 'Save');
assert.doesNotMatch(card.textContent, /Unknown setting/);
});
test('failed settings saves retain edits and re-enable the action', async () => {
settingsPage(main);
await settle();
const card = section(main, 'FanFicFare settings (personal.ini)');
field(main, 'personal.ini').value = 'invalid ini';
api.on('PUT', 'settings', { error: 'Invalid settings' }, 400);
await click(card, 'Save');
assert.equal(field(main, 'personal.ini').value, 'invalid ini');
assert.equal(find(card, 'button', 'Save').disabled, false);
assert.match(ui.toasts.textContent, /Invalid settings/);
});
test('adding stories keeps input on rejection and clears it after a mixed result', async () => {
await mountStories({ ...config, library_available: false });
const urls = nodes(main).find((el) => el.tagName === 'textarea');
urls.value = ' https://site.test/1\n\nhttps://site.test/2 ';
api.on('POST', 'stories', { error: 'Unavailable' }, 503);
await click(main, 'Add');
assert.match(urls.value, /site.test/);
api.on('POST', 'stories', { jobs: [{ id: 'job' }], errors: ['Unsupported URL'] });
await click(main, 'Add');
assert.deepEqual(api.sent('POST', 'stories').at(-1).body, { urls: ' https://site.test/1\n\nhttps://site.test/2 ' });
assert.equal(urls.value, '');
assert.match(ui.toasts.textContent, /Unsupported URL/);
await click(main, 'Add');
assert.equal(api.sent('POST', 'stories').length, 2);
});
test('story filtering handles early input, empty results and case-insensitive matches', async () => {
let resolve;
api.on('GET', 'stories', () => new Promise((done) => { resolve = done; }));
cleanups.push(storiesPage(main, {}, config));
input(field(main, 'Filter stories'), ' WRITER ');
resolve([details.story]);
await settle();
assert.ok(find(main, 'a', 'Story'));
input(field(main, 'Filter stories'), 'missing');
assert.match(main.textContent, /No stories match/);
input(field(main, 'Filter stories'), 'example.com');
assert.ok(find(main, 'a', 'Story'));
cleanups.pop()();
api.on('GET', 'stories', []);
await mountStories();
assert.match(main.textContent, /No stories yet/);
});
for (const storyId of [undefined, 1]) {
test(`epub import ${storyId ?? 'new story'} sends the file and check option`, async () => {
const root = epubImport(storyId);
const path = storyId ? 'stories/1/import-epub' : 'stories/import-epub';
await click(root, 'Import');
assert.equal(writes().length, 0);
assert.match(ui.toasts.textContent, /Choose an ePub/);
const file = field(root, 'Epub file');
const book = new File(['book'], 'story.epub', { type: 'application/epub+zip' });
file.files = [book];
file.value = 'story.epub';
change(nodes(root).find((el) => el.attributes.type === 'checkbox'), false);
api.on('POST', path, { error: 'Wrong story' }, 400);
await click(root, 'Import');
assert.equal(file.value, 'story.epub');
api.on('POST', path, { id: 'import' });
await click(root, 'Import');
const sent = api.sent('POST', path).at(-1).body;
assert.equal(sent.get('check'), 'false');
assert.equal(sent.get('file').name, 'story.epub');
assert.equal(await sent.get('file').text(), 'book');
assert.equal(file.value, '');
});
}
test('library import sends only selected books and handles an empty candidate list', async () => {
await mountStories();
api.on('GET', 'library/candidates', []);
await click(main, 'Find stories in the library');
assert.match(main.textContent, /tracked already/);
const books = [{ calibre_id: 8, uuid: 'a', title: 'A' }, { calibre_id: 9, uuid: 'b', title: 'B' }];
api.on('GET', 'library/candidates', books);
await click(main, 'Find stories in the library');
change(field(main, 'Select all'), false);
await click(main, 'Import selected');
assert.equal(writes().length, 0);
change(field(main, 'Select all'), true);
change(field(main, 'A'), false);
change(field(main, 'A'), true);
change(field(main, 'B'), false);
const card = section(main, 'Import from the library');
change(nodes(card).find((el) => el.attributes.type === 'checkbox'), false);
api.on('POST', 'library/import', [{ id: 'job' }]);
await click(main, 'Import selected');
assert.deepEqual(api.sent('POST', 'library/import')[0].body, { books: [{ id: 8, uuid: 'a', title: 'A' }], check: false });
});
test('rules edits, moves, removal and paragraph choices are saved in order', async () => {
api.on('GET', 'stories/1/rules', { ...emptyRules, rules: [rule, { ...rule, pattern: 'second' }] });
const editor = rulesEditor('stories/1/rules');
await settle();
const root = editor.element;
const controls = (label) => nodes(root).filter((el) => el.attributes['aria-label'] === label);
emit(controls('Move up')[0], 'click');
emit(controls('Move down')[1], 'click');
emit(controls('Move down')[0], 'click');
input(controls('Pattern')[0], 'edited');
change(controls('Enabled')[0], false);
emit(controls('Move up')[1], 'click');
emit(controls('Remove')[0], 'click');
await click(root, 'Add rule');
input(controls('Replacement')[1], 'replacement');
api.on('PUT', 'stories/1/rules', (req) => ({ ...req.body, global_paragraphs: true }));
for (const [choice, expected] of [['off', false], ['on', true], ['', null]]) {
change(field(root, 'paragraphs'), choice);
await click(root, 'Save');
const sent = api.sent('PUT', 'stories/1/rules').at(-1).body;
assert.equal(sent.paragraphs, expected);
assert.equal(sent.rules[0].pattern, 'edited');
assert.equal(sent.rules[0].enabled, false);
assert.equal(sent.rules[1].replacement, 'replacement');
}
});
test('rule validation failures retain edits and preview uses unsaved values', async () => {
api.on('GET', 'rules', { ...emptyRules, rules: [rule], paragraphs: false });
const editor = rulesEditor('rules');
await settle();
input(field(editor.element, 'Pattern'), '[');
change(nodes(editor.element).find((el) => el.attributes.type === 'checkbox'), true);
api.on('PUT', 'rules', { error: 'Invalid pattern', index: 0 }, 400);
await click(editor.element, 'Save');
assert.equal(field(editor.element, 'Pattern').value, '[');
assert.match(ui.toasts.textContent, /Invalid pattern/);
api.on('POST', 'rules/preview', { error: 'Invalid pattern', index: 0 }, 400);
await assert.rejects(editor.preview(2, false), /Invalid pattern/);
input(field(editor.element, 'Pattern'), 'fixed');
api.on('POST', 'rules/preview', { identical: true });
assert.deepEqual(await editor.preview(2, true), { identical: true });
assert.deepEqual(api.sent('POST', 'rules/preview').at(-1).body,
{ rules: [{ ...rule, pattern: 'fixed' }], paragraphs: true, chapter_id: 2, source: true });
});
test('rule previews keep a valid chapter selection and skip empty lists', async () => {
const calls = [];
const editor = { preview: async (...args) => { calls.push(args); return { identical: calls.length < 3, html: '<p>diff</p>' }; } };
const preview = rulesPreview(editor);
await click(preview.element, 'Preview');
assert.equal(calls.length, 0);
preview.setChapters(details.chapters);
change(field(preview.element, 'Chapter to preview'), '3');
preview.setChapters(details.chapters);
await click(preview.element, 'Preview');
change(nodes(preview.element).find((el) => el.attributes.type === 'checkbox'), true);
await click(preview.element, 'Preview');
preview.setChapters([details.chapters[0]]);
await click(preview.element, 'Preview');
assert.deepEqual(calls, [[3, false], [3, true], [2, true]]);
preview.setChapters([]);
await click(preview.element, 'Preview');
assert.equal(calls.length, 3);
});
test('story download modes send selected chapters and clear forced fields after success', async () => {
await mountStory();
api.on('POST', 'stories/1/download', { mode_label: 'Queued' });
const radio = (mode) => nodes(main).find((el) => el.attributes.type === 'radio' && el.value === mode);
change(radio('recheck'), 'recheck');
await click(main, 'Start');
await click(main, 'Recheck selected');
await click(main, 'Take out of book');
assert.equal(writes().length, 0);
change(field(main, 'Select One'), true);
change(field(main, 'Select One'), false);
change(field(main, 'Select all chapters'), true);
change(field(main, 'Select all chapters'), false);
change(field(main, 'Select One'), true);
const force = nodes(section(main, 'Download')).filter((el) => el.attributes.type === 'checkbox');
change(force[0], true);
change(force[1], true);
change(force[1], false);
await click(main, 'Start');
assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode: 'recheck', force_fields: ['title'], chapter_ids: [2] });
for (const mode of ['new', 'recheck_all', 'full', 'metadata']) {
change(radio(mode), mode);
await click(main, 'Start');
assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode, force_fields: [], chapter_ids: [] });
}
await click(main, 'Recheck selected');
assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode: 'recheck', chapter_ids: [2] });
await click(main, 'Rebuild book');
assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode: 'rebuild' });
});
test('chapter inclusion changes reload the story after queuing the rebuild', async () => {
await mountStory();
change(field(main, 'Select One'), true);
api.on('POST', 'stories/1/chapters', {});
for (const [label, included] of [['Take out of book', false], ['Put back in book', true]]) {
await click(main, label);
assert.deepEqual(api.sent('POST', 'stories/1/chapters').at(-1).body, { chapter_ids: [2], included });
}
assert.equal(api.sent('GET', 'stories/1').length, 3);
});
test('unsaved metadata and update settings survive job completion, then save typed values', async () => {
await mountStory();
startPolling();
await settle();
api.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'running' }]);
await refreshJobs();
input(field(main, 'Title'), 'My title');
input(field(main, 'Description'), 'One\n\nTwo <three>');
input(field(main, 'Authors'), 'Writer');
change(field(main, 'Lock Title'), true);
change(field(main, 'Lock Authors'), true);
change(field(main, 'Lock Authors'), false);
input(field(main, 'recheck-days'), '5');
change(nodes(section(main, 'Updates')).find((el) => el.attributes.type === 'checkbox'), false);
api.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'done' }]);
await refreshJobs();
await settle();
assert.equal(field(main, 'Title').value, 'My title');
assert.equal(field(main, 'Lock Title').checked, true);
assert.equal(field(main, 'recheck-days').value, '5');
api.on('PUT', 'stories/1/settings', {});
api.on('PUT', 'stories/1/metadata', { job: { id: 'rebuild' } });
await click(section(main, 'Metadata'), 'Save');
assert.deepEqual(api.sent('PUT', 'stories/1/settings').at(-1).body, { locked_fields: ['title'] });
assert.deepEqual(api.sent('PUT', 'stories/1/metadata')[0].body,
{ values: { title: 'My title', comments: '<p>One</p><p>Two &lt;three&gt;</p>' } });
await click(section(main, 'Updates'), 'Save');
assert.deepEqual(api.sent('PUT', 'stories/1/settings').at(-1).body, { auto_update: false, recheck_days: 5 });
input(field(main, 'recheck-days'), '');
await click(section(main, 'Updates'), 'Save');
assert.equal(api.sent('PUT', 'stories/1/settings').at(-1).body.recheck_days, 0);
});
test('metadata saves skip unchanged drafts, allow edits without a rebuild, and restore site values', async () => {
await mountStory();
api.on('PUT', 'stories/1/settings', {});
api.on('PUT', 'stories/1/metadata', { job: null });
input(field(main, 'Title'), 'Edited title');
await click(section(main, 'Metadata'), 'Save');
assert.equal(api.sent('PUT', 'stories/1/metadata').length, 0);
input(field(main, 'Title'), 'New title');
await click(section(main, 'Metadata'), 'Save');
assert.deepEqual(api.sent('PUT', 'stories/1/metadata')[0].body, { values: { title: 'New title' } });
await click(main, 'Use the site value');
assert.deepEqual(api.sent('PUT', 'stories/1/metadata').at(-1).body, { values: { title: null } });
});
test('failed rule saves do not queue a rebuild', async () => {
await mountStory();
const card = section(main, 'Replacement rules');
api.on('PUT', 'stories/1/rules', { error: 'Bad rule' }, 400);
await click(card, 'Save and rebuild');
assert.equal(api.sent('POST', 'stories/1/download').length, 0);
api.on('PUT', 'stories/1/rules', emptyRules);
api.on('POST', 'stories/1/download', { mode_label: 'Rebuild' });
await click(card, 'Save and rebuild');
assert.deepEqual(api.sent('POST', 'stories/1/download')[0].body, { mode: 'rebuild' });
});
test('library search trims the query and links by UUID', async () => {
await mountStory();
const form = nodes(section(main, 'Library link')).find((el) => el.tagName === 'form');
emit(form, 'submit');
assert.equal(api.requests.some((r) => r.path.startsWith('library/search')), false);
field(main, 'Search the library').value = ' A & B ';
api.on('GET', 'library/search?q=A%20%26%20B', []);
emit(form, 'submit');
await settle();
assert.match(main.textContent, /No library books match/);
api.on('GET', 'library/search?q=A%20%26%20B', [{ id: 7, uuid: 'book-7', title: 'A & B', authors: ['A', 'B'] },
{ id: 8, uuid: 'book-8', title: 'Tracked', authors: 'Writer', tracked_as: 'Other' }]);
emit(form, 'submit');
await settle();
const links = nodes(main).filter((el) => el.tagName === 'button' && el.textContent === 'Link');
assert.equal(links[1].disabled, true);
api.on('POST', 'stories/1/link', {});
await emit(links[0], 'click');
assert.deepEqual(api.sent('POST', 'stories/1/link')[0].body, { uuid: 'book-7' });
assert.equal(api.sent('GET', 'stories/1').length, 2);
});
test('sending as a new book and stopping tracking require confirmation', async () => {
details.story.ingested_at = '2026-01-01';
await mountStory();
window.confirm = () => false;
await click(main, 'Send to CWA as a new book');
await click(main, 'Stop tracking this story');
assert.equal(writes().length, 0);
window.confirm = () => true;
api.on('POST', 'stories/1/link', {});
api.on('POST', 'stories/1/download', { mode_label: 'Rebuild' });
await click(main, 'Send to CWA as a new book');
assert.deepEqual(writes().map((r) => [r.path, r.body]), [['stories/1/link', { uuid: null }], ['stories/1/download', { mode: 'rebuild' }]]);
api.on('DELETE', 'stories/1', {});
window.location.hash = '#/story/1';
await click(main, 'Stop tracking this story');
assert.equal(api.sent('DELETE', 'stories/1').length, 1);
assert.equal(window.location.hash, '#/');
});
test('story load failures are reported and sparse offline stories remain usable', async () => {
api.on('GET', 'stories/1', { error: 'Missing story' }, 404);
await mountStory();
assert.match(ui.toasts.textContent, /Missing story/);
cleanups.pop()();
details.story.pinned = {};
details.story.site_values = {};
details.chapters = [];
details.recent_chapters = 0;
details.story.last_full_recheck = null;
details.story.calibre_id = 4;
details.library_error = 'Offline';
api.on('GET', 'stories/1', () => details);
await mountStory({ ...config, library_available: false, update_interval: 0 });
assert.equal(field(main, 'Title').value, '');
assert.match(main.textContent, /Offline/);
});
test('chapter comparisons and revision changes send the chosen revision numbers', async () => {
let diff = { chapter: details.chapters[0], a: 1, b: 2, identical: false, html: '<p>diff</p>',
changed: 1, added: 0, removed: 0, revisions: [{ rev: 1, size: 100 }, { rev: 2, size: 110 }, { rev: 3, size: 120 }] };
api.on('GET', 'chapters/2/diff?', () => diff);
cleanups.push(chapterPage(main, { storyId: '1', chapterId: '2' }));
await settle();
change(field(main, 'Older revision'), '2');
change(field(main, 'Newer revision'), '1');
change(nodes(main).find((el) => el.attributes.type === 'checkbox'), true);
api.on('GET', 'chapters/2/diff?a=2&b=1&source=1', () => diff);
await click(main, 'Compare');
assert.equal(api.sent('GET', 'chapters/2/diff?a=2&b=1&source=1').length, 1);
api.on('POST', 'chapters/2/revision', {});
api.on('GET', 'chapters/2/diff?a=1&b=2&source=1', () => diff);
await click(main, 'Use this revision');
assert.deepEqual(api.sent('POST', 'chapters/2/revision')[0].body, { rev: 2 });
window.confirm = () => false;
await click(main, 'Delete');
assert.equal(api.sent('DELETE', 'chapters/2/revisions/2').length, 0);
window.confirm = () => true;
api.on('DELETE', 'chapters/2/revisions/2', {});
diff = { ...diff, a: 1, b: 1, revisions: [{ rev: 1, size: 100 }] };
api.on('GET', 'chapters/2/diff?source=1', () => diff);
await click(main, 'Delete');
assert.equal(api.sent('DELETE', 'chapters/2/revisions/2').length, 1);
assert.equal(api.sent('GET', 'chapters/2/diff?source=1').length, 1);
});
test('job cancellation posts the job ID and refreshes polling', async () => {
startPolling();
await settle();
const { el: root, render } = jobPanel();
render([{ id: 'cancel-me', status: 'queued', url: 'story-url' },
{ id: 'running', status: 'running', story_id: 1, progress: 0.5,
result: { changed: [{ chapter_id: 2, title: 'One' }, { chapter_id: 3, title: 'Two' }] } }]);
api.on('POST', 'jobs/cancel-me/cancel', {});
await click(root, 'Cancel');
await settle();
assert.equal(api.sent('POST', 'jobs/cancel-me/cancel').length, 1);
assert.equal(api.sent('GET', 'jobs').length, 2);
});
test('chapter titles show the entities that FanFicFare keeps as text', async () => {
details.chapters[0].title = 'Folly &amp; Magic &lt;1&gt;';
await mountStory();
assert.ok(find(main, 'a', 'Folly & Magic <1>'));
const { el, render } = jobPanel();
render([{ id: 'j', status: 'done', story_id: 1, result: { changed: [{ chapter_id: 2, title: 'A &amp; B' }] } }]);
assert.ok(find(el, 'a', 'A & B'));
});
+235
View File
@@ -0,0 +1,235 @@
// 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}`);
}
}
});
+88
View File
@@ -0,0 +1,88 @@
// Tests for the job polling in bindery/static/js/jobs.js. Run with: node --test tests/js/
import assert from 'node:assert/strict';
import { after, beforeEach, test } from 'node:test';
// jobs.js listens for tab visibility changes when it loads.
globalThis.document = { hidden: false, addEventListener() {} };
let jobs = [];
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => jobs });
const { refreshJobs, startPolling, stopPolling, watchJobs } = await import('../../bindery/static/js/jobs.js');
const job = (id, storyId, status) => ({ id, story_id: storyId, status });
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
beforeEach(async () => {
jobs = [];
startPolling();
await tick();
});
after(() => stopPolling());
test('watchJobs renders the jobs of one story', async () => {
const rendered = [];
const stop = watchJobs({ storyId: 1, render: (list) => rendered.push(list.map((j) => j.id)) });
jobs = [job('a', 1, 'queued'), job('b', 2, 'queued')];
await refreshJobs();
assert.deepEqual(rendered.at(-1), ['a']);
stop();
jobs = [job('c', 1, 'queued')];
await refreshJobs();
assert.deepEqual(rendered.at(-1), ['a']);
});
test('watchJobs reports a finished job once', async () => {
let finished = 0;
const stop = watchJobs({ storyId: 1, onFinished: () => finished++ });
jobs = [job('a', 1, 'queued')];
await refreshJobs();
jobs = [job('a', 1, 'running')];
await refreshJobs();
assert.equal(finished, 0);
jobs = [job('a', 1, 'done')];
await refreshJobs();
assert.equal(finished, 1);
await refreshJobs();
assert.equal(finished, 1);
stop();
});
test('watchJobs ignores jobs that finished before it started, and other stories', async () => {
let finished = 0;
jobs = [job('old', 1, 'done')];
await refreshJobs();
const stop = watchJobs({ storyId: 1, onFinished: () => finished++ });
jobs = [job('old', 1, 'done'), job('other', 2, 'running')];
await refreshJobs();
jobs = [job('old', 1, 'done'), job('other', 2, 'error')];
await refreshJobs();
assert.equal(finished, 0);
stop();
});
test('watchJobs without a story watches every job', async () => {
let finished = 0;
const stop = watchJobs({ onFinished: () => finished++ });
jobs = [job('a', 1, 'running'), job('b', null, 'queued')];
await refreshJobs();
jobs = [job('a', 1, 'cancelled'), job('b', null, 'error')];
await refreshJobs();
assert.equal(finished, 1);
stop();
});
test('stopPolling stops the requests', async () => {
stopPolling();
let calls = 0;
const saved = globalThis.fetch;
globalThis.fetch = async () => {
calls++;
return { ok: true, status: 200, json: async () => [] };
};
await refreshJobs();
assert.equal(calls, 0);
globalThis.fetch = saved;
});
+129
View File
@@ -0,0 +1,129 @@
// Stylesheet contracts. Check the rendered layout in a browser.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
const css = readFileSync(new URL('../../bindery/static/style.css', import.meta.url), 'utf8');
const normalize = (selector) => selector.trim().replace(/\s+/g, ' ');
function declarations(selector, source = css) {
const rules = [...source.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const rule = rules.find(([, selectors]) => normalize(selectors) === selector);
assert.ok(rule, `Missing CSS rule: ${selector}`);
return Object.fromEntries(rule[2].split(';').filter((entry) => entry.trim()).map((entry) => {
const colon = entry.indexOf(':');
return [entry.slice(0, colon).trim(), entry.slice(colon + 1).trim()];
}));
}
test('header and main share a centered width without a gap between them', () => {
const shared = declarations('header, main');
assert.equal(shared.width, 'min(1100px, calc(100% - 32px))');
assert.equal(shared.margin, '0px auto');
for (const selector of ['header', 'main']) {
const styles = declarations(selector);
assert.equal(styles.width, undefined);
assert.equal(styles.margin, undefined);
}
});
test('header and main share a theme-aware frame without adding a second login frame', () => {
const frame = declarations('header, main:not(.login)');
assert.equal(frame.border, '1px solid var(--border)');
assert.equal(frame.background, 'var(--surface)');
assert.ok(frame['box-shadow']);
assert.equal(declarations('header')['border-top-left-radius'], '12px');
assert.equal(declarations('header')['border-top-right-radius'], '12px');
assert.equal(declarations('main')['border-bottom-left-radius'], '12px');
assert.equal(declarations('main')['border-bottom-right-radius'], '12px');
assert.equal(declarations('main:not(.login)')['border-top'], '0px');
assert.equal(declarations('main.login')['max-width'], '400px');
assert.equal(declarations('main > :last-child')['margin-bottom'], '0');
});
test('header and main keep matching gutters on narrow screens', () => {
const mobile = css.slice(css.indexOf('@media (max-width: 600px)'));
const shared = declarations('header, main', mobile);
assert.equal(shared.width, 'calc(100% - 24px)');
assert.equal(shared.margin, undefined);
assert.equal(shared.padding, '12px');
assert.equal(declarations('header', mobile)['padding-bottom'], '0');
});
test('navigation looks like tabs and joins the active link to the main surface', () => {
const navigation = declarations('header nav');
assert.equal(navigation['flex-basis'], '100%');
assert.equal(navigation['margin-bottom'], '-1px');
assert.equal(declarations('header nav a')['border-radius'], '8px 8px 0 0');
const current = declarations("header nav a[aria-current='page']");
assert.equal(current.background, 'var(--surface)');
assert.equal(current.color, 'var(--text)');
assert.equal(current['border-bottom-color'], 'var(--surface)');
assert.equal(current['box-shadow'], undefined);
const focus = declarations('a:focus-visible, button:focus-visible, summary:focus-visible');
assert.equal(focus.outline, '2px solid var(--accent)');
});
test('inactive tabs have a visible outline and fill without a shadow', () => {
const tab = declarations('header nav a');
assert.equal(tab.border, '1px solid var(--border)');
assert.equal(tab.background, 'var(--bg)');
assert.equal(tab['box-shadow'], undefined);
const hover = declarations('header nav a:hover');
assert.equal(hover.background, 'var(--surface)');
assert.equal(hover['box-shadow'], undefined);
});
test('the header title and icon grow without changing the login title', () => {
assert.equal(declarations('header .brand')['font-size'], '1.65rem');
assert.equal(declarations('header .brand img').width, '32px');
assert.equal(declarations('header .brand img').height, '32px');
assert.equal(declarations('.brand')['font-size'], '1.1rem');
});
test('the Add stories textarea uses the smaller text size without shrinking other textareas', () => {
assert.equal(declarations('.ini, .story-urls')['font-size'], 'var(--text-sm)');
assert.equal(declarations('textarea')['font-size'], undefined);
});
function luminance(hex) {
const channels = hex.slice(1).match(/../g).map((channel) => {
const value = parseInt(channel, 16) / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
}
test('cards keep their borders and use a subtle background in both themes', () => {
const card = declarations('.card');
assert.equal(card.background, 'var(--card-bg)');
assert.equal(card.border, '1px solid var(--border)');
assert.equal(card['border-radius'], '8px');
assert.equal(declarations('.card-danger')['border-color'], 'var(--err)');
const dark = css.slice(css.indexOf('@media (prefers-color-scheme: dark)'));
for (const source of [css, dark]) {
const theme = declarations(':root', source);
assert.notEqual(theme['--card-bg'], theme['--surface']);
assert.notEqual(theme['--card-bg'], theme['--bg']);
const channels = theme['--card-bg'].slice(1).match(/../g).map((channel) => parseInt(channel, 16));
assert.ok(channels.every((channel) => channel === channels[0]), 'Cards use neutral grey without a colour tint');
for (const foreground of ['--text', '--muted', '--accent', '--err']) {
const light = [luminance(theme[foreground]), luminance(theme['--card-bg'])].sort((a, b) => b - a);
assert.ok((light[0] + 0.05) / (light[1] + 0.05) >= 4.5, `${foreground} on a card`);
}
}
});
test('header text has at least 4.5:1 contrast in both color themes', () => {
assert.equal(declarations('header').background, 'var(--header-bg)');
const dark = css.slice(css.indexOf('@media (prefers-color-scheme: dark)'));
for (const source of [css, dark]) {
const theme = declarations(':root', source);
assert.notEqual(theme['--header-bg'], theme['--surface']);
for (const foreground of ['--text', '--muted']) {
const light = [luminance(theme[foreground]), luminance(theme['--header-bg'])].sort((a, b) => b - a);
assert.ok((light[0] + 0.05) / (light[1] + 0.05) >= 4.5, `${foreground} on the header`);
}
}
});
+161
View File
@@ -0,0 +1,161 @@
// Tests for the helpers in bindery/static/js/lib.js. Run with: node --test "tests/js/*.test.mjs"
import assert from 'node:assert/strict';
import { beforeEach, test } from 'node:test';
// The comments field is HTML. A browser turns it into text with DOMParser.
globalThis.DOMParser = class {
parseFromString(html) {
return { body: { textContent: html.replace(/<[^>]*>/g, '') } };
}
};
// h() builds elements. These fakes keep only what the tests read.
globalThis.document = {
createElement: (tagName) => ({
tagName,
attributes: {},
children: [],
append(...children) { this.children.push(...children); },
setAttribute(name, value) { this.attributes[name] = String(value); },
}),
};
const { api, ApiError, chapterTitle, externalLink, fmtDate, formatField, plural, setUnauthorizedHandler, textToHtml } = await import(
'../../bindery/static/js/lib.js'
);
let requests = [];
let reply = { status: 200, body: {} };
beforeEach(() => {
requests = [];
reply = { status: 200, body: {} };
globalThis.fetch = async (url, options) => {
requests.push({ url, ...options });
return {
ok: reply.status < 400,
status: reply.status,
statusText: 'Status',
json: async () => {
if (reply.body === undefined) throw new SyntaxError('not JSON');
return reply.body;
},
};
};
setUnauthorizedHandler(() => {});
});
test('formatField shows series with and without a number', () => {
assert.equal(formatField('series', ['Saga', 2]), 'Saga [2]');
assert.equal(formatField('series', ['Saga', null]), 'Saga');
assert.equal(formatField('series', ['', 2]), '');
assert.equal(formatField('series', 'Saga'), 'Saga');
});
test('formatField joins lists and handles empty values', () => {
assert.equal(formatField('tags', ['Fantasy', 'LitRPG']), 'Fantasy, LitRPG');
assert.equal(formatField('title', 'A Story'), 'A Story');
assert.equal(formatField('title', null), '');
assert.equal(formatField('title', undefined), '');
assert.equal(formatField('title', ''), '');
});
test('formatField turns the description into short text', () => {
assert.equal(formatField('comments', '<p>A <b>bold</b> start.</p>'), 'A bold start.');
const long = formatField('comments', `<p>${'x'.repeat(300)}</p>`);
assert.equal(long.length, 201);
assert.ok(long.endsWith('…'));
});
test('textToHtml makes paragraphs and escapes the text', () => {
assert.equal(textToHtml('One <b>\nline\n\n Two & more \n\n\n'), '<p>One &lt;b&gt;<br/>line</p><p>Two &amp; more</p>');
assert.equal(textToHtml(' '), '');
});
test('fmtDate', () => {
assert.equal(fmtDate(null), '');
assert.equal(fmtDate(''), '');
assert.equal(fmtDate('not a date'), 'not a date');
const text = fmtDate('2026-01-02T03:04:05');
assert.ok(text.length > 0 && text !== '2026-01-02T03:04:05');
});
test('chapterTitle turns the entities that FanFicFare keeps into text', () => {
assert.equal(chapterTitle('Folly &amp; Magic &lt;1&gt;'), 'Folly & Magic <1>');
assert.equal(chapterTitle('Literal &amp;lt;'), 'Literal &lt;');
assert.equal(chapterTitle(undefined), '');
});
test('plural', () => {
assert.equal(plural(1, 'chapter'), '1 chapter');
assert.equal(plural(0, 'chapter'), '0 chapters');
assert.equal(plural(3, 'chapter'), '3 chapters');
});
test('api reads with a relative GET and no CSRF header', async () => {
reply.body = [{ id: 1 }];
assert.deepEqual(await api('stories'), [{ id: 1 }]);
assert.equal(requests[0].url, 'api/stories');
assert.equal(requests[0].method, 'GET');
assert.deepEqual(requests[0].headers, {});
assert.equal(requests[0].body, undefined);
});
test('api sends changes as JSON with the CSRF header', async () => {
await api('stories', 'POST', { urls: 'x' });
assert.equal(requests[0].method, 'POST');
assert.equal(requests[0].headers['X-Requested-With'], 'bindery');
assert.equal(requests[0].headers['Content-Type'], 'application/json');
assert.equal(requests[0].body, '{"urls":"x"}');
await api('stories/1', 'DELETE');
assert.equal(requests[1].headers['X-Requested-With'], 'bindery');
assert.equal(requests[1].headers['Content-Type'], undefined);
});
test('api sends a file upload as it is, so the browser sets the multipart type', async () => {
const form = new FormData();
form.append('file', new Blob(['epub']), 'story.epub');
await api('stories/import-epub', 'POST', form);
assert.equal(requests[0].body, form);
assert.equal(requests[0].headers['X-Requested-With'], 'bindery');
assert.equal(requests[0].headers['Content-Type'], undefined);
});
test('api turns an error answer into an ApiError with its data', async () => {
reply = { status: 400, body: { error: 'Bad pattern', index: 2 } };
await assert.rejects(api('rules', 'PUT', { rules: [] }), (e) => {
assert.ok(e instanceof ApiError);
assert.equal(e.message, 'Bad pattern');
assert.equal(e.status, 400);
assert.equal(e.data.index, 2);
return true;
});
});
test('api reports an answer that is not JSON by its status', async () => {
reply = { status: 502, body: undefined };
await assert.rejects(api('stories'), { message: '502 Status', status: 502 });
});
test('api calls the unauthorized handler, except for login requests', async () => {
let calls = 0;
setUnauthorizedHandler(() => calls++);
reply = { status: 401, body: { error: 'Unauthorized' } };
await assert.rejects(api('stories'));
assert.equal(calls, 1);
await assert.rejects(api('auth/login', 'POST', {}));
assert.equal(calls, 1);
});
test('externalLink links http and https URLs, and leaves other URLs as text', () => {
const a = externalLink('https://example.com/s/1', 'example.com');
assert.equal(a.tagName, 'a');
assert.deepEqual(a.attributes, { href: 'https://example.com/s/1', target: '_blank', rel: 'noreferrer noopener' });
assert.deepEqual(a.children, ['example.com']);
assert.deepEqual(externalLink('HTTP://example.com').children, ['HTTP://example.com']);
assert.equal(externalLink('javascript:alert(1)'), 'javascript:alert(1)');
assert.equal(externalLink(' javascript:alert(1)', 'site'), 'site');
assert.equal(externalLink('data:text/html,x', 'site'), 'site');
assert.equal(externalLink(null, 'site'), 'site');
});
+60
View File
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { install, server, settle } from './flow-helpers.mjs';
const ui = install();
const { startPolling, stopPolling, watchJobs } = await import('../../bindery/static/js/jobs.js');
test('polling adapts to work and visibility, retries failures, and stops after an in-flight request', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const api = server(t);
const rendered = [];
const unsubscribe = watchJobs({ render: (jobs) => rendered.push(jobs) });
t.after(() => { unsubscribe(); stopPolling(); });
let jobs = [];
api.on('GET', 'jobs', () => jobs);
startPolling();
await settle();
t.mock.timers.tick(9999);
assert.equal(api.requests.length, 1);
jobs = [{ id: 'job', status: 'running' }];
t.mock.timers.tick(1);
await settle();
assert.equal(api.requests.length, 2);
t.mock.timers.tick(1999);
assert.equal(api.requests.length, 2);
t.mock.timers.tick(1);
await settle();
assert.equal(api.requests.length, 3);
document.hidden = true;
ui.documentEvents.visibilitychange();
assert.equal(api.requests.length, 3);
t.mock.timers.tick(2000);
await settle();
t.mock.timers.tick(59999);
assert.equal(api.requests.length, 4);
t.mock.timers.tick(1);
await settle();
assert.equal(api.requests.length, 5);
document.hidden = false;
api.on('GET', 'jobs', () => { throw new Error('Connection lost'); });
const previous = rendered.length;
ui.documentEvents.visibilitychange();
await settle();
assert.equal(rendered.length, previous);
jobs = [{ id: 'job', status: 'done' }];
api.on('GET', 'jobs', () => jobs);
t.mock.timers.tick(2000);
await settle();
assert.deepEqual(rendered.at(-1), jobs);
let resolve;
api.on('GET', 'jobs', () => new Promise((done) => { resolve = done; }));
t.mock.timers.tick(10000);
stopPolling();
resolve([]);
await settle();
const total = api.requests.length;
t.mock.timers.tick(60000);
await settle();
assert.equal(api.requests.length, total);
});
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { install, server, find, settle } from './flow-helpers.mjs';
const ui = install();
test('proxy authentication without a user does not offer a password login', async (t) => {
const backend = server(t);
backend.on('GET', 'auth/check', { authenticated: false, mode: 'proxy' });
await import('../../bindery/static/js/main.js');
await settle();
assert.match(ui.app.textContent, /Log in through the proxy/);
assert.equal(find(ui.app, 'button', 'Log in'), undefined);
assert.deepEqual(backend.requests.map((r) => r.path), ['auth/check']);
});
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { find, install, server, settle } from './flow-helpers.mjs';
const ui = install();
const { api, setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js');
const { stopPolling } = await import('../../bindery/static/js/jobs.js');
// The proxy login page has no form to retry from, and a fetch keeps the proxy's own session
// cookie, so only a full page load lets the proxy step in again.
test('a 401 while logged in through the proxy reloads the page', async (t) => {
t.after(() => {
stopPolling();
setUnauthorizedHandler(() => {});
});
const backend = server(t);
backend.on('GET', 'auth/check', { authenticated: true, mode: 'proxy', user: 'alice' });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories', []);
backend.on('GET', 'expired', { error: 'Session ended' }, 401);
await import('../../bindery/static/js/main.js');
await settle();
assert.match(ui.app.textContent, /alice/);
let reloaded = 0;
window.location.reload = () => reloaded++;
await assert.rejects(api('expired'), /Session ended/);
await settle();
assert.equal(reloaded, 1);
assert.equal(backend.sent('GET', 'auth/check').length, 1);
});
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { install, nodes, server, settle } from './flow-helpers.mjs';
install();
const { globalRulesCard, regexDocs } = await import('../../bindery/static/js/rules.js');
const SYNTAX = 'https://docs.python.org/3/library/re.html#regular-expression-syntax';
const HOWTO = 'https://docs.python.org/3/howto/regex.html';
const links = (root) => nodes(root).filter((el) => el.tagName === 'a').map((a) => [a.textContent, a.attributes.href]);
test('the rule hints link the regular expression syntax and an introduction', async (t) => {
const backend = server(t);
backend.on('GET', 'rules', { rules: [], paragraphs: null, global_paragraphs: false });
backend.on('GET', 'stories', []);
const card = globalRulesCard();
await settle();
const expected = [['Python regular expressions', SYNTAX], ['introduction', HOWTO]];
assert.deepEqual(links(card).filter(([, href]) => href.startsWith('https://docs.python.org/')), expected);
const [syntax] = regexDocs();
assert.equal(syntax.attributes.target, '_blank');
assert.equal(syntax.attributes.rel, 'noreferrer noopener');
});
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Element, find, install, nodes, server, settle } from './flow-helpers.mjs';
install();
const { chapterPage } = await import('../../bindery/static/js/chapter.js');
test('the revision in the book and the newest revision have no Delete button', async (t) => {
const backend = server(t);
backend.on('GET', 'jobs', []);
backend.on('GET', 'chapters/2/diff?', {
chapter: { id: 2, title: 'One', url: 'https://example.com/c/1', current_rev: 1 }, story_title: 'Story',
a: 1, b: 3, identical: false, html: '', changed: 1, added: 0, removed: 0,
revisions: [{ rev: 1, size: 100 }, { rev: 2, size: 100 }, { rev: 3, size: 100 }],
});
const main = new Element('main');
const cleanup = chapterPage(main, { storyId: '1', chapterId: '2' });
await settle();
cleanup?.();
const rows = nodes(main).filter((el) => el.tagName === 'tr').slice(1);
const badges = rows.map((row) => nodes(row.children[0]).filter((el) => el.tagName === 'span').map((el) => el.textContent));
assert.deepEqual(badges, [['in book'], [], ['newest']]);
assert.deepEqual(rows.map((row) => Boolean(find(row, 'button', 'Delete'))), [false, true, false]);
});
+11
View File
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
test('the monospace rule fields use the small text size, and stay as tall as the note field', () => {
const css = readFileSync(new URL('../../bindery/static/style.css', import.meta.url), 'utf8');
const rules = [...css.matchAll(/\.rule input:not\(\.rule-note\) \{([^}]*)\}/g)].map((m) => m[1]).join('');
for (const declaration of ['font-family: var(--mono)', 'font-size: var(--text-sm)', 'line-height: 1.5rem']) {
assert.ok(rules.includes(`${declaration};`), declaration);
}
});
+14
View File
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { install, server, settle } from './flow-helpers.mjs';
const ui = install();
test('startup failure reports the API error rather than leaving a blank application', async (t) => {
const backend = server(t);
backend.on('GET', 'auth/check', { error: 'Service unavailable' }, 503);
await import('../../bindery/static/js/main.js');
await settle();
assert.match(ui.app.textContent, /Service unavailable/);
assert.deepEqual(backend.requests.map((r) => r.path), ['auth/check']);
});
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Element, install, nodes, server, settle } from './flow-helpers.mjs';
install();
const { storyPage } = await import('../../bindery/static/js/story.js');
const details = (cover) => ({
story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com',
locked_fields: [], pinned: {}, site_values: {}, auto_update: true, status: 'ok', cover },
chapters: [],
recent_chapters: 0,
});
const rules = { rules: [], paragraphs: null, global_paragraphs: false };
async function render(t, cover) {
const backend = server(t);
backend.on('GET', 'stories/1', details(cover));
backend.on('GET', 'stories/1/rules', rules);
backend.on('GET', 'jobs?story_id=1', []);
const main = new Element('main');
const cleanup = storyPage(main, { storyId: '1' }, { metadata_fields: {}, mode_labels: {} });
await settle();
cleanup?.();
const summary = nodes(main).find((el) => el.className === 'card story-summary');
return summary.children.filter((el) => el instanceof Element);
}
test('the cover comes first in the story summary, with its version in the URL', async (t) => {
const [cover, heading] = await render(t, 'abc123');
assert.equal(cover.tagName, 'img');
assert.equal(cover.className, 'cover');
assert.equal(cover.attributes.src, 'api/stories/1/cover?v=abc123');
assert.equal(heading.className, 'story-heading');
assert.equal(heading.children[0].textContent, 'Story');
});
test('a story without a cover shows only its heading', async (t) => {
const children = await render(t, null);
assert.deepEqual(children.map((el) => el.className), ['story-heading']);
});
+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { install, nodes, server, settle } from './flow-helpers.mjs';
const ui = install();
const { stopPolling } = await import('../../bindery/static/js/jobs.js');
const title = 'A very long story title that the tab cuts off with an ellipsis';
const story = (id, storyTitle) => ({
story: { id, title: storyTitle, author: 'Writer', url: 'https://example.com/story', site: 'example.com',
locked_fields: [], pinned: {}, site_values: {}, auto_update: true, status: 'ok' },
chapters: [{ id: 2, title: 'One', url: 'chapter-1', included: true, on_site: true, current_rev: 1, revisions: 1 }],
recent_chapters: 0,
});
const diff = { chapter: { id: 2, title: 'One', url: 'https://example.com/c/1', current_rev: 1 }, story_title: title,
a: 1, b: 1, identical: true, html: '', changed: 0, added: 0, removed: 0, revisions: [{ rev: 1, size: 10 }] };
const rules = { rules: [], paragraphs: null, global_paragraphs: false };
const tabs = () => nodes(ui.app).filter((el) => el.tagName === 'nav').flatMap((nav) => nav.children);
const storyTabs = () => tabs().filter((a) => a.className === 'story-tab');
const current = () => tabs().filter((a) => a.attributes['aria-current'] === 'page').map((a) => a.textContent);
const go = async (hash) => {
window.location.hash = hash;
ui.windowEvents.hashchange();
await settle();
};
test('an open story has a tab with its title, also on its chapter pages', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(stopPolling);
const backend = server(t);
backend.on('GET', 'auth/check', { authenticated: true, mode: 'none', user: '' });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories', []);
backend.on('GET', 'settings', { recent_chapters: 0, personal_ini: '' });
backend.on('GET', 'rules', rules);
backend.on('GET', 'stories/1', story(1, title));
backend.on('GET', 'stories/1/rules', rules);
backend.on('GET', 'stories/2', story(2, 'Second'));
backend.on('GET', 'stories/2/rules', rules);
backend.on('GET', 'chapters/2/diff?', diff);
await import('../../bindery/static/js/main.js');
await settle();
assert.deepEqual(storyTabs(), []);
await go('#/story/1');
const [tab] = storyTabs();
assert.equal(tab.textContent, title);
assert.equal(tab.attributes.title, title);
assert.equal(tab.attributes.href, '#/story/1');
assert.deepEqual(current(), [title]);
await go('#/story/1/chapter/2');
assert.deepEqual(storyTabs(), [tab]);
assert.deepEqual(current(), [title]);
await go('#/story/2');
assert.equal(storyTabs().length, 1);
assert.deepEqual(current(), ['Second']);
await go('#/settings');
assert.deepEqual(storyTabs(), []);
assert.deepEqual(current(), ['Settings']);
});
test('a story that loads after the user left it gets no tab', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(stopPolling);
const backend = server(t);
let release;
backend.on('GET', 'stories', []);
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories/3', () => new Promise((resolve) => { release = () => resolve(story(3, 'Late')); }));
backend.on('GET', 'stories/3/rules', rules);
await go('#/story/3');
await go('#/');
release();
await settle();
assert.deepEqual(storyTabs(), []);
assert.deepEqual(current(), ['Stories']);
});
test('a long title ends in an ellipsis', () => {
const css = readFileSync(new URL('../../bindery/static/style.css', import.meta.url), 'utf8');
const rule = css.match(/header nav a\.story-tab \{([^}]*)\}/)[1];
for (const declaration of ['max-width: 24ch', 'overflow: hidden', 'text-overflow: ellipsis', 'white-space: nowrap']) {
assert.ok(rule.includes(`${declaration};`), declaration);
}
// Without it, the nav keeps the width of the whole title and runs past the header on a phone.
assert.ok(css.match(/header nav \{([^}]*)\}/)[1].includes('min-width: 0;'));
});
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Element, change, click, find, install, nodes, section, server, settle } from './flow-helpers.mjs';
const ui = install();
const { settingsPage } = await import('../../bindery/static/js/settings.js');
const { storyPage } = await import('../../bindery/static/js/story.js');
const { storiesPage } = await import('../../bindery/static/js/stories.js');
const book = { id: 7, uuid: 'book-7', title: 'Library Title', authors: ['Writer'] };
const details = (story, extra = {}) => ({
story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com',
locked_fields: [], pinned: {}, site_values: {}, auto_update: true, status: 'ok', cover: null,
calibre_id: null, calibre_uuid: null, ingested_at: null, upload_held: false, needs_approval: false, ...story },
chapters: [],
recent_chapters: 0,
uploads_paused: false,
book: null,
...extra,
});
async function renderStory(t, data) {
const backend = server(t);
backend.on('GET', 'stories/1', data);
backend.on('GET', 'stories/1/rules', { rules: [], paragraphs: null, global_paragraphs: false });
backend.on('GET', 'jobs?story_id=1', []);
backend.on('GET', 'jobs', []);
const main = new Element('main');
const cleanup = storyPage(main, { storyId: '1' }, { metadata_fields: {}, mode_labels: {} });
t.after(() => cleanup?.());
await settle();
const summary = nodes(main).find((el) => el.className === 'card story-summary');
return { backend, notice: nodes(summary).find((el) => el.className === 'notice') };
}
test('the upload switches load and save on the settings page', async (t) => {
const backend = server(t);
backend.on('GET', 'settings', { recent_chapters: 0, hold_new_links: true, pause_uploads: false, personal_ini: '' });
backend.on('PUT', 'settings', {});
backend.on('GET', 'rules', { rules: [], paragraphs: false });
backend.on('GET', 'stories', []);
const main = new Element('main');
settingsPage(main);
await settle();
const card = section(main, 'Uploads to CWA');
const [hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
assert.deepEqual([hold.checked, pause.checked], [true, false]);
change(pause, true);
await click(card, 'Save');
assert.deepEqual(backend.sent('PUT', 'settings')[0].body, { hold_new_links: true, pause_uploads: true });
});
test('a held book with a new link names the book and sends it on approval', async (t) => {
const data = details(
{ calibre_id: 7, calibre_uuid: 'book-7', ingested_at: '2026-01-01', upload_held: true, needs_approval: true },
{ book },
);
const { backend, notice } = await renderStory(t, data);
assert.match(notice.textContent, /waits for your approval\. The next upload replaces the ePub of library book 7: Library Title · Writer\./);
backend.on('POST', 'stories/1/approve', { story: data.story, job: { id: 'j1' } });
await click(notice, 'Approve and send');
await settle();
assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid: 'book-7' });
assert.match(ui.toasts.textContent, /Approved\. Bindery rebuilds the book and sends it\./);
});
test('a wrong link can go to CWA as a new book instead', async (t) => {
const data = details(
{ calibre_id: 7, calibre_uuid: 'book-7', ingested_at: '2026-01-01', upload_held: true, needs_approval: true },
{ book },
);
const { backend, notice } = await renderStory(t, data);
backend.on('POST', 'stories/1/link', data.story);
backend.on('POST', 'stories/1/approve', { story: data.story, job: null });
await click(notice, 'Send as a new book instead');
await settle();
assert.deepEqual(
backend.requests.filter((r) => r.method === 'POST').map((r) => [r.path, r.body]),
[['stories/1/link', { uuid: null }], ['stories/1/approve', { uuid: null }]],
);
});
test('a new story asks for approval to go to CWA as a new book', async (t) => {
const { notice } = await renderStory(t, details({ needs_approval: true }));
assert.match(notice.textContent, /The next upload goes to CWA as a new book\./);
assert.ok(find(notice, 'button', 'Approve'));
assert.equal(find(notice, 'button', 'Send as a new book instead'), undefined);
});
test('a paused upload points to the settings', async (t) => {
const { notice } = await renderStory(
t,
details({ calibre_id: 7, calibre_uuid: 'book-7', ingested_at: '2026-01-01', upload_held: true }, { book, uploads_paused: true }),
);
assert.match(notice.textContent, /Uploads to CWA are paused/);
assert.equal(find(notice, 'a', 'Settings').attributes.href, '#/settings');
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
});
test('a story that went to CWA without a link shows no approval until Bindery finds its book', async (t) => {
const { notice } = await renderStory(t, details({ ingested_at: '2026-01-01', needs_approval: true }));
assert.equal(notice, undefined);
});
test('the story list marks a held book', async (t) => {
const backend = server(t);
backend.on('GET', 'stories', [
{ id: 1, url: 'u1', title: 'Held', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
message: '', upload_held: true },
{ id: 2, url: 'u2', title: 'Sent', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
message: '', upload_held: false },
]);
backend.on('GET', 'jobs', []);
const main = new Element('main');
const cleanup = storiesPage(main, {}, { library_available: false });
t.after(() => cleanup?.());
await settle();
const held = nodes(main).filter((el) => el.tagName === 'tr').filter((row) => find(row, 'span', 'held'));
assert.equal(held.length, 1);
assert.match(held[0].textContent, /^Held/);
});