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,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');
|
||||
});
|
||||
Reference in New Issue
Block a user