From 4f8f71e861dc041fc6d7bf8d392d96b3073077ca Mon Sep 17 00:00:00 2001 From: fkwp Date: Thu, 17 Sep 2026 11:52:11 +0200 Subject: [PATCH] Cover the device menu with stories and end-to-end specs - The stories supply what the app supplies rather than what a harness has lying about: a microphone, and a call-sized root element. - Invented device ids meant the menu asked for hardware that does not exist, so the meter reported no microphone. A tone played into a real MediaStream fixes it, and the meter runs its own analyser over it. - Without a root element the list was bounded by Storybook's whole frame and the menu ran off the canvas; without a call-sized one, four devices scrolled. - Standalone specs: the switch that does not drop the call, the meter alive while muted, the pinned meter, the focus ring. - Component specs: the list sized to the call and not the window, resized while open, every device reachable, and the modality belonging to one call. - A headless browser has one microphone and one speaker, so fake-devices.ts adds synthetic ones. It is explicit that it does not route audio. --- playwright/audio-menu.spec.ts | 253 ++++++++++++ playwright/component/audio-menu.spec.ts | 165 ++++++++ playwright/utils/fake-devices.ts | 98 +++++ .../MediaMuteAndSwitchButton.stories.tsx | 369 +++++++++++++++--- 4 files changed, 833 insertions(+), 52 deletions(-) create mode 100644 playwright/audio-menu.spec.ts create mode 100644 playwright/component/audio-menu.spec.ts create mode 100644 playwright/utils/fake-devices.ts diff --git a/playwright/audio-menu.spec.ts b/playwright/audio-menu.spec.ts new file mode 100644 index 000000000..c72673255 --- /dev/null +++ b/playwright/audio-menu.spec.ts @@ -0,0 +1,253 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { SpaHelpers } from "./spa-helpers.ts"; +import { installFakeDevices } from "./utils/fake-devices.ts"; + +test.describe("the quick audio menu", () => { + test("lists speakers and microphones with a live level meter", async ({ + page, + }) => { + await installFakeDevices(page, { microphones: 3, speakers: 3 }); + await joinACall(page, "Menu user", "Audio menu"); + await openAudioMenu(page); + + // The speaker list is what the settings modal used to be the only home of. + await expect(page.getByRole("group", { name: "Speaker" })).toBeVisible(); + await expect(page.getByRole("group", { name: "Microphone" })).toBeVisible(); + // Named rather than counted: the browser contributes its own fake output + // and a "Default" entry, so a total would be a fact about the browser. + for (const n of [1, 2, 3]) + await expect( + page + .getByRole("group", { name: "Speaker" }) + .getByRole("menuitemradio", { name: `Fake Speaker ${n}` }), + ).toBeVisible(); + + // Only one entry of a kind is marked, and the meter reports a number + // rather than a colour. + await expect( + page.getByRole("menuitemradio", { checked: true }), + ).toHaveCount(2); + const meter = page.getByRole("meter", { name: "Microphone level" }); + await expect(meter).toBeVisible(); + await expect(meter).toHaveAttribute("aria-valuenow", /\d+/); + await expect(meter).toHaveAttribute("aria-valuetext", /\d+ of \d+/); + + // Both browsers in the matrix can route audio to a chosen output, so the + // section offers a real choice. The case where a platform cannot — Safari, + // and anything without setSinkId — is covered by a unit check, since no + // browser here can reach it. + await expect( + page + .getByRole("group", { name: "Speaker" }) + .getByRole("menuitemradio") + .first(), + ).toHaveAttribute("aria-disabled", "false"); + }); + + test("moves the microphone and the speaker without disturbing the call", async ({ + browser, + }) => { + // Two browsers, two joins and a real call between them. + test.slow(); + const hostContext = await browser.newContext({ reducedMotion: "reduce" }); + const host = await hostContext.newPage(); + await installFakeDevices(host, { microphones: 3, speakers: 3 }); + await joinACall(host, "Host", "Device switch"); + + const inviteLink = await SpaHelpers.getCallInviteLink(host); + const guestContext = await browser.newContext({ reducedMotion: "reduce" }); + const guest = await guestContext.newPage(); + await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Guest"); + await SpaHelpers.expectVideoTilesCount(guest, 2); + + await openAudioMenu(host); + await selectDevice(host, "Microphone", "Fake Microphone 2"); + await openAudioMenu(host); + await selectDevice(host, "Speaker", "Fake Speaker 2"); + + // The point of the criterion: the switch is not a rejoin. Neither side + // sees the call drop, and the guest still has both tiles — so the host + // never left and came back. + await expect( + host.getByRole("dialog", { name: "Reconnecting…" }), + ).not.toBeVisible(); + await expect( + guest.getByRole("dialog", { name: "Reconnecting…" }), + ).not.toBeVisible(); + await SpaHelpers.expectVideoTilesCount(guest, 2); + await expect(guest.getByText("Waiting for media...")).not.toBeVisible(); + + await hostContext.close(); + await guestContext.close(); + }); + + test("keeps the meter moving while muted, and sends nothing", async ({ + browser, + }) => { + // Two browsers, two joins and a real call between them. + test.slow(); + const hostContext = await browser.newContext({ reducedMotion: "reduce" }); + const host = await hostContext.newPage(); + await installFakeDevices(host); + await joinACall(host, "Muted host", "Muted meter"); + + const inviteLink = await SpaHelpers.getCallInviteLink(host); + const guestContext = await browser.newContext({ reducedMotion: "reduce" }); + const guest = await guestContext.newPage(); + await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Listener"); + await SpaHelpers.expectVideoTilesCount(guest, 2); + + const mute = host.getByTestId("incall_mute"); + await mute.click(); + await expect(mute).toHaveAttribute("aria-checked", "false"); + await openAudioMenu(host); + + // The microphone is held open while muted, so the meter still reports the + // hardware. The mute control is what says nothing is being transmitted. + const meter = host.getByRole("meter", { name: "Microphone level" }); + await expect(meter).toBeVisible(); + // Queried by test id, not by role: the menu is modal, so Radix takes the + // rest of the call out of the accessibility tree while it is open. + await expect(mute).toHaveAttribute("aria-checked", "false"); + await expect(mute).toBeVisible(); + // And the listener is told so, rather than being left to guess from silence. + await expect( + guest.getByTestId("videoTile").filter({ hasText: "Muted host" }), + ).toBeVisible(); + + await hostContext.close(); + await guestContext.close(); + }); + + test("keeps the meter at the foot of the list while it scrolls", async ({ + page, + }) => { + await installFakeDevices(page, { microphones: 20, speakers: 4 }); + await joinACall(page, "Scroller", "Long device list"); + await openAudioMenu(page); + + const meter = page.getByRole("meter", { name: "Microphone level" }); + await expect(meter).toBeVisible(); + + // Scrolled so the microphones start at the top of the list and run past its + // bottom: the position that tells a pinned meter from one that merely + // happens to be last. + const list = page.locator("[role='menu'] div[role='none']").first(); + await list.evaluate((element) => { + const group = element.querySelector("[role='group'][aria-label*='icro']"); + element.scrollTop += + group!.getBoundingClientRect().top - + element.getBoundingClientRect().top; + }); + + await expect(meter).toBeInViewport(); + await expectPinnedInside(meter, list); + // Every entry stays reachable, which is what the scroll is for. + await expect( + page.getByRole("menuitemradio", { name: "Fake Microphone 20" }), + ).toBeVisible(); + }); + + test("shows the focus ring only when the keyboard moved the focus", async ({ + page, + browserName, + }) => { + test.skip( + browserName === "firefox", + "Headless Firefox does not deliver synthetic key presses reliably; see reconnect.spec.ts", + ); + await installFakeDevices(page); + await joinACall(page, "Keyboard user", "Focus ring"); + await openAudioMenu(page); + + const first = page.getByRole("menuitemradio").first(); + // Opened by pointer, so no ring, even though Radix has moved focus into the + // menu already. + await expect.poll(async () => outlineWidth(first)).toBe(0); + + await page.keyboard.press("ArrowDown"); + const focused = page.locator("[role='menuitemradio']:focus"); + await expect.poll(async () => outlineWidth(focused)).toBeGreaterThan(0); + + // The pointer takes it away again: the menu focuses whatever it is over, so + // a ring that followed focus alone would trail the mouse. + await first.hover(); + await expect.poll(async () => outlineWidth(focused)).toBe(0); + }); +}); + +/** Creates a call and joins it, leaving the page in the call. */ +async function joinACall( + page: Page, + userName: string, + callName: string, +): Promise { + await page.goto("/"); + await SpaHelpers.createCall(page, userName, callName, true); + await expect(page.getByTestId("name_tag")).toContainText(userName); + // The media controls stay disabled until the devices have enumerated, and + // every test here drives them. + await expect(page.getByTestId("incall_mute")).toBeEnabled({ + timeout: 10_000, + }); +} + +async function openAudioMenu(page: Page): Promise { + await page.getByRole("button", { name: "Microphone" }).click(); + await expect(page.getByRole("menu")).toBeVisible(); +} + +async function selectDevice( + page: Page, + section: "Speaker" | "Microphone", + name: string, +): Promise { + const item = page + .getByRole("group", { name: section }) + .getByRole("menuitemradio", { name }); + await item.click(); + // Selecting does not close the menu — the component prevents the default so + // the list survives a mis-click — so it is dismissed explicitly. + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu")).not.toBeVisible(); +} + +/** + * Asserts the meter sits within the scrollport, and inside the menu's frame. + * + * The meter is the one opaque element in the menu, so it is the one thing that + * can paint over the border. Whether it actually does needs a screenshot; this + * pins the geometry that decides it. + */ +async function expectPinnedInside( + meter: Locator, + list: Locator, +): Promise { + const meterBox = (await meter.boundingBox())!; + const listBox = (await list.boundingBox())!; + const frame = (await meter.page().getByRole("menu").boundingBox())!; + + expect(meterBox.y + meterBox.height).toBeLessThanOrEqual( + listBox.y + listBox.height + 1, + ); + expect(meterBox.y).toBeGreaterThanOrEqual(listBox.y - 1); + expect(meterBox.x).toBeGreaterThan(frame.x); + expect(meterBox.x + meterBox.width).toBeLessThan(frame.x + frame.width); +} + +/** The painted outline width in pixels, however the stylesheet spells it. */ +async function outlineWidth(item: Locator): Promise { + if ((await item.count()) === 0) return 0; + return item.first().evaluate((element) => { + const { outlineStyle, outlineWidth } = getComputedStyle(element); + return outlineStyle === "none" ? 0 : Number.parseFloat(outlineWidth) || 0; + }); +} diff --git a/playwright/component/audio-menu.spec.ts b/playwright/component/audio-menu.spec.ts new file mode 100644 index 000000000..03c88e30c --- /dev/null +++ b/playwright/component/audio-menu.spec.ts @@ -0,0 +1,165 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, type Locator, type Page, test } from "@playwright/test"; + +import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts"; +import { installFakeDevices } from "../utils/fake-devices.ts"; + +/** + * The device menu where Element Call is a component in a host's page rather + * than the whole of one. + * + * This is the case the stylesheets cannot describe: the menu is portalled to + * the document, so a container query and a viewport unit both measure the wrong + * thing — the first has no container to resolve against out there, the second + * measures a page Element Call does not own. The menu has to be sized against + * the space the call is actually drawn in. + * + * Driven from the lobby rather than a joined call. The footer builds the same + * menu from the same device behaviours in both, and the container is the same + * size either way, so joining would only add two connections' worth of flake. + */ + +// Signing in, setting up crypto and syncing happen twice before anything is on +// screen, as in component-call.spec.ts +test.describe.configure({ timeout: 180_000 }); + +test("sizes the device list against the call, not the window", async ({ + page, +}) => { + await installFakeDevices(page, { microphones: 20, speakers: 4 }); + const { username, roomId } = await createUserAndRoom("menusize"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + + // A short call in a much taller page: the difference between measuring the + // call and measuring the window. + const container = pane.getByTestId("call-container"); + await resizeContainer(container, { width: 900, height: 400 }); + await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({ + timeout: 60_000, + }); + + const list = await openDeviceList(page, pane); + const callHeight = (await container.boundingBox())!.height; + const windowHeight = page.viewportSize()!.height; + const listHeight = (await list.boundingBox())!.height; + + // Sized against the call. Were it sized against the window the list would be + // half as tall again, and the assertion below would not be able to tell. + expect(callHeight).toBeLessThan(windowHeight * 0.75); + expect(listHeight).toBeLessThanOrEqual(callHeight); + expect(listHeight).toBeLessThan(windowHeight * 0.6); +}); + +test("follows the call area when the host resizes it", async ({ page }) => { + await installFakeDevices(page, { microphones: 20, speakers: 4 }); + const { username, roomId } = await createUserAndRoom("menuresize"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + + const container = pane.getByTestId("call-container"); + await resizeContainer(container, { width: 900, height: 360 }); + await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({ + timeout: 60_000, + }); + + const list = await openDeviceList(page, pane); + const whenShort = (await list.boundingBox())!.height; + + // The host grows the space Element Call is drawn in while the menu is open — + // a panel opening, a window dragged, a phone turned. A bound taken once on + // opening would still describe the smaller call. + await resizeContainer(container, { width: 900, height: 700 }); + await expect + .poll(async () => (await list.boundingBox())!.height) + .toBeGreaterThan(whenShort); +}); + +test("keeps every device reachable in a small container", async ({ page }) => { + await installFakeDevices(page, { microphones: 20, speakers: 4 }); + const { username, roomId } = await createUserAndRoom("menureach"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + + const container = pane.getByTestId("call-container"); + // Narrow as well as short, which is where entries get pushed out of reach. + await resizeContainer(container, { width: 400, height: 360 }); + await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({ + timeout: 60_000, + }); + + const list = await openDeviceList(page, pane); + // More devices than the space allows, so the list has to scroll rather than + // put entries somewhere they cannot be got at. + expect(await list.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe( + true, + ); + + const last = page.getByRole("menuitemradio", { name: "Fake Microphone 20" }); + await last.scrollIntoViewIfNeeded(); + await expect(last).toBeInViewport(); + // Reachable means usable, not merely painted: D11 accepts that the menu may + // be drawn outside the call area, so this asserts reach rather than + // containment. + await last.click(); + await expect(last).toHaveAttribute("aria-checked", "true"); +}); + +test("tracks the focus modality of its own call, not the page", async ({ + page, +}) => { + await installFakeDevices(page); + const { username, roomId } = await createUserAndRoom("menufocus"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + const other = panes.nth(1); + + await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({ + timeout: 60_000, + }); + await openDeviceList(page, pane); + // The menu owns the modality, because every item it can focus has to answer + // to it — the device rows and the camera menu's blur toggle alike. + const menu = page.getByRole("menu"); + + // Asserted on the attribute rather than the painted ring, which cannot be + // read here: the menu is portalled outside the call root, and the component + // build scopes the stylesheet to it, so neither the ring nor the rule that + // suppresses the browser's own reaches this menu. The paint is asserted + // standalone instead — in the story and in audio-menu.spec.ts. What is on + // trial here is which call the tracking answers for. + await expect(menu).toHaveAttribute("data-focus-modality", "pointer"); + + // A key pressed in the other call on this page — or anywhere in the host's + // own page — says nothing about how this menu is being used. + await other.evaluate((element) => + element.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }), + ), + ); + await expect(menu).toHaveAttribute("data-focus-modality", "pointer"); + + // A key pressed in this menu does. + await page.keyboard.press("ArrowDown"); + await expect(menu).toHaveAttribute("data-focus-modality", "keyboard"); +}); + +/** + * Opens the microphone menu of one component and returns its scrolling device + * list, which lives outside the component: the menu is portalled to the page. + */ +async function openDeviceList(page: Page, pane: Locator): Promise { + await pane + .getByRole("button", { name: "Microphone" }) + .click({ timeout: 60_000 }); + await expect(page.getByRole("menu")).toBeVisible(); + const list = page.locator("[role='menu'] div[role='none']").first(); + await expect(list).toBeVisible(); + return list; +} diff --git a/playwright/utils/fake-devices.ts b/playwright/utils/fake-devices.ts new file mode 100644 index 000000000..c152955b0 --- /dev/null +++ b/playwright/utils/fake-devices.ts @@ -0,0 +1,98 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { type Page } from "@playwright/test"; + +/** + * Gives the browser more fake devices than it ships with. + * + * A headless browser's fake capture offers one microphone and one speaker, + * which is one short of what a device menu is for: with no choice to make, every + * entry renders disabled. These are synthetic entries on top of the real fake + * device, so the menu has a list to show and a selection to move, and the app + * runs its real device pipeline against them. + * + * What they do not do is route audio: every entry is backed by the same capture, + * and `setSinkId` is accepted rather than honoured. A test can prove that + * choosing a device changes the app's state and does not disturb the call. That + * a listener hears the change needs hardware, and stays a manual check. + * + * Must be called before the page navigates. + */ +export async function installFakeDevices( + page: Page, + { microphones = 2, speakers = 2 } = {}, +): Promise { + await page.addInitScript( + ({ microphones, speakers }) => { + const synthetic = ( + kind: MediaDeviceKind, + count: number, + name: string, + ): MediaDeviceInfo[] => + Array.from({ length: count }, (_, i) => { + const info = { + deviceId: `${kind}-${i + 1}`, + groupId: `${kind}-group-${i + 1}`, + kind, + label: `${name} ${i + 1}`, + }; + return { ...info, toJSON: () => info } as MediaDeviceInfo; + }); + const ids = new Set( + [ + ...synthetic("audioinput", microphones, ""), + ...synthetic("audiooutput", speakers, ""), + ].map((d) => d.deviceId), + ); + + const devices = navigator.mediaDevices; + const enumerate = devices.enumerateDevices.bind(devices); + devices.enumerateDevices = async (): Promise => [ + ...(await enumerate()), + ...synthetic("audioinput", microphones, "Fake Microphone"), + ...synthetic("audiooutput", speakers, "Fake Speaker"), + ]; + + // Our ids name no hardware, so an exact-device constraint on one would be + // rejected. Drop it and let the one real fake device answer. + const getUserMedia = devices.getUserMedia.bind(devices); + devices.getUserMedia = async ( + constraints?: MediaStreamConstraints, + ): Promise => { + const audio = constraints?.audio; + if (typeof audio === "object") { + const requested = audio.deviceId; + const id = + typeof requested === "object" && requested !== null + ? ((requested as ConstrainDOMStringParameters).exact as string) + : (requested as string | undefined); + if (id !== undefined && ids.has(id)) + return getUserMedia({ ...constraints, audio: true }); + } + return getUserMedia(constraints); + }; + + // Routing to a device that does not exist would reject, and the app + // treats that as a failed switch. Both sinks are patched: Element Call + // routes its own AudioContext as well as the media elements, and leaving + // that one alone logs a NotFoundError for every switch. + for (const proto of [ + HTMLMediaElement.prototype, + AudioContext.prototype, + ]) { + const sink = proto as { setSinkId?: (id: string) => Promise }; + const setSinkId = sink.setSinkId; + if (setSinkId === undefined) continue; + sink.setSinkId = async function (id: string): Promise { + if (!ids.has(id)) await setSinkId.call(this, id); + }; + } + }, + { microphones, speakers }, + ); +} diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index af78c418d..67bc3d557 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -6,12 +6,14 @@ Please see LICENSE in the repository root for full details. */ import { fn, userEvent, waitFor, within, expect } from "storybook/test"; -import { type JSX } from "react"; +import { useEffect, useState, type FC, type JSX, type ReactNode } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; import styles from "./MediaMuteAndSwitchButton.module.css"; +import meterStyles from "./MicrophoneLevelMeter.module.css"; import { MediaDevicesContext } from "../MediaDevicesContext"; +import { RootElementProvider } from "../RootElementContext"; import { MediaDevices } from "../state/MediaDevices"; import { globalScope } from "../state/ObservableScope"; @@ -19,12 +21,97 @@ const mediaDevices = new MediaDevices(globalScope, { controlledAudioDevices: false, }); +/** + * Gives these stories a microphone to read. + * + * The menu opens a capture of whichever device it has been told is selected, + * and the devices in a story are invented: asking for one by an id no hardware + * answers to fails, and the meter reports that — correctly — as there being no + * microphone. So the story provides one rather than borrowing the machine's: a + * wavering tone played into a real MediaStream, which the meter then runs its + * own analyser over. Nothing here stands in for the meter itself. + */ +const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => { + useEffect(() => { + const context = new AudioContext(); + const microphone = context.createMediaStreamDestination(); + const tone = context.createOscillator(); + const loudness = context.createGain(); + // Swinging between about a third and two thirds of the range, so the meter + // reads as something live rather than as a level someone pinned there. + const swing = context.createOscillator(); + const depth = context.createGain(); + loudness.gain.value = 0.25; + depth.gain.value = 0.2; + swing.frequency.value = 0.6; + tone.frequency.value = 220; + swing.connect(depth).connect(loudness.gain); + tone.connect(loudness).connect(microphone); + tone.start(); + swing.start(); + + const devices = navigator.mediaDevices; + const openedForReal = devices.getUserMedia.bind(devices); + const opened = Promise.resolve(microphone.stream); + // A fresh clone each time, so that a caller stopping its tracks when it is + // done does not take the microphone away from the next one. + devices.getUserMedia = async (): Promise => + (await opened).clone(); + + return (): void => { + devices.getUserMedia = openedForReal; + tone.stop(); + swing.stop(); + void context.close(); + }; + }, []); + + return <>{children}; +}; + +/** + * Gives these stories the call area the menu belongs to. + * + * The menu sizes its device list against the space Element Call is drawn in, + * and takes that from a provider. Without one it falls back to the document + * body — which in a story is the whole of Storybook's frame, so the list is + * bounded by something far larger than the story it is drawn in and runs off + * the top of the canvas. Supplying a root is the same courtesy as supplying the + * devices: the story stands in for the call, so it has to say how big it is. + */ +const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => { + const [callArea, setCallArea] = useState(null); + return ( +
+ {callArea !== null && ( + {children} + )} +
+ ); +}; + const meta = { component: MediaMuteAndSwitchButton, decorators: [ (Story): JSX.Element => ( - + + + + + ), ], @@ -43,6 +130,16 @@ export const Default: Story = { { label: { type: "name", name: "Option 2" }, id: "2" }, ], selectedOption: "1", + // The audio menu always has a speaker section: the footer hands it an + // output list whenever it draws the chevron at all, so a microphone menu + // with no speakers in it is a shape nothing in the app produces. Set here + // rather than in each story, since the others build on these. + outputOptions: [ + { label: { type: "default", name: "Built-in Output" }, id: "default" }, + { label: { type: "name", name: "Headset" }, id: "spk2" }, + ], + selectedOutputOption: "default", + onSelectOutput: fn(), onMuteClick: fn(), onSelect: fn(), }, @@ -74,6 +171,7 @@ export const AudioMute: Story = { export const AudioUnmute: Story = { args: { + ...Default.args, title: "Microphone", iconsAndLabels: "audio", enabled: true, @@ -81,7 +179,6 @@ export const AudioUnmute: Story = { { label: { type: "name", name: "Microphone 1" }, id: "1" }, { label: { type: "name", name: "Microphone 2" }, id: "2" }, ], - selectedOption: "2", }, }; @@ -142,6 +239,14 @@ export const SpeakerAndMicrophoneSections: Story = { }); await userEvent.click(headset); await expect(args.onSelectOutput).toHaveBeenCalledWith("spk2"); + + // A handful of devices fits: only a list longer than the space it is given + // scrolls, and a menu that scrolled at four devices would be bounded by + // something far smaller than the call it is drawn in. + const list = document.body.querySelector( + `.${styles.deviceList}`, + )!; + await expect(list.scrollHeight).toBe(list.clientHeight); }, }; @@ -199,6 +304,171 @@ export const OnlyOneDevice: Story = { }, }; +/** + * A device has been asked for and has not arrived. Nothing in either section + * can be picked until it does, so a second request cannot overtake the first. + */ +export const SelectionSettling: Story = { + args: { + ...Default.args, + title: "Microphone", + iconsAndLabels: "audio", + enabled: true, + options: [ + { label: { type: "name", name: "Microphone 1" }, id: "mic1" }, + { label: { type: "name", name: "Microphone 2" }, id: "mic2" }, + ], + selectedOption: "mic1", + outputOptions: [ + { label: { type: "name", name: "Speakers" }, id: "spk1" }, + { label: { type: "name", name: "Headset" }, id: "spk2" }, + ], + selectedOutputOption: "spk1", + onSelectOutput: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Microphone" })); + const menu = within(document.body); + + // The story never changes `selectedOption`, which is what a device that has + // not taken effect yet looks like from here. + await userEvent.click( + await menu.findByRole("menuitemradio", { name: "Microphone 2" }), + ); + + await expect(await menu.findByLabelText("Activating…")).toBeVisible(); + for (const item of menu.getAllByRole("menuitemradio")) + await expect(item).toHaveAttribute("aria-disabled", "true"); + }, +}; + +/** + * The focus ring belongs to the keyboard. Radix focuses whatever the pointer is + * over, so a ring that followed focus alone would trail the mouse. + * + * Asserted on the painted outline rather than on `data-focus-modality`: the + * attribute is what the stylesheet keys off, so asserting it would pass even + * with the rule deleted. + */ +export const KeyboardFocusRing: Story = { + args: { + ...Default.args, + title: "Microphone", + iconsAndLabels: "audio", + enabled: true, + options: [ + { label: { type: "name", name: "Microphone 1" }, id: "mic1" }, + { label: { type: "name", name: "Microphone 2" }, id: "mic2" }, + ], + selectedOption: "mic1", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Microphone" })); + const menu = within(document.body); + const first = await menu.findByRole("menuitemradio", { + name: "Microphone 1", + }); + + // Opened by pointer: no ring, even though Radix has moved focus. + await expect(outlineWidth(first)).toBe(0); + + await userEvent.keyboard("{ArrowDown}"); + const focused = document.activeElement as HTMLElement; + await expect(focused).toHaveRole("menuitemradio"); + await expect(outlineWidth(focused)).toBeGreaterThan(0); + + // And the pointer takes it away again. + await userEvent.hover(first); + await expect(outlineWidth(document.activeElement as HTMLElement)).toBe(0); + }, +}; + +/** + * More devices than the menu can show. The list scrolls, and the meter stays at + * the foot of the Microphone section rather than scrolling away with it. + */ +export const ManyDevices: Story = { + args: { + ...Default.args, + title: "Microphone", + iconsAndLabels: "audio", + enabled: true, + options: Array.from({ length: 20 }, (_, i) => ({ + label: { type: "name" as const, name: `Microphone ${i + 1}` }, + id: `mic${i + 1}`, + })), + selectedOption: "mic1", + outputOptions: Array.from({ length: 6 }, (_, i) => ({ + label: { type: "name" as const, name: `Speaker ${i + 1}` }, + id: `spk${i + 1}`, + })), + selectedOutputOption: "spk1", + onSelectOutput: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Microphone" })); + const menu = within(document.body); + + // The scroll container and the opaque sticky wrapper, named rather than + // walked: the nesting between them is layout, and it moves. The wrapper + // rather than the meter itself, because this story is about where the meter + // sits, not what it reads — without a fake microphone, as on WebKit, it + // says it has no permission instead of showing a level. + const list = document.body.querySelector( + `.${styles.deviceList}`, + )!; + const sticky = await waitFor(() => { + const element = document.body.querySelector( + `.${styles.stickyMeter}`, + ); + if (element === null) throw new Error("the meter has not rendered yet"); + return element; + }); + await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight); + + // Scrolled so the Microphone section starts at the top of the scrollport. + // Its devices then run past the bottom, which is the position that tells a + // pinned meter from one that simply happens to be the last element: at the + // very bottom of the list the two look identical. + const group = await menu.findByRole("group", { name: "Microphone" }); + list.scrollTop += + group.getBoundingClientRect().top - list.getBoundingClientRect().top; + await expect(list.scrollTop + list.clientHeight).toBeLessThan( + list.scrollHeight, + ); + + const scrollport = list.getBoundingClientRect(); + const pinned = sticky.getBoundingClientRect(); + await expect(pinned.bottom).toBeLessThanOrEqual(scrollport.bottom + 1); + await expect(pinned.top).toBeGreaterThanOrEqual(scrollport.top - 1); + + // The whole menu is on screen. It opens upward from the foot of the call, + // so a list bounded by something bigger than the call — the document, say — + // runs off the top and takes the speakers with it. + const frame = document.body + .querySelector("[role='menu']")! + .getBoundingClientRect(); + await expect(frame.top).toBeGreaterThanOrEqual(0); + await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1); + + // The meter is the one opaque thing in the menu, so it is the one thing + // that can cover the frame. Its box has to stay inside the menu's own. The + // paint itself needs a screenshot; this pins the geometry that decides it. + await expect(pinned.left).toBeGreaterThan(frame.left); + await expect(pinned.right).toBeLessThan(frame.right); + }, +}; + +/** The painted outline width, in pixels, however the stylesheet spells it. */ +function outlineWidth(element: HTMLElement): number { + const { outlineStyle, outlineWidth } = getComputedStyle(element); + if (outlineStyle === "none") return 0; + return Number.parseFloat(outlineWidth) || 0; +} + /** * A platform that enumerates no output devices and offers no way to choose one * — Safari. The section still names where audio is going, disabled, rather than @@ -230,6 +500,50 @@ export const OutputNotEnumerated: Story = { }, }; +/** + * The level meter's icon sits on the same centre line as the radio controls of + * the devices above it. + * + * Held here because it is a fact about two components side by side, and because + * layout decides it: the meter's row is inset to keep the menu's frame clear, + * and its icon is a different size from a radio control, so the padding that + * lines them up is arithmetic that would otherwise go stale in silence. + */ +export const MeterAlignsWithTheDeviceRows: Story = { + args: { + ...Default.args, + title: "Microphone", + iconsAndLabels: "audio", + enabled: true, + options: [ + { label: { type: "name", name: "Microphone 1" }, id: "mic1" }, + { label: { type: "name", name: "Microphone 2" }, id: "mic2" }, + ], + selectedOption: "mic1", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Microphone" })); + + const menu = document.body.querySelector("[role='menu']")!; + const radio = menu.querySelector("input[type='radio']")!; + const icon = await waitFor(() => { + const element = document.body.querySelector(`.${meterStyles.icon}`); + if (element === null) throw new Error("the meter has not rendered yet"); + return element; + }); + + // A pixel of slack, for subpixel layout. + await expect(Math.abs(centre(icon) - centre(radio))).toBeLessThanOrEqual(1); + }, +}; + +/** Where an element sits on the inline axis, at its middle. */ +function centre(element: Element): number { + const box = element.getBoundingClientRect(); + return box.left + box.width / 2; +} + /** * Walking the device list with the keyboard, all the way to the last entry. * @@ -375,55 +689,6 @@ function overlapping(element: Element, overlays: Element[]): number { }, 0); } -/** - * The focus ring belongs to the keyboard. Radix focuses whatever the pointer is - * over, so a ring that followed focus alone would trail the mouse. - * - * Asserted on the painted outline rather than on `data-focus-modality`: the - * attribute is what the stylesheet keys off, so asserting it would pass even - * with the rule deleted. - */ -export const KeyboardFocusRing: Story = { - args: { - ...Default.args, - title: "Microphone", - iconsAndLabels: "audio", - enabled: true, - options: [ - { label: { type: "name", name: "Microphone 1" }, id: "mic1" }, - { label: { type: "name", name: "Microphone 2" }, id: "mic2" }, - ], - selectedOption: "mic1", - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button", { name: "Microphone" })); - const menu = within(document.body); - const first = await menu.findByRole("menuitemradio", { - name: "Microphone 1", - }); - - // Opened by pointer: no ring, even though Radix has moved focus. - await expect(outlineWidth(first)).toBe(0); - - await userEvent.keyboard("{ArrowDown}"); - const focused = document.activeElement as HTMLElement; - await expect(focused).toHaveRole("menuitemradio"); - await expect(outlineWidth(focused)).toBeGreaterThan(0); - - // And the pointer takes it away again. - await userEvent.hover(first); - await expect(outlineWidth(document.activeElement as HTMLElement)).toBe(0); - }, -}; - -/** The painted outline width, in pixels, however the stylesheet spells it. */ -function outlineWidth(element: HTMLElement): number { - const { outlineStyle, outlineWidth } = getComputedStyle(element); - if (outlineStyle === "none") return 0; - return Number.parseFloat(outlineWidth) || 0; -} - /** * The camera menu's blur toggle, which the keyboard reaches after the cameras. *