diff --git a/playwright/fixtures/spa-user.ts b/playwright/fixtures/spa-user.ts new file mode 100644 index 000000000..62d2aa928 --- /dev/null +++ b/playwright/fixtures/spa-user.ts @@ -0,0 +1,76 @@ +/* +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 { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +import { SynapseAdmin } from "../utils/synapse-admin.ts"; +import { HOMESERVER_URL } from "../utils/matrix-api.ts"; + +/** A user of the Playwright backend's homeserver. */ +export interface SpaUser { + /** The localpart actually registered, which carries a unique suffix. */ + username: string; + mxId: string; + accessToken: string; +} + +export interface SpaFixtures { + /** Registers a user over the admin API, without a browser session. */ + registerUser: (username: string) => Promise; + /** A user registered and logged in to Element Call on the test's page. */ + spaUser: SpaUser; +} + +const PASSWORD = "password1!"; + +// A dev server that was already running keeps whatever config.json it was +// started with, since Playwright reuses it rather than installing the one it +// wants, so the tests serve the config they assert against. +const CONFIG_PATH = fileURLToPath( + new URL("../../config/config.devenv.json", import.meta.url), +); + +export const spaTest = test.extend({ + context: async ({ context }, use) => { + await context.route( + "**/config.json", + async (route) => await route.fulfill({ path: CONFIG_PATH }), + ); + await use(context); + }, + + registerUser: async ({ browserName }, use) => { + const admin = SynapseAdmin.forHomeserver(HOMESERVER_URL); + await use(async (username: string): Promise => { + // Every browser and worker registers against the one homeserver. + const unique = `${username}_${browserName}_${randomUUID().slice(0, 8)}`; + const registered = await admin.registerUser(unique, PASSWORD); + return { + username: unique, + mxId: registered.user_id, + accessToken: registered.access_token, + }; + }); + }, + + spaUser: async ({ page, registerUser }, use) => { + const user = await registerUser("caller"); + + await page.goto("/"); + await page.getByTestId("home_login").click(); + await page.getByTestId("login_username").fill(user.username); + await page.getByTestId("login_password").fill(PASSWORD); + await page.getByTestId("login_login").click(); + await expect( + page.getByRole("heading", { name: "Start new call" }), + ).toBeVisible(); + + await use(user); + }, +}); diff --git a/playwright/knock.spec.ts b/playwright/knock.spec.ts new file mode 100644 index 000000000..53693560c --- /dev/null +++ b/playwright/knock.spec.ts @@ -0,0 +1,177 @@ +/* +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 } from "@playwright/test"; + +import { spaTest as test, type SpaUser } from "./fixtures/spa-user.ts"; +import { + banUser, + createCallRoom, + inviteUser, + kickUser, + membershipOf, +} from "./utils/matrix-api.ts"; + +/** The app URL for a call known only by its room ID. */ +const callUrl = (roomId: string): string => + `/room/#?roomId=${encodeURIComponent(roomId)}`; + +/** Reads the membership the server holds for `user`, as a moderator sees it. */ +const membershipPoll = + (moderator: SpaUser, roomId: string, user: SpaUser) => + async (): Promise => + await membershipOf(moderator.accessToken, roomId, user.mxId); + +test("Ask to join a call, withdraw, ask again and be let in", async ({ + page, + spaUser, + registerUser, +}) => { + test.slow(); + const moderator = await registerUser("moderator"); + const roomId = await createCallRoom({ + accessToken: moderator.accessToken, + name: "Knock call", + joinRule: "knock", + }); + const membership = membershipPoll(moderator, roomId, spaUser); + + await page.goto(callUrl(roomId)); + + const joinButton = page.getByTestId("lobby_joinCall"); + await expect(joinButton).toHaveText("Request to join call"); + await expect(joinButton).toBeEnabled(); + await expect(page.locator("video")).toBeVisible(); + + await joinButton.click(); + + await expect(joinButton).toHaveText("Request to join sent"); + await expect(joinButton).toBeDisabled(); + await expect(page.getByTestId("lobby_joinMessage")).toContainText( + "You will receive an invite to join the call if your request is accepted.", + ); + await expect.poll(membership).toBe("knock"); + + await page.getByTestId("lobby_cancelRequest").click(); + + await expect(joinButton).toHaveText("Request to join call"); + await expect(joinButton).toBeEnabled(); + await expect(page.getByTestId("lobby_joinMessage")).toBeHidden(); + await expect.poll(membership).toBe("leave"); + + await joinButton.click(); + + await expect(joinButton).toHaveText("Request to join sent"); + await expect.poll(membership).toBe("knock"); + + await inviteUser(moderator.accessToken, roomId, spaUser.mxId); + + // The accepted request joins the room and skips a second lobby. + await expect(page.getByTestId("incall_leave")).toBeVisible({ + timeout: 60_000, + }); + await expect(joinButton).toBeHidden(); +}); + +test("A declined request is shown in the lobby", async ({ + page, + spaUser, + registerUser, +}) => { + const moderator = await registerUser("moderator"); + const roomId = await createCallRoom({ + accessToken: moderator.accessToken, + name: "Declining call", + joinRule: "knock", + }); + + await page.goto(callUrl(roomId)); + await page.getByTestId("lobby_joinCall").click(); + await expect.poll(membershipPoll(moderator, roomId, spaUser)).toBe("knock"); + + await kickUser(moderator.accessToken, roomId, spaUser.mxId); + + const message = page.getByTestId("lobby_joinMessage"); + await expect(message).toContainText("Access denied"); + await expect(message).toContainText("Your request to join was declined."); + await expect(page.getByTestId("lobby_joinCall")).toBeHidden(); + // The camera preview outlives the refusal. + await expect(page.locator("video")).toBeVisible(); +}); + +test("A ban is shown in the lobby, with its reason", async ({ + page, + spaUser, + registerUser, +}) => { + const moderator = await registerUser("moderator"); + const roomId = await createCallRoom({ + accessToken: moderator.accessToken, + name: "Banning call", + joinRule: "knock", + }); + + await page.goto(callUrl(roomId)); + await page.getByTestId("lobby_joinCall").click(); + await expect.poll(membershipPoll(moderator, roomId, spaUser)).toBe("knock"); + + await banUser(moderator.accessToken, roomId, spaUser.mxId, "Wrong call"); + + const message = page.getByTestId("lobby_joinMessage"); + await expect(message).toContainText("Banned"); + await expect(message).toContainText("You have been banned from the room."); + await expect(message).toContainText("Reason: Wrong call"); + await expect(page.getByTestId("lobby_joinCall")).toBeHidden(); +}); + +test("A call that takes no requests says so in the lobby", async ({ + page, + spaUser, + registerUser, +}) => { + const moderator = await registerUser("moderator"); + // Synapse serves an MSC3266 summary for knock and knock_restricted rooms + // only, so a `knock_restricted` room, whose rule Element Call cannot act on + // without knowing the user's other rooms, is the one shape that reaches the + // lobby with nothing to offer. + const roomId = await createCallRoom({ + accessToken: moderator.accessToken, + name: "Space call", + joinRule: "knock_restricted", + }); + + await page.goto(callUrl(roomId)); + + await expect(page.getByTestId("lobby_joinMessage")).toContainText( + "You need an invite to join this call.", + ); + await expect(page.getByTestId("lobby_joinCall")).toBeHidden(); + await expect(page.locator("video")).toBeVisible(); +}); + +test("An invite-only call cannot be reached at all", async ({ + page, + spaUser, + registerUser, +}) => { + const moderator = await registerUser("moderator"); + const roomId = await createCallRoom({ + accessToken: moderator.accessToken, + name: "Private call", + joinRule: "invite", + }); + + await page.goto(callUrl(roomId)); + + // Synapse serves no room summary for an invite-only room, so Element Call + // falls back to joining as if the room were public and the refusal lands on + // the error page rather than in the lobby. + await expect( + page.getByRole("heading", { name: "Something went wrong" }), + ).toBeVisible(); + await expect(page.getByTestId("lobby_joinCall")).toBeHidden(); +}); diff --git a/playwright/utils/matrix-api.ts b/playwright/utils/matrix-api.ts new file mode 100644 index 000000000..e8fdfc3ad --- /dev/null +++ b/playwright/utils/matrix-api.ts @@ -0,0 +1,146 @@ +/* +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. +*/ + +/** The homeserver of the Playwright backend, as `config.devenv.json` names it. */ +export const HOMESERVER_URL = "https://synapse.m.localhost"; + +/** MSC3417: the room type Element Call gives its calls. */ +const CALL_ROOM_TYPE = "org.matrix.msc3417.call"; + +/** The state event every call participant sends to announce their membership. */ +const RTC_MEMBER_EVENT_TYPE = "org.matrix.msc3401.call.member"; + +async function csApiRequest( + accessToken: string, + method: "GET" | "POST", + path: string, + body?: unknown, +): Promise { + const init: RequestInit = { + method, + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }; + if (body !== undefined) init.body = JSON.stringify(body); + return await fetch(`${HOMESERVER_URL}/_matrix/client/v3/${path}`, init); +} + +async function csApi( + accessToken: string, + method: "GET" | "POST", + path: string, + body?: unknown, +): Promise { + const response = await csApiRequest(accessToken, method, path, body); + if (!response.ok) + throw new Error( + `${method} ${path} failed: ${response.status} ${await response.text()}`, + ); + return (await response.json()) as T; +} + +/** + * Create a room that Element Call sees as a call, and that anyone who gets in + * may participate in. The creator is its only moderator, and `joinRule` is + * written as `m.room.join_rules` over whatever the preset would set. + * + * @returns The room's ID + */ +export async function createCallRoom({ + accessToken, + name, + joinRule, +}: { + accessToken: string; + name: string; + joinRule: string; +}): Promise { + const created = await csApi<{ room_id: string }>( + accessToken, + "POST", + "createRoom", + { + name, + preset: "private_chat", + creation_content: { type: CALL_ROOM_TYPE }, + initial_state: [ + { + type: "m.room.join_rules", + state_key: "", + content: { join_rule: joinRule }, + }, + ], + power_level_content_override: { + events: { [RTC_MEMBER_EVENT_TYPE]: 0 }, + }, + }, + ); + return created.room_id; +} + +export async function inviteUser( + accessToken: string, + roomId: string, + userId: string, +): Promise { + await csApi( + accessToken, + "POST", + `rooms/${encodeURIComponent(roomId)}/invite`, + { + user_id: userId, + }, + ); +} + +export async function kickUser( + accessToken: string, + roomId: string, + userId: string, +): Promise { + await csApi(accessToken, "POST", `rooms/${encodeURIComponent(roomId)}/kick`, { + user_id: userId, + }); +} + +export async function banUser( + accessToken: string, + roomId: string, + userId: string, + reason?: string, +): Promise { + await csApi(accessToken, "POST", `rooms/${encodeURIComponent(roomId)}/ban`, { + user_id: userId, + reason, + }); +} + +/** + * Read a user's membership as the server reports it, from the room state, so + * that knocks and leaves are visible and not just joins. + * + * @returns The membership, or undefined while the user has no member event + */ +export async function membershipOf( + accessToken: string, + roomId: string, + userId: string, +): Promise { + const response = await csApiRequest( + accessToken, + "GET", + `rooms/${encodeURIComponent(roomId)}/state/m.room.member/${encodeURIComponent(userId)}`, + ); + if (response.status === 404) return undefined; + if (!response.ok) + throw new Error( + `Reading the membership of ${userId} failed: ${response.status} ${await response.text()}`, + ); + return ((await response.json()) as { membership: string }).membership; +}