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