Skip to content

Commit 080b782

Browse files
committed
fix(recording): allow stop while paused and guard native pause
- Fix stopRecording to handle MediaRecorder 'paused' state by resuming before stopping, ensuring final data chunk is flushed - Guard pauseRecording as no-op for native recordings (macOS SCK / Windows WGC) that lack pause IPC support - Add 14 unit tests for pause/stop state machine
1 parent a95f32a commit 080b782

File tree

2 files changed

+240
-1
lines changed

2 files changed

+240
-1
lines changed
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
/**
4+
* Tests for the MediaRecorder pause/stop state machine logic
5+
* extracted from useScreenRecorder.
6+
*
7+
* These verify that:
8+
* - Stop works from both "recording" and "paused" states
9+
* - Resume is called before stop when stopping from paused state
10+
* - Pause is a no-op when already paused or not recording
11+
* - Resume is a no-op when not paused
12+
*/
13+
14+
function createMockMediaRecorder(initialState: RecordingState = "inactive") {
15+
let _state: RecordingState = initialState;
16+
return {
17+
get state() {
18+
return _state;
19+
},
20+
pause: vi.fn(() => {
21+
if (_state === "recording") _state = "paused";
22+
}),
23+
resume: vi.fn(() => {
24+
if (_state === "paused") _state = "recording";
25+
}),
26+
stop: vi.fn(() => {
27+
_state = "inactive";
28+
}),
29+
start: vi.fn(() => {
30+
_state = "recording";
31+
}),
32+
};
33+
}
34+
35+
/**
36+
* Extracted state machine logic matching useScreenRecorder's stopRecording,
37+
* pauseRecording, and resumeRecording implementations.
38+
*/
39+
function stopRecording(
40+
recorder: ReturnType<typeof createMockMediaRecorder>,
41+
isNativeRecording: boolean,
42+
) {
43+
if (isNativeRecording) {
44+
return { stopped: true, wasNative: true };
45+
}
46+
47+
const recorderState = recorder.state;
48+
if (recorderState === "recording" || recorderState === "paused") {
49+
if (recorderState === "paused") {
50+
recorder.resume();
51+
}
52+
recorder.stop();
53+
return { stopped: true, wasNative: false };
54+
}
55+
return { stopped: false, wasNative: false };
56+
}
57+
58+
function pauseRecording(
59+
recorder: ReturnType<typeof createMockMediaRecorder>,
60+
recording: boolean,
61+
paused: boolean,
62+
isNativeRecording: boolean,
63+
): boolean {
64+
if (!recording || paused) return false;
65+
if (isNativeRecording) return false;
66+
if (recorder.state === "recording") {
67+
recorder.pause();
68+
return true;
69+
}
70+
return false;
71+
}
72+
73+
function resumeRecording(
74+
recorder: ReturnType<typeof createMockMediaRecorder>,
75+
recording: boolean,
76+
paused: boolean,
77+
): boolean {
78+
if (!recording || !paused) return false;
79+
if (recorder.state === "paused") {
80+
recorder.resume();
81+
return true;
82+
}
83+
return false;
84+
}
85+
86+
describe("useScreenRecorder state machine", () => {
87+
let recorder: ReturnType<typeof createMockMediaRecorder>;
88+
89+
beforeEach(() => {
90+
recorder = createMockMediaRecorder("recording");
91+
});
92+
93+
describe("stopRecording", () => {
94+
it("stops from recording state", () => {
95+
const result = stopRecording(recorder, false);
96+
97+
expect(result.stopped).toBe(true);
98+
expect(recorder.stop).toHaveBeenCalled();
99+
expect(recorder.resume).not.toHaveBeenCalled();
100+
expect(recorder.state).toBe("inactive");
101+
});
102+
103+
it("resumes then stops from paused state", () => {
104+
recorder.pause();
105+
expect(recorder.state).toBe("paused");
106+
107+
const result = stopRecording(recorder, false);
108+
109+
expect(result.stopped).toBe(true);
110+
expect(recorder.resume).toHaveBeenCalled();
111+
expect(recorder.stop).toHaveBeenCalled();
112+
expect(recorder.state).toBe("inactive");
113+
});
114+
115+
it("resume is called before stop when paused", () => {
116+
recorder.pause();
117+
const callOrder: string[] = [];
118+
recorder.resume.mockImplementation(() => {
119+
callOrder.push("resume");
120+
});
121+
recorder.stop.mockImplementation(() => {
122+
callOrder.push("stop");
123+
});
124+
125+
stopRecording(recorder, false);
126+
127+
expect(callOrder).toEqual(["resume", "stop"]);
128+
});
129+
130+
it("does nothing when already inactive", () => {
131+
const inactiveRecorder = createMockMediaRecorder("inactive");
132+
133+
const result = stopRecording(inactiveRecorder, false);
134+
135+
expect(result.stopped).toBe(false);
136+
expect(inactiveRecorder.stop).not.toHaveBeenCalled();
137+
});
138+
139+
it("delegates to native path for native recordings", () => {
140+
const result = stopRecording(recorder, true);
141+
142+
expect(result.stopped).toBe(true);
143+
expect(result.wasNative).toBe(true);
144+
expect(recorder.stop).not.toHaveBeenCalled();
145+
});
146+
});
147+
148+
describe("pauseRecording", () => {
149+
it("pauses an active recording", () => {
150+
const result = pauseRecording(recorder, true, false, false);
151+
152+
expect(result).toBe(true);
153+
expect(recorder.pause).toHaveBeenCalled();
154+
expect(recorder.state).toBe("paused");
155+
});
156+
157+
it("does nothing when already paused", () => {
158+
recorder.pause();
159+
recorder.pause.mockClear();
160+
161+
const result = pauseRecording(recorder, true, true, false);
162+
163+
expect(result).toBe(false);
164+
expect(recorder.pause).not.toHaveBeenCalled();
165+
});
166+
167+
it("does nothing when not recording", () => {
168+
const result = pauseRecording(recorder, false, false, false);
169+
170+
expect(result).toBe(false);
171+
expect(recorder.pause).not.toHaveBeenCalled();
172+
});
173+
174+
it("does nothing for native recordings", () => {
175+
const result = pauseRecording(recorder, true, false, true);
176+
177+
expect(result).toBe(false);
178+
expect(recorder.pause).not.toHaveBeenCalled();
179+
});
180+
});
181+
182+
describe("resumeRecording", () => {
183+
it("resumes a paused recording", () => {
184+
recorder.pause();
185+
186+
const result = resumeRecording(recorder, true, true);
187+
188+
expect(result).toBe(true);
189+
expect(recorder.resume).toHaveBeenCalled();
190+
expect(recorder.state).toBe("recording");
191+
});
192+
193+
it("does nothing when not paused", () => {
194+
const result = resumeRecording(recorder, true, false);
195+
196+
expect(result).toBe(false);
197+
expect(recorder.resume).not.toHaveBeenCalled();
198+
});
199+
200+
it("does nothing when not recording", () => {
201+
const result = resumeRecording(recorder, false, true);
202+
203+
expect(result).toBe(false);
204+
});
205+
});
206+
207+
describe("pause → stop → editor flow", () => {
208+
it("full lifecycle: record → pause → stop completes cleanly", () => {
209+
expect(recorder.state).toBe("recording");
210+
211+
pauseRecording(recorder, true, false, false);
212+
expect(recorder.state).toBe("paused");
213+
214+
const result = stopRecording(recorder, false);
215+
expect(result.stopped).toBe(true);
216+
expect(recorder.state).toBe("inactive");
217+
});
218+
219+
it("full lifecycle: record → pause → resume → stop completes cleanly", () => {
220+
expect(recorder.state).toBe("recording");
221+
222+
pauseRecording(recorder, true, false, false);
223+
expect(recorder.state).toBe("paused");
224+
225+
resumeRecording(recorder, true, true);
226+
expect(recorder.state).toBe("recording");
227+
228+
const result = stopRecording(recorder, false);
229+
expect(result.stopped).toBe(true);
230+
expect(recorder.state).toBe("inactive");
231+
});
232+
});
233+
});

src/hooks/useScreenRecorder.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
185185
return;
186186
}
187187

188-
if (mediaRecorder.current?.state === "recording") {
188+
const recorderState = mediaRecorder.current?.state;
189+
if (recorderState === "recording" || recorderState === "paused") {
190+
if (recorderState === "paused") {
191+
mediaRecorder.current.resume();
192+
}
189193
cleanupCapturedMedia();
190194
mediaRecorder.current.stop();
191195
setRecording(false);
@@ -550,6 +554,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
550554

551555
const pauseRecording = useCallback(() => {
552556
if (!recording || paused) return;
557+
// Native recordings (macOS SCK / Windows WGC) don't support pause yet
558+
if (nativeScreenRecording.current) return;
553559
if (mediaRecorder.current?.state === "recording") {
554560
mediaRecorder.current.pause();
555561
setPaused(true);

0 commit comments

Comments
 (0)