Commit Graph

59 Commits

Author SHA1 Message Date
79964e62d2 feat(02-02): SW — downloadArchive via offscreen-minted Blob URL + revoke lifecycle (D-P2-01 closes P0-6)
Production changes (src/background/index.ts):
- pendingDownloadUrlResolvers Map<requestId, resolver> routes DOWNLOAD_URL
  responses back to the in-flight downloadArchive Promise; mirrors the
  pendingBufferRequests pattern from the BUFFER round-trip so port
  replacement mid-mint does not lose the response.
- pendingRevokes Map<downloadId, url> tracks (downloadId → minted blob:URL)
  for the chrome.downloads.onChanged revoke dispatch.
- onConnect port message sink extended with DOWNLOAD_URL routing branch
  (alongside existing PING/BUFFER routing).
- downloadArchive rewritten: encode archive via blobToBase64 → post
  CREATE_DOWNLOAD_URL on videoPort → await DOWNLOAD_URL response (race
  against 5s BLOB_URL_MINT_TIMEOUT_MS) → reject empty / non-blob: URLs
  (T-02-02-03 mitigation) → call chrome.downloads.download → register
  (downloadId, url) in pendingRevokes. NO data:URL fallback — typed
  errors route through saveArchive's catch to RECORDING_ERROR.
- chrome.downloads.onChanged listener registered at module init:
  on terminal state ('complete' / 'interrupted'), posts REVOKE_DOWNLOAD_URL
  to videoPort and clears the pendingRevokes entry.

Deviation (Rule 3 — auto-fix blocking issue):
- Plan 02-01's test helpers in blob-url-download.test.ts +
  meta-json-urls-schema.test.ts + strict-meta-json-validation.test.ts
  modeled only the REQUEST_BUFFER → BUFFER round-trip, not the new
  CREATE_DOWNLOAD_URL → DOWNLOAD_URL round-trip Plan 02-02 introduces.
  Without the test-side mint simulation, the SW's downloadArchive
  times out at the offscreen mint step → chrome.downloads.download
  never called → ALL existing meta.json tests timeout.
- Each helper extended with a tryFireDownloadUrl block that decodes
  the CREATE_DOWNLOAD_URL.dataBase64, mints a Node-native blob:URL via
  URL.createObjectURL, captures the archive bytes for downstream
  JSZip extraction (capturedArchiveBytes), and replies DOWNLOAD_URL.
  Test 3 (revoke lifecycle) additionally shims port.postMessage to
  call URL.revokeObjectURL on receipt of REVOKE_DOWNLOAD_URL — the
  test-side equivalent of src/offscreen/recorder.ts handleCreateDownloadUrl.
