mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-28 22:45:49 +00:00
[Feature] Quick audio menu (#4275)
* Give the microphone level its own observable * Add a microphone level meter * Show speakers and microphones in the quick audio menu * Wire speaker selection through the call footer * Add stories for the meter and the device menu * Add end-to-end specs for the quick audio menu * Keep the footer while a menu opened from it is open * Use the shared audio capture stub in the lobby test --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
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);
|
||||
|
||||
await expect(page.getByRole("group", { name: "Speaker" })).toBeVisible();
|
||||
await expect(page.getByRole("group", { name: "Microphone" })).toBeVisible();
|
||||
// By name, not count: the browser adds its own fake output and a Default.
|
||||
for (const n of [1, 2, 3])
|
||||
await expect(
|
||||
page
|
||||
.getByRole("group", { name: "Speaker" })
|
||||
.getByRole("menuitemradio", { name: `Fake Speaker ${n}` }),
|
||||
).toBeVisible();
|
||||
|
||||
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 here can route audio; the platform that can't is a unit check.
|
||||
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 and a real call.
|
||||
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");
|
||||
|
||||
// Not a rejoin: neither side drops, and the guest still has both tiles.
|
||||
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 and a real call.
|
||||
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);
|
||||
|
||||
const meter = host.getByRole("meter", { name: "Microphone level" });
|
||||
await expect(meter).toBeVisible();
|
||||
// By test id: the modal menu hides the rest of the call from the a11y tree.
|
||||
await expect(mute).toHaveAttribute("aria-checked", "false");
|
||||
await expect(mute).toBeVisible();
|
||||
// And the guest is shown the mute.
|
||||
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: only there does a pinned
|
||||
// meter differ from one that is simply 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);
|
||||
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: no ring, though Radix has moved focus into the menu.
|
||||
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);
|
||||
|
||||
// And the pointer takes it away again.
|
||||
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 devices have enumerated.
|
||||
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 doesn't close the menu, so dismiss it.
|
||||
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. */
|
||||
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);
|
||||
}
|
||||
|
||||
/** Painted outline width, in px. */
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
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 with Element Call as a component in a host page, where the
|
||||
* portalled menu must be sized against the call, not the window. Driven from
|
||||
* the lobby: it builds the same menu, without two connections' worth of flake.
|
||||
*/
|
||||
|
||||
// Sign-in, crypto setup and sync happen twice before anything shows.
|
||||
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.
|
||||
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;
|
||||
|
||||
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 call while the menu is open.
|
||||
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");
|
||||
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 fit, so the list must scroll.
|
||||
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 clickable: the menu may be drawn outside the call area.
|
||||
await last.click();
|
||||
await expect(last).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
test("tracks the focus source 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);
|
||||
const menu = page.getByRole("menu");
|
||||
|
||||
// Asserted on the attribute: the component build's scoped stylesheet doesn't
|
||||
// reach the portalled menu, so its ring can't be read here.
|
||||
await expect(menu).toHaveAttribute("data-focus-source", "pointer");
|
||||
|
||||
// A key pressed in the other call says nothing about this menu.
|
||||
await other.evaluate((element) =>
|
||||
element.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
|
||||
),
|
||||
);
|
||||
await expect(menu).toHaveAttribute("data-focus-source", "pointer");
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(menu).toHaveAttribute("data-focus-source", "keyboard");
|
||||
});
|
||||
|
||||
/** Opens a component's microphone menu and returns its device list. */
|
||||
async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
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";
|
||||
|
||||
/**
|
||||
* Adds synthetic devices beside the browser's one fake microphone and speaker,
|
||||
* so the menu has a choice to show. They share one capture and `setSinkId` is
|
||||
* accepted but not honoured: routing still needs hardware and a manual check.
|
||||
* Call before the page navigates.
|
||||
*/
|
||||
export async function installFakeDevices(
|
||||
page: Page,
|
||||
{ microphones = 2, speakers = 2 } = {},
|
||||
): Promise<void> {
|
||||
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<MediaDeviceInfo[]> => [
|
||||
...(await enumerate()),
|
||||
...synthetic("audioinput", microphones, "Fake Microphone"),
|
||||
...synthetic("audiooutput", speakers, "Fake Speaker"),
|
||||
];
|
||||
|
||||
// Our ids name no hardware, so drop the exact-device constraint.
|
||||
const getUserMedia = devices.getUserMedia.bind(devices);
|
||||
devices.getUserMedia = async (
|
||||
constraints?: MediaStreamConstraints,
|
||||
): Promise<MediaStream> => {
|
||||
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);
|
||||
};
|
||||
|
||||
// Accept routing to our ids on both media elements and the AudioContext,
|
||||
// which the app also routes.
|
||||
for (const proto of [
|
||||
HTMLMediaElement.prototype,
|
||||
AudioContext.prototype,
|
||||
]) {
|
||||
const sink = proto as { setSinkId?: (id: string) => Promise<void> };
|
||||
const setSinkId = sink.setSinkId;
|
||||
if (setSinkId === undefined) continue;
|
||||
sink.setSinkId = async function (id: string): Promise<void> {
|
||||
if (!ids.has(id)) await setSinkId.call(this, id);
|
||||
};
|
||||
}
|
||||
},
|
||||
{ microphones, speakers },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user