// ============================================================================= // DSL type TestTree = { name: string } & (TestGroup | Test); type TestGroup = { children: TestTree[] }; type Test = { test: (TestController) => void }; let TESTS: ({ name: string } & TestGroup)[] = []; export function suite(name: string, children: TestTree[]) { checkDuplicates(name, children) TESTS.push({ name, children }); } export function context(name: string, children: TestTree[]): TestTree { checkDuplicates(name, children) return { name, children }; } export const group = context; export function test(name: string, test: (TestController) => void): TestTree { return { name, test }; } function checkDuplicates(parent: string, names: { name: string }[]) { let seen = new Set(); for (const { name } of names) { if (seen.has(name)) { throw new RangeError(`duplicate name '${name}' in '${parent}'`); } seen.add(name); } } class FailNowSentinel {} class TestController { #logBuf: string[]; #failed: boolean; constructor() { this.#logBuf = []; this.#failed = false; } fail() { this.#failed = true; } failed(): boolean { return this.#failed; } failNow(): never { this.fail(); throw new FailNowSentinel(); } log(message: string) { this.#logBuf.push(message); } error(message: string) { this.log(message); this.fail(); } fatal(message: string): never { this.log(message); this.failNow(); } getLog(): string { return this.#logBuf.join("\n"); } } // ============================================================================= // Execution export interface TestResult { success: boolean; output: string; } function runTests(reporter: Reporter, parents: string[], tree: TestTree) { const path = [...parents, tree.name]; if ("children" in tree) { for (const c of tree.children) runTests(reporter, path, c); return; } let controller = new TestController(); let excStr: string; try { tree.test(controller); } catch (e) { if (!(e instanceof FailNowSentinel)) { controller.fail(); excStr = extractExceptionString(e); } } const log = controller.getLog(); const output = (log && excStr) ? `${log}\n${excStr}` : `${log}${excStr ?? ''}`; reporter.update(path, { success: !controller.failed(), output }); } export function run(reporter: Reporter) { for (const suite of TESTS) { for (const c of suite.children) runTests(reporter, [suite.name], c); } reporter.finalize(); } function extractExceptionString(e: any): string { // String() instead of .toString() as null and undefined don't have // properties. const s = String(e); if (!(e instanceof Error && "stack" in e)) return s; // v8 (Chromium, NodeJS) include the error message, while // Firefox and WebKit do not. if (e.stack.includes(s)) return e.stack; return `${s}\n${e.stack}`; } // ============================================================================= // Reporting export interface Reporter { update(path: string[], result: TestResult): void; finalize(): void; } export interface Stream { writeln(s: string): void; } 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(" ❯ "); 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) { const key = f.path.slice(0, -1).join(" ❯ "); 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} ❯ ` : "", 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.output) { const lines = test.output.split("\n"); if (lines.length <= 1) { output = `: ${test.output}`; } else { const padding = nested ? " " : " "; output = ":\n" + lines.map((line) => padding + line).join("\n"); } } this.stream.writeln(`${prefix}${test.name}${output}`); } } export class DOMReporter implements Reporter { update(path: string[], result: TestResult) { if (path.length === 0) throw new RangeError("path is empty"); const counter = document.getElementById(result.success ? "successes" : "failures"); counter.innerText = (Number(counter.innerText) + 1).toString(); let parent = document.getElementById("root"); for (const node of path) { let child = null; outer: for (const d of parent.children) { 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 === null) { child = document.createElement("details"); child.className = "test-node"; const summary = document.createElement("summary"); summary.appendChild(document.createTextNode(node)); 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.output) { const pre = document.createElement("pre"); pre.appendChild(document.createTextNode(result.output)); p.appendChild(pre); } else { p.classList.add("italic"); p.appendChild(document.createTextNode("No output.")); } parent.appendChild(p); } finalize() {} }