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
Showing only changes of commit 6ac23fdbd8 - Show all commits

View File

@@ -1,37 +1,39 @@
// tests/background/save-archive-stops-recording.test.ts // tests/background/save-archive-does-not-stop-recording.test.ts
// //
// Plan 01-13 Task 9 operator UAT closure — debug session // Plan 01-09 Amendment 3 (2026-05-19) — charter reversal of the
// `.planning/debug/01-09-save-stops-recording.md`. // prior `save-archive-stops-recording.test.ts` (renamed via `git mv`
// from this same file; history preserved). Debug session record:
// `.planning/debug/resolved/01-09-save-does-not-stop-recording.md`.
// //
// Contract: SAVE_ARCHIVE is one-shot per SPEC intent (`Тз расширение // Contract (REVERSED 2026-05-19): SAVE_ARCHIVE creates a new zip but
// фаза1.md`: "one click MUST produce a self-contained archive"). After // does NOT stop the recorder. The recording is continuous — the only
// the SW's saveArchive() completes (zip created + chrome.downloads.download // termination paths are:
// dispatched), the SW MUST: // 1. Operator clicks Chrome's "Stop sharing" banner → offscreen's
// 1. send STOP_RECORDING to offscreen (releases the MediaStream tracks + // onUserStoppedSharing emits RECORDING_ERROR{error:'user-stopped-sharing'}
// stops the MediaRecorder + closes Chrome's sharing banner) // → SW's RECORDING_ERROR handler routes to setIdleMode (Bug B
// 2. transition the state machine to IDLE (setBadgeText('') + setPopup('')) // branch — preserved). Tests in `tests/background/badge-state-machine.test.ts`
// so the operator sees the recording cleared AND the toolbar click // pin this path; A14 harness assertion verifies it end-to-end.
// re-fires onClicked for a fresh session start // 2. Browser closes (SW terminates; offscreen tears down with it).
// 3. NOT fire a recovery notification — the stop is deliberate (mirrors // 3. Extension uninstalled.
// the Bug B user-stopped-sharing branch — see
// `.planning/debug/resolved/01-09-recovery-flow.md`)
// //
// Pre-fix behaviour (operator empirical UAT 2026-05-19): SAVE_ARCHIVE only // SPEC alignment: `Тз расширение фаза1.md` framing is "continuous
// produced the zip; recording stayed live; toolbar press re-opened the // capture / always-on safety net". Operator workflow: hit a bug → SAVE
// SAVE-only popup with no state delta. Operator dispatched 4 toolbar // (zip lands) → keep working → next bug also captured. The prior
// presses across ~94s; 2 zips downloaded; 1 misclick. // Amendment 2 fix at commits cd83eb0+4f4c3e2+2b6c24b+89f3337 implemented
// SAVE=stop semantics; 2026-05-19 operator UX iteration REVERSED that.
// //
// Tests A..D below pin the post-SAVE state machine transition contract. // Tests A..D below INVERT the prior contract: after SAVE_ARCHIVE, the
// All RED today; all GREEN after src/background/index.ts saveArchive() // SW state machine MUST remain in REC (badge stays 'REC', popup stays
// is patched to dispatch STOP_RECORDING + setIdleMode. // pinned to popup.html, no STOP_RECORDING dispatched to offscreen, no
// recovery notification fired).
// //
// Test architecture mirrors `tests/background/request-id-protocol.test.ts`: // Test architecture (unchanged from the prior file): synthetic BUFFER
// synthetic BUFFER response delivered via port.onMessage listeners to drive // response delivered via port.onMessage listeners to drive saveArchive's
// saveArchive's request-id'd buffer fetch to completion. The empty-segments // request-id'd buffer fetch to completion. The empty-segments BUFFER
// BUFFER causes createArchive to throw EmptyVideoBufferError (intentional // causes createArchive to throw EmptyVideoBufferError (intentional
// the post-save state transition MUST happen on BOTH the success path // the post-save state invariance MUST hold on BOTH the success path
// AND the empty-buffer-error path, per the fix design's operator-UI // AND the empty-buffer-error path: SAVE click does NOT stop recording,
// contract: SAVE click = stop, success or empty-buffer alike). // success or empty-buffer alike).
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
@@ -274,8 +276,9 @@ async function setupSwInRecState(stub: BgChromeStub): Promise<PortStub> {
/** /**
* Dispatch SAVE_ARCHIVE and drain the resulting async chain to completion. * Dispatch SAVE_ARCHIVE and drain the resulting async chain to completion.
* Resolves the in-flight REQUEST_BUFFER with an empty BUFFER (which causes * Resolves the in-flight REQUEST_BUFFER with an empty BUFFER (which causes
* createArchive EmptyVideoBufferError saveArchive catch branch the * createArchive EmptyVideoBufferError saveArchive catch branch under
* branch under test for the post-save STOP_RECORDING + setIdleMode dispatch). * the REVERSED contract, neither the success path nor the catch path may
* stop the recording).
* *
* @param stub - The chrome stub. * @param stub - The chrome stub.
* @param port - The wired offscreen port. * @param port - The wired offscreen port.
@@ -302,11 +305,10 @@ async function dispatchSaveArchiveAndDrain(
await new Promise<void>((resolve) => setTimeout(resolve, 0)); await new Promise<void>((resolve) => setTimeout(resolve, 0));
for (let i = 0; i < 32; i++) await Promise.resolve(); for (let i = 0; i < 32; i++) await Promise.resolve();
// Find the REQUEST_BUFFER post and respond with an empty-segments BUFFER // Find the REQUEST_BUFFER post and respond with an empty-segments BUFFER.
// (mirrors the empty-buffer fallback path; saveArchive's createArchive // createArchive throws EmptyVideoBufferError when segments.length === 0,
// throws EmptyVideoBufferError when segments.length === 0, the catch // the catch branch runs (broadcasts RECORDING_ERROR), and the REVERSED
// branch handles it, and the FIX dispatches STOP_RECORDING + setIdleMode // contract requires that no STOP_RECORDING/setIdleMode happen at all.
// regardless of which branch ran).
const requestBufferCalls = port.postMessage.mock.calls.filter( const requestBufferCalls = port.postMessage.mock.calls.filter(
(c: unknown[]) => (c: unknown[]) =>
typeof c[0] === 'object' && typeof c[0] === 'object' &&
@@ -326,8 +328,9 @@ async function dispatchSaveArchiveAndDrain(
// Drain again so the BUFFER response resolves through the SW's // Drain again so the BUFFER response resolves through the SW's
// pendingBufferRequests map → saveArchive resumes → throws // pendingBufferRequests map → saveArchive resumes → throws
// EmptyVideoBufferError in createArchive → catch branch + FIX-dispatched // EmptyVideoBufferError → catch branch runs to completion. Under the
// post-save STOP_RECORDING + setIdleMode all complete. // REVERSED contract there is no finally block, so the state machine
// remains in REC (badge='REC', popup pinned, isRecording=true).
for (let i = 0; i < 64; i++) await Promise.resolve(); for (let i = 0; i < 64; i++) await Promise.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 0)); await new Promise<void>((resolve) => setTimeout(resolve, 0));
for (let i = 0; i < 32; i++) await Promise.resolve(); for (let i = 0; i < 32; i++) await Promise.resolve();
@@ -335,49 +338,50 @@ async function dispatchSaveArchiveAndDrain(
return saveResp; return saveResp;
} }
describe('Plan 01-13 Task 9 (debug 01-09-save-stops-recording): SAVE_ARCHIVE auto-stops recording', () => { describe('Plan 01-09 Amendment 3 (debug 01-09-save-does-not-stop-recording): SAVE_ARCHIVE does NOT stop recording', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
}); });
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// Test A — after SAVE_ARCHIVE, the badge transitions to OFF (text=''). // Test A — after SAVE_ARCHIVE, the badge stays REC (no new '' painted).
// //
// RED today because saveArchive() in src/background/index.ts:614-704 // RED today (between the rename + the SW patch) because saveArchive()'s
// does NOT call setIdleMode after downloadArchive completes — the // `finally` block (introduced by Amendment 2, commit 4f4c3e2) still
// function ends with `return { success: true }` and the state machine // calls setIdleMode() which dispatches setBadgeText({text:''}).
// never advances. The badge remains 'REC' from setupSwInRecState.
// //
// GREEN after the fix: saveArchive() dispatches STOP_RECORDING and // GREEN after Amendment 3's revert: the `finally` block is removed;
// calls setIdleMode (which calls setBadgeText({text:''})). // saveArchive returns to "create zip → download → done"; no new badge
// transition occurs; the badge remains 'REC' from setupSwInRecState.
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
it('A: SAVE_ARCHIVE triggers setBadgeText({text:""}) — recording cleared after save', async () => { it('A: SAVE_ARCHIVE does NOT trigger setBadgeText({text:""}) — recording continues after save', async () => {
const stub = buildBgStub(); const stub = buildBgStub();
const port = await setupSwInRecState(stub); const port = await setupSwInRecState(stub);
// Snapshot baseline AFTER setup — setIdleMode at SW init painted '' // Snapshot baseline AFTER setup — setIdleMode at SW init painted ''
// once, then setRecordingMode painted 'REC'. The SAVE-triggered // once, then setRecordingMode painted 'REC'. Under the REVERSED
// delta we're testing is a NEW '' transition AFTER this baseline. // contract there is NO additional '' transition after SAVE.
const offBefore = badgeTextCallsFor(stub, '').length; const offBefore = badgeTextCallsFor(stub, '').length;
await dispatchSaveArchiveAndDrain(stub, port); await dispatchSaveArchiveAndDrain(stub, port);
const offAfter = badgeTextCallsFor(stub, '').length; const offAfter = badgeTextCallsFor(stub, '').length;
expect(offAfter - offBefore).toBeGreaterThanOrEqual(1); expect(offAfter - offBefore).toBe(0);
}); });
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// Test B — after SAVE_ARCHIVE, the popup empties (setPopup({popup:''})). // Test B — after SAVE_ARCHIVE, the popup is NOT emptied (stays pinned
// to src/popup/index.html so the SAVE popup remains accessible for
// the next operator gesture and onClicked stays inert).
// //
// RED today (same reason as Test A). GREEN after setIdleMode dispatch. // RED today (Amendment 2's finally block calls setIdleMode which calls
// setPopup({popup:''})).
// //
// This is the load-bearing UX side of the fix: an empty popup makes // GREEN after Amendment 3's revert: no setPopup({popup:''}) call;
// chrome.action.onClicked fire on the next toolbar click (MV3 contract // popup stays at POPUP_HTML_PATH (set by setRecordingMode during
// — onClicked only fires when popup is unset). Without this, the // setupSwInRecState).
// operator's next toolbar click would re-open the (now-meaningless)
// SAVE popup against an empty buffer.
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
it('B: SAVE_ARCHIVE triggers setPopup({popup:""}) — onClicked path re-enabled', async () => { it('B: SAVE_ARCHIVE does NOT trigger setPopup({popup:""}) — popup stays pinned to popup.html', async () => {
const stub = buildBgStub(); const stub = buildBgStub();
const port = await setupSwInRecState(stub); const port = await setupSwInRecState(stub);
@@ -386,24 +390,23 @@ describe('Plan 01-13 Task 9 (debug 01-09-save-stops-recording): SAVE_ARCHIVE aut
await dispatchSaveArchiveAndDrain(stub, port); await dispatchSaveArchiveAndDrain(stub, port);
const popupIdleAfter = setPopupCallsFor(stub, '').length; const popupIdleAfter = setPopupCallsFor(stub, '').length;
expect(popupIdleAfter - popupIdleBefore).toBeGreaterThanOrEqual(1); expect(popupIdleAfter - popupIdleBefore).toBe(0);
}); });
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// Test C — after SAVE_ARCHIVE, SW dispatches STOP_RECORDING to offscreen. // Test C — after SAVE_ARCHIVE, SW does NOT dispatch STOP_RECORDING to
// offscreen. Regression guard against accidental re-introduction of
// the Amendment 2 finally block (or an equivalent dispatch elsewhere).
// //
// RED today. GREEN after the chrome.runtime.sendMessage({type:'STOP_RECORDING'}) // RED today (Amendment 2's `chrome.runtime.sendMessage({type:'STOP_RECORDING'})`
// dispatch is added inside saveArchive(). // in the finally block still runs).
// //
// Why chrome.runtime.sendMessage instead of videoPort.postMessage? // GREEN after Amendment 3's revert: no STOP_RECORDING dispatch from
// The production SW → offscreen control plane uses // saveArchive. The offscreen MediaRecorder + MediaStream remain live;
// chrome.runtime.sendMessage for START_RECORDING (see // Chrome's screen-sharing banner stays open; the next chunk continues
// src/background/index.ts:434). STOP_RECORDING joins the same channel // to land in the offscreen ring buffer.
// — the offscreen onMessage handler at recorder.ts:848 already
// dispatches STOP_RECORDING through `stopRecording()`. Using
// sendMessage keeps the START/STOP symmetry.
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
it('C: SAVE_ARCHIVE dispatches STOP_RECORDING via chrome.runtime.sendMessage', async () => { it('C: SAVE_ARCHIVE does NOT dispatch STOP_RECORDING via chrome.runtime.sendMessage', async () => {
const stub = buildBgStub(); const stub = buildBgStub();
const port = await setupSwInRecState(stub); const port = await setupSwInRecState(stub);
@@ -412,21 +415,24 @@ describe('Plan 01-13 Task 9 (debug 01-09-save-stops-recording): SAVE_ARCHIVE aut
await dispatchSaveArchiveAndDrain(stub, port); await dispatchSaveArchiveAndDrain(stub, port);
const stopAfter = sendMessageCallsForType(stub, 'STOP_RECORDING').length; const stopAfter = sendMessageCallsForType(stub, 'STOP_RECORDING').length;
expect(stopAfter - stopBefore).toBeGreaterThanOrEqual(1); expect(stopAfter - stopBefore).toBe(0);
}); });
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// Test D — after SAVE_ARCHIVE, NO recovery notification fires. // Test D — after SAVE_ARCHIVE, NO recovery notification fires directly
// from saveArchive. Preserved as regression guard from the prior
// contract (Amendment 2) — under either charter, saveArchive itself
// does not create recovery notifications. The RECORDING_ERROR self-
// dispatch on the empty-buffer catch path goes through the SW's
// onMessage handler, not the chrome.notifications API directly.
// //
// The deliberate stop is NOT an error condition; surfacing a recovery // Under the synthetic test harness, chrome.runtime.sendMessage is a
// notification ("Recording stopped. Click to start new session.") would // vi.fn().mockResolvedValue(undefined) — it does NOT loop back into
// be UX noise. Mirrors the Bug B `user-stopped-sharing` branch which // the onMessage handler (no test-side bus). So even the
// similarly suppresses the recovery notification (see badge-state-machine // RECORDING_ERROR self-dispatch doesn't create the notification here.
// test E + debug session 01-09-recovery-flow). // The test remains GREEN — a regression guard against any future
// // code path that would directly create a recovery notification
// GREEN today AND after the fix — the regression guard. The fix MUST NOT // inside saveArchive itself.
// accidentally route the SAVE auto-stop through setErrorMode + recovery
// notification create.
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
it('D: SAVE_ARCHIVE does NOT fire a mokosh-recovery-* notification', async () => { it('D: SAVE_ARCHIVE does NOT fire a mokosh-recovery-* notification', async () => {
const stub = buildBgStub(); const stub = buildBgStub();