chore: merge partial executor worktree (worktree-agent-aac9035b8c3b890ac) — Wave 3 Plan 02-04 A24+A25 (529 mid-plan)
This commit is contained in:
@@ -2789,6 +2789,306 @@ async function assertA22(): Promise<AssertionResult> {
|
|||||||
return result;
|
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 `<action>` 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<AssertionResult> {
|
||||||
|
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 ? '<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 ? '<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<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
|
* Read `chrome.runtime.getManifest().version`. Used by the host-side
|
||||||
* orchestrator at startup to capture the expected version for A13's
|
* orchestrator at startup to capture the expected version for A13's
|
||||||
@@ -2832,6 +3132,10 @@ declare global {
|
|||||||
assertA22: () => Promise<AssertionResult>;
|
assertA22: () => Promise<AssertionResult>;
|
||||||
// Plan 01-14 — picker narrowing
|
// Plan 01-14 — picker narrowing
|
||||||
assertA23: () => Promise<AssertionResult>;
|
assertA23: () => Promise<AssertionResult>;
|
||||||
|
// 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>;
|
getManifestVersion: () => Promise<string>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2861,14 +3165,16 @@ window.__mokoshHarness = {
|
|||||||
assertA21,
|
assertA21,
|
||||||
assertA22,
|
assertA22,
|
||||||
assertA23,
|
assertA23,
|
||||||
|
assertA24,
|
||||||
|
assertA25,
|
||||||
getManifestVersion,
|
getManifestVersion,
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
if (statusEl !== null) {
|
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 {};
|
export {};
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ import {
|
|||||||
driveA22,
|
driveA22,
|
||||||
// Plan 01-14 — picker-narrowing constraint
|
// Plan 01-14 — picker-narrowing constraint
|
||||||
driveA23,
|
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,
|
getManifestVersion,
|
||||||
} from './lib/harness-page-driver';
|
} from './lib/harness-page-driver';
|
||||||
import {
|
import {
|
||||||
@@ -256,8 +260,8 @@ async function assertA0_GrepGate(): Promise<{
|
|||||||
* @returns Process exit code: 0 on 15/15 GREEN, 1 on any failure.
|
* @returns Process exit code: 0 on 15/15 GREEN, 1 on any failure.
|
||||||
*/
|
*/
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
process.stdout.write('\nMokosh Plan 01-13 + 01-14 — UAT harness orchestrator\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, A23)\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');
|
process.stdout.write('='.repeat(72) + '\n');
|
||||||
|
|
||||||
// A0 pre-flight (no Chrome launch needed; runs against built dist/).
|
// A0 pre-flight (no Chrome launch needed; runs against built dist/).
|
||||||
@@ -311,6 +315,10 @@ async function main(): Promise<number> {
|
|||||||
(page) => driveA12(page, handles.downloadsDir);
|
(page) => driveA12(page, handles.downloadsDir);
|
||||||
const driveA13Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
const driveA13Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
|
||||||
(page) => driveA13(page, handles.downloadsDir, expectedManifestVersion);
|
(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<AssertionRecord> =
|
||||||
|
(page) => driveA25(page, handles.downloadsDir);
|
||||||
|
|
||||||
const drivers: ReadonlyArray<{
|
const drivers: ReadonlyArray<{
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -373,6 +381,23 @@ async function main(): Promise<number> {
|
|||||||
// Chrome ≥ 119 picker-narrowing semantics). Independent of A14 —
|
// Chrome ≥ 119 picker-narrowing semantics). Independent of A14 —
|
||||||
// no new getDisplayMedia call, no new state change.
|
// no new getDisplayMedia call, no new state change.
|
||||||
{ name: 'A23', drive: driveA23 },
|
{ 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 };
|
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };
|
||||||
|
|||||||
@@ -1178,6 +1178,160 @@ export async function driveA23(page: Page): Promise<AssertionRecord> {
|
|||||||
}) as AssertionRecord;
|
}) 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<AssertionRecord> {
|
||||||
|
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<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
|
// Note (Wave 3D): the AssertionWithBytes interface is retained at the
|
||||||
// top of this file as a public export — but Wave 3D's drivers no
|
// 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
|
// longer use it (the host side now does all bytes-handling internally
|
||||||
|
|||||||
Reference in New Issue
Block a user