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

445 lines
22 KiB
JavaScript

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'));
});