// ============================================================================= // DSL type TestTree = TestGroup | Test; type TestGroup = { name: string, children: TestTree[] }; type Test = { name: string, test: (TestController) => void }; export class TestRegistrar { #suites: TestGroup[]; constructor() { this.#suites = []; } suite(name: string, children: TestTree[]) { checkDuplicates(name, children) this.#suites.push({ name, children }); } run(reporter: Reporter) { reporter.register(this.#suites); for (const suite of this.#suites) { for (const c of suite.children) runTests(reporter, [suite.name], c); } reporter.finalize(); } } export let TESTS = new TestRegistrar(); // Register a suite in the global registrar. export function suite(name: string, children: TestTree[]) { TESTS.suite(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); } } export type TestState = "success" | "failure" | "skip"; class AbortSentinel {} export class TestController { #state: TestState; logs: string[]; constructor() { this.#state = "success"; this.logs = []; } getState(): TestState { return this.#state; } fail() { this.#state = "failure"; } failed(): boolean { return this.#state === "failure"; } failNow(): never { this.fail(); throw new AbortSentinel(); } skip(message?: string): never { if (message != null) this.log(message); if (this.#state !== "failure") this.#state = "skip"; throw new AbortSentinel(); } skipped(): boolean { return this.#state === "skip"; } log(message: string) { this.logs.push(message); } error(message: string) { this.log(message); this.fail(); } fatal(message: string): never { this.log(message); this.failNow(); } } // ============================================================================= // Execution export interface TestResult { state: TestState; logs: string[]; } function runTests(reporter: Reporter, parents: string[], node: TestTree) { const path = [...parents, node.name]; if ("children" in node) { for (const c of node.children) runTests(reporter, path, c); return; } let controller = new TestController(); try { node.test(controller); } catch (e) { if (!(e instanceof AbortSentinel)) { controller.error(extractExceptionString(e)); } } reporter.update(path, { state: controller.getState(), logs: controller.logs }); } 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.startsWith(s)) return e.stack; return `${s}\n${e.stack}`; } // ============================================================================= // Reporting export interface Reporter { register(suites: TestGroup[]): void update(path: string[], result: TestResult): void; finalize(): void; } export class NoOpReporter implements Reporter { suites: TestGroup[]; results: ({ path: string[] } & TestResult)[]; finalized: boolean; constructor() { this.suites = []; this.results = []; this.finalized = false; } register(suites: TestGroup[]) { this.suites = suites; } update(path: string[], result: TestResult) { this.results.push({ path, ...result }); } finalize() { this.finalized = true; } } export interface Stream { writeln(s: string): void; } const SEP = " ❯ "; export class StreamReporter implements Reporter { stream: Stream; verbose: boolean; #successes: ({ path: string[] } & TestResult)[]; #failures: ({ path: string[] } & TestResult)[]; #skips: ({ path: string[] } & TestResult)[]; constructor(stream: Stream, verbose: boolean = false) { this.stream = stream; this.verbose = verbose; this.#successes = []; this.#failures = []; this.#skips = []; } register(suites: TestGroup[]) {} update(path: string[], result: TestResult) { if (path.length === 0) throw new RangeError("path is empty"); const pathStr = path.join(SEP); switch (result.state) { case "success": this.#successes.push({ path, ...result }); if (this.verbose) this.stream.writeln(`✅️ ${pathStr}`); break; case "failure": this.#failures.push({ path, ...result }); this.stream.writeln(`⚠️ ${pathStr}`); break; case "skip": this.#skips.push({ path, ...result }); this.stream.writeln(`⏭️ ${pathStr}`); break; } } finalize() { if (this.verbose) this.#displaySection("successes", this.#successes, true); this.#displaySection("failures", this.#failures); this.#displaySection("skips", this.#skips); this.stream.writeln(""); this.stream.writeln( `${this.#successes.length} succeeded, ${this.#failures.length} failed` + (this.#skips.length ? `, ${this.#skips.length} skipped` : "") ); } #displaySection(name: string, data: ({ path: string[] } & TestResult)[], ignoreEmpty: boolean = false) { if (!data.length) return; // Transform [{ path: ["a", "b", "c"] }, { path: ["a", "b", "d"] }] // into { "a ❯ b": ["c", "d"] }. let pathMap = new Map(); for (const t of data) { const key = t.path.slice(0, -1).join(SEP); if (!pathMap.has(key)) pathMap.set(key, []); pathMap.get(key).push({ name: t.path.at(-1), ...t }); } this.stream.writeln(""); this.stream.writeln(name.toUpperCase()); this.stream.writeln("=".repeat(name.length)); for (let [path, tests] of pathMap) { if (ignoreEmpty) tests = tests.filter((t) => t.logs.length); if (tests.length === 0) continue; 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); } } } #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}`); } } export class DOMReporter implements Reporter { register(suites: TestGroup[]) {} update(path: string[], result: TestResult) { if (path.length === 0) throw new RangeError("path is empty"); if (result.state === "skip") { document.getElementById("skip-counter-text").classList.remove("hidden"); } const counter = document.getElementById(`${result.state}-counter`); 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.state === "failure") child.open = true; if (!child.classList.contains("failure") && (result.state !== "success" || !child.classList.contains("skip"))) { child.classList.add(result.state); } 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() {} } interface GoNode { name: string; subtests?: GoNode[]; } // Used to display results via `go test`, via some glue code from the Go side. export class GoTestReporter implements Reporter { // Convert a test tree into the one expected by the Go code. static serialize(node: TestTree): GoNode { return { name: node.name, subtests: "children" in node ? node.children.map(GoTestReporter.serialize) : null, }; } register(suites: TestGroup[]) { console.log(JSON.stringify(suites.map(GoTestReporter.serialize))); } update(path: string[], result: TestResult) { console.log(JSON.stringify({ path: path, ...result })); } finalize() { console.log("null"); } }