Task 3 of Plan 01-11 (Puppeteer UAT harness).
Harness file tree (tests/uat/):
- harness.test.ts: tsx-runnable top-to-bottom harness entry point.
Runs A0 inline (filesystem grep gate, abort-on-fail T-1-11-01),
then launches Chrome + opens popup bridge + queries manifest, then
iterates A1-A13 stubs. Each stub throws "NOT YET IMPLEMENTED —
Plan 01-11 Task N wires this assertion". Exit code = 0 on full
pass, 1 otherwise. Final line: "UAT harness: N/14 assertions passed".
- lib/launch.ts: launchHarnessBrowser() — wraps puppeteer.launch with
enableExtensions:[dist-test/], headless default (HEADLESS=0
override), --no-sandbox + --auto-select-desktop-capture-source flags.
Polls browser.extensions() until the extension registers (empirically
~100ms but the first call right after launch returns Map(0)).
Opens both a blank page (for triggerExtensionAction) AND the popup
page (the bridge surface). Returns { browser, extension, extensionId,
sw, downloadsDir, page, popup }.
- lib/extension.ts: waitForOffscreenTarget + attachToOffscreen +
countOffscreenTargets. Offscreen attach uses target.type() ===
'background_page' + .asPage() (NOT .page() — RESEARCH §4 Pitfall 1).
- lib/sw.ts: chrome.* state queries via the POPUP page handle (NOT
the WebWorker handle — see architecture note below). getBadgeText,
getPopup, getManifest, getIconSize, getIsRecording (side-channeled
through badge text), fireOnStartup (via __mokoshTestQuery bridge),
sendSyntheticRecordingError, getNotificationSnapshot (via bridge),
keepalivePing (no-op message to wake SW for ~30s).
- lib/offscreen.ts: getDisplaySurface, simulateUserStop (the
dispatchEvent('ended') path per RESEARCH §7 BLOCKER — DO NOT REFACTOR
to track.stop()), getSegmentCount.
- lib/assertions.ts: runAssertion(idx, name, buffers, fn) wrapper —
records pass/fail/duration; on failure dumps last 30 lines of SW
+ offscreen console buffers to stderr before rethrowing. assertEqual
/ assertMatch / assertTrue / assertGte / waitFor polling helper.
- lib/zip.ts: jszip-based assertArchiveShape + extractEntryToFile for
assertions 12 + 13.
- README.md: runtime + local-debug + CI semantics + locale gotcha
+ dev-dep size note + assertion catalog table.
- tsconfig.json: per-tree type-check config (mirrors root tsconfig.json
compiler options but includes the harness tree explicitly).
Architecture refinement (DEVIATION from RESEARCH §1 — Rule 1+3 inline fix):
- RESEARCH §1 sketched `sw.evaluate(() => chrome.action.getBadgeText({}))`
as the chrome.* query path. Empirical probes during Task 3 execution
against Puppeteer 25.0.2 + Chrome 148 + --headless=true revealed two
blockers:
1. Puppeteer's WebWorker.evaluate runs in an ISOLATED WORLD that
carries SW globals (clients, registration, ...) but NOT the
extension's full chrome.* API surface. Object.keys(chrome) inside
sw.evaluate returns ["loadTimes","csi"] — the public webpage
chrome, not the extension chrome.
2. Chrome 148's headless mode aggressively suspends MV3 service
workers; subsequent swTarget.worker() calls return
"Protocol error: No target with given id found".
- WORKAROUND: open the popup page (chrome-extension://<id>/src/popup/
index.html) as a separate Puppeteer Page. The popup has full
chrome.* access (it's an extension context with same privileges as
the SW) AND stable Puppeteer lifetime. For SW-globalThis state
(__mokoshTest in the SW isolate, NOT in the popup), bridge via
chrome.runtime.sendMessage. The popup sends
{ type: '__mokoshTestQuery', op: 'snapshot' | 'fire-on-startup' |
'handler-types' }; the SW hook's onMessage handler responds.
- Bridge implementation added to src/test-hooks/sw-hooks.ts — registers
AFTER the production listeners so it never intercepts production
messages (__mokoshTest* type is unambiguously test-only). Tier-1
grep gate (no-test-hooks-in-prod-bundle.test.ts) continues to enforce
ZERO __mokoshTest occurrences in dist/ — the bridge handler is
tree-shaken alongside the rest of the hook module via the
__MOKOSH_UAT__ gate.
Other configuration changes:
- vitest.config.ts: exclude tests/uat/** from vitest discovery. The
Puppeteer harness is invoked via `npm run test:uat` (not vitest);
running it under vitest would try to launch real Chrome inside a
vitest worker. The .test.ts suffix is retained for editor +
naming-convention consistency with the rest of the tree.
Verification:
- npx tsc --noEmit (src/): exit 0
- npx tsc --noEmit -p tests/uat: exit 0
- npm run build: exit 0
- grep -rln '__mokoshTest|simulateUserStop|getSegmentCount|setCurrentStream|setSegmentCountGetter|__mokoshTestQuery|__mokoshKeepalive' dist/: ZERO matches
- npm run build:test: exit 0; dist-test/ populated with the new bridge code
- SKIP_BUILD=1 npx vitest run: 89/89 GREEN
- SKIP_PROD_REBUILD=1 npx tsx tests/uat/harness.test.ts:
→ A0 [PASS]: production bundle has no test-hook leaks (19ms)
→ Browser launches; popup opens; manifest read succeeds
→ A1-A13 [FAIL]: NOT YET IMPLEMENTED — Plan 01-11 Task N wires this
→ "UAT harness: 1/14 assertions passed, 13 failed (first failure: A1)"
→ Exit code: 1 (expected — 13 RED stubs intentional)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 2 of Plan 01-11 (Puppeteer UAT harness).
Test hook surface:
- src/test-hooks/types.ts: canonical MokoshTestSurface — handlers
(onClicked, onStartup, notificationOnClicked), notificationCount,
lastNotificationOptions<true>, notificationIds, getCurrentStream,
getSegmentCount. globalThis.__mokoshTest ambient declaration.
- src/test-hooks/sw-hooks.ts: SW-side hook. Monkey-patches addListener
on chrome.action.onClicked / chrome.runtime.onStartup / chrome
.notifications.onClicked to capture handler refs while chaining to
the original. Wraps chrome.notifications.create across all four
overload shapes (id+options+cb, options+cb, id+options→Promise,
options→Promise) to increment notificationCount, save
lastNotificationOptions, push resolved id into notificationIds.
- src/test-hooks/offscreen-hooks.ts: offscreen-side hook. Exports
setCurrentStream + setSegmentCountGetter; the recorder calls both
inside startRecording after the mediaStream + segments assignments.
getCurrentStream getter closes over the cell so the harness reads
the live MediaStream for displaySurface inspection + 'ended'
dispatch (Bug B BLOCKER per RESEARCH §7).
- tests/uat/lib/test-hook-contract.d.ts: manual harness-side mirror of
MokoshTestSurface (decoupled from src/ to keep tests/ import-clean
per RESEARCH §11 resolution 5; drift risk documented inline).
Production-side wires (gated by __MOKOSH_UAT__ token):
- src/background/index.ts top-of-module: `if (__MOKOSH_UAT__) { await
import('../test-hooks/sw-hooks'); }`. MUST run before any chrome.*
addListener call below — top-of-module placement satisfies this.
- src/offscreen/recorder.ts top-of-module: symmetric gated dynamic
import + module-scoped testHooks reference.
- src/offscreen/recorder.ts inside startRecording (after mediaStream
assignment): `if (__MOKOSH_UAT__) { testHooks?.setCurrentStream(stream);
testHooks?.setSegmentCountGetter(() => segments.length); }`
- src/offscreen/recorder.ts inside onUserStoppedSharing (after
mediaStream = null): `if (__MOKOSH_UAT__) { testHooks?.setCurrentStream(null); }`
— T-1-11-05 (Repudiation: stale stream ref) mitigation.
Build-time token wiring:
- vite.config.ts: declares `define: { __MOKOSH_UAT__: 'false' }` (prod
default) + bumps `build.target: 'es2022'` so the top-level await in
the gated dynamic imports compiles (MDN: Chrome 89 / Edge 89 /
Firefox 89 / Safari 15 support TLA; MV3 floor Chrome 88 is
effectively Chrome 89+ in field — comfortably inside the envelope).
- vite.test.config.ts: overrides `define: { __MOKOSH_UAT__: 'true' }`
so the test bundle has the hooks active.
- vitest.config.ts: declares `define: { __MOKOSH_UAT__: 'false' }` for
vitest's own source-loading runs. CRITICAL — without this, vitest
would throw `ReferenceError: __MOKOSH_UAT__ is not defined` when
loading src/background/index.ts; OR if we'd used `import.meta.env.MODE
=== 'test'` (RESEARCH §6's initial guidance), vitest's default
MODE='test' would have ACTIVATED the hooks under unit tests +
clobbered every existing vi.fn() chrome.notifications.create mock.
The dedicated `__MOKOSH_UAT__` token sidesteps both failure modes
cleanly — a refinement on RESEARCH §6 documented in the comment
preambles of all three configs.
- globals.d.ts: declares `__MOKOSH_UAT__: boolean` ambient so
`npx tsc --noEmit` passes without per-file annotations.
- tsconfig.json: include adds `globals.d.ts`.
Notification options generic refinement:
- chrome.notifications.NotificationOptions is declared with a
`<true | false>` generic distinguishing "create" (all required —
true) from "update" (all optional — false). Plan 01-11's production
code always uses the create shape; types.ts + sw-hooks.ts pin to
`NotificationOptions<true>` so the harness reads iconUrl etc. as
definitely-present.
Verification:
- npx tsc --noEmit: exit 0
- npm run build: exit 0
- grep -rln '__mokoshTest\|simulateUserStop\|getSegmentCount\|setCurrentStream\|setSegmentCountGetter' dist/:
ZERO matches (Tier-1 gate stays GREEN)
- npm run build:test: exit 0; dist-test/ emits separate sw-hooks-*.js
+ offscreen-hooks-*.js chunks (the gated dynamic imports survive
tree-shaking when __MOKOSH_UAT__ === true)
- grep -rln '__mokoshTest' dist-test/: 2 matches
(assets/sw-hooks-*.js + assets/offscreen-hooks-*.js)
- SKIP_BUILD=1 npx vitest run: 89/89 GREEN
(83 baseline + 6 Tier-1 hook-leak surfaces)
- sw-bundle-import.test.ts: GREEN (the gated dynamic import does not
break production module init — the `if (false)` branch is never
reachable so the await + import are dead code in dist/)
In-flight bugs auto-fixed (Rule 1 + Rule 3):
- Rule 3: original RESEARCH §6 plan called for `import.meta.env.MODE
=== 'test'` as the gate; switched to `__MOKOSH_UAT__` define-token
after observing vitest contamination (vitest defaults MODE='test'
→ hooks activated under unit tests → 8 existing tests broke with
"Cannot read properties of undefined (reading 'calls')" because the
hook wrapper replaced vi.fn() mocks). Documented in the comment
preambles of all three configs as a refinement on RESEARCH §6.
- Rule 3: esbuild rejected TLA against the default ES2020 target;
bumped to es2022 (Chrome 89+ supports TLA per MDN — inside MV3
envelope). Recorded in vite.config.ts preamble.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Untracked file present at session spawn (per orchestrator pre-flight
intelligence). The Bug B debug investigation that produced commit
b9eeeeb (the conditional-routing fix in src/background/index.ts
RECORDING_ERROR handler that Plan 01-11 assertion 6 verifies) was
recorded under .planning/debug/resolved/01-09-recovery-flow.md but
never committed. Importing it now so the debug provenance is
preserved alongside Plan 01-11's harness coverage of the bug class.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 1 of Plan 01-11 (Puppeteer UAT harness).
- npm install --save-dev puppeteer@^25.0.2 tsx@^4 @types/node
resolved: puppeteer@25.x, tsx@4.22.1, @types/node@25.8.0
pulls ~150MB Chromium binary at install time (T-1-11-03 — accepted,
package-lock pins resolved hashes via @puppeteer/browsers).
- package.json scripts: add build:test + test:uat (per RESEARCH §10
two-bundle orchestration); existing dev/build/preview/test untouched.
- vite.test.config.ts: extends ./vite.config.ts via mergeConfig with
mode:'test' + build.outDir:'dist-test' + emptyOutDir:true. Verified
npm run build:test produces dist-test/ in 7.93s; npm run build keeps
producing dist/ in 7.67s (no clobber).
- tsconfig.json `include: ["src"]` already covers src/test-hooks/**/*
via wildcard — no edit needed.
- tests/background/no-test-hooks-in-prod-bundle.test.ts: Tier-1 gate
mirroring sw-bundle-import.test.ts's execFile pattern. Greps the
BUILT dist/ tree for 5 forbidden hook surfaces (one `it` per surface
for granular failure isolation): __mokoshTest, simulateUserStop,
getSegmentCount, setCurrentStream, setSegmentCountGetter. All 5
surfaces absent today (RED-then-GREEN polarity inverted — the gate
is GREEN now and MUST stay GREEN after Task 2 lands the hooks).
SKIP_BUILD=1 escape hatch for developer iteration.
- .gitignore: add dist-test/ (no point versioning generated test bundle).
Verification:
- npx tsc --noEmit: exit 0
- npm run build: exit 0; dist/ populated (375.37 kB SW chunk)
- npm run build:test: exit 0; dist-test/ populated (identical chunk sizes —
the gated dynamic imports do not land until Task 2; this commit only
proves the two-bundle plumbing)
- SKIP_BUILD=1 npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts:
6/6 GREEN (1 build-sanity + 5 forbidden-surface)
- SKIP_BUILD=1 npx vitest run (full suite): 89/89 GREEN
(83 baseline + 6 new Tier-1 surfaces = 89)
Working-tree cleanup: a stale 5.4 MB tests/fixtures/last_30sec.webm
(unrelated operator smoke regen present at session spawn) was stashed
before running the baseline — it caused the webm-playback test to time
out at 5s. After stashing back to HEAD's 1.9 MB fixture, baseline passes
cleanly. Not committing the fixture restoration here (pre-existing
working-tree state, not part of Task 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
D-05 picked Newsreader as display serif. Engineering research
(01-12-RESEARCH.md §1, commit 3df2750) verified Newsreader ships NO
Cyrillic glyphs via designer's own Brief embedded subset list +
Production Type's repo README ("Google Fonts Latin Plus glyph set").
Russian display text (welcome hero, tagline) under current D-05 picks
silently falls through to Iowan Old Style → Times New Roman → serif.
Mokosh's audience is Russian-primary, so this is the default rendering
path, not a corner case.
Routing back to designer per D-05 OWNER (Design team) with 4 options:
- R1 fallback chain (Newsreader + Cyrillic-capable secondary)
- R2 substitute display family entirely
- R3 other (designer proposes)
- R4 accept the Iowan/Times fallback
Candidate Cyrillic-capable OFL serifs: PT Serif, EB Garamond, Lora,
Source Serif. PT Serif highlighted (ParaType, RU-foundry, pairs with
IBM Plex Sans).
Blocks Plan 01-12 planner spawn + Plan 01-10 welcome hero.
Plan 01-11 (harness) unaffected — non-overlapping surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 research surfaces investigated per Plan 01-12 prompt. Key findings:
- BLOCKER §1: Newsreader has no Cyrillic glyphs (verified via Decision
Brief's own embedded @font-face subsets + Production Type README).
Russian display text in Newsreader will silently fall through to
serif fallback. Recommend §1 R1: add Cyrillic-capable OFL serif (PT
Serif / EB Garamond / Lora) to --mks-font-display stack.
- §3 + §4: Mark legibility at 16 px is VERIFIED OK. rsvg-convert
produces 406 / 784 / 1952 B PNGs at 16/48/128 — all clear Chrome
imageUtil floors. The 2x2 weave holds at 16 px.
- §10: 8 i18n strings extracted verbatim from Brief §02 (decoded via
Python JSON-decode of the bundler-template script). Recommended
__MSG_*__ key names + source-file mapping table included.
- §12: D-09 (smoke gating) is NEAR-NO-OP. smoke.sh is a standalone
bash script; nothing in src/ references it; dist/ carries no smoke
artifacts. Recommend documenting current state + adding optional
no-smoke-in-dist regression test.
- §13: tokens.css line 12 Google Fonts @import migration → 8 local
@font-face rules (Plex variable consolidation collapses the
estimated 12-18 down to 8).
All tooling verified installed on dev machine: rsvg-convert 2.60.0,
pyftsubset 4.63.0, woff2_compress, inkscape 1.4.4. No npm install
needed for execute-plan; only one-off font source TTF downloads.
7 assumptions logged for planner to surface (A1 default_locale, A2
popup DOM rework, A4 Cyrillic serif fallback are highest-impact).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retires operator-as-assertion-library role from Plan 01-09 Task 5. Bug A
(notification icon API rejection) and Bug B (state-machine routing of
user-stopped-sharing) both escaped vitest unit coverage and cost ~4-6h of
operator UAT cycles in Phase 1. Plan 01-11 ships a Puppeteer-driven Node
harness with CDP attach to SW + offscreen contexts; the 14 assertions cover
the Plan 01-08/01-09 functional contract end-to-end.
Locked from research (RESEARCH §1-§11):
- Puppeteer 25.0.2 + tsx + node:assert/strict (no vitest browser mode)
- Two-bundle separation via vite.test.config.ts (mode 'test' + dist-test/)
- Hook gating: import.meta.env.MODE === 'test' + dynamic import (Vite
tree-shakes from production)
- Bug B trigger: track.dispatchEvent(new Event('ended')) — NOT track.stop()
(W3C spec + empirical probe7 — track.stop does NOT fire 'ended')
- Tier-1 grep gate (tests/background/no-test-hooks-in-prod-bundle.test.ts)
enforces zero __mokoshTest in production dist/
- Single browser, serial assertions, bail-on-first-failure (open question 4)
Wave structure (4 waves):
- Wave 0 (Task 1): puppeteer+tsx install, vite.test.config, build:test +
test:uat scripts, Tier-1 grep gate committed GREEN.
- Wave 1 (Task 2): gated SW + offscreen hooks at src/test-hooks/; production
bundle remains hook-free.
- Wave 2 (Task 3): harness scaffolding — tests/uat/lib/* + harness.test.ts
with assertion 0 wired GREEN + assertions 1-13 stubbed RED.
- Wave 3 (Tasks 4-7): wire 13 assertions in 4 logically-grouped bundles
(1-4 toolbar/displaySurface; 5-7 SAVE+BugB+ERROR; 8-10 BugA+icons+manifest;
11-13 buffer continuity + ffprobe + zip).
- Wave 4 (Tasks 8-9): amend Plan 01-09 Task 5 to redirect functional steps
to npm run test:uat; operator confirms brand/design.
Bug A + Bug B each have RED-on-regression canonical demos required in the
respective task's commit body — proves the harness CAN catch the regression,
not just passes under current conditions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Original step 11 expected stop-sharing → ERROR badge + recovery
notification; under Bug B fix (b9eeeeb), user-stopped-sharing is
correctly routed → IDLE (badge OFF, no recovery notification) because
it's a deliberate lifecycle event, not an error.
Amendments:
- Step 11: badge OFF (not ERROR); no recovery notification; popup cleared
- Step 12: operator clicks toolbar directly (no notification to click)
- Step 14: failure-mode list updated to match new expectations
- Step 15 added: ERROR state coverage moved to separate genuine-error step
- Success criteria #3: split user-stopped-sharing (→ OFF) from genuine
errors (→ ERROR + recovery notification, preserved fallback)
See .planning/debug/resolved/01-09-recovery-flow.md for the debug record.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Patches the RECORDING_ERROR onMessage handler in src/background/index.ts
(lines 725-744 pre-patch) with conditional routing on the incoming
`message.error` payload:
- 'user-stopped-sharing' → setIdleMode() (popup empties; badge OFF;
isRecording flipped to false). Recovery notification suppressed —
the operator stopped deliberately, surfacing one would be UX noise.
The offscreen recorder's onUserStoppedSharing has already cleared
the buffer (src/offscreen/recorder.ts:457 resetBuffer), so IDLE is
the correct landing state.
- all other codes → setErrorMode() + recovery notification, preserving
the existing operator-facing surface for genuine capture failures
(codec-unsupported, wrong-display-surface, capture-failed, etc.).
Closes the operator-lockout regression observed in Plan 01-09 Task 5
empirical UAT: after Chrome's "Stop sharing" banner click, the badge
stayed yellow and the popup pinned to SAVE-only, gating
chrome.action.onClicked behind the popup forever. Operator had no
restart path. With IDLE routing, the popup empties and the toolbar
click fires startVideoCapture as designed.
Tests: 83/83 GREEN (was 81; +2 from Tests E+F). tsc clean. Build exit 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Tests E + F to tests/background/badge-state-machine.test.ts pinning
the conditional-routing contract for RECORDING_ERROR onMessage:
E (RED today): RECORDING_ERROR{error:'user-stopped-sharing'} must route
through setIdleMode — badge OFF (text '', red #D32F2F), popup ''. The
current handler routes ALL codes through setErrorMode, locking the
operator out of restart (popup wins toolbar.onClicked forever).
F (GREEN today, preserved after fix): RECORDING_ERROR with any other
error code (representative: 'codec-unsupported') continues to route
through setErrorMode — badge ERR + yellow #F9A825 + popup html. This
is the defensive-fallback regression pin guarding against the patch
over-rotating to IDLE for all codes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HANDOFF.json artifact consumed per /gsd-resume-work workflow
(one-shot resumption pointer). STATE.md synced forward to reflect
true mid-Plan-01-09 state — status flipped planning→executing,
progress 90→92, Current Position now shows Phase 1 REOPENED with
Plan 01-09 Bug B pending and Plan 01-10 Wave 3 pending.
Session continuity records:
- Pause checkpoint commit: ed82fd6
- Bug A icons commit: a881bf0
- Intel docs unlock commit: f768498
- Next: /gsd-debug session for Bug B state-machine routing
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
icons/icon{16,48,128}.png at 574/1153/2615 bytes — all above Chrome
imageUtil silent-rejection floors per assets-spec.md A-01/A-02/A-03.
Auto-generated via ImageMagick (Path A pathway). Branded variants
swap in cleanly when design team delivers (Path B/C).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan 01-09 Task 4 GREEN — flips all 13 Task 3 RED tests to GREEN:
src/background/index.ts:
• Badge palette + notification id prefix constants (SCREAMING_SNAKE).
• setBadgeState(state) helper: 3-state machine REC/OFF/ERROR with
deterministic setBadgeText + setBadgeBackgroundColor + setTitle.
Each chrome call wrapped in try/catch (defense in depth).
• setIdleMode / setRecordingMode / setErrorMode helpers — drive the
setPopup dance: '' in OFF (so onClicked fires), html path in REC/
ERROR (so popup opens for SAVE).
• startVideoCapture wires setRecordingMode on success, setErrorMode
in catch.
• chrome.action.onClicked.addListener — direct toolbar-to-picker flow
(no popup needed for start). isRecording guard prevents double-start.
• chrome.runtime.onStartup.addListener — fires once per browser
session; creates mokosh-startup- notification inviting recording.
• chrome.notifications.onClicked.addListener — T-1-09-01 spoofing
mitigation via 'mokosh-' prefix gate; clears notification + invokes
startVideoCapture (notification click is a valid activation gesture).
• RECORDING_ERROR onMessage branch — setErrorMode + creates a
mokosh-recovery- notification inviting the operator to restart.
• initialize() calls setIdleMode at SW boot — ensures fresh OFF state
on every (re-)spawn including Chrome's idle-eviction respawn.
• All new listener registrations wrapped in try/catch so unit-test
chrome stubs that don't define action/notifications/onStartup don't
crash SW load (preserves the 5 pre-existing request-id-protocol +
1 port-lifecycle-continuous tests as GREEN).
src/popup/index.ts:
• Removed checkPermissions + requestPermissions functions entirely
(no more REQUEST_PERMISSIONS round-trip on popup open).
• popupState defaults isRecording=true, hasPermissions=true under
SAVE-only charter — the popup ONLY opens when recording is active
(REC/ERROR setPopup html path), so SAVE button is always enabled.
• init() calls updateUI() directly (no async permission probe).
• Empty-state copy updated: 'Откройте запись через иконку расширения'
(Open recording via the extension icon — points operator back to
the toolbar for starting a new session).
• saveArchive() simplified: no permission re-check.
manifest.json:
• Added 'notifications' to permissions array (preserves all existing).
• default_popup retained — popup still opens in REC/ERROR modes.
smoke.sh (W-04 5-sub-step update):
• SHARE_TARGET='Entire screen' (was 'Mokosh Smoke Test').
• Added 14-line locale-fallback comment block citing Chromium
generated_resources.grd as authoritative source + 4 known locale
strings + KEEP_PROFILE=1 fallback path.
• <title> changed to 'Mokosh Smoke Test — monitor mode' to keep tab
title distinct from the screen-source string.
• <ol> instruction updated: picker auto-accepts entire screen, not
the tab. Body intro paragraph also updated.
• T+/wall timer overlay (commit 923aaca) preserved — no behavioral
change to polling/Downloads-snapshot/ffprobe-gate logic.
Tests: 13/13 new GREEN; full suite 18 files / 81 tests / all GREEN.
tsc --noEmit exit 0. npm run build exit 0; dist/manifest.json has
'notifications' permission. Tier-1 SW-bundle-import gate (Layer 1 + 2)
remains GREEN.
Plan 01-09 Task 3 RED — 13 tests across 3 new files:
tests/background/toolbar-action.test.ts (5 tests):
A: chrome.action.onClicked.addListener registered at SW init
B: onClicked while not recording triggers startVideoCapture
C: onClicked while isRecording does NOT double-start
D: setPopup('') in OFF mode, popup html path in REC mode
E: popup init does NOT send REQUEST_PERMISSIONS + saveButton enabled
(W-02 fix — without jsdom, uses node-env document stub)
tests/background/badge-state-machine.test.ts (4 tests):
A: REC state = text 'REC' + #00C853 green + Recording title
B: OFF state = text '' + #D32F2F red + Not recording title
(fired at SW init via initialize → setIdleMode)
C: ERROR state = text 'ERR' + #F9A825 yellow + error title
D: RECORDING_ERROR onMessage triggers setBadgeText('ERR') within microtask
tests/background/onstartup-notification.test.ts (4 tests):
A: chrome.runtime.onStartup.addListener registered at SW load
B: onStartup fires exactly one mokosh-startup- notification
with basic type + 'Mokosh ready' title + Click-instructed message
C: notifications.onClicked with mokosh- id clears + triggers START_RECORDING
D: RECORDING_ERROR onMessage triggers mokosh-recovery- notification
Task 4 will flip all 13 to GREEN by adding the listeners + state machine
+ helpers in src/background/index.ts, popup SAVE-only, manifest update.
Deviation Rule 3: jsdom not in node_modules; refactored Test E to use a
node-env document stub instead of @vitest-environment jsdom pragma.
Plan 01-08 Task 5 closeout. The post-B+ smoke run produced a working
single-EBML WebM (28.76s, 676 frames, 1.89 MB, monotonic 0→28.76s
timestamps). Operator-confirmed empirically (timer overlay in smoke
HTML showed the latest frames matched expectations).
Two-fixture split resolves a test-design conflict surfaced when
last_30sec.webm flipped from pre-remux input shape to post-remux
output shape:
- tests/fixtures/last_30sec.webm — POST-REMUX output (single EBML,
41 ffmpeg dry-run lines). Validates webm-playback.test.ts'
playable-duration + structural assertions.
- tests/fixtures/raw-3ebml-concat.webm — PRE-REMUX input (3-EBML
concat, 299 ffmpeg dry-run lines = 3 segment boundaries).
Preserved from the original 2026-05-15 Phase 1 closure fixture.
Used by webm-remux.test.ts to test that remuxSegments correctly
transforms 3-EBML input → single-EBML output.
tests/background/webm-remux.test.ts FIXTURE_PATH updated to point at
raw-3ebml-concat.webm; the hardcoded EBML byte offsets [0, 509038,
970967] and frame bounds [905, 912] remain valid against that
preserved input.
Result: 64/64 vitest GREEN (was 61/64). tsc clean. Build exit 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The smoke test page now displays a fixed top-right overlay showing
elapsed-since-load (T+) and wall-clock (HH:MM:SS). Operator can:
- Note timer values at save-click moment
- Examine the saved WebM's last frame for the visible timer values
- Compute (save-click value − last-frame value) = operator-visible
"stale gap" the D-13 architecture leaks
This converts the subjective "video isn't latest" observation into a
precise measurement, enabling correct routing:
- Gap ≤ 10s → matches D-13 in-flight-segment trade-off (architectural,
not a regression; would inform a follow-up plan to reduce the gap)
- Gap > 10s → real regression (ring buffer rotation broken or similar)
Pure diagnostic addition to smoke.sh; no extension code changed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 01-08 fix took TWO iterations, not one. Iteration 1
(commits 52c7636 + 74400ae, archived in cc6e81a) resolved
the SW INIT crash via resolve.alias for ebml + chrome.* mock
for the Tier-1 Layer 1 gate. That landing masked a SECOND
defect — ts-ebml's EBMLDecoder constructor crashes with
`ReferenceError: Buffer is not defined` because MV3 SW has
no Buffer global. The runtime path is unreachable at module
init (EBMLDecoder is only constructed when remuxSegments
runs, which only fires from the SAVE_ARCHIVE handler), so
Layer 1 of the gate could not catch it.
Iteration 2 (commits dd7bf00 + 761dfc0) closed that gap by
extending the Tier-1 gate to Layer 2 (source-imports
webm-remux.ts, invokes remuxSegments — caught the Buffer
bug empirically) and applying B+ — vite-plugin-node-polyfills
with narrow Buffer-only config — to provide Buffer at SW
runtime via bundler-level import rewrite.
Updates to the debug archive:
- frontmatter `updated:` bumped to 12:25Z
- two new Evidence entries (12:15Z Layer 2 RED, 12:20Z B+
GREEN) document the iteration-2 empirical path
- one new Eliminated entry: "C-config alone is sufficient" —
FALSIFIED by Layer 2 (the resolve.alias fix from iteration
1 is necessary but not sufficient; ts-ebml's runtime Buffer
use is an orthogonal concern that requires the polyfill)
- Resolution.root_cause rewritten to describe BOTH defects
(bundler-config + runtime-Buffer) and explain why they
surfaced sequentially
- Resolution.fix rewritten with iteration-1 / iteration-2
structure, citing all 4 commits across both iterations
- Resolution.verification rewritten with explicit Layer 1
vs Layer 2 verification claims and the full vitest count
(62 passing, 2 failing — pre-existing fixture-dependent
webm-playback duration tests, unchanged)
- Resolution.files_changed lists all 4 commits across both
iterations + this archive update
The session was correctly resolved-and-archived after
iteration 1 with the information then available; iteration
2 is an additive correction once the extended gate surfaced
the second defect. Per the project's
feedback-pre-checkpoint-bundle-gates memory, the extended
Tier-1 gate is now the canonical bundle-loadability check
any future plan executor with SW surfaces must run before
operator-empirical checkpoints.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original Layer 1 gate (74400ae) verified module-init under
SW-simulated globals. It did not exercise remuxSegments — the
actual runtime code path the SW reaches on SAVE_ARCHIVE.
Layer 2 imports webm-remux.ts as SOURCE in a spawned Node child
under SW-simulated globals, invokes remuxSegments with a synthetic
single-segment EBML payload, and classifies the outcome:
- `ok` (returned a Blob) or `domain_error` (e.g. invalid EBML
header — proves runtime path is structurally reachable) → PASS
- `sw_incompat` (ReferenceError for Node globals, EvalError /
unsafe-eval for CSP) → FAIL with the specific error surfaced
This is the gate that empirically caught the ts-ebml Buffer issue
addressed by the preceding polyfill commit; it closes the loop
between "bundle loads" (Layer 1) and "bundle works at runtime"
(Layer 2).
Polyfill-aware design: Layer 2 leaves `Buffer` AVAILABLE in the
child env (split strip list: SW_SOURCE_STRIP_GLOBALS omits
'Buffer'). The vite-plugin-node-polyfills rewrite is BUNDLER-LEVEL
(Buffer → imported polyfill chunk) and does not apply when source
is loaded outside Vite. Leaving Buffer available faithfully
models what the polyfilled bundle provides at SW runtime, while
keeping the classifier ready to flag Buffer regressions if the
polyfill ever gets removed. `process`/`window`/`document` remain
stripped (polyfill is configured globals.process: false; SW
genuinely lacks DOM).
Node 24 native TS transform (`--experimental-transform-types`)
is used for source loading; a tiny inline resolution hook
appends `.ts` to extensionless relative specifiers, mimicking
vite/rollup's extension policy. Hook is base64-encoded as a
data: URL so the test stays self-contained (no on-disk hook file).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layer 2 of the extended SW-bundle-import gate caught a runtime
ReferenceError: Buffer is not defined at EBMLDecoder.constructor
(this._buffer = Buffer.alloc(0)). Reached from remuxSegments via
extractFramesFromSegment for every input segment — would crash
the SW on every SAVE_ARCHIVE click in real Chrome.
ts-ebml has a 5-year-old open issue (legokichi/ts-ebml#37,
"Can't use Buffer in browser") acknowledging the incompatibility
with no maintainer fix. The canonical Vite workaround is
vite-plugin-node-polyfills with a narrow Buffer-only config (per
the plugin author's official docs).
Changes:
- vite-plugin-node-polyfills@0.27.0 added as devDependency
- vite.config.ts adds nodePolyfills plugin with narrow config:
include: ['buffer'], globals.Buffer: true, globals.global: false,
globals.process: false, protocolImports: false (Buffer only, no
stdlib pull-in)
- bundle delta: SW chunk 373.05 kB (-0.49 kB vs C-config alone);
+27.48 kB shared polyfill chunk (index-CgqXENQe.js, used by SW
and offscreen). Net cost ~26.3 kB for full Buffer support.
Bundle verification:
- bundled EBMLDecoder.js now reads `this._buffer = me.alloc(0)`
where `me` is the imported polyfill Buffer (was `Buffer.alloc(0)`
against undefined globalThis.Buffer). Same rewrite applied to
all 3 Buffer.alloc/Buffer.concat/Buffer.from sites in ts-ebml.
- bundle does NOT depend on globalThis.Buffer (the polyfill
rewrites references as imports, not as global assignments) —
Layer 1 of the gate still strips Buffer from globalThis and
passes, confirming this.
Layer 2 gate: RED → GREEN. resolve.alias.ebml fix from commit
52c7636 preserved — still required for ebml CJS-interop;
the polyfill addresses an orthogonal runtime concern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Tier-1 SW-bundle-loadability gate (c75854c) stripped
Buffer/process/window/document from the spawned Node isolate
but did not mock chrome.*. A correctly-bundled SW that reaches
addListener calls at module init would (correctly) progress to
chrome.runtime.onMessage.addListener(...) and throw
ReferenceError because chrome was undefined — a false-positive
RED.
This commit adds a minimal Proxy-based chrome.* stub that
no-ops any chrome.<api>.<method>(...) chain. The gate now
verifies what its file-header comment claims: "bundled artifact
reaches module-init completion under SW-simulated globals."
RED->GREEN: the gate now correctly passes against the post-fix
bundle and would catch any future regression in SW
bundle-loadability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds tests/background/sw-bundle-import.test.ts that loads the built SW
chunk under SW-simulated globals (Buffer/process/window/document stripped)
via a spawned Node child process. Pins the orchestrator-side gap that
caused Plan 01-08's SW init crash: the prior deps test only checked
SOURCE packages under default Node globals, never the bundled output, so
Vite/Rollup's CJS-interop bug (tree-shaking the `ebml` package while
leaving a dangling `{tools:f}=Pc` destructure against an empty Pc) went
undetected until operator empirical smoke.
RED against HEAD aabbd0c — failure surfaces the exact production error
("Cannot read properties of undefined (reading 'readVint')"), proving
the test is a true regression gate, not a tautology.
Also rewrites .planning/debug/01-08-sw-incompatibility.md to reflect the
actual root cause (Vite/Rollup CJS interop) rather than the orchestrator's
initial falsified hypothesis (new Function + Buffer globals — disproven
by Node simulation showing the throw fires at module-init line 12:33809
before any CSP-eval or Buffer-ref code path executes).
Full vitest: 60 passing + 3 RED (this gate + the 2 pre-existing Task 5
fixture-dependent duration tests). No regressions.
Per feedback-pre-checkpoint-bundle-gates.md (auto-loaded memory): any
future plan executor whose work surfaces a SW must run this test before
any operator-empirical checkpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- D-14-remux WebM remux pipeline (ts-ebml parse + webm-muxer write)
replaces D-13 file-concat; single-EBML-headered output empirically
spans 29.954 s of 912 VP9 frames (matching the per-segment sum
301+300+311 with zero loss).
- All 5 RED unit tests in tests/background/webm-remux.test.ts flipped
GREEN; 2 SW-compat tests in webm-remux-deps.test.ts GREEN; 53 baseline
tests preserved. tsc exit 0. npm run build exit 0.
- mergeVideoSegments deleted from src/background/index.ts; only a
retirement comment naming Plan 01-08 D-14-remux remains.
EmptyVideoBufferError surface preserved (W-01 free-text rename only).
- CONTEXT.md amendment provenance verified intact (B-01 grep checks all
pass — no file mutation by this plan; the orchestrator landed the
amendment at plan-creation time in commit 2e499d7).
- 2 deviations documented (Rule 1: tsc-required codec field in
EncodedVideoChunkMetadata; Rule 3: stale comment cleanup in
decodeBufferSegments). No scope creep.
- Self-check: all 6 files + 4 task commits verified present.
- Task 5 = checkpoint:human-verify (operator regenerates
tests/fixtures/last_30sec.webm via ./smoke.sh, confirms Chrome + mpv
playback ~30 s, flips the 2 webm-playback duration tests GREEN).
- src/background/index.ts now imports remuxSegments from './webm-remux'
and awaits it in createArchive instead of synchronously calling the
retired file-concat mergeVideoSegments.
- mergeVideoSegments function declaration deleted entirely; only a
retirement comment remains naming Plan 01-08 D-14-remux as the
superseding decision.
- EmptyVideoBufferError throw paths preserved on (a) zero segments
AND (b) zero-byte output. Error message free-text changed from
"merged video blob is zero bytes" to "remuxed video blob is zero
bytes"; pre-flight grep (W-01 fix from plan checker pass)
confirmed no downstream consumer matches on the legacy string —
request-id-protocol.test.ts asserts on error.code ('empty-video-
buffer'), not the free-text message.
- createArchive remains async (was already declared async); saveArchive
already awaits createArchive so no upstream signature changes.
- Stale comment in decodeBufferSegments referencing mergeVideoSegments
updated to reflect the new remux pipeline (Rule 3: keep forward-
references accurate).
- CONTEXT.md amendment provenance verified intact via 4 grep checks
(B-01 fix from plan checker, folded from retired Task 6):
(a) D-14-remux disambiguated marker present (1 match)
(b) original D-13 line preserved (1 match)
(c) D-17-port-lifecycle amendment intact (1 match)
(d) webm-remux.ts replaces citation present (1 match)
No CONTEXT.md mutation by this task — verify-only step.
- npm run build exit 0; main SW bundle 374.56 KB (108.44 KB gzipped,
matches the d13 library survey's ~100 KB estimate for ts-ebml +
webm-muxer combined).
- Full suite: 13 files / 60 GREEN + 2 RED (webm-playback duration
assertions waiting on Task 5 fixture regen). tsc exit 0.
Drives all 5 RED tests in tests/background/webm-remux.test.ts to GREEN.
- Parses each VideoSegment via ts-ebml Decoder; tracks current Cluster
Timestamp; extracts each SimpleBlock's VP9 frame(s) + keyframe flag
+ segment-local timestamp via tools.ebmlBlock.
- Re-emits all frames through a single webm-muxer Muxer<ArrayBufferTarget>
configured with type:'webm', codec:'V_VP9', and adjusted monotonic
timestamps (segmentBaseMs + cluster.Timestamp + block.timecode,
microseconds for the muxer).
- Picks track info (PixelWidth, PixelHeight, optional CodecPrivate)
from first segment that exposes them; falls back to 1024x768 with
a logged warning per Task 5's failure-mode (e).
- Defensive: empty input -> empty Blob (Test 5); sort by timestamp
ascending (mirrors retired mergeVideoSegments order discipline).
- 434 LOC including extensive JSDoc per project style; 8 small named
helpers, no nested mega-functions.
- Empirically: 3-segment fixture -> 912 frames in 29.954 s,
1_643_057 bytes (single-EBML); ffprobe duration=29.94s, count_frames=912.
- Logging via new Logger('Remux'); no console.* anywhere; no as any;
no @ts-ignore.
Full suite: 13 files / 60 GREEN + 2 RED (webm-playback duration assertions
still failing against the stale fixture — Task 4 swaps the call site,
Task 5 regenerates the fixture). tsc exit 0.
- Add ts-ebml ^3.0.2 (parse half) and webm-muxer ^5.1.4 (write half) per
CONTEXT.md amendment D-14-remux; both MIT, both verified SW-compatible
in the d13 debug-session library survey.
- tests/background/webm-remux-deps.test.ts pins two contracts:
(a) named exports surface (Muxer + ArrayBufferTarget + Decoder).
(b) both libraries import cleanly when window/document are absent on
globalThis — guards the published dist against accidentally
acquiring DOM globals on the hot path that would crash the
Chrome service-worker runtime.
- Note: webm-muxer 5.1.4 upstream-deprecated in favor of Mediabunny; the
pinned version still meets the d13 architectural requirement
(single-EBML output via addVideoChunkRaw). Migration to Mediabunny is
out of scope for Plan 01-08 and would require a new ADR.
- Baseline 53 GREEN + 2 new GREEN; tsc clean; 2 webm-playback duration
RED still pending (drive to GREEN in Tasks 3-5).
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>
REQ-video-ring-buffer flipped from [x] back to [ ]. ROADMAP.md Phase 1
row reverted from [x] Closed 2026-05-15 to [ ] reopened 2026-05-16.
STATE.md status flipped phase_complete → phase_reopened with full
historical narrative preserved.
Root cause (confirmed at byte level by gsd-debugger 2026-05-16):
D-13's concat-of-self-contained-WebM-segments architecture produces a
3-EBML-header WebM that standards-compliant Matroska parsers
(mpv, ffmpeg, Chrome HTMLMediaElement) play only as the first segment
(~9.94 s) and silently drop the remaining 2 segments. Confirmed via
operator mpv drag-drop test of BOTH the canonical 2026-05-15 closure
fixture and the 2026-05-16 UAT-produced fixture — both exhibit the
same broken playback.
The 2026-05-15 "operator-confirmed clean Chrome playback" assessment
was insufficient: it verified the file plays without freezing but did
not measure total duration. Phase 1's primary deliverable
(REQ-video-ring-buffer / SPEC §10 #7) is therefore NOT satisfied.
Fix path chosen by user: ts-ebml (parse) + webm-muxer (write) to
replace mergeVideoSegments file-concat with real single-EBML remux.
Will land as Plan 01-08 via fresh /gsd-plan-phase ceremony.
RED test landed in tests/offscreen/webm-playback.test.ts (2 new
assertions on container-format-duration + ffmpeg-full-decode-duration).
2 failures, 53 baseline tests still GREEN.
Option C port-lifecycle refactor (debug session
empty-archive-port-race, commits 674c415..f0871c0) DID land cleanly
and is retained — that fix was orthogonal and correctly resolved the
silent-empty-archive symptom that previously masked this deeper bug.
Debug session: .planning/debug/d13-multi-ebml-concat-unplayable.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two doc updates closing the debug session per the resolved pattern this
phase has established (cf. resolved/d12-blob-port-transfer-fails.md and
resolved/webm-playback-freeze.md):
1. **Move debug session to resolved/** with the Resolution section
filled in (root_cause, fix, verification, files_changed). Status
flipped tdd_red_confirmed -> resolved. Original investigation
notes + bisect results + Option C strategy spec all preserved
in-place — the file is the full provenance trail.
2. **Amend 01-CONTEXT.md D-17** with the new port lifecycle commitments.
Append-only (D-17 itself untouched) per the doc cascade rule
established earlier this phase ("amendments append, do not replace,
to preserve SPEC provenance"). The amendment narrates:
- What was Claude's-discretion at Phase 1 plan time has been
specified by Option C.
- The 290 s pre-emptive setTimeout reconnect (Pitfall 4) is RETIRED.
- The architectural commitments added: PING/PONG health probe,
request-id'd REQUEST_BUFFER/BUFFER, SW retry on port replacement,
outer 10 s hard-timeout, operator-visible EmptyVideoBufferError
surface.
- The 4 pinning contracts added (port-health-probe,
request-id-protocol, port-lifecycle-continuous, plus the
refactored port-reconnect-race).
Suite remains 11 files / 53 tests, all GREEN. Quality gates intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements Option C step 3 per .planning/debug/empty-archive-port-race.md:
"Continuous end-to-end vitest covering 600 s of port lifecycle
(2 reconnects + simulated REQUEST_BUFFER round-trips). Becomes the
new pinning contract for the port lifecycle."
The UAT Test 3 BLOCKER surfaced because no test exercised the full
operator timeline — 5+ minute recording with port-replacement windows
crossing real SAVE_ARCHIVE round-trips. This file pins that contract
end-to-end at the unit-test level.
What's exercised:
- Both SW (src/background/index.ts) and offscreen recorder
(src/offscreen/recorder.ts) loaded into the SAME chrome stub, with
paired port-pair factory (one connect() yields offPort + swPort
that talk to each other through captured listeners).
- 12 ping/pong cycles (~300 s simulated wall-clock).
- 3 SAVE_ARCHIVE round-trips (one before reconnect, two after each
of the two forced reconnects).
- 2 EXTERNAL port disconnects (port._disconnected=true) — simulates
the SW eviction / port glitch path that the H1.b test pins.
- JSZip mocked at file scope (vi.mock) because Node 22+ JSZip can't
read native Blobs — preserves integration shape (size accounting)
without depending on JSZip's Node compatibility.
Final assertions:
1. All 3 saveArchive calls return success:true.
2. EVERY BUFFER message that crossed the wire carried segments (no
silent-loss path was reachable).
3. PONGs round-tripped (proves health-probe loop closes).
Suite: 53 GREEN / 53 tests. tsc --noEmit exit 0; type-safety grep clean;
npm run build exit 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retires the upstream silent-skip defect (bisected to commit 555eb05 —
imported broken from before Phase 1, never on the original 22-defect
audit because it needed a real failure mode to surface). Per
.planning/debug/empty-archive-port-race.md Option C step 4: even with
the architectural port lifecycle now bullet-proof, a hard outer timeout
on the BUFFER fetch (10 s after every retry) must result in an
operator-VISIBLE failure — not a silent video-less archive.
1. **EmptyVideoBufferError** — typed error class with a stable `code`
field ('empty-video-buffer') matching the offscreen-side
CaptureErrorCode union vocabulary. Lets saveArchive's catch
distinguish "no segments" failure from JSZip/manifest failures.
2. **createArchive throws** — when videoBufferResponse.segments.length
=== 0 OR when the merged blob is zero bytes, throw the typed error
with a detail string for diagnostics. Replaces the silent-skip
branch that was the bisect-confirmed transport for the H2 class.
3. **saveArchive broadcast** — on EmptyVideoBufferError, emit
{type:'RECORDING_ERROR', error:'empty-video-buffer'} via
chrome.runtime.sendMessage. The popup's existing RECORDING_ERROR
handler surfaces the failure to the operator (same channel as
codec-unsupported, user-cancelled, etc.). saveArchive still returns
{success:false, error} so the popup's direct-response path also
sees the failure (defense-in-depth via two channels).
Status: 52 GREEN / 52 tests passing. All 12 RED tests from the Option C
gate (3 in port-reconnect-race + 4 in port-health-probe + 5 in
request-id-protocol) are now GREEN. Build clean (npm run build exit 0).
Pinning contracts intact:
- D-12 port-serialization (base64 wire format): GREEN
- D-13 segment-rotation (3 x 10 s restart-segments ring): GREEN
- A3 webm-playback (ffmpeg dry-run on fixture): GREEN
tsc --noEmit exit 0; type-safety grep clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the SW-side architectural refactor per
.planning/debug/empty-archive-port-race.md "Fix Strategy: Option C":
1. **Request-id'd protocol** — getVideoBufferFromOffscreen generates a
uuid (crypto.randomUUID with Math.random fallback) and sends
{type:'REQUEST_BUFFER', requestId} on the live videoPort. The
per-request listener pattern is GONE; replaced by a module-level
pendingBufferRequests Map<requestId, PendingBufferRequest>. The
onConnect-level message sink routes BUFFER -> resolve by id.
2. **Stale BUFFER routing** — BUFFER messages without a matching
requestId in the Map are silently dropped (no cross-talk). BUFFER
without a valid requestId at all is rejected with a warn (Option C
protocol requires the id).
3. **Retry on port replacement** — every onConnect (post-bootstrap)
scans pendingBufferRequests and re-issues REQUEST_BUFFER on the
fresh port with the SAME requestId. The offscreen posts BUFFER on
the current keepalivePort (see prior offscreen commit), the sink
matches by id, and the request resolves. This retires the H2
silent-drop class architecturally — the BUFFER reaches the SW
regardless of port-replacement timing.
4. **PING -> PONG echo** — the sink replies to every PING with PONG.
Closes the offscreen's health-probe loop (it counts missed PONGs
and reconnects when MAX_MISSED_PONGS exceeded — see prior offscreen
commit). The PONG post is wrapped in try/catch to absorb the same
port-closed-mid-response race the offscreen ping path handles.
5. **Outer hard-timeout bumped 2s -> 10s** — the legacy per-port
BUFFER_FETCH_TIMEOUT_MS = 2000 was too tight to retry across a
reconnect. The new outer budget covers EVERY retry across port
replacements; the inner round-trip is still ~100-200 ms.
6. **decodeBufferSegments extracted** — pulled out of the legacy
inline handler so the new onConnect sink can decode wire segments
without duplicating the logic. Preserves WR-07 (empty wire segment
filter) and base64ToBlob defensive catch behaviour. Closes the
pre-existing implicit-undefined-return path the legacy flatMap
catch had (tsc happy but semantically ambiguous).
Status: 51 GREEN, 1 RED. The remaining RED (createArchive must throw
on empty video, surfacing to operator) is addressed in the next commit.
Pinning contracts (D-12 port-serialization, D-13 segment-rotation,
A3 webm-playback) untouched. tsc --noEmit exit 0; type-safety grep clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the offscreen-side architectural refactor per
.planning/debug/empty-archive-port-race.md "Fix Strategy: Option C":
1. **Retired** the 290_000 ms pre-emptive reconnect setTimeout. Its race
window between the synchronous .disconnect() and the onDisconnect
handler running was the bisect-confirmed proximate cause of the H1
"Attempting to use a disconnected port object" Uncaught Errors.
2. **Added** PONG-based health probe: each ping increments missedPongs;
if MAX_MISSED_PONGS (3) consecutive PINGs go without echo, reconnect
via the same clean teardown path the onDisconnect handler uses.
PONG receipt resets the counter. Liveness-based replacement for the
time-based pre-emptive rotation.
3. **H1 fix** — wrap PING postMessage in try/catch. The port object can
transition to disconnected synchronously (SW eviction, port glitch)
between the interval-callback being queued and it running. The catch
absorbs the throw and routes through reconnectPort() — no more
uncaught throws bubble out to the offscreen console.
4. **Request-id'd protocol** — REQUEST_BUFFER carries the SW-generated
requestId; BUFFER response echoes it. The offscreen now posts on the
CURRENT keepalivePort (no more portAtRequest stale-port refuse-to-
post). The SW matches BUFFER → request by id, so port replacement
mid-encode no longer drops the response — the SW retries on the new
port and the matching BUFFER routes correctly.
5. **reconnectPort(reason)** — new helper consolidating the
teardown+disconnect+reconnect dance used by both the missed-PONG
path and the synchronous-throw path. Idempotent w.r.t. the chained
onDisconnect callback.
Test updates:
- H2 now sends REQUEST_BUFFER with a requestId (Option C contract).
- H1.b refactored to test the externally-disconnected path (since the
pre-emptive timeout path is gone): port._disconnected=true, fire
ping, assert no throw + a fresh port appears.
- Top-level snapshots of timer globals + afterEach restoration so a
failing test doesn't leak overridden globals into the next test.
Status: 48 GREEN, 4 RED (the remaining RED is all SW-side — addressed
in next commit). All H1 + H1.b + H2 contracts now GREEN. Pinning
contracts (D-12 port-serialization, D-13 segment-rotation, A3 webm-
playback) untouched. tsc --noEmit exit 0; type-safety grep clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure type-extension step for the Option C architectural refactor of the
offscreen↔SW port lifecycle (.planning/debug/empty-archive-port-race.md).
PortMessage.requestId is optional so PING/PONG keep their no-payload
shape. REQUEST_BUFFER and BUFFER will populate it once the recorder
(offscreen) and getVideoBufferFromOffscreen (SW) are wired up in the
next commits. The narrowing remains structural — every consumer must
still validate `type` before accessing `requestId`.
PONG joins PING as a no-payload liveness signal. The SW echoes PONG on
PING, closing the health-probe loop the offscreen uses to detect a dead
port (replacing the 290 s pre-emptive setTimeout that was the proximate
cause of the H1 race window per the bisect).
No runtime change. 52 tests, 10 RED still RED (waiting for impl).
tsc --noEmit exit 0; type-safety grep clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per .planning/debug/empty-archive-port-race.md "Fix Strategy: Option C
(Architectural)", land RED tests that pin the 4 sub-behaviours the
refactor must satisfy at the unit level. These complement the operator-
facing contract already pinned by port-reconnect-race.test.ts (H1+H2).
Offscreen side (tests/offscreen/port-health-probe.test.ts):
A. Bootstrap installs no 290_000 ms pre-emptive reconnect timer
(the timing-based race window from b064a21 is gone).
B. Missed PONGs (5 PINGs without echo) trigger a clean reconnect via
the same path the onDisconnect handler uses.
C. PONG echoes within timeout keep the port alive indefinitely
(counter-test for over-eager probe — already GREEN today).
D. REQUEST_BUFFER with requestId → BUFFER response echoes the same id
(the architectural mechanism that retires cross-talk).
SW side (tests/background/request-id-protocol.test.ts):
1. getVideoBufferFromOffscreen sends REQUEST_BUFFER with a generated
uuid requestId on the live videoPort.
2. Stale BUFFER (mismatched requestId) is ignored — no resolution.
3. Port replacement mid-request → SW re-issues REQUEST_BUFFER on the
new port with the SAME requestId. Retires the H2 silent-drop class.
4. Empty video segments → saveArchive returns {success:false, error}
(operator-visible) instead of {success:true} with no-video archive.
5. SW echoes PONG on PING, closing the health-probe loop.
Suite status: 10 files / 52 tests (42 GREEN, 10 RED).
- 40 baseline + 2 new GREEN (port-health-probe C; request-id 2 & 4
accidentally pass due to test-stub side effects — they will continue
to pass after fix for the right reasons).
- 3 RED in port-reconnect-race + 3 RED in port-health-probe + 4 RED
in request-id-protocol.
Quality gates: tsc --noEmit exit 0; type-safety grep clean.
No production code touched in this commit — fix lands in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 UAT Test 3 surfaced a two-headed BLOCKER:
(a) silent empty-video archive when save crosses a port-reconnect window,
(b) 3x "Attempting to use a disconnected port object" Uncaught Errors
starting at the 290 s pre-emptive reconnect mark.
Bisect confirmed: H1 (port lifecycle race) was introduced by Plan 01-04
(b064a21); H2 (createArchive silent-skip on empty segments) is an upstream
defect (555eb05) that became fatal once CR-01 + sweep #5 guaranteed the
silent-skip branch would fire on every save during a reconnect window.
This commit lands the 3 RED tests at the unit-test level — they match the
UAT error string byte-for-byte for H1/H1.b and pin the silent-drop
contract for H2. They will flip GREEN as the Option C architectural
refactor (request-id'd port protocol + port-health probe + retry +
operator-visible error surface) lands across the next commits.
Baseline: 8 files / 43 tests (40 GREEN, 3 RED). tsc --noEmit exit 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>