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:
@@ -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');
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 <three></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 & Magic <1>';
|
||||
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 & B' }] } }]);
|
||||
assert.ok(find(el, 'a', 'A & B'));
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 <b><br/>line</p><p>Two & 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 & Magic <1>'), 'Folly & Magic <1>');
|
||||
assert.equal(chapterTitle('Literal &lt;'), 'Literal <');
|
||||
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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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;'));
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user