- 0 succeeded, 0 failed.
+ 0 succeeded, 0
+ failed, 0 skipped.
+Successful test
+Failed test
+Partially or fully skipped test
+
diff --git a/cmd/mbf/internal/pkgserver/ui/jstest/jstest.ts b/cmd/mbf/internal/pkgserver/ui/jstest/jstest.ts
index a8a8127f..1a47290e 100644
--- a/cmd/mbf/internal/pkgserver/ui/jstest/jstest.ts
+++ b/cmd/mbf/internal/pkgserver/ui/jstest/jstest.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) {
@@ -262,11 +297,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;
@@ -274,6 +314,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;
}
}
@@ -281,18 +322,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) {
@@ -330,7 +393,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/mbf/internal/pkgserver/ui/jstest/skip-closed.svg b/cmd/mbf/internal/pkgserver/ui/jstest/skip-closed.svg
new file mode 100644
index 00000000..61223376
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/jstest/skip-closed.svg
@@ -0,0 +1,21 @@
+
+
+
diff --git a/cmd/mbf/internal/pkgserver/ui/jstest/skip-open.svg b/cmd/mbf/internal/pkgserver/ui/jstest/skip-open.svg
new file mode 100644
index 00000000..bc84308b
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/jstest/skip-open.svg
@@ -0,0 +1,21 @@
+
+
+
diff --git a/cmd/mbf/internal/pkgserver/ui/jstest/style.css b/cmd/mbf/internal/pkgserver/ui/jstest/style.css
index 0b54273b..1af65d22 100644
--- a/cmd/mbf/internal/pkgserver/ui/jstest/style.css
+++ b/cmd/mbf/internal/pkgserver/ui/jstest/style.css
@@ -1,3 +1,8 @@
+/*
+ * When updating the theme colors, also update them in success-closed.svg and
+ * success-open.svg!
+ */
+
:root {
--bg: #d3d3d3;
--fg: black;
@@ -34,8 +39,38 @@ 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("./success-closed.svg") / "success";
+ }
+ &.success[open] > summary::marker {
+ content: url("./success-open.svg") / "success";
+ }
&.failure > summary::marker {
color: red;
+ content: url("./failure-closed.svg") / "failure";
+ }
+ &.failure[open] > summary::marker {
+ content: url("./failure-open.svg") / "failure";
+ }
+ &.skip > summary::marker {
+ color: blue;
+ content: url("./skip-closed.svg") / "skip";
+ }
+ &.skip[open] > summary::marker {
+ content: url("./skip-open.svg") / "skip";
}
}
diff --git a/cmd/mbf/internal/pkgserver/ui/jstest/success-closed.svg b/cmd/mbf/internal/pkgserver/ui/jstest/success-closed.svg
new file mode 100644
index 00000000..4f22691b
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/jstest/success-closed.svg
@@ -0,0 +1,16 @@
+
+
+
diff --git a/cmd/mbf/internal/pkgserver/ui/jstest/success-open.svg b/cmd/mbf/internal/pkgserver/ui/jstest/success-open.svg
new file mode 100644
index 00000000..b8d76413
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/jstest/success-open.svg
@@ -0,0 +1,16 @@
+
+
+
diff --git a/cmd/mbf/internal/pkgserver/ui/sample_test.ts b/cmd/mbf/internal/pkgserver/ui/sample_test.ts
index 3c60a849..984336af 100644
--- a/cmd/mbf/internal/pkgserver/ui/sample_test.ts
+++ b/cmd/mbf/internal/pkgserver/ui/sample_test.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/mbf/internal/pkgserver/ui/test_ui.go b/cmd/mbf/internal/pkgserver/ui/test_ui.go
index 76c894fd..c5659c51 100644
--- a/cmd/mbf/internal/pkgserver/ui/test_ui.go
+++ b/cmd/mbf/internal/pkgserver/ui/test_ui.go
@@ -7,6 +7,7 @@ import "embed"
//go:generate tsc -p tsconfig.test.json
//go:generate cp index.html style.css favicon.ico static
//go:generate cp jstest/index.html jstest/style.css static/jstest
+//go:generate sh -c "cp jstest/*.svg static/jstest"
//go:embed static
var _static embed.FS
var static = staticFS(_static)