// ============================================================================= // DSL type TestTree = TestGroup | Test; type TestGroup = { name: string; children: TestTree[] }; type Test = { name: string; test: (t: TestController) => void }; // A registrar provides a central location to register test suites. export class TestRegistrar { // Note that, while this is equivalent to a new tree node sans a name, the // lack of a name provides the illusion of multiple “top-level” suites, // while still allowing reporters to pick their favorite name—say, “JS // tests”—were they to need to label all suites together. #suites: TestGroup[]; constructor() { this.#suites = []; } suite(name: string, children: TestTree[]) { checkDuplicates(name, children); this.#suites.push({ name, children }); } run(reporter: Reporter) { for (const suite of this.#suites) { for (const c of suite.children) runTests(reporter, [suite.name], c); } reporter.finalize(); } } export let GLOBAL_REGISTRAR = new TestRegistrar(); // Register a suite in the global registrar. export function suite(name: string, children: TestTree[]) { GLOBAL_REGISTRAR.suite(name, children); } export function group(name: string, children: TestTree[]): TestTree { checkDuplicates(name, children); return { name, children }; } export const context = group; export const describe = group; export function test(name: string, test: (t: TestController) => void): TestTree { return { name, test }; } // While this function could certainly refine the type to a map instead of // simply checking for duplicates and discarding that knowledge, these test // trees are primarily for flooding—that is, iteration—for which an array is // better suited. 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 {} export class TestController { logs: string[]; #failed: boolean; constructor() { this.logs = []; this.#failed = false; } fail() { this.#failed = true; } failed(): boolean { return this.#failed; } failNow(): never { this.fail(); throw new FailNowSentinel(); } 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 { success: boolean; 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 FailNowSentinel)) { controller.error(extractExceptionString(e)); } } reporter.update(path, { success: !controller.failed(), 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 && e.stack)) return s; // v8 (Chromium, NodeJS) includes 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 { // While we could simply call a function with a tree representing all // results, which would indeed greatly simplify implementation of reporters, // simply registering a path and allowing the reporter to—either implicitly // or explicitly—construct a tree themselves allows for results to be // *incrementally reported*, instead of a great deal of silence until all // tests finish. update(path: string[], result: TestResult): void; // With just update(), the reporter never knows when all tests have // completed. The simplest possible use for this is to notify the user, but // its intent is actually more tailored to the StreamReporter scenario: // while destructively updated report rendering (as with a tree of DOM nodes // which are mutated) always displays the results in a structured manner // matching that of the tests, “rerendering” or otherwise destructively // updating the rendered output might be infeasible in some paradigms, such // as command-line applications—all existing implementations of such // rendering both mess up the scrollback position and necessarily crop out // some of the data at the bottom (since the top of the tree is forced to be // at the top of the screen, as one cannot unscroll portably). Explicitly // signaling to the reporter that no more results will be received permits // it to simply display live test progress linearly, while building up // a tree and displaying it once it's known the tree is complete. finalize(): void; } // A reporter that diligently reports absolutely nothing. This is essentially // a way to “undo” the incremental reporting update() provides, getting back the // underlying result tree; this makes it extremely convenient in some cases like // testing ourself. export class NoOpReporter implements Reporter { results: ({ path: string[] } & TestResult)[]; finalized: boolean; constructor() { this.results = []; this.finalized = false; } update(path: string[], result: TestResult) { this.results.push({ path, ...result }); } finalize() { this.finalized = true; } } export interface Stream { writeln(s: string): void; } const SEP = " ❯ "; // A simple reporter that outputs to some stream; suitable for CLIs. 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 }; } succeeded(): boolean { return this.counts.successes > 0 && this.counts.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++; // NOTE: emojis are used instead of colored Unicode symbols as // coloring isn't possible through all streams and detecting if // colors should be used is very difficult¹. Furthermore, ensuring // reasonable contrast is retained on every possible theme is // difficult, with reverse video often being the only way (which // also has questionable support across terminal emulators), and the // Unicode characters might be too small to be immediately // noticeable. Emojis have an upper hand in that they're more common // than obscure Unicode characters—which also means you're more // likely to have an emoji font but not a font with those // symbols—and that they're double-width. // // ¹This necessitates checking if the stream is a TTY, checking if // $TERM is `dumb` when connected to a TTY, checking // https://no-color.org, https://bixense.com/clicolors, and // https://force-color.org, checking if setting the // ENABLE_VIRTUAL_TERMINAL_PROCESSING bit on the TTY works when on // on Windows, and doing something similar for Cygwin. 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"] }. NOTE: intermediate nodes are collapsed as // excessive nesting is difficult to convey clearly in a text-only // environment. 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; } // A reporter that directly translates a tree of results into a tree of // collapsible elements in the DOM. 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) { // Minimize successes by default, by only expanding failures. 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() {} }