Commit Graph

81 Commits

Author SHA1 Message Date
c647f61553 wip(01-11): prototype — A6 via test-page+bridge+synthetic-stream PASSES
Plan 01-11 orchestrator commissioned a research+prototype investigation
into whether full MV3 UAT automation is feasible with the architecture:
extension-internal test page + chrome.runtime.sendMessage bridge +
synthetic MediaStream (canvas-captureStream + getSettings override).

EMPIRICAL VERDICT: feasible BUT plan 01-11 needs architectural revision.

Architectural findings (with proof):

1. DYNAMIC IMPORT BLOCKED IN MV3 SW. Top-of-module
   `await import('../test-hooks/sw-hooks')` in src/background/index.ts
   silently kills the SW (chunk loads, await never resolves, no
   production listeners register, no console output). This is by design
   per Chromium docs (es_modules.md) + w3c/webextensions#212. The Plan
   01-11 RESEARCH §6 architecture was wrong for the SW side.
   Workaround in this prototype: REMOVE the SW-side gated dynamic
   import. SW-side test hooks need a different design (see verdict).

2. OFFSCREEN-SIDE DYNAMIC IMPORT WORKS. Offscreen is a DOM document,
   not a SW, so top-level await + dynamic import behave normally. The
   offscreen-hooks.ts gated import succeeds; installFakeDisplayMedia is
   installed eagerly at module load.

3. EXTENSION-INTERNAL PAGE HAS FULL chrome.* SURFACE. Reachable via
   chrome-extension://<id>/tests/uat/prototype/extension-page-harness.html
   (added as rollup input in vite.test.config.ts). The page can call
   chrome.action.getBadgeText, chrome.action.getPopup, chrome.offscreen
   .createDocument, chrome.notifications.getAll, chrome.runtime
   .sendMessage — everything needed for A6.

4. NO 'tabs' PERMISSION → tab.url IS UNDEFINED. Production
   startVideoCapture's `chrome.tabs.query({active:true})` check
   (`if (!tab.id || !tab.url) throw`) fails because the manifest lacks
   the 'tabs' permission. Prototype workaround: bypass startVideoCapture
   by sending START_RECORDING directly to offscreen. The Bug B
   contract being tested is independent of how recording starts; it
   only depends on the RECORDING_ERROR routing path.

5. SYNTHETIC MEDIASTREAM WORKS. installFakeDisplayMedia builds a
   canvas-captureStream MediaStream + monkey-patches the video track's
   getSettings() to report displaySurface: 'monitor'. Production code's
   post-grant validation passes. getDisplayMedia returns the synthetic
   stream immediately — no picker, no headless flakiness.

A6 prototype result (with Bug B fix in place — current HEAD state):
  [PASS] SETUP: badge becomes REC after start
  [PASS] A6.1: badge text is '' (NOT 'ERR') after user-stop
  [PASS] A6.2: popup is '' (NOT manifest default) after user-stop
  [PASS] A6.3: NO recovery notification fired (count delta === 0)
  [PASS] A6.4: isRecording=false (via badge proxy)

