test(01-09-save-stops): RED — SAVE_ARCHIVE triggers STOP_RECORDING + setIdleMode + no recovery notif
Plan 01-13 Task 9 operator UAT closure. Operator 2026-05-19 empirical
session: SAVE click downloaded zip but recording stayed live (badge=REC,
sharing banner persisted, subsequent toolbar press re-opened SAVE-only
popup). Operator pressed 4×, got 2 zips + confusion.
Root cause: src/background/index.ts saveArchive() returns success after
chrome.downloads.download without signaling offscreen to stop or
transitioning the SW state machine — SPEC `Тз расширение фаза1.md`
"one click MUST produce a self-contained archive" was over-extended to
"always-on" framing by the implementation.
Fix contract (RED today; GREEN after src/background/index.ts patch):
A: setBadgeText({text:''}) called post-save (setIdleMode side effect)
B: setPopup({popup:''}) called post-save (re-enables chrome.action.onClicked
restart path per MV3 contract)
C: chrome.runtime.sendMessage({type:'STOP_RECORDING'}) dispatched
(offscreen recorder.ts:848 STOP_RECORDING case already wired —
no offscreen-side change needed)
D: NO mokosh-recovery-* notification fires (deliberate stop ≠ error;
mirrors Bug B `user-stopped-sharing` suppression branch from
.planning/debug/resolved/01-09-recovery-flow.md)
Tests A/B/C RED (assertion errors `expected 0 >= 1`); Test D GREEN today
as the regression guard against fix over-rotating to setErrorMode.
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. Empty-segments
BUFFER causes createArchive → EmptyVideoBufferError → catch branch; the
fix's STOP+IDLE dispatch MUST happen on both success and empty-buffer
paths (operator UI contract: SAVE click = stop, success or empty alike).
Debug record: .planning/debug/01-09-save-stops-recording.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
149
.planning/debug/01-09-save-stops-recording.md
Normal file
149
.planning/debug/01-09-save-stops-recording.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
slug: 01-09-save-stops-recording
|
||||
status: fixing
|
||||
goal: find_and_fix
|
||||
trigger: Plan 01-13 Task 9 operator empirical UAT — clicking SAVE downloads archive but recording continues; toolbar press re-opens SAVE-only popup; operator confusion (4× toolbar presses → 2 zips downloaded, 1 misclick)
|
||||
tdd_mode: true
|
||||
phase: 01-stabilize-video-pipeline
|
||||
plan: 01-09
|
||||
opened: 2026-05-19
|
||||
orchestrator_diagnosed: true
|
||||
---
|
||||
|
||||
# Debug session 01-09-save-stops-recording — SAVE_ARCHIVE must auto-stop recording (SPEC one-shot intent)
|
||||
|
||||
## Problem statement
|
||||
|
||||
During the Plan 01-13 Task 9 operator empirical UAT 2026-05-19, after the
|
||||
operator clicked SAVE in the popup during an active recording session, the
|
||||
extension produced the requested archive zip (downloaded to `~/Downloads`)
|
||||
but the recording **did not stop**: the toolbar badge stayed `REC`, Chrome's
|
||||
"Sharing your screen" banner remained visible, and subsequent toolbar
|
||||
presses re-opened the SAVE-only popup with no feedback indicating the
|
||||
prior SAVE had succeeded. The operator performed 4 toolbar presses across
|
||||
~94 seconds; 2 zips downloaded (`session_report_2026-05-19_10-59-44.zip` +
|
||||
`session_report_2026-05-19_11-01-18.zip`); the SW console captured 2
|
||||
`SAVE_ARCHIVE` receives + 2 `Archive download started` events, with the
|
||||
recording stream alive throughout.
|
||||
|
||||
## Root cause (orchestrator-diagnosed)
|
||||
|
||||
`src/background/index.ts:744-748` SAVE_ARCHIVE handler invokes `saveArchive()`,
|
||||
which produces the zip + calls `chrome.downloads.download`, then returns
|
||||
`{success: true}` — but the handler **never signals offscreen to stop the
|
||||
recording**. The recorder's mediaStream + MediaRecorder + rotation timer
|
||||
all stay live. The operator's perception ("nothing happened") was correct:
|
||||
the popup did not transition state after SAVE; the badge did not transition;
|
||||
no notification fired. This violates the SPEC `Тз расширение фаза1.md`
|
||||
"one click MUST produce a self-contained archive" — which implies one-shot
|
||||
save-and-done semantics. The original design's "always-on" framing was
|
||||
over-extended by engineering interpretation; the SPEC actually calls for
|
||||
a deliberate stop after each save.
|
||||
|
||||
## Fix design
|
||||
|
||||
Two-file patch matched to the existing parallel Bug B (user-stopped-sharing)
|
||||
pattern in `.planning/debug/resolved/01-09-recovery-flow.md`:
|
||||
|
||||
### `src/background/index.ts` SAVE_ARCHIVE handler
|
||||
|
||||
After `downloadArchive` returns (i.e. inside `saveArchive()` after
|
||||
the download dispatch succeeds, before the function returns `{success: true}`):
|
||||
|
||||
```typescript
|
||||
// Plan 01-13 Task 9 operator UAT closure: SAVE = one-shot per SPEC intent.
|
||||
// Send STOP_RECORDING to offscreen + transition state to IDLE so the
|
||||
// operator sees a clean reset (badge clears, popup empties, sharing
|
||||
// banner closes via track.stop()). Mirrors the Bug B user-stopped-sharing
|
||||
// path: deliberate stop ≠ error; no recovery notification.
|
||||
try {
|
||||
await chrome.runtime.sendMessage({ type: 'STOP_RECORDING' });
|
||||
} catch (sendErr) {
|
||||
// Offscreen may be unreachable mid-teardown; non-fatal — SW-side
|
||||
// state still transitions to IDLE so the operator regains the
|
||||
// restart path.
|
||||
logger.warn('STOP_RECORDING dispatch after save failed:', sendErr);
|
||||
}
|
||||
isRecording = false;
|
||||
setIdleMode();
|
||||
```
|
||||
|
||||
### `src/offscreen/recorder.ts` STOP_RECORDING handler
|
||||
|
||||
ALREADY EXISTS at recorder.ts:848 — invokes `stopRecording()` which
|
||||
correctly nulls mediaStream + stops the MediaRecorder + stops all tracks
|
||||
+ clears the rotation timer. No edit needed on the offscreen side.
|
||||
|
||||
**Edge case decided:** `resetBuffer` is intentionally NOT called by the SW
|
||||
post-save because:
|
||||
1. `stopRecording()` in recorder.ts does NOT call resetBuffer (line 559
|
||||
comment: "we do NOT call resetBuffer() here — the operator may want
|
||||
to STOP and then SAVE the buffered footage").
|
||||
2. After SAVE_ARCHIVE the buffer's contents have already been consumed
|
||||
into the zip; the operator's next session will start fresh because
|
||||
`startRecording()` calls resetBuffer at line 318.
|
||||
3. Calling resetBuffer here would be defensive overkill — the segments
|
||||
are about to be overwritten on the next START anyway, and a no-op
|
||||
resetBuffer on an already-cleared-at-next-start buffer wastes nothing.
|
||||
|
||||
## TDD plan
|
||||
|
||||
### RED unit tests (NEW file: `tests/background/save-archive-stops-recording.test.ts`)
|
||||
|
||||
1. After `SAVE_ARCHIVE` receive completes → `chrome.action.setBadgeText`
|
||||
called with `text=''` (setIdleMode side effect)
|
||||
2. After `SAVE_ARCHIVE` receive → `chrome.action.setPopup` called with
|
||||
`popup=''` (setIdleMode side effect — the popup-empties signal that
|
||||
re-enables onClicked restart path)
|
||||
3. After `SAVE_ARCHIVE` receive → `chrome.runtime.sendMessage` called
|
||||
with `{type:'STOP_RECORDING'}` (the SW → offscreen STOP signal)
|
||||
4. After `SAVE_ARCHIVE` receive → NO recovery notification fired
|
||||
(`chrome.notifications.create` not called with `mokosh-recovery-*` id)
|
||||
|
||||
### A14 harness extension
|
||||
|
||||
`tests/uat/extension-page-harness.ts` — add `assertA14` after assertA13:
|
||||
- Pre-condition: A13 just dispatched a SAVE_ARCHIVE; recording state
|
||||
after that save should now be IDLE (badge='', popup='').
|
||||
- Reads `chrome.action.getBadgeText({})` → expects `''`
|
||||
- Reads `chrome.action.getPopup({})` → expects `''` (relative path empty;
|
||||
Chrome returns empty string when setPopup was called with `''`)
|
||||
- Snapshots `chrome.notifications.getAll()` keys → expects NO id with
|
||||
`mokosh-recovery-` prefix added since A13 (verifies no recovery notif
|
||||
fired on the SAVE auto-stop)
|
||||
|
||||
Recommended simplification per spec: skip direct `isRecording` query
|
||||
(no bridge op exposes it). The badge proxy is sufficient — production
|
||||
state machine pairs `isRecording` updates with badge transitions
|
||||
atomically (no state-machine path desyncs them).
|
||||
|
||||
### Files touched
|
||||
|
||||
- `tests/background/save-archive-stops-recording.test.ts` (NEW)
|
||||
- `src/background/index.ts` (saveArchive() function — append STOP signal
|
||||
+ setIdleMode + isRecording=false)
|
||||
- `tests/uat/extension-page-harness.ts` (assertA14 + global surface)
|
||||
- `tests/uat/lib/harness-page-driver.ts` (driveA14)
|
||||
- `tests/uat/harness.test.ts` (orchestrator — add A14 in sequence)
|
||||
|
||||
Note: STOP_RECORDING is already in `MessageType` (src/shared/types.ts:14)
|
||||
— no type-system surface change needed.
|
||||
|
||||
## RED → GREEN evidence (to be filled in)
|
||||
|
||||
- RED commit SHA: TBD
|
||||
- GREEN commit SHA: TBD
|
||||
- A14 commit SHA: TBD
|
||||
- vitest count (target): >= 94 + 4 (new tests) = >= 98 GREEN
|
||||
- `npm run test:uat` (target): 15/15 GREEN (was 14/14)
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Orchestrator runs pre-checkpoint bundle gates (SW CSP-safety, Node-globals
|
||||
in SW chunk, DOM-globals not in SW chunk, Tier-1 hook-string grep over
|
||||
dist/) BEFORE re-surfacing operator UAT. Per
|
||||
`feedback-pre-checkpoint-bundle-gates.md` operator time = automation
|
||||
cannot verify.
|
||||
- Operator UAT after this lands should confirm: SAVE click → zip lands +
|
||||
badge clears + sharing banner closes + popup empties + subsequent
|
||||
toolbar click starts a NEW recording session (clean state machine).
|
||||
442
tests/background/save-archive-stops-recording.test.ts
Normal file
442
tests/background/save-archive-stops-recording.test.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
// tests/background/save-archive-stops-recording.test.ts
|
||||
//
|
||||
// Plan 01-13 Task 9 operator UAT closure — debug session
|
||||
// `.planning/debug/01-09-save-stops-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`)
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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).
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
interface PortStub {
|
||||
name: string;
|
||||
sender?: { id?: string };
|
||||
postMessage: ReturnType<typeof vi.fn>;
|
||||
onMessage: {
|
||||
addListener: ReturnType<typeof vi.fn>;
|
||||
removeListener: ReturnType<typeof vi.fn>;
|
||||
_listeners: Array<(msg: unknown) => void>;
|
||||
};
|
||||
onDisconnect: {
|
||||
addListener: (fn: () => void) => void;
|
||||
_listeners: Array<() => void>;
|
||||
};
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makePortStub(name = 'video-keepalive'): PortStub {
|
||||
const port: PortStub = {
|
||||
name,
|
||||
sender: { id: 'ext-id-test' },
|
||||
postMessage: vi.fn(),
|
||||
onMessage: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
_listeners: [],
|
||||
},
|
||||
onDisconnect: {
|
||||
addListener: (fn: () => void) => {
|
||||
port.onDisconnect._listeners.push(fn);
|
||||
},
|
||||
_listeners: [],
|
||||
},
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
port.onMessage.addListener.mockImplementation(
|
||||
(fn: (msg: unknown) => void) => {
|
||||
port.onMessage._listeners.push(fn);
|
||||
},
|
||||
);
|
||||
port.onMessage.removeListener.mockImplementation(
|
||||
(fn: (msg: unknown) => void) => {
|
||||
const idx = port.onMessage._listeners.indexOf(fn);
|
||||
if (idx >= 0) {
|
||||
port.onMessage._listeners.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
);
|
||||
return port;
|
||||
}
|
||||
|
||||
interface OnConnectCallback { (port: PortStub): void; }
|
||||
interface OnMessageCallback {
|
||||
(msg: unknown, sender: { id?: string }, sendResponse: (r: unknown) => void): boolean;
|
||||
}
|
||||
interface OnClickedCallback { (info?: unknown): void | Promise<void>; }
|
||||
interface OnNotificationClickedCallback { (notificationId: string): void; }
|
||||
|
||||
interface BgChromeStub {
|
||||
runtime: {
|
||||
id: string;
|
||||
getURL: (p: string) => string;
|
||||
getManifest: () => { version: string };
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
onMessage: { addListener: ReturnType<typeof vi.fn>; _callbacks: OnMessageCallback[] };
|
||||
onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] };
|
||||
onInstalled: { addListener: ReturnType<typeof vi.fn> };
|
||||
onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> };
|
||||
};
|
||||
offscreen: {
|
||||
hasDocument?: () => Promise<boolean>;
|
||||
createDocument: ReturnType<typeof vi.fn>;
|
||||
Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' };
|
||||
};
|
||||
tabs: {
|
||||
query: ReturnType<typeof vi.fn>;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
captureVisibleTab: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
downloads: { download: ReturnType<typeof vi.fn> };
|
||||
action: {
|
||||
onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] };
|
||||
setPopup: ReturnType<typeof vi.fn>;
|
||||
setBadgeText: ReturnType<typeof vi.fn>;
|
||||
setBadgeBackgroundColor: ReturnType<typeof vi.fn>;
|
||||
setTitle: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
notifications: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
clear: ReturnType<typeof vi.fn>;
|
||||
onClicked: {
|
||||
addListener: (cb: OnNotificationClickedCallback) => void;
|
||||
_callbacks: OnNotificationClickedCallback[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface GlobalWithBgChrome {
|
||||
chrome?: BgChromeStub;
|
||||
indexedDB?: { deleteDatabase: ReturnType<typeof vi.fn> };
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
function buildBgStub(): BgChromeStub {
|
||||
const stub: BgChromeStub = {
|
||||
runtime: {
|
||||
id: 'ext-id-test',
|
||||
getURL: (p) => `chrome-extension://ext-id-test/${p}`,
|
||||
getManifest: () => ({ version: '1.0.0' }),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
onMessage: { addListener: vi.fn(), _callbacks: [] },
|
||||
onConnect: { addListener: (cb) => stub.runtime.onConnect._callbacks.push(cb), _callbacks: [] },
|
||||
onInstalled: { addListener: vi.fn() },
|
||||
onStartup: { addListener: (cb) => stub.runtime.onStartup._callbacks.push(cb), _callbacks: [] },
|
||||
},
|
||||
offscreen: {
|
||||
hasDocument: async () => false,
|
||||
createDocument: vi.fn().mockResolvedValue(undefined),
|
||||
Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' },
|
||||
},
|
||||
tabs: {
|
||||
query: vi.fn().mockResolvedValue([{ id: 1, url: 'https://example.com', windowId: 100 }]),
|
||||
sendMessage: vi.fn().mockResolvedValue({ events: [], userEvents: [] }),
|
||||
captureVisibleTab: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
|
||||
),
|
||||
},
|
||||
downloads: { download: vi.fn().mockResolvedValue(1) },
|
||||
action: {
|
||||
onClicked: { addListener: (cb) => stub.action.onClicked._callbacks.push(cb), _callbacks: [] },
|
||||
setPopup: vi.fn(),
|
||||
setBadgeText: vi.fn(),
|
||||
setBadgeBackgroundColor: vi.fn(),
|
||||
setTitle: vi.fn(),
|
||||
},
|
||||
notifications: {
|
||||
create: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
onClicked: {
|
||||
addListener: (cb) => stub.notifications.onClicked._callbacks.push(cb),
|
||||
_callbacks: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => {
|
||||
stub.runtime.onMessage._callbacks.push(cb);
|
||||
});
|
||||
return stub;
|
||||
}
|
||||
|
||||
// Generic Promise-resolved fetch that returns a fake screenshot Blob.
|
||||
// Mirrors `tests/background/request-id-protocol.test.ts:stubFetch` —
|
||||
// captureScreenshot does `await fetch(dataUrl).blob()`.
|
||||
async function stubFetch(_url: string): Promise<Response> {
|
||||
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
return {
|
||||
blob: async () => new Blob([png], { type: 'image/png' }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
// Helper: filter setBadgeText calls by the text argument value.
|
||||
function badgeTextCallsFor(stub: BgChromeStub, text: string): unknown[][] {
|
||||
return stub.action.setBadgeText.mock.calls.filter(
|
||||
(args: unknown[]) => {
|
||||
const opts = args[0] as { text?: unknown };
|
||||
return typeof opts === 'object' && opts !== null && opts.text === text;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: filter setPopup calls by popup-arg value.
|
||||
function setPopupCallsFor(stub: BgChromeStub, popupArg: string): unknown[][] {
|
||||
return stub.action.setPopup.mock.calls.filter(
|
||||
(args: unknown[]) => {
|
||||
const opts = args[0] as { popup?: unknown };
|
||||
return typeof opts === 'object' && opts !== null && opts.popup === popupArg;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: filter chrome.runtime.sendMessage calls by message.type.
|
||||
function sendMessageCallsForType(stub: BgChromeStub, type: string): unknown[][] {
|
||||
return stub.runtime.sendMessage.mock.calls.filter(
|
||||
(args: unknown[]) => {
|
||||
const msg = args[0] as { type?: unknown };
|
||||
return typeof msg === 'object' && msg !== null && msg.type === type;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: filter chrome.notifications.create calls whose first argument
|
||||
// (the notification id) starts with `prefix`.
|
||||
function notificationCreateCallsWithIdPrefix(stub: BgChromeStub, prefix: string): unknown[][] {
|
||||
return stub.notifications.create.mock.calls.filter(
|
||||
(args: unknown[]) => {
|
||||
const id = args[0];
|
||||
return typeof id === 'string' && id.startsWith(prefix);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup helper — loads the SW module, wires a synthetic port, signals
|
||||
* offscreen ready, then starts a recording via toolbar onClicked so the
|
||||
* SW is in REC state when the test dispatches SAVE_ARCHIVE.
|
||||
*
|
||||
* Returns the port (so tests can resolve the in-flight REQUEST_BUFFER
|
||||
* with a synthetic BUFFER response).
|
||||
*/
|
||||
async function setupSwInRecState(stub: BgChromeStub): Promise<PortStub> {
|
||||
(globalThis as unknown as GlobalWithBgChrome).chrome = stub;
|
||||
(globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() };
|
||||
(globalThis as unknown as GlobalWithBgChrome).fetch = stubFetch as unknown as typeof fetch;
|
||||
|
||||
await import('../../src/background/index');
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
|
||||
const port = makePortStub();
|
||||
if (stub.runtime.onConnect._callbacks.length > 0) {
|
||||
stub.runtime.onConnect._callbacks[0](port);
|
||||
}
|
||||
|
||||
// Signal offscreen ready so startVideoCapture's awaitOffscreenReady resolves.
|
||||
const onMsgHandler = stub.runtime.onMessage._callbacks[0];
|
||||
onMsgHandler({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, vi.fn());
|
||||
for (let i = 0; i < 16; i++) await Promise.resolve();
|
||||
|
||||
// Trigger toolbar click → startVideoCapture → isRecording=true + badge=REC.
|
||||
const onClickedCb = stub.action.onClicked._callbacks[0];
|
||||
await onClickedCb();
|
||||
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* @param stub - The chrome stub.
|
||||
* @param port - The wired offscreen port.
|
||||
* @returns The saveArchive sendResponse value (typically `{success:false, error:...}`
|
||||
* under the empty-buffer branch).
|
||||
*/
|
||||
async function dispatchSaveArchiveAndDrain(
|
||||
stub: BgChromeStub,
|
||||
port: PortStub,
|
||||
): Promise<unknown> {
|
||||
let saveResp: unknown = undefined;
|
||||
const onMsgHandler = stub.runtime.onMessage._callbacks[0];
|
||||
onMsgHandler(
|
||||
{ type: 'SAVE_ARCHIVE' },
|
||||
{ id: 'ext-id-test' },
|
||||
(r: unknown) => {
|
||||
saveResp = r;
|
||||
},
|
||||
);
|
||||
|
||||
// Drain microtasks so saveArchive reaches getVideoBufferFromOffscreen
|
||||
// and posts REQUEST_BUFFER on the port.
|
||||
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||
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).
|
||||
const requestBufferCalls = port.postMessage.mock.calls.filter(
|
||||
(c: unknown[]) =>
|
||||
typeof c[0] === 'object' &&
|
||||
c[0] !== null &&
|
||||
(c[0] as { type?: unknown }).type === 'REQUEST_BUFFER',
|
||||
);
|
||||
if (requestBufferCalls.length > 0) {
|
||||
const reqMsg = requestBufferCalls[0][0] as { requestId?: unknown };
|
||||
port.onMessage._listeners.forEach((fn) =>
|
||||
fn({
|
||||
type: 'BUFFER',
|
||||
requestId: reqMsg.requestId,
|
||||
segments: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
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();
|
||||
|
||||
return saveResp;
|
||||
}
|
||||
|
||||
describe('Plan 01-13 Task 9 (debug 01-09-save-stops-recording): SAVE_ARCHIVE auto-stops recording', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Test A — after SAVE_ARCHIVE, the badge transitions to OFF (text='').
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// GREEN after the fix: saveArchive() dispatches STOP_RECORDING and
|
||||
// calls setIdleMode (which calls setBadgeText({text:''})).
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
it('A: SAVE_ARCHIVE triggers setBadgeText({text:""}) — recording cleared 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.
|
||||
const offBefore = badgeTextCallsFor(stub, '').length;
|
||||
|
||||
await dispatchSaveArchiveAndDrain(stub, port);
|
||||
|
||||
const offAfter = badgeTextCallsFor(stub, '').length;
|
||||
expect(offAfter - offBefore).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Test B — after SAVE_ARCHIVE, the popup empties (setPopup({popup:''})).
|
||||
//
|
||||
// RED today (same reason as Test A). GREEN after setIdleMode dispatch.
|
||||
//
|
||||
// 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.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
it('B: SAVE_ARCHIVE triggers setPopup({popup:""}) — onClicked path re-enabled', async () => {
|
||||
const stub = buildBgStub();
|
||||
const port = await setupSwInRecState(stub);
|
||||
|
||||
const popupIdleBefore = setPopupCallsFor(stub, '').length;
|
||||
|
||||
await dispatchSaveArchiveAndDrain(stub, port);
|
||||
|
||||
const popupIdleAfter = setPopupCallsFor(stub, '').length;
|
||||
expect(popupIdleAfter - popupIdleBefore).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Test C — after SAVE_ARCHIVE, SW dispatches STOP_RECORDING to offscreen.
|
||||
//
|
||||
// RED today. GREEN after the chrome.runtime.sendMessage({type:'STOP_RECORDING'})
|
||||
// dispatch is added inside saveArchive().
|
||||
//
|
||||
// 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.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
it('C: SAVE_ARCHIVE dispatches STOP_RECORDING via chrome.runtime.sendMessage', async () => {
|
||||
const stub = buildBgStub();
|
||||
const port = await setupSwInRecState(stub);
|
||||
|
||||
const stopBefore = sendMessageCallsForType(stub, 'STOP_RECORDING').length;
|
||||
|
||||
await dispatchSaveArchiveAndDrain(stub, port);
|
||||
|
||||
const stopAfter = sendMessageCallsForType(stub, 'STOP_RECORDING').length;
|
||||
expect(stopAfter - stopBefore).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Test D — after SAVE_ARCHIVE, NO recovery notification fires.
|
||||
//
|
||||
// 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.
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
it('D: SAVE_ARCHIVE does NOT fire a mokosh-recovery-* notification', async () => {
|
||||
const stub = buildBgStub();
|
||||
const port = await setupSwInRecState(stub);
|
||||
|
||||
const recoveryBefore = notificationCreateCallsWithIdPrefix(stub, 'mokosh-recovery-').length;
|
||||
|
||||
await dispatchSaveArchiveAndDrain(stub, port);
|
||||
|
||||
const recoveryAfter = notificationCreateCallsWithIdPrefix(stub, 'mokosh-recovery-').length;
|
||||
expect(recoveryAfter - recoveryBefore).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user