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

@@ -2961,6 +2961,134 @@ async function assertA24(): Promise<AssertionResult> {
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<A25Result> {
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
@@ -3004,8 +3132,10 @@ declare global {
assertA22: () => Promise<AssertionResult>;
// Plan 01-14 — picker narrowing
assertA23: () => Promise<AssertionResult>;
// Plan 02-04 Task 1 — Phase 2 closure (A24 first)
// Plan 02-04 Task 1 — Phase 2 closure (A24 D-P2-01 Blob URL)
assertA24: () => Promise<AssertionResult>;
// Plan 02-04 Task 2 — REQ-archive-export-latency (5s ceiling)
assertA25: () => Promise<A25Result>;
getManifestVersion: () => Promise<string>;
};
}
@@ -3036,6 +3166,7 @@ window.__mokoshHarness = {
assertA22,
assertA23,
assertA24,
assertA25,
getManifestVersion,
};
@@ -3044,6 +3175,6 @@ if (statusEl !== null) {
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 + Plan 02-04 Task 1: A24 + 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 {};