export interface TestResult { success: boolean; logs: string[]; } // ============================================================================= // Reporting export interface Reporter { update(path: string[], result: TestResult): void; finalize(): void; } export interface Stream { writeln(s: string): void; } const SEP = " ❯ "; export class StreamReporter implements Reporter { stream: Stream; verbose: boolean; #failures: ({ path: string[] } & TestResult)[]; counts: { successes: number, failures: number }; constructor(stream: Stream, verbose: boolean = false) { this.stream = stream; this.verbose = verbose; this.#failures = []; this.counts = { successes: 0, failures: 0 }; } update(path: string[], result: TestResult) { if (path.length === 0) throw new RangeError("path is empty"); const pathStr = path.join(SEP); if (result.success) { this.counts.successes++; if (this.verbose) this.stream.writeln(`✅️ ${pathStr}`); } else { this.counts.failures++; this.stream.writeln(`⚠️ ${pathStr}`); this.#failures.push({ path, ...result }); } } finalize() { // Transform [{ path: ["a", "b", "c"] }, { path: ["a", "b", "d"] }] // into { "a ❯ b": ["c", "d"] }. let pathMap = new Map(); for (const f of this.#failures) { if (f.path.length === 0) throw new RangeError("path is empty"); const key = f.path.slice(0, -1).join(SEP); if (!pathMap.has(key)) pathMap.set(key, []); pathMap.get(key)!.push({ name: f.path.at(-1)!, ...f }); } this.stream.writeln(""); this.stream.writeln("FAILURES"); this.stream.writeln("========"); for (const [path, tests] of pathMap) { if (tests.length === 1) { this.#writeOutput(tests[0], path ? `${path}${SEP}` : "", false); } else { this.stream.writeln(path); for (const t of tests) this.#writeOutput(t, " - ", true); } } this.stream.writeln(""); const { successes, failures } = this.counts; this.stream.writeln(`${successes} succeeded, ${failures} failed`); } #writeOutput(test: { name: string } & TestResult, prefix: string, nested: boolean) { let output = ""; if (test.logs.length) { // Individual logs might span multiple lines, so join them together // then split it again. const logStr = test.logs.join("\n"); const lines = logStr.split("\n"); if (lines.length <= 1) { output = `: ${logStr}`; } else { const padding = nested ? " " : " "; output = ":\n" + lines.map((line) => padding + line).join("\n"); } } this.stream.writeln(`${prefix}${test.name}${output}`); } } function assertGetElementById(id: string): HTMLElement { let elem = document.getElementById(id); if (elem == null) throw new ReferenceError(`element with ID '${id}' missing from DOM`); return elem; } export class DOMReporter implements Reporter { update(path: string[], result: TestResult) { if (path.length === 0) throw new RangeError("path is empty"); const counter = assertGetElementById(result.success ? "successes" : "failures"); counter.innerText = (Number(counter.innerText) + 1).toString(); let parent = assertGetElementById("root"); for (const node of path) { let child: HTMLDetailsElement | null = null; let d: Element; outer: for (d of parent.children) { if (!(d instanceof HTMLDetailsElement)) continue; for (const s of d.children) { if (!(s instanceof HTMLElement)) continue; if (!(s.tagName === "SUMMARY" && s.innerText === node)) continue; child = d; break outer; } } if (!child) { child = document.createElement("details"); child.className = "test-node"; child.ariaRoleDescription = "test"; const summary = document.createElement("summary"); summary.appendChild(document.createTextNode(node)); summary.ariaRoleDescription = "test name"; child.appendChild(summary); parent.appendChild(child); } if (!result.success) { child.open = true; child.classList.add("failure"); } parent = child; } const p = document.createElement("p"); p.classList.add("test-desc"); if (result.logs.length) { const pre = document.createElement("pre"); pre.appendChild(document.createTextNode(result.logs.join("\n"))); p.appendChild(pre); } else { p.classList.add("italic"); p.appendChild(document.createTextNode("No output.")); } parent.appendChild(p); } finalize() {} } let r = globalThis.document ? new DOMReporter() : new StreamReporter({ writeln: console.log }); r.update(["alien", "can walk"], { success: false, logs: ["assertion failed"] }); r.update(["alien", "can speak"], { success: false, logs: ["Uncaught ReferenceError: larynx is not defined"] }); r.update(["alien", "sleep"], { success: true, logs: [] }); r.update(["Tetromino", "generate", "tessellates"], { success: false, logs: ["assertion failed: 1 != 2"] }); r.update(["Tetromino", "solve", "works"], { success: true, logs: [] }); r.update(["discombobulate", "english"], { success: false, logs: ["hippopotomonstrosesquipedaliophobia\npneumonoultramicroscopicsilicovolcanoconiosis", "supercalifragilisticexpialidocious"] }); r.update(["discombobulate", "geography"], { success: false, logs: ["Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu"] }); r.update(["recombobulate"], { success: true, logs: [] }); r.finalize();