A6 prototype result (with Bug B fix rewound to `if (false)`):
  [PASS] SETUP: badge becomes REC after start
  [FAIL] A6.1: badge text is '' (got "ERR")
  [FAIL] A6.2: popup is '' (got chrome-extension://.../popup/index.html)
  [FAIL] A6.3: notif delta = 0 (got 1)
  [PASS] A6.4: isRecording=false  ← false-positive (badge='ERR' not 'REC')

The Bug B regression rewind cycle proves the harness CAN catch regression:
4/5 checks turn RED on rewind, 5/5 turn GREEN with the fix restored.

Files in this commit:
- tests/uat/prototype/extension-page-harness.{html,ts} — the harness
  page (chrome-extension URL, exposes window.__mokoshHarness.assertA6)
- tests/uat/prototype/a6.test.ts — Puppeteer driver (~270 lines)
- tests/uat/prototype/probe_*.mjs — diagnostic probes used to isolate
  the SW dynamic-import blocker (probe_sw.mjs is the key one)
- src/test-hooks/offscreen-hooks.ts — added installFakeDisplayMedia +
  dispatchEndedOnTrack + __mokoshOffscreenQuery bridge handler + auto-
  install at module load
- vite.test.config.ts — added prototype harness page as rollup input;
  added modulePreload.polyfill=false (red herring; harmless)
- src/background/index.ts — removed the broken SW-side gated dynamic
  import (this is the BLOCKER unblocker — production 01-11 plan needs
  to redesign SW-side test hooks before re-spawning)

Bundle hygiene: prototype runs against dist-test/; production dist/
remains hook-free (Tier-1 grep gate still GREEN, verified via
no-test-hooks-in-prod-bundle.test.ts in the unit test suite).

Vitest baseline: 89/89 GREEN preserved.

Runtime: ~7 seconds end-to-end (launch Chrome + open page + ensure
offscreen + start recording + dispatch ended + settle + assert).

See: research return for VERDICT + recommended next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 12:06:08 +02:00
f44ca3afba wip(01-11): wave-3 partial — A1+A4 attempted, popup-bridge SW state query unreliable
Task 4 of Plan 01-11 attempted A1-A4 wiring. Empirical run reveals an
architectural blocker that needs orchestrator-level decision.

Current state after this commit (SKIP_PROD_REBUILD=1 npx tsx tests/uat/harness.test.ts):
- A0 [PASS]: production bundle hook-leak grep gate (17ms)
- A1 [FAIL]: SW bootstrap → setIdleMode — popup state never transitions
  to '' despite keepalive ping + 3s waitFor. chrome.action.getPopup({})
  from the popup page consistently returns the manifest default
  (chrome-extension://<id>/src/popup/index.html), not the '' that
  setIdleMode's chrome.action.setPopup({popup:''}) should produce.
- A2 [FAIL]: toolbar onClicked — badge never transitions to "REC" after
  page.triggerExtensionAction(extension); 8s timeout. Either the
  toolbar action isn't reaching the SW listener, OR getDisplayMedia's
  picker isn't resolving in headless mode (despite the auto-select flag).
- A3 [FAIL]: offscreen target never appears (correlates with A2 — no
  recording started, no offscreen document spawned).
- A4 [PASS]: trivially passes (offscreen count is 0 → 0, both before
  + after the click). Not a true assertion of behavior; would also pass
  if the whole extension were broken.
- A5-A13: stubbed RED per plan.

Architectural blocker (Rule 4 — needs orchestrator decision):
- Puppeteer 25.0.2 + Chrome 148 + headless cannot reliably keep the MV3
  SW alive long enough OR expose its real chrome.* state to a popup
  page query. The popup-bridge architecture (Task 3 commit dbd977c)
  works for synchronous bridge queries (snapshot, fire-on-startup)
  but does NOT reliably reflect chrome.action.setPopup / setBadgeText
  state changes initiated by the SW.

Three plausible paths forward (need orchestrator pick):

  Option A — Content-script bridge: inject a content script that
    bridges chrome.* queries to a webpage's window.* RPC surface;
    harness uses page.evaluate against the content script instead of
    popup.evaluate. Pros: content scripts have stable lifetime tied to
    the page they're injected into. Cons: content scripts have
    DIFFERENT chrome.* surface (no chrome.action API surface — they
    can't read getBadgeText / getPopup at all). Likely DOESN'T solve
    the underlying problem.

  Option B — Headful with Xvfb on CI: relax the headless requirement;
    accept Xvfb dependency. Per Plan 01-11 RESEARCH §3, RESEARCH
    claimed headless works on Chrome 148 — empirical refutation here.
    Pros: SW lifetime is more stable in headful mode; setPopup
    propagation is reliable. Cons: introduces Xvfb dep that RESEARCH
    explicitly said wasn't needed; CI complication.

  Option C — Shrink harness scope to bridge-able assertions: A0 (grep
    gate), A8 (Bug A onStartup via bridge), A9 (icon sizes via popup
    fetch), A10 (manifest via popup), A13 (zip shape — operator runs
    SAVE_ARCHIVE manually + drops zip to a known path; harness reads
    it). Skip A1-A7, A11, A12 (the ones that require live SW state
    observation through chrome.action API). Pros: ships the
    bug-A-coverage portion of the harness today; keeps Plan 01-09's
    Task 5 operator-checkpoint partly automated. Cons: doesn't retire
    operator entirely; Plan 01-09 stays open on operator-empirical
    A1-A7.

  Option D — Switch to WebDriver BiDi (the Puppeteer 25 alternative
    backend): Puppeteer 25 supports BiDi via {protocol: 'webDriverBiDi'}.
    BiDi may handle extension SW evaluation differently (different
    isolation model). Speculative — no empirical evidence either way.

What landed cleanly:
- Tier-1 hook-leak grep gate (T-1-11-01) GREEN: dist/ has zero
  __mokoshTest / simulateUserStop / getSegmentCount / setCurrentStream
  / setSegmentCountGetter / __mokoshTestQuery / __mokoshKeepalive
  occurrences after npm run build.
- Two-bundle infrastructure (dist/ vs dist-test/) operational.
- Bridge handler in sw-hooks.ts works for snapshot + fire-on-startup
  + handler-types ops (verified by no-hang on keepalivePing call).
- Existing 89-test vitest baseline preserved (no regression from any
  Wave 0/1/2/3 work).

Verification:
- npx tsc --noEmit (src/): exit 0
- npx tsc --noEmit -p tests/uat: exit 0
- npm run build: exit 0; dist/ hook-free
- SKIP_BUILD=1 npx vitest run: 89/89 GREEN
- SKIP_PROD_REBUILD=1 npx tsx tests/uat/harness.test.ts:
  2/14 passed (A0 + A4-trivially), 12 FAIL — non-zero exit as expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:24:06 +02:00
dbd977c815 feat(01-11): wave-2 — Puppeteer harness scaffolding + A0 GREEN, popup-bridge architecture
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>
2026-05-18 09:14:58 +02:00
cb1a729962 feat(01-11): wave-1 — gated test hooks for SW + offscreen, dist/ stays hook-free
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>
2026-05-17 22:46:26 +02:00
96fa8e8e11 chore(01-11): wave-0 — install puppeteer + tsx, add vite.test.config + Tier-1 hook-leak grep gate
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>
2026-05-17 21:42:46 +02:00
91b4475ea1 test(01-09): RED — Bug B route user-stopped-sharing → IDLE; other codes → ERROR
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>
2026-05-17 16:35:27 +02:00
2d7ff7d4e3 test(01-09): RED — toolbar-onClicked + badge state machine + onStartup notification + popup SAVE-only
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.
2026-05-17 15:27:41 +02:00
333e0dcb18 test(01-09): RED — displaySurface:'monitor' + cursor:'always' constraint contract
Plan 01-09 Task 1 RED — pins 4 tests for D-15-display-surface contract:
1. getDisplayMedia called with strict {video:{displaySurface:'monitor',
   cursor:'always'},audio:false} (deep-equality, NOT objectContaining).
2. Non-monitor pick (browser/window) tears down stream + emits
   RECORDING_ERROR wrong-display-surface.
3. Monitor pick does NOT trip wrong-display-surface (over-fire guard).
4. classifyCaptureError routes 'wrong-display-surface' message prefix
   to 'wrong-display-surface' code.

Task 2 will flip Tests 1, 2, 4 to GREEN by adding constraints +
post-grant validation + extending CaptureErrorCode union.

Deviation Rule 3: navigator getter-only in Vitest's node env required
Object.defineProperty wrapper (installNavigatorStub helper) instead
of direct assignment.
2026-05-17 15:08:35 +02:00
e40949d1d2 test(01-08): regenerate last_30sec.webm fixture + split remux input/output fixtures
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>
2026-05-17 12:44:50 +02:00
761dfc0388 test(debug-01-08): extend Tier-1 gate to Layer 2 (exercises remuxSegments)
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>
2026-05-17 12:18:04 +02:00
74400ae6ac test(debug-01-08): complete SW-bundle-import gate — mock chrome.* surface
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>
2026-05-17 11:16:05 +02:00
c75854cbef test(debug-01-08): RED Tier-1 SW-bundle-loadability gate + corrected hypothesis
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>
2026-05-17 09:52:45 +02:00
407e683e9b test(01-08): RED unit tests for remuxSegments — single-EBML + monotonic + frame-count + size + empty
- 5 RED tests pinning the contract for src/background/webm-remux.ts
  (created in Task 3). All fail with "module missing" today — the
  Task 3 GREEN gate.
- Test 1: exactly 1 EBML header + 1 Segment magic in output.
- Test 2: output size within [0.7x, 1.3x] of input sum.
- Test 3: ffprobe format=duration >= 25_000 ms (skip-if-no-ffprobe).
- Test 4: ffprobe -count_frames in [905, 912] (per-seg sum 912 ± 3 boundary
  partial-frame drops, I-01 tightening).
- Test 5: empty input -> empty Blob (defense-in-depth).
- Fixture sliced at d13-confirmed byte offsets (0 / 509038 / 970967);
  verified against committed last_30sec.webm at Task 2 land time.
- Baseline counts: 13 files / 62 tests / 7 failed (2 webm-playback +
  5 new webm-remux) | 55 passed. tsc exit 0.
2026-05-17 09:23:53 +02:00
503531485c feat(01-08): install ts-ebml + webm-muxer; pin SW-compat via deps test
- 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).
2026-05-17 09:22:46 +02:00
bc310d98cf revert(01): reopen Phase 1 — D-13 multi-EBML-concat is unplayable
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>
2026-05-16 19:47:47 +02:00
246eadb2ef test(option-c): continuous 600 s port lifecycle pinning contract
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>
2026-05-16 14:53:47 +02:00
1fb3e978cb feat(option-c-offscreen): port health probe + request-id'd BUFFER + H1 try/catch
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>
2026-05-16 14:37:22 +02:00
4306d59dfd test(option-c): RED gate for request-id'd port protocol + health probe + error surface
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>
2026-05-16 14:27:17 +02:00
674c415945 test(debug-empty-archive): RED gate for empty-archive-port-race (H1 + H1.b + H2)
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>
2026-05-16 14:17:45 +02:00
08a79a61ac fix(01-review): sweep #1 stopRecording nulls mediaStream first to prevent rotation race 2026-05-16 10:52:59 +02:00
680eee3cc7 fix(01-review): IN-04 delete decodeDryRun helper, retain only spawnSync-based decodeDryRunStrict 2026-05-16 10:34:03 +02:00
cb23143ccf fix(01-review): IN-03 delete vestigial ring-buffer.test.ts breadcrumb 2026-05-16 10:32:43 +02:00
e9aae09f6d fix(01-review): WR-07 base64ToBlob empty-input shortcut + SW-side empty-segment filter 2026-05-16 10:24:38 +02:00
650c546a6e fix(01-review): WR-01+WR-02 stable capture error codes + pure assertCodecSupported 2026-05-16 09:49:01 +02:00
cd61cbccb6 test(01-07): commit regenerated last_30sec.webm fixture against D-13 recorder
Replaces the stale fixture committed in 87909d9 (test(fix-a3): commit
debug-session test artifacts + stale fixture) — that one was captured
under the D-09..D-11 single-continuous-recorder lifecycle and froze
~1 s into playback per the resolved A3 cluster-alignment session.

This fresh fixture (1.6 MB, VP9 1142×1038) was produced by ./smoke.sh
against the D-13 restart-segments recorder (commits 5530292..872f25d):
three self-contained 10 s WebM segments concatenated by the SW into a
multi-EBML-header file the operator confirmed plays cleanly in Chrome.

Acceptance evidence:
- ffprobe -v error -f matroska -i tests/fixtures/last_30sec.webm → exit 0
- ffmpeg -v warning -i tests/fixtures/last_30sec.webm -f null - → exit 0
  with zero decoder errors; only the expected muxer DTS-monotonicity
  warnings at segment join boundaries (non-blocking — multi-EBML-header
  concat is the documented D-13 trade-off)
- Operator confirmed end-to-end Chrome playback clean (no ~1 s freeze)
- All 30/30 unit tests green incl. empirical webm-playback.test.ts gate

This closes the D-12 + A3 acceptance gates referenced in:
- .planning/debug/resolved/d12-blob-port-transfer-fails.md
- .planning/debug/resolved/webm-playback-freeze.md
2026-05-15 21:42:13 +02:00
87909d976c test(fix-a3): commit debug-session test artifacts + stale fixture
Captures the RED contracts that the webm-playback-freeze debug
session landed (before this fix-a3 cycle started) plus the original
Plan 07 smoke fixture they run against. None of these files were
modified by this fix cycle — they are landed as-is from the debug
session to make the test history bisectable.

Files staged:
- tests/offscreen/segment-keyframes.test.ts
  Three describe blocks (~340 LOC):
    * documentation — pure-simulation tests that pin the D-09..D-11
      failure mode as executable evidence (regression guard against
      re-introducing single-continuous-recorder semantics)
    * GREEN-pinning — pure-simulation tests that pin the D-13
      segment-keyframe invariant
    * production-driven — imports src/offscreen/recorder.ts and
      asserts (i) `getSegments` exported as a function, (ii) it
      returns at most 3 Blobs. THIS BLOCK IS NOW GREEN after the
      D-13 activation in the prior commits — was the genuine TDD
      anchor for fix-a3.
- tests/offscreen/webm-playback.test.ts
  Two empirical-ffmpeg assertions on tests/fixtures/last_30sec.webm:
    * zero "Error submitting packet to decoder" lines from the
      VP9 decoder
    * no "File ended prematurely" container-finalization error
  Both STAY RED in this commit because the committed fixture is
  still the stale one from Plan 07's pre-fix smoke. They flip
  GREEN after the operator runs ./smoke.sh to regenerate the
  fixture against the D-13 recorder — see the closing message
  and the NEXT-STEP block of the resolved debug session.
- tests/fixtures/last_30sec.webm
  The 2.1 MB Plan 07 smoke artifact. Committed deliberately so
  the empirical RED test has something to run against. Will be
  overwritten by the next ./smoke.sh run (single-file rotation —
  the path is fixed by the smoke script + zip extraction step
  in the debug-session reproduction).

Verification:
- npx vitest run --reporter=dot → Tests 2 failed | 28 passed (30)
- The 2 fails are EXACTLY the two empirical-ffmpeg assertions in
  webm-playback.test.ts; the structural production-driven block
  in segment-keyframes.test.ts is fully GREEN.
- npx tsc --noEmit clean.
- npm run build succeeds.

Operator action required before Phase 1 close (Plan 07 still owns
REQ-video-ring-buffer): re-run ./smoke.sh per the documented
6-step reproduction in the debug session, then re-run
`npx vitest run tests/offscreen/webm-playback.test.ts` and
expect both assertions to flip GREEN. Plan 07 success criterion
§10 #7 (playback) lands at that point.
2026-05-15 21:16:02 +02:00
5530292270 feat(fix-a3): retire ring-buffer first-chunk pin tests, add segment-rotation contract
Per debug session webm-playback-freeze "Activation Plan" step 4: the
D-09..D-11 ring-buffer semantics (first-chunk header pin + 30 s age trim)
are being replaced by D-13 restart-segments. The pinned-header assertions
were architecture-specific and become meaningless once each segment is
a self-contained WebM with its own EBML header and seed keyframe.

Changes:
- tests/offscreen/ring-buffer.test.ts: collapsed to a single breadcrumb
  test pointing at the successor file. Kept the path so git history /
  failure bisects land on the retirement commit cleanly.
- tests/offscreen/segment-rotation.test.ts (new): 8 tests pinning the
  D-13 invariants against the production recorder module:
    * MAX_SEGMENTS = 3, SEGMENT_DURATION_MS = 10_000 (= legacy 30 s window)
    * empty-by-default, ordered, oldest-evicted-at-cap
    * resetBuffer clears
    * getSegments returns a defensive snapshot (no internal aliasing)
  Uses a `pushSegmentForTest` seam so vitest can drive rotation
  deterministically without instantiating a real MediaRecorder.

RED today by design (TDD discipline) — the segment-rotation suite
imports `getSegments`, `pushSegmentForTest`, `MAX_SEGMENTS`,
`SEGMENT_DURATION_MS` from src/offscreen/recorder.ts. Those exports
land in the next commit. tsconfig.include is "src" only so tsc stays
clean during the RED window.
2026-05-15 20:59:01 +02:00
c0d9166a1d feat(fix-d12): add binary encode/decode helpers in src/shared/binary.ts
- Add blobToBase64 / base64ToBlob in src/shared/binary.ts:
  portable Blob↔base64 round-trip for the chrome.runtime.Port
  wire-format. JSON.stringify(blob) returns "{}" across extension
  contexts, so binary payloads must travel as base64 strings.
- Mirror the GREEN-block helper signatures from
  tests/offscreen/port-serialization.test.ts so the same test pins
  both the standalone helpers and the production wire format.
- Land tests/offscreen/port-serialization.test.ts as the RED+GREEN
  executable contract for the D-12 fix: the RED block reproduces
  the 75-byte "[object Object]" failure mode byte-for-byte; the
  GREEN block pins the base64 wire-format the fix must implement.
- Uses arrayBuffer() + btoa(String.fromCharCode...) rather than
  FileReader: FileReader is browser-only; the chosen approach
  works in both Chrome extension contexts and the Node-based
  vitest environment.

Refs: debug session d12-blob-port-transfer-fails.
2026-05-15 20:06:51 +02:00
408aa3354c test(01-02): add RED handshake + port tests
Three RED tests pin Pattern 4 (handshake) and Pattern 5 / Pitfall 4
(port reconnect on disconnect) contracts:

handshake.test.ts:
- 'sends OFFSCREEN_READY after listener registration' — exactly one
  OFFSCREEN_READY emitted at module load, AFTER onMessage.addListener

port.test.ts:
- 'connects on module load' — chrome.runtime.connect called once
- 'reconnects when port disconnects' — firing onDisconnect triggers
  immediate re-connect (Pitfall 4 idle-timer reset)

chrome.runtime is stubbed locally (no vitest-chrome dependency added).
No 'as any' / no '@ts-ignore'; casts are 'as unknown as T'.

Plan 04 must wire OFFSCREEN_READY send + port.connect({ name:
'video-keepalive' }) + onDisconnect-driven reconnect at the import-side
effect layer of src/offscreen/recorder.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:25:03 +02:00
d7840a811c test(01-02): add RED codec-check tests
Two RED tests pin D-20 (codec strict-mode, no silent fallback):
- 'throws on unsupported vp9 and emits RECORDING_ERROR'
- 'does not throw when vp9 IS supported'

vi.resetModules() between tests is critical: module-load side-effects
(handshake + port connect) happen once per import, so isolation across
the four test files depends on it.

chrome.runtime is stubbed locally (no vitest-chrome dependency added,
per threat T-1-NEW-02-01 — minimize supply chain for four test files).
No 'as any' / no '@ts-ignore'; the cast is 'as unknown as T'.

Plan 03 must export assertCodecSupported() from src/offscreen/recorder.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:24:18 +02:00
2e73a21151 test(01-02): add RED ring-buffer tests
Four RED tests pin D-10 (header pinning) and D-11 (30s trim) contracts:
- 'first chunk is header' — isFirst marker on first addChunk
- 'second chunk is NOT header' — only the first is pinned
- 'trim 30s — keeps header, evicts aged tail' — header survives indefinitely
- 'trim with empty buffer does not throw' — defensive edge case

Plan 03 must export {addChunk, trimAged, getBuffer, resetBuffer} from
src/offscreen/recorder.ts to flip these to GREEN.

Also stages tests/fixtures/.gitkeep so the fixture dir survives clean
checkouts (Plan 07 drops a known-good last_30sec.webm into it after the
manual smoke test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:23:50 +02:00