From 0c293bbbd0e1b8c33df516b688f6b2b6adc4d799 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Mon, 1 Dec 2025 09:21:32 +0000 Subject: [PATCH] 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 --- playwright/e2e/voip/element-call.spec.ts | 163 +++++++++++++++++- .../sample-files/fake-element-call.html | 87 ++++++++++ 2 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 playwright/sample-files/fake-element-call.html diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts index 846fd5d2ba..e52d781ac7 100644 --- a/playwright/e2e/voip/element-call.spec.ts +++ b/playwright/e2e/voip/element-call.spec.ts @@ -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: "

Hello world

", + // 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); + }, + ); + }); }); diff --git a/playwright/sample-files/fake-element-call.html b/playwright/sample-files/fake-element-call.html new file mode 100644 index 0000000000..a0fc1f8689 --- /dev/null +++ b/playwright/sample-files/fake-element-call.html @@ -0,0 +1,87 @@ + + + + + + +
+

Fake Element Call

+

State: Loading

+ + +
+ + +