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
+100
View File
@@ -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),
};
}