diff --git a/.gitignore b/.gitignore index 938fe508..6481b5e8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ yarn-error.log /test-results/ /playwright-report/ /blob-report/ -/playwright/.cache/ +/playwright/.cache/ \ No newline at end of file diff --git a/WIDGET_TEST.md b/WIDGET_TEST.md new file mode 100644 index 00000000..53e26a29 --- /dev/null +++ b/WIDGET_TEST.md @@ -0,0 +1,28 @@ +# Testing Element-Call in widget mode + +When running `yarn backend` the latest element-web develop will be deployed and served on `http://localhost:8081`. +In a development environment, you might prefer to just use the `element-web` repo directly, but this setup is useful for CI/CD testing. + +## Setup + +The element-web configuration is modified to: + +- Enable to use the local widget instance (`element_call.url` https://localhost:3000). +- Enable the labs features (`feature_group_calls`, `feature_element_call_video_rooms`). + +The default configuration used by docker-compose is in `test-container/config.json`. There is a fixture for playwright +that uses + +## Running the element-web instance + +It is part of the existing backend setup. To start the backend, run: + +```sh +yarn backend +``` + +Then open `http://localhost:8081` in your browser. + +## Basic fixture + +A base fixture is provided in `/playwright/fixtures/widget-user.ts` that will register two users that shares a room. diff --git a/backend/ew.test.config.json b/backend/ew.test.config.json new file mode 100644 index 00000000..7fe0c63f --- /dev/null +++ b/backend/ew.test.config.json @@ -0,0 +1,54 @@ +{ + "default_server_config": { + "m.homeserver": { + "base_url": "http://synapse.localhost:8008", + "server_name": "synapse.localhost" + } + }, + "disable_custom_urls": false, + "disable_guests": false, + "disable_login_language_selector": false, + "disable_3pid_login": false, + "force_verification": false, + "brand": "Element", + "integrations_ui_url": "https://scalar.vector.im/", + "integrations_rest_url": "https://scalar.vector.im/api", + "integrations_widgets_urls": [ + "https://scalar.vector.im/_matrix/integrations/v1", + "https://scalar.vector.im/api", + "https://scalar-staging.vector.im/_matrix/integrations/v1", + "https://scalar-staging.vector.im/api", + "https://scalar-staging.riot.im/scalar/api" + ], + "default_widget_container_height": 280, + "default_country_code": "GB", + "show_labs_settings": false, + "features": { + "feature_element_call_video_rooms": true, + "feature_video_rooms": true, + "feature_group_calls": true, + "feature_release_announcement": false + }, + "default_federate": true, + "default_theme": "light", + "room_directory": { + "servers": ["matrix.org"] + }, + "enable_presence_by_hs_url": { + "https://matrix.org": false, + "https://matrix-client.matrix.org": false + }, + "setting_defaults": { + "breadcrumbs": true, + "feature_group_calls": true + }, + "jitsi": { + "preferred_domain": "meet.element.io" + }, + "element_call": { + "url": "https://localhost:3000", + "participant_limit": 8, + "brand": "Element Call" + }, + "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx" +} diff --git a/dev-backend-docker-compose.yml b/dev-backend-docker-compose.yml index dcfb8d66..fa07afde 100644 --- a/dev-backend-docker-compose.yml +++ b/dev-backend-docker-compose.yml @@ -68,6 +68,17 @@ services: networks: - ecbackend + element-web: + image: ghcr.io/element-hq/element-web:develop + volumes: + - ./backend/ew.test.config.json:/app/config.json + environment: + ELEMENT_WEB_PORT: 81 + ports: + - "8081:81" + networks: + - ecbackend + nginx: # openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout tls_localhost_key.pem -out tls_localhost_cert.pem -subj "/C=GB/ST=London/L=London/O=Alros/OU=IT Department/CN=localhost" hostname: synapse.localhost diff --git a/playwright-backend-docker-compose.yml b/playwright-backend-docker-compose.yml index fed10fe8..e5cf12b5 100644 --- a/playwright-backend-docker-compose.yml +++ b/playwright-backend-docker-compose.yml @@ -51,6 +51,17 @@ services: networks: - ecbackend + element-web: + image: ghcr.io/element-hq/element-web:develop + volumes: + - ./backend/ew.test.config.json:/app/config.json + environment: + ELEMENT_WEB_PORT: 81 + ports: + - "8081:81" + networks: + - ecbackend + synapse: hostname: homeserver image: docker.io/matrixdotorg/synapse:latest diff --git a/playwright/fixtures/widget-user.ts b/playwright/fixtures/widget-user.ts new file mode 100644 index 00000000..391a2dd6 --- /dev/null +++ b/playwright/fixtures/widget-user.ts @@ -0,0 +1,169 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +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/src"; + +export type UserBaseFixture = { + mxId: string; + page: Page; + clientHandle: JSHandle; +}; + +export type BaseWidgetSetup = { + brooks: UserBaseFixture; + whistler: UserBaseFixture; +}; + +export interface MyFixtures { + asWidget: BaseWidgetSetup; +} + +const PASSWORD = "foobarbaz1!"; + +// Minimal config.json for the local element-web instance +const CONFIG_JSON = { + default_server_config: { + "m.homeserver": { + base_url: "http://synapse.localhost:8008", + server_name: "synapse.localhost", + }, + }, + + element_call: { + url: "https://localhost:3000", + participant_limit: 8, + brand: "Element Call", + }, + + // The default language is set here for test consistency + setting_defaults: { + language: "en-GB", + feature_group_calls: true, + }, + + // the location tests want a map style url. + map_style_url: + "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx", + + features: { + // We don't want to go through the feature announcement during the e2e test + feature_release_announcement: false, + feature_element_call_video_rooms: true, + feature_video_rooms: true, + feature_group_calls: true, + }, +}; + +export const widgetTest = test.extend({ + asWidget: async ({ browser, context }, pUse) => { + await context.route(`http://localhost:8081/config.json*`, async (route) => { + await route.fulfill({ json: CONFIG_JSON }); + }); + + const userA = `brooks_${Date.now()}`; + const userB = `whistler_${Date.now()}`; + + const user1Context = await browser.newContext({ + reducedMotion: "reduce", + }); + const ewPage1 = await user1Context.newPage(); + // Register the first user + await ewPage1.goto("http://localhost:8081/#/welcome"); + await ewPage1.getByRole("link", { name: "Create Account" }).click(); + await ewPage1.getByRole("textbox", { name: "Username" }).fill(userA); + await ewPage1 + .getByRole("textbox", { name: "Password", exact: true }) + .fill(PASSWORD); + await ewPage1.getByRole("textbox", { name: "Confirm password" }).click(); + await ewPage1 + .getByRole("textbox", { name: "Confirm password" }) + .fill(PASSWORD); + await ewPage1.getByRole("button", { name: "Register" }).click(); + await expect( + ewPage1.getByRole("heading", { name: `Welcome ${userA}` }), + ).toBeVisible(); + + const brooksClientHandle = await ewPage1.evaluateHandle(() => + window.mxMatrixClientPeg.get(), + ); + const brooksMxId = (await brooksClientHandle.evaluate((cli) => { + return cli.getUserId(); + }, brooksClientHandle))!; + + const user2Context = await browser.newContext({ + reducedMotion: "reduce", + }); + const ewPage2 = await user2Context.newPage(); + // Register the second user + await ewPage2.goto("http://localhost:8081/#/welcome"); + await ewPage2.getByRole("link", { name: "Create Account" }).click(); + await ewPage2.getByRole("textbox", { name: "Username" }).fill(userB); + await ewPage2 + .getByRole("textbox", { name: "Password", exact: true }) + .fill(PASSWORD); + await ewPage2.getByRole("textbox", { name: "Confirm password" }).click(); + await ewPage2 + .getByRole("textbox", { name: "Confirm password" }) + .fill(PASSWORD); + await ewPage2.getByRole("button", { name: "Register" }).click(); + await expect( + ewPage2.getByRole("heading", { name: `Welcome ${userB}` }), + ).toBeVisible(); + + const whistlerClientHandle = await ewPage2.evaluateHandle(() => + window.mxMatrixClientPeg.get(), + ); + const whistlerMxId = (await whistlerClientHandle.evaluate((cli) => { + return cli.getUserId(); + }, whistlerClientHandle))!; + + // Invite the second user + await ewPage1.getByRole("button", { name: "Add room" }).click(); + await ewPage1.getByText("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 ewPage1 + .getByRole("button", { name: "Invite to this room", exact: true }) + .click(); + await expect( + ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }), + ).toBeVisible(); + + await ewPage1.getByRole("textbox").fill(whistlerMxId); + await ewPage1.getByRole("textbox").click(); + await ewPage1.getByRole("button", { name: "Invite" }).click(); + + // Accept the invite + await expect( + ewPage2.getByRole("treeitem", { name: "Welcome Room" }), + ).toBeVisible(); + await ewPage2.getByRole("treeitem", { name: "Welcome Room" }).click(); + await ewPage2.getByRole("button", { name: "Accept" }).click(); + await expect( + ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }), + ).toBeVisible(); + + // Renamed use to pUse, as a workaround for eslint error that was thinking this use was a react use. + await pUse({ + brooks: { + mxId: brooksMxId, + page: ewPage1, + clientHandle: brooksClientHandle, + }, + whistler: { + mxId: whistlerMxId, + page: ewPage2, + clientHandle: whistlerClientHandle, + }, + }); + }, +}); diff --git a/playwright/global.d.ts b/playwright/global.d.ts new file mode 100644 index 00000000..2108240e --- /dev/null +++ b/playwright/global.d.ts @@ -0,0 +1,16 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import type * as Matrix from "matrix-js-sdk/src"; + +declare global { + interface Window { + mxMatrixClientPeg: { + get(): Matrix.MatrixClient; + }; + } +} diff --git a/playwright/widget/simple-create.spec.ts b/playwright/widget/simple-create.spec.ts new file mode 100644 index 00000000..3712f5c4 --- /dev/null +++ b/playwright/widget/simple-create.spec.ts @@ -0,0 +1,97 @@ +/* +Copyright 2025 New Vector 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"; + +widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => { + test.skip( + browserName === "firefox", + "This test is not working on firefox, after hangup brooks is locked in a strange state with a blank widget", + ); + + const { brooks, whistler } = asWidget; + + await expect( + brooks.page.getByRole("button", { name: "Video call" }), + ).toBeVisible(); + await brooks.page.getByRole("button", { name: "Video call" }).click(); + + await expect( + brooks.page.getByRole("menuitem", { name: "Legacy Call" }), + ).toBeVisible(); + await expect( + brooks.page.getByRole("menuitem", { name: "Element Call" }), + ).toBeVisible(); + + await brooks.page.getByRole("menuitem", { name: "Element Call" }).click(); + + await expect( + brooks.page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByTestId("lobby_joinCall"), + ).toBeVisible(); + + await brooks.page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByTestId("lobby_joinCall") + .click(); + + // Check the join indicator on the room list + await expect( + brooks.page.locator("div").filter({ hasText: /^Joined • 1$/ }), + ).toBeVisible(); + + // Join from the other side + await expect(whistler.page.getByText("Video call started")).toBeVisible(); + await expect( + whistler.page.getByRole("button", { name: "Join" }), + ).toBeVisible(); + await whistler.page.getByRole("button", { name: "Join" }).click(); + + await expect( + whistler.page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByTestId("lobby_joinCall"), + ).toBeVisible(); + + await whistler.page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByTestId("lobby_joinCall") + .click(); + + await expect( + whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }), + ).toBeVisible(); + + await expect( + brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }), + ).toBeVisible(); + + // Whistler leaves + await whistler.page.waitForTimeout(1000); + await whistler.page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByTestId("incall_leave") + .click(); + + // Brooks leaves + await brooks.page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByTestId("incall_leave") + .click(); + + await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible(); + await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible(); +});