merge: upstream Element Call v0.28.0 (cinny #201) #61

Merged
jared merged 197 commits from lotus-upstream-v0.28.0 into lotus 2026-10-24 01:14:32 -04:00
4 changed files with 261 additions and 17 deletions
Showing only changes of commit ace78de749 - Show all commits
+23 -8
View File
@@ -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
View File
@@ -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,
},
},
],
});
+102
View File
@@ -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,
});
});
+110
View File
@@ -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,
);
}