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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user