feat(01-13): wave-3B — A5+A6+A7 GREEN + Bug B canonical regression rewind

Wave 3B lands the A5 (SAVE_ARCHIVE → zip on disk) and A7 (genuine
RECORDING_ERROR → ERR + recovery notification) assertions, completing
8/14 of the orchestrator's GREEN floor (A0+A1+A2+A3+A4+A5+A6+A7).
Bails at A8 (Wave 3C scope).

Changes per file:

  tests/uat/extension-page-harness.ts
    - assertA5: 11s settle (>= SEGMENT_DURATION_MS so first rotation
      lands a segment) + send SAVE_ARCHIVE + assert resp.success=true.
      Page-side only checks SW handler ack; host-side driver verifies
      disk-side outcome (zip presence + size floor).
    - assertA7: setupFreshRecording helper (A6 tears down; A7 needs
      REC state) → snapshot notif count → send RECORDING_ERROR with
      a non-Bug-B error code ('codec-unsupported') → 200ms settle →
      assert badge='ERR' + popup endsWith popup.html + notif delta=1
      + set-membership for 'mokosh-recovery-*' prefix.
    - setupFreshRecording: shared helper for A7 + future assertions
      that need a fresh REC state after a teardown.

  tests/uat/lib/harness-page-driver.ts
    - driveA5: page.evaluate(assertA5) THEN host-side fs polling for
      *.zip in handles.downloadsDir. The CDP Browser.setDownloadBehavior
      override renames the file to download.zip (data: URL filename
      gap), so we accept any *.zip suffix. Merges page-side check +
      host-side checks into a single AssertionRecord. Signature now
      takes downloadsDir as a second arg.
    - driveA7: standard page.evaluate wrapper (no host-side work).

  tests/uat/harness.test.ts
    - Wraps driveA5 in a closure that captures handles.downloadsDir.
    - Reordered: launchHarnessBrowser MUST run before driver list so
      the closure can read handles without a TDZ trap.

  tests/uat/lib/launch.ts
    - Victim page switched from about:blank to a file:// URL backed by
      a tmp HTML file in downloadsDir. About:blank breaks A5 because
      chrome.tabs.captureVisibleTab needs <all_urls> permission which
      matches http/https/file/ftp but NOT about: or data: URLs. The
      stub HTML satisfies <all_urls> + provides a real .url for the
      production saveArchive's chrome.tabs.query.

  src/test-hooks/offscreen-hooks.ts (test-only — tree-shaken from prod)
    - installFakeDisplayMedia: mintStream() helper called per
      fakeGetDisplayMedia invocation; each call mints a FRESH
      MediaStream from the persistent canvas. Real getDisplayMedia
      returns a new stream per call — fake now matches. Required for
      A7's setupFreshRecording where the previous recording's stream
      tracks were stopped by A6's onUserStoppedSharing teardown.
    - Added 33ms setInterval-driven drawFrame() alongside the
      existing requestAnimationFrame loop. RAF can throttle in
      headless Chrome on offscreen documents (page-visibility
      heuristics produce 0 fps), which yields zero-byte
      MediaRecorder segments that crash ts-ebml's VINT decode in
      webm-remux.extractFramesFromSegment with "Unrepresentable
      length: Infinity". The setInterval is redundant when RAF fires
      at full rate; it's a safety net for the headless-MV3 corner.

Bug B regression-catch demo (success_criteria #3 — MANDATORY per plan):

Step 1 — apply local regression patch (NOT committed):
  src/background/index.ts:792  setIdleMode() → setErrorMode()

Step 2 — npm run build:test && npm run test:uat RED snippet:

  A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: FAIL
    [PASS] SETUP: badge becomes REC after start
    [FAIL] A6.1: badge text is '' (NOT 'ERR') after user-stop
           expected: ""
           actual:   "ERR"
    [FAIL] A6.2: popup is '' (NOT manifest default) after user-stop
           expected: ""
           actual:   "chrome-extension://<id>/src/popup/index.html"
    [PASS] A6.3: NO recovery notification fired (count delta === 0)
    [PASS] A6.4: isRecording=false (via badge proxy)

  UAT harness: 6/14 assertions passed (bailed: A6 failed; see above)

Step 3 — revert local patch (git checkout -- src/background/index.ts).

Step 4 — npm run build:test && npm run test:uat GREEN snippet:

  A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: PASS
    [PASS] SETUP: badge becomes REC after start
    [PASS] A6.1: badge text is '' (NOT 'ERR') after user-stop
    [PASS] A6.2: popup is '' (NOT manifest default) after user-stop
    [PASS] A6.3: NO recovery notification fired (count delta === 0)
    [PASS] A6.4: isRecording=false (via badge proxy)

  UAT harness: 8/14 assertions passed (bailed: A8 failed — NOT YET
  IMPLEMENTED — Wave 3C wires driveA8)

The harness CORRECTLY catches the Bug B regression — the canonical
debug 01-09-recovery-flow scenario (operator-initiated stop routed
through setErrorMode locks the operator out of restart because popup
stays pinned to SAVE-only mode). Bug B is now CI-callable end-to-end.

vitest 93/93 GREEN throughout (unit-test layer unaffected). Tier-1
grep gate GREEN (9 forbidden hook strings: 0 occurrences in dist/).
npm run build exit 0; npx tsc --noEmit exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 17:01:06 +02:00
parent 1b67b1c1d3
commit 6a77967b6c
5 changed files with 609 additions and 65 deletions

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 {};