From 90277bf6fe230713d14e89d1e7b268a674ca44f5 Mon Sep 17 00:00:00 2001 From: Kat <00-kat@proton.me> Date: Sun, 29 Mar 2026 04:27:14 +1100 Subject: [PATCH] cmd/pkgserver/ui_test: implement skipping from DSL --- cmd/pkgserver/test_ui.go | 12 ++ cmd/pkgserver/ui_test/lib/failure-closed.svg | 13 ++ cmd/pkgserver/ui_test/lib/failure-open.svg | 35 +++++ cmd/pkgserver/ui_test/lib/skip-closed.svg | 21 +++ cmd/pkgserver/ui_test/lib/skip-open.svg | 21 +++ cmd/pkgserver/ui_test/lib/success-closed.svg | 16 +++ cmd/pkgserver/ui_test/lib/success-open.svg | 16 +++ cmd/pkgserver/ui_test/lib/test.ts | 133 ++++++++++++++----- cmd/pkgserver/ui_test/lib/ui.html | 7 +- cmd/pkgserver/ui_test/lib/ui.scss | 36 +++++ cmd/pkgserver/ui_test/sample_tests.ts | 6 +- 11 files changed, 282 insertions(+), 34 deletions(-) create mode 100644 cmd/pkgserver/ui_test/lib/failure-closed.svg create mode 100644 cmd/pkgserver/ui_test/lib/failure-open.svg create mode 100644 cmd/pkgserver/ui_test/lib/skip-closed.svg create mode 100644 cmd/pkgserver/ui_test/lib/skip-open.svg create mode 100644 cmd/pkgserver/ui_test/lib/success-closed.svg create mode 100644 cmd/pkgserver/ui_test/lib/success-open.svg diff --git a/cmd/pkgserver/test_ui.go b/cmd/pkgserver/test_ui.go index 9ac8e44c..a8ee177c 100644 --- a/cmd/pkgserver/test_ui.go +++ b/cmd/pkgserver/test_ui.go @@ -26,6 +26,18 @@ func serveTestWebUIStaticContent(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/testui/style.css": http.ServeFileFS(w, r, test_content, "ui_test/lib/ui.css") + case "/testui/skip-closed.svg": + http.ServeFileFS(w, r, test_content, "ui_test/lib/skip-closed.svg") + case "/testui/skip-open.svg": + http.ServeFileFS(w, r, test_content, "ui_test/lib/skip-open.svg") + case "/testui/success-closed.svg": + http.ServeFileFS(w, r, test_content, "ui_test/lib/success-closed.svg") + case "/testui/success-open.svg": + http.ServeFileFS(w, r, test_content, "ui_test/lib/success-open.svg") + case "/testui/failure-closed.svg": + http.ServeFileFS(w, r, test_content, "ui_test/lib/failure-closed.svg") + case "/testui/failure-open.svg": + http.ServeFileFS(w, r, test_content, "ui_test/lib/failure-open.svg") default: http.NotFound(w, r) } diff --git a/cmd/pkgserver/ui_test/lib/failure-closed.svg b/cmd/pkgserver/ui_test/lib/failure-closed.svg new file mode 100644 index 00000000..c08068b3 --- /dev/null +++ b/cmd/pkgserver/ui_test/lib/failure-closed.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/cmd/pkgserver/ui_test/lib/failure-open.svg b/cmd/pkgserver/ui_test/lib/failure-open.svg new file mode 100644 index 00000000..891e61ae --- /dev/null +++ b/cmd/pkgserver/ui_test/lib/failure-open.svg @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/cmd/pkgserver/ui_test/lib/skip-closed.svg b/cmd/pkgserver/ui_test/lib/skip-closed.svg new file mode 100644 index 00000000..61223376 --- /dev/null +++ b/cmd/pkgserver/ui_test/lib/skip-closed.svg @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/cmd/pkgserver/ui_test/lib/skip-open.svg b/cmd/pkgserver/ui_test/lib/skip-open.svg new file mode 100644 index 00000000..bc84308b --- /dev/null +++ b/cmd/pkgserver/ui_test/lib/skip-open.svg @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/cmd/pkgserver/ui_test/lib/success-closed.svg b/cmd/pkgserver/ui_test/lib/success-closed.svg new file mode 100644 index 00000000..4f22691b --- /dev/null +++ b/cmd/pkgserver/ui_test/lib/success-closed.svg @@ -0,0 +1,16 @@ + + + + + + + diff --git a/cmd/pkgserver/ui_test/lib/success-open.svg b/cmd/pkgserver/ui_test/lib/success-open.svg new file mode 100644 index 00000000..b8d76413 --- /dev/null +++ b/cmd/pkgserver/ui_test/lib/success-open.svg @@ -0,0 +1,16 @@ + + + + + + + diff --git a/cmd/pkgserver/ui_test/lib/test.ts b/cmd/pkgserver/ui_test/lib/test.ts index e88d9b18..7668549a 100644 --- a/cmd/pkgserver/ui_test/lib/test.ts +++ b/cmd/pkgserver/ui_test/lib/test.ts @@ -54,28 +54,34 @@ function checkDuplicates(parent: string, names: { name: string }[]) { } } -class FailNowSentinel {} +export type TestState = "success" | "failure" | "skip"; + +class AbortSentinel {} export class TestController { + #state: TestState; logs: string[]; - #failed: boolean; constructor() { + this.#state = "success"; this.logs = []; - this.#failed = false; + } + + getState(): TestState { + return this.#state; } fail() { - this.#failed = true; + this.#state = "failure"; } failed(): boolean { - return this.#failed; + return this.#state === "failure"; } failNow(): never { this.fail(); - throw new FailNowSentinel(); + throw new AbortSentinel(); } log(message: string) { @@ -91,13 +97,23 @@ export class TestController { this.log(message); this.failNow(); } + + skip(message?: string): never { + if (message != null) this.log(message); + if (this.#state !== "failure") this.#state = "skip"; + throw new AbortSentinel(); + } + + skipped(): boolean { + return this.#state === "skip"; + } } // ============================================================================= // Execution export interface TestResult { - success: boolean; + state: TestState; logs: string[]; } @@ -111,11 +127,11 @@ function runTests(reporter: Reporter, parents: string[], node: TestTree) { try { node.test(controller); } catch (e) { - if (!(e instanceof FailNowSentinel)) { + if (!(e instanceof AbortSentinel)) { controller.error(extractExceptionString(e)); } } - reporter.update(path, { success: !controller.failed(), logs: controller.logs }); + reporter.update(path, { state: controller.getState(), logs: controller.logs }); } function extractExceptionString(e: any): string { @@ -171,18 +187,20 @@ const SEP = " ❯ "; export class StreamReporter implements Reporter { stream: Stream; verbose: boolean; + #successes: ({ path: string[] } & TestResult)[]; #failures: ({ path: string[] } & TestResult)[]; - counts: { successes: number, failures: number }; + #skips: ({ path: string[] } & TestResult)[]; constructor(stream: Stream, verbose: boolean = false) { this.stream = stream; this.verbose = verbose; + this.#successes = []; this.#failures = []; - this.counts = { successes: 0, failures: 0 }; + this.#skips = []; } succeeded(): boolean { - return this.counts.successes > 0 && this.counts.failures === 0; + return this.#successes.length > 0 && this.#failures.length === 0; } register(suites: TestGroup[]) {} @@ -190,32 +208,53 @@ export class StreamReporter implements Reporter { 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++; + switch (result.state) { + case "success": + this.#successes.push({ path, ...result }); if (this.verbose) this.stream.writeln(`✅️ ${pathStr}`); - } else { - this.counts.failures++; - this.stream.writeln(`⚠️ ${pathStr}`); + break; + case "failure": this.#failures.push({ path, ...result }); + this.stream.writeln(`⚠️ ${pathStr}`); + break; + case "skip": + this.#skips.push({ path, ...result }); + this.stream.writeln(`⏭️ ${pathStr}`); + break; } } finalize() { + if (this.verbose) this.#displaySection("successes", this.#successes, true); + this.#displaySection("failures", this.#failures); + this.#displaySection("skips", this.#skips); + this.stream.writeln(""); + this.stream.writeln( + `${this.#successes.length} succeeded, ${this.#failures.length} failed` + + (this.#skips.length ? `, ${this.#skips.length} skipped` : ""), + ); + } + + #displaySection(name: string, data: ({ path: string[] } & TestResult)[], ignoreEmpty: boolean = false) { + if (!data.length) return; + // 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); + for (const t of data) { + if (t.path.length === 0) throw new RangeError("path is empty"); + const key = t.path.slice(0, -1).join(SEP); if (!pathMap.has(key)) pathMap.set(key, []); - pathMap.get(key)!.push({ name: f.path.at(-1)!, ...f }); + pathMap.get(key)!.push({ name: t.path.at(-1)!, ...t }); } this.stream.writeln(""); - this.stream.writeln("FAILURES"); - this.stream.writeln("========"); + this.stream.writeln(name.toUpperCase()); + this.stream.writeln("=".repeat(name.length)); - for (const [path, tests] of pathMap) { + for (let [path, tests] of pathMap) { + if (ignoreEmpty) tests = tests.filter((t) => t.logs.length); + if (tests.length === 0) continue; if (tests.length === 1) { this.#writeOutput(tests[0], path ? `${path}${SEP}` : "", false); } else { @@ -223,10 +262,6 @@ export class StreamReporter implements Reporter { 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) { @@ -258,11 +293,16 @@ export class DOMReporter implements Reporter { update(path: string[], result: TestResult) { if (path.length === 0) throw new RangeError("path is empty"); - const counter = assertGetElementById(result.success ? "successes" : "failures"); + if (result.state === "skip") { + assertGetElementById("skip-counter-text").hidden = false; + } + const counter = assertGetElementById(`${result.state}-counter`); counter.innerText = (Number(counter.innerText) + 1).toString(); + let parent = assertGetElementById("root"); for (const node of path) { let child: HTMLDetailsElement | null = null; + let summary: HTMLElement | null = null; let d: Element; outer: for (d of parent.children) { if (!(d instanceof HTMLDetailsElement)) continue; @@ -270,6 +310,7 @@ export class DOMReporter implements Reporter { if (!(s instanceof HTMLElement)) continue; if (!(s.tagName === "SUMMARY" && s.innerText === node)) continue; child = d; + summary = s; break outer; } } @@ -277,18 +318,40 @@ export class DOMReporter implements Reporter { child = document.createElement("details"); child.className = "test-node"; child.ariaRoleDescription = "test"; - const summary = document.createElement("summary"); + summary = document.createElement("summary"); summary.appendChild(document.createTextNode(node)); summary.ariaRoleDescription = "test name"; child.appendChild(summary); parent.appendChild(child); } - if (!result.success) { + if (!summary) throw new Error("unreachable as assigned above"); + + switch (result.state) { + case "failure": child.open = true; child.classList.add("failure"); + child.classList.remove("skip"); + child.classList.remove("success"); + // The summary marker does not appear in the AOM, so setting its + // alt text is fruitless; label the summary itself instead. + summary.setAttribute("aria-labelledby", "failure-description"); + break; + case "skip": + if (child.classList.contains("failure")) break; + child.classList.add("skip"); + child.classList.remove("success"); + summary.setAttribute("aria-labelledby", "skip-description"); + break; + case "success": + if (child.classList.contains("failure") || child.classList.contains("skip")) break; + child.classList.add("success"); + summary.setAttribute("aria-labelledby", "success-description"); + break; } + parent = child; } + const p = document.createElement("p"); p.classList.add("test-desc"); if (result.logs.length) { @@ -325,7 +388,13 @@ export class GoTestReporter implements Reporter { } update(path: string[], result: TestResult) { - console.log(JSON.stringify({ path, ...result })); + let state: number; + switch (result.state) { + case "success": state = 0; break; + case "failure": state = 1; break; + case "skip": state = 2; break; + } + console.log(JSON.stringify({ path, state, logs: result.logs })); } finalize() { diff --git a/cmd/pkgserver/ui_test/lib/ui.html b/cmd/pkgserver/ui_test/lib/ui.html index 57e8a37c..e10f2307 100644 --- a/cmd/pkgserver/ui_test/lib/ui.html +++ b/cmd/pkgserver/ui_test/lib/ui.html @@ -18,9 +18,14 @@

- 0 succeeded, 0 failed. + 0 succeeded, 0 + failed.

+ + + +
diff --git a/cmd/pkgserver/ui_test/lib/ui.scss b/cmd/pkgserver/ui_test/lib/ui.scss index 16bc37ec..2036ef73 100644 --- a/cmd/pkgserver/ui_test/lib/ui.scss +++ b/cmd/pkgserver/ui_test/lib/ui.scss @@ -1,3 +1,8 @@ +/* + * If updating the theme colors, also update them in success-closed.svg and + * success-open.svg! + */ + :root { --bg: #d3d3d3; --fg: black; @@ -34,8 +39,39 @@ details.test-node { > summary { cursor: pointer; } + &.success > summary::marker { + /* + * WebKit only supports color and font-size properties in ::marker [1], + * and its ::-webkit-details-marker only supports hiding the marker + * entirely [2], contrary to mdn's example [3]; thus, set a color as + * a fallback: while it may not be accessible for colorblind + * individuals, it's better than no indication of a test's state for + * anyone, as that there's no other way to include an indication in the + * marker on WebKit. + * + * [1]: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::marker#browser_compatibility + * [2]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/summary#default_style + * [3]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/summary#changing_the_summarys_icon + */ + color: var(--fg); + content: url("/testui/success-closed.svg") / "success"; + } + &.success[open] > summary::marker { + content: url("/testui/success-open.svg") / "success"; + } &.failure > summary::marker { color: red; + content: url("/testui/failure-closed.svg") / "failure"; + } + &.failure[open] > summary::marker { + content: url("/testui/failure-open.svg") / "failure"; + } + &.skip > summary::marker { + color: blue; + content: url("/testui/skip-closed.svg") / "skip"; + } + &.skip[open] > summary::marker { + content: url("/testui/skip-open.svg") / "skip"; } } diff --git a/cmd/pkgserver/ui_test/sample_tests.ts b/cmd/pkgserver/ui_test/sample_tests.ts index d3a9dfed..8d1b5526 100644 --- a/cmd/pkgserver/ui_test/sample_tests.ts +++ b/cmd/pkgserver/ui_test/sample_tests.ts @@ -37,6 +37,10 @@ suite("cat", [ test("likes headpats", (t) => { t.log("meow"); }), + test("owns skipping rope", (t) => { + t.skip("this cat is stuck in your machine!"); + t.log("never logged"); + }), test("tester tester", (t) => { const r = new TestRegistrar(); r.suite("explod", [ @@ -73,7 +77,7 @@ suite("cat", [ 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.state !== "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"]`); }