From d749d46bd1ec52e30c6574a9359ecfc1949da81f Mon Sep 17 00:00:00 2001 From: kat <00-kat@proton.me> Date: Sun, 29 Mar 2026 01:34:50 +1100 Subject: [PATCH] cmd/mbf: jstest: add basic CLI reporter --- .../internal/pkgserver/ui/jstest/jstest.ts | 102 ++++++++++++++++++ cmd/mbf/internal/pkgserver/ui/test_ui.go | 11 ++ cmd/mbf/internal/pkgserver/ui/tsconfig.json | 9 +- .../internal/pkgserver/ui/tsconfig.test.json | 5 + .../internal/pkgserver/ui/tsconfig.ui.json | 6 ++ cmd/mbf/internal/pkgserver/ui/ui.go | 14 ++- cmd/mbf/internal/pkgserver/ui/ui_full.go | 18 +--- 7 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 cmd/mbf/internal/pkgserver/ui/jstest/jstest.ts create mode 100644 cmd/mbf/internal/pkgserver/ui/test_ui.go create mode 100644 cmd/mbf/internal/pkgserver/ui/tsconfig.test.json create mode 100644 cmd/mbf/internal/pkgserver/ui/tsconfig.ui.json diff --git a/cmd/mbf/internal/pkgserver/ui/jstest/jstest.ts b/cmd/mbf/internal/pkgserver/ui/jstest/jstest.ts new file mode 100644 index 00000000..875fa174 --- /dev/null +++ b/cmd/mbf/internal/pkgserver/ui/jstest/jstest.ts @@ -0,0 +1,102 @@ +export interface TestResult { + success: boolean; + logs: string[]; +} + +// ============================================================================= +// Reporting + +export interface Reporter { + update(path: string[], result: TestResult): void; + finalize(): void; +} + +export interface Stream { + writeln(s: string): void; +} + +const SEP = " ❯ "; + +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(SEP); + 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) { + 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}`); + } +} + +const r = new StreamReporter({ writeln: console.log }, true); +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/mbf/internal/pkgserver/ui/test_ui.go b/cmd/mbf/internal/pkgserver/ui/test_ui.go new file mode 100644 index 00000000..aeb14b12 --- /dev/null +++ b/cmd/mbf/internal/pkgserver/ui/test_ui.go @@ -0,0 +1,11 @@ +//go:build frontend && frontend_test + +package ui + +import "embed" + +//go:generate tsc -p tsconfig.test.json +//go:generate cp index.html style.css favicon.ico static +//go:embed static +var _static embed.FS +var static = staticFS(_static) diff --git a/cmd/mbf/internal/pkgserver/ui/tsconfig.json b/cmd/mbf/internal/pkgserver/ui/tsconfig.json index 24df4936..a157aab9 100644 --- a/cmd/mbf/internal/pkgserver/ui/tsconfig.json +++ b/cmd/mbf/internal/pkgserver/ui/tsconfig.json @@ -1,8 +1,11 @@ +// This file defines the common options for all TypeScript here. This shouldn't +// be directly used as the project file in builds; see tsconfig.*.json instead, +// which inherit from this file and essentially define specific build targets. { "compilerOptions": { "target": "ES2024", "strict": true, "alwaysStrict": true, - "outDir": "static" - } -} \ No newline at end of file + "outDir": "static", + }, +} diff --git a/cmd/mbf/internal/pkgserver/ui/tsconfig.test.json b/cmd/mbf/internal/pkgserver/ui/tsconfig.test.json new file mode 100644 index 00000000..0e38d7a4 --- /dev/null +++ b/cmd/mbf/internal/pkgserver/ui/tsconfig.test.json @@ -0,0 +1,5 @@ +// Project file for building pkgserver alongside its tests. test_ui.go uses this +// as its project file. +{ + "extends": "./tsconfig.json", +} diff --git a/cmd/mbf/internal/pkgserver/ui/tsconfig.ui.json b/cmd/mbf/internal/pkgserver/ui/tsconfig.ui.json new file mode 100644 index 00000000..a2c8979f --- /dev/null +++ b/cmd/mbf/internal/pkgserver/ui/tsconfig.ui.json @@ -0,0 +1,6 @@ +// Project file for building just the pkgserver UI, with none of the testing +// stuff attached. ui_full.go uses this as its project file. +{ + "extends": "./tsconfig.json", + "exclude": ["jstest"], +} diff --git a/cmd/mbf/internal/pkgserver/ui/ui.go b/cmd/mbf/internal/pkgserver/ui/ui.go index 3fc0d89c..352a3f8f 100644 --- a/cmd/mbf/internal/pkgserver/ui/ui.go +++ b/cmd/mbf/internal/pkgserver/ui/ui.go @@ -1,7 +1,19 @@ // Package ui holds the static web UI. package ui -import "net/http" +import ( + "io/fs" + "net/http" +) + +// staticFS is an internal helper to wrap around go:embed's filesystem. +func staticFS(static fs.FS) fs.FS { + if f, err := fs.Sub(static, "static"); err != nil { + panic(err) + } else { + return f + } +} // Register arranges for mux to serve the embedded frontend. func Register(mux *http.ServeMux) { diff --git a/cmd/mbf/internal/pkgserver/ui/ui_full.go b/cmd/mbf/internal/pkgserver/ui/ui_full.go index e3fc9134..3b5fb24f 100644 --- a/cmd/mbf/internal/pkgserver/ui/ui_full.go +++ b/cmd/mbf/internal/pkgserver/ui/ui_full.go @@ -1,21 +1,11 @@ -//go:build frontend +//go:build frontend && !frontend_test package ui -import ( - "embed" - "io/fs" -) +import "embed" -//go:generate tsc +//go:generate tsc -p tsconfig.ui.json //go:generate cp index.html style.css favicon.ico static //go:embed static var _static embed.FS - -var static = func() fs.FS { - if f, err := fs.Sub(_static, "static"); err != nil { - panic(err) - } else { - return f - } -}() +var static = staticFS(_static)