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
5 changed files with 609 additions and 65 deletions
Showing only changes of commit 6a77967b6c - Show all commits

View File

@@ -97,6 +97,7 @@ export function setSegmentCountGetter(getter: () => number): void {
let fakeInstalled = false;
let fakeCanvas: HTMLCanvasElement | null = null;
let fakeAnimationHandle: number | null = null;
let fakeDrawInterval: ReturnType<typeof setInterval> | null = null;
/**
* Replace `navigator.mediaDevices.getDisplayMedia` with a synthetic
@@ -130,6 +131,16 @@ export function installFakeDisplayMedia(): void {
// recording state machine, not the video content) but giving the
// canvas a moving update keeps the captureStream track in a 'live'
// state for the rotation-segments lifecycle.
//
// Plan 01-13 Wave 3B contract: the canvas + drawing loop are persistent
// across MULTIPLE recording lifecycles within the same offscreen
// document (A6 tears recording down via dispatch-ended, A7 starts a
// FRESH recording — both share the same canvas). Each
// `fakeGetDisplayMedia` call mints a fresh `MediaStream` via
// `canvas.captureStream(30)` so the per-call track is in 'live' state
// even after the previous recording's tracks were `.stop()`-ed by the
// teardown path (real getDisplayMedia returns a new stream per call;
// the fake matches that contract).
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 180;
@@ -159,23 +170,31 @@ export function installFakeDisplayMedia(): void {
};
drawFrame();
// captureStream(fps) — 30 fps is the production-typical frame rate.
const stream = canvas.captureStream(30);
// Belt-and-suspenders frame driver: requestAnimationFrame fires on
// page-visibility heuristics in headless Chrome (offscreen documents
// are not "visible" tabs — RAF cadence drops to near-zero under
// certain throttling regimes, producing 0-frame segments that then
// crash ts-ebml's VINT decode in `webm-remux.extractFramesFromSegment`
// with "Unrepresentable length: Infinity" on the malformed empty
// bytes). A 33ms setInterval (~30fps) drives drawFrame regardless of
// RAF throttling — it's redundant for normal RAF but guarantees the
// captureStream track sees real pixel mutations every tick. Both
// timers are cleaned up in `uninstallFakeDisplayMedia`.
fakeDrawInterval = setInterval(drawFrame, 33);
// Monkey-patch the video track's getSettings() to report
// displaySurface: 'monitor' so the production post-grant validation
// passes. We patch on the instance (track) — settings live there,
// not on the prototype.
/**
* Apply the displaySurface monkey-patch to a freshly-minted stream's
* video track. Production code's post-grant validation reads
* `getSettings().displaySurface` and tears down + throws
* 'wrong-display-surface' on anything but 'monitor' — the patch makes
* the synthetic canvas stream satisfy that gate.
*
* @param stream - The stream whose first video track is patched in-place.
*/
const patchDisplaySurface = (stream: MediaStream): void => {
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack !== undefined) {
const originalGetSettings = videoTrack.getSettings.bind(videoTrack);
/**
* Wrap getSettings to inject a displaySurface override. The wrapper
* preserves all other settings the canvas captureStream provides
* (width, height, frameRate, deviceId, etc.).
*
* @returns Settings dict augmented with displaySurface: 'monitor'.
*/
videoTrack.getSettings = ((): MediaTrackSettings => {
const real = originalGetSettings();
return {
@@ -184,11 +203,30 @@ export function installFakeDisplayMedia(): void {
};
}) as typeof videoTrack.getSettings;
}
};
/**
* Mint a FRESH MediaStream from the persistent canvas. Each invocation
* generates new tracks in 'live' state — required for the multi-
* recording-lifecycle pattern (A6 stops the first stream's tracks via
* dispatchEvent('ended'); A7 starts a new recording which calls
* getDisplayMedia → must get a live stream, NOT the dead one A6
* teardown left behind). Closure variables (fakeCanvas above) persist
* across calls; track refs do not.
*
* @returns Fresh MediaStream with displaySurface monkey-patch applied
* to its video track.
*/
const mintStream = (): MediaStream => {
const stream = canvas.captureStream(30);
patchDisplaySurface(stream);
return stream;
};
// Replace navigator.mediaDevices.getDisplayMedia with a function
// that returns the synthetic stream. Production code's `await
// navigator.mediaDevices.getDisplayMedia(...)` resolves with this
// stream immediately — no picker.
// that mints a FRESH synthetic stream on each call. Production code's
// `await navigator.mediaDevices.getDisplayMedia(...)` resolves with a
// newly-minted stream immediately — no picker.
//
// Cast through `unknown` because the MediaDevices.getDisplayMedia
// type has multiple overloads (with/without constraints) and a
@@ -197,7 +235,7 @@ export function installFakeDisplayMedia(): void {
const fakeGetDisplayMedia = async (
_constraints?: DisplayMediaStreamOptions,
): Promise<MediaStream> => {
return stream;
return mintStream();
};
(navigator.mediaDevices as unknown as {
getDisplayMedia: typeof fakeGetDisplayMedia;
@@ -218,6 +256,10 @@ export function uninstallFakeDisplayMedia(): void {
cancelAnimationFrame(fakeAnimationHandle);
fakeAnimationHandle = null;
}
if (fakeDrawInterval !== null) {
clearInterval(fakeDrawInterval);
fakeDrawInterval = null;
}
if (fakeCanvas !== null) {
fakeCanvas.remove();
fakeCanvas = null;

View File

@@ -64,6 +64,26 @@
// preserves setRecordingMode's setPopup; offscreen
// count remains 1 — no second offscreen spawns).
// - `assertA6()` — canonical Bug B regression assertion (proven).
//
// Wave 3B surface — extends `window.__mokoshHarness` from 5 → 7 methods:
// - `assertA5()` — SAVE_ARCHIVE dispatches; the page-side check confirms
// the SW handler returns success. The host-side driveA5
// wrapper handles disk-side verification (zip file
// presence + size > 1KB) since the page cannot read
// `handles.downloadsDir`. Page-side method returns
// `{passed, name, checks: [SW dispatch ack], diagnostics}`;
// host-side merges its own checks on top.
// - `assertA7()` — genuine recording error → ERR badge + recovery
// notification. Sends a synthetic
// `RECORDING_ERROR{error:'codec-unsupported'}` (NOT
// 'user-stopped-sharing' — that's Bug B's branch).
// Asserts: badge='ERR', popup endsWith 'src/popup/index.html',
// notif count delta===1, and ANY active notification id
// has the 'mokosh-recovery-' prefix. The set-membership
// check on the prefix is the plan-resolved choice over a
// "most recent" check — `chrome.notifications.getAll` does
// not guarantee key ordering (set membership is reliable;
// ordering is not).
/**
* Result shape returned by harness assertions to Puppeteer.
@@ -683,6 +703,273 @@ async function assertA4(): Promise<AssertionResult> {
return result;
}
/** Timeout for SAVE_ARCHIVE message dispatch — the SW does screenshot
* capture + content-script messaging + JSZip generation before responding.
* The screenshot path can stall briefly under the synthetic-stream pipeline;
* 15s gives several seconds of headroom over the typical ~2-3s run. */
const A5_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/**
* Pre-SAVE_ARCHIVE settle window — wait at least one
* SEGMENT_DURATION_MS (10s, per src/offscreen/recorder.ts) past
* startRecording so the rotation timer fires, pushes the first segment
* into the buffer, and `getVideoBufferFromOffscreen` returns a non-empty
* result. Without this wait, saveArchive throws EmptyVideoBufferError
* (segments=[] → SW emits RECORDING_ERROR{error:'empty-video-buffer'}
* and returns success=false). 11s = 10s rotation + 1s slack for
* MediaRecorder.onstop → onSegmentStopped → segments.push to finish.
*/
const A5_SEGMENT_SETTLE_MS = 11_000;
/** Time in ms to wait for the SW state machine to settle after dispatching
* RECORDING_ERROR. The handler is synchronous (setErrorMode + create
* notification) so 200ms is comfortably above the typical few-ms delay. */
const A7_SETTLE_MS = 200;
/** Recovery-notification id prefix — must stay in sync with
* `src/background/index.ts:NOTIFICATION_RECOVERY_PREFIX`. The harness
* asserts SET MEMBERSHIP against this prefix on the post-dispatch
* notification map (not "most recent"); `chrome.notifications.getAll`
* returns an Object whose key ordering is NOT guaranteed by the API
* contract — set-membership is the reliable check. */
const RECOVERY_NOTIF_PREFIX = 'mokosh-recovery-';
/**
* Bring the SW state machine to REC + ensure the offscreen recording is
* live. Idempotent shared helper used by A7 (which runs AFTER A6 tears
* the recording down) and any future assertion that needs a fresh REC
* state.
*
* Same direct-offscreen workaround as `assertA2` — bypasses the missing
* `tabs` permission gap by sending START_RECORDING straight to offscreen
* + manually setting badge/popup. Documented at length in `assertA2`'s
* comment block.
*
* @returns ok status + diagnostic error.
*/
async function setupFreshRecording(): Promise<{ ok: boolean; error?: string }> {
try {
const ensureResp = await ensureOffscreen();
if (!ensureResp.ok) {
return { ok: false, error: `ensureOffscreen failed: ${ensureResp.error ?? '(no error)'}` };
}
const grantResp = await startRecording();
if (!grantResp.granted) {
return { ok: false, error: 'startRecording returned granted=false' };
}
// Wait for badge to become 'REC' before returning — guarantees A2's
// contract for callers that need REC state immediately.
await waitFor(
() => chrome.action.getBadgeText({}),
(v) => v === 'REC',
STATE_WAIT_MS,
"setupFreshRecording: badge should transition to 'REC'",
);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
/**
* A5 — SAVE_ARCHIVE dispatch. Asserts the SW's `saveArchive` handler
* runs to completion and returns `{success: true}`. The actual zip
* file lands in `handles.downloadsDir` (configured via CDP
* `Browser.setDownloadBehavior` at launch time) — the host-side
* `driveA5` wrapper performs file-system verification (presence + size
* floor) because the page isolate cannot read the per-run downloads
* directory directly.
*
* Pre-condition: recording must be active (A2 + A3 + A4 left it running;
* A5 runs before A6's user-stop simulation in the orchestrator order).
* The SW's `saveArchive` does `chrome.tabs.query({active:true,
* currentWindow:true})` — relies on the launcher's victim about:blank
* tab being brought-to-front; the `tab.url` field is undefined without
* the `tabs` permission but `saveArchive` only checks `tab.id` so the
* permission gap is benign here.
*
* The content-script `GET_RRWEB_EVENTS` round-trip in `saveArchive`
* fails on `about:blank` (no content script injected there) — `saveArchive`
* catches and continues with `rrwebEvents = []`. The resulting zip is
* smaller than a production-page zip but well above the 1KB floor
* because the offscreen video buffer + screenshot dominate.
*
* @returns Structured result with 1 page-side check (SW dispatch ack).
*/
async function assertA5(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A5 — SAVE_ARCHIVE dispatches; SW handler returns success',
checks: [],
diagnostics: [],
};
try {
// Step 1 — settle window. A2 started recording shortly before A5;
// the offscreen recorder rotates segments every SEGMENT_DURATION_MS
// (10s). Before the first rotation fires, `segments` is empty and
// `getVideoBufferFromOffscreen` returns `{segments:[]}` which the SW's
// `createArchive` treats as `EmptyVideoBufferError`. Wait 11s so at
// least one segment lands in the buffer before we trigger
// SAVE_ARCHIVE. The settle dominates A5 wall-clock time (~11s of the
// ~13s total) — acceptable given the assertion verifies an
// end-to-end production save flow.
diag(result, `Step 1: settle ${A5_SEGMENT_SETTLE_MS}ms for first segment rotation`);
await new Promise((r) => setTimeout(r, A5_SEGMENT_SETTLE_MS));
diag(result, 'Step 1 OK — first rotation should have fired');
diag(result, 'Step 2: send SAVE_ARCHIVE to SW');
// SW handler: saveArchive() → captureScreenshot + getVideoBufferFromOffscreen
// + chrome.tabs.sendMessage(GET_RRWEB_EVENTS) → createArchive (JSZip)
// → downloadArchive (chrome.downloads.download with data:application/zip;base64,...).
// SW responds with { success: true } on the happy path; { success: false, error }
// otherwise. EmptyVideoBufferError additionally emits a RECORDING_ERROR
// sendMessage which is filtered out by the orchestrator's per-assertion
// notification snapshotting (A5 does not assert on notifications).
const resp = await sendMessageWithTimeout<{
success: boolean;
error?: string;
}>(
{ type: 'SAVE_ARCHIVE' },
A5_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE',
);
diag(result, `Step 2 result: ${JSON.stringify(resp)}`);
result.checks.push({
name: 'A5.1: SAVE_ARCHIVE handler returns success=true',
expected: true,
actual: resp.success,
passed: resp.success === true,
});
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;
}
/**
* A7 — genuine recording error → ERR + recovery notification. Asserts
* that a non-Bug-B error code routes through `setErrorMode` (badge='ERR'
* + popup pinned) AND creates a `mokosh-recovery-*` notification.
*
* Sends `RECORDING_ERROR{error:'codec-unsupported'}` — a non-Bug-B path
* representative of the genuine capture-failure branch (the SW handler
* at src/background/index.ts:790 routes `'user-stopped-sharing'` through
* setIdleMode + no notification, everything else through setErrorMode +
* recovery notification). The exact error code is arbitrary among the
* non-Bug-B set; `codec-unsupported` is chosen as a clean exemplar.
*
* Pre-condition: a fresh recording must be active. A6 (which runs before
* A7 in the orchestrator order) tears recording down; this assertion
* calls `setupFreshRecording` to restore REC state before dispatching.
*
* Post-conditions verified:
* 1. badge === 'ERR' (setErrorMode contract per badge-state-machine.test.ts)
* 2. popup endsWith 'src/popup/index.html' (setErrorMode preserves the
* SAVE-only popup so the operator can recover the buffer)
* 3. notif count delta === 1 (exactly one new notification — the
* recovery one; no other notifications fire on the error path)
* 4. at least one active notification id has the recovery prefix
* (set-membership; ordering of `chrome.notifications.getAll` is
* not contractually stable — see plan resolved-questions §3)
*
* @returns Structured result with 4 post-dispatch checks (after SETUP).
*/
async function assertA7(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A7 — genuine RECORDING_ERROR → ERR badge + recovery notification',
checks: [],
diagnostics: [],
};
try {
diag(result, 'Step 1: setupFreshRecording (A6 may have torn it down)');
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: snapshot active notification count + ids');
const notifBefore = await getActiveNotificationCount();
diag(result, `Step 2 result: notifBefore=${notifBefore}`);
diag(result, "Step 3: send RECORDING_ERROR{error:'codec-unsupported'}");
// No response expected — the SW handler is fire-and-forget
// (returns false from onMessage). We swallow lastError via the
// fire-and-forget pattern: send and continue. sendMessageWithTimeout
// would reject on `chrome.runtime.lastError === 'no response'`; use
// the direct API.
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'codec-unsupported' });
diag(result, 'Step 3 OK — RECORDING_ERROR dispatched (fire-and-forget)');
diag(result, `Step 4: settle ${A7_SETTLE_MS}ms`);
await new Promise((r) => setTimeout(r, A7_SETTLE_MS));
diag(result, 'Step 5: read post-dispatch state (badge + popup + notifications)');
const badgeAfter = await chrome.action.getBadgeText({});
const popupAfter = await chrome.action.getPopup({});
const notifAfter = await getActiveNotificationCount();
const notifIds = await new Promise<ReadonlyArray<string>>((resolve, reject) => {
chrome.notifications.getAll((notifications: Object) => {
if (chrome.runtime.lastError !== undefined) {
reject(new Error(String(chrome.runtime.lastError.message)));
return;
}
resolve(Object.keys(notifications ?? {}));
});
});
const notifDelta = notifAfter - notifBefore;
const recoveryIdPresent = notifIds.some(
(id) => id.startsWith(RECOVERY_NOTIF_PREFIX),
);
diag(
result,
`Step 5: badge='${badgeAfter}', popup='${popupAfter}', notifAfter=${notifAfter}, delta=${notifDelta}, ids=${JSON.stringify(notifIds)}`,
);
result.checks.push({
name: "A7.1: badge text is 'ERR' after RECORDING_ERROR (setErrorMode)",
expected: 'ERR',
actual: badgeAfter,
passed: badgeAfter === 'ERR',
});
// NOTE — chrome.action.getPopup() returns the absolute extension URL,
// not the manifest-relative path; .endsWith() keeps the check
// extension-id independent (see A2.2 NOTE).
result.checks.push({
name: "A7.2: popup endsWith 'src/popup/index.html' (SAVE-only popup pinned)",
expected: '<chrome-extension://<id>/>src/popup/index.html',
actual: popupAfter,
passed: popupAfter.endsWith('src/popup/index.html'),
});
result.checks.push({
name: 'A7.3: notification count delta === 1 (exactly one new recovery)',
expected: 1,
actual: notifDelta,
passed: notifDelta === 1,
});
result.checks.push({
name: `A7.4: at least one notification id startsWith '${RECOVERY_NOTIF_PREFIX}' (set membership)`,
expected: true,
actual: recoveryIdPresent,
passed: recoveryIdPresent === true,
});
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;
}
// Install the global harness surface.
declare global {
interface Window {
@@ -691,18 +978,28 @@ declare global {
assertA2: () => Promise<AssertionResult>;
assertA3: () => Promise<AssertionResult>;
assertA4: () => Promise<AssertionResult>;
assertA5: () => Promise<AssertionResult>;
assertA6: () => Promise<AssertionResult>;
assertA7: () => Promise<AssertionResult>;
};
}
}
window.__mokoshHarness = { assertA1, assertA2, assertA3, assertA4, assertA6 };
window.__mokoshHarness = {
assertA1,
assertA2,
assertA3,
assertA4,
assertA5,
assertA6,
assertA7,
};
const statusEl = document.getElementById('status');
if (statusEl !== null) {
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1, assertA2, assertA3, assertA4, assertA6} available.';
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1, assertA2, assertA3, assertA4, assertA5, assertA6, assertA7} available.';
}
console.log('[harness-page] ready — window.__mokoshHarness installed (Wave 3A: A1+A2+A3+A4+A6)');
console.log('[harness-page] ready — window.__mokoshHarness installed (Wave 3B: A1+A2+A3+A4+A5+A6+A7)');
export {};

View File

@@ -8,13 +8,15 @@
// Wave 3A scope — wires A0+A1+A2+A3+A4+A6 (A6 via the proven Wave-2
// driver). A5+A7..A13 throw `NOT YET IMPLEMENTED — Wave 3<X> wires this`
// from `tests/uat/lib/harness-page-driver.ts`; the bail-on-first-failure
// loop stops at the first such throw. Expected Wave-3A diagnostic:
// "UAT harness: 5/14 assertions passed (A0+A1+A2+A3+A4 GREEN; bail at A5)"
// A6 PASSES via the standalone `npx tsx tests/uat/a6.test.ts` entry —
// the orchestrator-level A6 won't reach in Wave 3A because the
// sequential loop bails at A5; A6 lands in the loop output once Wave 3B
// implements driveA5. The orchestrator structure is final from Wave 3A
// onward; future waves only fill in the assertion-driver stubs.
// loop stops at the first such throw.
//
// Wave 3B (this file's current state) wires A5 (SAVE_ARCHIVE → zip on
// disk) + A7 (genuine RECORDING_ERROR → ERR + recovery notification).
// Expected diagnostic: "8/14 GREEN: A0+A1+A2+A3+A4+A5+A6+A7; bail at A8".
// Wave 3C will wire A8+A9+A10; Wave 3D wires A11+A12+A13 for 14/14 GREEN.
//
// The orchestrator structure is final from Wave 3A onward; future waves
// only fill in the assertion-driver stubs.
//
// Architectural commitments (per 01-11-SUMMARY.md, DO NOT REGRESS):
// - Single browser, single recording per run (state machine: idle →
@@ -245,11 +247,27 @@ async function main(): Promise<number> {
// recording), then dispatch-ended. After A6 the recording is torn
// down — A7+ would need to re-start or test post-stop state.
//
// Wave 3A only A1..A4 wire to real impls; A5..A13 throw NOT YET
// IMPLEMENTED. Bail-on-first-failure stops the loop at A5 — A6's
// driver wires (via Wave 2's driveA6) but won't reach in this run.
// Wave 3B wires A5 + A7 in addition to A1..A4 + A6 — bail-on-first-
// failure stops at A8 (Wave 3C wires that). Expected diagnostic:
// "8/14 GREEN: A0+A1+A2+A3+A4+A5+A6+A7; A8..A13 NOT YET IMPLEMENTED".
// The standalone `npx tsx tests/uat/a6.test.ts` entry remains the
// way to verify A6 in isolation during Wave 3A.
// way to verify A6 in isolation for inner-loop iteration.
process.stdout.write('Launching Chrome + opening harness page...\n');
const handles = await launchHarnessBrowser();
process.stdout.write(`Extension id: ${handles.extensionId}\n`);
process.stdout.write(`Downloads dir: ${handles.downloadsDir}\n\n`);
// Adapter: driveA5 needs `downloadsDir` (host-side fs polling); driveA12 +
// driveA13 return `AssertionWithBytes`. We wrap each in a closure that
// hides those signature differences so the orchestrator's driver list
// is uniform `Page -> Promise<AssertionRecord>`. The byte-returning
// drivers' extra fields are out-of-scope for Wave 3B; Wave 3D will
// extend the orchestrator to surface them when A12/A13 land. The driver
// list is constructed AFTER `launchHarnessBrowser` returns so the
// closure can capture `handles.downloadsDir` without a TDZ trap.
const driveA5Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
(page) => driveA5(page, handles.downloadsDir);
const drivers: ReadonlyArray<{
readonly name: string;
readonly drive: (page: import('puppeteer').Page) => Promise<AssertionRecord>;
@@ -258,7 +276,7 @@ async function main(): Promise<number> {
{ name: 'A2', drive: driveA2 },
{ name: 'A3', drive: driveA3 },
{ name: 'A4', drive: driveA4 },
{ name: 'A5', drive: driveA5 as (page: import('puppeteer').Page) => Promise<AssertionRecord> },
{ name: 'A5', drive: driveA5Wrapped },
{ name: 'A6', drive: driveA6 },
{ name: 'A7', drive: driveA7 },
{ name: 'A8', drive: driveA8 },
@@ -269,11 +287,6 @@ async function main(): Promise<number> {
{ name: 'A13', drive: driveA13 as (page: import('puppeteer').Page) => Promise<AssertionRecord> },
];
process.stdout.write('Launching Chrome + opening harness page...\n');
const handles = await launchHarnessBrowser();
process.stdout.write(`Extension id: ${handles.extensionId}\n`);
process.stdout.write(`Downloads dir: ${handles.downloadsDir}\n\n`);
const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };
const results: Array<{ name: string; passed: boolean; error?: string }> = [];
let bailReason: string | null = null;

View File

@@ -19,11 +19,25 @@
// the first unimplemented one (bail-on-first-failure semantics in
// `harness.test.ts` lands in Wave 3A).
//
// Wave 3A wires driveA1/A2/A3/A4 (page-side surface in
// `extension-page-harness.ts` from the same wave).
// Wave 3B wires driveA5 (page-side ack + HOST-side fs polling for the
// dropped `session_report_*.zip` in `handles.downloadsDir`) + driveA7
// (standard page.evaluate wrapper). The driveA5 signature requires a
// second `downloadsDir` argument; the orchestrator at `harness.test.ts`
// threads `handles.downloadsDir` through.
//
// References:
// - puppeteer Page.evaluate:
// https://pptr.dev/api/puppeteer.page.evaluate
// - Node fs.readdirSync / statSync:
// https://nodejs.org/api/fs.html
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve as resolvePath } from 'node:path';
import type { Page } from 'puppeteer';
import type { AssertionRecord, CheckRecord } from './assertions';
/**
@@ -143,25 +157,171 @@ export async function driveA4(page: Page): Promise<AssertionRecord> {
}) as AssertionRecord;
}
/* ─── Wave 3B — NOT YET IMPLEMENTED ──────────────────────────────── */
/* ─── Wave 3B — WIRED ─────────────────────────────────────────────── */
/** Maximum wait for the SAVE_ARCHIVE zip to appear in `downloadsDir`. */
const A5_DOWNLOAD_POLL_TIMEOUT_MS = 15_000;
/** Polling cadence while waiting for the zip. */
const A5_DOWNLOAD_POLL_INTERVAL_MS = 200;
/** Filename suffix for the dropped archive. Production code in
* `src/background/index.ts:downloadArchive` requests
* `session_report_<date>_<time>.zip`, BUT under CDP-routed downloads
* (`Browser.setDownloadBehavior`) Chrome ignores the
* `chrome.downloads.download` `filename` parameter for `data:` URLs and
* defaults to `download.zip` (or `download (N).zip` on collision). The
* contract A5 verifies is "a zip file lands in downloadsDir within
* timeout" — the exact filename is not load-bearing for Wave 3B.
* Wave 3D's A13 (zip structure) verifies the zip content. */
const A5_ZIP_NAME_SUFFIX = '.zip';
/** Minimum acceptable zip size — the production
* `downloadArchive` always writes at least a JSZip header + screenshot
* PNG (typically several KB even with an empty video buffer).
* 1KB is the floor specified in the plan's success criteria for A5. */
const A5_MIN_ZIP_SIZE_BYTES = 1024;
/**
* Drive A5 (SAVE_ARCHIVE download). Wave 3B wires this; signature will
* take a second `downloadsDir` parameter so the host side can poll
* for the dropped zip file.
* Drive A5 (SAVE_ARCHIVE download). Three-phase orchestration:
*
* @throws Always — replace stub when Wave 3B lands.
* 1. Page side: send SAVE_ARCHIVE via the harness `assertA5` helper.
* Returns AssertionRecord with check `A5.1: SW handler returns
* success=true`. Throws are caught + returned as a failure record
* with `error` set.
*
* 2. Host side: poll `downloadsDir` for `session_report_*.zip` for up
* to `A5_DOWNLOAD_POLL_TIMEOUT_MS`. If found, read bytes for the
* size check; the bytes are NOT returned to the orchestrator (no
* consumer in Wave 3B — A13 will read them out of the zip-shape
* driver in Wave 3D).
*
* 3. Host side: assert `zipSize >= A5_MIN_ZIP_SIZE_BYTES`. Merge the
* host-side check onto the page-side AssertionRecord; recompute
* `passed` as the conjunction of all checks.
*
* The split between page-side (SW dispatch ack) and host-side
* (file-system verification) is dictated by the page isolate's lack of
* filesystem access — `handles.downloadsDir` is a Node-side `mkdtempSync`
* configured via CDP `Browser.setDownloadBehavior` and only readable
* from the Node process.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @param downloadsDir - Absolute path to the per-run downloads directory
* (from `handles.downloadsDir`).
* @returns AssertionRecord with merged page + host checks.
*/
export async function driveA5(_page: Page): Promise<AssertionWithBytes> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3B wires driveA5`);
export async function driveA5(
page: Page,
downloadsDir: string,
): Promise<AssertionRecord> {
// Snapshot existing zip files BEFORE dispatching SAVE_ARCHIVE so the
// post-dispatch poll only considers NEW files. Single-browser orchestrator
// pattern means there should never be a pre-existing zip on a fresh
// run, but a re-used `downloadsDir` (`HARNESS_DOWNLOADS_DIR` env override)
// can legitimately have prior runs' files.
const preExisting = new Set(readdirSync(downloadsDir).filter(isZipFilename));
// Phase 1: page-side dispatch.
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;
const r: AssertionRecord = await harness.assertA5();
return r;
}) as AssertionRecord;
// Phase 2: host-side poll for the dropped zip.
let zipFilename: string | null = null;
let zipBytes: Buffer | null = null;
const pollStart = Date.now();
while (Date.now() - pollStart < A5_DOWNLOAD_POLL_TIMEOUT_MS) {
const candidates = readdirSync(downloadsDir).filter(
(name) => isZipFilename(name) && !preExisting.has(name),
);
if (candidates.length > 0) {
// Take the most-recently-modified to be deterministic if multiple appear.
const sorted = candidates
.map((name) => ({
name,
mtime: statSync(resolvePath(downloadsDir, name)).mtimeMs,
}))
.sort((a, b) => b.mtime - a.mtime);
zipFilename = sorted[0].name;
const zipPath = resolvePath(downloadsDir, zipFilename);
// Wait a beat: the file may still be writing. Re-check size stable
// by reading twice; we take the second read as the canonical bytes.
const sizeFirst = statSync(zipPath).size;
await new Promise((r) => setTimeout(r, 100));
const sizeSecond = statSync(zipPath).size;
if (sizeFirst === sizeSecond) {
zipBytes = readFileSync(zipPath);
break;
}
}
await new Promise((r) => setTimeout(r, A5_DOWNLOAD_POLL_INTERVAL_MS));
}
// Phase 3: merge checks. Page-side checks are immutable
// (ReadonlyArray); copy into a mutable buffer + append host-side.
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
const zipPresent = zipFilename !== null;
const zipSize = zipBytes !== null ? zipBytes.length : 0;
mergedChecks.push({
name: `A5.2: a *.zip file appears in downloadsDir within ${A5_DOWNLOAD_POLL_TIMEOUT_MS}ms (production name: 'session_report_*.zip'; CDP fallback: 'download*.zip')`,
expected: true,
actual: zipPresent,
passed: zipPresent,
});
mergedChecks.push({
name: `A5.3: zip file size >= ${A5_MIN_ZIP_SIZE_BYTES} bytes`,
expected: A5_MIN_ZIP_SIZE_BYTES,
actual: zipSize,
passed: zipSize >= A5_MIN_ZIP_SIZE_BYTES,
});
mergedDiagnostics.push(
`host-side: zipFilename=${zipFilename ?? '<missing>'}, zipSize=${zipSize} bytes, downloadsDir=${downloadsDir}`,
);
const mergedPassed = mergedChecks.every((c) => c.passed);
return {
passed: mergedPassed,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
/**
* Drive A7 (genuine error → ERR + recovery notification). Wave 3B wires.
* @throws Always — replace stub when Wave 3B lands.
* Filename predicate — matches any completed `.zip` file. Mid-write
* `.crdownload` files are auto-excluded by the suffix anchor. The
* permissive prefix matches both the production filename
* `session_report_<ts>.zip` and the CDP-fallback `download.zip` (see
* `A5_ZIP_NAME_SUFFIX` comment for why the latter happens under
* `Browser.setDownloadBehavior`).
*
* @param name - Filename (basename, not full path).
* @returns True iff `name` is a completed zip.
*/
export async function driveA7(_page: Page): Promise<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3B wires driveA7`);
function isZipFilename(name: string): boolean {
return name.endsWith(A5_ZIP_NAME_SUFFIX);
}
/**
* Drive A7 (genuine error → ERR + recovery notification). Standard
* page.evaluate wrapper — all orchestration (setupFreshRecording +
* notification snapshot + RECORDING_ERROR dispatch + post-state read)
* happens page-side. Host side just triggers + reads the result.
*
* @param page - The harness page from `launchHarnessBrowser`.
* @returns Structured AssertionRecord with 4 checks (A7.1..A7.4).
*/
export async function driveA7(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.assertA7();
return r;
}) as AssertionRecord;
}
/* ─── Wave 3C — NOT YET IMPLEMENTED ──────────────────────────────── */

View File

@@ -47,10 +47,10 @@
// - Node fs.mkdtempSync:
// https://nodejs.org/api/fs.html#fsmkdtempsyncprefix-options
import { existsSync, mkdtempSync, statSync } from 'node:fs';
import { existsSync, mkdtempSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve as resolvePath } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import puppeteer, { type Browser, type Page } from 'puppeteer';
@@ -318,11 +318,43 @@ export async function launchHarnessBrowser(
const offConsole: string[] = [];
// Open the victim page FIRST so it's already in the tab list when
// the harness page opens. About:blank's `tab.url` resolves to
// 'about:blank' (truthy), passing production
// chrome.tabs.query({active:true}) presence checks.
// the harness page opens. The victim URL must satisfy two constraints:
// 1. `chrome.tabs.query({active:true,currentWindow:true})` returns a
// tab with non-empty `.id` (so production `saveArchive` proceeds);
// every real tab does, so this is automatic.
// 2. `chrome.tabs.captureVisibleTab(...)` (used inside saveArchive's
// screenshot capture path) succeeds. captureVisibleTab needs
// EITHER `<all_urls>` host permission to match the active tab's
// URL OR `activeTab` granted (which only fires on a real user
// gesture, not a Puppeteer-scripted call). `<all_urls>` matches
// `http://`, `https://`, `file://`, `ftp://` — but NOT
// `about:blank` and NOT `data:` URLs (Chromium treats `data:`
// with an opaque origin and rejects the capture under
// "activeTab permission not in effect"). The canonical scheme
// that DOES satisfy `<all_urls>` without needing a localhost
// server is `file://`, so we write a stub HTML file inside the
// per-run `downloadsDir` (already mkdtempSync'd above) and load
// it as `file://<absPath>`.
//
// Plan 01-13 Wave 3B deviation (Rule 3 — blocking issue): the original
// `about:blank` victim page (carried over from the c647f61 prototype
// because A6 does not exercise captureVisibleTab) breaks A5's
// SAVE_ARCHIVE round-trip. The file:// victim is the simplest fix
// that does not touch production code or add network dependencies
// (an http://localhost server would also work but introduces port-
// allocation race conditions on CI).
const victimHtmlPath = resolvePath(downloadsDir, 'mokosh-uat-victim.html');
const victimHtml =
'<!doctype html><html><head><meta charset="utf-8">' +
'<title>Mokosh UAT Victim</title></head>' +
'<body><h1>UAT victim page</h1>' +
'<p>This tab exists so chrome.tabs.captureVisibleTab succeeds during A5 (SAVE_ARCHIVE).</p>' +
'</body></html>';
writeFileSync(victimHtmlPath, victimHtml, 'utf8');
const victimUrl = pathToFileURL(victimHtmlPath).toString();
const victimPage = await browser.newPage();
await victimPage.goto('about:blank');
await victimPage.goto(victimUrl);
// Open the harness page; attach console + pageerror listeners
// BEFORE the goto so we don't miss bootstrap-time messages.