diff --git a/cmd/pkgserver/ui.go b/cmd/pkgserver/ui.go index 85e9dbb7..4a09edf0 100644 --- a/cmd/pkgserver/ui.go +++ b/cmd/pkgserver/ui.go @@ -33,6 +33,18 @@ func serveStaticContent(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, content, "ui/static/all_tests.js") case "/static/test_tests.js": http.ServeFileFS(w, r, content, "ui/static/test_tests.js") + case "/static/success-closed.svg": + http.ServeFileFS(w, r, content, "ui/static/success-closed.svg") + case "/static/success-open.svg": + http.ServeFileFS(w, r, content, "ui/static/success-open.svg") + case "/static/failure-closed.svg": + http.ServeFileFS(w, r, content, "ui/static/failure-closed.svg") + case "/static/failure-open.svg": + http.ServeFileFS(w, r, content, "ui/static/failure-open.svg") + case "/static/skip-closed.svg": + http.ServeFileFS(w, r, content, "ui/static/skip-closed.svg") + case "/static/skip-open.svg": + http.ServeFileFS(w, r, content, "ui/static/skip-open.svg") default: http.NotFound(w, r) diff --git a/cmd/pkgserver/ui/static/failure-closed.svg b/cmd/pkgserver/ui/static/failure-closed.svg new file mode 100644 index 00000000..249c7ea5 --- /dev/null +++ b/cmd/pkgserver/ui/static/failure-closed.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/cmd/pkgserver/ui/static/failure-open.svg b/cmd/pkgserver/ui/static/failure-open.svg new file mode 100644 index 00000000..8308ffce --- /dev/null +++ b/cmd/pkgserver/ui/static/failure-open.svg @@ -0,0 +1,33 @@ + + + + + + + diff --git a/cmd/pkgserver/ui/static/skip-closed.svg b/cmd/pkgserver/ui/static/skip-closed.svg new file mode 100644 index 00000000..3a63834f --- /dev/null +++ b/cmd/pkgserver/ui/static/skip-closed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cmd/pkgserver/ui/static/skip-open.svg b/cmd/pkgserver/ui/static/skip-open.svg new file mode 100644 index 00000000..6eb7ce1f --- /dev/null +++ b/cmd/pkgserver/ui/static/skip-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cmd/pkgserver/ui/static/success-closed.svg b/cmd/pkgserver/ui/static/success-closed.svg new file mode 100644 index 00000000..cc0fddb4 --- /dev/null +++ b/cmd/pkgserver/ui/static/success-closed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cmd/pkgserver/ui/static/success-open.svg b/cmd/pkgserver/ui/static/success-open.svg new file mode 100644 index 00000000..27c7497f --- /dev/null +++ b/cmd/pkgserver/ui/static/success-open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cmd/pkgserver/ui/static/test.scss b/cmd/pkgserver/ui/static/test.scss index 70cf9a87..1b0e4419 100644 --- a/cmd/pkgserver/ui/static/test.scss +++ b/cmd/pkgserver/ui/static/test.scss @@ -13,8 +13,33 @@ details.test-node { > summary { cursor: pointer; } + &.success > summary::marker { + /* + * WebKit only supports color and font-size properties in ::marker, and + * its ::-webkit-details-marker doesn't seem to work whatsoever; thus, + * set a color as a fallback: while it may be confusing for colorblind + * individuals, it's better than no indication of a test's state. + * See: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::marker#browser_compatibility + */ + color: black; + content: url("/static/success-closed.svg"); + } + &.success[open] > summary::marker { + content: url("/static/success-open.svg"); + } &.failure > summary::marker { color: red; + content: url("/static/failure-closed.svg"); + } + &.failure[open] > summary::marker { + content: url("/static/failure-open.svg"); + } + &.skip > summary::marker { + color: blue; + content: url("/static/skip-closed.svg"); + } + &.skip[open] > summary::marker { + content: url("/static/skip-open.svg"); } } @@ -26,6 +51,10 @@ p.test-desc { } } +.hidden { + display: none; +} + .italic { font-style: italic; } diff --git a/cmd/pkgserver/ui/static/test.ts b/cmd/pkgserver/ui/static/test.ts index 27553bcf..2717e428 100644 --- a/cmd/pkgserver/ui/static/test.ts +++ b/cmd/pkgserver/ui/static/test.ts @@ -53,28 +53,44 @@ 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(); + } + + 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"; } log(message: string) { @@ -96,7 +112,7 @@ export class TestController { // Execution export interface TestResult { - success: boolean; + state: TestState; logs: string[]; } @@ -110,11 +126,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 { @@ -170,14 +186,16 @@ 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 = []; } register(suites: TestGroup[]) {} @@ -185,31 +203,52 @@ 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) { - const key = f.path.slice(0, -1).join(SEP); + for (const t of data) { + 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 { @@ -217,10 +256,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) { @@ -246,7 +281,10 @@ export class DOMReporter implements Reporter { update(path: string[], result: TestResult) { if (path.length === 0) throw new RangeError("path is empty"); - const counter = document.getElementById(result.success ? "successes" : "failures"); + if (result.state === "skip") { + document.getElementById("skip-counter-text").classList.remove("hidden"); + } + const counter = document.getElementById(`${result.state}-counter`); counter.innerText = (Number(counter.innerText) + 1).toString(); let parent = document.getElementById("root"); for (const node of path) { @@ -267,9 +305,10 @@ export class DOMReporter implements Reporter { child.appendChild(summary); parent.appendChild(child); } - if (!result.success) { - child.open = true; - child.classList.add("failure"); + if (result.state === "failure") child.open = true; + if (!child.classList.contains("failure") && + (result.state !== "success" || !child.classList.contains("skip"))) { + child.classList.add(result.state); } parent = child; } diff --git a/cmd/pkgserver/ui/static/test_tests.ts b/cmd/pkgserver/ui/static/test_tests.ts index fb1627c4..1ccec6a1 100644 --- a/cmd/pkgserver/ui/static/test_tests.ts +++ b/cmd/pkgserver/ui/static/test_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"]`); } diff --git a/cmd/pkgserver/ui/test.html b/cmd/pkgserver/ui/test.html index 1b7ab015..1ff11f92 100644 --- a/cmd/pkgserver/ui/test.html +++ b/cmd/pkgserver/ui/test.html @@ -12,7 +12,8 @@

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