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 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
|
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.
|
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 type { EventType, Preset } from "matrix-js-sdk/src/matrix";
|
||||||
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||||
import { test, expect } from "../../element-web-test";
|
import { test, expect } from "../../element-web-test";
|
||||||
import type { Credentials } from "../../plugins/homeserver";
|
import type { Credentials } from "../../plugins/homeserver";
|
||||||
import { Bot } from "../../pages/bot";
|
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(
|
function assertCommonCallParameters(
|
||||||
url: URLSearchParams,
|
url: URLSearchParams,
|
||||||
hash: URLSearchParams,
|
hash: URLSearchParams,
|
||||||
@@ -89,11 +99,13 @@ test.describe("Element Call", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async ({ page, user, app }) => {
|
test.beforeEach(async ({ page, user, app }) => {
|
||||||
// Mock a widget page. It doesn't need to actually be Element Call.
|
// Mock a widget page. We use a fake version of Element Call here.
|
||||||
await page.route("/widget.html", async (route) => {
|
// 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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
body: "<p> Hello world </p>",
|
// Do enough to
|
||||||
|
body: (await fakeCallClient).replace("widgetCodeHere", await widgetApi),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await app.settings.setValue(
|
await app.settings.setValue(
|
||||||
@@ -419,4 +431,147 @@ test.describe("Element Call", () => {
|
|||||||
expect(hash.get("returnToLobby")).toEqual("true");
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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