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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ jobs:
__tests__/unit/invite-decline.test.ts
__tests__/unit/linkified-text.test.ts
__tests__/unit/loom-import-ui.test.ts
__tests__/unit/recorder-device-enumeration.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
102 changes: 102 additions & 0 deletions apps/web/__tests__/unit/recorder-device-enumeration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCameraDevices } from "@/app/(org)/dashboard/caps/components/web-recorder-dialog/useCameraDevices";
import { useMicrophoneDevices } from "@/app/(org)/dashboard/caps/components/web-recorder-dialog/useMicrophoneDevices";

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
const device = (kind: MediaDeviceKind, deviceId: string) => ({
kind,
deviceId,
groupId: "",
label: deviceId,
toJSON: () => ({}),
});
const cases = [
{ name: "camera", kind: "videoinput" as const, useDevices: useCameraDevices },
{
name: "microphone",
kind: "audioinput" as const,
useDevices: useMicrophoneDevices,
},
];
describe.each(cases)("$name enumeration", ({ kind, useDevices }) => {
let root: Root;
let container: HTMLDivElement;
let devices: ReturnType<typeof useCameraDevices>;
let media: EventTarget & { enumerateDevices: ReturnType<typeof vi.fn> };
function Harness({ open }: { open: boolean }) {
devices = useDevices(open);
return null;
}
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
media = Object.assign(new EventTarget(), { enumerateDevices: vi.fn() });
vi.stubGlobal("navigator", { mediaDevices: media });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it("keeps the most recent device-change result when an older query resolves last", async () => {
const first = deferred<MediaDeviceInfo[]>();
const second = deferred<MediaDeviceInfo[]>();
media.enumerateDevices
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
await act(async () => root.render(createElement(Harness, { open: true })));
await act(async () => media.dispatchEvent(new Event("devicechange")));
await act(async () => second.resolve([device(kind, "new")]));
await act(async () => first.resolve([device(kind, "old")]));
expect(devices.devices.map((item) => item.deviceId)).toEqual(["new"]);
});
it("keeps the refreshed list when a pre-permission query resolves afterward", async () => {
const beforePermission = deferred<MediaDeviceInfo[]>();
const afterPermission = deferred<MediaDeviceInfo[]>();
media.enumerateDevices
.mockReturnValueOnce(beforePermission.promise)
.mockReturnValueOnce(afterPermission.promise);
await act(async () => root.render(createElement(Harness, { open: true })));
let refreshed!: Promise<void>;
await act(async () => {
refreshed = devices.refresh();
});
await act(async () => {
afterPermission.resolve([device(kind, "granted")]);
await refreshed;
});
await act(async () => beforePermission.resolve([device(kind, "")]));
expect(devices.devices.map((item) => item.deviceId)).toEqual(["granted"]);
});
it("does not publish a pending enumeration after the dialog closes", async () => {
const pending = deferred<MediaDeviceInfo[]>();
media.enumerateDevices.mockReturnValueOnce(pending.promise);
await act(async () => root.render(createElement(Harness, { open: true })));
await act(async () => root.render(createElement(Harness, { open: false })));
await act(async () => pending.resolve([device(kind, "old")]));
expect(devices.devices).toEqual([]);
});
it("filters placeholders and other device kinds and removes its listener on close", async () => {
media.enumerateDevices.mockResolvedValue([
device(kind, "device"),
device(kind, " "),
device(kind === "audioinput" ? "videoinput" : "audioinput", "other"),
]);
await act(async () => root.render(createElement(Harness, { open: true })));
expect(devices.devices.map((item) => item.deviceId)).toEqual(["device"]);
await act(async () => root.render(createElement(Harness, { open: false })));
await act(async () => media.dispatchEvent(new Event("devicechange")));
expect(media.enumerateDevices).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ export const useCameraDevices = (open: boolean) => {
[],
);
const isMountedRef = useRef(false);
const requestRef = useRef(0);

useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
requestRef.current += 1;
};
}, []);

const enumerateDevices = useCallback(async () => {
if (typeof navigator === "undefined" || !navigator.mediaDevices) return;

const requestId = ++requestRef.current;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = devices.filter((device) => {
Expand All @@ -26,7 +29,7 @@ export const useCameraDevices = (open: boolean) => {
}
return device.deviceId.trim().length > 0;
});
if (isMountedRef.current) {
if (isMountedRef.current && requestRef.current === requestId) {
setAvailableCameras(videoInputs);
}
} catch (err) {
Expand All @@ -49,6 +52,7 @@ export const useCameraDevices = (open: boolean) => {
);

return () => {
requestRef.current += 1;
navigator.mediaDevices?.removeEventListener(
"devicechange",
handleDeviceChange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ import { useCallback, useEffect, useRef, useState } from "react";
export const useMicrophoneDevices = (open: boolean) => {
const [availableMics, setAvailableMics] = useState<MediaDeviceInfo[]>([]);
const isMountedRef = useRef(false);
const requestRef = useRef(0);

useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
requestRef.current += 1;
};
}, []);

const enumerateDevices = useCallback(async () => {
if (typeof navigator === "undefined" || !navigator.mediaDevices) return;

const requestId = ++requestRef.current;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter((device) => {
Expand All @@ -24,7 +27,7 @@ export const useMicrophoneDevices = (open: boolean) => {
}
return device.deviceId.trim().length > 0;
});
if (isMountedRef.current) {
if (isMountedRef.current && requestRef.current === requestId) {
setAvailableMics(audioInputs);
}
} catch (err) {
Expand All @@ -47,6 +50,7 @@ export const useMicrophoneDevices = (open: boolean) => {
);

return () => {
requestRef.current += 1;
navigator.mediaDevices?.removeEventListener(
"devicechange",
handleDeviceChange,
Expand Down
Loading