mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
Test the standalone knock flow in Playwright
A knock call room, created over the CS API, walks through the lobby's join states: request to join, the waiting copy with its withdrawal link, a second request, and an invite from the room's moderator that lands the user in the call. A decline, a ban with its reason, and a room whose join rule the app cannot act on are covered too. An invite-only room asserts what happens today: Synapse serves no summary for it, so the refused join is a full-screen error rather than a lobby state. The `spaUser` fixture registers over the admin API and logs in through the form, and serves `config.devenv.json` itself, since Playwright reuses a dev server started with another config. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
1c8930daa0
commit
58b9e2d983
@@ -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<SpaUser>;
|
||||
/** 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<SpaFixtures>({
|
||||
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<SpaUser> => {
|
||||
// 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);
|
||||
},
|
||||
});
|
||||
@@ -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<string | undefined> =>
|
||||
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();
|
||||
});
|
||||
@@ -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<Response> {
|
||||
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<T>(
|
||||
accessToken: string,
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
await csApi(
|
||||
accessToken,
|
||||
"POST",
|
||||
`rooms/${encodeURIComponent(roomId)}/invite`,
|
||||
{
|
||||
user_id: userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function kickUser(
|
||||
accessToken: string,
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await csApi(accessToken, "POST", `rooms/${encodeURIComponent(roomId)}/kick`, {
|
||||
user_id: userId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function banUser(
|
||||
accessToken: string,
|
||||
roomId: string,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
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<string | undefined> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user