mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Cover Element Call as a component with end-to-end tests
The widget tests cannot reach what makes a component different, because the iframe used to guarantee it: that Element Call stays inside the space it was given, and that two of them can exist in one page. So the development harness gets driven by Playwright. Three tests. Two components in one page holding a real call between two devices of one account. The settings dialog and the reaction picker staying inside the container the host gave, asserted by bounding box. And the host bridge reporting in both directions, including a host-initiated mute coming back as a report of the new state. The containment test is the one worth having: both of the escapes found by running the harness by hand — the settings dialog centred on the window, and the reaction picker at 82vh landing below the container — would have failed it, and neither was visible to typechecking, linting or the unit tests. Users and rooms are created through the Synapse admin and client-server APIs rather than by driving an interface, and the harness now takes its credentials from its own query string so that a test can say which account and room to use. A host reading its own URL is proper; it was Element Call doing so that was the mistake. Playwright gains a second web server for the harness on port 3001, a Vite dev server whether or not the app itself is served from Docker, since the harness is a development page with nothing to build.
This commit is contained in:
@@ -37,16 +37,31 @@ const DEFAULT_CREDENTIALS: Credentials = {
|
||||
room: "",
|
||||
};
|
||||
|
||||
/** The last credentials used, so that a reload does not mean typing them again. */
|
||||
/**
|
||||
* The credentials to start with: the last ones used, so that a reload does not
|
||||
* mean typing them again, overridden by anything in the query string.
|
||||
*
|
||||
* A host reading its own URL is entirely proper — it was Element Call doing so
|
||||
* that was the mistake. It lets the end-to-end tests, or a shared link, say
|
||||
* which account and room to use.
|
||||
*/
|
||||
function loadCredentials(): Credentials {
|
||||
let stored: Partial<Credentials> = {};
|
||||
try {
|
||||
const stored = localStorage.getItem(CREDENTIALS_KEY);
|
||||
if (stored !== null)
|
||||
return { ...DEFAULT_CREDENTIALS, ...(JSON.parse(stored) as Credentials) };
|
||||
const json = localStorage.getItem(CREDENTIALS_KEY);
|
||||
if (json !== null) stored = JSON.parse(json) as Credentials;
|
||||
} catch (e) {
|
||||
logger.warn("Could not read the stored harness credentials", e);
|
||||
}
|
||||
return DEFAULT_CREDENTIALS;
|
||||
|
||||
const query = new URLSearchParams(location.search);
|
||||
const fromUrl = Object.fromEntries(
|
||||
(["homeserver", "username", "password", "room"] as const)
|
||||
.map((name) => [name, query.get(name)])
|
||||
.filter(([, value]) => value !== null),
|
||||
) as Partial<Credentials>;
|
||||
|
||||
return { ...DEFAULT_CREDENTIALS, ...stored, ...fromUrl };
|
||||
}
|
||||
|
||||
interface Session {
|
||||
@@ -88,7 +103,7 @@ const Pane: FC<{
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={styles.pane}>
|
||||
<section className={styles.pane} data-testid="call-pane">
|
||||
<div className={styles.paneBar}>
|
||||
<strong>{session.label}</strong>
|
||||
<code>{session.client.getDeviceId()}</code>
|
||||
@@ -110,7 +125,7 @@ const Pane: FC<{
|
||||
</div>
|
||||
{/* Resizable, because how Element Call copes with the size it is given is
|
||||
one of the things we cannot find out from the standalone app */}
|
||||
<div className={styles.paneCall}>
|
||||
<div className={styles.paneCall} data-testid="call-container">
|
||||
{mounted && (
|
||||
<ElementCall
|
||||
client={session.client}
|
||||
@@ -281,7 +296,7 @@ export const Harness: FC = (): ReactNode => {
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
<section className={styles.log}>
|
||||
<section className={styles.log} data-testid="bridge-log">
|
||||
<h2>Host bridge</h2>
|
||||
<ol>
|
||||
{entries.map((entry, i) => (
|
||||
|
||||
+26
-9
@@ -11,6 +11,8 @@ import { join } from "path";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
|
||||
|
||||
const baseURL = process.env.USE_DOCKER
|
||||
? "http://localhost:8080"
|
||||
: "https://localhost:3000";
|
||||
@@ -115,14 +117,29 @@ export default defineConfig({
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "./scripts/playwright-webserver-command.sh",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
ignoreHTTPSErrors: true,
|
||||
gracefulShutdown: {
|
||||
signal: "SIGTERM",
|
||||
timeout: 500,
|
||||
webServer: [
|
||||
{
|
||||
command: "./scripts/playwright-webserver-command.sh",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
ignoreHTTPSErrors: true,
|
||||
gracefulShutdown: {
|
||||
signal: "SIGTERM",
|
||||
timeout: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// The harness that embeds Element Call as a component. Always a Vite dev
|
||||
// server, whether or not the app itself is being served from Docker,
|
||||
// since there is nothing to build: it is a development page only.
|
||||
command: "pnpm dev:component",
|
||||
url: COMPONENT_HARNESS_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
ignoreHTTPSErrors: true,
|
||||
gracefulShutdown: {
|
||||
signal: "SIGTERM",
|
||||
timeout: 500,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
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 { createUserAndRoom, expectWithin, startHarness } from "./harness.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.
|
||||
*/
|
||||
|
||||
/** 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");
|
||||
|
||||
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("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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
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 } from "@playwright/test";
|
||||
|
||||
import { SynapseAdmin } from "../utils/synapse-admin.ts";
|
||||
|
||||
/**
|
||||
* Where the component harness is served — `component/dev`, which embeds Element
|
||||
* Call the way a host application would. Not the `baseURL` the rest of the
|
||||
* suite uses: these tests drive a page that contains Element Call rather than
|
||||
* Element Call itself.
|
||||
*/
|
||||
export const COMPONENT_HARNESS_URL = "https://localhost:3001";
|
||||
|
||||
const HOMESERVER_URL = "https://synapse.m.localhost";
|
||||
const PASSWORD = "foobarbaz1!";
|
||||
|
||||
/**
|
||||
* Registers a user through the Synapse admin API and creates a room for it to
|
||||
* call in, without touching a browser. The harness signs into this account
|
||||
* twice, giving two devices in one page and so a real call between the two
|
||||
* components.
|
||||
*/
|
||||
export async function createUserAndRoom(
|
||||
name: string,
|
||||
): Promise<{ username: string; roomId: string }> {
|
||||
const username = `${name}_${Date.now()}`;
|
||||
const { access_token: accessToken } = await SynapseAdmin.forHomeserver(
|
||||
HOMESERVER_URL,
|
||||
).registerUser(username, PASSWORD, name);
|
||||
|
||||
const response = await fetch(
|
||||
`${HOMESERVER_URL}/_matrix/client/v3/createRoom`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name: `${name}'s call`, preset: "private_chat" }),
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Could not create a room: ${response.status} ${await response.text()}`,
|
||||
);
|
||||
const { room_id: roomId } = (await response.json()) as { room_id: string };
|
||||
|
||||
return { username, roomId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the harness signed in as the given user, and waits for both embedded
|
||||
* calls to appear.
|
||||
*
|
||||
* @returns The two containers the host gave Element Call, in order.
|
||||
*/
|
||||
export async function startHarness(
|
||||
page: Page,
|
||||
username: string,
|
||||
roomId: string,
|
||||
): Promise<Locator> {
|
||||
const query = new URLSearchParams({
|
||||
homeserver: HOMESERVER_URL,
|
||||
username,
|
||||
password: PASSWORD,
|
||||
room: roomId,
|
||||
});
|
||||
await page.goto(`${COMPONENT_HARNESS_URL}/?${query.toString()}`);
|
||||
await page.getByRole("button", { name: "Start" }).click();
|
||||
|
||||
const panes = page.getByTestId("call-pane");
|
||||
// Two logins, two crypto setups and two initial syncs happen first
|
||||
await expect(panes).toHaveCount(2, { timeout: 120_000 });
|
||||
return panes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that one element is drawn entirely inside another.
|
||||
*
|
||||
* This is the check that being a component rather than an iframe costs us: an
|
||||
* iframe could not paint outside itself whatever its stylesheets said, whereas
|
||||
* a component shares the page and has to be made to stay put.
|
||||
*/
|
||||
export async function expectWithin(
|
||||
inner: Locator,
|
||||
outer: Locator,
|
||||
): Promise<void> {
|
||||
await expect(inner).toBeVisible();
|
||||
const innerBox = await inner.boundingBox();
|
||||
const outerBox = await outer.boundingBox();
|
||||
if (innerBox === null || outerBox === null)
|
||||
throw new Error("Expected both elements to be laid out");
|
||||
|
||||
// A pixel of slack, for subpixel layout
|
||||
const slack = 1;
|
||||
expect(innerBox.x).toBeGreaterThanOrEqual(outerBox.x - slack);
|
||||
expect(innerBox.y).toBeGreaterThanOrEqual(outerBox.y - slack);
|
||||
expect(innerBox.x + innerBox.width).toBeLessThanOrEqual(
|
||||
outerBox.x + outerBox.width + slack,
|
||||
);
|
||||
expect(innerBox.y + innerBox.height).toBeLessThanOrEqual(
|
||||
outerBox.y + outerBox.height + slack,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user