Implement Playwright tests to ensure calls persist across room switches (#31354)

* Add a fake ecall page

* Start to setup a test to check PiP works

* Complete test file

* cleanup

* lint

* use test fail

* lint again

* remove fake

* Fix flake

* better comment
This commit is contained in:
Will Hunt
2025-12-01 09:21:32 +00:00
committed by GitHub
parent 0577e245da
commit 0c293bbbd0
2 changed files with 246 additions and 4 deletions

View File

@@ -1,16 +1,26 @@
/*
Copyright 2025 New Vector Ltd.
Copyright (C) 2025 Element Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { readFile } from "node:fs/promises";
import { type Page } from "playwright-core";
import type { EventType, Preset } from "matrix-js-sdk/src/matrix";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { test, expect } from "../../element-web-test";
import type { Credentials } from "../../plugins/homeserver";
import { Bot } from "../../pages/bot";
// Load a copy of our fake Element Call app, and the latest widget API.
// The fake call app does *just* enough to convince Element Web that a call is ongoing
// and functions like PiP work. It does not actually do anything though, to limit the
// surface we test.
const widgetApi = readFile("node_modules/matrix-widget-api/dist/api.min.js", "utf-8");
const fakeCallClient = readFile("playwright/sample-files/fake-element-call.html", "utf-8");
function assertCommonCallParameters(
url: URLSearchParams,
hash: URLSearchParams,
@@ -89,11 +99,13 @@ test.describe("Element Call", () => {
});
test.beforeEach(async ({ page, user, app }) => {
// Mock a widget page. It doesn't need to actually be Element Call.
await page.route("/widget.html", async (route) => {
// Mock a widget page. We use a fake version of Element Call here.
// We should match on things after .html as these widgets get a ton of extra params.
await page.route(/\/widget.html.+/, async (route) => {
await route.fulfill({
status: 200,
body: "<p> Hello world </p>",
// Do enough to
body: (await fakeCallClient).replace("widgetCodeHere", await widgetApi),
});
});
await app.settings.setValue(
@@ -419,4 +431,147 @@ test.describe("Element Call", () => {
expect(hash.get("returnToLobby")).toEqual("true");
});
});
test.describe("Switching rooms", () => {
let charlie: Bot;
test.use({
room: async ({ page, app, user, homeserver, bot }, use) => {
charlie = new Bot(page, homeserver, { displayName: "Charlie" });
await charlie.prepareClient();
const roomId = await app.client.createRoom({
name: "TestRoom",
invite: [bot.credentials.userId, charlie.credentials.userId],
});
await app.client.createRoom({
name: "OtherRoom",
});
await use({ roomId });
},
});
async function openAndJoinCall(page: Page, existing = false) {
if (existing) {
await page.getByTestId("join-call-button").click();
} else {
await page.getByRole("button", { name: "Video call" }).click();
await page.getByRole("menuitem", { name: "Element Call" }).click();
}
const iframe = page.locator("iframe");
await expect(iframe).toBeVisible();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
const callFrame = page.frame({ url: frameUrlStr });
await callFrame.getByRole("button", { name: "Join Call" }).click();
await expect(callFrame.getByText("In call", { exact: true })).toBeVisible();
// Wait for Element Web to pickup the RTC session and update the room list entry.
await expect(await page.getByTestId("notification-decoration")).toBeVisible();
}
test("should be able to switch rooms and have the call persist", async ({ page, user, room, app }) => {
await app.viewRoomById(room.roomId);
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await openAndJoinCall(page);
await app.viewRoomByName("OtherRoom");
// We should have a PiP container here.
await expect(page.locator(".mx_AppTile_persistedWrapper")).toBeVisible();
});
test("should be able to start a call, close it via PiP, and start again in the same room", async ({
page,
user,
room,
app,
}) => {
await app.viewRoomById(room.roomId);
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await openAndJoinCall(page);
await app.viewRoomByName("OtherRoom");
const pipContainer = page.locator(".mx_WidgetPip");
// We should have a PiP container here.
await expect(pipContainer).toBeVisible();
// Leave the call.
const overlay = page.locator(".mx_WidgetPip_overlay");
await overlay.hover({ timeout: 2000 }); // Show the call footer.
await overlay.getByRole("button", { name: "Leave", exact: true }).click();
// PiP container goes.
await expect(pipContainer).not.toBeVisible();
// Wait for call to stop.
await expect(await page.getByTestId("notification-decoration")).not.toBeVisible();
await app.viewRoomById(room.roomId);
await expect(await page.getByTestId("join-call-button")).not.toBeVisible();
// Join the call again.
await openAndJoinCall(page);
});
test("should be able to start a call, close it via PiP, and start again in a different room", async ({
page,
user,
room,
app,
}) => {
await app.viewRoomById(room.roomId);
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await openAndJoinCall(page);
await app.viewRoomByName("OtherRoom");
const pipContainer = page.locator(".mx_WidgetPip");
// We should have a PiP container here.
await expect(pipContainer).toBeVisible();
// Leave the call.
const overlay = page.locator(".mx_WidgetPip_overlay");
await overlay.hover({ timeout: 2000 }); // Show the call footer.
await overlay.getByRole("button", { name: "Leave", exact: true }).click();
// PiP container goes.
await expect(pipContainer).not.toBeVisible();
// Wait for call to stop.
await expect(await page.getByTestId("notification-decoration")).not.toBeVisible();
await expect(await page.getByTestId("join-call-button")).not.toBeVisible();
// Join the call again, but from the other room.
await openAndJoinCall(page);
});
// For https://github.com/element-hq/element-web/issues/30838
test.fail(
"should be able to join a call, leave via PiP, and rejoin the call",
async ({ page, user, room, app, bot }) => {
await app.viewRoomById(room.roomId);
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
await sendRTCState(bot, room.roomId);
await openAndJoinCall(page, true);
await app.viewRoomByName("OtherRoom");
const pipContainer = page.locator(".mx_WidgetPip");
// We should have a PiP container here.
await expect(pipContainer).toBeVisible();
// Leave the call.
const overlay = page.locator(".mx_WidgetPip_overlay");
await overlay.hover({ timeout: 2000 }); // Show the call footer.
await overlay.getByRole("button", { name: "Leave", exact: true }).click();
// PiP container goes.
await expect(pipContainer).not.toBeVisible();
// Rejoin the call
await app.viewRoomById(room.roomId);
await openAndJoinCall(page, true);
},
);
});
});

View File

@@ -0,0 +1,87 @@
<!doctype html>
<style>
body {
background: rgb(139, 192, 253);
}
</style>
<!-- element-call.spec.ts will insert the widget API in this block -->
<script>
widgetCodeHere;
</script>
<div>
<p>Fake Element Call</p>
<p>State: <span id="state">Loading</span></p>
<button id="join-button">Join Call</button>
<button id="close-button">Close</button>
</div>
<!-- Minimal fake implementation of Element Call. Just enough for testing persistent widgets.-->
<script>
const content = {
"application": "m.call",
"call_id": "",
"device_id": "gycSobuY0z",
"expires": 14400000,
"foci_preferred": [
{
livekit_alias: "any-alias",
livekit_service_url: "https://example.org",
type: "livekit",
},
],
"focus_active": {
focus_selection: "oldest_membership",
type: "livekit",
},
"m.call.intent": "video",
"scope": "m.room",
};
const stateIndicator = document.querySelector("#state");
const { WidgetApi, WidgetApiToWidgetAction, MatrixCapabilities } = mxwidgets();
const widgetId = new URLSearchParams(window.location.search).get("widgetId");
const params = new URLSearchParams(window.location.hash.slice(1));
const userId = params.get("userId");
const deviceId = params.get("deviceId");
const roomId = params.get("roomId");
const api = new WidgetApi(widgetId, "*");
const stateKey = `_${userId}_${deviceId}_m.call`;
async function hangup() {
await api.sendStateEvent("org.matrix.msc3401.call.member", stateKey, {}, roomId);
await api.setAlwaysOnScreen(false);
await api.transport.send("io.element.close", {});
stateIndicator.innerHTML = "Ended";
}
document.querySelector("#join-button").onclick = async () => {
await api.setAlwaysOnScreen(true);
await api.transport.send("io.element.join", {});
await api.sendStateEvent("org.matrix.msc3401.call.member", stateKey, content, roomId);
stateIndicator.innerHTML = "In call";
};
document.querySelector("#close-button").onclick = () => {
hangup();
};
api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
api.requestCapability(`org.matrix.msc2762.timeline:${roomId}`);
api.requestCapabilityToSendState("org.matrix.msc3401.call.member", stateKey);
api.on("ready", (ev) => {
stateIndicator.innerHTML = "Ready";
// Pretend to join a call.
});
api.on("action:im.vector.hangup", async () => {
await hangup();
});
// Start the messaging
api.start();
// If waitForIframeLoad is false, tell the client that we're good to go
api.sendContentLoaded();
</script>