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..79c47d2a --- /dev/null +++ b/cmd/mbf/internal/pkgserver/ui/jstest/jstest.ts @@ -0,0 +1,136 @@ +export interface TestResult { + success: boolean; + logs: string[]; +} + +// ============================================================================= +// 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; +} + +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 }; + } + + 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, which would make + // this terminal-specific, and even in terminals and emulators + // thereof, it's very tedious to correctly detect whether one should + // use colors (https://no-color.org, https://bixense.com/clicolors, + // https://force-color.org), ensure reasonable contrast is retained + // on every possible theme (using reverse video is often the only + // way), and 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. + 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}`); + } +} + +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)