diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70bb14815be..e5643b31774 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,6 +141,9 @@ jobs: __tests__/unit/invite-decline.test.ts __tests__/unit/linkified-text.test.ts __tests__/unit/loom-import-ui.test.ts + __tests__/unit/comment-recorder-lifecycle.test.ts + __tests__/unit/voice-recorder-errors.test.ts + __tests__/unit/voice-recorder-error-ui.test.ts __tests__/unit/media-comment-playback-cleanup.test.ts __tests__/unit/media-format-converter-selection.test.ts __tests__/unit/media-format-converter.test.ts diff --git a/apps/web/__tests__/unit/comment-recorder-lifecycle.test.ts b/apps/web/__tests__/unit/comment-recorder-lifecycle.test.ts index 9ecdf58cb3e..7885de2cd23 100644 --- a/apps/web/__tests__/unit/comment-recorder-lifecycle.test.ts +++ b/apps/web/__tests__/unit/comment-recorder-lifecycle.test.ts @@ -61,6 +61,8 @@ class Stream { class Recorder { static isTypeSupported = () => true; + static last: Recorder; + static stopError = false; static startError: Error | null = null; state: RecordingState = "inactive"; @@ -69,7 +71,9 @@ class Recorder { onerror: ((event: Event) => void) | null = null; mimeType = "video/webm"; - constructor(private readonly stream: Stream) {} + constructor(private readonly stream: Stream) { + Recorder.last = this; + } start() { if (Recorder.startError) throw Recorder.startError; @@ -81,6 +85,18 @@ class Recorder { stop() { this.state = "inactive"; + if (Recorder.stopError) + queueMicrotask(() => { + const error = new Event("error"); + Object.defineProperty(error, "target", { value: this }); + Object.defineProperty(error, "error", { + value: new Error("Encoder failed"), + }); + this.onerror?.(error); + const stopped = new Event("stop"); + Object.defineProperty(stopped, "target", { value: this }); + this.onstop?.(stopped); + }); } } @@ -100,6 +116,7 @@ describe("comment recorder lifecycle", () => { beforeEach(async () => { vi.clearAllMocks(); Recorder.startError = null; + Recorder.stopError = false; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("MediaStream", Stream); vi.stubGlobal("MediaRecorder", Recorder); @@ -183,4 +200,43 @@ describe("comment recorder lifecycle", () => { ); expect(videoTrack.stop).toHaveBeenCalled(); }); + it.each([true, false])( + "releases video capture after an unexpected browser stop (error: %s)", + async (error) => { + const video = new Track("video"); + mocks.display.mockResolvedValue(new Stream([video])); + mocks.mic.mockRejectedValue(new Error("No microphone")); + await act(async () => recorder.startScreen()); + expect(recorder.phase).toBe("recording"); + const target = Recorder.last; + await act(async () => { + if (error) { + const event = new Event("error"); + Object.defineProperty(event, "target", { value: target }); + Object.defineProperty(event, "error", { + value: new Error("Encoder failed"), + }); + target.onerror?.(event); + } + target.state = "inactive"; + const event = new Event("stop"); + Object.defineProperty(event, "target", { value: target }); + target.onstop?.(event); + }); + expect(video.stop).toHaveBeenCalled(); + expect(recorder.phase).toBe("idle"); + expect(recorder.error).toContain("stopped unexpectedly"); + }, + ); + it("reports a video encoder error received while stopping", async () => { + const video = new Track("video"); + mocks.display.mockResolvedValue(new Stream([video])); + mocks.mic.mockRejectedValue(new Error("No microphone")); + await act(async () => recorder.startScreen()); + Recorder.stopError = true; + await act(async () => recorder.stop()); + expect(video.stop).toHaveBeenCalled(); + expect(recorder.phase).toBe("idle"); + expect(recorder.error).toContain("stopped unexpectedly"); + }); }); diff --git a/apps/web/__tests__/unit/voice-recorder-error-ui.test.ts b/apps/web/__tests__/unit/voice-recorder-error-ui.test.ts new file mode 100644 index 00000000000..7e1c4d77257 --- /dev/null +++ b/apps/web/__tests__/unit/voice-recorder-error-ui.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; +import { VoiceRecorderPanel } from "@/app/s/[videoId]/_components/timeline/recording/VoiceRecorderPanel"; + +const start = vi.hoisted(() => vi.fn()); +vi.mock("@/app/s/[videoId]/_components/timeline/recording/DevicePill", () => ({ + DevicePill: () => null, +})); +vi.mock( + "@/app/s/[videoId]/_components/timeline/recording/PauseResumeButton", + () => ({ PauseResumeButton: () => null }), +); +vi.mock( + "@/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder", + () => ({ + useVoiceRecorder: () => ({ + phase: "error", + durationMs: 0, + start, + stop: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + restart: vi.fn(), + switchMic: vi.fn(), + cancel: vi.fn(), + }), + }), +); +vi.mock( + "@/app/s/[videoId]/_components/timeline/recording/useCommentMediaDevices", + () => ({ + useCommentMediaDevices: () => ({ + mics: [], + selectedMicId: null, + adoptMic: vi.fn(), + refreshDevices: vi.fn(), + selectMic: vi.fn(), + }), + }), +); +it("explains an encoder failure and offers retry", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => + root.render( + createElement(VoiceRecorderPanel, { + timestamp: 0, + onFinish: vi.fn(), + onCancel: vi.fn(), + }), + ), + ); + expect(container.textContent).toContain( + "Recording stopped unexpectedly. Please try again.", + ); + const button = [...container.querySelectorAll("button")].find( + (item) => item.textContent === "Try again", + ); + expect(button).toBeDefined(); + await act(async () => button?.click()); + expect(start).toHaveBeenCalledTimes(1); + } finally { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/__tests__/unit/voice-recorder-errors.test.ts b/apps/web/__tests__/unit/voice-recorder-errors.test.ts new file mode 100644 index 00000000000..b732fe41aec --- /dev/null +++ b/apps/web/__tests__/unit/voice-recorder-errors.test.ts @@ -0,0 +1,173 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { useVoiceRecorder } from "@/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder"; + +const mocks = vi.hoisted(() => ({ + mic: vi.fn(), + mixer: vi.fn(), + decode: vi.fn(), +})); +vi.mock("@cap/recorder-core/capture-streams", () => ({ + acquireMicStream: mocks.mic, + createAudioMixer: mocks.mixer, +})); +vi.mock( + "@/app/s/[videoId]/_components/timeline/recording/compute-waveform-peaks", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/app/s/[videoId]/_components/timeline/recording/compute-waveform-peaks") + >()), + decodeToPeaks: mocks.decode, + }), +); +class Track { + readyState = "live"; + stop = vi.fn(() => { + this.readyState = "ended"; + }); + getSettings() { + return { deviceId: "microphone" }; + } +} +class Stream { + constructor(readonly track = new Track()) {} + getTracks() { + return [this.track]; + } + getAudioTracks() { + return [this.track]; + } +} +class Recorder { + static isTypeSupported = () => true; + static last: Recorder; + static stopError = false; + static startError: Error | null = null; + static pendingStops: Array<() => void> | null = null; + constructor() { + Recorder.last = this; + } + state: RecordingState = "inactive"; + mimeType = "audio/webm"; + ondataavailable: ((event: BlobEvent) => void) | null = null; + onstop: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + start() { + if (Recorder.startError) throw Recorder.startError; + this.state = "recording"; + } + stop() { + this.state = "inactive"; + const finish = () => { + if (Recorder.stopError) { + const error = new Event("error"); + Object.defineProperty(error, "target", { value: this }); + Object.defineProperty(error, "error", { + value: new Error("Encoder failed"), + }); + this.onerror?.(error); + } + const event = new Event("dataavailable"); + Object.defineProperty(event, "target", { value: this }); + Object.defineProperty(event, "data", { + value: new Blob(["audio"], { type: "audio/webm" }), + }); + this.ondataavailable?.(event as BlobEvent); + const stopped = new Event("stop"); + Object.defineProperty(stopped, "target", { value: this }); + this.onstop?.(stopped); + }; + if (Recorder.pendingStops) Recorder.pendingStops.push(finish); + else queueMicrotask(finish); + } +} +let root: Root; +let container: HTMLDivElement; +let mounted: boolean; +let recorder: ReturnType; +const onFinish = vi.fn(); +function mixer(stream = new Stream()) { + return { + context: {}, + stream, + close: vi.fn(async () => {}), + setMicStream: vi.fn(), + }; +} +beforeEach(async () => { + Recorder.startError = null; + Recorder.stopError = false; + Recorder.pendingStops = null; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("MediaRecorder", Recorder); + onFinish.mockReset(); + mocks.mic.mockReset().mockImplementation(async () => new Stream()); + mocks.mixer.mockReset().mockImplementation(async () => mixer()); + mocks.decode.mockReset().mockResolvedValue([0.25, 1]); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + mounted = true; + function Harness() { + recorder = useVoiceRecorder({ onFinish, selectedMicId: null }); + return null; + } + await act(async () => root.render(createElement(Harness))); +}); +afterEach(async () => { + if (mounted) await unmount(); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +async function unmount() { + await act(async () => root.unmount()); + mounted = false; +} + +function browserStops(error: boolean) { + const target = Recorder.last; + if (error) { + const event = new Event("error"); + Object.defineProperty(event, "target", { value: target }); + Object.defineProperty(event, "error", { + value: new Error("Encoder failed"), + }); + target.onerror?.(event); + } + target.state = "inactive"; + const stopped = new Event("stop"); + Object.defineProperty(stopped, "target", { value: target }); + target.onstop?.(stopped); +} + +it.each([true, false])( + "releases voice capture after an unexpected browser stop (error: %s)", + async (error) => { + const mic = new Stream(); + const activeMixer = mixer(); + mocks.mic.mockResolvedValueOnce(mic); + mocks.mixer.mockResolvedValueOnce(activeMixer); + await act(async () => recorder.start()); + expect(recorder.phase).toBe("recording"); + await act(async () => browserStops(error)); + expect(mic.track.stop).toHaveBeenCalled(); + expect(activeMixer.close).toHaveBeenCalled(); + expect(recorder.phase).toBe("error"); + expect(onFinish).not.toHaveBeenCalled(); + }, +); + +it("reports a voice encoder error received while stopping", async () => { + const mic = new Stream(); + mocks.mic.mockResolvedValueOnce(mic); + await act(async () => recorder.start()); + Recorder.stopError = true; + await act(async () => recorder.stop()); + expect(mic.track.stop).toHaveBeenCalled(); + expect(recorder.phase).toBe("error"); + expect(onFinish).not.toHaveBeenCalled(); +}); diff --git a/apps/web/app/s/[videoId]/_components/timeline/recording/VoiceRecorderPanel.tsx b/apps/web/app/s/[videoId]/_components/timeline/recording/VoiceRecorderPanel.tsx index 8ef284a21e7..a78bd37cd8c 100644 --- a/apps/web/app/s/[videoId]/_components/timeline/recording/VoiceRecorderPanel.tsx +++ b/apps/web/app/s/[videoId]/_components/timeline/recording/VoiceRecorderPanel.tsx @@ -121,13 +121,15 @@ export function VoiceRecorderPanel({

- {phase === "denied" - ? "Microphone is blocked. Enable it in your browser settings, then try again." - : phase === "requesting" - ? "Waiting for microphone access…" - : devices.mics.length > 0 - ? "Pick a microphone, then click Start to record your voice note." - : "Click Start and your browser will ask for microphone access."} + {phase === "error" + ? "Recording stopped unexpectedly. Please try again." + : phase === "denied" + ? "Microphone is blocked. Enable it in your browser settings, then try again." + : phase === "requesting" + ? "Waiting for microphone access…" + : devices.mics.length > 0 + ? "Pick a microphone, then click Start to record your voice note." + : "Click Start and your browser will ask for microphone access."}

)} @@ -208,7 +210,7 @@ export function VoiceRecorderPanel({ disabled={phase === "requesting"} className="h-7 rounded-lg bg-blue-9 px-3 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-40" > - {phase === "denied" ? "Try again" : "Start"} + {phase === "denied" || phase === "error" ? "Try again" : "Start"} )} diff --git a/apps/web/app/s/[videoId]/_components/timeline/recording/useCommentRecorder.ts b/apps/web/app/s/[videoId]/_components/timeline/recording/useCommentRecorder.ts index a22e2fb94af..dada5680767 100644 --- a/apps/web/app/s/[videoId]/_components/timeline/recording/useCommentRecorder.ts +++ b/apps/web/app/s/[videoId]/_components/timeline/recording/useCommentRecorder.ts @@ -115,6 +115,7 @@ export function useCommentRecorder(options: { const { mediaRecorderRef, + recorderError, setLocalRecordingStrategy, onRecorderDataAvailable, onRecorderStop, @@ -185,6 +186,14 @@ export function useCommentRecorder(options: { [cleanupStreams, clearTimer, resetRecorder, resetTimer, updatePhase], ); + useEffect(() => { + if (!recorderError || (phase !== "recording" && phase !== "paused")) return; + runIdRef.current += 1; + micRunRef.current += 1; + discardRef.current = true; + failRecording("Recording stopped unexpectedly. Please try again."); + }, [recorderError, phase, failRecording]); + const stop = useCallback(async () => { if (phaseRef.current !== "recording" && phaseRef.current !== "paused") return; @@ -200,14 +209,19 @@ export function useCommentRecorder(options: { updatePhase("processing"); let blob: Blob | null = null; + let failed = false; try { blob = await stopRecordingInternal(cleanupStreams, clearTimer); } catch (cause) { + failed = true; console.error("Failed to finalize comment recording", cause); } resetRecorder(); resetTimer(); const discarded = discardRef.current; + if (failed && !discarded) { + setError("Recording stopped unexpectedly. Please try again."); + } updatePhase("idle"); if (discarded || !blob || blob.size === 0) return; diff --git a/apps/web/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder.ts b/apps/web/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder.ts index d38ffaf7dd4..7fd51604669 100644 --- a/apps/web/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder.ts +++ b/apps/web/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder.ts @@ -16,6 +16,7 @@ export type VoiceRecorderPhase = | "idle" | "requesting" | "denied" + | "error" | "recording" | "paused" | "processing"; @@ -67,6 +68,7 @@ export function useVoiceRecorder(options: { const { mediaRecorderRef, + recorderError, setLocalRecordingStrategy, onRecorderDataAvailable, onRecorderStop, @@ -102,6 +104,26 @@ export function useVoiceRecorder(options: { if (mixer) void mixer.close(); }, []); + useEffect(() => { + if (!recorderError || (phase !== "recording" && phase !== "paused")) return; + runIdRef.current += 1; + micRunRef.current += 1; + discardRef.current = true; + cleanupStreams(); + clearTimer(); + resetRecorder(); + resetTimer(); + updatePhase("error"); + }, [ + recorderError, + phase, + cleanupStreams, + clearTimer, + resetRecorder, + resetTimer, + updatePhase, + ]); + /** Point the level meter at a (new) mic stream on the mixer's context. */ const attachMeter = useCallback( (mixer: AudioMixer, micStream: MediaStream) => { @@ -376,15 +398,21 @@ export function useVoiceRecorder(options: { updatePhase("processing"); let blob: Blob | null = null; + let failed = false; try { blob = await stopRecordingInternal(cleanupStreams, clearTimer); } catch (cause) { + failed = true; console.error("Failed to finalize voice note", cause); } const livePeaks = peaksRef.current?.finish(); peaksRef.current = null; resetRecorder(); resetTimer(); + if (failed && !discardRef.current) { + updatePhase("error"); + return; + } if (discardRef.current || !blob || blob.size === 0) { updatePhase("idle");