// tests/uat/lib/assertions.ts — Plan 01-13 Wave 2. // // Host-side assertion primitives. Re-exports of node:assert/strict with // structured failure messages + diagnostic-dump wrappers that capture // SW + offscreen console buffers on failure. // // IMPORTANT — NO chrome.* helpers here. All chrome.* work happens // inside the extension-internal harness page (see // tests/uat/extension-page-harness.ts and its `window.__mokoshHarness` // surface). This module is host-side ONLY — it runs in the Node // process that drives Puppeteer. Calling chrome.* from here would // fail (no chrome global in Node) — by-construction, not by convention. // // References: // - node:assert/strict (deep strict equality): // https://nodejs.org/api/assert.html#strict-assertion-mode import * as assert from 'node:assert/strict'; /** * One assertion-internal check record — populated by the harness page's * `assertA*` methods. Each AssertionRecord carries 1..N CheckRecords * which collectively determine whether the AssertionRecord PASSES. */ export interface CheckRecord { readonly name: string; readonly expected: unknown; readonly actual: unknown; readonly passed: boolean; } /** * Structured result returned by every page-side `assertA*` method. * Mirrors the shape used by the proven prototype (c647f61) so the * host-side `runAssertion` + `printAssertionResult` can consume any * assertion uniformly. */ export interface AssertionRecord { readonly passed: boolean; readonly name: string; readonly checks: ReadonlyArray; readonly diagnostics: ReadonlyArray; readonly error?: string; } /** * Accumulating console buffers from `launchHarnessBrowser`. Passed * into `runAssertion` so a failing assertion can dump the SW + offscreen * logs to stderr alongside the structured CheckRecords. The buffers * are MUTABLE arrays owned by `launch.ts`; readers MUST NOT mutate. */ export interface ConsoleBuffers { readonly swConsole: ReadonlyArray; readonly offConsole: ReadonlyArray; } /** * How many trailing lines of each console buffer to dump on a failure. * Bounded so a long-running test with thousands of lines does not * overwhelm stderr; the cap is generous enough to capture the relevant * preamble + the actual failure trigger. */ const CONSOLE_DUMP_TAIL_LINES = 100; /** * Wrap a single assertion attempt with try/catch + diagnostic dump on * failure. The `fn` is the page-side call (typically a `driveA*` * wrapper from `harness-page-driver.ts`); a thrown error becomes an * AssertionRecord with `passed: false` + the error message in `.error`. * * On failure, dumps the last `CONSOLE_DUMP_TAIL_LINES` of each console * buffer to stderr — sized to fit the typical assertion timeline * (several seconds of SW + offscreen logs) without spamming. * * @param name - Assertion name (used only for the failure preamble). * @param fn - Async function returning the page-side AssertionRecord. * @param buffers - Console buffers from `launchHarnessBrowser`. * @returns The page-side AssertionRecord (with passed=false on throw). */ export async function runAssertion( name: string, fn: () => Promise, buffers: ConsoleBuffers, ): Promise { try { const result = await fn(); if (!result.passed) { dumpConsoleTail(name, buffers); } return result; } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); dumpConsoleTail(name, buffers); return { passed: false, name, checks: [], diagnostics: [`runAssertion caught: ${errMsg}`], error: errMsg, }; } } /** * Dump the tail of each console buffer to stderr — used by * `runAssertion` on any failure path. Each line is already pre-tagged * (`[sw:log] ...` / `[off:log] ...`) by the listeners in `launch.ts`. * * @param assertionName - Name to prefix the dump header. * @param buffers - Console buffers to dump. */ function dumpConsoleTail( assertionName: string, buffers: ConsoleBuffers, ): void { process.stderr.write( `\n--- console dump for assertion '${assertionName}' (tail ${CONSOLE_DUMP_TAIL_LINES} lines per buffer) ---\n`, ); const swTail = buffers.swConsole.slice(-CONSOLE_DUMP_TAIL_LINES); const offTail = buffers.offConsole.slice(-CONSOLE_DUMP_TAIL_LINES); for (const line of swTail) { process.stderr.write(line + '\n'); } for (const line of offTail) { process.stderr.write(line + '\n'); } process.stderr.write( `--- end console dump for '${assertionName}' ---\n\n`, ); } /** * Pretty-print an AssertionRecord to stdout. Used by both the * orchestrator (`harness.test.ts` in Wave 3A) and the standalone A6 * entry (`a6.test.ts`). Single source of formatting truth. * * @param result - The structured result from a page-side assertion. */ export function printAssertionResult(result: AssertionRecord): void { process.stdout.write('\n'); process.stdout.write('='.repeat(72) + '\n'); process.stdout.write(`${result.name}: ${result.passed ? 'PASS' : 'FAIL'}\n`); if (result.error !== undefined) { process.stdout.write(`Top-level error: ${result.error}\n`); } process.stdout.write('\nChecks:\n'); for (const check of result.checks) { const mark = check.passed ? '[PASS]' : '[FAIL]'; process.stdout.write(` ${mark} ${check.name}\n`); process.stdout.write(` expected: ${JSON.stringify(check.expected)}\n`); process.stdout.write(` actual: ${JSON.stringify(check.actual)}\n`); } process.stdout.write('\nDiagnostics:\n'); for (const diag of result.diagnostics) { process.stdout.write(` - ${diag}\n`); } process.stdout.write('='.repeat(72) + '\n'); } /** * Wrapper around `assert.deepStrictEqual` with a structured message * preamble. Throws AssertionError on mismatch (caller catches in * `runAssertion`). * * @param actual - Observed value. * @param expected - Reference value. * @param message - Human-readable context (e.g. assertion name). */ export function assertEqual(actual: unknown, expected: unknown, message: string): void { assert.deepStrictEqual(actual, expected, message); } /** * Assert `actual >= expected`. Throws AssertionError on failure with * a structured message including both values. * * @param actual - Observed numeric value. * @param expected - Lower bound (inclusive). * @param message - Human-readable context. */ export function assertGte(actual: number, expected: number, message: string): void { if (actual < expected) { throw new assert.AssertionError({ message: `${message} — expected ${actual} >= ${expected}`, actual, expected, operator: '>=', }); } } /** * Assert `actual` matches the regex. Throws AssertionError on failure. * * @param actual - Observed string. * @param regex - Pattern to test. * @param message - Human-readable context. */ export function assertMatch(actual: string, regex: RegExp, message: string): void { if (!regex.test(actual)) { throw new assert.AssertionError({ message: `${message} — expected ${JSON.stringify(actual)} to match ${regex}`, actual, expected: regex, operator: 'match', }); } } /** * Assert `cond` is exactly `true`. Throws AssertionError otherwise. * * @param cond - Boolean to assert true. * @param message - Human-readable context. */ export function assertTrue(cond: boolean, message: string): void { assert.strictEqual(cond, true, message); } /** * Default polling interval for `waitFor` — matches the prototype's * 100ms cadence (good tradeoff between CPU and detection latency). */ const WAIT_FOR_POLL_INTERVAL_MS = 100; /** * Poll an async probe until it satisfies the predicate or the timeout * elapses. Mirrors the prototype's host-side polling primitive * (verbatim semantics, host-side scope). * * IMPORTANT: this is the HOST-SIDE waitFor. The HARNESS-PAGE-SIDE * waitFor (inside `tests/uat/extension-page-harness.ts`) is a separate * implementation with identical semantics — the page-side runs in the * browser isolate; the host-side runs in Node. They cannot share a * module because one is bundled into the HTML harness and the other * runs natively. * * @param probe - Async function returning the current value. * @param predicate - Returns true when the value matches the expectation. * @param timeoutMs - Maximum wait time before throwing. * @param description - Used in the timeout error message. * @returns The value that satisfied the predicate. * @throws If the timeout elapses; the error includes the last observed value. */ export async function waitFor( probe: () => Promise | T, predicate: (value: T) => boolean, timeoutMs: number, description: string, ): Promise { const start = Date.now(); let lastValue: T = await probe(); while (Date.now() - start < timeoutMs) { if (predicate(lastValue)) { return lastValue; } await new Promise((resolve) => setTimeout(resolve, WAIT_FOR_POLL_INTERVAL_MS)); lastValue = await probe(); } throw new Error( `waitFor timeout (${timeoutMs}ms) — ${description}; lastValue=${JSON.stringify(lastValue)}`, ); }