From 2a3f6f5384ff620c9e6629fe26bc874e72f9dcc5 Mon Sep 17 00:00:00 2001 From: Kat <00-kat@proton.me> Date: Sat, 14 Mar 2026 20:52:28 +1100 Subject: [PATCH] cmd/pkgserver: implement JS test DSL and runner --- cmd/pkgserver/ui.go | 2 + cmd/pkgserver/ui/static/run_tests.ts | 4 + cmd/pkgserver/ui/static/test.ts | 129 ++++++++++++++++++++++++-- cmd/pkgserver/ui/static/test_tests.ts | 40 ++++++++ cmd/pkgserver/ui/test.html | 6 +- 5 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 cmd/pkgserver/ui/static/run_tests.ts create mode 100644 cmd/pkgserver/ui/static/test_tests.ts diff --git a/cmd/pkgserver/ui.go b/cmd/pkgserver/ui.go index 5b1c510..0cbb6f3 100644 --- a/cmd/pkgserver/ui.go +++ b/cmd/pkgserver/ui.go @@ -29,6 +29,8 @@ func serveStaticContent(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, content, "ui/static/test.js") case "/static/test.css": http.ServeFileFS(w, r, content, "ui/static/test.css") + case "/static/test_tests.js": + http.ServeFileFS(w, r, content, "ui/static/test_tests.js") default: http.NotFound(w, r) diff --git a/cmd/pkgserver/ui/static/run_tests.ts b/cmd/pkgserver/ui/static/run_tests.ts new file mode 100644 index 0000000..fd3ed28 --- /dev/null +++ b/cmd/pkgserver/ui/static/run_tests.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import "./test_tests.js"; +import { run, StreamReporter } from "./test.js"; +run(new StreamReporter({ writeln: console.log })); diff --git a/cmd/pkgserver/ui/static/test.ts b/cmd/pkgserver/ui/static/test.ts index 05741c8..af4cc3f 100644 --- a/cmd/pkgserver/ui/static/test.ts +++ b/cmd/pkgserver/ui/static/test.ts @@ -1,8 +1,127 @@ +// ============================================================================= +// 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 @@ -121,13 +240,3 @@ export class DOMReporter implements Reporter { finalize() {} } - -let r = typeof document !== "undefined" ? new DOMReporter() : new StreamReporter({ writeln: console.log }); -r.update(["alien", "can walk"], { success: false, output: "assertion failed" }); -r.update(["alien", "can speak"], { success: false, output: "Uncaught ReferenceError: larynx is not defined" }); -r.update(["alien", "sleep"], { success: true, output: "" }); -r.update(["Tetromino", "generate", "tessellates"], { success: false, output: "assertion failed: 1 != 2" }); -r.update(["Tetromino", "solve", "works"], { success: true, output: "" }); -r.update(["discombobulate"], { success: false, output: "hippopotamonstrosesquippedaliophobia" }); -r.update(["recombobulate"], { success: true, output: "" }); -r.finalize(); diff --git a/cmd/pkgserver/ui/static/test_tests.ts b/cmd/pkgserver/ui/static/test_tests.ts new file mode 100644 index 0000000..26333cc --- /dev/null +++ b/cmd/pkgserver/ui/static/test_tests.ts @@ -0,0 +1,40 @@ +import { context, group, suite, test } from "./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"); + }), +]); diff --git a/cmd/pkgserver/ui/test.html b/cmd/pkgserver/ui/test.html index 79d2153..f6536b7 100644 --- a/cmd/pkgserver/ui/test.html +++ b/cmd/pkgserver/ui/test.html @@ -18,7 +18,11 @@
- + +