feat(01-09-save-stops): GREEN — SAVE_ARCHIVE auto-stops recording per SPEC one-shot intent

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 13:22:37 +02:00
parent cd83eb0498
commit 4f4c3e2241

View File

@@ -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() { async function saveArchive() {
try { try {
logger.log('Starting archive save process'); logger.log('Starting archive save process');
@@ -648,9 +664,9 @@ async function saveArchive() {
rrwebEvents = response?.events ?? []; rrwebEvents = response?.events ?? [];
userEvents = response?.userEvents ?? []; userEvents = response?.userEvents ?? [];
logger.log(`✓ Received ${rrwebEvents.length} rrweb events, ${userEvents.length} user events`); logger.log(`✓ Received ${rrwebEvents.length} rrweb events, ${userEvents.length} user events`);
// Логируем первые несколько событий для отладки // Логируем первые несколько событий для отладки
if (rrwebEvents.length > 0) { if (rrwebEvents.length > 0) {
logger.log('First rrweb event sample:', JSON.stringify(rrwebEvents[0]).substring(0, 200) + '...'); logger.log('First rrweb event sample:', JSON.stringify(rrwebEvents[0]).substring(0, 200) + '...');
@@ -658,7 +674,7 @@ async function saveArchive() {
if (userEvents.length > 0) { if (userEvents.length > 0) {
logger.log('First user event:', userEvents[0]); logger.log('First user event:', userEvents[0]);
} }
} catch (messageError) { } catch (messageError) {
logger.warn('✗ Failed to get events from content script:', 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'); 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 }; 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();
} }
} }