mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
- 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.
166 lines
6.8 KiB
TypeScript
166 lines
6.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, 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<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;
|
|
}
|