mirror of
https://github.com/element-hq/element-call.git
synced 2026-10-09 22:47:22 +00:00
- Compound 10.3.0 bounds a menu by the space it has and scrolls what doesn't fit, within a boundary the app passes. The menus pass the call area, 16px in from its sides, rather than measuring its width. - The 296px width is the menu's own style. Compound gives that style only to the dropdown, so menuIsDrawer, which copied its phone rule, goes. - Compound draws the menu's frame over its content, so nothing that scrolls can hide it in Safari: the list, headings and meter no longer keep a border width clear of it, and the checks that they did go. - The device list is Compound's scroll area, so it fades out at the foot with Compound's chevron while there is more below. - The microphone menu's meter sticks at that foot, with rows scrolling under it as before, so the list has no fade above it: Compound's is a mask fixed to the list's edge, while a bounce at the top carries the meter with the rows. The band under the meter is filled, or rows would show there. The edge fades from where the chevron starts, so a bounce fades the meter out before it reaches the chevron. A row reached by keyboard stays above the meter. - The list's height is still its share of the call. - The component harness leaves the host's stylesheets unscoped, Compound's among them, as a host does: scoped, they never reached the menu, which is portalled out of Element Call's root. - Its specs check only Element Call's own document styles on the host page, and find the device list within Compound's scroll area. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
280 lines
11 KiB
TypeScript
280 lines
11 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,
|
|
expectWithin,
|
|
resizeContainer,
|
|
startHarness,
|
|
} from "./harness.ts";
|
|
import { SpaHelpers } from "../spa-helpers.ts";
|
|
|
|
/**
|
|
* Element Call embedded as a React component, driven through the development
|
|
* harness in `component/dev`.
|
|
*
|
|
* What these cover that the widget tests cannot is everything that follows from
|
|
* sharing a page with a host: whether Element Call stays inside the space it
|
|
* was given, and whether two of it can exist at once. As a widget, the iframe
|
|
* guaranteed both.
|
|
*/
|
|
|
|
// Each test signs in twice, sets up crypto twice and syncs twice before
|
|
// anything is on screen, and then waits for media to connect; the waits below
|
|
// are sized for that, so the tests have to be too
|
|
test.describe.configure({ timeout: 180_000 });
|
|
|
|
/** The settings button, whichever of the two the footer is currently showing. */
|
|
function settingsButton(pane: Locator): Locator {
|
|
return pane
|
|
.getByTestId("settings-bottom-left")
|
|
.or(pane.getByTestId("settings-bottom-center"))
|
|
.filter({ visible: true })
|
|
.first();
|
|
}
|
|
|
|
test("holds a call between two components on one page", async ({ page }) => {
|
|
const { username, roomId } = await createUserAndRoom("twocomponents");
|
|
const panes = await startHarness(page, username, roomId);
|
|
|
|
// Each component shows a lobby of its own, and neither has joined anything
|
|
// just by being rendered
|
|
for (const index of [0, 1])
|
|
await expect(panes.nth(index).getByTestId("lobby_joinCall")).toBeVisible({
|
|
timeout: 60_000,
|
|
});
|
|
|
|
for (const index of [0, 1])
|
|
await panes.nth(index).getByTestId("lobby_joinCall").click();
|
|
|
|
// Two devices of one account, so each component should see itself and the
|
|
// other. This is the part that proves two Element Calls in one page are two
|
|
// calls, and not one shared thing wearing two hats.
|
|
for (const index of [0, 1])
|
|
await expect(panes.nth(index).getByTestId("videoTile")).toHaveCount(2, {
|
|
timeout: 60_000,
|
|
});
|
|
});
|
|
|
|
test("keeps its modals inside the container it was given", async ({ page }) => {
|
|
const { username, roomId } = await createUserAndRoom("containment");
|
|
const panes = await startHarness(page, username, roomId);
|
|
const pane = panes.first();
|
|
const container = pane.getByTestId("call-container");
|
|
|
|
// In the flat container the harness gives it by default, Element Call hides
|
|
// its controls a few seconds after the call starts, as it would in a flat
|
|
// window. A full-size container keeps them on screen to be clicked.
|
|
await resizeContainer(container, { width: 900, height: 640 });
|
|
await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 });
|
|
await expect(pane.getByTestId("footer-container")).toBeVisible({
|
|
timeout: 60_000,
|
|
});
|
|
|
|
// Both of these are positioned `fixed`, and were centred on the window
|
|
// rather than the container until it was made a containing block. The
|
|
// settings dialog spilled over the host's interface; the reaction picker sat
|
|
// at 82vh, which put it below the container entirely and so out of sight.
|
|
await settingsButton(pane).click();
|
|
await expectWithin(pane.getByRole("dialog"), container);
|
|
await pane.getByTestId("modal_close").click();
|
|
|
|
await pane.getByRole("button", { name: "Reactions" }).click();
|
|
await expectWithin(
|
|
pane.getByRole("dialog", { name: "Pick reaction" }),
|
|
container,
|
|
);
|
|
});
|
|
|
|
test("keeps the call inside the container, wherever the host put it", async ({
|
|
page,
|
|
}) => {
|
|
const { username, roomId } = await createUserAndRoom("tileswithin");
|
|
const panes = await startHarness(page, username, roomId);
|
|
const pane = panes.first();
|
|
const container = pane.getByTestId("call-container");
|
|
|
|
// Large enough for the layout switch to be offered
|
|
await resizeContainer(container, { width: 900, height: 640 });
|
|
await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 });
|
|
await expect(pane.getByTestId("footer-container")).toBeVisible({
|
|
timeout: 60_000,
|
|
});
|
|
|
|
// The spotlight layout draws its tile in the fixed grid, which is positioned
|
|
// against Element Call's root rather than laid out in flow. The harness puts
|
|
// the container below a header of its own, so a grid offset measured from
|
|
// the top of the page instead of from the root would land the tile on top of
|
|
// the footer and out of the bottom of the container.
|
|
await pane.getByRole("radio", { name: "Spotlight" }).check();
|
|
const tile = pane.getByTestId("videoTile").first();
|
|
await expect(tile).toBeVisible({ timeout: 60_000 });
|
|
await expectWithin(tile, container);
|
|
});
|
|
|
|
test("leaves the host's own page unstyled", async ({ page }) => {
|
|
const { username, roomId } = await createUserAndRoom("hoststyles");
|
|
const panes = await startHarness(page, username, roomId);
|
|
await expect(panes.first().getByTestId("lobby_joinCall")).toBeVisible({
|
|
timeout: 60_000,
|
|
});
|
|
|
|
// Compound's document styles reach the host, but it loads them itself.
|
|
const host = await page.evaluate(() => {
|
|
const sub = document.body.appendChild(document.createElement("sub"));
|
|
const verticalAlign = getComputedStyle(sub).verticalAlign;
|
|
sub.remove();
|
|
return {
|
|
verticalAlign,
|
|
token: getComputedStyle(document.documentElement).getPropertyValue(
|
|
"--font-scale",
|
|
),
|
|
};
|
|
});
|
|
expect(host).toEqual({ verticalAlign: "sub", token: "" });
|
|
|
|
// While inside the container, the same rules do apply
|
|
const root = panes.first().locator("[data-element-call-root]");
|
|
await expect(root).toHaveCSS("font-feature-settings", /"kern"/);
|
|
});
|
|
|
|
test("tells its host what it is doing", async ({ page }) => {
|
|
const { username, roomId } = await createUserAndRoom("hostbridge");
|
|
const panes = await startHarness(page, username, roomId);
|
|
const pane = panes.first();
|
|
const log = page.getByTestId("bridge-log");
|
|
|
|
// Every component reports to its host through the bridge, whether that host
|
|
// is a widget container or an application embedding it directly
|
|
await expect(log).toContainText("contentLoaded", { timeout: 60_000 });
|
|
|
|
await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 });
|
|
await expect(log).toContainText("notifyJoined", { timeout: 60_000 });
|
|
await expect(log).toContainText("setAlwaysOnScreen(true)", {
|
|
timeout: 60_000,
|
|
});
|
|
|
|
// And takes instructions back: the host asking for a mute should come back
|
|
// as the component reporting the new state
|
|
await pane.getByRole("button", { name: "Mute" }).click();
|
|
await expect(log).toContainText("notifyDeviceMute(audio: false", {
|
|
timeout: 30_000,
|
|
});
|
|
});
|
|
|
|
test("lays itself out for the space it is given, not the page", async ({
|
|
page,
|
|
}) => {
|
|
const { username, roomId } = await createUserAndRoom("containersize");
|
|
const panes = await startHarness(page, username, roomId);
|
|
const pane = panes.first();
|
|
const container = pane.getByTestId("call-container");
|
|
const call = pane.locator("[data-layout]");
|
|
|
|
await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 });
|
|
await expect(call).toBeVisible({ timeout: 60_000 });
|
|
await expect(call).not.toHaveAttribute("data-layout", "pip");
|
|
|
|
// As a widget, Element Call's container and its window were one and the same:
|
|
// a host wanting a picture-in-picture made the iframe small, and Element Call
|
|
// saw the window shrink. A component gets no such signal from the window,
|
|
// which stays as large as it ever was; only the container changes.
|
|
await resizeContainer(container, { width: 300, height: 300 });
|
|
await expect(call).toHaveAttribute("data-layout", "pip");
|
|
|
|
await resizeContainer(container, { width: 900, height: 700 });
|
|
await expect(call).not.toHaveAttribute("data-layout", "pip");
|
|
});
|
|
|
|
/**
|
|
* The shape of a call at whatever size it has been given: the layout it chose,
|
|
* how much of the height the tile and the footer take, and which controls the
|
|
* footer shows. Two calls with the same shape look the same, participants aside.
|
|
*/
|
|
async function callShape(scope: Page | Locator): Promise<{
|
|
layout: string | null;
|
|
tileHeight: number;
|
|
footerHeight: number;
|
|
buttons: (string | null)[];
|
|
}> {
|
|
const call = scope.locator("[data-layout]");
|
|
const footer = scope.getByTestId("footer-container");
|
|
await expect(footer).toBeVisible();
|
|
// The tile arrives with the media connection, which can take a while
|
|
const tile = scope.getByTestId("videoTile").first();
|
|
await expect(tile).toBeVisible({ timeout: 60_000 });
|
|
const tileBox = (await tile.boundingBox())!;
|
|
const footerBox = (await footer.boundingBox())!;
|
|
// Buttons and switches alike: the mute controls are switches
|
|
const buttons = await footer
|
|
.locator("button")
|
|
.filter({ visible: true })
|
|
.evaluateAll((elements) =>
|
|
elements.map((element) => element.getAttribute("aria-label")),
|
|
);
|
|
return {
|
|
layout: await call.getAttribute("data-layout"),
|
|
tileHeight: Math.round(tileBox.height),
|
|
footerHeight: Math.round(footerBox.height),
|
|
buttons,
|
|
};
|
|
}
|
|
|
|
test("looks the same in a small container as in a small window", async ({
|
|
page,
|
|
browser,
|
|
}) => {
|
|
// Two calls to set up, one of them through the harness's two logins
|
|
test.setTimeout(300_000);
|
|
const size = { width: 300, height: 300 };
|
|
|
|
// The reference is Element Call owning a window of that size, which is what
|
|
// a mobile app's webview or a browser's picture-in-picture gives it, and
|
|
// what its small-window styling was written for.
|
|
// No permissions to grant: each browser is launched with fake media that is
|
|
// handed out without asking (see playwright.config.ts), and Firefox rejects
|
|
// a request for `camera` or `microphone` outright
|
|
const referenceContext = await browser.newContext({
|
|
viewport: size,
|
|
ignoreHTTPSErrors: true,
|
|
});
|
|
const referencePage = await referenceContext.newPage();
|
|
await referencePage.goto("/");
|
|
await SpaHelpers.createCall(referencePage, "Reference", "smallwindow", true);
|
|
const reference = await callShape(referencePage);
|
|
await referencePage.screenshot({
|
|
path: test.info().outputPath("small-window.png"),
|
|
});
|
|
|
|
// The component gets a container of that size, in a window that is far larger
|
|
const { username, roomId } = await createUserAndRoom("smallcontainer");
|
|
const panes = await startHarness(page, username, roomId);
|
|
const pane = panes.first();
|
|
const container = pane.getByTestId("call-container");
|
|
await resizeContainer(container, size);
|
|
await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 });
|
|
await expect(pane.locator("[data-layout]")).toBeVisible({ timeout: 60_000 });
|
|
const component = await callShape(pane);
|
|
await container.screenshot({
|
|
path: test.info().outputPath("small-container.png"),
|
|
});
|
|
await referenceContext.close();
|
|
|
|
// The breakpoints in Element Call's stylesheets are container queries, so a
|
|
// small container gets the compact footer a small window does, rather than
|
|
// the full-width one the window's own size would call for
|
|
expect(component).toEqual(reference);
|
|
// And that footer is the compact one: a single row of controls, not the
|
|
// full-height bar with its logo and layout switch that a large window gets
|
|
expect(component.footerHeight).toBeLessThan(size.height / 3);
|
|
expect(component.tileHeight + component.footerHeight).toBeLessThanOrEqual(
|
|
size.height,
|
|
);
|
|
});
|