Skip to content

Commit 479b451

Browse files
authored
Add tests to cover joining and starting an Element call (#30843)
* Add tests * Add test IDs * Revert to pre-new-widget-refactors state * Update codeowners * Remove one of the test IDs * Update snapshots as DMs don't have room names :) * Remove only * fix a import * fix docstring * update snaps * remove a line * update snaps
1 parent b89de61 commit 479b451

File tree

8 files changed

+338
-3
lines changed

8 files changed

+338
-3
lines changed

.github/CODEOWNERS

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@
1818
/playwright/e2e/settings/encryption-user-tab/ @element-hq/element-crypto-web-reviewers
1919

2020

21-
/src/models/Call.ts @element-hq/element-call-reviewers
22-
/src/call-types.ts @element-hq/element-call-reviewers
23-
/src/components/views/voip @element-hq/element-call-reviewers
21+
/src/models/Call.ts @element-hq/element-call-reviewers
22+
/src/call-types.ts @element-hq/element-call-reviewers
23+
/src/components/views/voip @element-hq/element-call-reviewers
24+
/playwright/e2e/voip/element-call.spec.ts @element-hq/element-call-reviewers
2425

2526
# Ignore translations as those will be updated by GHA for Localazy download
2627
/src/i18n/strings
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import type { EventType, Preset } from "matrix-js-sdk/src/matrix";
9+
import { SettingLevel } from "../../../src/settings/SettingLevel";
10+
import { test, expect } from "../../element-web-test";
11+
import type { Credentials } from "../../plugins/homeserver";
12+
import type { Bot } from "../../pages/bot";
13+
14+
function assertCommonCallParameters(
15+
url: URLSearchParams,
16+
hash: URLSearchParams,
17+
user: Credentials,
18+
room: { roomId: string },
19+
): void {
20+
expect(url.has("widgetId")).toEqual(true);
21+
expect(url.has("parentUrl")).toEqual(true);
22+
23+
expect(hash.get("perParticipantE2EE")).toEqual("false");
24+
expect(hash.get("userId")).toEqual(user.userId);
25+
expect(hash.get("deviceId")).toEqual(user.deviceId);
26+
expect(hash.get("roomId")).toEqual(room.roomId);
27+
expect(hash.get("preload")).toEqual("false");
28+
29+
expect(hash.get("returnToLobby")).toEqual("false");
30+
}
31+
32+
async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification") {
33+
const resp = await bot.sendStateEvent(
34+
roomId,
35+
"org.matrix.msc3401.call.member",
36+
{
37+
application: "m.call",
38+
call_id: "",
39+
device_id: "OiDFxsZrjz",
40+
expires: 180000000,
41+
foci_preferred: [
42+
{
43+
livekit_alias: roomId,
44+
livekit_service_url: "https://example.org",
45+
type: "livekit",
46+
},
47+
],
48+
focus_active: {
49+
focus_selection: "oldest_membership",
50+
type: "livekit",
51+
},
52+
scope: "m.room",
53+
},
54+
`_@${bot.credentials.userId}_OiDFxsZrjz_m.call`,
55+
);
56+
if (!notification) {
57+
return;
58+
}
59+
await bot.sendEvent(roomId, null, "org.matrix.msc4075.rtc.notification", {
60+
"lifetime": 30000,
61+
"m.mentions": {
62+
room: true,
63+
user_ids: [],
64+
},
65+
"m.relates_to": {
66+
event_id: resp.event_id,
67+
rel_type: "org.matrix.msc4075.rtc.notification.parent",
68+
},
69+
"notification_type": notification,
70+
"sender_ts": 1758611895996,
71+
});
72+
}
73+
74+
test.describe("Element Call", () => {
75+
test.use({
76+
config: {
77+
element_call: {
78+
use_exclusively: false,
79+
},
80+
features: {
81+
feature_group_calls: true,
82+
},
83+
},
84+
displayName: "Alice",
85+
botCreateOpts: {
86+
autoAcceptInvites: true,
87+
displayName: "Bob",
88+
},
89+
});
90+
91+
test.beforeEach(async ({ page, user, app }) => {
92+
// Mock a widget page. It doesn't need to actually be Element Call.
93+
await page.route("/widget.html", async (route) => {
94+
await route.fulfill({
95+
status: 200,
96+
body: "<p> Hello world </p>",
97+
});
98+
});
99+
await app.settings.setValue(
100+
"Developer.elementCallUrl",
101+
null,
102+
SettingLevel.DEVICE,
103+
new URL("/widget.html#", page.url()).toString(),
104+
);
105+
});
106+
107+
test.describe("Group Chat", () => {
108+
test.use({
109+
room: async ({ page, app, user, bot }, use) => {
110+
const roomId = await app.client.createRoom({ name: "TestRoom", invite: [bot.credentials.userId] });
111+
await use({ roomId });
112+
},
113+
});
114+
test("should be able to start a video call", async ({ page, user, room, app }) => {
115+
await app.viewRoomById(room.roomId);
116+
await expect(page.getByText("Bob joined the room")).toBeVisible();
117+
118+
await page.getByRole("button", { name: "Video call" }).click();
119+
await page.getByRole("menuitem", { name: "Element Call" }).click();
120+
121+
const frameUrlStr = await page.locator("iframe").getAttribute("src");
122+
await expect(frameUrlStr).toBeDefined();
123+
// Ensure we set the correct parameters for ECall.
124+
const url = new URL(frameUrlStr);
125+
const hash = new URLSearchParams(url.hash.slice(1));
126+
assertCommonCallParameters(url.searchParams, hash, user, room);
127+
expect(hash.get("intent")).toEqual("start_call");
128+
expect(hash.get("skipLobby")).toEqual("false");
129+
});
130+
131+
test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => {
132+
await app.viewRoomById(room.roomId);
133+
await expect(page.getByText("Bob joined the room")).toBeVisible();
134+
135+
await page.getByRole("button", { name: "Video call" }).click();
136+
await page.keyboard.down("Shift");
137+
await page.getByRole("menuitem", { name: "Element Call" }).click();
138+
await page.keyboard.up("Shift");
139+
140+
const frameUrlStr = await page.locator("iframe").getAttribute("src");
141+
await expect(frameUrlStr).toBeDefined();
142+
const url = new URL(frameUrlStr);
143+
const hash = new URLSearchParams(url.hash.slice(1));
144+
assertCommonCallParameters(url.searchParams, hash, user, room);
145+
expect(hash.get("intent")).toEqual("start_call");
146+
expect(hash.get("skipLobby")).toEqual("true");
147+
});
148+
149+
test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
150+
await app.viewRoomById(room.roomId);
151+
// Allow bob to create a call
152+
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
153+
await expect(page.getByText("Bob joined the room")).toBeVisible();
154+
// Fake a start of a call
155+
await sendRTCState(bot, room.roomId);
156+
const button = page.getByTestId("join-call-button");
157+
await expect(button).toBeInViewport({ timeout: 5000 });
158+
// And test joining
159+
await button.click();
160+
const frameUrlStr = await page.locator("iframe").getAttribute("src");
161+
console.log(frameUrlStr);
162+
await expect(frameUrlStr).toBeDefined();
163+
const url = new URL(frameUrlStr);
164+
const hash = new URLSearchParams(url.hash.slice(1));
165+
assertCommonCallParameters(url.searchParams, hash, user, room);
166+
167+
expect(hash.get("intent")).toEqual("join_existing");
168+
expect(hash.get("skipLobby")).toEqual("false");
169+
});
170+
171+
[true, false].forEach((skipLobbyToggle) => {
172+
test(
173+
`should be able to join a call via incoming call toast (skipLobby=${skipLobbyToggle})`,
174+
{ tag: ["@screenshot"] },
175+
async ({ page, user, bot, room, app }) => {
176+
await app.viewRoomById(room.roomId);
177+
// Allow bob to create a call
178+
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
179+
await expect(page.getByText("Bob joined the room")).toBeVisible();
180+
// Fake a start of a call
181+
await sendRTCState(bot, room.roomId, "notification");
182+
const toast = page.locator(".mx_Toast_toast");
183+
const button = toast.getByRole("button", { name: "Join" });
184+
if (skipLobbyToggle) {
185+
await toast.getByRole("switch").check();
186+
await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-checked.png");
187+
} else {
188+
await toast.getByRole("switch").uncheck();
189+
await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-unchecked.png");
190+
}
191+
192+
// And test joining
193+
await button.click();
194+
const frameUrlStr = await page.locator("iframe").getAttribute("src");
195+
console.log(frameUrlStr);
196+
await expect(frameUrlStr).toBeDefined();
197+
const url = new URL(frameUrlStr);
198+
const hash = new URLSearchParams(url.hash.slice(1));
199+
assertCommonCallParameters(url.searchParams, hash, user, room);
200+
201+
expect(hash.get("intent")).toEqual("join_existing");
202+
expect(hash.get("skipLobby")).toEqual(skipLobbyToggle.toString());
203+
},
204+
);
205+
});
206+
});
207+
208+
test.describe("DMs", () => {
209+
test.use({
210+
room: async ({ page, app, user, bot }, use) => {
211+
const roomId = await app.client.createRoom({
212+
preset: "trusted_private_chat" as Preset.TrustedPrivateChat,
213+
invite: [bot.credentials.userId],
214+
});
215+
await app.client.setAccountData("m.direct" as EventType.Direct, {
216+
[bot.credentials.userId]: [roomId],
217+
});
218+
await use({ roomId });
219+
},
220+
});
221+
222+
test("should be able to start a video call", async ({ page, user, room, app }) => {
223+
await app.viewRoomById(room.roomId);
224+
await expect(page.getByText("Bob joined the room")).toBeVisible();
225+
226+
await page.getByRole("button", { name: "Video call" }).click();
227+
await page.getByRole("menuitem", { name: "Element Call" }).click();
228+
const frameUrlStr = await page.locator("iframe").getAttribute("src");
229+
230+
await expect(frameUrlStr).toBeDefined();
231+
const url = new URL(frameUrlStr);
232+
const hash = new URLSearchParams(url.hash.slice(1));
233+
assertCommonCallParameters(url.searchParams, hash, user, room);
234+
expect(hash.get("intent")).toEqual("start_call_dm");
235+
expect(hash.get("skipLobby")).toEqual("false");
236+
});
237+
238+
test("should be able to skip lobby by holding down shift", async ({ page, user, room, app }) => {
239+
await app.viewRoomById(room.roomId);
240+
await expect(page.getByText("Bob joined the room")).toBeVisible();
241+
242+
await page.getByRole("button", { name: "Video call" }).click();
243+
await page.keyboard.down("Shift");
244+
await page.getByRole("menuitem", { name: "Element Call" }).click();
245+
await page.keyboard.up("Shift");
246+
const frameUrlStr = await page.locator("iframe").getAttribute("src");
247+
248+
await expect(frameUrlStr).toBeDefined();
249+
const url = new URL(frameUrlStr);
250+
const hash = new URLSearchParams(url.hash.slice(1));
251+
assertCommonCallParameters(url.searchParams, hash, user, room);
252+
expect(hash.get("intent")).toEqual("start_call_dm");
253+
expect(hash.get("skipLobby")).toEqual("true");
254+
});
255+
256+
test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
257+
await app.viewRoomById(room.roomId);
258+
// Allow bob to create a call
259+
await expect(page.getByText("Bob joined the room")).toBeVisible();
260+
// Fake a start of a call
261+
await sendRTCState(bot, room.roomId);
262+
const button = page.getByTestId("join-call-button");
263+
await expect(button).toBeInViewport({ timeout: 5000 });
264+
// And test joining
265+
await button.click();
266+
const frameUrlStr = await page.locator("iframe").getAttribute("src");
267+
console.log(frameUrlStr);
268+
await expect(frameUrlStr).toBeDefined();
269+
const url = new URL(frameUrlStr);
270+
const hash = new URLSearchParams(url.hash.slice(1));
271+
assertCommonCallParameters(url.searchParams, hash, user, room);
272+
273+
expect(hash.get("intent")).toEqual("join_existing_dm");
274+
expect(hash.get("skipLobby")).toEqual("false");
275+
});
276+
277+
[true, false].forEach((skipLobbyToggle) => {
278+
test(
279+
`should be able to join a call via incoming call toast (skipLobby=${skipLobbyToggle})`,
280+
{ tag: ["@screenshot"] },
281+
async ({ page, user, bot, room, app }) => {
282+
await app.viewRoomById(room.roomId);
283+
// Allow bob to create a call
284+
await expect(page.getByText("Bob joined the room")).toBeVisible();
285+
// Fake a start of a call
286+
await sendRTCState(bot, room.roomId, "ring");
287+
const toast = page.locator(".mx_Toast_toast");
288+
const button = toast.getByRole("button", { name: "Join" });
289+
if (skipLobbyToggle) {
290+
await toast.getByRole("switch").check();
291+
await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-checked.png");
292+
} else {
293+
await toast.getByRole("switch").uncheck();
294+
await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-unchecked.png");
295+
}
296+
297+
// And test joining
298+
await button.click();
299+
const frameUrlStr = await page.locator("iframe").getAttribute("src");
300+
console.log(frameUrlStr);
301+
await expect(frameUrlStr).toBeDefined();
302+
const url = new URL(frameUrlStr);
303+
const hash = new URLSearchParams(url.hash.slice(1));
304+
assertCommonCallParameters(url.searchParams, hash, user, room);
305+
306+
expect(hash.get("intent")).toEqual("join_existing_dm");
307+
expect(hash.get("skipLobby")).toEqual(skipLobbyToggle.toString());
308+
},
309+
);
310+
});
311+
});
312+
});

