1
0
forked from rosa/hakurei

TODO: implement skipping from TestController

This commit is contained in:
Kat
2026-03-20 03:49:44 +11:00
parent 877be3308e
commit 98c6d86be0
11 changed files with 147 additions and 32 deletions

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="12" viewBox="0 0 100 100">
<rect x="10" width="20" height="100" rx="4" fill="red"/>
<rect x="70" width="20" height="100" rx="4" fill="red"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="12" viewBox="0 0 100 100">
<rect y="10" width="100" height="20" rx="4" fill="red"/>
<rect y="70" width="100" height="20" rx="4" fill="red"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="12" viewBox="0 0 100 100">
<rect x="10" width="20" height="100" rx="4" fill="blue"/>
<rect x="70" width="20" height="100" rx="4" fill="blue"/>
</svg>

After

Width:  |  Height:  |  Size: 227 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="12" viewBox="0 0 100 100">
<rect y="10" width="100" height="20" rx="4" fill="blue"/>
<rect y="70" width="100" height="20" rx="4" fill="blue"/>
</svg>

After

Width:  |  Height:  |  Size: 227 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="12" viewBox="0 0 100 100">
<rect x="10" width="20" height="100" rx="4" fill="black"/>
<rect x="70" width="20" height="100" rx="4" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="12" viewBox="0 0 100 100">
<rect y="10" width="100" height="20" rx="4" fill="black"/>
<rect y="70" width="100" height="20" rx="4" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

View File

@@ -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;
}

View File

@@ -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<string, ({ name: string } & TestResult)[]>();
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;
}

View File

@@ -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"]`);
}