test(01-09-no-stop): RED — invert save-archive contract to lock always-on charter
Per operator UX iteration (2026-05-19), the Amendment 2 save-stops-recording
fix (commits cd83eb0+4f4c3e2+2b6c24b+89f3337) is REVERSED. SAVE_ARCHIVE
creates a new zip but does NOT stop the recorder — matches SPEC's
continuous-capture / always-on safety-net framing.
This commit renames the test file via `git mv` (history preserved) and
inverts tests A..C to assert the new contract:
- A: no NEW setBadgeText({text:''}) call (badge stays REC)
- B: no setPopup({popup:''}) call (popup stays pinned to popup.html)
- C: no STOP_RECORDING dispatch via chrome.runtime.sendMessage
Test D (no recovery notification) preserved unchanged as regression guard.
RED expected — src/background/index.ts still has the Amendment 2
`finally` block dispatching STOP_RECORDING + setIdleMode. Next commit
removes that block to drive GREEN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||||
Reference in New Issue
Block a user