Plans cover the post-D-13 architecture and the auto-start UX charter expansion that landed during 2026-05-16 UAT: - Plan 01-08 — WebM remux via ts-ebml@3.0.2 + webm-muxer@5.1.4. Replaces the broken file-concat in mergeVideoSegments with a real single-EBML remux. Drives the 2 RED tests in tests/offscreen/webm-playback.test.ts to GREEN. Regenerates the canonical fixture against the remuxer. 5 tasks (4 TDD + 1 operator empirical checkpoint), wave 1. - Plan 01-09 — Whole-desktop constraint (displaySurface:'monitor', cursor:'always') + post-grant validation, chrome.action.onClicked direct toolbar invocation, chrome.action badge state machine (REC/OFF/ERROR), chrome.runtime.onStartup notification + recovery notification on onUserStoppedSharing, popup scoped to SAVE-only. 17 new test assertions across 4 test files. smoke.sh updated to auto-select an entire screen. 5 tasks (4 TDD + 1 operator empirical checkpoint), wave 2 (depends on 01-08). - Plan 01-10 — chrome.runtime.onInstalled welcome tab on first install via chrome.storage.local guard; vanilla welcome.html/ts/css bundle with single "Начать запись" button consuming install-time activation. Uses centralized Logger pattern. 4 tasks (3 TDD + 1 operator empirical checkpoint), wave 3 (depends on 01-09). CONTEXT.md amendment block appended with 4 disambiguated decisions: - D-14-remux: WebM remux supersedes D-13 file-concat - D-15-display-surface: whole-desktop + cursor visibility lifted from Phase 5 deferral - D-16-toolbar: toolbar onClicked + popup SAVE-only + badge state machine + onStartup/recovery notifications - D-17-onboarding: welcome tab on first install (distinct from D-17-port-lifecycle from Option C) The earlier D-17 port-lifecycle heading also renamed to hyphenated form for cross-ref consistency. Plan-check loop: 3 iterations (initial + 2 revisions). Iteration 1 surfaced 11 findings (2 BLOCKER + 6 WARNING + 3 INFO); all addressed via revision iter 1 with checker-recommended fixes. Iteration 2 surfaced 3 derivative regressions (literal-string grep anchors from the iter-1 fixes did not match live CONTEXT.md); all addressed in iter 2 with empirically-validated literals. Iteration 3 PASSED clean. Validation: gsd-sdk frontmatter.validate + verify.plan-structure both return valid=True for all 3 plans. Plan 01-08 Task 4 verify-chain grep tested end-to-end against live CONTEXT.md (exit 0). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
51 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | tags | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-stabilize-video-pipeline | 09 | tdd | 2 |
|
|
false |
|
|
|
Scope Sanity Note (W-03 fix, 2026-05-16 checker pass)
5 tasks + 9 files modified. The plan-checker pass flagged that 5 tasks is the WARNING threshold (per the planner's <scope_estimation> guidance: "2-3 tasks max") and 9 files in files_modified is at the borderline.
Why we accept the borderline rather than split:
- The three concerns (displaySurface constraint, toolbar-onClicked routing, badge+notification UX) are tightly coupled at the operator-experience level — splitting them across plans would force the operator-driven Task 5 checkpoint to be duplicated (one per sub-plan), tripling the operator's manual-test burden for no architectural benefit.
- The 9 files are NOT uniformly heavy: manifest.json gains 1 line; smoke.sh changes 3 string literals + adds ~14 comment lines (per W-04 enumeration); src/popup/index.html may not change at all (Task 4 step 15 says "no markup change needed"). The genuinely heavy edits are concentrated in
src/background/index.ts(~80 LOC of new helpers + listeners) andsrc/offscreen/recorder.ts(~12 LOC for the displaySurface constraint + post-grant validation block). - Context-cost estimate: Task 1 + Task 2 fit in ~25% context (small displaySurface change); Task 3 + Task 4 fit in ~40% context (the broader background + popup work). Task 5 is a checkpoint with no executor-side context cost. Total: ~50% — within the planner's quality-degradation-curve target of "complete within ~50% context."
Per the planner's own guidance ("<scope_estimation>: Split if any of these apply: more than 3 tasks, multiple subsystems"): the "split if multiple subsystems" trigger would normally apply (we touch offscreen + background + popup + manifest + smoke), BUT the per-subsystem cost is small enough that the unified plan stays within budget. The plan-checker's recommendation (a) — "accept as borderline; document rationale; do not duplicate the operator checkpoint" — is the path taken here.
If a future revision DOES force a split, the natural cut line is: Plan 01-09A = displaySurface constraint (Tasks 1+2 only — purely offscreen-recorder-side); Plan 01-09B = toolbar/badge/notification UX (Tasks 3+4 — background-side + popup + manifest). Task 5 (operator checkpoint) merges into 01-09B since the displaySurface change is verifiable via the unit tests in 01-09A alone.
Reduce per-session activation cost and eliminate the operator-picks-tab footgun.Per-session clicks drop from 3 (toolbar -> popup-open -> Start button -> picker) to 2 (toolbar -> picker accept). Active prompting via OS notification at Chrome startup makes recording state operator-visible without requiring them to remember to click anything.
Three coordinated changes:
- displaySurface constraint in getDisplayMedia locks Chrome's screen-share picker to monitor selection (eliminates UAT Test 3 "Share this tab instead" footgun documented in 01-UAT.md gaps section, and lifts the Phase 5 cursor:'always' refinement opportunistically).
- chrome.action.onClicked migration replaces popup-Start-button flow with direct toolbar-icon-click flow. Popup remains for SAVE only.
- Notification + badge state machine surfaces recording state: onStartup notification invites a fresh-day start, badge color/text shows REC/OFF/ERROR at a glance, onUserStoppedSharing emits a recovery prompt.
Output:
- src/offscreen/recorder.ts updated getDisplayMedia call + new wrong-display-surface CaptureErrorCode branch.
- src/background/index.ts adds chrome.action.onClicked + chrome.runtime.onStartup -> notification + chrome.notifications.onClicked + badge helper + RECORDING_ERROR handler.
- src/popup/index.ts has start-on-open path removed; popup is SAVE-only; src/popup/index.html copy may need a one-line update.
- manifest.json adds notifications permission.
- 4 new test files covering each new contract.
- smoke.sh updated for the displaySurface monitor mode (or carries a documented limitation note).
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/REQUIREMENTS.md @.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md @.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md @.planning/phases/01-stabilize-video-pipeline/01-UAT.md @.planning/phases/01-stabilize-video-pipeline/01-08-PLAN.md @src/offscreen/recorder.ts @src/background/index.ts @src/popup/index.ts @src/popup/index.html @manifest.json @smoke.sh Key types and Chrome API surfaces the executor will use.From src/offscreen/recorder.ts current getDisplayMedia call (lines 213-216):
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
New shape under this plan (D-15-display-surface):
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'monitor', cursor: 'always' },
audio: false,
});
// Post-grant validation:
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack !== undefined) {
const settings = videoTrack.getSettings();
if (settings.displaySurface !== 'monitor') {
// Operator overrode the hint and picked a tab or window.
stream.getTracks().forEach((t) => { try { t.stop(); } catch (e) {} });
mediaStream = null;
throw new Error('wrong-display-surface: got "' + settings.displaySurface + '"');
}
}
From src/offscreen/recorder.ts CaptureErrorCode union (lines 158-164) — extend with new code:
export type CaptureErrorCode =
| 'user-cancelled'
| 'permission-denied'
| 'codec-unsupported'
| 'no-source-selected'
| 'capture-failed'
| 'wrong-display-surface' // NEW (Plan 01-09 D-15-display-surface)
| 'unknown';
classifyCaptureError (line 166) gains one new branch — matched on Error message prefix:
if (error instanceof Error && error.message.startsWith('wrong-display-surface')) {
return 'wrong-display-surface';
}
Chrome API: chrome.action.onClicked.addListener fires when the operator clicks the toolbar icon UNLESS a default_popup is set. Two options:
- Option A: remove default_popup entirely; onClicked fires on every click; handler opens popup programmatically via chrome.action.openPopup() when needed.
- Option B: dynamically swap setPopup ('' for start mode; 'src/popup/index.html' for save mode) so onClicked fires when popup is empty.
This plan uses Option B + setPopup:
function setIdleMode() {
chrome.action.setPopup({ popup: '' });
setBadgeState('OFF');
}
function setRecordingMode() {
chrome.action.setPopup({ popup: 'src/popup/index.html' });
setBadgeState('REC');
}
chrome.action.onClicked.addListener(async () => {
// This fires only when setPopup('') i.e. OFF mode.
if (!isRecording) {
await startVideoCapture();
}
});
Chrome API: chrome.notifications.create(notificationId, options, callback):
chrome.notifications.create('mokosh-startup-prompt', {
type: 'basic',
iconUrl: chrome.runtime.getURL('icons/icon128.png'),
title: 'Mokosh ready',
message: 'Click here to start recording your session.',
priority: 1,
});
chrome.notifications.onClicked.addListener((notificationId) => {
if (notificationId.startsWith('mokosh-')) {
chrome.notifications.clear(notificationId);
startVideoCapture().catch((err) => logger.warn('notification-triggered start failed:', err));
}
});
Chrome API: badge surface — chrome.action.setBadgeText, setBadgeBackgroundColor, setTitle. Suggested palette:
- REC: background #00C853 (green), text 'REC', title 'Recording — last 30 s buffered. Click to save.'
- OFF: background #D32F2F (red), text '', title 'Not recording. Click to start.'
- ERROR: background #F9A825 (yellow), text 'ERR', title 'Recording error. Click to try again.'
Existing isRecording flag in src/background/index.ts line 36 is the source of truth the badge state machine reads.
Existing onMessage handler at line 631 routes RECORDING_ERROR (the offscreen recorder sends these). This plan extends it: on RECORDING_ERROR receipt set badge to ERROR and emit a recovery notification.
Existing popup at src/popup/index.ts lines 60-87 calls checkPermissions on init which triggers REQUEST_PERMISSIONS auto-start. The new design: checkPermissions is removed from init; popup just shows SAVE; empty-state copy in updateUI (line 51) becomes "Откройте запись через иконку расширения" (Open recording via the extension icon).
smoke.sh update path: Path A (preferred) updates auto-select string to target entire-screen ("Screen 1" or "Entire screen" — try both via fallback); Path B (fallback) documents a one-time manual step (KEEP_PROFILE=1 preserves picker memory across runs).
Existing patterns to reuse:
- Test scaffold: tests/background/request-id-protocol.test.ts (lines 53-120) shows the canonical pattern for a chrome stub with onConnect/onMessage/onInstalled callbacks plus dynamic import + await import. Extend with chrome.action, chrome.notifications, and badge methods.
- CaptureErrorCode extension pattern: tests/offscreen/codec-check.test.ts (lines 58-123) per-code-branch test idiom.
NO 'as any', NO @ts-ignore — every new chrome.* call must be properly typed via @types/chrome (already installed).
Task 1: RED tests — displaySurface constraint applied to getDisplayMedia + wrong-display-surface error code. - src/offscreen/recorder.ts lines 158-202 (CaptureErrorCode union + classifyCaptureError) - src/offscreen/recorder.ts lines 206-245 (startRecording — the getDisplayMedia call site) - tests/offscreen/codec-check.test.ts (test scaffold: per-error-code branch tests) - tests/offscreen/handshake.test.ts (chrome stub pattern with sendMessage spy) tests/offscreen/display-surface-constraint.test.ts - Test 1: when getDisplayMedia is mocked, startRecording invokes it with constraints `{video: { displaySurface: 'monitor', cursor: 'always' }, audio: false}`. **I-03 fix (2026-05-16 checker pass): use strict deep-equality on the constraints object.** Assert via `expect(getDisplayMediaSpy).toHaveBeenCalledWith({video: { displaySurface: 'monitor', cursor: 'always' }, audio: false})` — partial match (`expect.objectContaining`) would UN-PIN the `cursor: 'always'` refinement that this plan opportunistically lifts from the Phase 5 deferral list per D-15-display-surface. If a future refactor accidentally drops `cursor: 'always'` while keeping `displaySurface: 'monitor'`, the partial-match form would silently pass; strict deep-equality fails loudly. RED today (current call passes `video: true`). - Test 2: when getDisplayMedia returns a stream whose video track getSettings reports displaySurface 'browser' (operator picked a tab), startRecording stops the stream tracks AND emits chrome.runtime.sendMessage with RECORDING_ERROR + wrong-display-surface code. RED today. - Test 3: when getDisplayMedia returns a stream whose track getSettings reports displaySurface 'monitor', startRecording proceeds without emitting a wrong-display-surface RECORDING_ERROR. (Ensures Task 2 does not over-fire.) - Test 4: classifyCaptureError on Error with message starting 'wrong-display-surface' returns 'wrong-display-surface'. RED today (union doesn't include this code). 1. Create tests/offscreen/display-surface-constraint.test.ts. 2. Mock navigator.mediaDevices.getDisplayMedia per the handshake.test.ts chrome-stub pattern. Build a MediaStreamStub that includes getVideoTracks returning a track with getSettings, stop (vi.fn), and addEventListener. 3. Use vi.resetModules + dynamic await import per test (matches existing offscreen test discipline). 4. For Test 1: spy on getDisplayMedia; trigger startRecording via the recorder's onMessage handler (send START_RECORDING through the registered listener captured via the chrome.runtime.onMessage.addListener stub). **I-03 fix:** assert the spy was called with the exact constraints object via strict deep-equality — `expect(getDisplayMediaSpy).toHaveBeenCalledWith({video: { displaySurface: 'monitor', cursor: 'always' }, audio: false})` — NOT `expect.objectContaining(...)`. The strict form pins both the `displaySurface: 'monitor'` (D-15-display-surface footgun retirement) AND the `cursor: 'always'` (Phase 5 deferral opportunistically lifted); partial-match would leave the latter unpinned. 5. For Test 2: return a stream with displaySurface 'browser'; trigger START_RECORDING; await microtask drain (await Promise.resolve a few times). Assert the chrome.runtime.sendMessage stub received {type: 'RECORDING_ERROR', error: 'wrong-display-surface'}. Assert the stream's track stop was called for every track. 6. For Test 3: return a stream with displaySurface 'monitor'. Trigger START_RECORDING. Assert NO RECORDING_ERROR with wrong-display-surface was sent. (MediaRecorder will throw because jsdom doesn't supply it; catch and ignore — this test only cares about whether wrong-display-surface fires, not whether startRecording completed.) 7. For Test 4: await import the module; assert mod.classifyCaptureError on Error with 'wrong-display-surface' prefix returns 'wrong-display-surface'. 8. Run the test file; expect ALL 4 RED with descriptive failures. npx vitest run tests/offscreen/display-surface-constraint.test.ts - File exists with 4 tests; all 4 RED. - Baseline (Plan 01-08 final 12 files / 60 tests / all GREEN) + 4 new RED here = 13 files / 64 tests / 4 failed | 60 passed. - tsc still exit 0. 4 RED tests pin the displaySurface contract; baseline preserved. Task 2: GREEN — add displaySurface monitor + cursor always constraint and post-grant validation in src/offscreen/recorder.ts. - tests/offscreen/display-surface-constraint.test.ts (contract from Task 1) - src/offscreen/recorder.ts lines 158-202 (CaptureErrorCode union + classifyCaptureError) - src/offscreen/recorder.ts lines 206-245 (startRecording) src/offscreen/recorder.ts Drive the 4 RED tests from Task 1 to GREEN. 1. In the CaptureErrorCode union (line 158), add a new line: '| wrong-display-surface'. 2. In classifyCaptureError (line 166), add a branch near the top (after the instanceof Error guard, before the codec-unsupported check): if the error message startsWith 'wrong-display-surface', return 'wrong-display-surface'. 3. Update the getDisplayMedia call in startRecording (lines 213-216) to pass {video: { displaySurface: 'monitor', cursor: 'always' }, audio: false}. Add a block comment citing Plan 01-09 D-15-display-surface (UAT Test 3 footgun retirement, disambiguated from the historical 'D-15: Operator switching tabs' decision per B-02 fix) AND lifting the Phase 5 cursor:'always' deferral opportunistically. 4. Immediately after mediaStream = stream; add a post-grant validation block: read getVideoTracks()[0].getSettings().displaySurface; if not 'monitor' then call stream.getTracks().forEach(t => t.stop()), null out mediaStream, throw new Error(`wrong-display-surface: got "${observed}", expected "monitor"`). The throw routes through startRecording's existing catch block — classifyCaptureError maps the prefix; RECORDING_ERROR is broadcast; the throw propagates. 5. Run npx vitest run tests/offscreen/display-surface-constraint.test.ts — all 4 must flip GREEN. 6. Run full suite — 13 files / 64 tests / all GREEN. 7. Run npx tsc --noEmit — exit 0. Per project style: extensive docstrings; the post-grant validation block gets a dedicated multi-line comment explaining the constraint-hint-vs-enforcement gap (Chrome's getDisplayMedia spec treats displaySurface as a HINT, not a hard constraint — the operator can override; post-grant validation is the actual enforcement). No 'as any'; the settings check uses the official MediaTrackSettings.displaySurface field (lib.dom.d.ts covers this). npx vitest run tests/offscreen/display-surface-constraint.test.ts - CaptureErrorCode union includes 'wrong-display-surface'. - getDisplayMedia call carries displaySurface 'monitor' and cursor 'always' (grep verifies). - Post-grant validation block exists and emits wrong-display-surface when picker yields non-monitor. - 4 tests from Task 1 all GREEN. - Full suite 13 files / 64 tests / all GREEN. - npx tsc --noEmit exit 0. displaySurface contract pinned and enforced; per-error-code routing extended; baseline preserved. Task 3: RED tests — chrome.action.onClicked routing + badge state machine + onStartup notification + RECORDING_ERROR badge transition. - tests/background/request-id-protocol.test.ts lines 53-200 (chrome stub pattern; extend it) - src/background/index.ts lines 631-687 (onMessage handler — where REQUEST_PERMISSIONS lives; understand the existing dispatch shape) - src/background/index.ts lines 689-737 (initialize + onInstalled — where new onStartup will sit) - .planning/phases/01-stabilize-video-pipeline/01-UAT.md gaps section (the displaySurface footgun + the operator-side ergonomics motivation) tests/background/toolbar-action.test.ts, tests/background/badge-state-machine.test.ts, tests/background/onstartup-notification.test.ts Three new test files. All tests start RED today.File 1 — tests/background/toolbar-action.test.ts:
- Test A: chrome.action.onClicked.addListener is registered when the SW initializes.
- Test B: when isRecording is false and onClicked fires, the handler invokes startVideoCapture (the existing startVideoCapture path — chrome.tabs.query then ensureOffscreen then send START_RECORDING — should be detectable via the chrome.runtime.sendMessage spy receiving START_RECORDING).
- Test C: when isRecording is true (set via prior startVideoCapture call landing) and onClicked fires, the handler does NOT call startVideoCapture again (spy is called exactly once across both clicks). Defensive guard test.
- Test D: setPopup is called with empty string when transitioning to OFF state; with 'src/popup/index.html' when transitioning to REC state.
- **Test E (W-02 fix, 2026-05-16 checker pass) — popup-loads-when-idle race pin.** The checker flagged that Task 4 removes `checkPermissions` from `src/popup/index.ts` (lines 60-87) without any test asserting the post-removal behavior. Two assertions in this single test cover the change:
(E1) "popup init no longer fires `chrome.runtime.sendMessage` with `type: 'REQUEST_PERMISSIONS'`" — mock `globalThis.chrome.runtime.sendMessage = vi.fn().mockResolvedValue({granted: false})`; load `src/popup/index.ts` via dynamic `await import()` inside a JSDOM context with a stub `<body>` that includes `<button id="saveButton"><span class="button-text"></span></button>` and `<div id="statusMessage">`; await microtask drain (`await Promise.resolve()` ×3); assert `sendMessageSpy` was NOT called with an argument matching `expect.objectContaining({type: 'REQUEST_PERMISSIONS'})`. RED today because the current popup init at line 60-87 + line 167 (`init()` calls `checkPermissions()`) fires REQUEST_PERMISSIONS on every popup open. GREEN after Task 4 step 14 deletes the `checkPermissions()` call from `init()`.
(E2) "`saveButton.disabled === false` after popup init completes, regardless of any chrome message round-trip" — under the new SAVE-only popup charter (the popup ONLY opens when `isRecording === true` because `setRecordingMode` swaps `setPopup` to point at `src/popup/index.html`; in OFF mode `setPopup('')` causes the toolbar click to fire `onClicked` instead of opening the popup), the popup loading IMPLIES recording is active, so the SAVE button must be enabled unconditionally. After loading the popup module under JSDOM, query `document.querySelector('#saveButton')` and assert `(button as HTMLButtonElement).disabled === false`. RED today because the current `updateUI()` at line 33 gates `saveButton.disabled = !popupState.hasPermissions`, and `popupState.hasPermissions` defaults to false at init. GREEN after Task 4 step 14 also lands the `popupState.hasPermissions = true` unconditional set on popup init (per the action step's "Simplify: on popup load, ALWAYS enable the SAVE button" guidance).
Note: this test file's chrome stub must additionally provide `chrome.runtime.id`, `chrome.runtime.onMessage.addListener`, and an empty `chrome.action`/`chrome.notifications` shape if the popup module's strict-mode access patterns reach them; copy the minimal shape from the existing popup tests (none exist yet, so this is the first pop-up test — start with the chrome stub from `tests/background/request-id-protocol.test.ts` and PARE DOWN to only what the popup module actually touches via grep). The JSDOM environment is supplied by Vitest's default config; if not, override via `// @vitest-environment jsdom` pragma at the top of the test file.
File 2 — tests/background/badge-state-machine.test.ts:
- Test A: setBadgeState('REC') calls chrome.action.setBadgeText with text 'REC' AND setBadgeBackgroundColor with the green color AND setTitle.
- Test B: setBadgeState('OFF') calls setBadgeText with empty text AND setBadgeBackgroundColor with the red color AND setTitle.
- Test C: setBadgeState('ERROR') calls setBadgeText with 'ERR' AND setBadgeBackgroundColor with yellow AND setTitle.
- Test D: receiving a RECORDING_ERROR message via chrome.runtime.onMessage triggers setBadgeState('ERROR') (asserted via the setBadgeText spy receiving 'ERR' within a microtask of the dispatch).
File 3 — tests/background/onstartup-notification.test.ts:
- Test A: chrome.runtime.onStartup.addListener is registered when the SW module loads.
- Test B: firing the onStartup callback creates exactly one chrome.notifications.create call with notificationId starting 'mokosh-startup-' (or similar stable prefix), with type 'basic', title 'Mokosh ready', message containing 'Click'.
- Test C: firing chrome.notifications.onClicked with a 'mokosh-startup-...' id triggers chrome.notifications.clear(id) AND invokes startVideoCapture (assert via sendMessage spy receiving START_RECORDING after microtask drain).
- Test D: receiving a RECORDING_ERROR message triggers a recovery notification (a second chrome.notifications.create call with id prefix 'mokosh-recovery-...').
1. Copy the chrome stub from tests/background/request-id-protocol.test.ts as a starting point. Extend it with:
- chrome.action: { onClicked: { addListener, _callbacks }, setPopup: vi.fn(), setBadgeText: vi.fn(), setBadgeBackgroundColor: vi.fn(), setTitle: vi.fn() }
- chrome.notifications: { create: vi.fn(), clear: vi.fn(), onClicked: { addListener, _callbacks } }
- chrome.runtime.onStartup: { addListener, _callbacks }
Build each file's chrome stub by importing a shared builder helper if convenient; OR copy the stub into each test file (the existing tests duplicate the stub pattern, so duplication is acceptable per project convention).
2. Author each test per the behavior list. For tests that drive flows (Test C in file 1, Test C in file 3): set up the stub, register expected listeners, then synthesize the trigger by invoking the captured callback array directly (e.g. chromeStub.action.onClicked._callbacks[0]()).
3. For Test D in file 2 and Test D in file 3: invoke the onMessage callback via chromeStub.runtime.onMessage._callbacks[0]({type:'RECORDING_ERROR', error:'codec-unsupported'}, {id:'ext-id-test'}, vi.fn()); await microtask drain; assert downstream effects.
4. Run each test file individually; all should be RED with errors like "expected setBadgeText to have been called" or "expected chrome.notifications.create to have been called once".
5. DO NOT modify src/background/index.ts yet — Task 4 is the GREEN side.
npx vitest run tests/background/toolbar-action.test.ts tests/background/badge-state-machine.test.ts tests/background/onstartup-notification.test.ts
- 3 new test files exist with the listed tests (13 tests total: 5+4+4 — toolbar-action gains Test E per W-02 fix).
- All 13 are RED today.
- Baseline (after Task 2: 13 files / 64 tests / 0 RED) + 13 new RED = 16 files / 77 tests / 13 failed | 64 passed.
- tsc exit 0.
13 RED tests pinning toolbar (5: 4 routing + 1 popup-idle-race per W-02) + badge (4) + notification (4) contracts; baseline preserved.
Task 4: GREEN — add chrome.action.onClicked + badge state machine + onStartup notification + RECORDING_ERROR handler in src/background/index.ts; popup transitions to SAVE-only.
- tests/background/toolbar-action.test.ts, tests/background/badge-state-machine.test.ts, tests/background/onstartup-notification.test.ts (contracts from Task 3)
- src/background/index.ts (whole file — already familiar)
- src/popup/index.ts (lines 60-115 — the checkPermissions + requestPermissions auto-init flow to be removed)
- src/popup/index.html (the markup — likely no change needed)
- manifest.json (to add notifications permission)
src/background/index.ts, src/popup/index.ts, src/popup/index.html, manifest.json
Drive the 13 RED tests to GREEN (per W-02 fix the popup-idle-race test joins toolbar-action.test.ts).
1. manifest.json: add 'notifications' to the permissions array. Preserve all existing permissions. Keep default_popup pointing to src/popup/index.html.
2. src/background/index.ts top-level state additions (after isRecording line 36):
- Constants for badge palette:
BADGE_REC_COLOR = '#00C853', BADGE_OFF_COLOR = '#D32F2F', BADGE_ERROR_COLOR = '#F9A825'
BADGE_REC_TEXT = 'REC', BADGE_OFF_TEXT = '', BADGE_ERROR_TEXT = 'ERR'
BADGE_REC_TITLE = 'Recording — last 30 s buffered. Click to save.'
BADGE_OFF_TITLE = 'Not recording. Click to start.'
BADGE_ERROR_TITLE = 'Recording error. Click to try again.'
NOTIFICATION_ICON_PATH = 'icons/icon128.png' (already in manifest)
NOTIFICATION_STARTUP_PREFIX = 'mokosh-startup-'
NOTIFICATION_RECOVERY_PREFIX = 'mokosh-recovery-'
- Note in comments that the badge state machine is the operator-facing surface for recording state; per project naming use SCREAMING_SNAKE for these true constants.
3. setBadgeState(state: 'REC'|'OFF'|'ERROR'): void helper — calls chrome.action.setBadgeText, setBadgeBackgroundColor, setTitle with the three values per the palette table. Wrap each chrome call in try/catch (the SW context normally has chrome.action available but unit tests may not stub all methods atomically — defense in depth).
4. setIdleMode() helper: chrome.action.setPopup({popup: ''}); setBadgeState('OFF');
5. setRecordingMode() helper: chrome.action.setPopup({popup: 'src/popup/index.html'}); setBadgeState('REC');
6. setErrorMode() helper: chrome.action.setPopup({popup: 'src/popup/index.html'}); setBadgeState('ERROR'); (popup remains accessible so operator can see any error copy AND attempt save anyway).
7. startVideoCapture (line 305) — at the end of the try block (after isRecording = true; line 342), call setRecordingMode().
8. startVideoCapture catch block (line 345) — call setErrorMode() before re-throwing.
9. Register chrome.action.onClicked at SW module load (near the bottom of the file, before the initialize() call):
chrome.action.onClicked.addListener(async () => {
if (isRecording) return;
try { await startVideoCapture(); } catch (err) { logger.warn('toolbar-onClicked start failed:', err); }
});
Document inline that this listener fires ONLY when setPopup is '' (idle mode); setRecordingMode swaps the popup back so subsequent clicks open the popup for SAVE.
10. Register chrome.runtime.onStartup near initialize:
chrome.runtime.onStartup.addListener(() => {
setIdleMode();
const notificationId = NOTIFICATION_STARTUP_PREFIX + Date.now();
chrome.notifications.create(notificationId, {
type: 'basic',
iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
title: 'Mokosh ready',
message: 'Click here to start recording your session.',
priority: 1,
});
});
11. Register chrome.notifications.onClicked:
chrome.notifications.onClicked.addListener((notificationId) => {
if (!notificationId.startsWith('mokosh-')) return;
chrome.notifications.clear(notificationId);
startVideoCapture().catch((err) => logger.warn('notification-triggered start failed:', err));
});
12. Extend onMessage handler (line 631) with a new RECORDING_ERROR branch:
case 'RECORDING_ERROR':
logger.warn('RECORDING_ERROR received:', message); // do not destructure data — types use unknown
setErrorMode();
const recoveryId = NOTIFICATION_RECOVERY_PREFIX + Date.now();
chrome.notifications.create(recoveryId, {
type: 'basic', iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
title: 'Mokosh stopped', message: 'Recording stopped. Click here to start a new session.',
priority: 1,
});
return false;
13. In initialize() (line 690), at the end, call setIdleMode() — ensures fresh SW always boots into the OFF badge state and idle popup (popup='') so chrome.action.onClicked fires on first click.
14. src/popup/index.ts (line 60 onward): REMOVE checkPermissions function entirely. REMOVE the call to checkPermissions() in init() (line 167). The popup now opens; if popupState.hasPermissions is false (which it always will be on open since we never set it), the SAVE button stays disabled with the empty-state copy. UPDATE the empty-state copy in updateUI (line 51) to: 'Откройте запись через иконку расширения' (Open recording via the extension icon).
Actually re-think: with setRecordingMode active, the popup ONLY opens when isRecording is true (because in idle mode setPopup('') causes onClicked to fire instead). So when the popup loads, we KNOW isRecording is true (or the user got here through some odd race). Simplify: on popup load, ALWAYS enable the SAVE button (set hasPermissions = true unconditionally on init). Drop the entire requestPermissions function too.
15. src/popup/index.html: no markup change needed (the SAVE button works as-is); but consider updating the sub-label copy "Последние 30 сек видео + 10 мин лога" — it stays the same since it accurately describes SAVE.
16. **W-04 fix (2026-05-16 checker pass): smoke.sh update for monitor-mode picker — enumerate ALL five sub-steps** (the SHARE_TARGET infrastructure is more entangled than the prior single-line summary suggested; see `smoke.sh` lines 44, 109-113, 133 for the affected sites):
**16.a — Change the `SHARE_TARGET` variable value at line 44.**
```
# smoke.sh line 44 (was):
SHARE_TARGET="Mokosh Smoke Test"
# smoke.sh line 44 (after Plan 01-09):
SHARE_TARGET="Entire screen"
```
This variable is interpolated into line 133's `--auto-select-desktop-capture-source="${SHARE_TARGET}"` flag — changing the variable itself (NOT just the flag arg as a one-off literal) keeps the data-flow honest.
**16.b — Update the `data:` URL `<title>` element at line 107 to match.**
The HTML stub embedded in `smoke.sh` (the heredoc starting at line 106) currently begins with `<title>Mokosh Smoke Test</title>`. The `--auto-select-desktop-capture-source` Chromium flag historically matched against tab titles; under Plan 01-09 we want it to match the screen-source label instead. To keep the smoke tab's title from accidentally matching the new "Entire screen" target string, change the title to something distinct:
```
<title>Mokosh Smoke Test — monitor mode</title>
```
(Distinct enough that even if Chrome falls back to title-matching in some edge case, it does NOT match.)
**16.c — Update the on-screen instruction list in the same `data:` URL HTML.**
Lines 109-113 (the `<ol>` list inside the heredoc) currently say "The picker auto-accepts THIS tab". Under Plan 01-09 the picker auto-accepts the entire screen instead. Update the list-item copy:
```
<li>The picker auto-accepts <em>the entire screen</em> (not this tab — the new D-15-display-surface charter constrains to monitor mode).</li>
```
Also update any text that says "this tab is the share-screen target" near the top of the body to "this tab is informational; the picker shares the whole monitor".
**16.d — Document the empirical-test fallback for non-English Chrome locales.**
Add a comment block above line 44 explaining the fallback mode:
```
# Plan 01-09 D-15-display-surface: SHARE_TARGET must match the OS-locale-specific
# name Chrome's picker uses for entire-screen selection. Known strings:
# English: "Entire screen"
# Russian: "Весь экран"
# German: "Gesamter Bildschirm"
# French: "Ecran entier"
# If --auto-select-desktop-capture-source="${SHARE_TARGET}" fails to auto-accept
# on the operator's Chrome locale, the operator picks the screen manually one
# time; then KEEP_PROFILE=1 on subsequent runs carries the picker's last-pick
# memory across re-runs, sidestepping the auto-select string altogether.
```
**16.e — Cite the authoritative source for the locale-specific string.**
The picker string is locale-bound. **Authoritative source:** Chromium's grit resource definitions in `chrome/app/generated_resources.grd` (and per-locale `.xtb` translation files under `chrome/app/resources/`) — IDS_* identifiers like `IDS_DESKTOP_MEDIA_PICKER_SOURCE_TYPE_SCREEN` live in `.grd` (grit resource definition), not in `.cc` source. The previous draft cited `content/browser/media/desktop_capture_device_uma_types.cc` — that file contains UMA enums, not localized strings; **the citation was wrong, dropped here.** However the only reliable empirical check is to run the picker once and inspect the source label in Chrome's UI: per-locale translations drift across Chrome major versions and an offline `.grd` lookup is fragile. Document the operator's Chrome locale + version next to the SHARE_TARGET literal so future operators on different locales can update without surprise. Recommend the executor test the smoke harness against the operator's actual Chrome locale + version BEFORE committing the SHARE_TARGET value; if uncertain, default to the operator's locale and document the manual-pick fallback in the commit message body.
Net diff: smoke.sh adds ~14 lines of comments + changes 3 string literals (SHARE_TARGET value, `<title>` text, `<ol>` copy). No behavioral change to the polling / Downloads-snapshot / ffprobe-gate logic.
17. Run the 3 new test files; all 12 must GREEN.
18. Run full suite — 16 files / 77 tests / all GREEN.
19. Run npx tsc --noEmit — exit 0.
20. Run npm run build — exit 0; confirm dist/manifest.json has notifications permission.
Naming compliance: setBadgeState, setIdleMode, setRecordingMode, setErrorMode — camelCase verbs; NOTIFICATION_* and BADGE_* constants — SCREAMING_SNAKE; isRecording remains the boolean naming standard. No 'continue' statements; if-else chains for the badge-state switch. No 'as any'.
npx vitest run tests/background/toolbar-action.test.ts tests/background/badge-state-machine.test.ts tests/background/onstartup-notification.test.ts
- manifest.json permissions array includes 'notifications'.
- src/background/index.ts has setBadgeState, setIdleMode, setRecordingMode, setErrorMode helpers; chrome.action.onClicked + chrome.runtime.onStartup + chrome.notifications.onClicked listeners; RECORDING_ERROR onMessage branch; initialize() calls setIdleMode().
- src/popup/index.ts no longer auto-requests permissions on init; popup is SAVE-only.
- smoke.sh updated for monitor mode OR carries documented limitation note.
- All 13 new tests GREEN.
- Full suite 16 files / 77 tests / all GREEN.
- npx tsc --noEmit exit 0.
- npm run build exit 0.
Toolbar + badge + notification UX live; popup is SAVE-only (with W-02 test pinning the empty-REQUEST_PERMISSIONS init + always-enabled SAVE button); manifest updated; baseline preserved.
Task 5: Operator runs smoke.sh against the post-Plan-01-09 build; confirms toolbar-click recording flow + monitor-only picker + notification + badge state machine work end-to-end.
(operator-driven; no specific source file modified by this checkpoint)
See below — operator-driven empirical check; the executor agent must not bypass this checkpoint by stubbing.
echo "checkpoint:human-verify — see how-to-verify section; resume signal is the gate"
Operator types "approved" after running the how-to-verify steps. See for the exact gate.
Tasks 1-4 landed: getDisplayMedia constrained to monitor mode (UAT Test 3 footgun retired), chrome.action.onClicked direct toolbar flow live, chrome.notifications onStartup + recovery emitting, badge state machine REC/OFF/ERROR wired, popup is SAVE-only, manifest carries notifications permission. The 13 new unit tests are GREEN. This checkpoint validates the runtime end-to-end behavior in real Chrome.
1. Build: npm run build (must exit 0).
2. Run smoke: KEEP_PROFILE=0 ./smoke.sh.
3. After Chrome launches, Load Unpacked → select dist/. Observe the extension toolbar icon. Badge should display OFF state (no text, red background, tooltip 'Not recording. Click to start.').
4. Restart Chrome via chrome://restart (or close and re-launch the smoke profile). On startup, an OS notification 'Mokosh ready — Click here to start recording your session.' should appear within ~2 seconds.
5. Click the notification. Chrome's screen-share picker should appear. Confirm it offers ONLY entire-screen / monitor options (no tab list, no window list — or at least confirm tabs/windows are not the default selection).
6. Pick 'Entire screen' (or 'Screen 1' depending on Chrome build) and accept. The 'Sharing your screen' banner appears.
7. Observe the badge transitions to REC (green background, 'REC' text, tooltip 'Recording — last 30 s buffered. Click to save.').
8. Wait ~35 seconds (or longer to validate the post-Option-C port lifecycle still holds).
9. Click the toolbar icon. The popup opens (NOT the picker — because setRecordingMode set the popup to 'src/popup/index.html'). Click 'Сохранить отчёт об ошибке'. The save flow runs; the zip lands in ~/Downloads as session_report_*.zip.
10. Open the zip; confirm video/last_30sec.webm is present and (validated by Plan 01-08) plays for ~30s in Chrome and mpv.
11. Stop the sharing via Chrome's 'Stop sharing' banner button. Badge transitions to ERROR ('ERR' text, yellow background). A recovery notification 'Mokosh stopped — Recording stopped. Click here to start a new session.' appears.
12. Click the recovery notification. The picker reappears; pick Entire screen; recording resumes; badge returns to REC.
13. Edge-case: click the toolbar icon WITHOUT a prior recording (after step 3 + reload extension without restart). The picker should appear directly (NOT the popup). This validates that initialize → setIdleMode set the popup to '' so onClicked fires.
14. If any of steps 4 (notification appears), 5 (picker is monitor-only), 9 (popup opens on toolbar click while recording), 11 (badge transitions to ERROR + recovery notification), 12 (recovery click triggers picker), or 13 (idle toolbar click triggers picker directly) FAIL, document the exact failure mode + reproduction steps. The plan iterates on the failing handler.
Type 'approved' after steps 1-13 all PASS. If any step fails, paste the failure diagnostic + your Chrome version + locale + whether KEEP_PROFILE was used; Task 4 iterates.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| OS notification system <-> Chrome SW | NEW: chrome.notifications.* relies on the OS notification center. Misuse risks notification spam. Mitigation: notification ids are prefix-scoped ('mokosh-startup-', 'mokosh-recovery-') and we drain on click; we never create more than one notification per event class per second. |
| Popup activation gesture | UNCHANGED: getDisplayMedia requires a user-gesture; previously the popup-button-click provided it. Now chrome.action.onClicked and chrome.notifications.onClicked both provide it per Chrome's documented behavior (toolbar clicks and notification clicks are activation-bearing). |
| operator-controllable picker (displaySurface hint) | NEW HARDENING: a tab or window pick no longer reaches recording — track validation enforces monitor and tears down the stream + surfaces RECORDING_ERROR. |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-1-09-01 | Spoofing | chrome.notifications.onClicked accepting any notificationId | mitigate | Handler validates notificationId.startsWith('mokosh-') before any side effect. Foreign extensions cannot inject our prefixes (chrome.notifications IDs are per-extension-scoped). |
| T-1-09-02 | Tampering | operator-overridden displaySurface yielding tab/window capture | mitigate | Task 2 post-grant validation reads track.getSettings().displaySurface and aborts if not monitor; partial-screen capture cannot reach the buffer. |
| T-1-09-03 | Information Disclosure | recording the operator's whole desktop captures more sensitive content than tab mode would | accept | The whole-desktop constraint is the EXPLICIT user-requested charter (Plan 01-09 must-haves). Operator-facing UX surfaces the recording state via Chrome's permanent 'Sharing your screen' indicator + new REC badge + notifications; operator can stop sharing at any time. |
| T-1-09-04 | Denial of Service | repeat-firing of RECORDING_ERROR creating notification spam | mitigate | Recovery notification has Date.now()-suffixed id, so chrome.notifications.create coalesces if the OS notification center deduplicates by title; even worst-case (3 RECORDING_ERRORs in a row) produces 3 notifications which is operator-actionable, not spam. A future Phase 5 hardening can debounce; out of scope here. |
| T-1-09-05 | Elevation of Privilege | dynamic setPopup race — popup loads with isRecording false, attempts SAVE, ships empty archive | mitigate | Save path's existing EmptyVideoBufferError (Option C, D-17-port-lifecycle amendment) surfaces the error to the popup which displays it; the operator does not silently receive an empty archive. The setPopup dance is best-effort UX; the save-path correctness gate is the EmptyVideoBufferError throw. |
| </threat_model> |
<success_criteria> Plan 01-09 is complete when:
- The 4 displaySurface tests + 13 toolbar/badge/notification/popup-idle-race tests (17 new total) are all GREEN.
- All 60 baseline GREEN tests from Plan 01-08 remain GREEN.
- Operator runs the Task 5 checkpoint and confirms the end-to-end UX works: idle-toolbar-click triggers picker; recording-toolbar-click opens popup; onStartup notification appears; recovery notification appears after stop-sharing; badge transitions REC/OFF/ERROR visibly.
- manifest.json + smoke.sh + popup updated consistently with the new UX charter.
- tsc + build clean. </success_criteria>