Files
mokosh/tests/background/save-archive-stops-recording.test.ts
Mark cd83eb0498 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>
2026-05-19 13:17:19 +02:00

443 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
});