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>
This commit is contained in:
224
tests/background/start-video-capture-no-tab.test.ts
Normal file
224
tests/background/start-video-capture-no-tab.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user