diff --git a/component/dev/Harness.tsx b/component/dev/Harness.tsx index ddb69d327..141f94922 100644 --- a/component/dev/Harness.tsx +++ b/component/dev/Harness.tsx @@ -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 = {}; 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; + + return { ...DEFAULT_CREDENTIALS, ...stored, ...fromUrl }; } interface Session { @@ -88,7 +103,7 @@ const Pane: FC<{ ); return ( -
+
{session.label} {session.client.getDeviceId()} @@ -110,7 +125,7 @@ const Pane: FC<{
{/* 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 */} -
+
{mounted && ( { ))}
-
+

Host bridge

    {entries.map((entry, i) => ( diff --git a/playwright.config.ts b/playwright.config.ts index 85e65e13f..73112c7ad 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, + }, + }, + ], }); diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts new file mode 100644 index 000000000..233b826a3 --- /dev/null +++ b/playwright/component/component-call.spec.ts @@ -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, + }); +}); diff --git a/playwright/component/harness.ts b/playwright/component/harness.ts new file mode 100644 index 000000000..4602f22d8 --- /dev/null +++ b/playwright/component/harness.ts @@ -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 { + 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 { + 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, + ); +}