From 488326709829840872501d4fcc52e044644f4dbc Mon Sep 17 00:00:00 2001 From: fkwp Date: Tue, 22 Sep 2026 14:56:47 +0200 Subject: [PATCH] Add end-to-end specs for the quick audio menu - Two participants, for what only a real call can show: a device switch that neither drops the media session nor rejoins, and a meter that keeps moving while muted while the peer is still shown the mute rather than left to infer it from silence. - The component harness covers what only a host page shows: every device reachable in a small container, the list sized against the call rather than the window, and the menu following the call area when the host resizes it. - Synthetic devices are patched onto the media element and the audio context alike, so a browser with no hardware still enumerates and still routes. Co-Authored-By: Claude Opus 5 --- playwright/audio-menu.spec.ts | 253 ++++++++++++++++++++++++ playwright/component/audio-menu.spec.ts | 165 ++++++++++++++++ playwright/utils/fake-devices.ts | 98 +++++++++ 3 files changed, 516 insertions(+) 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 }, + ); +}