export interface TestResult { suite: string name: string ok: boolean error?: string stack?: string durationMs: number } interface SuiteDef { name: string tests: { name: string; fn: () => void | Promise }[] beforeEach?: () => void | Promise afterEach?: () => void | Promise } const suites: SuiteDef[] = [] let current: SuiteDef | null = null export function suite(name: string, fn: () => void): void { const prev = current current = { name, tests: [] } try { fn() suites.push(current) } finally { current = prev } } export function test(name: string, fn: () => void | Promise): void { if (!current) throw new Error(`test("${name}") called outside of suite()`) current.tests.push({ name, fn }) } export function beforeEach(fn: () => void | Promise): void { if (!current) throw new Error('beforeEach outside of suite()') current.beforeEach = fn } export function afterEach(fn: () => void | Promise): void { if (!current) throw new Error('afterEach outside of suite()') current.afterEach = fn } class Expectation { constructor(private value: unknown) {} toBe(expected: unknown): void { if (!Object.is(this.value, expected)) { throw new Error(`expected ${this.fmt(expected)}, got ${this.fmt(this.value)}`) } } toEqual(expected: unknown): void { const a = JSON.stringify(this.value) const b = JSON.stringify(expected) if (a !== b) throw new Error(`expected ${b}, got ${a}`) } toBeCloseTo(expected: number, precision = 4): void { const v = this.value as number const eps = Math.pow(10, -precision) / 2 if (Math.abs(v - expected) > eps) { throw new Error(`expected ≈${expected} (p=${precision}), got ${v}`) } } toBeTruthy(): void { if (!this.value) throw new Error(`expected truthy, got ${this.fmt(this.value)}`) } toBeFalsy(): void { if (this.value) throw new Error(`expected falsy, got ${this.fmt(this.value)}`) } toBeNull(): void { if (this.value !== null) throw new Error(`expected null, got ${this.fmt(this.value)}`) } toBeDefined(): void { if (this.value === undefined) throw new Error('expected defined value, got undefined') } toBeGreaterThan(n: number): void { if (!((this.value as number) > n)) throw new Error(`expected > ${n}, got ${this.value}`) } toBeLessThan(n: number): void { if (!((this.value as number) < n)) throw new Error(`expected < ${n}, got ${this.value}`) } toContain(sub: string): void { const s = this.value as string if (!s || !s.includes(sub)) { throw new Error(`expected string to contain ${JSON.stringify(sub)}`) } } toHaveLength(n: number): void { const l = (this.value as { length: number }).length if (l !== n) throw new Error(`expected length ${n}, got ${l}`) } private fmt(v: unknown): string { if (v === undefined) return 'undefined' try { return JSON.stringify(v) } catch { return String(v) } } } export function expect(value: unknown): Expectation { return new Expectation(value) } function cleanStack(stack: string | undefined): string | undefined { if (!stack) return undefined const lines = stack.split('\n') const filtered = lines.filter((l) => { if (l.includes('/testRunner.ts')) return false if (l.includes('/testRunner.js')) return false if (l.includes('node_modules')) return false return true }) return filtered.slice(0, 10).join('\n') } export interface RunOptions { onProgress?: (done: number, total: number) => void onTestStart?: (suite: string, name: string) => void onResult?: (result: TestResult) => void interTestDelayMs?: number } export async function runAll( register: () => void, opts: RunOptions = {}, ): Promise { suites.length = 0 register() const total = suites.reduce((a, s) => a + s.tests.length, 0) const results: TestResult[] = [] let done = 0 const delay = Math.max(0, opts.interTestDelayMs ?? 0) for (const s of suites) { for (const t of s.tests) { opts.onTestStart?.(s.name, t.name) await new Promise((r) => requestAnimationFrame(() => r(null))) const start = performance.now() let pushed: TestResult try { if (s.beforeEach) await s.beforeEach() await t.fn() if (s.afterEach) await s.afterEach() pushed = { suite: s.name, name: t.name, ok: true, durationMs: performance.now() - start, } } catch (err) { try { if (s.afterEach) await s.afterEach() } catch { /* ignore cleanup errors if test already failed */ } const e = err as Error pushed = { suite: s.name, name: t.name, ok: false, error: e.message || String(err), stack: cleanStack(e.stack), durationMs: performance.now() - start, } } results.push(pushed) // Per-result callback fires before the progress tick so the UI // can paint a failing-run indicator (e.g. flip the progress bar // to red) the moment a test fails instead of only once runAll // resolves. opts.onResult?.(pushed) done++ opts.onProgress?.(done, total) if (delay > 0) await new Promise((r) => setTimeout(r, delay)) else await new Promise((r) => setTimeout(r, 0)) } } return results }