// 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 = []; this.style = {}; } 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), }; }