docs(fix-d12): resolve debug session and update STATE

- Mark .planning/debug/d12-blob-port-transfer-fails.md as
  status: resolved; fill in the Resolution section with the
  applied fix (5 commit hashes, files changed), verification
  output (15/15 tests, tsc clean, vite build green, zero
  as-any/ts-ignore in fix-touched files), and inline answers
  to the specialist-review questions raised by the planner.
  Move the file to .planning/debug/resolved/.
- Update STATE.md frontmatter (stopped_at) + Decisions log
  + Session Continuity to record the D-12 fix landing and
  the open Plan 07 ffprobe gate (still requires operator
  smoke.sh + ffprobe re-run before Phase 1 can close).
- Land smoke.sh — the operator's D-12 acceptance-gate harness
  that surfaced the original failure. Self-contained: dedicated
  /tmp/mokosh-smoke-profile, auto-accept desktop-capture picker,
  Downloads polling, ffprobe gate, fixture staging.

REQ-video-ring-buffer remains NOT-complete — Plan 07 owns it,
operator must re-run ./smoke.sh to verify the fix end-to-end
in Chrome.

Refs: debug session d12-blob-port-transfer-fails (resolved).
This commit is contained in:
2026-05-15 20:23:29 +02:00
parent d5bb948d95
commit bf076199b4
3 changed files with 343 additions and 6 deletions

View File

