// tests/uat/lib/assertions.ts — Plan 01-11 harness assertion runner. // // Centralizes: // - `assertEqual` / `assertMatch` / `assertTrue` — thin wrappers // over `node:assert/strict` with explicit Plan 01-11 diagnostic // framing (cite the bug-class on Bug A / Bug B assertions). // - `runAssertion(name, fn)` — wraps each assertion in a try/catch // so the harness can collect a per-assertion pass/fail map AND // dump SW/offscreen console buffers on the FIRST failure (bail // semantics per RESEARCH §5). // - `waitFor(probe, predicate, timeoutMs)` — polling helper used by // assertions that need to wait for async state transitions // (badge changes, downloads, etc.). // // References: // - node:assert/strict: https://nodejs.org/api/assert.html#strict-assertion-mode import { strict as assert } from 'node:assert'; /** * Per-assertion outcome record. Accumulated by runAssertion + flushed * to the harness's final summary line. */ export interface AssertionRecord { readonly index: number; readonly name: string; readonly passed: boolean; readonly errorMessage: string; readonly durationMs: number; } /** * Console buffers captured from SW + offscreen contexts. The harness * wires `sw.on('console', ...)` + `offPage.on('console', ...)` at * launch + before each assertion-relevant phase; on failure these * buffers are dumped to stderr for triage. */ export interface ConsoleBuffers { swLines: string[]; offscreenLines: string[]; } /** * Run a single assertion, capturing its outcome + duration. On error, * dump the per-context console buffers to stderr BEFORE rethrowing so * the harness's top-level catch sees the diagnostic context. * * @param index - 0-13 (0 = grep gate, 1-13 = functional). * @param name - Human-readable assertion title. * @param buffers - Console buffers to dump on failure (may be empty). * @param fn - Async assertion body. * @returns Outcome record. */ export async function runAssertion( index: number, name: string, buffers: ConsoleBuffers, fn: () => Promise, ): Promise { const start = Date.now(); try { await fn(); const durationMs = Date.now() - start; process.stdout.write(` [PASS] A${index}: ${name} (${durationMs}ms)\n`); return { index, name, passed: true, errorMessage: '', durationMs, }; } catch (err) { const durationMs = Date.now() - start; const errorMessage = err instanceof Error ? `${err.name}: ${err.message}` : String(err); process.stderr.write(` [FAIL] A${index}: ${name} (${durationMs}ms)\n`); process.stderr.write(` ${errorMessage}\n`); dumpBuffers(buffers, index); return { index, name, passed: false, errorMessage, durationMs, }; } } /** * Dump SW + offscreen console buffers to stderr with structured framing. * Cap at the last 30 lines per context to keep failure output readable. * * @param buffers - The accumulating buffers. * @param assertionIndex - For framing the dump preamble. */ function dumpBuffers(buffers: ConsoleBuffers, assertionIndex: number): void { const TAIL = 30; const swTail = buffers.swLines.slice(-TAIL); const offTail = buffers.offscreenLines.slice(-TAIL); if (swTail.length > 0) { process.stderr.write( ` --- SW console (last ${swTail.length} lines, assertion A${assertionIndex}) ---\n`, ); for (const line of swTail) { process.stderr.write(` ${line}\n`); } } if (offTail.length > 0) { process.stderr.write( ` --- Offscreen console (last ${offTail.length} lines, assertion A${assertionIndex}) ---\n`, ); for (const line of offTail) { process.stderr.write(` ${line}\n`); } } } /** * Strict equality with a context-bearing message. Wraps * `assert.strictEqual` so the failure surface is uniform across * assertions. * * @param actual - Observed value. * @param expected - Expected value. * @param msg - Context for the failure diagnostic. */ export function assertEqual(actual: T, expected: T, msg: string): void { assert.strictEqual(actual, expected, msg); } /** * Assert that `actual` matches `regex`. Wraps `assert.match`. * * @param actual - String to test. * @param regex - Pattern. * @param msg - Context for the failure diagnostic. */ export function assertMatch(actual: string, regex: RegExp, msg: string): void { assert.match(actual, regex, msg); } /** * Assert that `cond` is truthy. Wraps `assert.ok`. * * @param cond - Boolean expression. * @param msg - Context for the failure diagnostic. */ export function assertTrue(cond: boolean, msg: string): void { assert.ok(cond, msg); } /** * Assert that the actual value is greater than or equal to expected. * Used by assertion 9 (icon size floors) + assertion 11 (segment count). * * @param actual - Observed value. * @param expected - Minimum acceptable value. * @param msg - Context for the failure diagnostic. */ export function assertGte(actual: number, expected: number, msg: string): void { assert.ok( actual >= expected, `${msg} — expected >= ${expected}, got ${actual}`, ); } /** * Poll `probe` until `predicate(probe())` returns true OR timeoutMs * elapses. Throws on timeout with a structured diagnostic. * * @param probe - Async function producing a value to test. * @param predicate - Returns true when the value satisfies the wait. * @param timeoutMs - Maximum wait time. * @param description - Human-readable description for the diagnostic. * @param pollIntervalMs - Interval between probe calls (default 100ms). * @returns The last probed value that satisfied the predicate. * @throws If timeoutMs elapses without predicate satisfaction. */ export async function waitFor( probe: () => Promise, predicate: (v: T) => boolean, timeoutMs: number, description: string, pollIntervalMs: number = 100, ): Promise { const start = Date.now(); let lastValue: T | undefined; while (Date.now() - start < timeoutMs) { lastValue = await probe(); if (predicate(lastValue)) { return lastValue; } await new Promise((r) => setTimeout(r, pollIntervalMs)); } throw new Error( `waitFor timeout ${timeoutMs}ms — ${description}; ` + `last probed value: ${JSON.stringify(lastValue)}`, ); }