diff --git a/cmd/pkgserver/test_ui.go b/cmd/pkgserver/test_ui.go index 0b974b4b..9ac8e44c 100644 --- a/cmd/pkgserver/test_ui.go +++ b/cmd/pkgserver/test_ui.go @@ -40,6 +40,24 @@ func serveTestLibrary(w http.ResponseWriter, r *http.Request) { } } +func serveTests(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/test/" { + http.Redirect(w, r, "/test.html", http.StatusMovedPermanently) + return + } + testPath := strings.TrimPrefix(r.URL.Path, "/test/") + + if path.Ext(testPath) != ".js" { + http.Error(w, "403 forbidden", http.StatusForbidden) + } + + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + + http.ServeFileFS(w, r, test_content, "ui_test/"+testPath) +} + func redirectUI(w http.ResponseWriter, r *http.Request) { // The base path should not redirect to the root. if r.URL.Path == "/ui/" { @@ -62,5 +80,6 @@ func testUiRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /test.html", serveTestWebUI) mux.HandleFunc("GET /testui/", serveTestWebUIStaticContent) mux.HandleFunc("GET /test/lib/", serveTestLibrary) + mux.HandleFunc("GET /test/", serveTests) mux.HandleFunc("GET /ui/", redirectUI) } diff --git a/cmd/pkgserver/ui_test/all_tests.ts b/cmd/pkgserver/ui_test/all_tests.ts new file mode 100644 index 00000000..69805f44 --- /dev/null +++ b/cmd/pkgserver/ui_test/all_tests.ts @@ -0,0 +1,2 @@ +// Import all test files to register their test suites. +import "./sample_tests.js"; diff --git a/cmd/pkgserver/ui_test/lib/cli.ts b/cmd/pkgserver/ui_test/lib/cli.ts new file mode 100644 index 00000000..ac729b94 --- /dev/null +++ b/cmd/pkgserver/ui_test/lib/cli.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +// Many editors have terminal emulators built in, so running tests with NodeJS +// provides faster iteration, especially for those acclimated to test-driven +// development. + +import "../all_tests.js"; +import { StreamReporter, GLOBAL_REGISTRAR } from "./test.js"; + +// TypeScript doesn't like process and Deno as their type definitions aren't +// installed, but doesn't seem to complain if they're accessed through +// globalThis. +const process: any = (globalThis as any).process; +const Deno: any = (globalThis as any).Deno; + +function getArgs(): string[] { + if (process) { + const [runtime, program, ...args] = process.argv; + return args; + } + if (Deno) return Deno.args; + return []; +} + +function exit(code?: number): never { + if (Deno) Deno.exit(code); + if (process) process.exit(code); + throw `exited with code ${code ?? 0}`; +} + +const args = getArgs(); +let verbose = false; +if (args.length > 1) { + console.error("Too many arguments"); + exit(1); +} +if (args.length === 1) { + if (args[0] === "-v" || args[0] === "--verbose" || args[0] === "-verbose") { + verbose = true; + } else if (args[0] !== "--") { + console.error(`Unknown argument '${args[0]}'`); + exit(1); + } +} + +let reporter = new StreamReporter({ writeln: console.log }, verbose); +GLOBAL_REGISTRAR.run(reporter); +exit(reporter.succeeded() ? 0 : 1); diff --git a/cmd/pkgserver/ui_test/lib/test.ts b/cmd/pkgserver/ui_test/lib/test.ts index 5bae6b06..7c441f00 100644 --- a/cmd/pkgserver/ui_test/lib/test.ts +++ b/cmd/pkgserver/ui_test/lib/test.ts @@ -1,8 +1,133 @@ +// ============================================================================= +// DSL + +type TestTree = TestGroup | Test; +type TestGroup = { name: string; children: TestTree[] }; +type Test = { name: string; test: (t: 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) { + 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 }; +} + +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 @@ -11,6 +136,24 @@ export interface Reporter { finalize(): void; } +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; } @@ -30,6 +173,10 @@ export class StreamReporter implements Reporter { 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); @@ -145,14 +292,3 @@ export class DOMReporter implements Reporter { 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(); diff --git a/cmd/pkgserver/ui_test/lib/ui.html b/cmd/pkgserver/ui_test/lib/ui.html index 5ce0edea..57e8a37c 100644 --- a/cmd/pkgserver/ui_test/lib/ui.html +++ b/cmd/pkgserver/ui_test/lib/ui.html @@ -24,7 +24,11 @@
- + diff --git a/cmd/pkgserver/ui_test/sample_tests.ts b/cmd/pkgserver/ui_test/sample_tests.ts new file mode 100644 index 00000000..61ca3820 --- /dev/null +++ b/cmd/pkgserver/ui_test/sample_tests.ts @@ -0,0 +1,64 @@ +import { NoOpReporter, TestRegistrar, context, group, suite, test } from "./lib/test.js"; + +suite("dog", [ + group("tail", [ + test("wags when happy", (t) => { + if (0 / 0 !== Infinity / Infinity) { + t.fatal("undefined must not be defined"); + } + }), + test("idle when down", (t) => { + t.log("test test"); + t.error("dog whining noises go here"); + }), + ]), + test("likes headpats", (t) => { + if (2 !== 2) { + t.error("IEEE 754 violated: 2 is NaN"); + } + }), + context("near cat", [ + test("is ecstatic", (t) => { + if (("b" + "a" + + "a" + "a").toLowerCase() === "banana") { + t.error("🍌🍌🍌"); + t.error("🍌🍌🍌"); + t.error("🍌🍌🍌"); + t.failNow(); + } + }), + test("playfully bites cats' tails", (t) => { + t.log("arf!"); + throw new Error("nom"); + }), + ]), +]); + +suite("cat", [ + test("likes headpats", (t) => { + t.log("meow"); + }), + test("tester tester", (t) => { + const r = new TestRegistrar(); + r.suite("explod", [ + test("with yarn", (t) => { + t.log("YAY"); + }), + ]); + const reporter = new NoOpReporter(); + r.run(reporter); + if (!reporter.finalized) t.error(`expected reporter to have been finalized`); + if (reporter.results.length !== 1) { + t.fatal(`incorrect result count got=${reporter.results.length} want=1`); + } + const result = reporter.results[0]; + if (!(result.path.length === 2 && + result.path[0] === "explod" && + result.path[1] === "with yarn")) { + t.error(`incorrect result path got=${result.path} want=["explod", "with yarn"]`); + } + if (!result.success) t.error(`expected test to succeed`); + if (!(result.logs.length === 1 && result.logs[0] === "YAY")) { + t.error(`incorrect result logs got=${result.logs} want=["YAY"]`); + } + }), +]);