Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 57 additions & 1 deletion apps/web/__tests__/unit/comment-recorder-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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);
});
}
}

Expand All @@ -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);
Expand Down Expand Up @@ -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");
});
});
72 changes: 72 additions & 0 deletions apps/web/__tests__/unit/voice-recorder-error-ui.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
173 changes: 173 additions & 0 deletions apps/web/__tests__/unit/voice-recorder-errors.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useVoiceRecorder>;
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();
});
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,15 @@ export function VoiceRecorderPanel({
</svg>
</span>
<p className="text-xs leading-relaxed text-gray-11">
{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."}
</p>
</div>
)}
Expand Down Expand Up @@ -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"}
</button>
</>
)}
Expand Down
Loading
Loading