@element-hq/element-web-playwright-common 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/Dockerfile +9 -0
  2. package/README.md +28 -0
  3. package/lib/element-web-test.d.ts +26 -0
  4. package/lib/element-web-test.d.ts.map +1 -0
  5. package/lib/element-web-test.js +54 -0
  6. package/lib/expect/axe.d.ts +11 -0
  7. package/lib/expect/axe.d.ts.map +1 -0
  8. package/lib/expect/axe.js +22 -0
  9. package/lib/expect/index.d.ts +6 -0
  10. package/lib/expect/index.d.ts.map +1 -0
  11. package/lib/expect/index.js +10 -0
  12. package/lib/expect/screenshot.d.ts +14 -0
  13. package/lib/expect/screenshot.d.ts.map +1 -0
  14. package/lib/expect/screenshot.js +48 -0
  15. package/lib/expect/toHaveNoViolations.d.ts +11 -0
  16. package/lib/expect/toHaveNoViolations.d.ts.map +1 -0
  17. package/lib/expect/toHaveNoViolations.js +22 -0
  18. package/lib/expect/toMatchScreenshot.d.ts +13 -0
  19. package/lib/expect/toMatchScreenshot.d.ts.map +1 -0
  20. package/lib/expect/toMatchScreenshot.js +44 -0
  21. package/lib/expect/toPassAxeCheck.d.ts +7 -0
  22. package/lib/expect/toPassAxeCheck.d.ts.map +1 -0
  23. package/lib/expect/toPassAxeCheck.js +22 -0
  24. package/lib/fixtures/axe.d.ts +8 -0
  25. package/lib/fixtures/axe.d.ts.map +1 -0
  26. package/lib/fixtures/axe.js +15 -0
  27. package/lib/fixtures/index.d.ts +10 -0
  28. package/lib/fixtures/index.d.ts.map +1 -0
  29. package/lib/fixtures/index.js +10 -0
  30. package/lib/fixtures/services.d.ts +57 -0
  31. package/lib/fixtures/services.d.ts.map +1 -0
  32. package/lib/fixtures/services.js +114 -0
  33. package/lib/fixtures/user.d.ts +31 -0
  34. package/lib/fixtures/user.d.ts.map +1 -0
  35. package/lib/fixtures/user.js +47 -0
  36. package/lib/index.d.ts +35 -0
  37. package/lib/index.d.ts.map +1 -0
  38. package/lib/index.js +54 -0
  39. package/lib/logger.d.ts +10 -0
  40. package/lib/logger.d.ts.map +1 -0
  41. package/lib/logger.js +59 -0
  42. package/lib/playwright.d.ts +2 -0
  43. package/lib/playwright.d.ts.map +1 -0
  44. package/lib/playwright.js +1 -0
  45. package/lib/testcontainers/HomeserverContainer.d.ts +70 -0
  46. package/lib/testcontainers/HomeserverContainer.d.ts.map +1 -0
  47. package/lib/testcontainers/HomeserverContainer.js +7 -0
  48. package/lib/testcontainers/index.d.ts +5 -0
  49. package/lib/testcontainers/index.d.ts.map +1 -0
  50. package/lib/testcontainers/index.js +9 -0
  51. package/lib/testcontainers/mailpit.d.ts +33 -0
  52. package/lib/testcontainers/mailpit.d.ts.map +1 -0
  53. package/lib/testcontainers/mailpit.js +52 -0
  54. package/lib/testcontainers/mas.d.ts +182 -0
  55. package/lib/testcontainers/mas.d.ts.map +1 -0
  56. package/lib/testcontainers/mas.js +311 -0
  57. package/lib/testcontainers/synapse.d.ts +229 -0
  58. package/lib/testcontainers/synapse.d.ts.map +1 -0
  59. package/lib/testcontainers/synapse.js +383 -0
  60. package/lib/utils/api.d.ts +49 -0
  61. package/lib/utils/api.d.ts.map +1 -0
  62. package/lib/utils/api.js +73 -0
  63. package/lib/utils/logger.d.ts +25 -0
  64. package/lib/utils/logger.d.ts.map +1 -0
  65. package/lib/utils/logger.js +74 -0
  66. package/lib/utils/object.d.ts +8 -0
  67. package/lib/utils/object.d.ts.map +1 -0
  68. package/lib/utils/object.js +15 -0
  69. package/lib/utils/port.d.ts +5 -0
  70. package/lib/utils/port.d.ts.map +1 -0
  71. package/lib/utils/port.js +20 -0
  72. package/lib/utils/rand.d.ts +6 -0
  73. package/lib/utils/rand.d.ts.map +1 -0
  74. package/lib/utils/rand.js +15 -0
  75. package/package.json +30 -0
  76. package/playwright-screenshots.sh +53 -0
  77. package/src/@types/playwright-core.d.ts +12 -0
  78. package/src/expect/axe.ts +37 -0
  79. package/src/expect/index.ts +21 -0
  80. package/src/expect/screenshot.ts +79 -0
  81. package/src/fixtures/axe.ts +22 -0
  82. package/src/fixtures/index.ts +15 -0
  83. package/src/fixtures/services.ts +188 -0
  84. package/src/fixtures/user.ts +93 -0
  85. package/src/index.ts +92 -0
  86. package/src/testcontainers/HomeserverContainer.ts +87 -0
  87. package/src/testcontainers/index.ts +15 -0
  88. package/src/testcontainers/mailpit.ts +62 -0
  89. package/src/testcontainers/mas.ts +382 -0
  90. package/src/testcontainers/synapse.ts +493 -0
  91. package/src/utils/api.ts +113 -0
  92. package/src/utils/logger.ts +79 -0
  93. package/src/utils/object.ts +16 -0
  94. package/src/utils/port.ts +22 -0
  95. package/src/utils/rand.ts +17 -0
  96. package/tsconfig.json +10 -0
