--- slug: 01-09-notification-start-no-active-tab status: awaiting_human_verify goal: find_and_fix trigger: "Operator UAT 2026-05-20: clicking onStartup notification ('Mokosh ready. Click to start a recording.') logs `Failed to start video capture: Error: No active tab found` and `notification-triggered start failed: Error: No active tab found` from the SW chunk (La function). Notification path silently fails; toolbar path unaffected." created: 2026-05-20 updated: 2026-05-20 phase: 01-stabilize-video-pipeline plan: 01-09 orchestrator_diagnosed: true red_test: tests/background/start-video-capture-no-tab.test.ts --- # Debug session 01-09-notification-start-no-active-tab — startVideoCapture must not depend on an active tab (D-01 cleanup gap) ## Problem statement After Plan 01-10 + the 2026-05-20 commit `4bba679` split the onStartup notification CTA text to "Mokosh ready. Click to start a recording.", operator UAT exercised the notification click path for the first time and observed silent failure: the SW devtools console logged ``` [SW:Main] 2026-05-20T08:56:43.336Z Failed to start video capture: Error: No active tab found at La (index.ts-CKI4Evvn.js:17:95466) [SW:Main] 2026-05-20T08:56:43.336Z notification-triggered start failed: Error: No active tab found at La (index.ts-CKI4Evvn.js:17:95466) ``` Badge stayed IDLE; no Chrome "Sharing your screen" banner; no recording began. Toolbar click path remained functional. ## Root cause `src/background/index.ts:514-521` (pre-fix) inside `startVideoCapture()`: ```typescript // Получаем активную вкладку const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab.id || !tab.url) { throw new Error('No active tab found'); } logger.log(`Starting video capture for tab ${tab.id}: ${tab.url}`); ``` This block was load-bearing in the pre-D-01 `chrome.tabCapture` era — the capture stream-id was bound to the active tab. Since Plan 01-09's D-01 conversion (`chrome.tabCapture` → `getDisplayMedia` whole-desktop in offscreen), the tab reference has been functionally dead: every line after 521 (`ensureOffscreen()`, `await offscreenReady`, `chrome.runtime.sendMessage({ type: 'START_RECORDING' })`, `isRecording = true`, `setRecordingMode()`, error handling) makes no reference to `tab` or to any tab property. The two callers diverge because of the chrome permission model: * `chrome.action.onClicked` (toolbar) — Chrome grants `activeTab` on the click gesture; `tab.url` is readable; the validation at line 517 passes; the dead log line executes; the function proceeds normally. * `chrome.notifications.onClicked` — NOT a user gesture toward a tab. `activeTab` is not granted, and the extension manifest declares no `tabs` permission (per `.planning/intel/01-11-SUMMARY.md` follow-up backlog: "tabs permission gap"). `chrome.tabs.query({ active: true, currentWindow: true })` resolves to a tab whose `url` is undefined (or `[]` depending on Chrome version + window state); `!tab.url` evaluates true; the function throws "No active tab found" before `ensureOffscreen()` is reached. The bug was latent because Plan 01-09's smoke validation only exercised toolbar.onClicked. The new Plan 01-10 + debug-2-of-3 notification CTA text (commit `4bba679`) explicitly invites operators down the previously-untested notification.onClicked path. Operators hit this on every browser-restart-after-install. ## Fix Surgical removal of the dead tab query + validation + tab-dependent log from `startVideoCapture` (`src/background/index.ts:514-521`). Replace with a tab-independent log message that documents WHY the removal happened (D-01 cleanup gap + this debug session). ```typescript // Plan 01-09 D-01 cleanup gap (debug session // `01-09-notification-start-no-active-tab`, 2026-05-20): // The legacy chrome.tabs.query({ active: true, currentWindow: true }) // here was load-bearing in the pre-D-01 chrome.tabCapture era but // is functionally dead post-D-01 — capture is whole-desktop via // getDisplayMedia in offscreen and the SW-side start path needs // no tab reference. The query also failed for chrome.notifications // .onClicked callers (no activeTab grant + no `tabs` permission → // tab.url undefined → "No active tab found" throw) so the onStartup // notification CTA was silently broken. captureScreenshot + // saveArchive retain their own genuine tab queries (tab.windowId // for captureVisibleTab, tab.id for content-script sendMessage). logger.log('Starting video capture (whole-desktop via getDisplayMedia in offscreen per D-01)'); ``` ### Out of scope (NOT touched — genuine tab dependencies) * `captureScreenshot()` `src/background/index.ts:568-591` — passes `tab.windowId` to `chrome.tabs.captureVisibleTab(tab.windowId, ...)`. Genuine dependency on the active tab's window. * `saveArchive()` `src/background/index.ts:741+` — uses `tab.id` for `chrome.tabs.sendMessage(tab.id, ...)` to query the rrweb content script for events, and uses the tab's window for the embedded screenshot. Genuine dependency. These remain because (a) they only fire under operator gestures that DO grant activeTab (toolbar click → popup → SAVE), and (b) they have real downstream API consumers of tab fields. A separate Phase 5 "adopt `tabs` permission" decision is the appropriate forum for hardening; not in scope for this surgical bug fix. ## TDD evidence ### RED test (new file) `tests/background/start-video-capture-no-tab.test.ts` — 3 tests: * **A:** `tabs.query` mocked to `[]` (no-activeTab notifications.onClicked context) → assert `chrome.runtime.sendMessage({type:'START_RECORDING'})` was dispatched (was failing pre-fix with "expected 0 to be greater than or equal to 1" — startVideoCapture threw before reaching sendMessage). * **B:** `tabs.query` mocked to `[{id: 1}]` (url-less tab) → same assertion. Was failing pre-fix for the identical reason. * **C:** REGRESSION GUARD — `tabs.query` mocked to a fully-populated tab (action.onClicked / activeTab-granted context) → same assertion. Was passing pre-fix; MUST stay passing after the fix so we don't over-trim and break the toolbar path. Pre-fix run: ``` Test Files 1 failed (1) Tests 2 failed | 1 passed (3) ``` Post-fix run: ``` Test Files 1 passed (1) Tests 3 passed (3) ``` ### Acceptance gates (all GREEN) | Gate | Pre-fix baseline | Post-fix | Notes | |------|------------------|----------|-------| | `npm test` | 150/150 | **153/153** | +3 from new test file; 27 → 28 test files | | `npm run test:uat` | 24/24 | **24/24** | No UAT changes; harness unaffected | | `npx tsc --noEmit` | exit 0 | **exit 0** | No type-system surface change | | `npm run build` | clean | **clean** | Built in 23.36s | Note: A single ffprobe-driven test (`tests/background/webm-remux.test.ts > ffprobe -count_frames`) showed an intermittent 5000ms timeout in one of the multi-suite runs but passed in isolation (5/5) and on the second full-suite run (153/153). This is a pre-existing flake under load (ffprobe child-process spawn time), entirely unrelated to this fix — no source files this fix touches are consumed by webm-remux tests. ### Pre-checkpoint bundle gates (per saved memory `feedback-pre-checkpoint-bundle-gates.md`) Inspected the production SW chunk `dist/assets/index.ts-DBpA3-1k.js` (referenced by `dist/service-worker-loader.js`): * **Hook-string Tier-1 grep:** 0 matches (TEST_HOOK / MOKOSH_TEST_HOOK / `__test__` / `window.mokosh` / `globalThis.mokosh` etc). * **SW CSP safety (eval / new Function):** 1 match — vendor library internal templater (rrweb-adjacent), guarded by `typeof` checks; pre-existing in baseline build; unrelated to this fix (no new vendor imports). * **Node-globals (require / process / Buffer / __dirname / global):** 2 matches — all inside vendor library `ArrayBuffer`/`Buffer` runtime feature-detect paths; pre-existing; not introduced by this fix. * **DOM-globals (window / document / localStorage / alert / navigator):** 3 matches — all inside vendor library `typeof window<"u"&&...` feature-detect patterns (firebug-shim, ArrayBuffer detect, JSDOM detect); evaluate false in SW context at runtime; pre-existing; not introduced by this fix. * **Manifest validation:** unchanged — same permissions, same service_worker entry, same web_accessible_resources. The diff is purely subtractive in the SW source (8 lines removed, 14 lines of code-comment + 1 new logger.log line added; no new imports, no new APIs, no new vendor dependencies). It cannot have introduced any of the pre-existing vendor matches. ## Why this was latent until 2026-05-20 * D-01 (`chrome.tabCapture` → `getDisplayMedia` in offscreen) shipped in Plan 01-09; the tab dependency was orphaned at that point. * Plan 01-09 smoke validation only exercised the toolbar `action.onClicked` path, which grants activeTab and made the dead code transparent. * The original `notifStartup` CTA copy wording was conservative ("Mokosh is ready"), not action-inviting; operators tended to dismiss the notification rather than click it. * Commit `4bba679` (2026-05-20) split `notifStartup` into `notifStartupCta` ("Mokosh ready. Click to start a recording.") + `notifRecordingStarted`. The new CTA copy explicitly invites the click — operators began exercising the path on browser restarts. The latent bug surfaced immediately. ## Files modified * `src/background/index.ts` — startVideoCapture: removed lines 514-521 dead tab query + validation + tab-dependent log; replaced with a multi-line WHY comment + a tab-independent `logger.log('Starting video capture (whole-desktop via getDisplayMedia in offscreen per D-01)')`. * `tests/background/start-video-capture-no-tab.test.ts` (NEW) — 3 tests pinning the new contract. ## Forward-looking notes * `captureScreenshot()` and `saveArchive()` still query the active tab; this is correct because they are reached only through gestures that grant activeTab (toolbar → popup → SAVE button). Do NOT generalise this fix to those functions — they have real downstream consumers of tab fields. * The "add `tabs` permission to manifest" question is a separate scope-expansion decision (Phase 5 hardening candidate). It would let notifications.onClicked + future no-gesture paths read tab metadata, but it widens the permission surface. Not required for the current bug because the start-recording path doesn't need tab data. * Defensive UX feedback (e.g., showing an error notification when startVideoCapture fails) is out of scope here. After this fix the failure mode that motivated such UX is gone; if a future failure path emerges, address it then. ## Operator UAT closure Orchestrator re-spawns the operator UAT post-merge to confirm: 1. Install built extension to fresh Chrome profile. 2. Restart Chrome → `onStartup` fires → mokosh-startup-* notification appears with CTA "Mokosh ready. Click to start a recording." 3. Click the notification → badge transitions to REC → Chrome shows "Sharing your screen" banner → SW devtools console shows `Starting video capture (whole-desktop via getDisplayMedia in offscreen per D-01)` + `START_RECORDING sent successfully` (NO "No active tab found" anywhere). 4. Toolbar click while not recording still starts a new session (regression guard against over-trimming).