diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 11116ed2..164afa07 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -99,6 +99,13 @@ module.exports = { "jsdoc/require-param-description": "off", }, }, + { + files: ["playwright/**"], + rules: { + // Playwright as a `use` function that has nothing to do with React hooks. + "react-hooks/rules-of-hooks": "off", + }, + }, ], settings: { react: { diff --git a/playwright/fixtures/widget-user.ts b/playwright/fixtures/widget-user.ts index c2f8ca23..eeddda47 100644 --- a/playwright/fixtures/widget-user.ts +++ b/playwright/fixtures/widget-user.ts @@ -6,15 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - type Browser, - type Page, - test, - expect, - type JSHandle, -} from "@playwright/test"; +import { type Page, test, expect, type JSHandle } from "@playwright/test"; import type { MatrixClient } from "matrix-js-sdk"; +import { TestHelpers } from "../widget/test-helpers.ts"; export type UserBaseFixture = { mxId: string; @@ -31,10 +26,11 @@ export type BaseWidgetSetup = { export interface MyFixtures { asWidget: BaseWidgetSetup; callType: "room" | "dm"; + addUser: ( + username: string /**, homeserver: string*/, + ) => Promise; } -const PASSWORD = "foobarbaz1!"; - // Minimal config.json for the local element-web instance const CONFIG_JSON = { default_server_config: { @@ -68,85 +64,6 @@ const CONFIG_JSON = { }, }; -/** - * Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`. - */ -const setDevToolElementCallDevUrl = process.env.USE_DOCKER - ? async (page: Page): Promise => { - await page.evaluate(() => { - window.mxSettingsStore.setValue( - "Developer.elementCallUrl", - null, - "device", - "http://localhost:8080/room", - ); - }); - } - : async (page: Page): Promise => { - await page.evaluate(() => { - window.mxSettingsStore.setValue( - "Developer.elementCallUrl", - null, - "device", - "https://localhost:3000/room", - ); - }); - }; - -/** - * Registers a new user and returns page, clientHandle and mxId. - */ -async function registerUser( - browser: Browser, - username: string, -): Promise<{ page: Page; clientHandle: JSHandle; mxId: string }> { - const userContext = await browser.newContext({ - reducedMotion: "reduce", - }); - const page = await userContext.newPage(); - await page.goto("http://localhost:8081/#/welcome"); - await page.getByRole("link", { name: "Create Account" }).click(); - await page.getByRole("textbox", { name: "Username" }).fill(username); - await page - .getByRole("textbox", { name: "Password", exact: true }) - .fill(PASSWORD); - await page.getByRole("textbox", { name: "Confirm password" }).click(); - await page.getByRole("textbox", { name: "Confirm password" }).fill(PASSWORD); - await page.getByRole("button", { name: "Register" }).click(); - - await expect( - page.getByRole("heading", { name: `Welcome ${username}` }), - ).toBeVisible(); - - const browserUnsupportedToast = page - .getByText("Element does not support this browser") - .locator("..") - .locator(".."); - - // Dismiss incompatible browser toast - const dismissButton = browserUnsupportedToast.getByRole("button", { - name: "Dismiss", - }); - try { - await expect(dismissButton).toBeVisible({ timeout: 700 }); - await dismissButton.click(); - } catch { - // dismissButton not visible, continue as normal - } - - await setDevToolElementCallDevUrl(page); - - const clientHandle = await page.evaluateHandle(() => - window.mxMatrixClientPeg.get(), - ); - const mxId = (await clientHandle.evaluate( - (cli: MatrixClient) => cli.getUserId(), - clientHandle, - ))!; - - return { page, clientHandle, mxId }; -} - export const widgetTest = test.extend({ // allow per-test override: `widgetTest.use({ callType: "dm" })` callType: ["room", { option: true }], @@ -163,25 +80,16 @@ export const widgetTest = test.extend({ page: ewPage1, clientHandle: brooksClientHandle, mxId: brooksMxId, - } = await registerUser(browser, brooksDisplayName); + } = await TestHelpers.registerUser(browser, brooksDisplayName); const { page: ewPage2, clientHandle: whistlerClientHandle, mxId: whistlerMxId, - } = await registerUser(browser, whistlerDisplayName); + } = await TestHelpers.registerUser(browser, whistlerDisplayName); // Invite the second user - await ewPage1 - .getByRole("navigation", { name: "Room list" }) - .getByRole("button", { name: "New conversation" }) - .click(); - if (callType === "room") { - await ewPage1.getByRole("menuitem", { name: "New Room" }).click(); - await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room"); - await ewPage1.getByRole("button", { name: "Create room" }).click(); - await expect(ewPage1.getByText("You created this room.")).toBeVisible(); - await expect(ewPage1.getByText("Encryption enabled")).toBeVisible(); + await TestHelpers.createRoom("Welcome Room", ewPage1); await ewPage1 .getByRole("button", { name: "Invite to this room", exact: true }) @@ -211,6 +119,11 @@ export const widgetTest = test.extend({ .getByRole("heading", { name: "Welcome Room" }), ).toBeVisible(); } else if (callType === "dm") { + await ewPage1 + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); + await ewPage1.getByRole("menuitem", { name: "Start chat" }).click(); await ewPage1.getByRole("textbox", { name: "Search" }).click(); await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId); @@ -253,4 +166,28 @@ export const widgetTest = test.extend({ }, }); }, + + /** + * Provide a way to add additional users within a test. + * The returned user will be registered on the default homeserver, the name will be made unique by appending a timestamp. + */ + addUser: async ({ browser }, use) => { + await use( + async ( + username: string /**, homeserver?: string*/, + ): Promise => { + const uniqueSuffix = Date.now(); + const { page, clientHandle, mxId } = await TestHelpers.registerUser( + browser, + `${username.toLowerCase()}_${uniqueSuffix}`, + ); + return { + mxId, + displayName: username, + page, + clientHandle, + }; + }, + ); + }, }); diff --git a/playwright/widget/huddle-call.test.ts b/playwright/widget/huddle-call.test.ts new file mode 100644 index 00000000..6acf176c --- /dev/null +++ b/playwright/widget/huddle-call.test.ts @@ -0,0 +1,160 @@ +/* +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.ts"; +import { TestHelpers } from "./test-helpers.ts"; + +widgetTest("Create and join a group call", 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", + ); + + 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 roomName = "Group Call Room"; + await TestHelpers.createRoom(roomName, valere.page, [ + timo.mxId, + robin.mxId, + halfshot.mxId, + florian.mxId, + ]); + + for (const user of [timo, robin, halfshot, florian]) { + // 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(); + } + + // Start the call as Valere + await TestHelpers.startCallInCurrentRoom(valere.page, false); + await expect( + 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(); + + 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(); + } + + for (const user of [timo, robin, halfshot, florian]) { + const frame = user.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + // No lobby, should start with video on + // The only way to know if it is muted or not is to look at the data-kind attribute.. + const videoButton = frame.getByTestId("incall_videomute"); + await expect(videoButton).toBeVisible(); + // video should be off by default in a voice call + await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); + } + + // We should see 5 video tiles everywhere now + for (const user of [valere, timo, robin, halfshot, florian]) { + const frame = user.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + await expect(frame.getByTestId("videoTile")).toHaveCount(5); + for (const participant of [valere, timo, robin, halfshot, florian]) { + // Check the names are correct + await expect(frame.getByText(participant.displayName)).toBeVisible(); + } + + // There is no other options than to wait for all media to be ready? + // Or it is too flaky :/ + await user.page.waitForTimeout(5000); + // 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(5); + await expect(frame.locator("video[autoplay]")).toHaveCount(5); + + const blockDisplayCount = await frame + .locator("video") + .evaluateAll( + (videos: Element[]) => + videos.filter( + (v: Element) => window.getComputedStyle(v).display === "block", + ).length, + ); + expect(blockDisplayCount).toBe(5); + } + + // Quickly test muting one participant to see it reflects and that our asserts works + const florianFrame = florian.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + const florianMuteButton = florianFrame.getByTestId("incall_videomute"); + await florianMuteButton.click(); + // Now the button should indicate we can start video + await expect(florianMuteButton).toHaveAttribute( + "aria-label", + /^Start video$/, + ); + + // wait a bit for the state to propagate + await valere.page.waitForTimeout(3000); + { + const frame = valere.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + const videoElements = await frame.locator("video").all(); + expect(videoElements.length).toBe(5); + + const blockDisplayCount = await frame + .locator("video") + .evaluateAll( + (videos: Element[]) => + videos.filter( + (v: Element) => window.getComputedStyle(v).display === "block", + ).length, + ); + + // out of 5 ONLY 4 are visible (display:block) !! + // XXX we need to be better at our HTML markup and accessibility, it would make + // 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 5a74cbac..4ef05ef4 100644 --- a/playwright/widget/test-helpers.ts +++ b/playwright/widget/test-helpers.ts @@ -5,7 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { expect, type Page } from "@playwright/test"; +import { + type Browser, + expect, + type JSHandle, + type Page, +} from "@playwright/test"; +import { type MatrixClient } from "matrix-js-sdk"; + +const PASSWORD = "foobarbaz1!"; export class TestHelpers { public static async startCallInCurrentRoom( @@ -22,4 +30,127 @@ export class TestHelpers { await page.getByRole("menuitem", { name: "Element Call" }).click(); } + + /** + * Registers a new user and returns page, clientHandle and mxId. + */ + public static async registerUser( + browser: Browser, + username: string, + ): Promise<{ + page: Page; + clientHandle: JSHandle; + mxId: string; + }> { + const userContext = await browser.newContext({ + reducedMotion: "reduce", + }); + const page = await userContext.newPage(); + await page.goto("http://localhost:8081/#/welcome"); + await page.getByRole("link", { name: "Create Account" }).click(); + await page.getByRole("textbox", { name: "Username" }).fill(username); + await page + .getByRole("textbox", { name: "Password", exact: true }) + .fill(PASSWORD); + await page.getByRole("textbox", { name: "Confirm password" }).click(); + await page + .getByRole("textbox", { name: "Confirm password" }) + .fill(PASSWORD); + await page.getByRole("button", { name: "Register" }).click(); + + await expect( + page.getByRole("heading", { name: `Welcome ${username}` }), + ).toBeVisible({ + // Increase timeout as registration can be slow :/ + timeout: 15_000, + }); + + const browserUnsupportedToast = page + .getByText("Element does not support this browser") + .locator("..") + .locator(".."); + + // Dismiss incompatible browser toast + const dismissButton = browserUnsupportedToast.getByRole("button", { + name: "Dismiss", + }); + try { + await expect(dismissButton).toBeVisible({ timeout: 700 }); + await dismissButton.click(); + } catch { + // dismissButton not visible, continue as normal + } + + 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 }; + } + + public static async createRoom( + name: string, + page: Page, + andInvite: string[] = [], + ): Promise { + await page.pause(); + await page + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); + + await page.getByRole("menuitem", { name: "New Room" }).click(); + await page.getByRole("textbox", { name: "Name" }).fill(name); + await page.getByRole("button", { name: "Create room" }).click(); + await expect(page.getByText("You created this room.")).toBeVisible(); + await expect(page.getByText("Encryption enabled")).toBeVisible(); + + // Invite users if any + if (andInvite.length > 0) { + await page + .getByRole("button", { name: "Invite to this room", exact: true }) + .click(); + + const inviteInput = page.getByRole("dialog").getByRole("textbox"); + for (const mxId of andInvite) { + await inviteInput.focus(); + await inviteInput.fill(mxId); + await inviteInput.press("Enter"); + } + + await page.getByRole("button", { name: "Invite" }).click(); + } + } + + /** + * Sets the current Element Web app to use the dev Element Call URL. + * @param page - The EW page + */ + public static async setDevToolElementCallDevUrl(page: Page): Promise { + if (process.env.USE_DOCKER) { + await page.evaluate(() => { + window.mxSettingsStore.setValue( + "Developer.elementCallUrl", + null, + "device", + "http://localhost:8080/room", + ); + }); + } else { + await page.evaluate(() => { + window.mxSettingsStore.setValue( + "Developer.elementCallUrl", + null, + "device", + "https://localhost:3000/room", + ); + }); + } + } }