Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
3 changed files with 219 additions and 4 deletions
Showing only changes of commit 4ae73250fa - Show all commits

View File

@@ -2789,6 +2789,178 @@ 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;
}
/** /**
* 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 +3004,8 @@ 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 first)
assertA24: () => Promise<AssertionResult>;
getManifestVersion: () => Promise<string>; getManifestVersion: () => Promise<string>;
}; };
} }
@@ -2861,14 +3035,15 @@ window.__mokoshHarness = {
assertA21, assertA21,
assertA22, assertA22,
assertA23, assertA23,
assertA24,
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 Task 1: A24 + getManifestVersion)');
export {}; export {};

View File

@@ -89,6 +89,8 @@ 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,
getManifestVersion, getManifestVersion,
} from './lib/harness-page-driver'; } from './lib/harness-page-driver';
import { import {
@@ -256,8 +258,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)\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/).
@@ -373,6 +375,15 @@ 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 },
]; ];
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole }; const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };

View File

@@ -1178,6 +1178,35 @@ 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;
}
// 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