// A minimal DOM, enough to drive the UI headlessly. // // Not a browser and not a substitute for clicking the real thing — it exists // so that the wiring (imports, handlers, the render/choose/log path) is // exercised by the test suite rather than discovered by a player. Anything // involving real files or real downloads is out of its scope and has to be // checked by hand. class FakeNode { constructor() { this.childNodes = []; this.parentNode = null; } get textContent() { return this.childNodes.map((child) => child.textContent).join(''); } } class FakeText extends FakeNode { constructor(text) { super(); this.nodeValue = String(text); } get textContent() { return this.nodeValue; } } class FakeElement extends FakeNode { constructor(tagName) { super(); this.tagName = tagName.toUpperCase(); this.className = ''; this.id = ''; this.attributes = {}; this.listeners = {}; this.dataset = {}; this.style = ''; // Predefined so `key in el` behaves the way it does in a real element: // these are set as properties, everything else falls through to attributes. this.disabled = false; this.value = ''; this.rows = 0; this.type = ''; this.accept = ''; this.href = ''; this.download = ''; this.files = null; } append(...nodes) { for (const node of nodes) { node.parentNode = this; this.childNodes.push(node); } } replaceChildren(...nodes) { this.childNodes = []; this.append(...nodes); } remove() { const siblings = this.parentNode?.childNodes; if (siblings) siblings.splice(siblings.indexOf(this), 1); } setAttribute(name, value) { this.attributes[name] = String(value); } getAttribute(name) { return this.attributes[name] ?? null; } addEventListener(type, handler) { (this.listeners[type] ??= []).push(handler); } dispatch(type, event = {}) { for (const handler of this.listeners[type] ?? []) handler({ target: this, ...event }); } click() { this.dispatch('click'); } } function walk(node, out = []) { out.push(node); for (const child of node.childNodes) if (child instanceof FakeElement) walk(child, out); return out; } /** Every element under `root` whose class list contains `className`. */ export function byClass(root, className) { return walk(root).filter((el) => el.className.split(/\s+/).includes(className)); } /** The first such element, or undefined. */ export function firstByClass(root, className) { return byClass(root, className)[0]; } /** Every element of a tag. */ export function byTag(root, tagName) { return walk(root).filter((el) => el.tagName === tagName.toUpperCase()); } class FakeStorage { constructor() { this.map = new Map(); } getItem(key) { return this.map.has(key) ? this.map.get(key) : null; } setItem(key, value) { this.map.set(key, String(value)); } removeItem(key) { this.map.delete(key); } clear() { this.map.clear(); } } /** * Install the fake DOM as globals and return a handle for the test. Call the * returned `restore` afterwards so suites do not leak into each other. */ export function installFakeDom() { const root = new FakeElement('div'); root.id = 'app'; const document = { createElement: (tag) => new FakeElement(tag), createTextNode: (text) => new FakeText(text), getElementById: (id) => (id === 'app' ? root : null), body: new FakeElement('body'), }; const listeners = {}; const window = { addEventListener: (type, handler) => { (listeners[type] ??= []).push(handler); }, }; const previous = {}; const globals = { document, window, Node: FakeNode, localStorage: new FakeStorage(), requestAnimationFrame: (fn) => setTimeout(fn, 0), confirm: () => true, alert: () => {}, }; for (const [key, value] of Object.entries(globals)) { previous[key] = globalThis[key]; globalThis[key] = value; } return { document, window, root, storage: globals.localStorage, /** Fire a window event, e.g. beforeunload. */ fireWindow: (type) => { for (const handler of listeners[type] ?? []) handler({}); }, restore() { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete globalThis[key]; else globalThis[key] = value; } }, }; } export { FakeElement, FakeText, FakeNode };