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:
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
87
playwright/sample-files/fake-element-call.html
Normal file
87
playwright/sample-files/fake-element-call.html
Normal 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>
|
||||
Reference in New Issue
Block a user