import { ArrayPrototypeJoin, ArrayPrototypePush, ArrayPrototypeSplice, Error, ErrorGetStack, ObjectCreate, Promise, String, TypeError, uncurryThis, } from "ext:core/primordials"; import { op_test_config, op_test_event } from "ext:core/ops"; import type { TestEvent } from "ext:core/ops"; import type { TestContext } from "./main.js"; import { clearTimeout, setTimeout } from "ext:timers/timers.js"; import { required, usv } from "ext:web/idl.js"; import { flag, optionsOf, registry, type Definition, type ParsedStep } from "./shared.js"; type Report = (event: TestEvent) => void; interface Shared { settled: boolean; test: string; } interface Local { running: boolean; pending: Promise[]; failed: number; } const indexOf = uncurryThis(String.prototype.indexOf); const { now: dateNow } = Date; function parseStep(nameOrOptions: unknown, fn: unknown, count: number): ParsedStep { required(count, 1); if (typeof nameOrOptions === "string") { if (count > 2 || (count === 2 && typeof fn !== "function")) { throw new TypeError("Expected step(name, fn)"); } // The string form cannot express ignore, so a missing function is always // an error here rather than a late internal TypeError. if (fn === undefined) throw new TypeError(`Step "${usv(nameOrOptions)}" requires a function`); return { name: usv(nameOrOptions), fn, ignore: false } as ParsedStep; } if (count > 1) throw new TypeError("Expected step(options)"); const options = optionsOf(nameOrOptions, ["name", "fn", "ignore"], "step"); if (options.name === undefined) throw new TypeError("Step requires a name"); const ignore = flag(options, "ignore", "Step"); const func = options.fn; if (func === undefined) { if (!ignore) throw new TypeError(`Step "${usv(options.name)}" requires a function`); } else if (typeof func !== "function") { throw new TypeError(`Step "${usv(options.name)}" requires a function`); } return { name: usv(options.name), fn: func, ignore } as ParsedStep; } function formatError(error: unknown): string { let text; try { if (error instanceof Error) { const stack = ErrorGetStack(error); text = typeof stack === "string" && stack !== "" ? stack : String(error); } else { text = String(error); } } catch { return "Unknown error"; } // The runner's own frames never explain a failure. const lines = text.split("\n"); const kept = [lines[0]]; for (let index = 1; index < lines.length; index++) { if ( indexOf(lines[index], "ext:test/runner.js") === -1 && indexOf(lines[index], "ext:test/main.js") === -1 && indexOf(lines[index], "ext:test/shared.js") === -1 ) { ArrayPrototypePush(kept, lines[index]); } } return ArrayPrototypeJoin(kept, "\n"); } function withTimeout(work: () => Promise, ms: number, name: string): Promise { let timer: number | undefined; const started = (() => { try { return { failed: false, value: work() }; } catch (error) { return { failed: true, error }; } })(); const attempt = started.failed ? Promise.reject(started.error) : Promise.resolve(started.value); if (!(ms < Infinity)) return attempt; const expired = new Promise((_, reject) => { timer = setTimeout( () => reject(new Error(`Test "${name}" timed out after ${ms}ms`)), ms, ); }); return Promise.race([attempt, expired]).then( (value) => { clearTimeout(timer); return value; }, (error) => { clearTimeout(timer); throw error; }, ); } // One test's contexts: `shared` observes the whole test (its name for // stale-step errors and the settled flag), while each `local` tracks only // its own running step, pending children, and failed children — so a // concurrent violation fails its caller and ancestors, never its victim. function context(name: string, depth: number, report: Report, shared: Shared, local: Local): TestContext { const self: { name: string; step: TestContext["step"] } = ObjectCreate(null); self.name = name; self.step = function step(nameOrOptions: unknown, fn?: unknown): Promise { required(arguments.length, 1); const parsed = parseStep(nameOrOptions, fn, arguments.length); if (shared.settled) { throw new TypeError(`Step "${parsed.name}" called after "${shared.test}" settled`); } if (local.running) { local.failed += 1; report({ kind: "fail", name: parsed.name, depth: depth + 1, elapsedMs: 0, text: `Error: Started step "${parsed.name}" while another step was running`, }); return Promise.resolve(false); } local.running = true; const outcome = runStep(local, parsed, depth + 1, report, shared); ArrayPrototypePush(local.pending, outcome); return outcome; }; return self; } // Waits out every step a body started without awaiting: an unawaited step // still decides its parent. Loops so a step spawned from a step callback // joins the same drain; the test timeout bounds evasive patterns. async function drainSteps(local: Local): Promise { let waited = 0; while (waited < local.pending.length) { waited = local.pending.length; await Promise.all(local.pending); } } function failedStepsText(failed: number): string { return `due to ${failed} failed step${failed === 1 ? "" : "s"}`; } // A step never throws its body's failure: it reports and resolves false, // while the failure still fails every ancestor up to the test. async function runStep(owner: Local, parsed: ParsedStep, depth: number, report: Report, shared: Shared): Promise { const local: Local = { running: false, pending: [], failed: 0 }; const start = dateNow(); try { if (parsed.ignore) { report({ kind: "ignore", name: parsed.name, depth }); return false; } await parsed.fn(context(parsed.name, depth, report, shared, local)); await drainSteps(local); // A completion after the test settled (a timed-out body waking up) has // no parent left to report under, so it is dropped. if (shared.settled) return false; if (local.failed > 0) throw new Error(failedStepsText(local.failed)); report({ kind: "pass", name: parsed.name, depth, elapsedMs: dateNow() - start }); return true; } catch (error) { if (shared.settled) return false; report({ kind: "fail", name: parsed.name, depth, elapsedMs: dateNow() - start, text: formatError(error), }); owner.failed += 1; return false; } finally { owner.running = false; } } async function runTest(definition: Definition, report: Report): Promise { if (definition.ignore) { report({ kind: "ignore", name: definition.name, depth: 0 }); return; } const shared = { settled: false, test: definition.name }; const local: Local = { running: false, pending: [], failed: 0 }; const start = dateNow(); try { await withTimeout(async () => { await definition.fn(context(definition.name, 0, report, shared, local)); await drainSteps(local); if (local.failed > 0) throw new Error(failedStepsText(local.failed)); }, definition.timeout, definition.name); report({ kind: "pass", name: definition.name, depth: 0, elapsedMs: dateNow() - start, }); } catch (error) { report({ kind: "fail", name: definition.name, depth: 0, elapsedMs: dateNow() - start, text: formatError(error), }); } finally { shared.settled = true; } } export async function runTests(): Promise { registry.sealed = true; const config = op_test_config(); const filter = config.filter ?? null; const matches = (name: string): boolean => filter === null || indexOf(name, filter) !== -1; const selected: Definition[] = []; for (let index = 0; index < registry.definitions.length; index++) { if (matches(registry.definitions[index].name)) { ArrayPrototypePush(selected, registry.definitions[index]); } else { op_test_event({ kind: "filtered", name: registry.definitions[index].name, depth: 0 }); } } if (config.noRun) { for (let index = 0; index < selected.length; index++) { const definition = selected[index]; op_test_event({ kind: definition.ignore ? "ignore" : "found", name: definition.name, depth: 0, }); } op_test_event({ kind: "done" }); return; } let focused = false; for (let index = 0; index < selected.length; index++) { if (selected[index].only) focused = true; } // Rejections nobody handles would otherwise abort the file through the // host with no attribution, or vanish once the run resolves. Collect them // under the running test and fail the file with the full list instead. const strays: { promise: Promise; reason: unknown; test: string | null }[] = []; let current: string | null = null; function onUnhandled(event: PromiseRejectionEvent): void { try { event.preventDefault(); } catch { // The host decides; the list below still records what was lost. } ArrayPrototypePush( strays, { promise: event.promise, reason: event.reason, test: current }, ); } function onHandled(event: PromiseRejectionEvent): void { for (let index = 0; index < strays.length; index++) { if (strays[index].promise === event.promise) { ArrayPrototypeSplice(strays, index, 1); break; } } } globalThis.addEventListener("unhandledrejection", onUnhandled); globalThis.addEventListener("rejectionhandled", onHandled); try { for (let index = 0; index < selected.length; index++) { const definition = selected[index]; if (focused && !definition.only) { op_test_event({ kind: "filtered", name: definition.name, depth: 0 }); continue; } op_test_event({ kind: "start", name: definition.name, depth: 0, timeoutMs: definition.timeout === Infinity ? null : definition.timeout, }); current = definition.name; await runTest(definition, op_test_event); current = null; } op_test_event({ kind: "done" }); // Let strays queued during the run surface before judging them. await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); if (strays.length > 0) { const lines = ["Unhandled rejections during the test run:"]; for (let index = 0; index < strays.length; index++) { const entry = strays[index]; const during = entry.test === null ? "after the run" : `during "${entry.test}"`; ArrayPrototypePush(lines, ` ${during}:`); const text = formatError(entry.reason).split("\n"); for (let line = 0; line < text.length; line++) { ArrayPrototypePush(lines, ` ${text[line]}`); } } throw new Error(ArrayPrototypeJoin(lines, "\n")); } } finally { globalThis.removeEventListener("unhandledrejection", onUnhandled); globalThis.removeEventListener("rejectionhandled", onHandled); } }