fix(desktop): stop stale microphone test streams (#11309)

This commit is contained in:
Jasper
2026-08-19 15:31:55 +00:00
committed by GitHub
parent 01695e8bd9
commit 89fb81e4e0
2 changed files with 151 additions and 3 deletions
@@ -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>