diff --git a/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.test.tsx index ae680a9d6e..95c1862d70 100644 --- a/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/[debateId]/debate-room-page-client.test.tsx @@ -508,6 +508,43 @@ describe('upcomingTurnLabel', () => { }); }); +/** + * Open a device settings menu once the intro connection can no longer close it. + * + * The intro screen opens its own LiveKit connection, and `devicesLocked` — `ready_at`, or + * `roomState` being 'connecting'/'reconnecting' — force-closes any open settings menu + * (`debate-pre-join-screen.tsx`, "an open popover or sheet keeps its radios clickable"). That + * behaviour is deliberate and has its own test. The problem is the race around it: a menu opened + * while the intro connection is still in flight gets shut underneath the test, and every query for + * a device radio then fails with `Unable to find role="radio"`. + * + * It reproduces at about one run in six under CPU contention, and deterministically by holding + * `roomConnect` pending across the interaction — which is why it surfaced on CI, on this branch + * and on master alike, while passing every time on an idle machine. An earlier attempt read it as + * a re-render between two clicks and switched to `findByRole`; waiting longer cannot help, because + * the menu is closed and stays closed. + * + * So wait for the connection to have been attempted *and* for the trigger to be enabled, which is + * exactly `devicesLocked === false`. + */ +/** As {@link openDeviceSettings}, but focused first — for the tests that assert focus return. */ +async function openDeviceSettingsFocused(name: 'Audio settings' | 'Video settings') { + await waitFor(() => expect(mocks.roomConnect).toHaveBeenCalled()); + const trigger = await screen.findByRole('button', { name }); + await waitFor(() => expect(trigger).toBeEnabled()); + trigger.focus(); + fireEvent.click(trigger); + return trigger; +} + +async function openDeviceSettings(name: 'Audio settings' | 'Video settings') { + await waitFor(() => expect(mocks.roomConnect).toHaveBeenCalled()); + const trigger = await screen.findByRole('button', { name }); + await waitFor(() => expect(trigger).toBeEnabled()); + fireEvent.click(trigger); + return trigger; +} + describe('DebateRoomPageClient', () => { // GEO-2599. The debate-again picker's All tab waits on the claim-space allowlist, which walks the // Root space's topic tree — about thirteen sequential round trips cold, and the tab stays empty @@ -796,7 +833,7 @@ describe('DebateRoomPageClient', () => { mocks.debate = readyDebate({ localReady: false, remoteReady: false }); render(); - fireEvent.click(await screen.findByRole('button', { name: 'Audio settings' })); + await openDeviceSettings('Audio settings'); await waitFor(() => expect(mocks.roomConnect).toHaveBeenCalled()); fireEvent.click(screen.getByRole('radio', { name: 'Studio Speakers' })); @@ -828,7 +865,7 @@ describe('DebateRoomPageClient', () => { mocks.debate = readyDebate({ localReady: false, remoteReady: false }); render(); - fireEvent.click(await screen.findByRole('button', { name: 'Video settings' })); + await openDeviceSettings('Video settings'); await waitFor(() => expect(mocks.roomConnect).toHaveBeenCalledTimes(1)); fireEvent.click(screen.getByRole('radio', { name: 'Desk Camera' })); @@ -1090,7 +1127,7 @@ describe('DebateRoomPageClient', () => { const audioTrigger = await screen.findByRole('button', { name: 'Audio settings' }); expect(audioTrigger).toHaveAttribute('data-state', 'closed'); - fireEvent.click(audioTrigger); + await openDeviceSettings('Audio settings'); const audioSettings = screen.getByRole('dialog', { name: 'Audio settings' }); expect(audioSettings).toHaveAttribute('data-side', 'top'); expect(audioSettings.closest('[data-radix-popper-content-wrapper]')?.parentElement).toHaveClass('elevated-popover'); @@ -1180,7 +1217,7 @@ describe('DebateRoomPageClient', () => { mocks.selectAudioOutput.mockReturnValueOnce(selectedOutput.promise); render(); - fireEvent.click(await screen.findByRole('button', { name: 'Audio settings' })); + await openDeviceSettings('Audio settings'); await waitFor(() => expect(mocks.createLocalTracks).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mocks.roomConnect).toHaveBeenCalled()); @@ -1225,7 +1262,7 @@ describe('DebateRoomPageClient', () => { mocks.supportsAudioOutputSelection.mockReturnValue(false); render(); - fireEvent.click(await screen.findByRole('button', { name: 'Audio settings' })); + await openDeviceSettings('Audio settings'); expect(await screen.findByRole('radio', { name: 'System default' })).toBeChecked(); expect(screen.getByRole('radio', { name: 'System default' })).toBeDisabled(); @@ -1238,7 +1275,7 @@ describe('DebateRoomPageClient', () => { mocks.selectAudioOutput.mockRejectedValueOnce(Object.assign(new Error('Not allowed'), { name: 'NotAllowedError' })); render(); - fireEvent.click(await screen.findByRole('button', { name: 'Audio settings' })); + await openDeviceSettings('Audio settings'); await waitFor(() => expect(mocks.createLocalTracks).toHaveBeenCalledTimes(1)); fireEvent.click(screen.getByRole('radio', { name: 'Studio Speakers' })); @@ -1269,7 +1306,7 @@ describe('DebateRoomPageClient', () => { ); render(); - fireEvent.click(await screen.findByRole('button', { name: 'Audio settings' })); + await openDeviceSettings('Audio settings'); // Awaited, not `getByRole`: the speaker list is populated from `enumerateDevices`, and // selecting a speaker re-renders it while that selection is still pending — so a name can be // briefly absent between two clicks. A synchronous query here made this test fail about one run @@ -1298,9 +1335,7 @@ describe('DebateRoomPageClient', () => { mocks.debate = readyDebate({ localReady: false, remoteReady: false }); render(); - const trigger = await screen.findByRole('button', { name: 'Audio settings' }); - trigger.focus(); - fireEvent.click(trigger); + const trigger = await openDeviceSettingsFocused('Audio settings'); const settings = screen.getByRole('dialog', { name: 'Audio settings' }); fireEvent.keyDown(settings, { key: 'Escape' }); @@ -1313,8 +1348,7 @@ describe('DebateRoomPageClient', () => { mocks.debate = readyDebate({ localReady: false, remoteReady: false }); render(); - const trigger = await screen.findByRole('button', { name: 'Audio settings' }); - fireEvent.click(trigger); + const trigger = await openDeviceSettings('Audio settings'); expect(screen.getByRole('dialog', { name: 'Audio settings' })).toBeInTheDocument(); await act(() => new Promise(resolve => window.setTimeout(resolve, 0))); @@ -1329,8 +1363,7 @@ describe('DebateRoomPageClient', () => { mocks.debate = readyDebate({ localReady: false, remoteReady: false }); render(); - const trigger = await screen.findByRole('button', { name: 'Audio settings' }); - fireEvent.click(trigger); + const trigger = await openDeviceSettings('Audio settings'); expect(screen.getByRole('dialog', { name: 'Audio settings' })).toBeInTheDocument(); fireEvent.click(trigger); @@ -1342,7 +1375,7 @@ describe('DebateRoomPageClient', () => { mocks.debate = readyDebate({ localReady: false, remoteReady: false }); render(); - fireEvent.click(await screen.findByRole('button', { name: 'Video settings' })); + await openDeviceSettings('Video settings'); const selectedCamera = screen.getByRole('radio', { name: 'HD Pro Webcam' }); selectedCamera.focus(); @@ -1361,7 +1394,7 @@ describe('DebateRoomPageClient', () => { mocks.debate = readyDebate({ localReady: false, remoteReady: false }); render(); - fireEvent.click(await screen.findByRole('button', { name: 'Audio settings' })); + await openDeviceSettings('Audio settings'); const pendingTracks = deferred< Array | { mediaStreamTrack: { kind: string }; stop: () => void }> @@ -1383,7 +1416,7 @@ describe('DebateRoomPageClient', () => { setMobileLayout(true); render(); - fireEvent.click(await screen.findByRole('button', { name: 'Video settings' })); + await openDeviceSettings('Video settings'); expect(screen.getByRole('dialog', { name: 'Video settings' })).toHaveAttribute('data-layout', 'bottom-sheet'); const videos = document.querySelectorAll('video'); @@ -1400,7 +1433,7 @@ describe('DebateRoomPageClient', () => { expect(await screen.findByText('Speak to test your mic')).toBeInTheDocument(); expect(screen.getByRole('meter')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Audio settings' }).parentElement).toHaveClass('gap-[6px]'); - fireEvent.click(await screen.findByRole('button', { name: 'Audio settings' })); + await openDeviceSettings('Audio settings'); expect(screen.getByRole('dialog', { name: 'Audio settings' })).toHaveAttribute('data-layout', 'bottom-sheet'); expect(screen.getByText('Select a microphone')).toBeInTheDocument(); @@ -1412,8 +1445,7 @@ describe('DebateRoomPageClient', () => { setMobileLayout(true); render(); - const trigger = await screen.findByRole('button', { name: 'Audio settings' }); - fireEvent.click(trigger); + const trigger = await openDeviceSettings('Audio settings'); expect(screen.getByRole('dialog', { name: 'Audio settings' })).toHaveAttribute('data-layout', 'bottom-sheet'); fireEvent.click(screen.getByRole('button', { name: 'Close Audio settings' })); @@ -1426,7 +1458,7 @@ describe('DebateRoomPageClient', () => { mocks.debate = readyDebate({ localReady: false, remoteReady: false }); render(); - fireEvent.click(await screen.findByRole('button', { name: 'Audio settings' })); + await openDeviceSettings('Audio settings'); fireEvent.click(await screen.findByRole('radio', { name: 'Studio Mic' })); await waitFor(() => expect(mocks.createLocalTracks).toHaveBeenCalledWith({ @@ -1685,7 +1717,7 @@ describe('DebateRoomPageClient', () => { mocks.debate = readyDebate({ localReady: false, remoteReady: false }); render(); - fireEvent.click(await screen.findByRole('button', { name: 'Video settings' })); + await openDeviceSettings('Video settings'); await waitFor(() => expect(mocks.roomConnect).toHaveBeenCalledTimes(1)); fireEvent.click(screen.getByRole('radio', { name: 'Desk Camera' })); @@ -1804,7 +1836,7 @@ describe('DebateRoomPageClient', () => { const view = render(); await waitFor(() => expect(mocks.roomConnect).toHaveBeenCalled()); - fireEvent.click(await screen.findByRole('button', { name: 'Video settings' })); + await openDeviceSettings('Video settings'); expect(screen.getByRole('radio', { name: 'Desk Camera' })).toBeInTheDocument(); mocks.debate = {