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
+88
View File
@@ -0,0 +1,88 @@
// Tests for the job polling in bindery/static/js/jobs.js. Run with: node --test tests/js/
import assert from 'node:assert/strict';
import { after, beforeEach, test } from 'node:test';
// jobs.js listens for tab visibility changes when it loads.
globalThis.document = { hidden: false, addEventListener() {} };
let jobs = [];
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => jobs });
const { refreshJobs, startPolling, stopPolling, watchJobs } = await import('../../bindery/static/js/jobs.js');
const job = (id, storyId, status) => ({ id, story_id: storyId, status });
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
beforeEach(async () => {
jobs = [];
startPolling();
await tick();
});
after(() => stopPolling());
test('watchJobs renders the jobs of one story', async () => {
const rendered = [];
const stop = watchJobs({ storyId: 1, render: (list) => rendered.push(list.map((j) => j.id)) });
jobs = [job('a', 1, 'queued'), job('b', 2, 'queued')];
await refreshJobs();
assert.deepEqual(rendered.at(-1), ['a']);
stop();
jobs = [job('c', 1, 'queued')];
await refreshJobs();
assert.deepEqual(rendered.at(-1), ['a']);
});
test('watchJobs reports a finished job once', async () => {
let finished = 0;
const stop = watchJobs({ storyId: 1, onFinished: () => finished++ });
jobs = [job('a', 1, 'queued')];
await refreshJobs();
jobs = [job('a', 1, 'running')];
await refreshJobs();
assert.equal(finished, 0);
jobs = [job('a', 1, 'done')];
await refreshJobs();
assert.equal(finished, 1);
await refreshJobs();
assert.equal(finished, 1);
stop();
});
test('watchJobs ignores jobs that finished before it started, and other stories', async () => {
let finished = 0;
jobs = [job('old', 1, 'done')];
await refreshJobs();
const stop = watchJobs({ storyId: 1, onFinished: () => finished++ });
jobs = [job('old', 1, 'done'), job('other', 2, 'running')];
await refreshJobs();
jobs = [job('old', 1, 'done'), job('other', 2, 'error')];
await refreshJobs();
assert.equal(finished, 0);
stop();
});
test('watchJobs without a story watches every job', async () => {
let finished = 0;
const stop = watchJobs({ onFinished: () => finished++ });
jobs = [job('a', 1, 'running'), job('b', null, 'queued')];
await refreshJobs();
jobs = [job('a', 1, 'cancelled'), job('b', null, 'error')];
await refreshJobs();
assert.equal(finished, 1);
stop();
});
test('stopPolling stops the requests', async () => {
stopPolling();
let calls = 0;
const saved = globalThis.fetch;
globalThis.fetch = async () => {
calls++;
return { ok: true, status: 200, json: async () => [] };
};
await refreshJobs();
assert.equal(calls, 0);
globalThis.fetch = saved;
});