feat(web): add useAudioDevices hook for permission-aware audio enumeration

This commit is contained in:
Jannis Braun
2026-05-03 00:51:20 +02:00
parent c0e71b1ded
commit a1ebb23264
2 changed files with 300 additions and 0 deletions
@@ -0,0 +1,129 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useAudioDevices } from './useAudioDevices';
type Listener = () => void;
function setupMediaDevicesMock(opts: {
permissionState?: 'granted' | 'prompt' | 'denied';
devices?: MediaDeviceInfo[];
permissionThrows?: boolean;
} = {}) {
const listeners = new Set<Listener>();
const permState = opts.permissionState ?? 'granted';
const devices = opts.devices ?? [
{ deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in Mic', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
{ deviceId: 'mic-2', kind: 'audioinput', label: 'USB Headset', groupId: 'g2', toJSON: () => ({}) } as MediaDeviceInfo,
{ deviceId: 'spk-1', kind: 'audiooutput', label: 'Built-in Speakers', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
{ deviceId: 'spk-2', kind: 'audiooutput', label: 'USB Headset', groupId: 'g2', toJSON: () => ({}) } as MediaDeviceInfo,
];
const mediaDevices = {
enumerateDevices: vi.fn().mockResolvedValue(devices),
addEventListener: (_evt: string, l: Listener) => { listeners.add(l); },
removeEventListener: (_evt: string, l: Listener) => { listeners.delete(l); },
getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }] }),
};
Object.defineProperty(navigator, 'mediaDevices', { value: mediaDevices, configurable: true });
const permStatus = {
state: permState,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
const permissions = {
query: opts.permissionThrows
? vi.fn().mockRejectedValue(new Error('not supported'))
: vi.fn().mockResolvedValue(permStatus),
};
Object.defineProperty(navigator, 'permissions', { value: permissions, configurable: true });
return { listeners, mediaDevices, permissions, permStatus, fireDeviceChange: () => listeners.forEach(l => l()) };
}
describe('useAudioDevices', () => {
beforeEach(() => { vi.restoreAllMocks(); });
it('starts in unknown state, transitions to granted, enumerates inputs and outputs', async () => {
const m = setupMediaDevicesMock({ permissionState: 'granted' });
const { result } = renderHook(() => useAudioDevices());
expect(result.current.permState).toBe('unknown');
await waitFor(() => expect(result.current.permState).toBe('granted'));
await waitFor(() => expect(result.current.inputs.length).toBe(2));
expect(result.current.outputs.length).toBe(2);
expect(result.current.inputs[0].deviceId).toBe('mic-1');
});
it('returns prompt state when permission is prompt', async () => {
setupMediaDevicesMock({ permissionState: 'prompt' });
const { result } = renderHook(() => useAudioDevices());
await waitFor(() => expect(result.current.permState).toBe('prompt'));
expect(result.current.inputs).toEqual([]);
expect(result.current.outputs).toEqual([]);
});
it('returns denied state when permission is denied', async () => {
setupMediaDevicesMock({ permissionState: 'denied' });
const { result } = renderHook(() => useAudioDevices());
await waitFor(() => expect(result.current.permState).toBe('denied'));
});
it('falls back to prompt when permissions.query throws', async () => {
setupMediaDevicesMock({ permissionThrows: true });
const { result } = renderHook(() => useAudioDevices());
await waitFor(() => expect(result.current.permState).toBe('prompt'));
});
it('refreshes lists on devicechange', async () => {
const m = setupMediaDevicesMock({ permissionState: 'granted' });
const { result } = renderHook(() => useAudioDevices());
await waitFor(() => expect(result.current.inputs.length).toBe(2));
m.mediaDevices.enumerateDevices.mockResolvedValueOnce([
{ deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in Mic', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
]);
act(() => { m.fireDeviceChange(); });
await waitFor(() => expect(result.current.inputs.length).toBe(1));
});
it('deduplicates devices by deviceId', async () => {
setupMediaDevicesMock({
permissionState: 'granted',
devices: [
{ deviceId: 'mic-1', kind: 'audioinput', label: 'A', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
{ deviceId: 'mic-1', kind: 'audioinput', label: 'A', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
{ deviceId: 'spk-1', kind: 'audiooutput', label: 'B', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
],
});
const { result } = renderHook(() => useAudioDevices());
await waitFor(() => expect(result.current.permState).toBe('granted'));
expect(result.current.inputs.length).toBe(1);
expect(result.current.outputs.length).toBe(1);
});
it('requestPermission fires getUserMedia({audio:true}) and stops the stream', async () => {
const m = setupMediaDevicesMock({ permissionState: 'prompt' });
const { result } = renderHook(() => useAudioDevices());
await waitFor(() => expect(result.current.permState).toBe('prompt'));
await act(async () => { await result.current.requestPermission(); });
expect(m.mediaDevices.getUserMedia).toHaveBeenCalledWith({ audio: true });
});
it('builds display labels with disambiguation suffix for duplicate names', async () => {
setupMediaDevicesMock({
permissionState: 'granted',
devices: [
{ deviceId: 'mic-1', kind: 'audioinput', label: 'USB Audio', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
{ deviceId: 'mic-2', kind: 'audioinput', label: 'USB Audio', groupId: 'g2', toJSON: () => ({}) } as MediaDeviceInfo,
{ deviceId: 'mic-3', kind: 'audioinput', label: '', groupId: 'g3', toJSON: () => ({}) } as MediaDeviceInfo,
],
});
const { result } = renderHook(() => useAudioDevices());
await waitFor(() => expect(result.current.inputs.length).toBe(3));
expect(result.current.inputLabels.get('mic-1')).toBe('USB Audio (1)');
expect(result.current.inputLabels.get('mic-2')).toBe('USB Audio (2)');
expect(result.current.inputLabels.get('mic-3')).toBe('Microphone 3');
});
});