Files
element-call-Github/playwright/audio-menu.spec.ts
fkwp 4f8f71e861 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.
2026-09-17 11:56:31 +02:00

254 lines
9.8 KiB
TypeScript

/*
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<void> {
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<void> {
await page.getByRole("button", { name: "Microphone" }).click();
await expect(page.getByRole("menu")).toBeVisible();
}
async function selectDevice(
page: Page,
section: "Speaker" | "Microphone",
name: string,
): Promise<void> {
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<void> {
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<number> {
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;
});
}