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