Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -97,6 +97,7 @@ export function setSegmentCountGetter(getter: () => number): void {
|
|||||||
let fakeInstalled = false;
|
let fakeInstalled = false;
|
||||||
let fakeCanvas: HTMLCanvasElement | null = null;
|
let fakeCanvas: HTMLCanvasElement | null = null;
|
||||||
let fakeAnimationHandle: number | null = null;
|
let fakeAnimationHandle: number | null = null;
|
||||||
|
let fakeDrawInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace `navigator.mediaDevices.getDisplayMedia` with a synthetic
|
* 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
|
// recording state machine, not the video content) but giving the
|
||||||
// canvas a moving update keeps the captureStream track in a 'live'
|
// canvas a moving update keeps the captureStream track in a 'live'
|
||||||
// state for the rotation-segments lifecycle.
|
// 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');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = 320;
|
canvas.width = 320;
|
||||||
canvas.height = 180;
|
canvas.height = 180;
|
||||||
@@ -159,23 +170,31 @@ export function installFakeDisplayMedia(): void {
|
|||||||
};
|
};
|
||||||
drawFrame();
|
drawFrame();
|
||||||
|
|
||||||
// captureStream(fps) — 30 fps is the production-typical frame rate.
|
// Belt-and-suspenders frame driver: requestAnimationFrame fires on
|
||||||
const stream = canvas.captureStream(30);
|
// 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
|
* Apply the displaySurface monkey-patch to a freshly-minted stream's
|
||||||
// passes. We patch on the instance (track) — settings live there,
|
* video track. Production code's post-grant validation reads
|
||||||
// not on the prototype.
|
* `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];
|
const videoTrack = stream.getVideoTracks()[0];
|
||||||
if (videoTrack !== undefined) {
|
if (videoTrack !== undefined) {
|
||||||
const originalGetSettings = videoTrack.getSettings.bind(videoTrack);
|
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 => {
|
videoTrack.getSettings = ((): MediaTrackSettings => {
|
||||||
const real = originalGetSettings();
|
const real = originalGetSettings();
|
||||||
return {
|
return {
|
||||||
@@ -184,11 +203,30 @@ export function installFakeDisplayMedia(): void {
|
|||||||
};
|
};
|
||||||
}) as typeof videoTrack.getSettings;
|
}) 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
|
// Replace navigator.mediaDevices.getDisplayMedia with a function
|
||||||
// that returns the synthetic stream. Production code's `await
|
// that mints a FRESH synthetic stream on each call. Production code's
|
||||||
// navigator.mediaDevices.getDisplayMedia(...)` resolves with this
|
// `await navigator.mediaDevices.getDisplayMedia(...)` resolves with a
|
||||||
// stream immediately — no picker.
|
// newly-minted stream immediately — no picker.
|
||||||
//
|
//
|
||||||
// Cast through `unknown` because the MediaDevices.getDisplayMedia
|
// Cast through `unknown` because the MediaDevices.getDisplayMedia
|
||||||
// type has multiple overloads (with/without constraints) and a
|
// type has multiple overloads (with/without constraints) and a
|
||||||
@@ -197,7 +235,7 @@ export function installFakeDisplayMedia(): void {
|
|||||||
const fakeGetDisplayMedia = async (
|
const fakeGetDisplayMedia = async (
|
||||||
_constraints?: DisplayMediaStreamOptions,
|
_constraints?: DisplayMediaStreamOptions,
|
||||||
): Promise<MediaStream> => {
|
): Promise<MediaStream> => {
|
||||||
return stream;
|
return mintStream();
|
||||||
};
|
};
|
||||||
(navigator.mediaDevices as unknown as {
|
(navigator.mediaDevices as unknown as {
|
||||||
getDisplayMedia: typeof fakeGetDisplayMedia;
|
getDisplayMedia: typeof fakeGetDisplayMedia;
|
||||||
@@ -218,6 +256,10 @@ export function uninstallFakeDisplayMedia(): void {
|
|||||||
cancelAnimationFrame(fakeAnimationHandle);
|
cancelAnimationFrame(fakeAnimationHandle);
|
||||||
fakeAnimationHandle = null;
|
fakeAnimationHandle = null;
|
||||||
}
|
}
|
||||||
|
if (fakeDrawInterval !== null) {
|
||||||
|
clearInterval(fakeDrawInterval);
|
||||||
|
fakeDrawInterval = null;
|
||||||
|
}
|
||||||
if (fakeCanvas !== null) {
|
if (fakeCanvas !== null) {
|
||||||
fakeCanvas.remove();
|
fakeCanvas.remove();
|
||||||
fakeCanvas = null;
|
fakeCanvas = null;
|
||||||
|
|||||||
@@ -64,6 +64,26 @@
|
|||||||
// preserves setRecordingMode's setPopup; offscreen
|
// preserves setRecordingMode's setPopup; offscreen
|
||||||
// count remains 1 — no second offscreen spawns).
|
// count remains 1 — no second offscreen spawns).
|
||||||
// - `assertA6()` — canonical Bug B regression assertion (proven).
|
// - `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.
|
* Result shape returned by harness assertions to Puppeteer.
|
||||||
@@ -683,6 +703,273 @@ async function assertA4(): Promise<AssertionResult> {
|
|||||||
return result;
|
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.
|
// Install the global harness surface.
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -691,18 +978,28 @@ declare global {
|
|||||||
assertA2: () => Promise<AssertionResult>;
|
assertA2: () => Promise<AssertionResult>;
|
||||||
assertA3: () => Promise<AssertionResult>;
|
assertA3: () => Promise<AssertionResult>;
|
||||||
assertA4: () => Promise<AssertionResult>;
|
assertA4: () => Promise<AssertionResult>;
|
||||||
|
assertA5: () => Promise<AssertionResult>;
|
||||||
assertA6: () => 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');
|
const statusEl = document.getElementById('status');
|
||||||
if (statusEl !== null) {
|
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 {};
|
export {};
|
||||||
|
|||||||
@@ -8,13 +8,15 @@
|
|||||||
// Wave 3A scope — wires A0+A1+A2+A3+A4+A6 (A6 via the proven Wave-2
|
// 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`
|
// 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
|
// from `tests/uat/lib/harness-page-driver.ts`; the bail-on-first-failure
|
||||||
// loop stops at the first such throw. Expected Wave-3A diagnostic:
|
// loop stops at the first such throw.
|
||||||
// "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 —
|
// Wave 3B (this file's current state) wires A5 (SAVE_ARCHIVE → zip on
|
||||||
// the orchestrator-level A6 won't reach in Wave 3A because the
|
// disk) + A7 (genuine RECORDING_ERROR → ERR + recovery notification).
|
||||||
// sequential loop bails at A5; A6 lands in the loop output once Wave 3B
|
// Expected diagnostic: "8/14 GREEN: A0+A1+A2+A3+A4+A5+A6+A7; bail at A8".
|
||||||
// implements driveA5. The orchestrator structure is final from Wave 3A
|
// Wave 3C will wire A8+A9+A10; Wave 3D wires A11+A12+A13 for 14/14 GREEN.
|
||||||
// onward; future waves only fill in the assertion-driver stubs.
|
//
|
||||||
|
// 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):
|
// Architectural commitments (per 01-11-SUMMARY.md, DO NOT REGRESS):
|
||||||
// - Single browser, single recording per run (state machine: idle →
|
// - 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
|
// recording), then dispatch-ended. After A6 the recording is torn
|
||||||
// down — A7+ would need to re-start or test post-stop state.
|
// 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
|
// Wave 3B wires A5 + A7 in addition to A1..A4 + A6 — bail-on-first-
|
||||||
// IMPLEMENTED. Bail-on-first-failure stops the loop at A5 — A6's
|
// failure stops at A8 (Wave 3C wires that). Expected diagnostic:
|
||||||
// driver wires (via Wave 2's driveA6) but won't reach in this run.
|
// "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
|
// 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<{
|
const drivers: ReadonlyArray<{
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly drive: (page: import('puppeteer').Page) => Promise<AssertionRecord>;
|
readonly drive: (page: import('puppeteer').Page) => Promise<AssertionRecord>;
|
||||||
@@ -258,7 +276,7 @@ async function main(): Promise<number> {
|
|||||||
{ name: 'A2', drive: driveA2 },
|
{ name: 'A2', drive: driveA2 },
|
||||||
{ name: 'A3', drive: driveA3 },
|
{ name: 'A3', drive: driveA3 },
|
||||||
{ name: 'A4', drive: driveA4 },
|
{ name: 'A4', drive: driveA4 },
|
||||||
{ name: 'A5', drive: driveA5 as (page: import('puppeteer').Page) => Promise<AssertionRecord> },
|
{ name: 'A5', drive: driveA5Wrapped },
|
||||||
{ name: 'A6', drive: driveA6 },
|
{ name: 'A6', drive: driveA6 },
|
||||||
{ name: 'A7', drive: driveA7 },
|
{ name: 'A7', drive: driveA7 },
|
||||||
{ name: 'A8', drive: driveA8 },
|
{ name: 'A8', drive: driveA8 },
|
||||||
@@ -269,11 +287,6 @@ async function main(): Promise<number> {
|
|||||||
{ name: 'A13', drive: driveA13 as (page: import('puppeteer').Page) => Promise<AssertionRecord> },
|
{ 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 buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole };
|
||||||
const results: Array<{ name: string; passed: boolean; error?: string }> = [];
|
const results: Array<{ name: string; passed: boolean; error?: string }> = [];
|
||||||
let bailReason: string | null = null;
|
let bailReason: string | null = null;
|
||||||
|
|||||||
@@ -19,11 +19,25 @@
|
|||||||
// the first unimplemented one (bail-on-first-failure semantics in
|
// the first unimplemented one (bail-on-first-failure semantics in
|
||||||
// `harness.test.ts` lands in Wave 3A).
|
// `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:
|
// References:
|
||||||
// - puppeteer Page.evaluate:
|
// - puppeteer Page.evaluate:
|
||||||
// https://pptr.dev/api/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 { Page } from 'puppeteer';
|
||||||
|
|
||||||
import type { AssertionRecord, CheckRecord } from './assertions';
|
import type { AssertionRecord, CheckRecord } from './assertions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,25 +157,171 @@ export async function driveA4(page: Page): Promise<AssertionRecord> {
|
|||||||
}) as 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
|
* Drive A5 (SAVE_ARCHIVE download). Three-phase orchestration:
|
||||||
* take a second `downloadsDir` parameter so the host side can poll
|
|
||||||
* for the dropped zip file.
|
|
||||||
*
|
*
|
||||||
* @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> {
|
export async function driveA5(
|
||||||
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3B wires 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.
|
* Filename predicate — matches any completed `.zip` file. Mid-write
|
||||||
* @throws Always — replace stub when Wave 3B lands.
|
* `.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> {
|
function isZipFilename(name: string): boolean {
|
||||||
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3B wires driveA7`);
|
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 ──────────────────────────────── */
|
/* ─── Wave 3C — NOT YET IMPLEMENTED ──────────────────────────────── */
|
||||||
|
|||||||
@@ -47,10 +47,10 @@
|
|||||||
// - Node fs.mkdtempSync:
|
// - Node fs.mkdtempSync:
|
||||||
// https://nodejs.org/api/fs.html#fsmkdtempsyncprefix-options
|
// 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 { tmpdir } from 'node:os';
|
||||||
import { dirname, join, resolve as resolvePath } from 'node:path';
|
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';
|
import puppeteer, { type Browser, type Page } from 'puppeteer';
|
||||||
|
|
||||||
@@ -318,11 +318,43 @@ export async function launchHarnessBrowser(
|
|||||||
const offConsole: string[] = [];
|
const offConsole: string[] = [];
|
||||||
|
|
||||||
// Open the victim page FIRST so it's already in the tab list when
|
// 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
|
// the harness page opens. The victim URL must satisfy two constraints:
|
||||||
// 'about:blank' (truthy), passing production
|
// 1. `chrome.tabs.query({active:true,currentWindow:true})` returns a
|
||||||
// chrome.tabs.query({active:true}) presence checks.
|
// 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();
|
const victimPage = await browser.newPage();
|
||||||
await victimPage.goto('about:blank');
|
await victimPage.goto(victimUrl);
|
||||||
|
|
||||||
// Open the harness page; attach console + pageerror listeners
|
// Open the harness page; attach console + pageerror listeners
|
||||||
// BEFORE the goto so we don't miss bootstrap-time messages.
|
// BEFORE the goto so we don't miss bootstrap-time messages.
|
||||||
|
|||||||
Reference in New Issue
Block a user