Files
mokosh/tests/background/start-video-capture-no-tab.test.ts
Mark a2dfc8cb9b fix(01-09): startVideoCapture — remove stale active-tab dependency (D-01 cleanup gap)
The legacy chrome.tabs.query({ active: true, currentWindow: true }) +
"No active tab found" validation inside startVideoCapture were load-
bearing in the pre-D-01 chrome.tabCapture era but became functionally
dead after Plan 01-09's D-01 conversion to getDisplayMedia-in-offscreen.
The only post-D-01 consumer was a log line at index.ts:521.

The dead validation caused an activeTab-permission-scope asymmetry
between callers: chrome.action.onClicked grants activeTab on the click
gesture (so tab.url was readable → toolbar path worked silently) but
chrome.notifications.onClicked does NOT grant activeTab and the extension
has no `tabs` permission, so notifications.onClicked → startVideoCapture
threw "No active tab found" before reaching ensureOffscreen. Operator
2026-05-20 UAT against the new notifStartupCta CTA copy ("Mokosh ready.
Click to start a recording.", commit 4bba679) surfaced the silent
notification failure.

Surgical fix: remove the dead tab query + validation + tab-dependent log
(src/background/index.ts:514-521); replace with a tab-independent log
that documents WHY (cites D-01 + this debug session). captureScreenshot
+ saveArchive retain their genuine tab dependencies (tab.windowId for
chrome.tabs.captureVisibleTab; tab.id for content-script sendMessage).

Tests: tests/background/start-video-capture-no-tab.test.ts (NEW) pins
the contract with 3 cases (tabs.query → []; → [{id}] url-less; →
[{id,url,windowId}] regression guard for toolbar path).

Gates: vitest 153/153 GREEN (was 150/150 baseline; +3); test:uat 24/24
GREEN; tsc clean; build clean. Pre-checkpoint bundle gates per
feedback-pre-checkpoint-bundle-gates.md: SW chunk hook-string Tier-1
grep 0 matches; eval/Node-global/DOM-global matches unchanged from
baseline (all vendor-library feature-detect, guarded; no new imports).

Debug record: .planning/debug/resolved/01-09-notification-start-no-active-tab.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:33:18 +02:00

225 lines
10 KiB
TypeScript

// tests/background/start-video-capture-no-tab.test.ts
//
// Plan 01-09 — Debug `01-09-notification-start-no-active-tab`
//
// Regression pin: startVideoCapture must NOT depend on the presence of
// an active tab. Post Plan 01-09 D-01 (chrome.tabCapture → getDisplayMedia
// in offscreen) the SW-side recording-start path has no functional need
// for a tab — the only consumer of the legacy tab query was a log line.
//
// chrome.notifications.onClicked does NOT grant the activeTab permission
// and the extension manifest carries no `tabs` permission (per
// .planning/intel/01-11-SUMMARY.md follow-up backlog). So in production,
// chrome.tabs.query({ active: true, currentWindow: true }) called from
// inside the notifications.onClicked path resolves to `[]` (or a tab
// stripped of `url` / `id`). The previous implementation rejected with
// "No active tab found" before reaching the ensureOffscreen + START_RECORDING
// dispatch — the operator-observed silent failure of the onStartup
// notification CTA path.
//
// These tests pin both halves of the contract:
// * empty tabs.query result → still dispatches START_RECORDING (no throw)
// * url-less tab → still dispatches START_RECORDING (no throw)
//
// They are isomorphic to the action.onClicked path which today works
// because activeTab is granted on the click gesture. Removing the dead
// tab query equalises the two callers.
import { describe, it, expect, vi, beforeEach } from 'vitest';
interface OnConnectCallback { (port: unknown): 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> };
}
function buildBgStub(tabsQueryResult: unknown[]): 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: {
// Mirror the production notifications.onClicked context: no activeTab
// grant + no `tabs` permission → tabs.query returns `[]`. The
// url-less-tab variant is supplied via the parameter.
query: vi.fn().mockResolvedValue(tabsQueryResult),
sendMessage: vi.fn().mockResolvedValue({ events: [], userEvents: [] }),
captureVisibleTab: vi.fn().mockResolvedValue('data:image/png;base64,xx'),
},
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;
}
/**
* Drive the SW startVideoCapture path via notifications.onClicked + a
* connected video-keepalive port + an OFFSCREEN_READY onMessage event,
* which is the same await-offscreenReady satisfaction that the existing
* onstartup-notification.test.ts Test C uses.
*/
async function driveNotificationStart(stub: BgChromeStub): Promise<void> {
const port = {
name: 'video-keepalive',
sender: { id: 'ext-id-test' },
postMessage: vi.fn(),
onMessage: { addListener: vi.fn() },
onDisconnect: { addListener: vi.fn() },
disconnect: vi.fn(),
};
if (stub.runtime.onConnect._callbacks.length > 0) {
stub.runtime.onConnect._callbacks[0](port);
}
const onMsgHandler = stub.runtime.onMessage._callbacks[0];
onMsgHandler({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, vi.fn());
// Synthesize the click on a mokosh-* notification id.
const notifCb = stub.notifications.onClicked._callbacks[0];
notifCb('mokosh-startup-1234567890');
// Drain microtasks so the entire async chain reaches sendMessage.
for (let i = 0; i < 64; i++) await Promise.resolve();
}
function countStartRecording(stub: BgChromeStub): number {
return stub.runtime.sendMessage.mock.calls.filter((args: unknown[]) => {
const msg = args[0] as { type?: unknown };
return typeof msg === 'object' && msg !== null && msg.type === 'START_RECORDING';
}).length;
}
describe('Plan 01-09 — startVideoCapture: no active-tab dependency', () => {
beforeEach(() => {
vi.resetModules();
});
// ──────────────────────────────────────────────────────────────────────
// Test A — RED today, GREEN after fix:
// chrome.tabs.query returns [] (real production notifications.onClicked
// context, since the extension has no `tabs` permission and notification
// clicks do not grant activeTab) → startVideoCapture must NOT throw
// "No active tab found"; START_RECORDING must still be dispatched.
// ──────────────────────────────────────────────────────────────────────
it('A: tabs.query → [] still dispatches START_RECORDING (no "No active tab" throw)', async () => {
const stub = buildBgStub([]);
(globalThis as unknown as GlobalWithBgChrome).chrome = stub;
(globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() };
await import('../../src/background/index');
await driveNotificationStart(stub);
expect(countStartRecording(stub)).toBeGreaterThanOrEqual(1);
});
// ──────────────────────────────────────────────────────────────────────
// Test B — RED today, GREEN after fix:
// tabs.query returns a tab whose `url` is undefined (the activeTab-less
// shape Chrome surfaces when the caller lacks both activeTab and `tabs`
// permission) → startVideoCapture must NOT throw; START_RECORDING is
// dispatched.
// ──────────────────────────────────────────────────────────────────────
it('B: tabs.query → [{id, url: undefined}] still dispatches START_RECORDING', async () => {
const stub = buildBgStub([{ id: 1 }]);
(globalThis as unknown as GlobalWithBgChrome).chrome = stub;
(globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() };
await import('../../src/background/index');
await driveNotificationStart(stub);
expect(countStartRecording(stub)).toBeGreaterThanOrEqual(1);
});
// ──────────────────────────────────────────────────────────────────────
// Test C — REGRESSION GUARD (was GREEN today, MUST stay GREEN after fix):
// tabs.query returns a fully-populated tab (the action.onClicked /
// click-gesture-activeTab context) → still dispatches START_RECORDING.
// Pins that the fix does not over-trim and break the toolbar path.
// ──────────────────────────────────────────────────────────────────────
it('C: tabs.query → [{id, url}] still dispatches START_RECORDING (regression guard)', async () => {
const stub = buildBgStub([{ id: 1, url: 'https://example.com', windowId: 100 }]);
(globalThis as unknown as GlobalWithBgChrome).chrome = stub;
(globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() };
await import('../../src/background/index');
await driveNotificationStart(stub);
expect(countStartRecording(stub)).toBeGreaterThanOrEqual(1);
});
});