diff --git a/backend/dev_nginx.conf b/backend/dev_nginx.conf index be015060..d3ddbc53 100644 --- a/backend/dev_nginx.conf +++ b/backend/dev_nginx.conf @@ -181,7 +181,10 @@ server { } -# Convenience reverse proxy for the call.m.localhost domain to yarn dev --host +# Convenience reverse proxy for the call.m.localhost domain to element call +# running on the host either via +# - yarn dev --host or +# - falling back to http (the element call docker container) server { listen 80; listen [::]:80; @@ -197,7 +200,7 @@ server { ssl_certificate /root/ssl/cert.pem; ssl_certificate_key /root/ssl/key.pem; - + # 1. Attempt HTTPS first location ^~ / { proxy_set_header Host $host; @@ -208,8 +211,23 @@ server { proxy_pass https://host.docker.internal:3000; proxy_ssl_verify off; + # 2. Redirect specific errors (e.g., 502 Bad Gateway or 504 Timeout) + # to the named fallback location + error_page 502 503 504 = @http_fallback; + + } + + # 3. Fallback location using HTTP + location @http_fallback { + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_pass http://host.docker.internal:8080; + } - error_page 500 502 503 504 /50x.html; } diff --git a/playwright/fixtures/widget-user.ts b/playwright/fixtures/widget-user.ts index eeddda47..31422fcc 100644 --- a/playwright/fixtures/widget-user.ts +++ b/playwright/fixtures/widget-user.ts @@ -9,7 +9,7 @@ Please see LICENSE in the repository root for full details. import { type Page, test, expect, type JSHandle } from "@playwright/test"; import type { MatrixClient } from "matrix-js-sdk"; -import { TestHelpers } from "../widget/test-helpers.ts"; +import { HOST1, TestHelpers } from "../widget/test-helpers.ts"; export type UserBaseFixture = { mxId: string; @@ -26,9 +26,7 @@ export type BaseWidgetSetup = { export interface MyFixtures { asWidget: BaseWidgetSetup; callType: "room" | "dm"; - addUser: ( - username: string /**, homeserver: string*/, - ) => Promise; + addUser: (username: string, host: string) => Promise; } // Minimal config.json for the local element-web instance @@ -174,12 +172,14 @@ export const widgetTest = test.extend({ addUser: async ({ browser }, use) => { await use( async ( - username: string /**, homeserver?: string*/, + username: string, + host: string = HOST1, ): Promise => { const uniqueSuffix = Date.now(); const { page, clientHandle, mxId } = await TestHelpers.registerUser( browser, `${username.toLowerCase()}_${uniqueSuffix}`, + host, ); return { mxId, diff --git a/playwright/widget/federated-call.test.ts b/playwright/widget/federated-call.test.ts new file mode 100644 index 00000000..51ed1b41 --- /dev/null +++ b/playwright/widget/federated-call.test.ts @@ -0,0 +1,80 @@ +/* +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, test } from "@playwright/test"; + +import { widgetTest } from "../fixtures/widget-user"; +import { HOST1, HOST2, type RtcMode, TestHelpers } from "./test-helpers"; + +const modePairs: [RtcMode, RtcMode][] = [ + ["compat", "compat"], + ["legacy", "legacy"], + ["legacy", "compat"], + ["compat", "legacy"], +]; + +modePairs.forEach(([rtcMode1, rtcMode2]) => { + widgetTest( + `Test federated call with rtc modes ${rtcMode1} and ${rtcMode2}`, + async ({ addUser, browserName }) => { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); + + const florian = await addUser("floriant", HOST1); + const timo = await addUser("timo", HOST2); + + const roomName = "Call Room"; + + await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]); + + await TestHelpers.acceptRoomInvite(roomName, timo.page); + + await florian.page.pause(); + + await TestHelpers.setEmbeddedElementCallRtcMode(florian.page, rtcMode1); + await TestHelpers.setEmbeddedElementCallRtcMode(timo.page, rtcMode2); + + await TestHelpers.startCallInCurrentRoom(florian.page, false); + await TestHelpers.joinCallFromLobby(florian.page); + + // timo joins + await TestHelpers.joinCallInCurrentRoom(timo.page); + + // We should see 2 video tiles everywhere now + for (const user of [timo, florian]) { + const frame = user.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + await expect(frame.getByTestId("videoTile")).toHaveCount(2); + + // There are no other options than to wait for all media to be ready? + // Or it is too flaky :/ + await user.page.waitForTimeout(3000); + // No one should be waiting for media + await expect(frame.getByText("Waiting for media...")).not.toBeVisible(); + + // There should be 5 video elements, visible and autoplaying + const videoElements = await frame.locator("video").all(); + expect(videoElements.length).toBe(2); + + const blockDisplayCount = await frame + .locator("video") + .evaluateAll( + (videos: Element[]) => + videos.filter( + (v: Element) => window.getComputedStyle(v).display === "block", + ).length, + ); + expect(blockDisplayCount).toBe(2); + } + + // await florian.page.pause(); + }, + ); +}); diff --git a/playwright/widget/huddle-call.test.ts b/playwright/widget/huddle-call.test.ts index 6acf176c..b42c0ab2 100644 --- a/playwright/widget/huddle-call.test.ts +++ b/playwright/widget/huddle-call.test.ts @@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details. import { expect, test } from "@playwright/test"; import { widgetTest } from "../fixtures/widget-user.ts"; -import { TestHelpers } from "./test-helpers.ts"; +import { HOST1, TestHelpers } from "./test-helpers.ts"; widgetTest("Create and join a group call", async ({ addUser, browserName }) => { test.skip( @@ -18,11 +18,11 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { test.slow(); // We are registering multiple users here, give it more time - const valere = await addUser("Valere"); - const timo = await addUser("Timo"); - const robin = await addUser("Robin"); - const halfshot = await addUser("Halfshot"); - const florian = await addUser("florian"); + const valere = await addUser("Valere", HOST1); + const timo = await addUser("Timo", HOST1); + const robin = await addUser("Robin", HOST1); + const halfshot = await addUser("Halfshot", HOST1); + const florian = await addUser("florian", HOST1); const roomName = "Group Call Room"; await TestHelpers.createRoom(roomName, valere.page, [ @@ -36,15 +36,7 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { // Accept the invite // This isn't super stable to get this as this super generic locator, // but it works for now. - await expect( - user.page.getByRole("option", { name: roomName }), - ).toBeVisible(); - await user.page.getByRole("option", { name: roomName }).click(); - await user.page.getByRole("button", { name: "Accept" }).click(); - - await expect( - user.page.getByRole("main").getByRole("heading", { name: roomName }), - ).toBeVisible(); + await TestHelpers.acceptRoomInvite(roomName, user.page); } // Start the call as Valere @@ -53,24 +45,10 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { valere.page.locator('iframe[title="Element Call"]'), ).toBeVisible(); - await expect( - valere.page - .locator('iframe[title="Element Call"]') - .contentFrame() - .getByTestId("lobby_joinCall"), - ).toBeVisible(); - - await valere.page - .locator('iframe[title="Element Call"]') - .contentFrame() - .getByTestId("lobby_joinCall") - .click(); + await TestHelpers.joinCallFromLobby(valere.page); for (const user of [timo, robin, halfshot, florian]) { - // THis is the header button that notifies about an ongoing call - await expect(user.page.getByText("Video call started")).toBeVisible(); - await expect(user.page.getByRole("button", { name: "Join" })).toBeVisible(); - await user.page.getByRole("button", { name: "Join" }).click(); + await TestHelpers.joinCallInCurrentRoom(user.page); } for (const user of [timo, robin, halfshot, florian]) { @@ -155,6 +133,4 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { // this kind of stuff way easier to test if we could look out for aria attributes. expect(blockDisplayCount).toBe(4); } - - await valere.page.pause(); }); diff --git a/playwright/widget/test-helpers.ts b/playwright/widget/test-helpers.ts index 4ef05ef4..ff3cb121 100644 --- a/playwright/widget/test-helpers.ts +++ b/playwright/widget/test-helpers.ts @@ -15,6 +15,11 @@ import { type MatrixClient } from "matrix-js-sdk"; const PASSWORD = "foobarbaz1!"; +export const HOST1 = "https://app.m.localhost/#/welcome"; +export const HOST2 = "https://app.othersite.m.localhost/#/welcome"; + +export type RtcMode = "legacy" | "compat" | "2_0"; + export class TestHelpers { public static async startCallInCurrentRoom( page: Page, @@ -31,12 +36,39 @@ export class TestHelpers { await page.getByRole("menuitem", { name: "Element Call" }).click(); } + public static async joinCallFromLobby(page: Page): Promise { + await expect( + page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByTestId("lobby_joinCall"), + ).toBeVisible(); + + await page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByTestId("lobby_joinCall") + .click(); + } + + public static async joinCallInCurrentRoom( + page: Page, + audioOnly: boolean = false, + ): Promise { + // This is the header button that notifies about an ongoing call + const label = audioOnly ? "Voice call started" : "Video call started"; + await expect(page.getByText(label)).toBeVisible(); + await expect(page.getByRole("button", { name: "Join" })).toBeVisible(); + await page.getByRole("button", { name: "Join" }).click(); + } + /** * Registers a new user and returns page, clientHandle and mxId. */ public static async registerUser( browser: Browser, username: string, + host: string = HOST1, ): Promise<{ page: Page; clientHandle: JSHandle; @@ -46,9 +78,10 @@ export class TestHelpers { reducedMotion: "reduce", }); const page = await userContext.newPage(); - await page.goto("http://localhost:8081/#/welcome"); + await page.goto(host); await page.getByRole("link", { name: "Create Account" }).click(); await page.getByRole("textbox", { name: "Username" }).fill(username); + await page.getByRole("textbox", { name: "Password", exact: true }).click(); await page .getByRole("textbox", { name: "Password", exact: true }) .fill(PASSWORD); @@ -65,6 +98,25 @@ export class TestHelpers { timeout: 15_000, }); + await this.maybeDismissBrowserNotSupportedToast(page); + await this.maybeDismissServiceWorkerWarningToast(page); + + await TestHelpers.setDevToolElementCallDevUrl(page); + + const clientHandle = await page.evaluateHandle(() => + window.mxMatrixClientPeg.get(), + ); + const mxId = (await clientHandle.evaluate( + (cli: MatrixClient) => cli.getUserId(), + clientHandle, + ))!; + + return { page, clientHandle, mxId }; + } + + private static async maybeDismissBrowserNotSupportedToast( + page: Page, + ): Promise { const browserUnsupportedToast = page .getByText("Element does not support this browser") .locator("..") @@ -80,18 +132,24 @@ export class TestHelpers { } catch { // dismissButton not visible, continue as normal } + } - await TestHelpers.setDevToolElementCallDevUrl(page); + private static async maybeDismissServiceWorkerWarningToast( + page: Page, + ): Promise { + const toast = page + .locator(".mx_Toast_toast") + .getByText("Failed to load service worker"); - const clientHandle = await page.evaluateHandle(() => - window.mxMatrixClientPeg.get(), - ); - const mxId = (await clientHandle.evaluate( - (cli: MatrixClient) => cli.getUserId(), - clientHandle, - ))!; - - return { page, clientHandle, mxId }; + try { + await expect(toast).toBeVisible({ timeout: 700 }); + await page + .locator(".mx_Toast_toast") + .getByRole("button", { name: "OK" }) + .click(); + } catch { + // toast not visible, continue as normal + } } public static async createRoom( @@ -99,7 +157,6 @@ export class TestHelpers { page: Page, andInvite: string[] = [], ): Promise { - await page.pause(); await page .getByRole("navigation", { name: "Room list" }) .getByRole("button", { name: "New conversation" }) @@ -128,6 +185,58 @@ export class TestHelpers { } } + /** + * Accepts a room invite using the room name. + * Locatest the invite in the room list. + * + */ + public static async acceptRoomInvite( + roomName: string, + page: Page, + ): Promise { + await expect(page.getByRole("option", { name: roomName })).toBeVisible(); + await page.getByRole("option", { name: roomName }).click(); + await page.getByRole("button", { name: "Accept" }).click(); + + await expect( + page.getByRole("main").getByRole("heading", { name: roomName }), + ).toBeVisible(); + } + + /** + * Opens the widget and then goes to the settings to set the RTC mode. + * then closes the widget lobby. + * + * WORKS IF A ROOM IS CURRENTLY OPENED IN THE PAGE + */ + public static async setEmbeddedElementCallRtcMode( + page: Page, + mode: RtcMode, + ): Promise { + await page.getByRole("button", { name: "Video call" }).click(); + await page.getByRole("menuitem", { name: "Element Call" }).click(); + const iframe = page.locator('iframe[title="Element Call"]').contentFrame(); + + await iframe.getByRole("button", { name: "Settings" }).click(); + await iframe.getByRole("tab", { name: "Preferences" }).click(); + + // await iframe.getByText("Developer mode", { exact: true }).click(); + await iframe.getByText("Developer mode", { exact: true }).check(); // Idempotent: won't uncheck if already checked + + // Move to Developer tab now + await iframe.getByRole("tab", { name: "Developer" }).click(); + if (mode == "legacy") { + await iframe.getByText("Legacy: state events").click(); + } else if (mode == "2_0") { + await iframe.getByText("Matrix 2.0").click(); + } else { + // compat + await iframe.getByText("Compatibility: state events").click(); + } + await iframe.getByTestId("modal_close").click(); + await page.getByRole("button", { name: "Close lobby" }).click(); + } + /** * Sets the current Element Web app to use the dev Element Call URL. * @param page - The EW page @@ -139,7 +248,7 @@ export class TestHelpers { "Developer.elementCallUrl", null, "device", - "http://localhost:8080/room", + "https://call.m.localhost/room", ); }); } else {