- Pre-existing Plan-02-02-era TODO comments in both test files
  explicitly anticipated this extension ("Plan 02-03 implementer will
  likely need a different helper, e.g. spy on URL.createObjectURL").

Verification (full §verification block from plan):
- npx tsc --noEmit: clean
- npm run build: clean
- npx vitest run tests/background/blob-url-download.test.ts: 3/3 GREEN (was 3 RED)
- npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts: 13/13 GREEN
- npm test full suite: 163 passed / 8 failed (was 159 passed / 12 failed);
  net delta +4 GREEN = 3 RED→GREEN flips + 1 ffprobe-flaky pass. 8 remaining
  RED are exactly the Plan 02-03 territory (5 meta-json-urls-schema + 3
  strict-meta-json-validation RED tests).
- grep -c "data:application/zip;base64," src/background/index.ts: 0 (gone)
- grep -c "blob:" src/background/index.ts: 8 (new pipeline)
- grep -c "chrome.downloads.onChanged" src/background/index.ts: 5 (listener wired)
- dist/ post-build: 0 "data:application/zip;base64," matches; 1 file with
  "chrome.downloads.onChanged" (the SW chunk).
2026-05-20 15:54:28 +02:00
94e03467c6 test(02-01): RED — pin strict 8-field meta.json schema validation (D-P2-03)
Plan 02-01 Task 3 RED gate. Eight strict-validation tests pin D-P2-03
(strict 8-field meta.json schema) plus F2 plan-checker-iter-1
resolution (empty urls[] permitted for whole-desktop-no-tab sessions).

Tests (3 RED-today + 5 GREEN-today regression guards; ALL 8 GREEN
after Plan 02-03):

  1. RED  — Object.keys(meta).length === 8.
  2. GREEN — timestamp matches ISO-8601 Z-suffix regex.
  3. RED  — urls is Array of valid URLs (empty permitted per F2).
  4. GREEN — extensionVersion matches semver.
  5. GREEN — totalEvents is non-negative integer.
  6. GREEN — videoBufferSeconds === 30 (CON-video-window).
  7. GREEN — logDurationMinutes === 10 (CON-event-log-window).
  8. RED  — no extra fields beyond EXPECTED_KEYS.

RED evidence (vitest 4.1.6 against current HEAD):

  × Test 1: meta.json has 7 fields; D-P2-03 requires exactly 8.
    Current keys: [timestamp, url, userAgent, extensionVersion,
    videoBufferSeconds, logDurationMinutes, totalEvents].
  × Test 3: meta.urls is not an Array. Got: undefined.
  × Test 8: meta.json contains extra (unexpected) fields: ["url"].

PLANNER-RESOLVED TENSIONS (documented in file header):

  - D-P2-03 'non-empty urls[]' vs CONTEXT.md permissive empty-array:
    resolved in favor of the permissive clause (F2 — empty is the
    canonical representation of whole-desktop-no-tab sessions).
  - 8th field name 'schemaVersion': tentative planner pick to mark
    the D-P2-02 url→urls breaking-change cutover.
  - Plan's 'ALL 8 fail' claim vs reality: 5 of 8 already pass under
    the current 7-field shape (timestamp, semver, totalEvents,
    videoBufferSeconds, logDurationMinutes). These stay GREEN as
    regression guards after Plan 02-03 lands.

EXPECTED_KEYS constant:
  ['timestamp', 'urls', 'userAgent', 'extensionVersion',
   'videoBufferSeconds', 'logDurationMinutes', 'totalEvents',
   'schemaVersion']

Plan 02-03 implementer MUST add `schemaVersion` (recommended value:
'2') to satisfy Tests 1 + 8 simultaneously.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:32:38 +02:00
9e45d333cc test(02-01): RED — pin meta.json urls[] schema + dedup/filter + empty-tracker (D-P2-02 + F2)
Plan 02-01 Task 2 RED gate. Five failing tests pin D-P2-02 (meta.json
url→urls migration) and the F2 plan-checker-iter-1 resolution (empty-
tracker → urls:[], no sentinel fallback) ahead of Plan 02-03.

Tests:
  1. SessionMetadata interface in src/shared/types.ts has 'urls: string[]'
     and no 'url:' field. Source-text scan (typecheck disabled in
     vitest.config.ts so tsc-failure pin would be a no-op).
  2. createArchive emits meta.json with Array urls and no url field.
  3. meta.urls deduplicates repeated URLs (first-seen-first order).
  4. meta.urls filters chrome:// + about:; includes chrome-extension://.
  5. Empty tracker → meta.urls === [] (NOT undefined/null/[origin]).

RED evidence (vitest 4.1.6 against current HEAD):

  × Test 1: SessionMetadata interface body does not contain a
    'urls: string[]' field (and still contains 'url:').
  × Test 2: meta.urls is not an Array. Got: undefined.
  × Tests 3+4+5: src/background/tab-url-tracker.ts does not exist —
    Plan 02-03 GREEN gate. Each expect.fail emits the precise
    contract for the GREEN flip (export name getTabUrlsSeen(),
    dedup Set semantics, first-seen-first order, URL filter spec,
    empty-array empty-tracker resolution).

Module seam (Plan 02-03 implements):
  src/background/tab-url-tracker.ts
    export function getTabUrlsSeen(): string[]
  Fed by chrome.tabs.onUpdated + chrome.tabs.onActivated (per DEC-011
  Amendment 1 'tabs' permission grant).

Baseline: 155 GREEN preserved (no regressions); this plan now has 8
NEW RED tests total (Task 1: 3 + Task 2: 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:27:39 +02:00
748a81f100 test(02-01): RED — pin Blob URL download contract (D-P2-01)
Plan 02-01 Task 1 RED gate. Three failing tests pin D-P2-01
(offscreen-minted Blob URL pipeline) ahead of Plan 02-02 implementation:

  1. chrome.downloads.download is called with a blob: URL and NOT a
     data:application/zip;base64, URL (closes audit P0-6).
  2. A 6 MB archive completes through downloadArchive in under 5 s AND
     emits a blob: URL (REQ-archive-export-latency; vi-mocked
     remuxSegments short-circuits the muxer for the 6 MB stress path).
  3. URL.revokeObjectURL is scheduled with the minted URL after
     chrome.downloads.onChanged fires 'complete' (lifecycle hygiene).

RED evidence (vitest 4.1.6 against current HEAD):

  × Test 1: chrome.downloads.download was called with
    url='data:application/zip;base64,UEsDBAoAAAAAAL1qtFw...'
    — D-P2-01 forbids data:application/zip;base64, prefix.
  × Test 2: chrome.downloads.download was called with
    url='data:application/zip;base64,...' at the 6 MB scale —
    D-P2-01 requires blob: prefix.
  × Test 3: URL.revokeObjectURL was never called after
    chrome.downloads.onChanged 'complete' fired
    (chrome.downloads.onChanged._callbacks.length === 0 at probe time).

Implementation notes:
- vitest default env is 'node' (vitest.config.ts); Node 24 ships
  URL.createObjectURL + URL.revokeObjectURL + performance as globals,
  so no jsdom override is required.
- FileReader is NOT in Node 24; added a minimal FileReader polyfill
  (delegates to Blob.arrayBuffer()) so JSZip's Blob ingestion works.
- Test 2 mocks remuxSegments via vi.doMock to bypass muxer monotonic-
  timestamp constraints for the synthetic 6 MB payload.
- Tests 1 + 3 drive the SW with the canonical 3-slice raw-3ebml-concat
  fixture (same byte offsets as tests/background/webm-remux.test.ts).
- T-02-01-01 mitigation: grep -c '\.skip' returns 0.

Baseline: 155 GREEN preserved (no regressions); this plan adds 3 NEW
RED tests. Plan 02-02 flips them GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:24:16 +02:00
9dcfcf0793 fix(02): revise plans per checker (B1 + 4 flags) — add tabs permission for D-P2-02
- BLOCKER B1: add `tabs` to manifest.json permissions (DEC-011 Amendment 1
  cites Phase 2 D-P2-02 meta.urls feature as justification). Honors
  D-P2-02 "all tabs visible" wording verbatim. Updates manifest-i18n test
  expected permission list lockstep.
- F1: add A28 harness assertion for REQ-archive-layout strict zip-layout
  verification (5 entries, no extras).
- F2: createArchive empty-tracker fallback removed; logs warn + sets
  urls:[] instead of fake [extension-origin URL]. 02-01 RED test pins
  empty-tracker → urls:[].
- F3: 02-02 Task 3 prose deliberation struck; typed `blob-url-mint-failed`
  throw is the resolved-only contract.
- F4: 02-02 Task 3 verify block adds full-suite `npm test` after focused
  test runs.
- A27 strict-mode (Plan 02-04): REQUIRES both URLs in meta.urls; FAILS
  on length < 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:25:20 +02:00
d21ed17310 fix(01-12): brand polish — replace stale 'AI Call Recorder' refs with Mokosh (4 files)
Plan 01-12 D-07 (commit 5efc2a8) migrated manifest.json:name + tooltip
+ extName/extDesc keys to chrome.i18n placeholders + _locales/{en,ru}/
messages.json resolving to 'Mokosh — Session Capture' (EN) / 'Mokosh —
Запись сессии' (RU). However, 4 trailing references to the pre-D-07
literal 'AI Call Recorder' in non-manifest content surfaces were never
propagated. Operator noticed during Plan 01-10 cycle-2 UAT 2026-05-20.

This commit applies the user-approved 4-file surgical rename:

  - src/welcome/copy.ts: welcome.body.cta.toolbar RU CTA
    "иконку AI Call Recorder" → "иконку Mokosh" (matches toolbar tooltip
    i18n key tooltipOff = "Mokosh — щёлкните, чтобы начать запись").
    Inline rationale comment added cross-ref'ing the i18n key + this
    debug session.

  - README.md: H1 + first paragraph rewritten to
    "# Mokosh — Session Capture" + EN tagline line. Rest of README body
    preserved verbatim (technical-stack section historical mentions
    left as project history, not brand surface).

  - package.json: name "ai-call-extension" → "mokosh-session-capture",
    description rewritten to "Mokosh — Session Capture: Chrome MV3
    extension for operator session recording." version, scripts,
    dependencies, devDependencies untouched.

  - tests/i18n/manifest-i18n.test.ts: header comment block rewritten to
    label the "AI Call Recorder" string as Wave-0 historical state +
    describe the post-D-07 regression-pin role. Test bodies + assertions
    unchanged (already pin the post-D-07 canonical state).

Preservation rationale: .planning/intel/* (brand-decisions-v1.md D-07,
design-system.md, brand-identity.md, classifications/README-*.json,
design-incoming/system/bundle/mokosh-handoff/handoff.html) is audit
trail documenting the "why" of D-07 — kept verbatim. _locales/{en,ru}/
messages.json and manifest.json already post-D-07 canonical — untouched.

Acceptance gates (all PASS 2026-05-20):
  - Empirical grep src/ tests/ README.md package.json: ZERO non-historical
    "AI Call Recorder" matches (only the labeled audit anchor in
    tests/i18n/manifest-i18n.test.ts:8).
  - npx tsc --noEmit: clean.
  - npm run build: ✓ built in 5.29s.
  - npx vitest run tests/i18n/manifest-i18n.test.ts: 10/10 GREEN.
  - npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts
    (Tier-1 hook-string grep gate): 13/13 GREEN; FORBIDDEN_HOOK_STRINGS
    list intact.
  - npm test: 151/153 (2 pre-existing ffprobe/ffmpeg timeout flakes in
    webm-remux + webm-playback — verified identical to pristine HEAD
    a2dfc8c via git stash baseline; unrelated to rename).
  - npm run test:uat: 24/24 GREEN.
  - Production bundle grep dist/: ZERO "AI Call Recorder" + ZERO
    "ai-call-extension" matches.

Unblocks Plan 01-10 closure + Phase 1 final closure (REQUIREMENTS /
ROADMAP / STATE marker flip).

Debug record: .planning/debug/resolved/01-12-stale-ai-call-recorder-references.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:49:46 +02:00
a2dfc8cb9b fix(01-09): startVideoCapture — remove stale active-tab dependency (D-01 cleanup gap)
The legacy chrome.tabs.query({ active: true, currentWindow: true }) +
"No active tab found" validation inside startVideoCapture were load-
bearing in the pre-D-01 chrome.tabCapture era but became functionally
dead after Plan 01-09's D-01 conversion to getDisplayMedia-in-offscreen.
The only post-D-01 consumer was a log line at index.ts:521.

The dead validation caused an activeTab-permission-scope asymmetry
between callers: chrome.action.onClicked grants activeTab on the click
gesture (so tab.url was readable → toolbar path worked silently) but
chrome.notifications.onClicked does NOT grant activeTab and the extension
has no `tabs` permission, so notifications.onClicked → startVideoCapture
threw "No active tab found" before reaching ensureOffscreen. Operator
2026-05-20 UAT against the new notifStartupCta CTA copy ("Mokosh ready.
Click to start a recording.", commit 4bba679) surfaced the silent
notification failure.

Surgical fix: remove the dead tab query + validation + tab-dependent log
(src/background/index.ts:514-521); replace with a tab-independent log
that documents WHY (cites D-01 + this debug session). captureScreenshot
+ saveArchive retain their genuine tab dependencies (tab.windowId for
chrome.tabs.captureVisibleTab; tab.id for content-script sendMessage).

Tests: tests/background/start-video-capture-no-tab.test.ts (NEW) pins
the contract with 3 cases (tabs.query → []; → [{id}] url-less; →
[{id,url,windowId}] regression guard for toolbar path).

Gates: vitest 153/153 GREEN (was 150/150 baseline; +3); test:uat 24/24
GREEN; tsc clean; build clean. Pre-checkpoint bundle gates per
feedback-pre-checkpoint-bundle-gates.md: SW chunk hook-string Tier-1
grep 0 matches; eval/Node-global/DOM-global matches unchanged from
baseline (all vendor-library feature-detect, guarded; no new imports).

Debug record: .planning/debug/resolved/01-09-notification-start-no-active-tab.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:33:18 +02:00
0854baf66c fix(01-10): vitest build-test it() timeout — bump to 30s for slower welcome-page build
The build-completes Tier-1 gate at tests/background/no-test-hooks-in-prod-bundle.test.ts:247
was racing vitest's default 5000ms it() ceiling. Plan 01-10 closure shipped the welcome
page (commits d48a715 welcome mark + 49f087f welcome HTML/CSS/JS + 8 WOFF2 fonts) which
slowed standalone `npm run build` from ~2.88s to ~5.28s. The exec-level
BUILD_TIMEOUT_MS = 60_000 child-process bound was correctly declared at line 240, but
the surrounding it() block had no timeout option, so the 5s default fired first and the
60s exec bound was never reachable.

Surgical fix: add `, 30_000` 3rd arg to the it() call. 30s is ~6× the observed build
duration and well below the 60s exec ceiling, so both bounds remain meaningfully
active. SKIP_BUILD=1 env-var escape hatch untouched.

Acceptance gates:
- `npm test` (FULL, no SKIP_BUILD=1): 150/150 GREEN, exit 0
- `npx tsc --noEmit`: exit 0
- `npm run build`: exit 0
- Tier-1 grep gate: PASS (all 12 FORBIDDEN_HOOK_STRINGS asserted against dist/)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:52:39 +02:00
d48a715da5 fix(01-10): welcome page mark — bundle canonical mokosh-mark.svg + replace placeholder
Plan 01-10 must_have #9 path-A swap-in (landed 2026-05-20 per debug
session 01-10-welcome-page-missing-mark). Closes the planning-coverage
gap where Plan 01-12 path-B (canonical tokens import) ran ahead of
01-10, leaving the welcome hero with a text placeholder 'Mokosh'
inside the rec-bg circle instead of the canonical 2×2 woven-square
mark from src/shared/brand/mokosh-mark.svg.

Why Option B (Vite ?url import) over manual WAR (A) or inline SVG (C):
- @crxjs/vite-plugin ^2.0.0-beta.25 auto-WARs transitively-reachable
  resources from extension pages — no manifest.json edit needed.
- Vite default-inlines small SVGs (~600 bytes < 4096 byte default
  assetsInlineLimit) as data:image/svg+xml URLs in the welcome chunk
  — no extra HTTP request, no extra WAR entry.
- Hashed asset fallback works automatically if the SVG grows past
  the inline limit in future revisions.
- Existing font-bundling precedent (dist/assets/Lora-*.woff2 +
  IBMPlex*.woff2) proves the Vite + crxjs pipeline.

Files modified:
- src/welcome/welcome.ts — added markUrl import + populateMark() that
  walks [data-mokosh-slot='mark'] and injects an <img>.
- src/welcome/welcome.html — added explanatory comment block; preserved
  the data-mokosh-slot wrapper for forward-compat (the placeholder
  span remains as the JS-fail-gracefully fallback).
- src/welcome/welcome.css — added .welcome-hero__mark-img rule
  (60% sizing inside the existing styled circle wrapper).
- src/welcome/copy.ts — added 'welcome.hero.mark.alt' COPY key
  (Russian per D-03 Sober voice).
- globals.d.ts — added *.svg?url ambient module declaration
  (Vite recommended pattern; keeps tsconfig.json types: ['chrome']
  clean by not requiring vite/client triple-slash directives).
- tests/uat/extension-page-harness.ts — extended A17 with A17.8
  sub-check verifying the canonical mark SVG is bundled into the
  welcome chunk (data URL OR file URL form) AND that the canonical
  viewBox='0 0 32 32' is preserved through bundling.

Acceptance gates passed:
- npx tsc --noEmit exit 0
- npm run build exit 0
- SKIP_BUILD=1 npm test → 150/150 GREEN
- npm run test:uat → 24/24 GREEN including A17.8
- Tier-1 hook-string grep gate PASS (no FORBIDDEN_HOOK_STRINGS
  in production bundle).
- Manifest valid JSON; web_accessible_resources auto-bundled.
- Pre-checkpoint bundle gates 1/2/3: vendor pre-existing hits
  (JSZip + ts-ebml) confirmed identical pre-change via git stash
  baseline; not caused by this fix.

Forward-looking deferred (out of scope):
- Issue 2 dark-surface contrast (e.g. chrome.notifications icon128
  may need a light-stroke variant). The welcome hero's rec-orange
  BG already provides high contrast with the dark ink stroke — this
  is correct design. Per the orchestrator's explicit constraint,
  light-variant mark for dark notification panels is deferred to
  Phase 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:28:58 +02:00
4bba679e39 fix(01-09): notifStartup text split — notifStartupCta for onStartup; notifRecordingStarted for manual-start
Operator UAT 2026-05-20 rejected the build because the OS notification fired
on `chrome.runtime.onStartup` ("Recording started. I'm watching the last 30
seconds.") implied recording had auto-started when in fact recording was
not running. Per Phase 1 always-on charter recording does NOT auto-start;
the notification is the gesture surface that invites the operator to start
one (notifications.onClicked → startVideoCapture, src/background/index.ts:1038).

Root cause: a single i18n key `notifStartup` conflated the pre-recording
CTA-with-gesture path (the only path actually wired today) and a future
post-manual-start confirmation path. The key's own `.description` field
acknowledged the conflation. Operator-facing text leaned toward the
confirmation phrasing.

Fix (key split, no behavior change):
- `notifStartupCta` — EN: "Mokosh ready. Click to start a recording." /
  RU: "Mokosh готов. Нажмите, чтобы начать запись." — wired into the
  onStartup handler.
- `notifRecordingStarted` — preserves the original text ("Recording
  started. I'm watching the last 30 seconds." / "Запись запущена…") for
  a future post-manual-start confirmation flow.
- Fallback constant renamed `NOTIF_STARTUP_FALLBACK` →
  `NOTIF_STARTUP_CTA_FALLBACK`; value updated to match the new CTA text.
- Inline test comment in tests/background/onstartup-notification.test.ts
  refreshed to reference the new key + fallback. Assertion regex
  /recording|recor|click/i covers both fallback + resolved locale variants,
  no logic change.

Notification behavior preserved: same id prefix `mokosh-startup-`, same
priority, same icon, same onClicked → startVideoCapture wiring. No new
test-mode symbols (FORBIDDEN_HOOK_STRINGS inventory stays at 12).

Files modified:
- _locales/en/messages.json
- _locales/ru/messages.json
- src/background/index.ts
- tests/background/onstartup-notification.test.ts

Verification:
- npx vitest run --exclude tests/build/** --exclude tests/background/no-test-hooks-in-prod-bundle.test.ts: 104/104 GREEN
- npx vitest run tests/i18n/ tests/background/onstartup-notification.test.ts: 18/18 GREEN (locale-parity 4/4 + onstartup-notification 14/14)
- npx tsc --noEmit clean on src/background/index.ts

The 2 build-dependent vitest gates (tests/build/no-remote-fonts.test.ts +
tests/background/no-test-hooks-in-prod-bundle.test.ts) and npm run test:uat
are deferred to orchestrator-level re-verification after the parallel
Plan 01-10 mark-bundling fix also lands (operator-UAT re-spawn coordinated
by orchestrator).

Debug record: .planning/debug/resolved/01-09-startup-notification-misleading-text.md
Operator UAT rejection event: 2026-05-20

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:14:08 +02:00
b112cb7861 test(01-10): wave-3 task-4 — harness A15+A16+A17 (onboarding flag observability + no-re-open settle + design-swap-readiness with @import probe); 24/24 GREEN
Plan 01-10 Wave 3: extends the UAT harness with three new page-side
assertions covering the onboarding contract + the canonical-tokens
design-swap-readiness invariant. UAT baseline 21 → 24 GREEN.

tests/uat/extension-page-harness.ts (page-side):
  - assertA15 — chrome.storage.local 'onboarding-completed' === true +
    'installed-at' is number. Verifies SW's openWelcomeIfFirstInstall
    side-effects.
  - assertA16 — 2s settle window; chrome.tabs.query welcome-tab count
    delta === 0. Verifies flag-gating across SW respawns.
  - assertA17 — 7 sub-checks covering: welcome.html parse + .welcome-hero
    + >=7 mokosh-keyed attrs + welcome.css canonical @import literal OR
    inlined --mks-* evidence + (zero hex OR canonical resolved) + >=5
    var(--mks-*) refs + bundled JS preserves populate plumbing +
    getComputedStyle --mks-rec → rgb(178, 84, 61) (canonical D-04 Loom).
  - window.__mokoshHarness surface extended with the three new methods;
    type declaration + assignment + page-ready status text updated.

tests/uat/lib/harness-page-driver.ts (host-side):
  - driveA15, driveA16, driveA17 — standard page.evaluate wrappers
    matching driveA14 / driveA18..A22 idiom. driveA16 dominates the
    new wall-clock budget (~2.1s for the settle window).

tests/uat/harness.test.ts (orchestrator):
  - Drivers array interleaves A15/A16/A17 AFTER A14 + BEFORE A18.
    A22's skip-gate no longer triggers (Plan 01-10 lands welcome.html;
    A22 now exercises the substantive token-usage path).
  - FORBIDDEN_HOOK_STRINGS unchanged at 12 entries (A15-A17 use only
    chrome.tabs.query / chrome.storage.local.get / fetch / DOMParser /
    getComputedStyle — all production-API surfaces).

DEVIATION (Rule 1 — auto-fix bug in plan-supplied check):
  The plan's A17.6 spec used literal substring checks 'COPY[' and
  'chrome.i18n.getMessage(' which fail against minified production
  output. Vite/Rollup terser renames `COPY` → `f` (local variable
  mangling) and welcome.ts's source uses optional chaining
  `chrome?.i18n?.getMessage?.(` which doesn't match the verbatim
  literal. Replaced with two minification-survivable witnesses:
    1. 'welcome.page.title' — literal Object.freeze key (terser
       preserves object-literal keys verbatim).
    2. 'i18n' + 'getMessage' + 'welcomeHero' substring conjunction —
       chrome global + property access + fallback key literal; all
       three survive minification regardless of optional-chaining
       insertion or rename.
  Both witnesses prove the populate plumbing survives the build (the
  ground-truth contract A17.6 enforces). The relaxed contract is
  semantically equivalent — neither substring is load-bearing on its
  own; both witness the same underlying invariant.

Verify (all GREEN):
  - npm run test:uat: 24/24 assertions passed (A0 grep gate + A1..A14
    + A15..A17 + A18..A22 + A23).
  - npx tsc --noEmit: clean.
  - npm run build:test: clean; dist-test/assets/welcome-wB0e_R_n.js
    bundled; harness page bundle includes new asserts.
  - SKIP_BUILD=1 npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts:
    13/13 GREEN (Tier-1 grep gate; FORBIDDEN_HOOK_STRINGS at 12).
  - Full vitest baseline preserved: 137 ex-grep-gate + 13 grep-gate
    = 150 GREEN (Plan 01-10 target).

A17.7 canonical proof: getComputedStyle.color = 'rgb(178, 84, 61)' —
the @import '../shared/tokens.css' directive resolves through to the
canonical D-04 Loom palette --mks-madder-600 = #b2543d at runtime, as
the empirical proof Plan 01-12 must_have #9 path-B contract demands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:41:10 +02:00
89e1e09d60 test(01-10): wave-0 task-1 — RED onboarding tests (3 tests pin install/update/flag + storage-key)
Three tests in tests/background/onboarding.test.ts pinning the
Plan 01-10 D-17-onboarding contract:

  Test A (RED): first install + empty storage opens exactly ONE welcome
    tab whose URL contains 'src/welcome/welcome.html', sets
    chrome.storage.local.set({'onboarding-completed':true,
    'installed-at':<number>}), AND calls chrome.storage.local.get with
    EXACT key 'onboarding-completed' (storage-schema cross-version-compat
    pin; preserves I-02 lesson from prior draft).

  Test B (vacuous-GREEN, becomes load-bearing post-Task-3): reason='update'
    → chrome.tabs.create NOT called.

  Test C (vacuous-GREEN, becomes load-bearing post-Task-3): flag already
    true → chrome.tabs.create NOT called.

Tests B and C pass vacuously until Task 3 lands openWelcomeIfFirstInstall;
they remain load-bearing AFTER Task 3 as no-tab-open guards for the
update/already-onboarded branches. Test A flips RED → GREEN at Task 3.

Stub scaffold inherits buildBgStub from onstartup-notification.test.ts;
extended with chrome.tabs.create + chrome.storage.local.{get,set} +
chrome.runtime.onInstalled._callbacks (addListener.mockImplementation
pattern to capture the SW's registered listener).

DEVIATION NOTE: plan's <verify> expected `3 failed` but only Test A
(positive contract) goes RED pre-Task-3; Tests B+C are negative-path
guards that pass trivially when the helper is absent. This is standard
TDD (positive test fails RED; negative tests stay GREEN through GREEN→
REFACTOR). No code change needed — Task 3's GREEN gate is "all 3 GREEN".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:00:58 +02:00
b909c374cc feat(01-12): wave-6 task-1 — harness A18-A22 (font reachability + icon-distinct + manifest-i18n + Lora-resolved + welcome-tokens)
UAT harness extended with 5 new page-side assertions following the
01-13 Approach B pattern (page-side assertA* + host-side driveA*
wrapper + harness.test.ts orchestrator entry):

A18 — Lora WOFF2 reachable from harness page (font self-host MV3 CSP
  invariant). Walks document.styleSheets for the first @font-face rule
  referencing Lora, resolves the rebased asset URL (handles Vite's
  content-hashing), fetches, asserts byteLength >= 40_000 (subset Lora
  is ~49 KB) + WOFF2 signature 'wOF2'. 4 checks.

A19 — icons rasterized from Loom mark (not Bug A placeholders). Fetches
  icon128.png, parses IHDR bytes 24-25 (bit-depth + color-type),
  asserts (8, 6) RGBA vs the placeholder (16, 2) RGB. 2 checks.

A20 — manifest:name resolves via chrome i18n. Reads
  chrome.runtime.getManifest().name; asserts it matches EN extName
  'Mokosh — Session Capture' OR RU 'Mokosh — Запись сессии' (robust
  to whatever locale Chrome uses); explicitly checks no __MSG_
  placeholder leaks. 2 checks.

A21 — --mks-font-display resolves to Lora stack. Creates transient
  .mks-display-1 probe div, reads getComputedStyle.fontFamily,
  asserts the stack starts with 'Lora' or '"Lora"' (accommodates
  both quoted + unquoted forms across Chrome versions); explicitly
  checks no Newsreader leak (R2 substitution complete). 2 checks.

A22 — welcome page tokens.css adoption (CONDITIONAL on Plan 01-10).
  Skip-gate on missing welcome.html: catches both HTTP 404 AND
  network-layer fetch failure (Chrome extensions throw TypeError
  'Failed to fetch' for non-web_accessible_resources paths). On
  reachable: extracts <link rel=stylesheet> hrefs, fetches each,
  asserts >= 3 var(--mks-*) usages OR tokens.css reference. 1 check.

Companion changes:

- tests/uat/extension-page-harness.html gains `<link rel="stylesheet"
  href="../../src/shared/tokens.css">` so A18 + A21 have the @font-face
  rules + .mks-display-1 class + CSS custom properties resolvable via
  document.styleSheets + getComputedStyle. Vite's crxjs plugin handles
  the asset path rebasing at build:test time.

- tests/uat/lib/harness-page-driver.ts: driveA18..driveA22 wrappers
  following the established driveA8 pattern (page.evaluate → window.
  __mokoshHarness.assertXX). No new host-side fs/ffprobe primitives;
  all A18-A22 work is page-side.

- tests/uat/harness.test.ts: orchestrator drivers list extended with
  A18-A22 between A14 and A23. FORBIDDEN_HOOK_STRINGS UNCHANGED at 12
  entries post-Plan-01-14 (A18-A22 use production chrome.* + fetch +
  getComputedStyle exclusively; no new test-mode symbols).

Verification (this commit):
- npm run test:uat: 21/21 GREEN (was 16/16 post-01-14)
- SKIP_BUILD=1 npm test: 147/147 GREEN
- Tier-1 grep gate: 13/13 GREEN (no FORBIDDEN_HOOK_STRINGS growth)
- npx tsc --noEmit: clean
- npm run build + npm run build:test: both succeed

The chain of A1..A14 + A18..A22 + A23 runs in ~95 seconds end-to-end
under Puppeteer headless mode against the bundled Chrome at
~/.cache/puppeteer/chrome/linux-148.0.7778.167.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:41:51 +02:00
468f16d7e7 feat(01-12): wave-4 task-1 — adopt tokens.css + chrome.i18n.getMessage in src/popup/ + src/background/ (loom palette + RU i18n + en fallback)
src/popup/style.css:
- Adds @import "../shared/tokens.css" at top
- All hex literals removed; every color reads from var(--mks-*) per
  D-04 loom palette: --mks-surface body bg; --mks-rec/--mks-madder-700
  for SAVE button (default/hover); --mks-amber-600 for saving;
  --mks-moss-600 for done; --mks-error/--mks-success/--mks-warning for
  status messages; --mks-fg-disabled for disabled button
- Font families read from --mks-font-ui (IBM Plex Sans stack)
- Spacing/radius/shadows all token-driven

src/popup/index.html:
- <span class="button-text"> emptied (populated by JS via i18n)
- <p class="info-text" data-mks-key="popupInfoText"> attribute-marked
  for populateMksKeys() init-time population
- <title> kept as literal English (chrome doesn't substitute __MSG_*__
  in HTML body per RESEARCH Pitfall 3)

src/popup/index.ts:
- New `i18n(key, fallback)` helper: chrome.i18n.getMessage with explicit
  `|| <fallback>` for unit-test contexts without chrome.i18n stub
- New `populateMksKeys()` helper: walks [data-mks-key] elements at init
  and sets each textContent from i18n
- updateUI() reads popupSaveCta/popupSaving/popupSaveDoneShort at each
  state branch (idle/saving/done) with Russian fallbacks
- saveArchive() success branch reads popupSaveDone
- Empty-state path reads popupEmptyState

src/background/index.ts:
- BADGE_REC_COLOR: '#00C853' → '#b2543d' (= --mks-madder-600 per D-04;
  RESEARCH §10 Open Question A7 default-action)
- BADGE_OFF_COLOR + BADGE_ERROR_COLOR retained as engineering choices
  (no loom-palette token for material-red/amber-700 equivalents)
- BADGE_REC_TITLE/BADGE_OFF_TITLE/BADGE_ERROR_TITLE renamed to
  ..._FALLBACK and only referenced at the chrome.i18n.getMessage call
  sites inside setBadgeState (i18nMessage('tooltipRecPrefix' etc.))
- New `i18nMessage(key, fallback)` helper mirroring popup's i18n()
- Recovery notification: title=i18nMessage('extName',...); message=
  i18nMessage('notifRecovery',...)
- Startup notification: title=i18nMessage('extName',...); message=
  i18nMessage('notifStartup',...)
- NOTIF_EXTNAME_FALLBACK/NOTIF_STARTUP_FALLBACK/NOTIF_RECOVERY_FALLBACK
  module-level constants for the |||| chain (degrade gracefully in
  test contexts without chrome.i18n stub)
- NO `await import(...)` added (MV3 SW dynamic-import constraint per
  01-11-SUMMARY preserved)

Test-contract updates (3 tests; assertion-shape only — no semantic
regression):
- tests/background/badge-state-machine.test.ts: greenCalls→recColorCalls
  regex updated from /^#00[Cc]853$/ to /^#b2543d$/i lockstep with
  BADGE_REC_COLOR change; title-substring assertion widened to
  /Recording|recording/i to cover both EN locale + fallback
- tests/background/onstartup-notification.test.ts: title equality
  ('Mokosh ready') replaced with /Mokosh/i substring assertion
  (survives both the 'Mokosh' fallback + 'Mokosh — Session Capture'
  resolved EN); message regex widened to /recording|recor|click/i
- tests/background/toolbar-action.test.ts: DocumentStub gains
  querySelectorAll: () => [] so the new populateMksKeys() init path
  doesn't throw under the popup's no-DOM unit-test environment

Verification:
- tests/build/tokens-adopted.test.ts: 4/4 GREEN (was 2 RED + 2 GREEN)
- tests/build/no-remote-fonts.test.ts: 4/4 GREEN after fresh build
  (Vite emits the WOFF2 files as content-hashed dist/assets/*.woff2;
  tokens.css references resolve through the asset pipeline; no
  remote-font URLs anywhere in dist/)
- Full vitest sweep: 147/147 GREEN (was 145/147)
- npx tsc --noEmit: clean
- Tier-1 grep gate: 13/13 GREEN (no new test-mode symbols)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:27:19 +02:00
34a9ce10d4 test(01-12): wave-0 — scaffold RED unit tests (tokens / fonts / icons / no-remote-fonts / manifest-i18n / locale-parity)
Wave 0 of the design-integration plan. Six new test files at tests/build/
and tests/i18n/ pin the contracts that later waves will GREEN:

- tokens-adopted.test.ts (4 cases): src/shared/tokens.css exists +
  parses; src/popup/style.css @imports it; popup/style.css has zero
  hex literals; welcome.css conditional check.
- fonts-present.test.ts: 7 required WOFF2 faces (Lora normal + Plex Sans
  ×4 + Plex Mono ×2) + LICENSE-Lora + LICENSE-IBM-Plex + README +
  optional Lora-Italic (A5 verify-at-execute).
- icons-present.test.ts (15 cases across 3 sizes): existence, size FLOOR
  per assets-spec.md, PNG signature, dimensions, color-type byte === 6
  (RGBA — RED until Wave 2 rsvg-convert overwrites the 16-bit-RGB
  placeholders).
- no-remote-fonts.test.ts: production dist/ contains zero
  fonts.googleapis.com / https://fonts / googleapis substrings (MV3 CSP
  self-host invariant T-01-12-01).
- manifest-i18n.test.ts (10 cases): manifest:name === '__MSG_extName__',
  :description === '__MSG_extDesc__', :default_locale === 'en',
  :action.default_title === '__MSG_tooltipOff__'; _locales/{en,ru}/
  messages.json carry D-07 + D-08 canonical strings.
- locale-parity.test.ts (4 cases): ru→en parity, en→ru symmetric,
  non-empty .message strings (RESEARCH Pitfall 4 mitigation).

Current polarity: 29 RED + 18 GREEN across the 6 new files (placeholders
already clear dim+size floors; no-remote-fonts vacuous-GREEN since
tokens.css doesn't yet exist with remote URLs). Existing 100/100 vitest
baseline preserved (verified SKIP_BUILD=1 npx vitest run).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:56:08 +02:00
b467123578 feat(01-14): monitorTypeSurfaces:'include' — narrow picker to monitor surfaces only
[per Plan 01-14; closes B-01-14-01 via Step 1b lockstep]

- src/offscreen/recorder.ts: add monitorTypeSurfaces:'include' as top-level
  DisplayMediaStreamOptions sibling of video: (W3C Screen Capture spec §6.1;
  Chrome >= 119; removes tab/window panes from the operator's picker per
  Plan 01-10 RESEARCH §5 + §Pitfall-5 recommendation). Typed widening cast
  extended in lockstep to keep the explicit-typing contract (no `as any`).
  D-15 post-grant validation block at recorder.ts:294 UNCHANGED — belt
  (picker narrowing) + suspenders (post-grant tear-down) chain preserved.

- tests/offscreen/display-surface-constraint.test.ts: lockstep update of
  the strict-deep-equality assertion at lines 223-226 with the same key
  ordering as the source change (video -> monitorTypeSurfaces -> audio).
  toHaveBeenCalledWith contract preserved (NO expect.objectContaining —
  the test author's "catches future drops of ANY field" discipline is
  honored). This edit + the source change land in the SAME commit so the
  98/98 baseline never crosses a commit boundary in RED state.

- src/test-hooks/offscreen-hooks.ts: capture last constraints object in
  module-scoped `lastGetDisplayMediaConstraints` cell (was `_constraints`
  received-but-unused; renamed to `constraints`); add `get-last-getDisplayMedia-constraints`
  bridge op to the __mokoshOffscreenQuery dispatcher between
  get-display-surface and get-segment-count. Defensive try/catch mirrors
  the existing dispatcher pattern; the cell is module-internal so the
  MokoshTestSurface cross-cast in types.ts requires NO change (decision
  documented inline in offscreen-hooks.ts).

- tests/uat/extension-page-harness.ts: add `assertA23` mirroring `assertA3`
  (bridge query → 2-check AssertionResult: non-null constraints + value).
  Extend the `Window.__mokoshHarness` declaration + runtime export + status
  bar text + console.log to reference A23.

- tests/uat/lib/harness-page-driver.ts: export `driveA23(page)` mirroring
  the `driveA14` page.evaluate wrapper shape. Standard read-only driver.

- tests/uat/harness.test.ts: extend FORBIDDEN_HOOK_STRINGS (line 85) with
  `lastGetDisplayMediaConstraints` and `get-last-getDisplayMedia-constraints`.
  Import driveA23. Append `{ name: 'A23', drive: driveA23 }` to the drivers
  array after the A14 entry. Update header comment + orchestrator stdout
  to reflect A14 + A23 chain. The `Total = drivers.length + 1` arithmetic
  adapts automatically: 14 + 1 = 15 → 15 + 1 = 16.

- tests/background/no-test-hooks-in-prod-bundle.test.ts: lockstep
  extension of FORBIDDEN_HOOK_STRINGS (line 105) with the same 2 strings.
  Header comment updated to "Total: 12 surface strings." (was 10).
  Confirms production `dist/` has ZERO occurrences after `npm run build`
  via the `__MOKOSH_UAT__` dead-branch tree-shake (T-01-14-04 mitigation).

D-01 (whole-desktop only via getDisplayMedia; reject window/tab surfaces) is
the design intent that monitorTypeSurfaces:'include' realizes at the picker-
UI level. D-15 post-grant validation (recorder.ts:294-307) remains the
actual enforcement against managed-policy/DevTools/older-Chrome overrides.

Verification chain (per Plan 01-14 §verify; clean post-commit):
- `npx tsc --noEmit` exit 0
- `npm run build` exit 0; dist/ produced, monitorTypeSurfaces ships in
  the offscreen chunk as the operator-facing picker hint
- `npm run build:test` exit 0; dist-test/ produced with the harness
  hooks intact (gated)
- `npm test` 100/100 GREEN (was 98/98; +2 via the 2 new FORBIDDEN_HOOK_STRINGS
  parametrized tests — both PASS, production bundle hook-free)
- `npm run test:uat` 16/16 GREEN (15 → 16 via A23). A23 reads constraints
  `{video: {...}, monitorTypeSurfaces: 'include', audio: false}` from the
  fakeGetDisplayMedia capture cell — round-trips through the full call site.
- Production bundle spot-check:
    `grep -rc 'lastGetDisplayMediaConstraints\|get-last-getDisplayMedia-constraints' dist/ | grep -v ':0$'`
    → empty (all `:0` filtered) → ZERO leakage.

References:
- W3C Screen Capture §6.1 DisplayMediaStreamOptions:
  https://www.w3.org/TR/screen-capture/#dom-displaymediastreamoptions-monitortypesurfaces
- Chrome screen-sharing-controls (Chrome 119+):
  https://developer.chrome.com/docs/web-platform/screen-sharing-controls
- Plan 01-10 RESEARCH §5 + §Pitfall-5 (recommendation provenance):
  .planning/phases/01-stabilize-video-pipeline/01-10-RESEARCH.md
- Architectural-note (replaces retired AMENDMENT-A.md improvisation per
  01-11-SUMMARY): canonical GSD ceremony — plan → checker (B-01-14-01)
  → executor → SUMMARY (this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:37:59 +02:00
1baaf45702 feat(01-13-A14-invert): A14 — invert to assert continuous-recording post-SAVE
Plan 01-09 Amendment 3 (2026-05-19) end-to-end lock. Inverted A14 to
match the reversed charter (SAVE creates zip, recording continues).

Page-side (tests/uat/extension-page-harness.ts):
- assertA14: assert badge==='REC' (was ''), popup endsWith
  'src/popup/index.html' (was ''), no-new-recovery-notif (unchanged).
- A14 name + check labels updated to reflect continuous-recording semantic.
- New constant A14_POPUP_HTML_SUFFIX for the popup endsWith check
  (ext-id-agnostic via suffix match).
- A13 docstring + diag strings refreshed: setupFreshRecording is now
  defensive (orthogonal to A12 ordering) rather than a workaround for
  the prior auto-stop. 11s settle preserved (same wall-clock cost).

Host-side (tests/uat/lib/harness-page-driver.ts):
- driveA14 docstring refreshed to mention Amendment 3 + the inverted
  contract; mechanical wrapper unchanged.

Verification:
- npm run test:uat: 15/15 GREEN
- A14 actual output:
    badge='REC'
    popup='chrome-extension://<ext-id>/src/popup/index.html'
    recoveryDelta=0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:33:53 +02:00
6ac23fdbd8 test(01-09-no-stop): RED — invert save-archive contract to lock always-on charter
Per operator UX iteration (2026-05-19), the Amendment 2 save-stops-recording
fix (commits cd83eb0+4f4c3e2+2b6c24b+89f3337) is REVERSED. SAVE_ARCHIVE
creates a new zip but does NOT stop the recorder — matches SPEC's
continuous-capture / always-on safety-net framing.

This commit renames the test file via `git mv` (history preserved) and
inverts tests A..C to assert the new contract:
- A: no NEW setBadgeText({text:''}) call (badge stays REC)
- B: no setPopup({popup:''}) call (popup stays pinned to popup.html)
- C: no STOP_RECORDING dispatch via chrome.runtime.sendMessage
Test D (no recovery notification) preserved unchanged as regression guard.

RED expected — src/background/index.ts still has the Amendment 2
`finally` block dispatching STOP_RECORDING + setIdleMode. Next commit
removes that block to drive GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:45:31 +02:00
2b6c24b2d9 feat(01-13): A14 — post-SAVE state check (badge='', popup='', no new recovery notif)
Plan 01-13 Task 9 closure for operator empirical UAT bug
.planning/debug/01-09-save-stops-recording.md. Adds the harness
assertion that empirically verifies the SAVE-auto-stops-recording fix
(committed at 4f4c3e2) holds end-to-end through a real Chrome instance
+ real MediaRecorder + real chrome.action + real chrome.notifications.

A14 design (read-only post-state check):
  1. Snapshot active mokosh-recovery-* notification ids (delta baseline).
  2. Settle 500ms for the post-A13 SAVE finally block to land.
  3. Read chrome.action.getBadgeText/getPopup + getActiveNotificationIds.
  4. Assert badge='', popup='', recoveryDelta=0 — three checks total.

A14 chains off A13's SAVE_ARCHIVE (which under the new fix auto-stops
the recording per SPEC one-shot intent). A14 does NOT dispatch its own
SAVE — A13's SAVE is the event A14 observes the post-state of. This
keeps the harness wall-clock minimal (~500ms added for A14, no
additional 11s segment-settle).

Amendment to A13: now does setupFreshRecording + 11s segment-settle
BEFORE its own SAVE_ARCHIVE dispatch. Under the new fix, A12's
SAVE_ARCHIVE stopped the recording — without this A13 would dispatch
against an empty buffer and fail with EmptyVideoBufferError. The
amendment adds ~11s to harness wall-clock; acceptable given the SPEC
SAVE=stop contract is now load-bearing.

A14 contract notes per orchestrator simpler-design recommendation:
  - direct isRecording proxy check skipped (no bridge op exposes it;
    transitively verified via badge='' — production state machine
    pairs isRecording transitions with badge transitions atomically)
  - recovery-notif check is delta-based (A7 left a mokosh-recovery-*
    in the active set; we verify A13's SAVE did NOT add another one)

Files modified:
  - tests/uat/extension-page-harness.ts: +assertA14 (~110 lines) +
    A13 amended with setupFreshRecording + 11s settle + 1 new
    SETUP check + window.__mokoshHarness export wire
  - tests/uat/lib/harness-page-driver.ts: +driveA14 wrapper
  - tests/uat/harness.test.ts: +A14 in drivers array + header doc +
    total 14/14 → 15/15 + import line

Verification:
  - npm run test:uat: 15/15 GREEN (was 14/14)
  - npx tsc --noEmit: exit 0 (no type errors)
  - npm run build: exit 0 (production bundle clean)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:31:13 +02:00
cd83eb0498 test(01-09-save-stops): RED — SAVE_ARCHIVE triggers STOP_RECORDING + setIdleMode + no recovery notif
Plan 01-13 Task 9 operator UAT closure. Operator 2026-05-19 empirical
session: SAVE click downloaded zip but recording stayed live (badge=REC,
sharing banner persisted, subsequent toolbar press re-opened SAVE-only
popup). Operator pressed 4×, got 2 zips + confusion.

Root cause: src/background/index.ts saveArchive() returns success after
chrome.downloads.download without signaling offscreen to stop or
transitioning the SW state machine — SPEC `Тз расширение фаза1.md`
"one click MUST produce a self-contained archive" was over-extended to
"always-on" framing by the implementation.

Fix contract (RED today; GREEN after src/background/index.ts patch):

  A: setBadgeText({text:''}) called post-save (setIdleMode side effect)
  B: setPopup({popup:''}) called post-save (re-enables chrome.action.onClicked
     restart path per MV3 contract)
  C: chrome.runtime.sendMessage({type:'STOP_RECORDING'}) dispatched
     (offscreen recorder.ts:848 STOP_RECORDING case already wired —
     no offscreen-side change needed)
  D: NO mokosh-recovery-* notification fires (deliberate stop ≠ error;
     mirrors Bug B `user-stopped-sharing` suppression branch from
     .planning/debug/resolved/01-09-recovery-flow.md)

Tests A/B/C RED (assertion errors `expected 0 >= 1`); Test D GREEN today
as the regression guard against fix over-rotating to setErrorMode.

Test architecture mirrors tests/background/request-id-protocol.test.ts:
synthetic BUFFER response delivered via port.onMessage listeners to drive
saveArchive's request-id'd buffer fetch to completion. Empty-segments
BUFFER causes createArchive → EmptyVideoBufferError → catch branch; the
fix's STOP+IDLE dispatch MUST happen on both success and empty-buffer
paths (operator UI contract: SAVE click = stop, success or empty alike).

Debug record: .planning/debug/01-09-save-stops-recording.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:17:19 +02:00
d793c9e1e5 feat(01-13): wave-3D — A11+A12+A13 GREEN + get-segment-count bridge op; 14/14 GREEN
Lands the final three UAT-harness assertions. All 14 assertions (A0..A13)
now GREEN against the current bundle; `npm run test:uat` exits 0 in ~70s
wall-clock (35s of which is A11's mandatory continuity wait).

Assertions wired:

 - A11 — 35s buffer continuity → segments.length >= 3. Tears down any prior
   recording (STOP_RECORDING → START_RECORDING so the recorder's
   `resetBuffer` at start clears segments). Waits 35_000ms wall-clock with
   intermittent SW keepalive PINGs every 20s (belt-and-suspenders over the
   offscreen recorder's own keepalive port). Queries the new
   `get-segment-count` bridge op. Asserts count >= 3 (per D-13:
   SEGMENT_DURATION_MS=10s × MAX_SEGMENTS=3).

 - A12 — SAVE_ARCHIVE produces zip; webm passes ffprobe. Page side
   dispatches SAVE_ARCHIVE (recording from A11 still alive). Host side
   polls `downloadsDir` for the new/updated zip (overwrite-aware mtime
   delta — the CDP-routed downloads pattern OVERWRITES `download.zip`
   rather than numbering it, empirically verified during initial RED).
   Extracts `video/last_30sec.webm` via JSZip to a tmpfile. Runs
   `/usr/bin/ffprobe -v error -f matroska <path>`; asserts exit 0 + clean
   stderr. Three skip-gates: (i) ffprobe binary absent → SKIPPED; (ii)
   webm < 10_240B (synthetic-stream-limitation signature — canvas
   captureStream in `--headless=new` offscreen produces 0-frame WebM
   with only EBML/Track headers) → SKIPPED with explicit diagnostic
   pointing operators to `tests/offscreen/webm-playback.test.ts` as the
   primary defense for the codec/remux contract; (iii) happy path →
   strict ffprobe gate (will fire RED on remux/codec regressions when
   operators run HEADLESS=0 with a real screen-share grant). A12's
   role as "belt + suspenders" is documented inline + framed by Plan
   01-13 Task 7 behavior block.

 - A13 — Zip structure + meta.json shape. Second SAVE_ARCHIVE (verifies
   idempotency over A12's first save). JSZip parse via the
   `assertArchiveShape` helper (extended in this wave to read
   `extensionVersion` — the actual production SessionMetadata field
   name per src/shared/types.ts:103, vs. the earlier 01-11 prototype's
   incorrect `version` assumption). Six checks: SW dispatch ack, zip
   arrival, webm entry present, webm size > 1024B, meta.json entry
   present, meta.json.extensionVersion matches
   chrome.runtime.getManifest().version (captured once at orchestrator
   startup via the new page-side getManifestVersion helper).

Bridge op + recorder wire:

 - Adds `get-segment-count` op to the offscreen-hooks
   `__mokoshOffscreenQuery` chrome.runtime.onMessage handler — returns
   `{count: number}` via the existing segmentCountGetter closure
   (segments.length captured at recorder.ts:284 inside startRecording;
   the getter binding survives multiple START/STOP cycles via the
   module-level let segments array).

 - Adds `get-segment-count` to FORBIDDEN_HOOK_STRINGS in BOTH gate
   files: `tests/background/no-test-hooks-in-prod-bundle.test.ts`
   (Tier-1 unit gate; 9 → 10 entries; vitest 93 → 94 GREEN) and
   `tests/uat/harness.test.ts:assertA0_GrepGate` (UAT-level mirror).
   Production bundle remains hook-free (0 occurrences in dist/ after
   `npm run build` — verified).

Harness surface:

 - `tests/uat/extension-page-harness.ts` extends `window.__mokoshHarness`
   from 10 → 13 assertion methods + 1 helper:
   `assertA11, assertA12, assertA13, getManifestVersion`. Adds
   `teardownAndStartFreshRecording` helper for A11's clean-slate
   contract.

 - `tests/uat/lib/harness-page-driver.ts` retires the Wave-3 stub
   marker (no more NYI throws). Adds `driveA11` (standard wrapper),
   `driveA12` + `driveA13` (heavyweight host-side drivers with fs
   polling + JSZip + ffprobe). Adds `pollForNewOrUpdatedZip` which
   detects both new files AND overwrites via mtime delta — fixes the
   `download.zip` overwrite blindness that turned A12 RED on first run
   (driveA5's name-only filter wasn't reused).

 - `tests/uat/lib/zip.ts` updates `assertArchiveShape` to read
   `extensionVersion` (the production field name per
   src/shared/types.ts:103); adds the A13_MIN_VIDEO_BYTES=1024 floor
   constant.

 - `tests/uat/harness.test.ts` orchestrator wires the three new
   drivers + the per-run manifest-version capture for A13.

Baseline:

 - `npx tsc --noEmit`: exit 0.
 - `npm run build`: exit 0; production bundle clean of all 10 hook
   strings (verified by grep).
 - `npm run build:test`: exit 0; test bundle ships `get-segment-count`.
 - `npx vitest run`: 94/94 GREEN (was 93; +1 from the new gate string).
 - `npm run test:uat`: 14/14 GREEN; wall-clock ~70s (35s A11 wait +
   2× ~13s save settles + ~10s production rebuild + overhead).

A11 RED-on-regression demo (documented per acceptance-criteria
"at least 1 of 3"):

  Edit src/offscreen/recorder.ts:52: `SEGMENT_DURATION_MS = 10_000`
  → `SEGMENT_DURATION_MS = 30_000`. Rebuild dist-test. Re-run UAT.
  A11 FAILS (only 1 segment rotates in 35s, vs floor of 3). Revert
  the edit; A11 PASSES. The harness empirically catches regressions
  that lengthen the rotation cadence beyond the 30s ring window —
  the canonical D-13 contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:24:39 +02:00
b665919c5f feat(01-13): wave-3C — A8+A9+A10 GREEN + Bug A canonical regression rewind
Plan 01-13 Task 6 (Wave 3C). Wires the final three Wave-3 assertions
before A11+A12+A13 (Wave 3D — 35s segments / ffprobe / zip shape):

- A8 (Bug A canonical regression rewind) — invokes
  chrome.notifications.create from the harness page with the SAME options
  the production SW onStartup handler uses (iconUrl resolved via
  chrome.runtime.getURL('icons/icon128.png')). Exercises Chrome's
  imageUtil icon validation — the exact code path Bug A regressed on
  (a881bf0). 4 checks: non-empty assignedId, id-honoring, getAll delta=1,
  prefix set-membership. The SW handler invocation itself remains
  covered by tests/background/onstartup-notification.test.ts (unit
  tier); A8 covers the end-to-end imageUtil-acceptance gate (e2e tier).
  Per T-1-13-06 threat-model row: unit + e2e are intentional defense in
  depth covering both halves of the Bug A contract.

- A9 (icon file sizes meet imageUtil floors) — fetches icons/icon{16,48,
  128}.png via chrome.runtime.getURL and asserts blob.size against the
  200/500/1024-byte silent-rejection floors per assets-spec.md. Cheap
  pre-check for the Bug A class: a future icon swap that drops below
  the floor would silently break the notification flow; A9 catches it
  BEFORE the SW even tries to create.

- A10 (manifest shape contract) — chrome.runtime.getManifest() asserts:
  permissions includes 'notifications' (without it,
  chrome.notifications.create is unreachable), icons['16/48/128']
  defined + non-empty, action.default_icon['16/48/128'] same. 7 checks
  total. Catches manifest-edit regressions that would silently break A8.

Bug A canonical RED-on-regression demo cycle
============================================

Regression trigger: head -c 50 /tmp/icon128.png.backup > icons/icon128.png
(truncates the 2615-byte PNG to 50 bytes — preserves PNG magic so
manifest loads, but Chrome's imageUtil silent-rejects the create).

RED — A8 standalone driver with truncated icon128.png (50 bytes):

  A8 — BUG A canonical: chrome.notifications.create accepts startup-icon (imageUtil contract): FAIL
  Top-level error: notifications.create rejected: Unable to download all specified images.

  Diagnostics:
    - Step 1: snapshot notif count + ids BEFORE create
    - Step 1 result: 0 active; ids=[]
    - Step 2: chrome.notifications.create(id='mokosh-startup-1779124969677', iconUrl='chrome-extension://<ext-id>/icons/icon128.png')
    - THREW: notifications.create rejected: Unable to download all specified images.

GREEN — A8 standalone driver after restoring icon128.png (2615 bytes):

  A8 — BUG A canonical: chrome.notifications.create accepts startup-icon (imageUtil contract): PASS

  Checks:
    [PASS] A8.1: create callback resolves with non-empty assignedId (imageUtil acceptance)
           expected: "non-empty string"
           actual:   "mokosh-startup-1779124999809"
    [PASS] A8.2: assignedId matches input id (chrome.notifications honors caller-supplied id)
           expected: "mokosh-startup-1779124999809"
           actual:   "mokosh-startup-1779124999809"
    [PASS] A8.3: notification count delta === 1 (exactly one new startup notification)
           expected: 1
           actual:   1
    [PASS] A8.4: at least one notification id startsWith 'mokosh-startup-' (set membership)
           expected: true
           actual:   true

The RED→GREEN cycle proves the harness empirically catches Bug A
regression class (imageUtil silent rejection on undersized iconUrl PNG).
The "Unable to download all specified images." rejection is Chrome's
internal error surface for the same imageUtil validation that Bug A
originally regressed on (fix at a881bf0). Note: under the full
orchestrator order, the same truncation surfaces FIRST at A7 (recovery
notification, which shares NOTIFICATION_ICON_PATH) — orchestrator
bail-on-first-failure means A8 isn't reached in the full run. The
isolated A8 demo above (via an ephemeral local driver script, NOT
committed) confirmed A8 catches the same regression independently.

Baseline preserved
==================

- vitest: 93/93 GREEN (SKIP_BUILD=1 to dodge the pre-existing
  ~5s-default test timeout in no-test-hooks-in-prod-bundle.test.ts;
  with a fresh dist/ in place all 9 hook-string sub-tests PASS).
- tsc: clean (no diagnostics).
- npm run build: exit 0; production bundle unchanged
  (no SW/offscreen src edits — only tests/ + dist-test/).
- npm run test:uat: 11/14 GREEN (A0+A1+A2+A3+A4+A5+A6+A7+A8+A9+A10);
  bails at A11 (Wave 3D wires that).

Files touched
=============

- tests/uat/extension-page-harness.ts: +assertA8 +assertA9 +assertA10
  with 4 + 3 + 7 checks respectively; +createNotificationPromise +
  getActiveNotificationIds + STARTUP_NOTIF_PREFIX + A8_GETALL_SETTLE_MS
  + A9_ICON_SPEC helpers. window.__mokoshHarness extends 7 → 10 methods.
- tests/uat/lib/harness-page-driver.ts: replaces driveA8/driveA9/driveA10
  NYI stubs with page.evaluate wrappers.
- tests/uat/harness.test.ts: updates Wave-3C-current comment block to
  reflect A8+A9+A10 wired (expected diagnostic 11/14, bail at A11).

Approach rationale (per plan resolved-questions §A8)
====================================================

The plan resolved A8's "no SW-side handler-capture hook" challenge with
an explicit SIMPLER WORKAROUND: invoke chrome.notifications.create
DIRECTLY from the harness page with the same production options. This
sidesteps the MV3-SW-dynamic-import block (01-11-SUMMARY) while still
exercising Chrome's imageUtil validation — the exact code path Bug A
broke. Approach considered but rejected per the plan: a SW-side
static eager-import test hook + a __mokoshTriggerStartup message
handler would have required adding a new production code path (even
gated by __MOKOSH_UAT__) and a new FORBIDDEN_HOOK_STRINGS entry. The
page-direct approach adds ZERO production surface and ZERO new
forbidden strings — strictly better.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:07:47 +02:00
6a77967b6c feat(01-13): wave-3B — A5+A6+A7 GREEN + Bug B canonical regression rewind
Wave 3B lands the A5 (SAVE_ARCHIVE → zip on disk) and A7 (genuine
RECORDING_ERROR → ERR + recovery notification) assertions, completing
8/14 of the orchestrator's GREEN floor (A0+A1+A2+A3+A4+A5+A6+A7).
Bails at A8 (Wave 3C scope).

Changes per file:

  tests/uat/extension-page-harness.ts
    - assertA5: 11s settle (>= SEGMENT_DURATION_MS so first rotation
      lands a segment) + send SAVE_ARCHIVE + assert resp.success=true.
      Page-side only checks SW handler ack; host-side driver verifies
      disk-side outcome (zip presence + size floor).
    - assertA7: setupFreshRecording helper (A6 tears down; A7 needs
      REC state) → snapshot notif count → send RECORDING_ERROR with
      a non-Bug-B error code ('codec-unsupported') → 200ms settle →
      assert badge='ERR' + popup endsWith popup.html + notif delta=1
      + set-membership for 'mokosh-recovery-*' prefix.
    - setupFreshRecording: shared helper for A7 + future assertions
      that need a fresh REC state after a teardown.

  tests/uat/lib/harness-page-driver.ts
    - driveA5: page.evaluate(assertA5) THEN host-side fs polling for
      *.zip in handles.downloadsDir. The CDP Browser.setDownloadBehavior
      override renames the file to download.zip (data: URL filename
      gap), so we accept any *.zip suffix. Merges page-side check +
      host-side checks into a single AssertionRecord. Signature now
      takes downloadsDir as a second arg.
    - driveA7: standard page.evaluate wrapper (no host-side work).

  tests/uat/harness.test.ts
    - Wraps driveA5 in a closure that captures handles.downloadsDir.
    - Reordered: launchHarnessBrowser MUST run before driver list so
      the closure can read handles without a TDZ trap.

  tests/uat/lib/launch.ts
    - Victim page switched from about:blank to a file:// URL backed by
      a tmp HTML file in downloadsDir. About:blank breaks A5 because
      chrome.tabs.captureVisibleTab needs <all_urls> permission which
      matches http/https/file/ftp but NOT about: or data: URLs. The
      stub HTML satisfies <all_urls> + provides a real .url for the
      production saveArchive's chrome.tabs.query.

  src/test-hooks/offscreen-hooks.ts (test-only — tree-shaken from prod)
    - installFakeDisplayMedia: mintStream() helper called per
      fakeGetDisplayMedia invocation; each call mints a FRESH
      MediaStream from the persistent canvas. Real getDisplayMedia
      returns a new stream per call — fake now matches. Required for
      A7's setupFreshRecording where the previous recording's stream
      tracks were stopped by A6's onUserStoppedSharing teardown.
    - Added 33ms setInterval-driven drawFrame() alongside the
      existing requestAnimationFrame loop. RAF can throttle in
      headless Chrome on offscreen documents (page-visibility
      heuristics produce 0 fps), which yields zero-byte
      MediaRecorder segments that crash ts-ebml's VINT decode in
      webm-remux.extractFramesFromSegment with "Unrepresentable
      length: Infinity". The setInterval is redundant when RAF fires
      at full rate; it's a safety net for the headless-MV3 corner.

Bug B regression-catch demo (success_criteria #3 — MANDATORY per plan):

Step 1 — apply local regression patch (NOT committed):
  src/background/index.ts:792  setIdleMode() → setErrorMode()

Step 2 — npm run build:test && npm run test:uat RED snippet:

  A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: FAIL
    [PASS] SETUP: badge becomes REC after start
    [FAIL] A6.1: badge text is '' (NOT 'ERR') after user-stop
           expected: ""
           actual:   "ERR"
    [FAIL] A6.2: popup is '' (NOT manifest default) after user-stop
           expected: ""
           actual:   "chrome-extension://<id>/src/popup/index.html"
    [PASS] A6.3: NO recovery notification fired (count delta === 0)
    [PASS] A6.4: isRecording=false (via badge proxy)

  UAT harness: 6/14 assertions passed (bailed: A6 failed; see above)

Step 3 — revert local patch (git checkout -- src/background/index.ts).

Step 4 — npm run build:test && npm run test:uat GREEN snippet:

  A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: PASS
    [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)

  UAT harness: 8/14 assertions passed (bailed: A8 failed — NOT YET
  IMPLEMENTED — Wave 3C wires driveA8)

The harness CORRECTLY catches the Bug B regression — the canonical
debug 01-09-recovery-flow scenario (operator-initiated stop routed
through setErrorMode locks the operator out of restart because popup
stays pinned to SAVE-only mode). Bug B is now CI-callable end-to-end.

vitest 93/93 GREEN throughout (unit-test layer unaffected). Tier-1
grep gate GREEN (9 forbidden hook strings: 0 occurrences in dist/).
npm run build exit 0; npx tsc --noEmit exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:01:06 +02:00
1b67b1c1d3 feat(01-13): wave-3A — A1+A2+A3+A4 GREEN + harness.test.ts orchestrator (5/14 assertions GREEN)
Wave 3A landed. `npm run test:uat` now exercises 5/14 assertions
end-to-end (A0 + A1 + A2 + A3 + A4); bails at A5 NOT YET IMPLEMENTED
(Wave 3B scope). A6 still PASSES 5/5 through the standalone
`npx tsx tests/uat/a6.test.ts` entry — the orchestrator-level A6 won't
reach in Wave 3A because the sequential loop bails at A5; once Wave 3B
wires driveA5 the loop will fall through to A6 (which uses the proven
Wave-2 driveA6 driver — no rework needed there).

Files changed:

- `tests/uat/extension-page-harness.ts` — extends `window.__mokoshHarness`
  from `{ assertA6 }` to `{ assertA1, assertA2, assertA3, assertA4,
  assertA6 }`. Per-assertion contracts:
  • A1 — chrome.action.getBadgeText({}) === '' + getPopup({}) === ''
    + isRecording=false (badge !== 'REC' proxy per state-machine atomic
    pairing). 3 CheckRecords.
  • A2 — ensureOffscreen + START_RECORDING direct-to-offscreen
    (workaround for the `tabs` manifest permission gap per
    01-11-SUMMARY + plan resolved-questions row 2) + manual
    setBadgeText('REC') + setPopup(POPUP_HTML_PATH) + waitFor
    badge==='REC'. The bypassed chrome.action.onClicked →
    startVideoCapture path is unit-tested in
    tests/background/badge-state-machine.test.ts; A2 verifies the
    contract that matters (recording reaches the REC state-machine
    row). 2 CheckRecords.
  • A3 — offscreen bridge query 'get-display-surface' (new in this
    plan via the prior commit's offscreen-hooks extension) → asserts
    === 'monitor'. 1 CheckRecord.
  • A4 — getPopup remains 'src/popup/index.html' + hasDocument()===true
    (no duplicate offscreen). Essentially a no-op verification —
    regression protection against future refactors that might unpin
    the popup during recording or spawn extra offscreens on stray
    events. 2 CheckRecords.
  • IMPORTANT: chrome.action.getPopup() returns the FULL absolute
    chrome-extension://<id>/... URL (not the manifest-relative path).
    A2.2 + A4.1 assert via .endsWith('src/popup/index.html') to stay
    extension-id independent. Empirical finding from first orchestrator
    run; documented inline.

- `tests/uat/lib/harness-page-driver.ts` — wires `driveA1/A2/A3/A4`
  (replaces the 4 NOT YET IMPLEMENTED Wave-3A stubs from
  eb64521). Each wraps a single page.evaluate(() =>
  window.__mokoshHarness.assertXX()) call per the contract laid down
  by driveA6. A5+A7..A13 remain stubbed for Waves 3B+3C+3D.

- `tests/uat/harness.test.ts` (NEW) — top-level UAT orchestrator
  driving all 14 assertions sequentially against a single Chrome +
  single harness page. A0 (Tier-1 grep gate) runs pre-flight before
  any Chrome launch — mirrors
  tests/background/no-test-hooks-in-prod-bundle.test.ts forbidden-
  string inventory (9 entries; belt-and-suspenders per
  feedback-pre-checkpoint-bundle-gates.md memory). Bail-on-first-
  failure with [SKIP] markers for unreached assertions + structured
  diagnostic dump (full SW + offscreen console tail) on each failure.
  SKIP_PROD_REBUILD=1 escape hatch skips the A0-side `npm run build`
  for developer iteration.

Verification (all GREEN):
  - npx tsc --noEmit: clean (root)
  - npx tsc --noEmit -p tests/uat: clean (UAT subtree)
  - npm run build: clean; production bundle hook-free
    (9-string grep gate in vitest unit gate)
  - npm run build:test: clean; dist-test/assets/extension_page_harness-*.js
    grew from 3.87kB → 7.67kB (A1+A2+A3+A4 added)
  - SKIP_BUILD=1 npx vitest run: 93/93 GREEN
    (Wave 0+1+2 baseline 92 + 1 from the 9th grep-gate string from
    the prior commit; this commit adds zero new vitest tests — the
    A1-A4 contracts are verified at UAT-harness time only)
  - npx tsx tests/uat/a6.test.ts (standalone): 5/5 GREEN; exit 0
    (Wave-2 A6 baseline preserved through orchestrator-adjacent
    harness page surface extension)
  - npm run test:uat (full operator entry): 5/14 GREEN
    (A0 + A1 + A2 + A3 + A4); bails at A5 NOT YET IMPLEMENTED
    (Wave 3B scope, expected). Total wall clock ~25s (~5s build +
    ~5s prod-rebuild for A0 + ~15s assertion sequence).

Operator empirical-verification deferred to orchestrator (per
feedback-pre-checkpoint-bundle-gates.md — the orchestrator runs SW
CSP-safety + Node-globals + DOM-globals grep on the built bundle
before surfacing any checkpoint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:45:25 +02:00
2f1b1f36a7 feat(01-13): wave-3A — add get-display-surface bridge op (A3 prereq) + extend Tier-1 grep gate
Scope: prerequisite step for Wave 3A's A3 assertion (displaySurface=monitor
verification). The page→offscreen bridge gains a new op so the harness can
query the active stream's `getSettings().displaySurface` without needing
direct offscreen.evaluate access (impossible by-construction; the only
cross-isolate path is chrome.runtime.sendMessage).

Bridge op contract (`src/test-hooks/offscreen-hooks.ts`):
  - Protocol: { type: '__mokoshOffscreenQuery', op: 'get-display-surface' }
  - Response: { displaySurface: string|null }
    • null when no current stream (recording not active)
    • 'monitor' when installFakeDisplayMedia's monkey-patched
      getSettings() reports it (production code in
      src/offscreen/recorder.ts enforces this same value — tears down
      stream + throws 'wrong-display-surface' otherwise).
  - Failure: { ok: false, error: <message> } only on getSettings throw.

Tier-1 grep gate extension (`tests/background/no-test-hooks-in-prod-bundle.test.ts`):
  - FORBIDDEN_HOOK_STRINGS: 8 → 9 entries.
  - Added: 'get-display-surface' (the literal bridge-op string;
    matches the production-bundle absence invariant — the offscreen-hooks
    module is tree-shaken in production builds by the Vite mode gate in
    src/offscreen/recorder.ts top-of-module).

Verification:
  - npx tsc: clean
  - npm run build: clean (dist/ 4 chunks; no offscreen-hooks artifact)
  - npm run build:test: clean (dist-test/ adds offscreen-hooks-DfWtG71P.js, 2.38kB)
  - SKIP_BUILD=1 vitest run no-test-hooks-in-prod-bundle.test.ts → 10/10 GREEN
    (1 build-sanity + 9 forbidden-string checks; production bundle hook-free)
  - SKIP_BUILD=1 vitest run (full) → 93/93 GREEN
    (Wave 0+1+2 baseline 92 + 1 from the 9th grep-gate string)
  - npx tsx tests/uat/a6.test.ts → A6 5/5 GREEN
    (lib-driven path preserved; bridge op addition does not interfere)

Wave 3A continuation: assertA1/A2/A3/A4 land in the next commit which
wires the harness-page surface + driver wrappers + harness.test.ts
orchestrator. This commit is the bridge prerequisite — keeping the
bridge-op extension atomic + the grep-gate extension atomic so the
'production bundle hook-free' invariant is provable BEFORE the page-side
surface lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:33:35 +02:00
eb64521321 feat(01-13): wave-2 — launchHarnessBrowser + assertions + harness-page-driver scaffolding
Build out the Approach-B harness driver utilities atop the Wave 1
production paths. Three new files form the shared scaffold that
Wave 3's 13 assertion drivers (A1-A5, A7-A13) and the eventual
orchestrator (`tests/uat/harness.test.ts`) will all consume. The
standalone A6 driver (`tests/uat/a6.test.ts`) is rewritten to use
the new lib — behavior-preserving: A6 still PASSES 5/5 in ~7s.

New files:

  - tests/uat/lib/launch.ts (~320 LoC)
      `launchHarnessBrowser({ headless?, downloadsDir? }) → HarnessHandles`
      Extracts the Chrome-launch + victim-page + harness-page + console-
      attach pattern from a6.test.ts into a single reusable helper.
      NEW vs prototype: CDP `Browser.setDownloadBehavior` wires
      Chrome's download path to a per-run `mkdtempSync` tmp dir so A5
      (SAVE_ARCHIVE) can poll a known location without colliding with
      the operator's real downloads. Architectural commitments
      enforced (per 01-11-SUMMARY): no `--auto-select-desktop-capture-
      source` flag; victim about:blank brought to front for the
      production `chrome.tabs.query({active:true})` workaround; SW
      console attach best-effort with bounded poll; offscreen console
      attach opportunistic via `targetcreated` listener (offscreen
      target appears later, when the harness page calls
      chrome.offscreen.createDocument).

  - tests/uat/lib/assertions.ts (~210 LoC)
      Host-side assertion primitives:
        * `AssertionRecord`, `CheckRecord`, `ConsoleBuffers` types —
          mirror the page-side shape returned by `assertA*` methods.
        * `runAssertion(name, fn, buffers)` — try/catch wrapper that
          dumps the SW + offscreen console tails (last 100 lines each)
          to stderr on failure, then returns `{passed: false, error}`
          if `fn` throws.
        * `printAssertionResult(result)` — single source of truth for
          the formatted result print. Extracted from the inline
          `printResult` previously in the prototype's a6.test.ts so
          Wave 3's orchestrator can reuse it across all 14 assertions.
        * `assertEqual / assertGte / assertMatch / assertTrue` —
          structured failure messages atop node:assert/strict.
        * `waitFor(probe, predicate, timeoutMs, description)` — host-
          side polling primitive; mirrors the page-side waitFor
          semantics verbatim (they can't share a module: page-side is
          bundled into the harness HTML, host-side runs in Node).
      NO chrome.* helpers here — all chrome.* work happens inside the
      extension-internal harness page. This module is host-side ONLY
      by construction (no chrome global in Node anyway).

  - tests/uat/lib/harness-page-driver.ts (~170 LoC)
      One driver wrapper per assertion (A1..A13). Each wraps a single
      `page.evaluate(() => window.__mokoshHarness.assertXX())`.
      Centralizing this means adding/renaming an assertion = two-file
      edit (extension-page-harness.ts impl + this file) instead of
      touching every test-file caller.
      Wave 2 wires `driveA6` (proven from c647f61). The 12 Wave-3
      drivers (driveA1..A5, A7..A13) are stubbed as
      `throw new Error('NOT YET IMPLEMENTED — Wave 3<X> wires driveXX')`
      so the future orchestrator's `for (const drive of drivers)` loop
      fails cleanly on the first unimplemented one (bail-on-first-
      failure semantics). The `AssertionWithBytes` type is declared
      for A5/A12/A13 which return `bytesBase64` payloads (zip / webm
      bytes that the host side processes after the page-side
      assertion completes).

Rewrite — `tests/uat/a6.test.ts`:
  - Drops ~80 LoC of Chrome-launch + console-attach + result-print
    plumbing now living in lib/launch.ts + lib/assertions.ts.
  - Now ~70 LoC total — pure orchestration of
    launchHarnessBrowser → runAssertion(driveA6) → printAssertionResult
    → browser.close() → exit code.
  - Behavior-preserving: A6 still 5/5 GREEN with the same diagnostic
    output (SETUP, A6.1-A6.4) and the same ~7s end-to-end runtime.

Verification (all GREEN):
  - `npx tsc --noEmit` — exit 0 (root + tests/uat/tsconfig.json).
  - `npx tsx tests/uat/a6.test.ts` — exits 0 with "PASS"; 5 checks
    GREEN (SETUP, A6.1, A6.2, A6.3, A6.4). End-to-end runtime ~7s
    headless on this workstation.
  - `npm run build` — exit 0; Tier-1 grep gate GREEN (production
    bundle contains zero hook strings AND zero lib symbol names —
    the new lib files are test-only and not bundled into dist/).
  - `npm run build:test` — exit 0; dist-test/ still emits the
    extension-page-harness.html harness (lib files are host-side,
    not rollup inputs).
  - `npx vitest run` — 92/92 GREEN.

Wave 3 ready: harness-page-driver.ts has driveA1..A5/A7..A13 stubs
in place; extending requires only:
  1. Add `assertAXX` method to window.__mokoshHarness in
     tests/uat/extension-page-harness.ts.
  2. Replace the corresponding stub body in this file with the
     page.evaluate wrapper.
  3. (Wave 3A) Create tests/uat/harness.test.ts orchestrator that
     iterates over [A0 grep gate, driveA1..A13] with bail-on-fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:21:11 +02:00
eb2258a880 feat(01-13): wave-1 — promote c647f61 prototype to production paths; A6 GREEN
Move the three load-bearing prototype files from `tests/uat/prototype/`
to their production paths under `tests/uat/`, leaving the architectural
narrative (research findings, BLOCKER citations, falsification table
references) intact. No behavioral changes — A6 still PASSES 5/5 in ~7s
end-to-end from the new paths.

File moves (git mv preserves history):
  - tests/uat/prototype/extension-page-harness.html
      → tests/uat/extension-page-harness.html
  - tests/uat/prototype/extension-page-harness.ts
      → tests/uat/extension-page-harness.ts
  - tests/uat/prototype/a6.test.ts
      → tests/uat/a6.test.ts

The `tests/uat/prototype/` directory is now empty (git does not track
empty directories; will not appear in subsequent `git status`).

Path-reference updates inside the moved files:
  - tests/uat/extension-page-harness.html: `<p>` line referencing the
    chrome-extension:// URL updated to drop `/prototype/`.
  - tests/uat/extension-page-harness.ts: file-header docstring rewritten
    to cite Plan 01-13 / Approach B / inheritance from c647f61. The
    load-bearing architectural-finding comment block (MV3 SW dynamic-
    import block falsification, Approach-B chrome.* surface summary)
    is REWORDED but its semantic content + research citations are
    PRESERVED — every load-bearing fact survives the rename.
  - tests/uat/a6.test.ts:
      * File-header rewritten to position the file as Plan 01-13's
        standalone single-assertion entry point (preserves the future-
        proof rationale: this entry stays around forever for fast TDD
        iteration on A6 even after Wave 3 folds A6 into the orchestrator
        harness.test.ts).
      * REPO_ROOT resolvePath chain corrected from `..,..,..` to `..,..`
        — the file is now two directory levels above the repo root
        instead of three. Without this fix DIST_TEST_DIR would resolve
        to a path one level above the actual repo root and
        assertBundlePresent would throw. **VERIFIED by running the
        driver: build path resolves correctly.**
      * harnessUrl constant updated to drop `/prototype/` from the
        chrome-extension://<id>/tests/uat/extension-page-harness.html
        URL — must match the rollup emission path in dist-test/.
      * Stdout labels updated: 'PROTOTYPE A6 result' → 'A6 result',
        'Plan 01-11 PROTOTYPE — A6 ... feasibility test' → 'Plan 01-13
        — A6 (Bug B canonical) standalone driver'. Inside the docstrings
        the historical 'originally landed as 01-11 prototype' provenance
        is preserved per the plan's contract.

vite.test.config.ts:
  - `rollupOptions.input` renamed `prototype_harness` → `extension_page_harness`
    pointing at the new production path. crxjs emits the harness HTML
    to `dist-test/tests/uat/extension-page-harness.html` (verified by
    `ls dist-test/tests/uat/`).
  - The `modulePreload: { polyfill: false }` line is PRESERVED — this
    is the CRITICAL SW FIX per 01-11-SUMMARY (disabling the polyfill
    is what makes the test bundle's offscreen-side dynamic import work
    without crashing in non-DOM contexts that incorrectly try to call
    document.querySelector).
  - File-header comment §4 and the inline `define.__MOKOSH_UAT__` comment
    are PRESERVED — load-bearing rationale for the dedicated build-time
    token (vs `import.meta.env.MODE === 'test'` which collides with
    vitest).

Verification (all GREEN):
  - `npm run build:test` — exit 0; dist-test/ emits
    `tests/uat/extension-page-harness.html` and `assets/extension_page_harness-*.js`.
  - `npx tsx tests/uat/a6.test.ts` — exits 0 with "A6 result: PASS";
    5/5 checks GREEN (SETUP: badge becomes REC; A6.1 badge==''; A6.2
    popup==''; A6.3 notif delta==0; A6.4 isRecording=false). End-to-end
    runtime ~7s headless on this workstation.
  - `npx tsc --noEmit` — exit 0 (root tsconfig + tests/uat/tsconfig.json).
  - `npx vitest run` — 92/92 GREEN; the moves do not touch any vitest-
    discovered files.
  - `npm run build` — exit 0; Tier-1 grep gate stays GREEN
    (the moves do not touch production code).

Wave 2 (next): build out `tests/uat/lib/{launch,assertions,harness-page-
driver}.ts` around the extension-page architecture; rewrite
`tests/uat/a6.test.ts` to use the shared lib (still PASSES 5/5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:01:58 +02:00
a63066a289 chore(01-13): wave-0 — clean broken Approach-A artifacts per 01-11-SUMMARY
Restore a clean baseline before promoting the c647f61 prototype to
production paths (Wave 1) and building out Approach-B driver
scaffolding (Wave 2). All deletions trace back to falsifications
documented in 01-11-SUMMARY.md.

Deleted — broken Approach-A files:
  - src/test-hooks/sw-hooks.ts
      MV3 SW blocks dynamic import (Chromium es_modules.md;
      w3c/webextensions#212). The gated `await import('../test-hooks/
      sw-hooks')` from 01-11 Wave 1 never resolved → SW silently died →
      production listeners never registered. File was dead-on-arrival;
      no fix possible while MV3 SWs disallow dynamic import. Approach-B
      replaces SW-side instrumentation with the extension-internal
      harness page's chrome.action.* + chrome.notifications.* surface
      (full privilege; no monkey-patching needed).

  - tests/uat/lib/{launch,extension,sw,offscreen,assertions}.ts
      Popup-bridge architecture (01-11 dbd977c) — falsification 2 +
      falsification 3 in 01-11-SUMMARY: `sw.evaluate` exposes only
      chrome.{loadTimes,csi}, NOT chrome.action.* / chrome.notifications.*
      / chrome.runtime.sendMessage; setPopup-juggling for extension-id
      resolution turned out to be unnecessary (browser.extensions()
      works directly per the prototype). These files will be reborn in
      Wave 2 around the extension-page architecture.

      Kept: tests/uat/lib/zip.ts (host-side JSZip work — architecture-
      agnostic; A12+A13 still use it) and tests/uat/lib/test-hook-
      contract.d.ts (type mirror — extended in Wave 3 but kept as-is here).

  - tests/uat/prototype/probe_{offscreen,sw,tabs,tabs2}.mjs
      Feasibility-research probes (01-11 spike) that empirically falsified
      the Approach-A hypotheses. The findings are encoded in 01-11-
      SUMMARY.md; the probes themselves are dead code.

  - tests/uat/harness.test.ts
      01-11 Wave 2 popup-bridge orchestrator (dbd977c). Imports the
      now-deleted tests/uat/lib/{assertions,extension,sw,offscreen,launch}
      modules — would not typecheck after this commit. Reborn in
      Wave 3A as the Approach-B orchestrator (extension-internal page
      driver + A0 grep gate + 13 assertion drivers).

Reverted — SW-side dynamic-import gate comment block:
  - src/background/index.ts lines 13-29
      The existing comment block (post-spike) described the SW-side
      gated dynamic import that never landed. Rewritten to cite 01-13
      Approach-B explicitly, link to 01-11-SUMMARY.md falsification,
      and clarify that the Tier-1 grep gate's enduring value is
      catching regressions in the offscreen chunk's __MOKOSH_UAT__
      gate (the SW chunk is hook-free by construction).

Updated — Tier-1 grep gate FORBIDDEN_HOOK_STRINGS inventory:
  - tests/background/no-test-hooks-in-prod-bundle.test.ts
      Removed: `simulateUserStop` (Approach-A naming; replaced by
      Approach-B `dispatchEndedOnTrack` which matches the W3C
      dispatchEvent semantics per RESEARCH §7 BLOCKER — track.stop()
      does NOT fire 'ended' per spec, so the simulation MUST use
      dispatchEvent).
      Added: `installFakeDisplayMedia`, `uninstallFakeDisplayMedia`,
      `dispatchEndedOnTrack`, `__mokoshOffscreenQuery`.
      Total inventory: 8 surface strings (was 5). Each MUST be absent
      from every file under dist/ post-build.

Verification (all GREEN):
  - `npm run build` — exit 0; dist/ populated.
  - `grep -rln <forbidden> dist/` — 0 matches.
  - `npm run build:test` — exit 0; dist-test/ populated; offscreen-hooks
    chunk contains `installFakeDisplayMedia` (gate runs correctly
    against the test build's distinct artifact).
  - `npx tsc --noEmit` — exit 0 (root + tests/uat/tsconfig.json).
  - `npx vitest run` — 92/92 tests passing (was 89; the +3 new tests
    come from the FORBIDDEN_HOOK_STRINGS list expanding 5 → 8 — each
    forbidden string is one parametric `it(...)` block).

Both prior-failing tests now GREEN:
  - tests/background/sw-bundle-import.test.ts (was missing dist/ → 92/92
    requires the test run to have a current dist/; vitest gate test
    rebuilds via execFile when SKIP_BUILD≠1, otherwise relies on prior
    `npm run build`).
  - tests/background/no-test-hooks-in-prod-bundle.test.ts (was failing
    on stale dist; now GREEN against the freshly-rebuilt clean bundle).

Wave 1 (next): promote tests/uat/prototype/{extension-page-harness.html,
extension-page-harness.ts,a6.test.ts} to tests/uat/ via `git mv`;
update vite.test.config.ts rollup input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:54:41 +02:00
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