Files
mokosh/tests/background/save-archive-does-not-stop-recording.test.ts
Mark 6ac23fdbd8 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>
2026-05-19 16:45:31 +02:00

449 lines
19 KiB
TypeScript
Raw Permalink 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-does-not-stop-recording.test.ts
//
// 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 (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.
//
// 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 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 (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';
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 — 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.
* @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.
// 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' &&
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 → 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();
return saveResp;
}
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 stays REC (no new '' painted).
//
// 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 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 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'. 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).toBe(0);
});
// ──────────────────────────────────────────────────────────────────────
// 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 (Amendment 2's finally block calls setIdleMode which calls
// setPopup({popup:''})).
//
// 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 does NOT trigger setPopup({popup:""}) — popup stays pinned to popup.html', 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).toBe(0);
});
// ──────────────────────────────────────────────────────────────────────
// 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 (Amendment 2's `chrome.runtime.sendMessage({type:'STOP_RECORDING'})`
// in the finally block still runs).
//
// 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 does NOT dispatch 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).toBe(0);
});
// ──────────────────────────────────────────────────────────────────────
// 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.
//
// 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();
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);
});
});