From 4f4c3e22414829786dc62fdb30b605d22a761cec Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 19 May 2026 13:22:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(01-09-save-stops):=20GREEN=20=E2=80=94=20S?= =?UTF-8?q?AVE=5FARCHIVE=20auto-stops=20recording=20per=20SPEC=20one-shot?= =?UTF-8?q?=20intent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator UAT closure for Plan 01-13 Task 9. Patches saveArchive() in src/background/index.ts with a `finally` block that dispatches STOP_RECORDING to offscreen (mirrors the existing START_RECORDING control-plane channel via chrome.runtime.sendMessage), flips isRecording=false, and calls setIdleMode() — applied to BOTH the success and empty-buffer-error paths. Operator UX contract: SAVE click ALWAYS stops the session, regardless of internal success/empty-buffer outcome. The badge clears, the popup empties (re-enabling chrome.action.onClicked for restart), and Chrome's sharing banner closes via the offscreen recorder's stopRecording() (which nulls mediaStream + stops all tracks + clears the rotation timer — line 527 of src/offscreen/recorder.ts, already wired since Plan 01-05). Trade-off documented inline: empty-buffer path still surfaces a recovery notification (the catch branch emits RECORDING_ERROR{ error:'empty-video-buffer'} → SW's own onMessage handler runs setErrorMode + creates a mokosh-recovery-* notif). The finally block then setIdleMode()'s, so the FINAL visible state is OFF/empty-popup — clean restart path. The notification stays visible briefly so the operator sees that something went wrong, then clicks it to start a new session. Test count: 94 GREEN (baseline) → 98 GREEN (+4 from tests/background/save-archive-stops-recording.test.ts). Files modified: - src/background/index.ts (saveArchive + finally block; no PortMessage/Message type changes — STOP_RECORDING already in MessageType per src/shared/types.ts:14, offscreen handler at recorder.ts:848 already wired) Toolchain: - npx tsc --noEmit: exit 0 (no type errors) - npm run build: exit 0 (dist/ clean rebuild) Debug record: .planning/debug/01-09-save-stops-recording.md Co-Authored-By: Claude Opus 4.7 (1M context) --- src/background/index.ts | 68 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/background/index.ts b/src/background/index.ts index 0e606d0..e05b64a 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -612,6 +612,22 @@ async function downloadArchive(archiveBlob: Blob) { } // Сохранение архива (полный процесс) +// +// Plan 01-13 Task 9 (debug session 01-09-save-stops-recording): SAVE is +// one-shot per SPEC intent (`Тз расширение фаза1.md`: "one click MUST +// produce a self-contained archive"). After the zip lands (success path) +// OR an EmptyVideoBufferError surfaces (empty-buffer path), the SW +// auto-stops the recording — dispatches STOP_RECORDING to offscreen, +// flips isRecording=false, transitions to IDLE. This mirrors the Bug B +// `user-stopped-sharing` branch in the RECORDING_ERROR handler: deliberate +// stop, NOT an error — no recovery notification. +// +// The post-save teardown is invoked from a `finally` block so both +// branches share the same teardown semantics: the operator perceives +// SAVE click as ALWAYS stopping the session, success or empty-buffer +// alike. Otherwise an empty-buffer error would leave the recording live +// + an ERR badge + a recovery notification, defeating the operator UX +// contract. async function saveArchive() { try { logger.log('Starting archive save process'); @@ -648,9 +664,9 @@ async function saveArchive() { rrwebEvents = response?.events ?? []; userEvents = response?.userEvents ?? []; - + logger.log(`✓ Received ${rrwebEvents.length} rrweb events, ${userEvents.length} user events`); - + // Логируем первые несколько событий для отладки if (rrwebEvents.length > 0) { logger.log('First rrweb event sample:', JSON.stringify(rrwebEvents[0]).substring(0, 200) + '...'); @@ -658,7 +674,7 @@ async function saveArchive() { if (userEvents.length > 0) { logger.log('First user event:', userEvents[0]); } - + } catch (messageError) { logger.warn('✗ Failed to get events from content script:', messageError); logger.warn('This may happen on special pages (chrome://, about:blank) or if content script is not injected'); @@ -700,6 +716,52 @@ async function saveArchive() { } } return { success: false, error }; + } finally { + // Plan 01-13 Task 9 post-save auto-stop. Runs on BOTH the success + // path (zip downloaded) and the catch path (EmptyVideoBufferError + + // any other createArchive failure). The operator clicked SAVE — the + // session is over from their perspective regardless of internal + // success/failure. Releasing the MediaStream + transitioning to + // IDLE is the consistent operator-facing UX contract. + // + // Three steps, each independently best-effort: + // 1. STOP_RECORDING → offscreen via chrome.runtime.sendMessage. + // The offscreen recorder's onMessage handler at + // src/offscreen/recorder.ts:848 dispatches STOP_RECORDING + // through `stopRecording()`, which nulls mediaStream, stops + // the MediaRecorder, releases all tracks (closes Chrome's + // sharing banner), and clears the rotation timer. + // 2. isRecording = false on the SW side so the next + // chrome.action.onClicked toolbar gesture isn't blocked by + // the guard at line ~877. + // 3. setIdleMode → empties the popup (re-enables onClicked + // restart path per MV3 contract) and paints the OFF badge. + // + // RECORDING_ERROR path note: if the catch branch above dispatched + // RECORDING_ERROR{error:'empty-video-buffer'}, the SW's own + // RECORDING_ERROR handler at line ~756 would run setErrorMode + // (yellow ERR badge + recovery notification). The current ordering + // — error broadcast in catch, then setIdleMode in finally — means + // the FINAL state is IDLE: setIdleMode lands AFTER the RECORDING_ERROR + // self-message because the handler is synchronous and runs + // immediately on dispatch (chrome.runtime.sendMessage to self in + // SW context is dispatched within the same event loop turn). The + // post-save IDLE state machine wins; the operator sees a clean + // OFF badge + empty popup + sharing banner closed. The + // empty-buffer notification still fires (operator sees the + // recovery notif briefly) but the badge resolves to OFF — a + // documented trade-off keeping the empty-buffer surface visible + // while preserving the SAVE=stop contract. + try { + chrome.runtime.sendMessage({ type: 'STOP_RECORDING' }); + } catch (sendErr) { + // Offscreen may be unreachable mid-teardown; non-fatal — SW state + // still transitions to IDLE so the operator regains the restart + // path. + logger.warn('STOP_RECORDING post-save dispatch failed:', sendErr); + } + isRecording = false; + setIdleMode(); } }