@@ -3,8 +3,8 @@ gsd_state_version: 1.0
milestone: v2.0.0 milestone: v2.0.0
milestone_name: milestone milestone_name: milestone
status: executing status: executing
stopped_at: Completed Plan 01-06 — vite.config.ts collapse (226 -> 21 lines), orphan offscreen/ dir deleted, dist build green, crxjs Outcome A confirmed and SW URL reconciled (chrome.runtime.getURL('src/offscreen/index.html')); 9/9 tests still green; Plan 07 next (ffprobe acceptance gate) stopped_at: D-12 port-blob serialization bug fixed via base64 wire-format (debug session d12-blob-port-transfer-fails resolved). Five commits on gsd/phase-01-stabilize-video-pipeline (c0d9166, d653283, 2831849, d5bb948 + this docs commit). All 15 tests green (incl. 6-test port-serialization contract — RED reproduces the 75-byte garbage, GREEN pins the base64 fix). tsc clean, npm run build succeeds. Plan 07 ffprobe gate still open — operator must re-run ./smoke.sh to verify the fix end-to-end in Chrome.
last_updated: "2026-05-15T16:16:50.760Z" last_updated: "2026-05-15T18:20:00.000Z"
last_activity: 2026-05-15 last_activity: 2026-05-15
progress: progress:
total_phases: 5 total_phases: 5
@@ -101,6 +101,7 @@ current work:
- [Phase ?]: [Phase 01-05]: indexedDB.deleteDatabase('VideoRecorderDB') in onInstalled — T-1-NEW-05-02 / RESEARCH.md Runtime State Inventory cleanup of orphaned IDB from pre-Phase-01 builds - [Phase ?]: [Phase 01-05]: indexedDB.deleteDatabase('VideoRecorderDB') in onInstalled — T-1-NEW-05-02 / RESEARCH.md Runtime State Inventory cleanup of orphaned IDB from pre-Phase-01 builds
- [Phase ?]: [Phase 01-06]: Collapsed vite.config.ts from 226 -> 21 lines (RESEARCH.md Example B verbatim); deleted 174-line inline copy-offscreen plugin (audit P0 #1 root cause) and the orphan offscreen/ top-level directory (D-08) - [Phase ?]: [Phase 01-06]: Collapsed vite.config.ts from 226 -> 21 lines (RESEARCH.md Example B verbatim); deleted 174-line inline copy-offscreen plugin (audit P0 #1 root cause) and the orphan offscreen/ top-level directory (D-08)
- [Phase ?]: [Phase 01-06]: crxjs Outcome A confirmed — dist/src/offscreen/index.html (preserves src/ prefix from rollupOptions.input key). SW URL adjusted to chrome.runtime.getURL('src/offscreen/index.html'); RESEARCH.md Pitfall 5 binding empirically verified - [Phase ?]: [Phase 01-06]: crxjs Outcome A confirmed — dist/src/offscreen/index.html (preserves src/ prefix from rollupOptions.input key). SW URL adjusted to chrome.runtime.getURL('src/offscreen/index.html'); RESEARCH.md Pitfall 5 binding empirically verified
- [Phase 01-07-debug-d12]: D-12 port-blob serialization fixed via base64 wire-format encode/decode (debug session d12-blob-port-transfer-fails resolved 2026-05-15). chrome.runtime.Port JSON-serializes payloads across extension contexts so Blob payloads were silently corrupted (JSON.stringify(blob) === "{}" → SW saw [{}, {}, ...] → new Blob([...]) coerced each to "[object Object]" → 75-byte text instead of WebM). Added src/shared/binary.ts (blobToBase64 / base64ToBlob), TransferredVideoChunk wire-format type, offscreen encode side, SW decode side. All 15 tests green incl. 6-test port-serialization spec. Re-run smoke.sh + ffprobe still required for end-to-end verification.
### Pending Todos ### Pending Todos
@@ -123,7 +124,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity ## Session Continuity
Last session: 2026-05-15T16:16:50.743Z Last session: 2026-05-15T18:20:00.000Z
Stopped at: Completed Plan 01-06 — vite.config.ts collapse (226 -> 21 lines), orphan offscreen/ dir deleted, dist build green, crxjs Outcome A confirmed and SW URL reconciled (chrome.runtime.getURL('src/offscreen/index.html')); 9/9 tests still green; Plan 07 next (ffprobe acceptance gate) Stopped at: D-12 port-blob serialization bug fixed via base64 wire-format encode/decode (debug session d12-blob-port-transfer-fails resolved). 5 commits on gsd/phase-01-stabilize-video-pipeline; all 15 tests green; tsc clean; npm run build succeeds. REQ-video-ring-buffer NOT yet marked complete — Plan 07 ffprobe gate still owns that, operator must re-run ./smoke.sh + ffprobe to verify the fix end-to-end in Chrome before closing Phase 1.
intel synthesis. Coverage validated: 11/11 v1 REQs mapped. Resume file: .planning/debug/resolved/d12-blob-port-transfer-fails.md
Resume file: None

View File

@@ -0,0 +1,98 @@
---
slug: d12-blob-port-transfer-fails
status: resolved
trigger: Phase 1 D-12 ffprobe gate failure surfaced during /gsd-execute-phase 1 Plan 01-07 manual smoke
created: 2026-05-15
updated: 2026-05-15
resolved: 2026-05-15
phase: 1
plan: 01-07
---
# Debug session — D-12 Blob/port transfer fails
## Symptoms
- **Expected:** Saved `session_report_*.zip` contains `video/last_30sec.webm` ≈ 1.5 MB of binary VP9 WebM video that ffprobe accepts as Matroska/WebM with a valid EBML header and one playable video stream.
- **Actual:** The WebM file is **75 bytes** of raw text: `"[object Object][object Object][object Object][object Object][object Object]"` (5× `"[object Object]"`, 5×15 = 75 bytes). ffprobe rejects with `EBML header parsing failed`; ffprobe's `-show_streams` lists a single stream of type `subtitle`, codec_name `text`, codec_long_name `raw UTF-8 text` — empirical proof the file payload is text, not video.
- **Error messages:**
- `Truncating packet of size 8810 to 71`
- `[matroska,webm @ 0x...] EBML header parsing failed`
- `Invalid data found when processing input`
- ffprobe exit code 1
- **Timeline:** First execution of Plan 01-07 (the D-12 acceptance gate) during /gsd-execute-phase 1 on 2026-05-15. Phase 1 Plans 01-01 through 01-06 all green (9/9 unit tests pass, build clean). Recording itself ran for ~35 s (operator clicked extension, share-screen picker auto-accepted via `--auto-select-desktop-capture-source="Mokosh Smoke Test"`, waited, clicked "Сохранить отчёт об ошибке").
- **Reproduction:**
1. `cd /home/parf/projects/work/repremium && npm run build` (already done; dist/ is current)
2. Load `dist/` unpacked in Chrome 148.0.7778.167 stable
3. Run `./smoke.sh` — Chrome launches with `--auto-select-desktop-capture-source="Mokosh Smoke Test"` + smoke profile at `/tmp/mokosh-smoke-profile`
4. Click extension icon, wait ~35 s, click "Сохранить отчёт об ошибке"
5. Latest archive at `~/Downloads/session_report_2026-05-15_19-42-01.zip`
6. `unzip -p <zip> video/last_30sec.webm > /tmp/last.webm; ffprobe -v error -f matroska -i /tmp/last.webm`
- **Archive forensics already collected:**
- `meta.json.totalEvents: 0` (content script doesn't run on `data:` URLs; orthogonal — known limitation, not part of this bug)
- `events.json: []` (same reason as above)
- `rrweb/session.json` = 2 bytes (`[]`, same)
- `screenshot.png` = 97 895 bytes (real PNG — confirms `chrome.tabs.captureVisibleTab` works fine; image data goes through `dataURL` string so escapes the suspected serialization bug)
## Current Focus
- **hypothesis:** `chrome.runtime.connect` port messages are JSON-serialized when crossing extension contexts (offscreen ↔ service worker). `JSON.stringify(blob)` returns `"{}"` because Blob has no enumerable own properties. The port handler in `src/offscreen/recorder.ts:174-178` sends `{ type: 'BUFFER', chunks: getBuffer() }` where each chunk has `.data: Blob`. After JSON round-trip the SW receives chunks where each `.data` is a plain empty object `{}`. The SW then calls `new Blob([...chunkDataArray], { type: 'video/webm' })` at `src/background/index.ts:213-217`; the Blob constructor stringifies non-Blob members via `String({})` which yields `"[object Object]"`. Concatenating 5 such strings (no commas because Blob ctor doesn't insert separators, unlike Array.toString) produces exactly 75 bytes — matching the observed payload to the byte.
- **next_action:** Write a RED unit test in `tests/offscreen/port-serialization.test.ts` (or similar) that proves the failure mode empirically. The test should: (1) build a fake `chrome.runtime.connect` port whose `postMessage` runs the payload through `structuredClone(JSON.parse(JSON.stringify(msg)))` — Chrome's documented cross-context behavior — and (2) assert that after the round-trip, `received.chunks[0].data instanceof Blob` is `false` AND `String(received.chunks[0].data) === "[object Object]"`. Once the test FAILS as predicted (which means the hypothesis IS reproduced), we have the RED gate. The fix then converts Blob → ArrayBuffer in offscreen before postMessage and ArrayBuffer → Blob in SW after receive (ArrayBuffer IS structured-cloneable across extension contexts).
- **expecting:** RED test goes red. If it goes green instead — hypothesis is wrong; reopen investigation.
- **reasoning_checkpoint:** the math (5×15 = 75) and the JSON.stringify(blob) = "{}" chain explain every digit of the observed payload, but the test is the empirical seal. I have NOT yet observed the bug via instrumentation (no console.log dump showing `chunk.data instanceof Blob` true at port-send and false at port-receive). The test is the cheapest way to seal that gap.
- **specialist_hint:** chrome-extension-mv3 or browser-platform; the bug is at the chrome.runtime port boundary, not in our application logic. The fix pattern (ArrayBuffer transfer for binary data across extension contexts) is documented in Chrome's developer docs and verified in production extensions (per Phase 1 RESEARCH.md Patterns 4+5 references).
## Evidence
- timestamp: 2026-05-15T17:50:37Z — Baseline test run: 9/9 existing tests green (`npm test -- --run`). Confirms the regression introduced by the new RED test is isolated and not a side-effect.
- timestamp: 2026-05-15T17:51:00Z — Source-file inspection confirms hypothesis-relevant code locations exactly as predicted:
- `src/offscreen/recorder.ts:168-180``onPortMessage` handler: `keepalivePort.postMessage({ type: 'BUFFER', chunks: getBuffer() })`. Each chunk in `videoBuffer` is built by `addChunk(blob: Blob, ts: number)` (line 31-43), so `chunk.data` IS a Blob at send-time.
- `src/background/index.ts:71-89` — SW-side port host. Handler chain `getVideoBufferFromOffscreen` → port message handler at lines 105-116 reads `(msg as { chunks?: VideoChunk[] }).chunks` directly with no Blob reconstruction.
- `src/background/index.ts:204-222``mergeVideoChunks`: `const blobs: Blob[] = sortedChunks.map((chunk) => chunk.data); new Blob(blobs, { type: 'video/webm' })`. After the JSON round-trip, `chunk.data` is `{}`, so `blobs` is `[{}, {}, …]`, and the Blob ctor coerces each member via `String({})` = `"[object Object]"`.
- `src/shared/types.ts:36-40``VideoChunk { data: Blob; timestamp: number; isFirst?: boolean }`. The type declaration is sound; the bug is the unstated assumption that this shape survives the port transport.
- timestamp: 2026-05-15T17:51:30Z — Forensic byte-level confirmation: `hexdump -C /tmp/mokosh-last_30sec.webm` yields 75 bytes = 5 × 16-byte stripes of `5b 6f 62 6a 65 63 74 20 4f 62 6a 65 63 74 5d` (= `[object Object]` with no separator). Matches predicted output byte-for-byte. Independently confirms that exactly 5 chunks were in the SW's view of the buffer at SAVE_ARCHIVE time, which is consistent with a 30 s recording at TIMESLICE_MS=2000 producing 15 chunks of which the age-trim keeps a tail (further forensics not needed — the failure mode is sealed by the test below).
- timestamp: 2026-05-15T17:52:44Z — **RED test written and executed**: `tests/offscreen/port-serialization.test.ts`. 6/6 tests PASS. Critical assertions:
- `JSON.stringify(new Blob([4 bytes])) === "{}"` — confirmed.
- `JSON.parse(JSON.stringify({chunks: [{data: blob}]}))` yields `chunks[0].data === {}` (not a Blob, no instanceof Blob) — confirmed.
- `new Blob([{}, {}, {}, {}, {}]).size === 75` AND its `.text() === "[object Object][object Object][object Object][object Object][object Object]"` — confirmed byte-exact match to the observed payload.
- End-to-end test: 5 real Blob chunks → JSON round-trip → SW-side `mergeVideoChunks`-equivalent → output is exactly 75 bytes of the same `"[object Object]"` repetition.
- Forward-pin GREEN block: base64 round-trip preserves the EBML magic bytes (`0x1a 0x45 0xdf 0xa3`) intact across `JSON.parse(JSON.stringify(...))`. This pins the fix's wire-format contract.
- timestamp: 2026-05-15T17:52:52Z — Full suite still green: 15/15 tests pass (9 baseline + 6 new). No regression in `recorder.ts` or any other surface.
## Eliminated
- **Not a MediaRecorder issue.** Existing `tests/offscreen/codec-check.test.ts` and `ring-buffer.test.ts` exercise the recorder side and show valid Blob accumulation with correct sizes. The recorder.ts module captures Blobs with `.size > 0`; the corruption is purely on the wire.
- **Not a Blob-ctor type-tag issue.** The SW passes `{ type: 'video/webm' }` correctly; if Blob members were preserved this would produce a valid WebM. The corruption is the contents of the array passed to the ctor, not the type tag.
- **Not a JSZip issue.** Plan 01-07 forensics showed `screenshot.png` = 97 895 bytes intact, because the screenshot goes Blob → dataURL → fetch → Blob inside the SW (not through a port). JSZip handles Blob input correctly; the input it received was already 75 bytes of garbage.
- **Not a Chrome version regression.** Per Chrome MV3 docs (https://developer.chrome.com/docs/extensions/develop/concepts/messaging), `chrome.runtime` messaging has ALWAYS used JSON serialization across contexts. The "structured clone" mention in some docs refers only to same-process messaging (popup→SW within the same renderer); offscreen↔SW crosses a process boundary.
- **Not a `videoPort === null` issue.** Even though the SW-side `videoPort` is reassigned per-connection, the `getVideoBufferFromOffscreen` request/response cycle completed (5 chunks arrived — not 0). If port disconnect were the issue, we would see 0 chunks → empty merge → 0-byte WebM, not 75 bytes.
- **Not a TIMESLICE/timing issue.** The 5 chunks suggest 10 seconds' worth of timeslices at 2000ms each; could be a partial buffer after age-trim or a short recording. Either way, the byte-level evidence shows the chunks are well-formed metadata + corrupted Blob, which is exactly what JSON serialization predicts.
## Resolution
- **root_cause:** `chrome.runtime.Port.postMessage` JSON-serializes payloads across extension contexts (offscreen ↔ service worker). `JSON.stringify(blob)` returns `"{}"` because `Blob` has no enumerable own properties. The receive side reads `chunks[i].data` as `{}` (a plain object), then passes the array `[{}, {}, ...]` to `new Blob(...)`. The Blob constructor coerces non-Blob members via `String({})` = `"[object Object]"`, concatenated with no separator, yielding exactly 75 bytes for 5 chunks. Verified empirically by `tests/offscreen/port-serialization.test.ts` (6/6 PASS, including byte-exact reproduction of the observed 75-byte payload).
- **fix (applied 2026-05-15):** Base64 wire-format encode/decode across the offscreen↔SW port. Four atomic source commits + one docs commit on `gsd/phase-01-stabilize-video-pipeline`:
| # | Commit | Subject |
|---|---------|----------------------------------------------------------------------------------|
| 1 | c0d9166 | feat(fix-d12): add binary encode/decode helpers in src/shared/binary.ts |
| 2 | d653283 | feat(fix-d12): add TransferredVideoChunk wire-format type in src/shared/types.ts |
| 3 | 2831849 | feat(fix-d12): encode chunks to base64 in offscreen REQUEST_BUFFER handler |
| 4 | d5bb948 | feat(fix-d12): decode chunks from base64 in SW BUFFER receive |
| 5 | (next) | docs(fix-d12): resolve debug session and update STATE |
Files created: `src/shared/binary.ts` (portable Blob↔base64 helpers, mirroring the GREEN-block algorithm from `port-serialization.test.ts`). Files modified: `src/shared/types.ts` (added `TransferredVideoChunk`, retargeted `PortMessage.chunks`), `src/offscreen/recorder.ts` (encode-and-send via fire-and-forget IIFE, per-chunk defensive encode, re-check `keepalivePort !== null` after `await`), `src/background/index.ts` (decode in `getVideoBufferFromOffscreen` BUFFER handler, per-chunk defensive decode with `video/webm;codecs=vp9` fallback MIME).
- **verification:**
- `npx vitest run`**5 files passed, 15 tests passed** (9 baseline + 6 new port-serialization). Both GREEN-block tests confirm the wire format: (a) base64 round-trip preserves the EBML magic bytes `0x1A 0x45 0xDF 0xA3` across `JSON.parse(JSON.stringify(...))`; (b) merging base64-decoded chunks yields a real WebM-prefixed Blob (size = 4 ≠ 75).
- `npx tsc --noEmit`**exit 0**, no errors.
- `npm run build`**vite v5.4.21 built in 1.45 s**; fresh `dist/` includes `dist/assets/binary-y3zCmpDG.js` proving the new module is bundled into the offscreen + SW chunks.
- `grep -RIn "as any\|@ts-ignore" src/{shared,offscreen,background}`**zero violations** in fix-touched files.
- End-to-end smoke (`./smoke.sh` + ffprobe gate) is the operator's next step — the unit-test contract is the wire-format proof; browser-runtime exercise validates port stability under real MediaRecorder load. Not part of this fix's scope (Plan 07 owns it).
- **specialist review (resolved inline):**
- Base64 vs alternatives: kept (~33% inflation × ~1.5 MB raw ≈ 2 MB string per export — within Chrome's ~50 MB port message limit by a wide margin). OPFS / chunked IDB were higher-engineering alternatives unjustified given the size envelope.
- Async `onPortMessage` reordering: ruled out — `saveArchive` is the only caller and serializes through `isRecording`. The fire-and-forget IIFE keeps the listener signature synchronous (port API ignores return values).
- **TDD gate status:** RED block still passes (it tests browser-documented JSON behavior, not our code path). GREEN block now also passes against the production-helper wire-format contract. Production code routes through the same encode/decode algorithm as the GREEN-block helpers, by import from `src/shared/binary.ts`.

239
smoke.sh Executable file
View File

@@ -0,0 +1,239 @@
#!/usr/bin/env bash
# Mokosh Phase 1 — D-12 ffprobe acceptance gate smoke test.
#
# Architecture:
# - Launches Chrome with a DEDICATED `/tmp/mokosh-smoke-profile` user-data-dir
# so we don't interfere with your daily Chrome session.
# - Opens a data: URL with title "Mokosh Smoke Test" as the share target.
# - Passes `--auto-select-desktop-capture-source="Mokosh Smoke Test"` so the
# getDisplayMedia picker auto-accepts that tab — no manual pick.
# - Logs Chrome stderr/stdout to /tmp/mokosh-chrome.log.
# - Polls ~/Downloads for the new session_report_*.zip.
# - Runs ffprobe gate, stages fixture, opens WebM in Chrome for visual check.
#
# Chrome 148+ removed `--load-extension`, so the extension load is the ONE
# manual step that has to happen each time the smoke profile is fresh. (Once
# loaded, it persists in the profile until the dir is wiped — see KEEP_PROFILE.)
#
# What YOU do (~3 clicks total):
# 1. Run this script.
# 2. In the Chrome window that opens, address-bar → chrome://extensions
# → toggle "Developer mode" ON → "Load unpacked" → select
# /home/parf/projects/work/repremium/dist (only needed if profile is fresh).
# 3. Click the extension icon (puzzle-piece menu if not pinned).
# The screen-share picker auto-accepts the "Mokosh Smoke Test" tab.
# 4. Wait ~35 seconds (or move mouse / scroll the smoke tab for frame deltas).
# 5. Click the icon again → click "Сохранить отчёт об ошибке".
# This script handles everything after that.
#
# Env knobs:
# CHROME_BIN — Chrome binary (default: /usr/bin/google-chrome-stable)
# KEEP_PROFILE=1 — don't wipe the smoke profile, so extension stays loaded
# across runs (default: wipe, fresh-load each run)
# POLL_TIMEOUT — max seconds to wait for the download (default: 900 = 15m)
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DIST_DIR="${REPO_DIR}/dist"
PROFILE_DIR="/tmp/mokosh-smoke-profile"
DOWNLOADS_DIR="${HOME}/Downloads"
FIXTURE_DEST="${REPO_DIR}/tests/fixtures/last_30sec.webm"
WEBM_TMP="/tmp/mokosh-last_30sec.webm"
CHROME_LOG="/tmp/mokosh-chrome.log"
SHARE_TARGET="Mokosh Smoke Test"
CHROME_BIN="${CHROME_BIN:-/usr/bin/google-chrome-stable}"
KEEP_PROFILE="${KEEP_PROFILE:-0}"
POLL_TIMEOUT="${POLL_TIMEOUT:-900}"
red() { printf '\033[31m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
blue() { printf '\033[34m%s\033[0m\n' "$*"; }
echo
blue "==> Mokosh Phase 1 smoke test (D-12 ffprobe gate)"
echo
# --- pre-flight ---
[[ -d "${DIST_DIR}" ]] || { red "FAIL: ${DIST_DIR} missing — run \`npm run build\` first"; exit 1; }
[[ -x "${CHROME_BIN}" ]] || { red "FAIL: ${CHROME_BIN} not found (set CHROME_BIN=...)"; exit 1; }
command -v ffprobe >/dev/null || { red "FAIL: ffprobe not installed"; exit 1; }
command -v unzip >/dev/null || { red "FAIL: unzip not installed"; exit 1; }
grep -q '"desktopCapture"' "${DIST_DIR}/manifest.json" || { red "FAIL: dist/manifest.json missing desktopCapture"; exit 1; }
! grep -q '"tabCapture"' "${DIST_DIR}/manifest.json" || { red "FAIL: dist/manifest.json still has tabCapture"; exit 1; }
green "✓ pre-flight checks passed"
echo " chrome: ${CHROME_BIN} ($("${CHROME_BIN}" --version 2>/dev/null || echo 'unknown'))"
echo " dist: ${DIST_DIR}"
echo " profile: ${PROFILE_DIR} (KEEP_PROFILE=${KEEP_PROFILE})"
echo " log: ${CHROME_LOG}"
echo " downloads: ${DOWNLOADS_DIR}"
echo
# --- snapshot Downloads ---
BEFORE_LIST=$(find "${DOWNLOADS_DIR}" -maxdepth 1 -name 'session_report_*.zip' 2>/dev/null | wc -l)
echo " existing session_report_*.zip in Downloads: ${BEFORE_LIST}"
echo
# --- profile prep ---
PROFILE_HAS_EXTENSION=0
if [[ "${KEEP_PROFILE}" != "1" ]]; then
rm -rf "${PROFILE_DIR}"
fi
mkdir -p "${PROFILE_DIR}"
# Detect if a previous run already loaded the extension into this profile
if [[ -d "${PROFILE_DIR}/Default/Extensions" ]] && \
find "${PROFILE_DIR}/Default/Extensions" -maxdepth 3 -name 'manifest.json' 2>/dev/null | head -1 | xargs -r grep -q 'AI Call Recorder' 2>/dev/null; then
PROFILE_HAS_EXTENSION=1
green "✓ extension already loaded in profile from previous KEEP_PROFILE=1 run"
fi
# --- compose the smoke tab data URL ---
read -r -d '' SMOKE_HTML <<'EOF' || true
<title>Mokosh Smoke Test</title>
<style>body{font-family:sans-serif;background:#222;color:#eee;padding:40px;line-height:1.5}code{background:#444;padding:2px 6px;border-radius:3px}ol li{margin:6px 0}.flash{animation:flash 1s infinite;background:#0a4;padding:4px 8px;display:inline-block}@keyframes flash{0%,100%{opacity:1}50%{opacity:.4}}</style>
<body>
<h1>🧵 Mokosh Smoke Test</h1>
<p>This tab is the share-screen target. The picker auto-accepts because the title matches <code>--auto-select-desktop-capture-source</code>.</p>
<h2>Steps:</h2>
<ol>
<li><strong class="flash">First time only:</strong> Go to <code>chrome://extensions</code> → toggle <strong>Developer mode</strong> ON → <strong>Load unpacked</strong> → select <code>/home/parf/projects/work/repremium/dist</code>.<br>(Set <code>KEEP_PROFILE=1</code> when re-running this script to skip the reload.)</li>
<li>Click the <strong>AI Call Recorder</strong> toolbar icon (or puzzle-piece menu).</li>
<li>The picker auto-accepts <em>this tab</em>. Confirm Chrome's "Sharing your screen" indicator appears.</li>
<li>Wait <strong>≥ 35 seconds</strong>. Move the mouse around or scroll this page so vp9 has frame deltas.</li>
<li>Click the toolbar icon again → click <strong>Сохранить отчёт об ошибке</strong>.</li>
</ol>
<p>The script in your terminal will detect the download and finish the ffprobe gate automatically.</p>
</body>
EOF
SMOKE_DATA_URL="data:text/html,$(printf '%s' "${SMOKE_HTML}" | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read(), safe=""))' 2>/dev/null || printf '%s' "${SMOKE_HTML}")"
# --- launch Chrome ---
blue "==> launching Chrome with smoke profile + auto-accept picker..."
"${CHROME_BIN}" \
--user-data-dir="${PROFILE_DIR}" \
--auto-select-desktop-capture-source="${SHARE_TARGET}" \
--no-first-run \
--no-default-browser-check \
--new-window \
"${SMOKE_DATA_URL}" \
> "${CHROME_LOG}" 2>&1 &
CHROME_PID=$!
echo " Chrome PID: ${CHROME_PID}"
echo " Tail with: tail -f ${CHROME_LOG}"
sleep 4
if ! ps -p "${CHROME_PID}" >/dev/null 2>&1; then
# Chrome may have exec'd into a singleton; check if any chrome procs are running with our profile dir
if ! pgrep -f "${PROFILE_DIR}" >/dev/null 2>&1; then
red "FAIL: Chrome exited within 4 seconds"
echo " Last 30 lines of ${CHROME_LOG}:"
tail -30 "${CHROME_LOG}" 2>/dev/null || true
exit 3
fi
fi
green "✓ Chrome running"
echo
# --- prompt ---
if [[ ${PROFILE_HAS_EXTENSION} -eq 1 ]]; then
yellow "==> Extension is already loaded. Just click the icon → wait 35s → click save."
else
yellow "==> In the Chrome window:"
echo " 1. chrome://extensions → Developer mode ON → Load unpacked → ${DIST_DIR}"
echo " 2. Confirm 'AI Call Recorder' appears with no red error."
echo " 3. Click the extension icon."
echo " 4. WAIT >= 35 seconds."
echo " 5. Click the icon again → 'Сохранить отчёт об ошибке'."
fi
echo
blue "==> waiting for a new session_report_*.zip to appear..."
echo " (Ctrl+C aborts. Auto-detects, ffprobes, stages fixture, opens WebM.)"
echo
# --- poll Downloads ---
NEW_ARCHIVE=""
WAITED=0
while [[ ${WAITED} -lt ${POLL_TIMEOUT} ]]; do
NEW_COUNT=$(find "${DOWNLOADS_DIR}" -maxdepth 1 -name 'session_report_*.zip' 2>/dev/null | wc -l)
if [[ ${NEW_COUNT} -gt ${BEFORE_LIST} ]]; then
sleep 2 # let the download settle
LATEST=$(find "${DOWNLOADS_DIR}" -maxdepth 1 -name 'session_report_*.zip' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2- || true)
if [[ -n "${LATEST}" ]]; then
NEW_ARCHIVE="${LATEST}"
break
fi
fi
sleep 1
WAITED=$((WAITED + 1))
if [[ $((WAITED % 30)) -eq 0 ]]; then
yellow " ...still waiting (${WAITED}s elapsed, count=${NEW_COUNT}/baseline=${BEFORE_LIST})"
fi
done
if [[ -z "${NEW_ARCHIVE}" ]]; then
red "FAIL: no new archive appeared in ${POLL_TIMEOUT}s"
echo " Chrome log tail:"
tail -30 "${CHROME_LOG}" 2>/dev/null || true
echo " Chrome still running — close manually or: kill ${CHROME_PID}"
exit 4
fi
green "✓ archive detected: ${NEW_ARCHIVE}"
echo
# --- extract + ffprobe gate ---
unzip -p "${NEW_ARCHIVE}" video/last_30sec.webm > "${WEBM_TMP}"
SIZE_BYTES=$(stat -c %s "${WEBM_TMP}")
SIZE_HUMAN=$(ls -lh "${WEBM_TMP}" | awk '{print $5}')
echo " WebM size: ${SIZE_HUMAN} (${SIZE_BYTES} bytes)"
if [[ ${SIZE_BYTES} -lt 100000 ]]; then
yellow "⚠ WebM is smaller than 100 KB — buffer may not have rotated; capture longer"
fi
echo
blue "==> D-12 ACCEPTANCE GATE — ffprobe -v error"
echo "---"
ffprobe -v error -f matroska -i "${WEBM_TMP}" && GATE=0 || GATE=$?
echo "---"
echo "ffprobe exit: ${GATE}"
if [[ ${GATE} -eq 0 ]]; then
green "✓ D-12 ACCEPTANCE GATE PASSED"
else
red "✗ D-12 ACCEPTANCE GATE FAILED — D-13 fallback (restart-segments) required"
yellow "==> diagnostic for D-13 escalation:"
ffprobe -v error -show_packets -i "${WEBM_TMP}" 2>&1 | head -50 || true
fi
echo
blue "==> stream / format dump (paste this back to the orchestrator):"
echo "---"
ffprobe -v error -show_format -show_streams "${WEBM_TMP}" 2>&1 | head -30 || true
echo "---"
echo
# --- stage fixture ---
if [[ ${GATE} -eq 0 ]]; then
mkdir -p "$(dirname "${FIXTURE_DEST}")"
cp "${WEBM_TMP}" "${FIXTURE_DEST}"
green "✓ fixture staged: ${FIXTURE_DEST} ($(ls -lh "${FIXTURE_DEST}" | awk '{print $5}'))"
fi
# --- open the WebM for visual check ---
echo
blue "==> opening the WebM for visual playback (SPEC §10 #7)..."
"${CHROME_BIN}" --user-data-dir="${PROFILE_DIR}" --new-window "file://${WEBM_TMP}" >/dev/null 2>&1 &
echo
green "==> smoke complete."
echo " Chrome (smoke profile) PID ${CHROME_PID} still running."
echo " Kill when done: kill ${CHROME_PID}"
echo " To keep the extension loaded across runs: KEEP_PROFILE=1 ./smoke.sh"
echo
if [[ ${GATE} -eq 0 ]]; then
green "==> NEXT: reply 'approved' to the orchestrator with the stream/format dump."
else
red "==> NEXT: reply 'ffprobe-failed' with the show_packets diagnostic above."
fi
exit "${GATE}"