diff --git a/.gitignore b/.gitignore index 5469a0f2..b541d009 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ go.work.sum /cmd/pkgserver/ui/static/*.js /cmd/pkgserver/ui/static/*.css* /cmd/pkgserver/ui/static/*.css.map +/cmd/pkgserver/ui_test/*.js +/cmd/pkgserver/ui_test/lib/*.js +/cmd/pkgserver/ui_test/lib/*.css* +/cmd/pkgserver/ui_test/lib/*.css.map /internal/pkg/testdata/testtool /internal/rosa/hakurei_current.tar.gz diff --git a/cmd/pkgserver/test_ui.go b/cmd/pkgserver/test_ui.go new file mode 100644 index 00000000..74d6f237 --- /dev/null +++ b/cmd/pkgserver/test_ui.go @@ -0,0 +1,5 @@ +//go:build frontend && frontend_test + +package main + +//go:generate tsc -p ui_test diff --git a/cmd/pkgserver/ui_test/lib/test.ts b/cmd/pkgserver/ui_test/lib/test.ts new file mode 100644 index 00000000..875fa174 --- /dev/null +++ b/cmd/pkgserver/ui_test/lib/test.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/pkgserver/ui_test/tsconfig.json b/cmd/pkgserver/ui_test/tsconfig.json new file mode 100644 index 00000000..02161c32 --- /dev/null +++ b/cmd/pkgserver/ui_test/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2024" + } +}