@@ -0,0 +1,493 @@
1
+ /*
2
+ Copyright 2024 New Vector Ltd.
3
+
4
+ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5
+ Please see LICENSE files in the repository root for full details.
6
+ */
7
+
8
+ import {
9
+ AbstractStartedContainer,
10
+ GenericContainer,
11
+ type RestartOptions,
12
+ type StartedTestContainer,
13
+ Wait,
14
+ } from "testcontainers";
15
+ import { type APIRequestContext, type TestInfo } from "@playwright/test";
16
+ import crypto from "node:crypto";
17
+ import * as YAML from "yaml";
18
+ import { set } from "lodash-es";
19
+
20
+ import { getFreePort } from "../utils/port.js";
21
+ import { randB64Bytes } from "../utils/rand.js";
22
+ import { deepCopy } from "../utils/object.js";
23
+ import { type HomeserverContainer, type StartedHomeserverContainer } from "./HomeserverContainer.js";
24
+ import { type StartedMatrixAuthenticationServiceContainer } from "./mas.js";
25
+ import { Api, ClientServerApi, type Verb, type Credentials } from "../utils/api.js";
26
+ import { StartedMailpitContainer } from "./mailpit.js";
27
+
28
+ const TAG = "develop@sha256:8d0049e8e0524ad6817cf7737453fe47de1ed3b8d04704f0c2fd6c136414c9d7";
29
+
30
+ const DEFAULT_CONFIG = {
31
+ server_name: "localhost",
32
+ public_baseurl: "", // set by start method
33
+ pid_file: "/homeserver.pid",
34
+ web_client: false,
35
+ soft_file_limit: 0,
36
+ // Needs to be configured to log to the console like a good docker process
37
+ log_config: "/data/log.config",
38
+ listeners: [
39
+ {
40
+ // Listener is always port 8008 (configured in the container)
41
+ port: 8008,
42
+ tls: false,
43
+ bind_addresses: ["::"],
44
+ type: "http",
45
+ x_forwarded: true,
46
+ resources: [
47
+ {
48
+ names: ["client"],
49
+ compress: false,
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ database: {
55
+ // An sqlite in-memory database is fast & automatically wipes each time
56
+ name: "sqlite3",
57
+ args: {
58
+ database: ":memory:",
59
+ },
60
+ },
61
+ rc_messages_per_second: 10000,
62
+ rc_message_burst_count: 10000,
63
+ rc_registration: {
64
+ per_second: 10000,
65
+ burst_count: 10000,
66
+ },
67
+ rc_joins: {
68
+ local: {
69
+ per_second: 9999,
70
+ burst_count: 9999,
71
+ },
72
+ remote: {
73
+ per_second: 9999,
74
+ burst_count: 9999,
75
+ },
76
+ },
77
+ rc_joins_per_room: {
78
+ per_second: 9999,
79
+ burst_count: 9999,
80
+ },
81
+ rc_3pid_validation: {
82
+ per_second: 1000,
83
+ burst_count: 1000,
84
+ },
85
+ rc_invites: {
86
+ per_room: {
87
+ per_second: 1000,
88
+ burst_count: 1000,
89
+ },
90
+ per_user: {
91
+ per_second: 1000,
92
+ burst_count: 1000,
93
+ },
94
+ },
95
+ rc_login: {
96
+ address: {
97
+ per_second: 10000,
98
+ burst_count: 10000,
99
+ },
100
+ account: {
101
+ per_second: 10000,
102
+ burst_count: 10000,
103
+ },
104
+ failed_attempts: {
105
+ per_second: 10000,
106
+ burst_count: 10000,
107
+ },
108
+ },
109
+ media_store_path: "/tmp/media_store",
110
+ max_upload_size: "50M",
111
+ max_image_pixels: "32M",
112
+ dynamic_thumbnails: false,
113
+ enable_registration: true,
114
+ enable_registration_without_verification: true,
115
+ disable_msisdn_registration: false,
116
+ registrations_require_3pid: [],
117
+ enable_metrics: false,
118
+ report_stats: false,
119
+ // These placeholders will be replaced with values generated at start
120
+ registration_shared_secret: "secret",
121
+ macaroon_secret_key: "secret",
122
+ form_secret: "secret",
123
+ // Signing key must be here: it will be generated to this file
124
+ signing_key_path: "/data/localhost.signing.key",
125
+ trusted_key_servers: [],
126
+ password_config: {
127
+ enabled: true,
128
+ },
129
+ ui_auth: {},
130
+ background_updates: {
131
+ // Inhibit background updates as this Synapse isn't long-lived
132
+ min_batch_size: 100000,
133
+ sleep_duration_ms: 100000,
134
+ },
135
+ enable_authenticated_media: true,
136
+ email: undefined as
137
+ | undefined
138
+ | {
139
+ enable_notifs: boolean;
140
+ smtp_host: string;
141
+ smtp_port: number;
142
+ smtp_user: string;
143
+ smtp_pass: string;
144
+ require_transport_security: false;
145
+ notif_from: string;
146
+ app_name: string;
147
+ notif_template_html: string;
148
+ notif_template_text: string;
149
+ notif_for_new_users: boolean;
150
+ client_base_url: string;
151
+ },
152
+ user_consent: undefined as
153
+ | undefined
154
+ | {
155
+ template_dir: string;
156
+ version: string;
157
+ server_notice_content: Record<string, unknown>;
158
+ send_server_notice_to_guests: boolean;
159
+ block_events_error: string;
160
+ require_at_registration: boolean;
161
+ },
162
+ server_notices: undefined as
163
+ | undefined
164
+ | {
165
+ system_mxid_localpart: string;
166
+ system_mxid_display_name: string;
167
+ system_mxid_avatar_url: string;
168
+ room_name: string;
169
+ },
170
+ allow_guest_access: false,
171
+ experimental_features: {},
172
+ oidc_providers: [],
173
+ serve_server_wellknown: true,
174
+ presence: {
175
+ enabled: true,
176
+ include_offline_users_on_sync: true,
177
+ },
178
+ room_list_publication_rules: [{ action: "allow" }],
179
+ };
180
+
181
+ /**
182
+ * Incomplete type describing the configuration for a Synapse homeserver
183
+ */
184
+ export type SynapseConfig = typeof DEFAULT_CONFIG;
185
+
186
+ /**
187
+ * A Synapse testcontainer
188
+ *
189
+ * Exposes port 8008.
190
+ * Waits for HTTP /health 8008 to 200.
191
+ */
192
+ export class SynapseContainer extends GenericContainer implements HomeserverContainer<SynapseConfig> {
193
+ private config: SynapseConfig;
194
+ private mas?: StartedMatrixAuthenticationServiceContainer;
195
+
196
+ public constructor() {
197
+ super(`ghcr.io/element-hq/synapse:${TAG}`);
198
+
199
+ this.config = deepCopy(DEFAULT_CONFIG);
200
+ this.config.registration_shared_secret = randB64Bytes(16);
201
+ this.config.macaroon_secret_key = randB64Bytes(16);
202
+ this.config.form_secret = randB64Bytes(16);
203
+
204
+ const signingKey = randB64Bytes(32);
205
+ this.withWaitStrategy(Wait.forHttp("/health", 8008)).withCopyContentToContainer([
206
+ { target: this.config.signing_key_path, content: `ed25519 x ${signingKey}` },
207
+ {
208
+ target: this.config.log_config,
209
+ content: YAML.stringify({
210
+ version: 1,
211
+ formatters: {
212
+ precise: {
213
+ format: "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s",
214
+ },
215
+ },
216
+ handlers: {
217
+ console: {
218
+ class: "logging.StreamHandler",
219
+ formatter: "precise",
220
+ },
221
+ },
222
+ loggers: {
223
+ "synapse.storage.SQL": {
224
+ level: "DEBUG",
225
+ },
226
+ "twisted": {
227
+ handlers: ["console"],
228
+ propagate: false,
229
+ },
230
+ },
231
+ root: {
232
+ level: "DEBUG",
233
+ handlers: ["console"],
234
+ },
235
+ disable_existing_loggers: false,
236
+ }),
237
+ },
238
+ ]);
239
+ }
240
+
241
+ public withConfigField(key: string, value: unknown): this {
242
+ set(this.config, key, value);
243
+ return this;
244
+ }
245
+
246
+ public withConfig(config: Partial<SynapseConfig>): this {
247
+ this.config = {
248
+ ...this.config,
249
+ ...config,
250
+ };
251
+ return this;
252
+ }
253
+
254
+ public withSmtpServer(mailpit: StartedMailpitContainer): this {
255
+ this.config.email = {
256
+ enable_notifs: false,
257
+ smtp_host: mailpit.internalHost,
258
+ smtp_port: mailpit.internalSmtpPort,
259
+ smtp_user: "username",
260
+ smtp_pass: "password",
261
+ require_transport_security: false,
262
+ notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>",
263
+ app_name: "Matrix",
264
+ notif_template_html: "notif_mail.html",
265
+ notif_template_text: "notif_mail.txt",
266
+ notif_for_new_users: true,
267
+ client_base_url: "http://localhost/element",
268
+ };
269
+ return this;
270
+ }
271
+
272
+ public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this {
273
+ this.mas = mas;
274
+ return this;
275
+ }
276
+
277
+ public override async start(): Promise<StartedSynapseContainer> {
278
+ // Synapse config public_baseurl needs to know what URL it'll be accessed from, so we have to map the port manually
279
+ const port = await getFreePort();
280
+
281
+ this.withExposedPorts({
282
+ container: 8008,
283
+ host: port,
284
+ })
285
+ .withConfig({
286
+ public_baseurl: `http://localhost:${port}`,
287
+ })
288
+ .withCopyContentToContainer([
289
+ {
290
+ target: "/data/homeserver.yaml",
291
+ content: YAML.stringify(this.config),
292
+ },
293
+ ]);
294
+
295
+ const container = await super.start();
296
+ const baseUrl = `http://localhost:${port}`;
297
+ if (this.mas) {
298
+ return new StartedSynapseWithMasContainer(
299
+ container,
300
+ baseUrl,
301
+ this.config.registration_shared_secret,
302
+ this.mas,
303
+ );
304
+ }
305
+
306
+ return new StartedSynapseContainer(container, baseUrl, this.config.registration_shared_secret);
307
+ }
308
+ }
309
+
310
+ /**
311
+ * A started Synapse testcontainer
312
+ */
313
+ export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer {
314
+ protected adminTokenPromise?: Promise<string>;
315
+ protected readonly adminApi: Api;
316
+ public readonly csApi: ClientServerApi;
317
+
318
+ public constructor(
319
+ container: StartedTestContainer,
320
+ public readonly baseUrl: string,
321
+ private readonly registrationSharedSecret: string,
322
+ ) {
323
+ super(container);
324
+ this.adminApi = new Api(`${this.baseUrl}/_synapse/admin`);
325
+ this.csApi = new ClientServerApi(this.baseUrl);
326
+ }
327
+
328
+ /**
329
+ * Restart the container
330
+ * Useful to reset the state of the homeserver between tests
331
+ * @param options - options to pass to the restart
332
+ */
333
+ public restart(options?: Partial<RestartOptions>): Promise<void> {
334
+ this.adminTokenPromise = undefined;
335
+ return super.restart(options);
336
+ }
337
+
338
+ public setRequest(request: APIRequestContext): void {
339
+ this.csApi.setRequest(request);
340
+ this.adminApi.setRequest(request);
341
+ }
342
+
343
+ public async onTestFinished(testInfo: TestInfo): Promise<void> {
344
+ // Clean up the server to prevent rooms leaking between tests
345
+ await this.deletePublicRooms();
346
+ }
347
+
348
+ protected async deletePublicRooms(): Promise<void> {
349
+ const token = await this.getAdminToken();
350
+ // We hide the rooms from the room directory to save time between tests and for portability between homeservers
351
+ const { chunk: rooms } = await this.csApi.request<{
352
+ chunk: { room_id: string }[];
353
+ }>("GET", "/v3/publicRooms", token, {});
354
+ await Promise.all(
355
+ rooms.map((room) =>
356
+ this.csApi.request("PUT", `/v3/directory/list/room/${room.room_id}`, token, { visibility: "private" }),
357
+ ),
358
+ );
359
+ }
360
+
361
+ private async registerUserInternal(
362
+ username: string,
363
+ password: string,
364
+ displayName?: string,
365
+ admin = false,
366
+ ): Promise<Credentials> {
367
+ const path = "/v1/register";
368
+ const { nonce } = await this.adminApi.request<{ nonce: string }>("GET", path, undefined, {});
369
+ const mac = crypto
370
+ .createHmac("sha1", this.registrationSharedSecret)
371
+ .update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`)
372
+ .digest("hex");
373
+ const data = await this.adminApi.request<{
374
+ home_server: string;
375
+ access_token: string;
376
+ user_id: string;
377
+ device_id: string;
378
+ }>("POST", path, undefined, {
379
+ nonce,
380
+ username,
381
+ password,
382
+ mac,
383
+ admin,
384
+ displayname: displayName,
385
+ });
386
+
387
+ return {
388
+ homeServer: data.home_server || data.user_id.split(":").slice(1).join(":"),
389
+ accessToken: data.access_token,
390
+ userId: data.user_id,
391
+ deviceId: data.device_id,
392
+ password,
393
+ displayName,
394
+ username,
395
+ };
396
+ }
397
+
398
+ protected async getAdminToken(): Promise<string> {
399
+ if (this.adminTokenPromise === undefined) {
400
+ this.adminTokenPromise = this.registerUserInternal(
401
+ "admin",
402
+ "totalyinsecureadminpassword",
403
+ undefined,
404
+ true,
405
+ ).then((res) => res.accessToken);
406
+ }
407
+ return this.adminTokenPromise;
408
+ }
409
+
410
+ private async adminRequest<R extends object>(verb: "GET", path: string, data?: never): Promise<R>;
411
+ private async adminRequest<R extends object>(verb: Verb, path: string, data?: object): Promise<R>;
412
+ private async adminRequest<R extends object>(verb: Verb, path: string, data?: object): Promise<R> {
413
+ const adminToken = await this.getAdminToken();
414
+ return this.adminApi.request(verb, path, adminToken, data);
415
+ }
416
+
417
+ /**
418
+ * Register a user on the given Homeserver using the shared registration secret.
419
+ * @param username - the username of the user to register
420
+ * @param password - the password of the user to register
421
+ * @param displayName - optional display name to set on the newly registered user
422
+ */
423
+ public registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
424
+ return this.registerUserInternal(username, password, displayName, false);
425
+ }
426
+
427
+ /**
428
+ * Logs into synapse with the given username/password
429
+ * @param userId - login username
430
+ * @param password - login password
431
+ */
432
+ public async loginUser(userId: string, password: string): Promise<Credentials> {
433
+ return this.csApi.loginUser(userId, password);
434
+ }
435
+
436
+ /**
437
+ * Binds a 3pid
438
+ * @param userId - the username of the user to bind the 3pid to
439
+ * @param medium - the medium of the 3pid to bind
440
+ * @param address - the address of the 3pid to bind
441
+ */
442
+ public async setThreepid(userId: string, medium: string, address: string): Promise<void> {
443
+ await this.adminRequest("PUT", `/v2/users/${userId}`, {
444
+ threepids: [
445
+ {
446
+ medium,
447
+ address,
448
+ },
449
+ ],
450
+ });
451
+ }
452
+ }
453
+
454
+ /**
455
+ * A started Synapse container when delegating auth to MAS
456
+ */
457
+ export class StartedSynapseWithMasContainer extends StartedSynapseContainer {
458
+ public constructor(
459
+ container: StartedTestContainer,
460
+ baseUrl: string,
461
+ registrationSharedSecret: string,
462
+ private readonly mas: StartedMatrixAuthenticationServiceContainer,
463
+ ) {
464
+ super(container, baseUrl, registrationSharedSecret);
465
+ }
466
+
467
+ protected async getAdminToken(): Promise<string> {
468
+ if (this.adminTokenPromise === undefined) {
469
+ this.adminTokenPromise = this.mas.getAdminToken();
470
+ }
471
+ return this.adminTokenPromise;
472
+ }
473
+
474
+ /**
475
+ * Register a user on the given Homeserver using the shared registration secret.
476
+ * @param username - the username of the user to register
477
+ * @param password - the password of the user to register
478
+ * @param displayName - optional display name to set on the newly registered user
479
+ */
480
+ public registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
481
+ return this.mas.registerUser(username, password, displayName);
482
+ }
483
+
484
+ /**
485
+ * Binds a 3pid
486
+ * @param userId - the username of the user to bind the 3pid to
487
+ * @param medium - the medium of the 3pid to bind
488
+ * @param address - the address of the 3pid to bind
489
+ */
490
+ public async setThreepid(userId: string, medium: string, address: string): Promise<void> {
491
+ return this.mas.setThreepid(userId, medium, address);
492
+ }
493
+ }
@@ -0,0 +1,113 @@
1
+ /*
2
+ Copyright 2025 New Vector Ltd.
3
+
4
+ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5
+ Please see LICENSE files in the repository root for full details.
6
+ */
7
+
8
+ import { type APIRequestContext } from "@playwright/test";
9
+
10
+ export type Verb = "GET" | "POST" | "PUT" | "DELETE";
11
+
12
+ /**
13
+ * A generic API client.
14
+ */
15
+ export class Api {
16
+ private _request?: APIRequestContext;
17
+
18
+ public constructor(private readonly baseUrl: string) {}
19
+
20
+ /**
21
+ * Set the request context to use for making requests.
22
+ * @param request - The request context to use.
23
+ */
24
+ public setRequest(request: APIRequestContext): void {
25
+ this._request = request;
26
+ }
27
+
28
+ /**
29
+ * Make a request to the API.
30
+ * @param verb - The HTTP verb to use.
31
+ * @param path - The path to request.
32
+ * @param token - The access token to use for the request.
33
+ * @param data - The data to send with the request.
34
+ */
35
+ public async request<R extends object>(verb: "GET", path: string, token?: string, data?: never): Promise<R>;
36
+ public async request<R extends object>(verb: Verb, path: string, token?: string, data?: object): Promise<R>;
37
+ public async request<R extends object>(verb: Verb, path: string, token?: string, data?: object): Promise<R> {
38
+ if (!this._request) {
39
+ throw new Error("No request context set");
40
+ }
41
+
42
+ const url = `${this.baseUrl}${path}`;
43
+ const res = await this._request.fetch(url, {
44
+ data,
45
+ method: verb,
46
+ headers: token
47
+ ? {
48
+ Authorization: `Bearer ${token}`,
49
+ }
50
+ : undefined,
51
+ });
52
+
53
+ if (!res.ok()) {
54
+ throw new Error(
55
+ `Request to ${url} failed with status ${res.status()}: ${JSON.stringify(await res.json())}`,
56
+ );
57
+ }
58
+
59
+ return res.json();
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Credentials for a user.
65
+ */
66
+ export interface Credentials {
67
+ accessToken: string;
68
+ userId: string;
69
+ deviceId: string;
70
+ homeServer: string;
71
+ password: string | null; // null for password-less users
72
+ displayName?: string;
73
+ username: string; // the localpart of the userId
74
+ }
75
+
76
+ /**
77
+ * A client-server API for interacting with a Matrix homeserver.
78
+ */
79
+ export class ClientServerApi extends Api {
80
+ public constructor(baseUrl: string) {
81
+ super(`${baseUrl}/_matrix/client`);
82
+ }
83
+
84
+ /**
85
+ * Register a user on the homeserver.
86
+ * @param userId - The user ID to register.
87
+ * @param password - The password to use for the user.
88
+ */
89
+ public async loginUser(userId: string, password: string): Promise<Credentials> {
90
+ const json = await this.request<{
91
+ access_token: string;
92
+ user_id: string;
93
+ device_id: string;
94
+ home_server: string;
95
+ }>("POST", "/v3/login", undefined, {
96
+ type: "m.login.password",
97
+ identifier: {
98
+ type: "m.id.user",
99
+ user: userId,
100
+ },
101
+ password: password,
102
+ });
103
+
104
+ return {
105
+ password,
106
+ accessToken: json.access_token,
107
+ userId: json.user_id,
108
+ deviceId: json.device_id,
109
+ homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"),
110
+ username: userId.slice(1).split(":")[0],
111
+ };
112
+ }
113
+ }
@@ -0,0 +1,79 @@
1
+ /*
2
+ Copyright 2024-2025 New Vector Ltd.
3
+
4
+ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5
+ Please see LICENSE files in the repository root for full details.
6
+ */
7
+
8
+ import { type BrowserContext, type Page, type TestInfo } from "@playwright/test";
9
+ import { type Readable } from "node:stream";
10
+ import stripAnsi from "strip-ansi";
11
+
12
+ /**
13
+ * A logger that captures console logs from pages and testcontainers.
14
+ */
15
+ export class Logger {
16
+ private pages: Page[] = [];
17
+ private logs: Record<string, string> = {};
18
+
19
+ /**
20
+ * Get a consumer function that captures logs for a given container.
21
+ * @param container - the human-readable name of the container.
22
+ */
23
+ public getConsumer(container: string) {
24
+ this.logs[container] = "";
25
+ return (stream: Readable) => {
26
+ stream.on("data", (chunk) => {
27
+ this.logs[container] += chunk.toString();
28
+ });
29
+ stream.on("err", (chunk) => {
30
+ this.logs[container] += "ERR " + chunk.toString();
31
+ });
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Hook to call when a test starts.
37
+ * @param context - the browser context of the test.
38
+ */
39
+ public async onTestStarted(context: BrowserContext) {
40
+ this.pages = [];
41
+ for (const id in this.logs) {
42
+ if (id.startsWith("page-")) {
43
+ delete this.logs[id];
44
+ } else {
45
+ this.logs[id] = "";
46
+ }
47
+ }
48
+
49
+ context.on("console", (msg) => {
50
+ const page = msg.page();
51
+ if (!page) return;
52
+ let pageIdx = this.pages.indexOf(page);
53
+ if (pageIdx === -1) {
54
+ this.pages.push(page);
55
+ pageIdx = this.pages.length - 1;
56
+ this.logs[`page-${pageIdx}`] = `Console logs for page with URL: ${page.url()}\n\n`;
57
+ }
58
+ const type = msg.type();
59
+ const text = msg.text();
60
+ this.logs[`page-${pageIdx}`] += `${type}: ${text}\n`;
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Hook to call when a test finishes.
66
+ * @param testInfo - the info about the test that just finished.
67
+ */
68
+ public async onTestFinished(testInfo: TestInfo) {
69
+ if (testInfo.status !== "passed") {
70
+ for (const id in this.logs) {
71
+ if (!this.logs[id]) continue;
72
+ await testInfo.attach(id, {
73
+ body: stripAnsi(this.logs[id]),
74
+ contentType: "text/plain",
75
+ });
76
+ }
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,16 @@
1
+ /*
2
+ Copyright 2024-2025 New Vector Ltd.
3
+
4
+ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
5
+ Please see LICENSE files in the repository root for full details.
6
+ */
7
+
8
+ /**
9
+ * Deep copy the given object. The object MUST NOT have circular references and
10
+ * MUST NOT have functions.
11
+ * @param obj - The object to deep copy.
12
+ * @returns A copy of the object without any references to the original.
13
+ */
14
+ export function deepCopy<T>(obj: T): T {
15
+ return JSON.parse(JSON.stringify(obj));
16
+ }