diff --git a/tests/uat/extension-page-harness.ts b/tests/uat/extension-page-harness.ts index c0aace3..80db0f3 100644 --- a/tests/uat/extension-page-harness.ts +++ b/tests/uat/extension-page-harness.ts @@ -2789,6 +2789,306 @@ async function assertA22(): Promise { return result; } +/* ─── Plan 02-04 Task 1 — A24 (Blob URL empirical) ───────────────────── + * + * A24 — D-P2-01 empirical: SAVE_ARCHIVE → chrome.downloads.download is + * invoked with a `blob:` URL prefix (NOT `data:application/zip;base64,`). + * Closes audit P0-6 functionally (Plan 02-02 unit-tested it at the + * wire boundary; A24 verifies end-to-end through a real Chrome + * instance + the offscreen mint round-trip + chrome.downloads + * platform call). + * + * FORBIDDEN_HOOK_STRINGS impact: NONE. A24 uses chrome.downloads.onCreated + * (production cross-realm API; canonical capture pattern) + chrome.runtime + * .sendMessage(SAVE_ARCHIVE). The Tier-1 inventory stays at 12. + * + * Future Plan 02-04 Tasks 2-3 will add A25+A26+A27+A28 (latency, meta.json + * shape, multi-tab urls strict, REQ-archive-layout strict zip-layout). + */ + +/** Timeout for SAVE_ARCHIVE message dispatch — matches A5_SAVE_ARCHIVE_TIMEOUT_MS + * (the SW does the same screenshot + content-script + JSZip work). 15s is + * generously above the typical ~2-3s observed in A5. */ +const A24_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; +/** Max time to poll for the chrome.downloads.download Proxy to fire after + * the SAVE_ARCHIVE ack returns. The platform call is async-resolved post + * the offscreen → SW → chrome.downloads round-trip; 5s is comfortable + * headroom over the typical sub-second delay. */ +const A24_DOWNLOAD_SPY_POLL_TIMEOUT_MS = 5_000; +/** Polling cadence while waiting for the chrome.downloads.download spy + * to capture its args. 100 ms matches the global POLL_INTERVAL_MS. */ +const A24_DOWNLOAD_SPY_POLL_INTERVAL_MS = 100; +/** Pre-SAVE segment-settle window — mirrors A5_SEGMENT_SETTLE_MS (11s + * = 10s rotation + 1s slack) so getVideoBufferFromOffscreen returns a + * non-empty segment buffer before A24 dispatches SAVE_ARCHIVE. */ +const A24_SEGMENT_SETTLE_MS = 11_000; + +/** + * A24 — D-P2-01 empirical: SAVE_ARCHIVE invokes chrome.downloads.download + * with a `blob:` URL (NOT `data:application/zip;base64,`). + * + * Strategy: chrome.downloads.onCreated listener captures the URL cross-realm. + * + * The plan's `` block proposed a chrome.downloads.download + * monkey-patch installed in the harness page realm. That approach + * intercepts only calls dispatched FROM the harness page realm — the + * SW's downloadArchive call lives in a different realm and bypasses + * the patch. The canonical cross-realm capture pattern is + * chrome.downloads.onCreated (fires for any download initiated by any + * extension realm, with the full DownloadItem including .url). + * + * Per the plan + saved memory feedback-no-unilateral-scope-reduction: + * NO new test-hook surface. chrome.downloads.onCreated is a production + * chrome.* API available to extension contexts under the existing + * `downloads` permission. Tier-1 FORBIDDEN_HOOK_STRINGS stays at 12. + * + * Chaining: A24 does its OWN setupFreshRecording + SAVE because the + * onCreated listener MUST be installed BEFORE the SAVE — chaining off + * A5/A11/A12/A13's already-completed saves misses the listener window. + * + * @returns Structured result with 4 checks (A24.1..A24.4). + */ +async function assertA24(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A24 — D-P2-01 empirical: chrome.downloads.download receives blob: URL (closes P0-6)', + checks: [], + diagnostics: [], + }; + + // Capture stored in closure so the onCreated listener can populate it + // across the async SAVE_ARCHIVE dispatch + post-ack poll. + let capturedUrl: string | null = null; + let listenerInstalled = false; + const onCreatedListener = (item: chrome.downloads.DownloadItem): void => { + // First-wins — A24's SAVE_ARCHIVE produces exactly one download; any + // subsequent download in the same test run (e.g. from a stray prior + // SAVE that landed late) would NOT overwrite the captured value. + if (capturedUrl === null) { + capturedUrl = item.url; + } + }; + + try { + diag(result, 'Step 1: setupFreshRecording (A24 owns its recording — onCreated listener installed pre-SAVE)'); + const setupResp = await setupFreshRecording(); + if (!setupResp.ok) { + throw new Error( + `setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`, + ); + } + diag(result, 'Step 1 OK — REC state established'); + + diag(result, `Step 2: settle ${A24_SEGMENT_SETTLE_MS}ms for first segment rotation`); + await new Promise((r) => setTimeout(r, A24_SEGMENT_SETTLE_MS)); + + diag(result, 'Step 3: install chrome.downloads.onCreated listener (cross-realm capture)'); + chrome.downloads.onCreated.addListener(onCreatedListener); + listenerInstalled = true; + + diag(result, 'Step 4: dispatch SAVE_ARCHIVE'); + const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>( + { type: 'SAVE_ARCHIVE' }, + A24_SAVE_ARCHIVE_TIMEOUT_MS, + 'SAVE_ARCHIVE (A24)', + ); + diag(result, `Step 4 result: ${JSON.stringify(ack)}`); + + result.checks.push({ + name: 'A24.1: SAVE_ARCHIVE ack received with success=true', + expected: true, + actual: ack.success, + passed: ack.success === true, + }); + + diag(result, `Step 5: poll up to ${A24_DOWNLOAD_SPY_POLL_TIMEOUT_MS}ms for chrome.downloads.onCreated to fire`); + const pollStart = Date.now(); + while (capturedUrl === null && Date.now() - pollStart < A24_DOWNLOAD_SPY_POLL_TIMEOUT_MS) { + await new Promise((r) => setTimeout(r, A24_DOWNLOAD_SPY_POLL_INTERVAL_MS)); + } + diag( + result, + `Step 5 result: capturedUrl=${capturedUrl === null ? '' : (capturedUrl as string).substring(0, 60) + '...'}`, + ); + + result.checks.push({ + name: 'A24.2: chrome.downloads.onCreated fired (download initiated by SW)', + expected: true, + actual: capturedUrl !== null, + passed: capturedUrl !== null, + }); + + const urlIsBlob = + capturedUrl !== null && (capturedUrl as string).startsWith('blob:'); + const urlIsDataBase64 = + capturedUrl !== null && + (capturedUrl as string).startsWith('data:application/zip;base64,'); + result.checks.push({ + name: 'A24.3: download URL starts with "blob:" (D-P2-01 Plan 02-02 closure of P0-6)', + expected: true, + actual: urlIsBlob, + passed: urlIsBlob, + }); + result.checks.push({ + name: 'A24.4: download URL does NOT start with "data:application/zip;base64," (legacy path retired)', + expected: true, + actual: !urlIsDataBase64, + passed: !urlIsDataBase64, + }); + diag( + result, + `capturedUrl prefix: ${capturedUrl === null ? '' : (capturedUrl as string).substring(0, 40) + '...'}`, + ); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } finally { + // T-02-04-01 mitigation: always remove the listener so it does not + // accumulate across re-runs of the harness within a single session. + // removeListener on a never-added listener is a benign no-op per + // chrome.events spec; the guard is purely diagnostic. + if (listenerInstalled) { + try { + chrome.downloads.onCreated.removeListener(onCreatedListener); + } catch (rmErr) { + diag(result, `(listener cleanup ignored: ${String(rmErr)})`); + } + } + } + + return result; +} + +/* ─── Plan 02-04 Task 2 — A25 (5s SAVE→ack+file-on-disk latency) ─────── + * + * A25 — REQ-archive-export-latency / SPEC §10 #6: page-side records + * dispatch→ack timings via performance.now() bookends; host-side + * driver merges the dispatch→file-on-disk timing via downloadsDir + * polling. End-to-end ceiling: 5000 ms. + * + * T-02-04-02 disposition (accept): the bracket is t0=just-before-SAVE, + * tAck=just-after-ack — NOT broader test orchestration. setupFreshRecording + * + segment-settle happen BEFORE the t0 mark. The 5s budget is measured + * FROM the SAVE dispatch instant. + */ + +/** SAVE_ARCHIVE dispatch timeout for A25 — matches A24's 15s. */ +const A25_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; +/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */ +const A25_SEGMENT_SETTLE_MS = 11_000; +/** Hard latency ceiling per SPEC §10 #6 + CON-archive-export-latency. */ +const A25_LATENCY_CEILING_MS = 5_000; + +/** + * Extended result shape — A25 returns the t0/tAck bookends so the + * host-side driver can compute the merged dispatch→file-on-disk + * latency check. + * + * t0Wall is `Date.now()` captured at the SAVE_ARCHIVE dispatch instant + * (NOT at setupFreshRecording start). It is the canonical anchor for + * host-side dispatch→file-on-disk measurement, because performance.now() + * is monotonic and not comparable to Date.now() across realms. The + * driver computes `tFileHost - t0Wall < 5000ms` to assert REQ-archive- + * export-latency per T-02-04-02 disposition (bracket only the SAVE + * dispatch, not the broader test orchestration). + */ +interface A25Result extends AssertionResult { + t0: number; + tAck: number; + t0Wall: number; + ackSuccess: boolean; +} + +/** + * A25 — SAVE_ARCHIVE → zip on disk in <5000ms (REQ-archive-export-latency). + * + * Page-side measures dispatch→ack ONLY (performance.now() bookends + * bracket the chrome.runtime.sendMessage call). Host-side `driveA25` + * additionally polls downloadsDir for the new zip + measures + * dispatch→file-on-disk via mtime delta. + * + * Chaining: A25 does its OWN setupFreshRecording + SAVE (clean latency + * measurement, not compounded with A24's still-pending state). The + * setupFreshRecording + 11s settle WALL TIME is OUTSIDE the t0/tAck + * bracket — only the SAVE dispatch latency counts against the 5s budget. + * + * @returns A25Result with structured checks + t0/tAck bookends for the + * host-side driver. + */ +async function assertA25(): Promise { + const result: A25Result = { + passed: false, + name: 'A25 — REQ-archive-export-latency: <5000ms SAVE→ack (SPEC §10 #6)', + checks: [], + diagnostics: [], + t0: 0, + tAck: 0, + t0Wall: 0, + ackSuccess: false, + }; + + try { + diag(result, 'Step 1: setupFreshRecording (A25 owns its recording — clean latency measurement)'); + const setupResp = await setupFreshRecording(); + if (!setupResp.ok) { + throw new Error( + `setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`, + ); + } + diag(result, 'Step 1 OK — REC state established'); + + diag(result, `Step 2: settle ${A25_SEGMENT_SETTLE_MS}ms for first segment rotation (NOT counted toward 5s budget)`); + await new Promise((r) => setTimeout(r, A25_SEGMENT_SETTLE_MS)); + + diag(result, 'Step 3: t0 = performance.now() AND t0Wall = Date.now(), then SAVE_ARCHIVE then tAck (this is the budgeted bracket)'); + const t0 = performance.now(); + const t0Wall = Date.now(); + const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>( + { type: 'SAVE_ARCHIVE' }, + A25_SAVE_ARCHIVE_TIMEOUT_MS, + 'SAVE_ARCHIVE (A25)', + ); + const tAck = performance.now(); + const elapsedAck = tAck - t0; + + result.t0 = t0; + result.tAck = tAck; + result.t0Wall = t0Wall; + result.ackSuccess = ack.success === true; + + diag( + result, + `Step 3 result: ack=${JSON.stringify(ack)}, t0=${t0.toFixed(0)}, tAck=${tAck.toFixed(0)}, t0Wall=${t0Wall}, elapsedAck=${elapsedAck.toFixed(0)}ms`, + ); + + result.checks.push({ + name: 'A25.1: SAVE_ARCHIVE ack received with success=true', + expected: true, + actual: ack.success, + passed: ack.success === true, + }); + result.checks.push({ + name: `A25.2: page-side dispatch → ack latency < ${A25_LATENCY_CEILING_MS}ms`, + expected: `<${A25_LATENCY_CEILING_MS}ms`, + actual: `${elapsedAck.toFixed(0)}ms`, + passed: elapsedAck < A25_LATENCY_CEILING_MS, + }); + diag( + result, + `page-side latency: t0=${t0.toFixed(0)} tAck=${tAck.toFixed(0)} delta=${elapsedAck.toFixed(0)}ms`, + ); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } + + return result; +} + /** * Read `chrome.runtime.getManifest().version`. Used by the host-side * orchestrator at startup to capture the expected version for A13's @@ -2832,6 +3132,10 @@ declare global { assertA22: () => Promise; // Plan 01-14 — picker narrowing assertA23: () => Promise; + // Plan 02-04 Task 1 — Phase 2 closure (A24 D-P2-01 Blob URL) + assertA24: () => Promise; + // Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling) + assertA25: () => Promise; getManifestVersion: () => Promise; }; } @@ -2861,14 +3165,16 @@ window.__mokoshHarness = { assertA21, assertA22, assertA23, + assertA24, + assertA25, getManifestVersion, }; const statusEl = document.getElementById('status'); if (statusEl !== null) { - statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..assertA17, assertA18..A22, assertA23, getManifestVersion} available.'; + statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A28, getManifestVersion} available.'; } -console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + getManifestVersion)'); +console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Tasks 1-2: A24+A25 + getManifestVersion)'); export {}; diff --git a/tests/uat/harness.test.ts b/tests/uat/harness.test.ts index 2ee67cc..41ea7db 100644 --- a/tests/uat/harness.test.ts +++ b/tests/uat/harness.test.ts @@ -89,6 +89,10 @@ import { driveA22, // Plan 01-14 — picker-narrowing constraint driveA23, + // Plan 02-04 Task 1 — D-P2-01 empirical Blob URL verification + driveA24, + // Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling) + driveA25, getManifestVersion, } from './lib/harness-page-driver'; import { @@ -256,8 +260,8 @@ async function assertA0_GrepGate(): Promise<{ * @returns Process exit code: 0 on 15/15 GREEN, 1 on any failure. */ async function main(): Promise { - process.stdout.write('\nMokosh Plan 01-13 + 01-14 — UAT harness orchestrator\n'); - process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A23)\n'); + process.stdout.write('\nMokosh Plan 01-13 + 01-14 + 02-04 — UAT harness orchestrator\n'); + process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25)\n'); process.stdout.write('='.repeat(72) + '\n'); // A0 pre-flight (no Chrome launch needed; runs against built dist/). @@ -311,6 +315,10 @@ async function main(): Promise { (page) => driveA12(page, handles.downloadsDir); const driveA13Wrapped: (page: import('puppeteer').Page) => Promise = (page) => driveA13(page, handles.downloadsDir, expectedManifestVersion); + // Plan 02-04 Task 2 — driveA25 needs downloadsDir for the host-side + // dispatch→file-on-disk latency check (mirrors A5/A12/A13 wrapping). + const driveA25Wrapped: (page: import('puppeteer').Page) => Promise = + (page) => driveA25(page, handles.downloadsDir); const drivers: ReadonlyArray<{ readonly name: string; @@ -373,6 +381,23 @@ async function main(): Promise { // Chrome ≥ 119 picker-narrowing semantics). Independent of A14 — // no new getDisplayMedia call, no new state change. { name: 'A23', drive: driveA23 }, + // Plan 02-04 Task 1 A24: D-P2-01 empirical Blob URL verification. + // Installs chrome.downloads.onCreated listener cross-realm, dispatches + // SAVE_ARCHIVE, captures the download URL, asserts the `blob:` prefix + // (closes audit P0-6 end-to-end through a real Chrome instance + + // the offscreen mint round-trip + chrome.downloads platform call). + // A24 does its OWN setupFreshRecording + SAVE because the listener + // must be installed pre-dispatch. After A24 the recording stays alive + // for any chained Plan 02-04 Tasks 2-3 assertions (Phase 2 closure). + { name: 'A24', drive: driveA24 }, + // Plan 02-04 Task 2 A25: REQ-archive-export-latency / SPEC §10 #6. + // Page-side measures SAVE→ack via performance.now() bookends; host-side + // adds the dispatch→file-on-disk latency check via downloadsDir + // polling + mtime delta. Hard ceiling: 5000ms end-to-end. A25 owns + // its setupFreshRecording (clean latency measurement; not compounded + // with A24's still-pending state). The 11s segment-settle is NOT + // counted toward the 5s budget — only the SAVE dispatch. + { name: 'A25', drive: driveA25Wrapped }, ]; const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole }; diff --git a/tests/uat/lib/harness-page-driver.ts b/tests/uat/lib/harness-page-driver.ts index 1b8071a..e56462d 100644 --- a/tests/uat/lib/harness-page-driver.ts +++ b/tests/uat/lib/harness-page-driver.ts @@ -1178,6 +1178,160 @@ export async function driveA23(page: Page): Promise { }) as AssertionRecord; } +/* ─── Plan 02-04 Task 1 — driveA24 (Blob URL empirical D-P2-01) ─────── */ + +/** + * Drive A24 (Plan 02-04 D-P2-01 empirical Blob URL verification). + * Standard page.evaluate wrapper — page side installs the + * chrome.downloads.onCreated listener, dispatches SAVE_ARCHIVE, captures + * the URL, asserts the `blob:` prefix and absence of the legacy + * `data:application/zip;base64,` prefix. + * + * Chains AFTER driveA23 in the orchestrator. A24 does its OWN + * setupFreshRecording + SAVE because the listener MUST be installed + * BEFORE the SAVE dispatch (chaining off A5/A11/A12/A13's already- + * completed saves misses the listener window). After A24 the recording + * is still alive (A24 does not call setIdleMode or STOP_RECORDING); + * subsequent assertions (Plan 02-04 Tasks 2-3 will add A25+A26+A27+A28) + * can either reuse A24's REC state or own their own recording. + * + * @param page - The harness page from `launchHarnessBrowser`. + * @returns Structured AssertionRecord with 4 checks (A24.1..A24.4). + */ +export async function driveA24(page: Page): Promise { + return await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA24(); + return r; + }) as AssertionRecord; +} + +/* ─── Plan 02-04 Task 2 — driveA25 (REQ-archive-export-latency, 5s) ──── */ + +/** Maximum wait for the SAVE_ARCHIVE zip to appear in downloadsDir, host- + * side. 6000ms gives 1s slack over the 5s SLO without making the harness + * hang too long on a regression. */ +const A25_HOST_POLL_TIMEOUT_MS = 6_000; +/** Polling cadence — matches A5's 100ms; balances CPU + detection latency. */ +const A25_HOST_POLL_INTERVAL_MS = 100; +/** Latency ceiling mirrors the page-side const (kept duplicated host-side + * to avoid a cross-bundle export). */ +const A25_LATENCY_CEILING_MS = 5_000; + +/** + * Drive A25 (Plan 02-04 REQ-archive-export-latency 5s ceiling). + * Four-phase orchestration: + * + * 1. Host side: snapshot existing zips in downloadsDir BEFORE dispatch. + * Record t0_host = Date.now() so the host-side latency bracket is + * symmetric with the page-side performance.now() bracket. + * + * 2. Page side: dispatch SAVE_ARCHIVE via `assertA25` harness method. + * Returns A25Result with t0/tAck (page-side bookends) + ackSuccess. + * + * 3. Host side: poll downloadsDir for the new zip; record tFile_host + * = Date.now() when the file appears. + * + * 4. Host side: merge checks — A25.3 (host-side dispatch→file-on-disk + * latency < 5000ms) on top of A25.1+A25.2 (page-side). + * + * Note: t0_host is captured BEFORE the page.evaluate that triggers the + * SAVE; the page-side t0 is captured INSIDE the page.evaluate just + * before the chrome.runtime.sendMessage. The two brackets differ by + * the CDP round-trip overhead (~few ms typically), which is in the + * noise floor of the 5s budget but documented here for completeness. + * + * Chains AFTER driveA24 in the orchestrator. A25 does its OWN + * setupFreshRecording inside `assertA25` because the latency + * measurement must be on a clean save. + * + * @param page - The harness page from `launchHarnessBrowser`. + * @param downloadsDir - Absolute path to the per-run downloads directory. + * @returns Structured AssertionRecord with 3 merged checks (page-side + * ack + page-side latency + host-side latency). + */ +export async function driveA25( + page: Page, + downloadsDir: string, +): Promise { + // Phase 1 — snapshot pre-existing zips (filename + mtime for the + // overwrite-aware detector below). Mirrors A12/A13's pattern. + // NOTE: we do NOT capture t0Host on the driver side; the page-side + // assertA25 captures `t0Wall = Date.now()` at the SAVE_ARCHIVE + // dispatch instant (just after the 11s segment-settle). The driver + // uses that page-supplied `t0Wall` as the host-side bracket anchor. + // This is the Rule-1 fix per T-02-04-02 disposition: bracket only + // the SAVE dispatch, NOT the setupFreshRecording + segment-settle + // (which is page-side-orchestrated wall time outside the 5s SLO). + const preSnapshot = snapshotExistingZips(downloadsDir); + + // Phase 2 — page-side dispatch via assertA25. + const pageResult = await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- A25Result extends AssertionRecord with t0/tAck/t0Wall/ackSuccess + const r: any = await harness.assertA25(); + return r; + }) as AssertionRecord & { + t0: number; + tAck: number; + t0Wall: number; + ackSuccess: boolean; + }; + + // Phase 3 — host-side poll for a new-or-updated zip. + let tFileHost: number | null = null; + const pollStart = Date.now(); + while (Date.now() - pollStart < A25_HOST_POLL_TIMEOUT_MS) { + const allZips = readdirSync(downloadsDir).filter((name) => name.endsWith('.zip')); + let foundNew = false; + for (const name of allZips) { + const fullPath = resolvePath(downloadsDir, name); + const mtimeMs = statSync(fullPath).mtimeMs; + const prior = preSnapshot.get(name); + if (prior === undefined || mtimeMs > prior.mtimeMs) { + foundNew = true; + break; + } + } + if (foundNew) { + tFileHost = Date.now(); + break; + } + await new Promise((r) => setTimeout(r, A25_HOST_POLL_INTERVAL_MS)); + } + + // Phase 4 — merge checks. + const mergedChecks: CheckRecord[] = pageResult.checks.slice(); + const mergedDiagnostics: string[] = pageResult.diagnostics.slice(); + + // Compute host-side dispatch→file latency using the PAGE-SUPPLIED + // t0Wall (captured at the SAVE_ARCHIVE dispatch instant, after the + // 11s segment-settle). This is the canonical REQ-archive-export- + // latency measurement per SPEC §10 #6. + const t0Wall = pageResult.t0Wall; + const elapsedFile = tFileHost !== null ? tFileHost - t0Wall : -1; + mergedChecks.push({ + name: `A25.3: host-side dispatch → zip-on-disk latency < ${A25_LATENCY_CEILING_MS}ms`, + expected: `<${A25_LATENCY_CEILING_MS}ms`, + actual: elapsedFile >= 0 ? `${elapsedFile}ms` : 'zip never appeared', + passed: elapsedFile >= 0 && elapsedFile < A25_LATENCY_CEILING_MS, + }); + mergedDiagnostics.push( + `host-side latency (anchored at page-supplied t0Wall): t0Wall=${t0Wall} tFileHost=${tFileHost ?? ''} delta=${elapsedFile}ms`, + ); + + const mergedPassed = mergedChecks.every((c) => c.passed); + return { + passed: mergedPassed, + name: pageResult.name, + checks: mergedChecks, + diagnostics: mergedDiagnostics, + error: pageResult.error, + }; +} + // Note (Wave 3D): the AssertionWithBytes interface is retained at the // top of this file as a public export — but Wave 3D's drivers no // longer use it (the host side now does all bytes-handling internally