Files

286 lines
12 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,
});
// Element Call's stylesheet is written for a page of its own: normalize.css
// gives `html` a line height, Compound gives `body` its font and feature
// settings, and the design tokens live on `:root`. None of that may reach the
// host's document — the harness sets none of these itself, so anything other
// than the browser's defaults here came from us.
const host = await page.evaluate(() => {
const html = getComputedStyle(document.documentElement);
const body = getComputedStyle(document.body);
return {
lineHeight: html.lineHeight,
fontFeatureSettings: body.fontFeatureSettings,
token: html.getPropertyValue("--cpd-color-text-primary"),
};
});
expect(host).toEqual({
lineHeight: "normal",
fontFeatureSettings: "normal",
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,
);
});