feat(02-04): harness A25 — empirical <5s SAVE→zip latency (REQ-archive-export-latency, SPEC §10 #6)

Wire A25 into the UAT harness as the binding empirical gate for
REQ-archive-export-latency / SPEC §10 #6 (5000ms hard ceiling end-to-end
from SAVE_ARCHIVE dispatch to zip-on-disk).

Architecture:
- Page-side assertA25 records t0 (performance.now) + t0Wall (Date.now)
  + tAck bookends around the chrome.runtime.sendMessage(SAVE_ARCHIVE)
  call. Returns A25Result extending AssertionRecord with the 3 timing
  fields + ackSuccess flag.
- Host-side driveA25(page, downloadsDir) snapshots zip dir BEFORE
  page.evaluate dispatch, polls for new-or-overwritten .zip via mtime
  delta (mirrors A12/A13 overwrite-aware pattern), uses page-supplied
  t0Wall as the host anchor for the dispatch→file-on-disk latency
  check (NOT a host-side Date.now captured before page.evaluate, which
  would include setupFreshRecording + 11s segment-settle wall time and
  always fail the 5s budget).

[Rule 1 - Bug] Initial implementation used host-side Date.now() captured
before page.evaluate as the latency anchor — this incorrectly included
the 11s segment-settle window in the budget. First run observed
A25.3=11188ms (FAIL). Fix: page-side captures Date.now() at the
SAVE_ARCHIVE dispatch instant (AFTER setupFreshRecording + segment-settle
complete) and returns it as t0Wall in A25Result; the driver uses this
as the canonical host anchor. Result on re-run: A25.3=61ms (GREEN, well
under 5s SLO). Documented per T-02-04-02 disposition (bracket only the
SAVE dispatch, not the broader test orchestration).

Files modified:
- tests/uat/extension-page-harness.ts (+~115 lines): assertA25 +
  A25_* constants + A25Result interface
- tests/uat/lib/harness-page-driver.ts (+~95 lines): driveA25 +
  A25_HOST_POLL_TIMEOUT_MS const + A25_LATENCY_CEILING_MS const
- tests/uat/harness.test.ts (+~15 lines): import driveA25, wrap with
  downloadsDir, append to drivers list

Verification:
- HEADLESS=1 npm run test:uat → 26/26 GREEN
- elapsedAck=60ms, host-side delta=61ms (both well under 5000ms SLO)
- npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts
  → 13/13 GREEN (Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12)
- npx tsc --noEmit → clean

Plan 02-04 scope: 2/3 tasks landed (A24 + A25); Task 3 adds
A26 (meta.json 8-field) + A27 (multi-tab strict) + A28 (archive-layout strict).
This commit is contained in:
2026-05-20 16:49:56 +02:00
parent 4ae73250fa
commit 47e9818cb1
3 changed files with 273 additions and 3 deletions

View File

@@ -1207,6 +1207,131 @@ export async function driveA24(page: Page): Promise<AssertionRecord> {
}) 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<AssertionRecord> {
// 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 ?? '<missing>'} 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