diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70bb14815be..681c8216a95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/web/__tests__/unit/recorder-device-enumeration.test.ts b/apps/web/__tests__/unit/recorder-device-enumeration.test.ts new file mode 100644 index 00000000000..d109ca89543 --- /dev/null +++ b/apps/web/__tests__/unit/recorder-device-enumeration.test.ts @@ -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() { + let resolve!: (value: T) => void; + const promise = new Promise((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; + let media: EventTarget & { enumerateDevices: ReturnType }; + 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(); + const second = deferred(); + 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(); + const afterPermission = deferred(); + media.enumerateDevices + .mockReturnValueOnce(beforePermission.promise) + .mockReturnValueOnce(afterPermission.promise); + await act(async () => root.render(createElement(Harness, { open: true }))); + let refreshed!: Promise; + 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(); + 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); + }); +}); diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useCameraDevices.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useCameraDevices.ts index 82b4aebd187..a8f444b3c05 100644 --- a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useCameraDevices.ts +++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useCameraDevices.ts @@ -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) => { @@ -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) { @@ -49,6 +52,7 @@ export const useCameraDevices = (open: boolean) => { ); return () => { + requestRef.current += 1; navigator.mediaDevices?.removeEventListener( "devicechange", handleDeviceChange, diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMicrophoneDevices.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMicrophoneDevices.ts index c841558c7a8..90396828d28 100644 --- a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMicrophoneDevices.ts +++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMicrophoneDevices.ts @@ -5,17 +5,20 @@ import { useCallback, useEffect, useRef, useState } from "react"; export const useMicrophoneDevices = (open: boolean) => { const [availableMics, setAvailableMics] = useState([]); 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) => { @@ -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) { @@ -47,6 +50,7 @@ export const useMicrophoneDevices = (open: boolean) => { ); return () => { + requestRef.current += 1; navigator.mediaDevices?.removeEventListener( "devicechange", handleDeviceChange,