playwright/pages/client.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,27 @@ export class Client {
469469
);
470470
}
471471

472+
/**
473+
* Set a power level to one or multiple users.
474+
* Will apply changes atop of current power level event.
475+
* @param roomId - the room to update power levels in
476+
* @param userId - the ID of the user or users to update power levels of
477+
* @param powerLevel - the numeric power level to update given users to
478+
*/
479+
public async setPowerLevel(
480+
roomId: string,
481+
userId: string | string[],
482+
powerLevel: number,
483+
): Promise<ISendEventResponse> {
484+
const client = await this.prepareClient();
485+
return client.evaluate(
486+
async (client, { roomId, userId, powerLevel }) => {
487+
return client.setPowerLevel(roomId, userId, powerLevel);
488+
},
489+
{ roomId, userId, powerLevel },
490+
);
491+
}
492+
472493
/**
473494
* Leaves the given room.
474495
* @param roomId ID of the room to leave
12.8 KB
Loading
13.1 KB
Loading
13.1 KB
Loading
13.4 KB
Loading

src/components/views/rooms/RoomHeader/RoomHeader.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export default function RoomHeader({
129129
disabled={!!videoCallDisabledReason}
130130
color="primary"
131131
aria-label={videoCallDisabledReason ?? _t("action|join")}
132+
data-testId="join-call-button"
132133
>
133134
{_t("action|join")}
134135
</Button>

0 commit comments

Comments
 (0)