fix(desktop): stop stale microphone test streams (#11309)
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { IntlTestWrapper } from '../../../i18n/test-utils';
|
||||
import { MicrophoneSelector } from './MicrophoneSelector';
|
||||
|
||||
type FakeStream = MediaStream & {
|
||||
track: { stop: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
type PendingStream = {
|
||||
stream: FakeStream;
|
||||
promise: Promise<MediaStream>;
|
||||
resolve: () => void;
|
||||
};
|
||||
|
||||
class MockAudioContext {
|
||||
createMediaStreamSource = vi.fn(() => ({ connect: vi.fn() }));
|
||||
createAnalyser = vi.fn(() => ({
|
||||
fftSize: 0,
|
||||
frequencyBinCount: 128,
|
||||
getByteTimeDomainData: vi.fn(),
|
||||
}));
|
||||
close = vi.fn(() => Promise.resolve());
|
||||
}
|
||||
|
||||
const createPendingStream = (): PendingStream => {
|
||||
const track = { stop: vi.fn() };
|
||||
const stream = {
|
||||
track,
|
||||
getTracks: () => [track],
|
||||
} as unknown as FakeStream;
|
||||
let resolvePromise: (stream: MediaStream) => void = () => {};
|
||||
const promise = new Promise<MediaStream>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
return {
|
||||
stream,
|
||||
promise,
|
||||
resolve: () => resolvePromise(stream),
|
||||
};
|
||||
};
|
||||
|
||||
const renderSelector = async () => {
|
||||
const result = render(<MicrophoneSelector selectedDeviceId={null} onDeviceChange={vi.fn()} />, {
|
||||
wrapper: IntlTestWrapper,
|
||||
});
|
||||
await screen.findByRole('button', { name: 'Test' });
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('MicrophoneSelector microphone lifecycle', () => {
|
||||
let pendingStreams: PendingStream[];
|
||||
|
||||
beforeEach(() => {
|
||||
pendingStreams = [];
|
||||
vi.stubGlobal('AudioContext', MockAudioContext);
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 1)
|
||||
);
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {
|
||||
enumerateDevices: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ kind: 'audioinput', deviceId: 'mic1', label: 'Mic One', groupId: 'group1' },
|
||||
]),
|
||||
getUserMedia: vi.fn(() => {
|
||||
const pendingStream = createPendingStream();
|
||||
pendingStreams.push(pendingStream);
|
||||
return pendingStream.promise;
|
||||
}),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('stops every stream acquired by overlapping test activations', async () => {
|
||||
await renderSelector();
|
||||
const button = screen.getByRole('button', { name: 'Test' });
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
await act(async () => {
|
||||
pendingStreams.forEach(({ resolve }) => resolve());
|
||||
await Promise.all(pendingStreams.map(({ promise }) => promise));
|
||||
});
|
||||
|
||||
const stopButton = screen.queryByRole('button', { name: 'Stop' });
|
||||
if (stopButton) fireEvent.click(stopButton);
|
||||
|
||||
expect(pendingStreams.length).toBeGreaterThan(0);
|
||||
pendingStreams.forEach(({ stream }) => expect(stream.track.stop).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it('stops a stream that arrives after the user stops the test', async () => {
|
||||
await renderSelector();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop' }));
|
||||
|
||||
await act(async () => {
|
||||
pendingStreams[0].resolve();
|
||||
await pendingStreams[0].promise;
|
||||
});
|
||||
|
||||
expect(pendingStreams[0].stream.track.stop).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole('button', { name: 'Test' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stops a stream that arrives after unmount', async () => {
|
||||
const { unmount } = await renderSelector();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
pendingStreams[0].resolve();
|
||||
await pendingStreams[0].promise;
|
||||
});
|
||||
|
||||
expect(pendingStreams[0].stream.track.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,7 @@ export const MicrophoneSelector = ({
|
||||
const testCtxRef = useRef<AudioContext | null>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
const testTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const testGenerationRef = useRef(0);
|
||||
|
||||
const enumerate = useCallback(async () => {
|
||||
try {
|
||||
@@ -103,6 +104,7 @@ export const MicrophoneSelector = ({
|
||||
};
|
||||
|
||||
const stopTest = useCallback(() => {
|
||||
testGenerationRef.current += 1;
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = 0;
|
||||
if (testTimerRef.current) clearTimeout(testTimerRef.current);
|
||||
@@ -117,6 +119,9 @@ export const MicrophoneSelector = ({
|
||||
|
||||
const startTest = async () => {
|
||||
stopTest();
|
||||
const generation = testGenerationRef.current;
|
||||
setIsTesting(true);
|
||||
|
||||
try {
|
||||
const constraints: MediaTrackConstraints = {
|
||||
echoCancellation: true,
|
||||
@@ -128,6 +133,10 @@ export const MicrophoneSelector = ({
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: constraints });
|
||||
if (testGenerationRef.current !== generation) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
testStreamRef.current = stream;
|
||||
|
||||
const ctx = new AudioContext();
|
||||
@@ -140,6 +149,8 @@ export const MicrophoneSelector = ({
|
||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
|
||||
const poll = () => {
|
||||
if (testGenerationRef.current !== generation) return;
|
||||
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
@@ -151,10 +162,13 @@ export const MicrophoneSelector = ({
|
||||
rafRef.current = requestAnimationFrame(poll);
|
||||
};
|
||||
|
||||
setIsTesting(true);
|
||||
rafRef.current = requestAnimationFrame(poll);
|
||||
testTimerRef.current = setTimeout(stopTest, TEST_DURATION_MS);
|
||||
testTimerRef.current = setTimeout(() => {
|
||||
if (testGenerationRef.current === generation) stopTest();
|
||||
}, TEST_DURATION_MS);
|
||||
} catch (e) {
|
||||
if (testGenerationRef.current !== generation) return;
|
||||
|
||||
console.error('Mic test failed:', e);
|
||||
stopTest();
|
||||
}
|
||||
@@ -211,7 +225,9 @@ export const MicrophoneSelector = ({
|
||||
value={selectedDeviceId ?? 'system_default'}
|
||||
onValueChange={(v) => onDeviceChange(v === 'system_default' ? null : v)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="system_default">{intl.formatMessage(i18n.systemDefault)}</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system_default">
|
||||
{intl.formatMessage(i18n.systemDefault)}
|
||||
</DropdownMenuRadioItem>
|
||||
{devices.map((device, i) => (
|
||||
<DropdownMenuRadioItem key={device.deviceId} value={device.deviceId}>
|
||||
<span className="truncate">{getDeviceLabel(device, i)}</span>
|
||||
|
||||
Reference in New Issue
Block a user