mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
The chevron beside the microphone button now opens a menu headed "Audio controls" that lists the microphones and, below a separator, the audio outputs. Selecting either switches the device and keeps the menu open. Where the browser offers no choice of output, or only one, the speaker group still names the output in use as a non-selectable row. The footer view model derives the outputs from the same MediaDevices helper as the device lists, so the menu reaches both surfaces at once: in a call on desktop, and before joining on every platform. Spec: FEATURES_SPEC/2026-09_Audio_Quick_Menu.md, slice 1 — AC1, AC2, AC3, AC5, AC6, AC7, AC25. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
75 lines
2.7 KiB
TypeScript
75 lines
2.7 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, type Locator, test } from "@playwright/test";
|
|
|
|
import { SpaHelpers } from "./spa-helpers";
|
|
|
|
test("audio menu leaves participants visible while switching output", async ({
|
|
browser,
|
|
browserName,
|
|
}) => {
|
|
test.skip(
|
|
browserName === "firefox",
|
|
"Firefox's fake media stream enumerates no audio outputs, so there is nothing to switch.",
|
|
);
|
|
|
|
// Reduced motion disables the animations that make these tests flaky.
|
|
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
|
|
const page = await creatorContext.newPage();
|
|
await page.goto("/");
|
|
await SpaHelpers.createCall(page, "Inviter", "Audio menu", true);
|
|
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
|
|
|
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
|
const guestPage = await guestContext.newPage();
|
|
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Guest");
|
|
|
|
await SpaHelpers.expectVideoTilesCount(page, 2);
|
|
|
|
// The chevron beside the microphone button opens the audio menu in place.
|
|
await page.getByRole("button", { name: "Microphone" }).click();
|
|
const menu = page.getByRole("menu");
|
|
await expect(
|
|
menu.getByRole("heading", { name: "Audio controls" }),
|
|
).toBeVisible();
|
|
|
|
// Pick another output where the browser offers one; where it does not, the
|
|
// speaker group still names the output in use.
|
|
const outputs = menu
|
|
.getByTestId("audio_menu_scroll")
|
|
.locator('> [role="menuitemradio"]');
|
|
const count = await outputs.count();
|
|
if (count > 1) {
|
|
// Pinned by position: a locator on "the unchecked row" would re-resolve to
|
|
// the previously active row once the click has moved the check mark.
|
|
const other = outputs.nth(await firstUncheckedIndex(outputs, count));
|
|
await other.click();
|
|
await expect(other).toHaveAttribute("aria-checked", "true");
|
|
await expect(menu).toBeVisible();
|
|
} else {
|
|
await expect(menu.getByTestId("speaker_readonly")).toBeVisible();
|
|
}
|
|
|
|
// Nothing covered the call: the other participant is still on screen with
|
|
// the menu open, and after it closes.
|
|
await SpaHelpers.expectVideoTilesCount(page, 2);
|
|
await page.keyboard.press("Escape");
|
|
await expect(menu).not.toBeVisible();
|
|
await SpaHelpers.expectVideoTilesCount(page, 2);
|
|
});
|
|
|
|
async function firstUncheckedIndex(
|
|
rows: Locator,
|
|
count: number,
|
|
): Promise<number> {
|
|
for (let i = 0; i < count; i++) {
|
|
if ((await rows.nth(i).getAttribute("aria-checked")) === "false") return i;
|
|
}
|
|
throw new Error("every output row is marked active");
|
|
}
|