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>
449 lines
19 KiB
TypeScript
449 lines
19 KiB
TypeScript
// 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);
|
||
});
|
||
});
|