Add the web interface

Plain HTML, CSS and JavaScript modules, with no build step. Hash routes
and relative URLs make it work under any path. The job list is polled:
every 2 seconds while a job runs, and less often otherwise.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 09d6b76a17
commit 2f4271314b
30 changed files with 4442 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
// Tests for the helpers in bindery/static/js/lib.js. Run with: node --test "tests/js/*.test.mjs"
import assert from 'node:assert/strict';
import { beforeEach, test } from 'node:test';
// The comments field is HTML. A browser turns it into text with DOMParser.
globalThis.DOMParser = class {
parseFromString(html) {
return { body: { textContent: html.replace(/<[^>]*>/g, '') } };
}
};
// h() builds elements. These fakes keep only what the tests read.
globalThis.document = {
createElement: (tagName) => ({
tagName,
attributes: {},
children: [],
append(...children) { this.children.push(...children); },
setAttribute(name, value) { this.attributes[name] = String(value); },
}),
};
const { api, ApiError, chapterTitle, externalLink, fmtDate, formatField, plural, setUnauthorizedHandler, textToHtml } = await import(
'../../bindery/static/js/lib.js'
);
let requests = [];
let reply = { status: 200, body: {} };
beforeEach(() => {
requests = [];
reply = { status: 200, body: {} };
globalThis.fetch = async (url, options) => {
requests.push({ url, ...options });
return {
ok: reply.status < 400,
status: reply.status,
statusText: 'Status',
json: async () => {
if (reply.body === undefined) throw new SyntaxError('not JSON');
return reply.body;
},
};
};
setUnauthorizedHandler(() => {});
});
test('formatField shows series with and without a number', () => {
assert.equal(formatField('series', ['Saga', 2]), 'Saga [2]');
assert.equal(formatField('series', ['Saga', null]), 'Saga');
assert.equal(formatField('series', ['', 2]), '');
assert.equal(formatField('series', 'Saga'), 'Saga');
});
test('formatField joins lists and handles empty values', () => {
assert.equal(formatField('tags', ['Fantasy', 'LitRPG']), 'Fantasy, LitRPG');
assert.equal(formatField('title', 'A Story'), 'A Story');
assert.equal(formatField('title', null), '');
assert.equal(formatField('title', undefined), '');
assert.equal(formatField('title', ''), '');
});
test('formatField turns the description into short text', () => {
assert.equal(formatField('comments', '<p>A <b>bold</b> start.</p>'), 'A bold start.');
const long = formatField('comments', `<p>${'x'.repeat(300)}</p>`);
assert.equal(long.length, 201);
assert.ok(long.endsWith('…'));
});
test('textToHtml makes paragraphs and escapes the text', () => {
assert.equal(textToHtml('One <b>\nline\n\n Two & more \n\n\n'), '<p>One &lt;b&gt;<br/>line</p><p>Two &amp; more</p>');
assert.equal(textToHtml(' '), '');
});
test('fmtDate', () => {
assert.equal(fmtDate(null), '');
assert.equal(fmtDate(''), '');
assert.equal(fmtDate('not a date'), 'not a date');
const text = fmtDate('2026-01-02T03:04:05');
assert.ok(text.length > 0 && text !== '2026-01-02T03:04:05');
});
test('chapterTitle turns the entities that FanFicFare keeps into text', () => {
assert.equal(chapterTitle('Folly &amp; Magic &lt;1&gt;'), 'Folly & Magic <1>');
assert.equal(chapterTitle('Literal &amp;lt;'), 'Literal &lt;');
assert.equal(chapterTitle(undefined), '');
});
test('plural', () => {
assert.equal(plural(1, 'chapter'), '1 chapter');
assert.equal(plural(0, 'chapter'), '0 chapters');
assert.equal(plural(3, 'chapter'), '3 chapters');
});
test('api reads with a relative GET and no CSRF header', async () => {
reply.body = [{ id: 1 }];
assert.deepEqual(await api('stories'), [{ id: 1 }]);
assert.equal(requests[0].url, 'api/stories');
assert.equal(requests[0].method, 'GET');
assert.deepEqual(requests[0].headers, {});
assert.equal(requests[0].body, undefined);
});
test('api sends changes as JSON with the CSRF header', async () => {
await api('stories', 'POST', { urls: 'x' });
assert.equal(requests[0].method, 'POST');
assert.equal(requests[0].headers['X-Requested-With'], 'bindery');
assert.equal(requests[0].headers['Content-Type'], 'application/json');
assert.equal(requests[0].body, '{"urls":"x"}');
await api('stories/1', 'DELETE');
assert.equal(requests[1].headers['X-Requested-With'], 'bindery');
assert.equal(requests[1].headers['Content-Type'], undefined);
});
test('api sends a file upload as it is, so the browser sets the multipart type', async () => {
const form = new FormData();
form.append('file', new Blob(['epub']), 'story.epub');
await api('stories/import-epub', 'POST', form);
assert.equal(requests[0].body, form);
assert.equal(requests[0].headers['X-Requested-With'], 'bindery');
assert.equal(requests[0].headers['Content-Type'], undefined);
});
test('api turns an error answer into an ApiError with its data', async () => {
reply = { status: 400, body: { error: 'Bad pattern', index: 2 } };
await assert.rejects(api('rules', 'PUT', { rules: [] }), (e) => {
assert.ok(e instanceof ApiError);
assert.equal(e.message, 'Bad pattern');
assert.equal(e.status, 400);
assert.equal(e.data.index, 2);
return true;
});
});
test('api reports an answer that is not JSON by its status', async () => {
reply = { status: 502, body: undefined };
await assert.rejects(api('stories'), { message: '502 Status', status: 502 });
});
test('api calls the unauthorized handler, except for login requests', async () => {
let calls = 0;
setUnauthorizedHandler(() => calls++);
reply = { status: 401, body: { error: 'Unauthorized' } };
await assert.rejects(api('stories'));
assert.equal(calls, 1);
await assert.rejects(api('auth/login', 'POST', {}));
assert.equal(calls, 1);
});
test('externalLink links http and https URLs, and leaves other URLs as text', () => {
const a = externalLink('https://example.com/s/1', 'example.com');
assert.equal(a.tagName, 'a');
assert.deepEqual(a.attributes, { href: 'https://example.com/s/1', target: '_blank', rel: 'noreferrer noopener' });
assert.deepEqual(a.children, ['example.com']);
assert.deepEqual(externalLink('HTTP://example.com').children, ['HTTP://example.com']);
assert.equal(externalLink('javascript:alert(1)'), 'javascript:alert(1)');
assert.equal(externalLink(' javascript:alert(1)', 'site'), 'site');
assert.equal(externalLink('data:text/html,x', 'site'), 'site');
assert.equal(externalLink(null, 'site'), 'site');
});