feat: show call participants in room list (Discord-style)
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled

This commit is contained in:
sorB
2026-05-10 14:25:35 +02:00
parent b797925316
commit 3da363517f
4610 changed files with 827237 additions and 1 deletions
@@ -0,0 +1,12 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
declare module "playwright-core/lib/utils" {
// This type is not public in playwright-core utils
export function sanitizeForFilePath(filePath: string): string;
}
@@ -0,0 +1,37 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect as baseExpect, type ExpectMatcherState, type MatcherReturnType } from "@playwright/test";
import type { AxeBuilder } from "@axe-core/playwright";
export type Expectations = {
/**
* Assert that the given AxeBuilder instance has no violations.
* @param receiver - The AxeBuilder instance to check.
*/
toHaveNoViolations: (this: ExpectMatcherState, receiver: AxeBuilder) => Promise<MatcherReturnType>;
};
export const expect = baseExpect.extend<Expectations>({
async toHaveNoViolations(this: ExpectMatcherState, receiver: AxeBuilder) {
const testInfo = test.info();
if (!testInfo) throw new Error(`toHaveNoViolations() must be called during the test`);
const results = await receiver.analyze();
await testInfo.attach("accessibility-scan-results", {
body: JSON.stringify(results, null, 2),
contentType: "application/json",
});
baseExpect(results.violations).toEqual([]);
return { pass: true, message: (): string => "", name: "toHaveNoViolations" };
},
});
@@ -0,0 +1,21 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { mergeExpects, type Expect } from "@playwright/test";
import {
expect as screenshotExpectations,
type Expectations as ScreenshotExpectations,
type ToMatchScreenshotOptions,
} from "./screenshot.js";
import { expect as axeExpectations, type Expectations as AxeExpectations } from "./axe.js";
export const expect = mergeExpects(screenshotExpectations, axeExpectations) as Expect<
ScreenshotExpectations & AxeExpectations
>;
export type { ToMatchScreenshotOptions };
@@ -0,0 +1,79 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
test,
expect as baseExpect,
type ElementHandle,
type ExpectMatcherState,
type Locator,
type Page,
type PageAssertionsToHaveScreenshotOptions,
type MatcherReturnType,
} from "@playwright/test";
import { sanitizeForFilePath } from "playwright-core/lib/utils";
import { extname } from "node:path";
import { ANNOTATION } from "../stale-screenshot-reporter.js";
// Based on https://github.com/microsoft/playwright/blob/2b77ed4d7aafa85a600caa0b0d101b72c8437eeb/packages/playwright/src/util.ts#L206C8-L210C2
function sanitizeFilePathBeforeExtension(filePath: string): string {
const ext = extname(filePath);
const base = filePath.substring(0, filePath.length - ext.length);
return sanitizeForFilePath(base) + ext;
}
export interface ToMatchScreenshotOptions extends PageAssertionsToHaveScreenshotOptions {
css?: string;
}
export type Expectations = {
toMatchScreenshot: (
this: ExpectMatcherState,
receiver: Page | Locator,
name: `${string}.png`,
options?: ToMatchScreenshotOptions,
) => Promise<MatcherReturnType>;
};
/**
* Provides an upgrade to the `toHaveScreenshot` expectation.
* Unfortunately, we can't just extend the existing `toHaveScreenshot` expectation
*/
export const expect = baseExpect.extend<Expectations>({
async toMatchScreenshot(receiver, name, options) {
const testInfo = test.info();
if (!testInfo) throw new Error(`toMatchScreenshot() must be called during the test`);
if (!testInfo.tags.includes("@screenshot")) {
throw new Error("toMatchScreenshot() must be used in a test tagged with @screenshot");
}
const page = "page" in receiver ? receiver.page() : receiver;
let style: ElementHandle<Element> | undefined;
if (options?.css) {
// We add a custom style tag before taking screenshots
style = (await page.addStyleTag({
content: options.css,
})) as ElementHandle<Element>;
}
const screenshotName = sanitizeFilePathBeforeExtension(name);
await baseExpect(receiver).toHaveScreenshot(screenshotName, options);
await style?.evaluate((tag) => tag.remove());
testInfo.annotations.push({
type: ANNOTATION,
description: testInfo.snapshotPath(screenshotName),
});
return { pass: true, message: (): string => "", name: "toMatchScreenshot" };
},
});
@@ -0,0 +1,24 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test as base } from "@playwright/test";
import { AxeBuilder } from "@axe-core/playwright";
// This fixture is useful for simple component library tests that won't want any extra services like a homeserver, so we
// explicitly avoid pulling anything more than playwright's base fixtures in.
export const test = base.extend<{
/**
* AxeBuilder instance for the current page
*/
axe: AxeBuilder;
}>({
axe: async ({ page }, use) => {
const builder = new AxeBuilder({ page });
await use(builder);
},
});
@@ -0,0 +1,12 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export { type Services, type WorkerOptions } from "./services.js";
// We avoid using `mergeTests` because it drops useful type information about the fixtures.
// `user` is the top of our stack of extensions (it extends services, axe, etc), so it includes everything.
export { test } from "./user.js";
@@ -0,0 +1,171 @@
/*
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MailpitClient } from "mailpit-api";
import { Network, type StartedNetwork } from "testcontainers";
import { type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import {
type SynapseConfig,
SynapseContainer,
type StartedMatrixAuthenticationServiceContainer,
type HomeserverContainer,
type StartedHomeserverContainer,
MailpitContainer,
type StartedMailpitContainer,
} from "../testcontainers/index.js";
import { Logger } from "../utils/logger.js";
// We want to avoid using `mergeTests` in index.ts because it drops useful type information about the fixtures. Instead,
// we add `axe` into our fixture suite by using its `test` as a base, so that there is a linear hierarchy.
import { test as base } from "./axe.js";
import { makePostgres } from "../testcontainers/postgres.js";
/**
* Test-scoped fixtures available in the test
*/
export interface TestFixtures {
/**
* The mailpit client instance for the test.
* This is a fresh client instance with no messages from prior tests.
*/
mailpitClient: MailpitClient;
}
export interface WorkerOptions {
/**
* The synapse configuration to use for the homeserver.
*/
synapseConfig: Partial<SynapseConfig>;
}
/**
* Worker-scoped "service" fixtures available in the test
*/
export interface Services {
/**
* The logger instance for the worker.
*/
logger: Logger;
/**
* The started testcontainers network instance for the worker.
*/
network: StartedNetwork;
/**
* The started postgres container instance for the worker.
*/
postgres: StartedPostgreSqlContainer;
/**
* The started mailpit container instance for the worker.
*/
mailpit: StartedMailpitContainer;
/**
* The homeserver instance container to use for the worker.
*/
_homeserver: HomeserverContainer<unknown>;
/**
* The started homeserver instance container for the worker.
*/
homeserver: StartedHomeserverContainer;
/**
* The Matrix Authentication Service container instance for the worker.
* May be undefined if no delegated auth is in use.
*/
mas?: StartedMatrixAuthenticationServiceContainer;
}
export const test = base.extend<TestFixtures, WorkerOptions & Services>({
logger: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
const logger = new Logger();
await use(logger);
},
{ scope: "worker" },
],
network: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
const network = await new Network().start();
await use(network);
await network.stop();
},
{ scope: "worker" },
],
postgres: [
async ({ logger, network }, use) => {
const container = await makePostgres(network, logger);
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mailpit: [
async ({ logger, network }, use) => {
const container = await new MailpitContainer()
.withNetwork(network)
.withNetworkAliases("mailpit")
.withLogConsumer(logger.getConsumer("mailpit"))
.start();
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mailpitClient: async ({ mailpit: container }, use) => {
await container.client.deleteMessages();
await use(container.client);
},
synapseConfig: [{}, { scope: "worker" }],
_homeserver: [
async ({ logger }, use) => {
const container = new SynapseContainer().withLogConsumer(logger.getConsumer("synapse"));
await use(container);
},
{ scope: "worker" },
],
homeserver: [
async ({ logger, network, _homeserver: homeserver, synapseConfig, mas }, use) => {
if (homeserver instanceof SynapseContainer) {
homeserver.withConfig(synapseConfig);
}
const container = await homeserver
.withNetwork(network)
.withNetworkAliases("homeserver")
.withLogConsumer(logger.getConsumer("homeserver"))
.withMatrixAuthenticationService(mas)
.start();
await use(container);
await container.stop();
},
{ scope: "worker" },
],
mas: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
// we stub the mas fixture to allow `homeserver` to depend on it to ensure
// when it is specified by `masHomeserver` it is started before the homeserver
await use(undefined);
},
{ scope: "worker" },
],
context: async ({ logger, context, request, homeserver }, use, testInfo) => {
homeserver.setRequest(request);
await logger.onTestStarted(context);
await use(context);
await logger.onTestFinished(testInfo);
await homeserver.onTestFinished(testInfo);
},
});
@@ -0,0 +1,163 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { expect, type Locator, type Page } from "@playwright/test";
// We want to avoid using `mergeTests` in index.ts because it drops useful type
// information about the fixtures. Instead, we add `services` into our fixture
// suite by using its `test` as a base, so that there is a linear hierarchy.
import { test as base } from "./services.js";
// This fixture provides convenient handling of Element Web's toasts.
export const test = base.extend<{
/**
* Convenience functions for handling toasts.
*/
toasts: Toasts;
}>({
toasts: async ({ page }, use) => {
const toasts = new Toasts(page);
await use(toasts);
},
});
class Toasts {
public constructor(public readonly page: Page) {}
/**
* Assert that no toasts exist.
*/
public async assertNoToasts(): Promise<void> {
await expect(this.page.locator(".mx_Toast_toast")).not.toBeVisible();
}
/**
* Return the toast with the supplied title. Fail or return null if it does
* not exist.
*
* If `required` is false, you should supply a relatively short `timeout`
* (e.g. 2000, meaning 2 seconds) to prevent your test taking too long.
*
* @param title - Expected title of the toast.
* @param timeout - Time in ms before we give up and decide the toast does
* not exist. If `required` is true, defaults to `timeout`
* in `TestConfig.expect`. Otherwise, defaults to 2000 (2
* seconds).
* @param required - If true, fail the test (throw an exception) if the
* toast is not visible. Otherwise, just return null if
* the toast is not visible.
* @returns the Locator for the matching toast, or null if it is not
* visible. (null will only be returned if `required` is false.)
*/
public async getToast(title: string, timeout?: number, required?: true): Promise<Locator>;
public async getToast(title: string, timeout: number | undefined, required: false): Promise<Locator | null>;
public async getToast(title: string, timeout?: number, required = true): Promise<Locator | null> {
const toast = this.page.locator(".mx_Toast_toast", { hasText: title }).first();
if (required) {
await expect(toast).toBeVisible({ timeout });
return toast;
} else {
// If we don't set a timeout, waitFor will wait forever. Since
// required is false, we definitely don't want to wait forever.
timeout = timeout ?? 2000;
try {
await toast.waitFor({ state: "visible", timeout });
return toast;
} catch {
return null;
}
}
}
/**
* Accept the toast with the supplied title, or fail if it does not exist.
*
* Only works if this toast is at the top of the stack of toasts.
*
* @param title - Expected title of the toast.
*/
public async acceptToast(title: string): Promise<void> {
return await clickToastButton(this, title, "primary");
}
/**
* Accept the toast with the supplied title, if it exists, or return after 2
* seconds if it is not found.
*
* Only works if this toast is at the top of the stack of toasts.
*
* @param title - Expected title of the toast.
*/
public async acceptToastIfExists(title: string): Promise<void> {
return await clickToastButton(this, title, "primary", 2000, false);
}
/**
* Reject the toast with the supplied title, or fail if it does not exist.
*
* Only works if this toast is at the top of the stack of toasts.
*
* @param title - Expected title of the toast.
*/
public async rejectToast(title: string): Promise<void> {
return await clickToastButton(this, title, "secondary");
}
/**
* Reject the toast with the supplied title, if it exists, or return after 2
* seconds if it is not found.
*
* Only works if this toast is at the top of the stack of toasts.
*
* @param title - Expected title of the toast.
*/
public async rejectToastIfExists(title: string): Promise<void> {
return await clickToastButton(this, title, "secondary", 2000, false);
}
}
/**
* Find the toast with the supplied title and click a button on it.
*
* Only works if this toast is at the top of the stack of toasts.
*
* If `required` is false, you should supply a relatively short `timeout`
* (e.g. 2000, meaning 2 seconds) to prevent your test taking too long.
*
* @param toasts - A Toasts instance.
* @param title - Expected title of the toast.
* @param button - Which button to click on the toast. Allowed values are
* "primary", which will accept the toast, or "secondary",
* which will reject it.
* @param timeout - Time in ms before we give up and decide the toast does
* not exist. If `required` is true, defaults to `timeout`
* in `TestConfig.expect`. Otherwise, defaults to 2000 (2
* seconds).
* @param required - If true, fail the test (throw an exception) if the
* toast is not visible. Otherwise, just return after
* `timeout` if the toast is not visible.
*/
async function clickToastButton(
toasts: Toasts,
title: string,
button: "primary" | "secondary",
timeout?: number,
required = true,
): Promise<void> {
let toast: Locator | null;
if (required) {
toast = await toasts.getToast(title, timeout, true);
} else {
toast = await toasts.getToast(title, timeout, false);
}
if (toast) {
await toast.locator(`.mx_Toast_buttons button[data-kind="${button}"]`).click();
}
}
@@ -0,0 +1,104 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { sample, uniqueId } from "lodash-es";
// We want to avoid using `mergeTests` in index.ts because it drops useful type
// information about the fixtures. Instead, we add `toasts` into our fixture
// suite by using its `test` as a base, so that there is a linear hierarchy.
import { test as base } from "./toasts.js";
import { type Credentials } from "../utils/api.js";
/** Adds an initScript to the given page which will populate localStorage appropriately so that Element will use the given credentials. */
export async function populateLocalStorageWithCredentials(page: Page, credentials: Credentials) {
await page.addInitScript(
({ credentials }) => {
window.localStorage.setItem("mx_hs_url", credentials.homeserverBaseUrl);
window.localStorage.setItem("mx_user_id", credentials.userId);
window.localStorage.setItem("mx_access_token", credentials.accessToken);
window.localStorage.setItem("mx_device_id", credentials.deviceId);
window.localStorage.setItem("mx_is_guest", "false");
window.localStorage.setItem("mx_has_pickle_key", "false");
window.localStorage.setItem("mx_has_access_token", "true");
window.localStorage.setItem(
"mx_local_settings",
JSON.stringify({
// Retain any other settings which may have already been set
...JSON.parse(window.localStorage.getItem("mx_local_settings") ?? "{}"),
// Ensure the language is set to a consistent value
language: "en",
}),
);
},
{ credentials },
);
}
export const test = base.extend<{
/**
* The displayname to use for the user registered in {@link #credentials}.
*
* To set it, call `test.use({ displayName: "myDisplayName" })` in the test file or `describe` block.
* See {@link https://playwright.dev/docs/api/class-test#test-use}.
*/
displayName?: string;
/**
* A test fixture which registers a test user on the {@link #homeserver} and supplies the details
* of the registered user.
*/
credentials: Credentials;
/**
* The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`},
* but adds an initScript which will populate localStorage with the user's details from
* {@link #credentials} and {@link #homeserver}.
*
* Similar to {@link #user}, but doesn't load the app.
*/
pageWithCredentials: Page;
/**
* A (rather poorly-named) test fixture which registers a user per {@link #credentials}, stores
* the credentials into localStorage per {@link #pageWithCredentials}, and then loads the front page of the
* app.
*/
user: Credentials;
}>({
displayName: undefined,
// We don't directly depend upon the `context` fixture, but we do need to make sure that it has been run
// before this fixture, since it is responsible for configuring the APIRequestContext on the homeserver, so
// without it we cannot register the user.
credentials: async ({ context, homeserver, displayName: testDisplayName }, use, testInfo) => {
const names = ["Alice", "Bob", "Charlie", "Daniel", "Eve", "Frank", "Grace", "Hannah", "Isaac", "Judy"];
const password = uniqueId("password_");
const displayName = testDisplayName ?? sample(names)!;
const credentials = await homeserver.registerUser(`user_${testInfo.testId}`, password, displayName);
console.log(`Registered test user ${credentials.userId} with displayname ${displayName}`);
await use({
...credentials,
displayName,
});
},
pageWithCredentials: async ({ page, credentials }, use) => {
await populateLocalStorageWithCredentials(page, credentials);
await use(page);
},
user: async ({ pageWithCredentials: page, credentials }, use) => {
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await use(credentials);
},
});
@@ -0,0 +1,188 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/**
* Flaky test reporter, creating & updating GitHub issues
* Only intended to run from within GitHub Actions
*/
import type { Reporter, TestCase } from "@playwright/test/reporter";
const REPO = "element-hq/element-web";
const LABEL = "Z-Flaky-Test";
const ISSUE_TITLE_PREFIX = "Flaky playwright test: ";
type PaginationLinks = {
prev?: string;
next?: string;
last?: string;
first?: string;
};
const ANSI_COLOUR_REGEX = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
// We see quite a few test flakes which are caused by the app exploding
// so we have some magic strings we check the logs for to better track the flake with its cause
const SPECIAL_CASES: Record<string, string> = {
"ChunkLoadError": "ChunkLoadError",
"Unreachable code should not be executed": "Rust crypto panic",
"Out of bounds memory access": "Rust crypto memory error",
};
class FlakyReporter implements Reporter {
private flakes = new Map<string, TestCase[]>();
public onTestEnd(test: TestCase): void {
// Ignores flakes on Dendrite and Pinecone as they have their own flakes we do not track
if (["Dendrite", "Pinecone"].includes(test.parent.project()!.name!)) return;
if (test.outcome() === "flaky") {
const failures: string[] = [];
const timedOutRuns = test.results.filter((result) => result.status === "timedOut");
const pageLogs = timedOutRuns.flatMap((result) =>
result.attachments.filter((attachment) => attachment.name.startsWith("page-")),
);
// If a test failed due to a systemic fault then the test is not flaky, the app is, record it as such.
const specialCases = Object.keys(SPECIAL_CASES).filter((log) =>
pageLogs.some((attachment) => attachment.name.startsWith("page-") && attachment.body?.includes(log)),
);
if (specialCases.length > 0) {
failures.push(...specialCases.map((specialCase) => SPECIAL_CASES[specialCase]));
}
// Check for fixtures failing to set up
const errorMessages = timedOutRuns
.map((r) => r.error?.message?.replace(ANSI_COLOUR_REGEX, ""))
.filter(Boolean) as string[];
for (const error of errorMessages) {
if (error.startsWith("Fixture") && error.endsWith("exceeded during setup.")) {
failures.push(error);
}
}
if (failures.length < 1) {
failures.push(`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`);
}
for (const title of failures) {
if (!this.flakes.has(title)) {
this.flakes.set(title, []);
}
this.flakes.get(title)!.push(test);
}
}
}
/**
* Parse link header to retrieve pagination links
* @see https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28#using-link-headers
* @param link link header from response or undefined
* @returns an empty object if link is undefined otherwise returns a map from type to link
*/
private parseLinkHeader(link: string): PaginationLinks {
/**
* link looks like:
* <https://api.github.com/repositories/1300192/issues?page=2>; rel="prev", <https://api.github.com/repositories/1300192/issues?page=4>;
*/
const map: PaginationLinks = {};
if (!link) return map;
const matches = link.matchAll(/(<(?<link>.+?)>; rel="(?<type>.+?)")/g);
for (const match of matches) {
const { link, type } = match.groups!;
map[type as keyof PaginationLinks] = link;
}
return map;
}
/**
* Fetch all flaky test issues that were updated since Jan-1-2024
* @returns A promise that resolves to a list of issues
*/
async getAllIssues(): Promise<any[]> {
const issues = [];
const { GITHUB_TOKEN, GITHUB_API_URL } = process.env;
// See https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#list-repository-issues
let url = `${GITHUB_API_URL}/repos/${REPO}/issues?labels=${LABEL}&state=all&per_page=100&sort=updated&since=2024-01-01`;
const headers = {
Authorization: `Bearer ${GITHUB_TOKEN}`,
Accept: "application / vnd.github + json",
};
while (url) {
// Fetch issues and add to list
const issuesResponse = await fetch(url, { headers });
const fetchedIssues = await issuesResponse.json();
issues.push(...fetchedIssues);
// Get the next link for fetching more results
const linkHeader = issuesResponse.headers.get("Link")!;
const parsed = this.parseLinkHeader(linkHeader);
url = parsed.next!;
}
return issues;
}
public async onExit(): Promise<void> {
if (this.flakes.size === 0) {
console.log("No flakes found");
return;
}
console.log("Found flakes: ");
for (const flake of this.flakes) {
console.log(flake);
}
const { GITHUB_TOKEN, GITHUB_API_URL, GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID } = process.env;
if (!GITHUB_TOKEN) return;
const issues = await this.getAllIssues();
for (const [flake, results] of this.flakes) {
const title = ISSUE_TITLE_PREFIX + "`" + flake + "`";
const existingIssue = issues.find((issue) => issue.title === title);
const headers = { Authorization: `Bearer ${GITHUB_TOKEN}` };
const body = `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`;
const labels = [LABEL, ...results.map((test) => `${LABEL}-${test.parent.project()?.name}`)];
if (existingIssue) {
console.log(`Found issue ${existingIssue.number} for ${flake}, adding comment...`);
// Ensure that the test is open
await fetch(existingIssue.url, {
method: "PATCH",
headers,
body: JSON.stringify({ state: "open" }),
});
await fetch(`${existingIssue.url}/labels`, {
method: "POST",
headers,
body: JSON.stringify({ labels }),
});
await fetch(`${existingIssue.url}/comments`, {
method: "POST",
headers,
body: JSON.stringify({ body }),
});
} else {
console.log(`Creating new issue for ${flake}...`);
await fetch(`${GITHUB_API_URL}/repos/${REPO}/issues`, {
method: "POST",
headers,
body: JSON.stringify({
title,
body,
labels: [...labels],
}),
});
}
}
}
}
export default FlakyReporter;
+102
View File
@@ -0,0 +1,102 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Config as BaseConfig } from "@element-hq/element-web-module-api";
import { test as base } from "./fixtures/index.js";
import { routeConfigJson } from "./utils/config_json.js";
export * from "./utils/config_json.js";
export * from "./utils/context.js";
export { populateLocalStorageWithCredentials } from "./fixtures/user.js";
// Enable experimental service worker support
// See https://playwright.dev/docs/service-workers-experimental#how-to-enable
process.env["PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS"] = "1";
// We extend the Module API Config interface so that all modules
// which use declaration merging will have their config types correctly applied.
export interface Config extends BaseConfig {
default_server_config: {
"m.homeserver"?: {
base_url: string;
server_name?: string;
};
"m.identity_server"?: {
base_url: string;
server_name?: string;
};
};
enable_presence_by_hs_url?: Record<string, boolean>;
setting_defaults: Record<string, unknown>;
map_style_url?: string;
features: Record<string, boolean>;
modules?: string[];
}
// This is deliberately quite a minimal config.json, so that we can test that the default settings actually work.
export const CONFIG_JSON: Partial<Config> = {
default_server_config: {},
// The default language is set here for test consistency
setting_defaults: {
language: "en-GB",
},
// 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,
},
};
export interface TestFixtures {
/**
* The contents of the config.json to send when the client requests it.
*/
config: Partial<typeof CONFIG_JSON>;
labsFlags: string[];
disablePresence: boolean;
/**
* Whether the left panel should have its width fixed.
* This is done because the library that we use for rendering collapsible
* panels uses math to calculate the width which can sometimes leads to +/-1px
* difference. While this does not matter to the user, it can lead to screenshot
* tests failing.
* Defaults to true, should be set to false via {@link base.use} when you want to test the collapse
* behaviour.
*/
lockLeftPanelWidth: boolean;
}
export const test = base.extend<TestFixtures>({
// We merge this atop the default CONFIG_JSON in the page fixture to make extending it easier
config: async ({}, use) => use({}),
labsFlags: async ({}, use) => use([]),
disablePresence: async ({}, use) => use(false),
lockLeftPanelWidth: true,
page: async ({ homeserver, context, page, config, labsFlags, disablePresence, lockLeftPanelWidth }, use) => {
await routeConfigJson(context, homeserver.baseUrl, config, labsFlags, disablePresence);
if (lockLeftPanelWidth) {
await page.addStyleTag({
content: `
#left-panel {
flex: 0 0 369.6875px !important;
}
`,
});
}
await use(page);
},
});
export { expect, type ToMatchScreenshotOptions } from "./expect/index.js";
@@ -0,0 +1,104 @@
/*
Copyright 2024 - 2025 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/**
* Test reporter which compares the reported screenshots vs those on disk to find stale screenshots
* Only intended to run from within GitHub Actions
*/
import { glob } from "glob";
import path from "node:path";
import { type Reporter, type TestCase } from "@playwright/test/reporter";
import { type FullConfig } from "@playwright/test";
/**
* The annotation type used to mark screenshots in tests.
* `_` prefix hides it from the HTML reporter
*/
export const ANNOTATION = "_screenshot";
class StaleScreenshotReporter implements Reporter {
private readonly snapshotRoots = new Set<string>();
private readonly screenshots = new Set<string>();
private readonly failing = new Set<string>();
private success = true;
public onBegin(config: FullConfig): void {
for (const project of config.projects) {
this.snapshotRoots.add(project.snapshotDir);
}
}
public onTestEnd(test: TestCase): void {
if (!test.ok()) {
this.failing.add(test.id);
return;
}
this.failing.delete(test.id); // delete if passed on re-run
for (const annotation of test.annotations) {
if (annotation.type === ANNOTATION && annotation.description) {
this.screenshots.add(annotation.description);
}
}
}
private error(msg: string, file: string) {
if (process.env.GITHUB_ACTIONS) {
console.log(`::error file=${file}::${msg}`);
}
console.error(msg, file);
this.success = false;
}
private async checkStaleScreenshots(): Promise<void> {
if (!this.snapshotRoots.size) {
this.error("No snapshot directories found, did you set the snapshotDir in your Playwright config?", "");
return;
}
const screenshotFiles = new Set<string>();
for (const snapshotRoot of this.snapshotRoots) {
const files = await glob(`**/*.png`, { cwd: snapshotRoot });
for (const file of files) {
screenshotFiles.add(path.join(snapshotRoot, file));
}
}
for (const screenshot of screenshotFiles) {
if (screenshot.split("-").at(-1) !== "linux.png") {
this.error(
"Found screenshot belonging to different platform, this should not be checked in",
screenshot,
);
}
}
for (const screenshot of this.screenshots) {
screenshotFiles.delete(screenshot);
}
if (screenshotFiles.size > 0) {
for (const screenshot of screenshotFiles) {
this.error("Stale screenshot file", screenshot);
}
}
}
public async onExit(): Promise<void> {
if (this.failing.size) {
this.error(`${this.failing.size} tests failed, skipping stale screenshot reporter.`, "");
} else {
await this.checkStaleScreenshots();
}
if (!this.success) {
process.exit(1);
}
}
}
export default StaleScreenshotReporter;
@@ -0,0 +1,87 @@
/*
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type AbstractStartedContainer, type GenericContainer } from "testcontainers";
import { type APIRequestContext, type TestInfo } from "@playwright/test";
import { type StartedMatrixAuthenticationServiceContainer } from "./mas.js";
import { type ClientServerApi, type Credentials } from "../utils/api.js";
import { type StartedMailpitContainer } from "./mailpit.js";
export interface HomeserverInstance {
readonly baseUrl: string;
readonly csApi: ClientServerApi;
/**
* Register a user on the given Homeserver using the shared registration secret.
* @param username the username of the user to register
* @param password the password of the user to register
* @param displayName optional display name to set on the newly registered user
*/
registerUser(username: string, password: string, displayName?: string): Promise<Credentials>;
/**
* Logs into synapse with the given username/password
* @param userId login username
* @param password login password
*/
loginUser(userId: string, password: string): Promise<Credentials>;
/**
* Sets a third party identifier for the given user. This only supports setting a single 3pid and will
* replace any others.
* @param userId The full ID of the user to edit (as returned from registerUser)
* @param medium The medium of the 3pid to set
* @param address The address of the 3pid to set
*/
setThreepid(userId: string, medium: string, address: string): Promise<void>;
}
export interface HomeserverContainer<Config> extends GenericContainer {
/**
* Set a configuration field in the config
* @param key - the key to set
* @param value - the value to set
*/
withConfigField<Key extends keyof Config>(key: Key, value: Config[Key]): this;
/**
* Merge a partial configuration into the config
* @param config - the partial configuration to merge
*/
withConfig(config: Partial<Config>): this;
/**
* Set the SMTP server to use for sending emails
* @param mailpit - the mailpit container to use
*/
withSmtpServer(mailpit: StartedMailpitContainer): this;
/**
* Set the MAS server to use for delegated auth
* @param mas - the MAS container to use
*/
withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this;
/**
* Start the container
*/
start(): Promise<StartedHomeserverContainer>;
}
export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance {
/**
* Set the request context for the APIs
* @param request - the request context to set
*/
setRequest(request: APIRequestContext): void;
/**
* Clean up the server to prevent rooms leaking between tests
* @param testInfo - the test info for the test that just finished
*/
onTestFinished(testInfo: TestInfo): Promise<void>;
}
@@ -0,0 +1,18 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
export { makePostgres } from "./postgres.js";
export type { HomeserverInstance, HomeserverContainer, StartedHomeserverContainer } from "./HomeserverContainer.js";
export { type SynapseConfig, SynapseContainer, StartedSynapseContainer } from "./synapse.js";
export {
type MasConfig,
MatrixAuthenticationServiceContainer,
StartedMatrixAuthenticationServiceContainer,
makeMas,
} from "./mas.js";
export { type MailpitClient, MailpitContainer, StartedMailpitContainer } from "./mailpit.js";
@@ -0,0 +1,62 @@
/*
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { AbstractStartedContainer, GenericContainer, type StartedTestContainer, Wait } from "testcontainers";
import { MailpitClient } from "mailpit-api";
export type { MailpitClient };
/**
* A testcontainer for Mailpit.
*
* Exposes port 8025.
* Waits for listening ports.
* Disables SMTP authentication.
*/
export class MailpitContainer extends GenericContainer {
public constructor() {
super("axllent/mailpit:latest");
this.withExposedPorts(8025).withWaitStrategy(Wait.forListeningPorts()).withEnvironment({
MP_SMTP_AUTH_ALLOW_INSECURE: "true",
MP_SMTP_AUTH_ACCEPT_ANY: "true",
});
}
/**
* Start the Mailpit container.
*/
public override async start(): Promise<StartedMailpitContainer> {
return new StartedMailpitContainer(await super.start());
}
}
/**
* A started Mailpit container.
*/
export class StartedMailpitContainer extends AbstractStartedContainer {
public readonly client: MailpitClient;
public constructor(container: StartedTestContainer) {
super(container);
this.client = new MailpitClient(`http://${container.getHost()}:${container.getMappedPort(8025)}`);
}
/**
* Get the hostname to use to connect to the Mailpit container from inside the docker network.
*/
public get internalHost(): string {
return "mailpit";
}
/**
* Get the port to use to connect to the Mailpit container from inside the docker network.
*/
public get internalSmtpPort(): number {
return 1025;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,369 @@
/*
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
AbstractStartedContainer,
GenericContainer,
type StartedTestContainer,
Wait,
type ExecResult,
type StartedNetwork,
} from "testcontainers";
import { type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import * as YAML from "yaml";
import { getFreePort } from "../utils/port.js";
import { deepCopy } from "../utils/object.js";
import { type Credentials } from "../utils/api.js";
// This file can be updated by running:
//
// curl -sL https://element-hq.github.io/matrix-authentication-service/config.schema.json \
// | npx json-schema-to-typescript -o packages/element-web-playwright-common/src/testconainers/mas-config.ts
import type { RootConfig as MasConfig } from "./mas-config.js";
import type { Logger } from "../utils/logger.js";
export { type MasConfig };
const DEFAULT_CONFIG = {
http: {
listeners: [
{
name: "web",
resources: [
{ name: "discovery" },
{ name: "human" },
{ name: "oauth" },
{ name: "compat" },
{ name: "graphql" },
{ name: "assets" },
],
binds: [
{
address: "[::]:8080",
},
],
proxy_protocol: false,
},
{
name: "internal",
resources: [
{
name: "health",
},
],
binds: [
{
address: "[::]:8081",
},
],
proxy_protocol: false,
},
],
public_base: "", // Needs to be set
issuer: "", // Needs to be set
},
database: {
host: "postgres",
port: 5432,
database: "postgres",
username: "postgres",
password: "p4S5w0rD",
},
email: {
from: '"Authentication Service" <root@localhost>',
reply_to: '"Authentication Service" <root@localhost>',
transport: "smtp",
mode: "plain",
hostname: "mailpit",
port: 1025,
username: "username",
password: "password",
},
secrets: {
encryption: "984b18e207c55ad5fbb2a49b217481a722917ee87b2308d4cf314c83fed8e3b5",
keys: [
{
kid: "YEAhzrKipJ",
key: "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAuIV+AW5vx52I4CuumgSxp6yvKfIAnRdALeZZCoFkIGxUli1B\nS79NJ3ls46oLh1pSD9RrhaMp6HTNoi4K3hnP9Q9v77pD7KwdFKG3UdG1zksIB0s/\n+/Ey/DmX4LPluwBBS7r/LkQ1jk745lENA++oiDqZf2D/uP8jCHlvaSNyVKTqi1ki\nOXPd4T4xBUjzuas9ze5jQVSYtfOidgnv1EzUipbIxgvH1jNt4raRlmP8mOq7xEnW\nR+cF5x6n/g17PdSEfrwO4kz6aKGZuMP5lVlDEEnMHKabFSQDBl7+Mpok6jXutbtA\nuiBnsKEahF9eoj4na4fpbRNPdIVyoaN5eGvm5wIDAQABAoIBAApyFCYEmHNWaa83\nCdVSOrRhRDE9r+c0r79pcNT1ajOjrk4qFa4yEC4R46YntCtfY5Hd1pBkIjU0l4d8\nz8Su9WTMEOwjQUEepS7L0NLi6kXZXYT8L40VpGs+32grBvBFHW0qEtQNrHJ36gMv\nx2rXoFTF7HaXiSJx3wvVxAbRqOE9tBXLsmNHaWaAdWQG5o77V9+zvMri3cAeEg2w\nVkKokb0dza7es7xG3tqS26k69SrwGeeuKo7qCHPH2cfyWmY5Yhv8iOoA59JzzbiK\nUdxyzCHskrPSpRKVkVVwmY3RBt282TmSRG7td7e5ESSj50P2e5BI5uu1Hp/dvU4F\nvYjV7kECgYEA6WqYoUpVsgQiqhvJwJIc/8gRm0mUy8TenI36z4Iim01Nt7fibWH7\nXnsFqLGjXtYNVWvBcCrUl9doEnRbJeG2eRGbGKYAWVrOeFvwM4fYvw9GoOiJdDj4\ncgWDe7eHbHE+UTqR7Nnr/UBfipoNWDh6X68HRBuXowh0Q6tOfxsrRFECgYEAyl/V\n4b8bFp3pKZZCb+KPSYsQf793cRmrBexPcLWcDPYbMZQADEZ/VLjbrNrpTOWxUWJT\nhr8MrWswnHO+l5AFu5CNO+QgV2dHLk+2w8qpdpFRPJCfXfo2D3wZ0c4cv3VCwv1V\n5y7f6XWVjDWZYV4wj6c3shxZJjZ+9Hbhf3/twbcCgYA6fuRRR3fCbRbi2qPtBrEN\nyO3gpMgNaQEA6vP4HPzfPrhDWmn8T5nXS61XYW03zxz4U1De81zj0K/cMBzHmZFJ\nNghQXQmpWwBzWVcREvJWr1Vb7erEnaJlsMwKrSvbGWYspSj82oAxr3hCG+lMOpsw\nb4S6pM+TpAK/EqdRY1WsgQKBgQCGoMaaTRXqL9bC0bEU2XVVCWxKb8c3uEmrwQ7/\n/fD4NmjUzI5TnDps1CVfkqoNe+hAKddDFqmKXHqUOfOaxDbsFje+lf5l5tDVoDYH\nfjTKKdYPIm7CiAeauYY7qpA5Vfq52Opixy4yEwUPp0CII67OggFtPaqY3zwJyWQt\n+57hdQKBgGCXM/KKt7ceUDcNJxSGjvu0zD9D5Sv2ihYlEBT/JLaTCCJdvzREevaJ\n1d+mpUAt0Lq6A8NWOMq8HPaxAik3rMQ0WtM5iG+XgsUqvTSb7NcshArDLuWGnW3m\nMC4rM0UBYAS4QweduUSH1imrwH/1Gu5+PxbiecceRMMggWpzu0Bq\n-----END RSA PRIVATE KEY-----\n",
},
{
kid: "8J1AxrlNZT",
key: "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIF1cjfIOEdy3BXJ72x6fKpEB8WP1ddZAUJAaqqr/6CpToAoGCCqGSM49\nAwEHoUQDQgAEfHdNuI1Yeh3/uOq2PlnW2vymloOVpwBYebbw4VVsna9xhnutIdQW\ndE8hkX8Yb0pIDasrDiwllVLzSvsWJAI0Kw==\n-----END EC PRIVATE KEY-----\n",
},
{
kid: "3BW6un1EBi",
key: "-----BEGIN EC PRIVATE KEY-----\nMIGkAgEBBDA+3ZV17r8TsiMdw1cpbTSNbyEd5SMy3VS1Mk/kz6O2Ev/3QZut8GE2\nq3eGtLBoVQigBwYFK4EEACKhZANiAASs8Wxjk/uRimRKXnPr2/wDaXkN9wMDjYQK\nmZULb+0ZP1/cXmuXuri8hUGhQvIU8KWY9PkpV+LMPEdpE54mHPKSLjq5CDXoSZ/P\n9f7cdRaOZ000KQPZfIFR9ujJTtDN7Vs=\n-----END EC PRIVATE KEY-----\n",
},
{
kid: "pkZ0pTKK0X",
key: "-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIHenfsXYPc5yzjZKUfvmydDR1YRwdsfZYvwHf/2wsYxooAcGBSuBBAAK\noUQDQgAEON1x7Vlu+nA0KvC5vYSOHhDUkfLYNZwYSLPFVT02h9E13yFFMIJegIBl\nAer+6PMZpPc8ycyeH9N+U9NAyliBhQ==\n-----END EC PRIVATE KEY-----\n",
},
],
},
passwords: {
enabled: true,
schemes: [
{
version: 1,
algorithm: "argon2id",
},
],
minimum_complexity: 0,
},
policy: {
data: {
client_registration: {
// allow non-SSL and localhost URIs
allow_insecure_uris: true,
},
},
},
account: {
password_registration_enabled: true,
},
matrix: {
kind: "synapse",
secret: "", // Needs to be set
},
rate_limiting: {
login: {
burst: 10,
per_second: 1,
},
registration: {
burst: 10,
per_second: 1,
},
},
} satisfies MasConfig;
/**
* A container running the Matrix Authentication Service.
*
* Exposes the MAS API on port 8080 and the health check on port 8081.
* Waits for HTTP /health on port 8081 to be available.
*/
export class MatrixAuthenticationServiceContainer extends GenericContainer {
private config: MasConfig;
private readonly args = ["-c", "/config/config.yaml"];
public constructor(
db: StartedPostgreSqlContainer,
image: string = "ghcr.io/element-hq/matrix-authentication-service:latest",
) {
super(image);
const initialConfig = deepCopy(DEFAULT_CONFIG);
initialConfig.database.host = db.getHostname();
initialConfig.database.username = db.getUsername();
initialConfig.database.password = db.getPassword();
this.config = initialConfig;
this.withExposedPorts(8080, 8081)
.withWaitStrategy(Wait.forHttp("/health", 8081))
.withCommand(["server", ...this.args]);
}
/**
* Adds additional configuration to the MAS config.
* @param config - additional config fields to add
*/
public withConfig(config: Partial<MasConfig>): this {
this.config = {
...this.config,
...config,
};
return this;
}
/**
* Starts the MAS container
*/
public override async start(): Promise<StartedMatrixAuthenticationServiceContainer> {
// MAS config issuer needs to know what URL it'll be accessed from, so we have to map the port manually
const port = await getFreePort();
this.config.http = {
...this.config.http,
public_base: `http://localhost:${port}/`,
issuer: `http://localhost:${port}/`,
};
this.withExposedPorts({
container: 8080,
host: port,
}).withCopyContentToContainer([
{
target: "/config/config.yaml",
content: YAML.stringify(this.config),
},
]);
return new StartedMatrixAuthenticationServiceContainer(
await super.start(),
`http://localhost:${port}`,
this.args,
this.config.matrix.secret,
);
}
}
/**
* A started Matrix Authentication Service container.
*/
export class StartedMatrixAuthenticationServiceContainer extends AbstractStartedContainer {
private adminTokenPromise?: Promise<string>;
public constructor(
container: StartedTestContainer,
public readonly baseUrl: string,
private readonly args: string[],
public readonly sharedSecret: string,
) {
super(container);
}
/**
* Retrieves a valid HS admin token
*/
public async getAdminToken(): Promise<string> {
if (this.adminTokenPromise === undefined) {
this.adminTokenPromise = this.registerUserInternal(
"admin",
"totalyinsecureadminpassword",
undefined,
true,
).then((res) => res.accessToken);
}
return this.adminTokenPromise;
}
public async manage(cmd: string, ...args: string[]): Promise<ExecResult> {
const result = await this.exec(["mas-cli", "manage", cmd, ...this.args, ...args]);
if (result.exitCode !== 0) {
throw new Error(`Failed mas-cli manage ${cmd}: ${result.output}`);
}
return result;
}
private async manageRegisterUser(
username: string,
password: string,
displayName?: string,
admin = false,
): Promise<string> {
const args: string[] = [];
if (admin) args.push("-a");
const result = await this.manage(
"register-user",
...args,
"-y",
"-p",
password,
"-d",
displayName ?? "",
username,
);
const registerLines = result.output.trim().split("\n");
const userId = registerLines
.find((line) => line.includes("Matrix ID: "))
?.split(": ")
.pop();
if (!userId) {
throw new Error(`Failed to register user: ${result.output}`);
}
return userId;
}
private async manageIssueCompatibilityToken(
username: string,
admin = false,
): Promise<{ accessToken: string; deviceId: string }> {
const args: string[] = [];
if (admin) args.push("--yes-i-want-to-grant-synapse-admin-privileges");
const result = await this.manage("issue-compatibility-token", ...args, username);
const parts = result.output.trim().split(/\s+/);
const accessToken = parts.find((part) => part.startsWith("mct_"));
const deviceId = parts.find((part) => part.startsWith("compat_session.device="))?.split("=")[1];
if (!accessToken || !deviceId) {
throw new Error(`Failed to issue compatibility token: ${result.output}`);
}
return { accessToken, deviceId };
}
private async registerUserInternal(
username: string,
password: string,
displayName?: string,
admin = false,
): Promise<Omit<Credentials, "homeserverBaseUrl">> {
const userId = await this.manageRegisterUser(username, password, displayName, admin);
const { deviceId, accessToken } = await this.manageIssueCompatibilityToken(username, admin);
return {
userId,
accessToken,
deviceId,
homeServer: userId.slice(1).split(":").slice(1).join(":"),
displayName,
username,
password,
};
}
/**
* Registers a user
*
* @param username - the username of the user to register
* @param password - the password of the user to register
* @param displayName - optional display name to set on the newly registered user
*/
public async registerUser(
username: string,
password: string,
displayName?: string,
): Promise<Omit<Credentials, "homeserverBaseUrl">> {
return this.registerUserInternal(username, password, displayName, false);
}
/**
* Binds a 3pid
* @param username - the username of the user to bind the 3pid to
* @param medium - the medium of the 3pid to bind
* @param address - the address of the 3pid to bind
*/
public async setThreepid(username: string, medium: string, address: string): Promise<void> {
if (medium !== "email") {
throw new Error("Only email threepids are supported by MAS");
}
await this.manage("add-email", username, address);
}
}
export async function makeMas(
postgres: StartedPostgreSqlContainer,
network: StartedNetwork,
logger: Logger,
config: Partial<MasConfig>,
name = "mas",
): Promise<StartedMatrixAuthenticationServiceContainer> {
const container = await new MatrixAuthenticationServiceContainer(postgres)
.withNetwork(network)
.withNetworkAliases(name)
.withLogConsumer(logger.getConsumer(name))
.withConfig(config)
.start();
return container;
}
@@ -0,0 +1,40 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { type StartedNetwork } from "testcontainers";
import { type Logger } from "../utils/logger.js";
export async function makePostgres(
network: StartedNetwork,
logger: Logger,
name = "postgres",
): Promise<StartedPostgreSqlContainer> {
const container = await new PostgreSqlContainer("postgres:13.3-alpine")
.withNetwork(network)
.withNetworkAliases(name)
.withLogConsumer(logger.getConsumer(name))
.withTmpFs({
"/dev/shm/pgdata/data": "",
})
.withEnvironment({
PG_DATA: "/dev/shm/pgdata/data",
})
.withCommand([
"-c",
"shared_buffers=128MB",
"-c",
`fsync=off`,
"-c",
`synchronous_commit=off`,
"-c",
"full_page_writes=off",
])
.start();
return container;
}
@@ -0,0 +1,529 @@
/*
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
AbstractStartedContainer,
GenericContainer,
type RestartOptions,
type StartedTestContainer,
Wait,
} from "testcontainers";
import { type APIRequestContext, type TestInfo } from "@playwright/test";
import crypto from "node:crypto";
import * as YAML from "yaml";
import { set } from "lodash-es";
import { getFreePort } from "../utils/port.js";
import { randB64Bytes } from "../utils/rand.js";
import { deepCopy } from "../utils/object.js";
import { type HomeserverContainer, type StartedHomeserverContainer } from "./HomeserverContainer.js";
import { type StartedMatrixAuthenticationServiceContainer } from "./mas.js";
import { Api, ClientServerApi, type Verb, type Credentials } from "../utils/api.js";
import { type StartedMailpitContainer } from "./mailpit.js";
const DEFAULT_CONFIG = {
server_name: "localhost",
public_baseurl: "", // set by start method
pid_file: "/homeserver.pid",
web_client: false,
soft_file_limit: 0,
// Needs to be configured to log to the console like a good docker process
log_config: "/data/log.config",
listeners: [
{
// Listener is always port 8008 (configured in the container)
port: 8008,
tls: false,
bind_addresses: ["::"],
type: "http",
x_forwarded: true,
resources: [
{
names: ["client"],
compress: false,
},
],
},
],
database: {
// An sqlite in-memory database is fast & automatically wipes each time
name: "sqlite3",
args: {
database: ":memory:",
},
},
rc_messages_per_second: 10000,
rc_message_burst_count: 10000,
rc_registration: {
per_second: 10000,
burst_count: 10000,
},
rc_joins: {
local: {
per_second: 9999,
burst_count: 9999,
},
remote: {
per_second: 9999,
burst_count: 9999,
},
},
rc_joins_per_room: {
per_second: 9999,
burst_count: 9999,
},
rc_3pid_validation: {
per_second: 1000,
burst_count: 1000,
},
rc_invites: {
per_room: {
per_second: 1000,
burst_count: 1000,
},
per_user: {
per_second: 1000,
burst_count: 1000,
},
},
rc_login: {
address: {
per_second: 10000,
burst_count: 10000,
},
account: {
per_second: 10000,
burst_count: 10000,
},
failed_attempts: {
per_second: 10000,
burst_count: 10000,
},
},
rc_room_creation: {
per_second: 1000,
burst_count: 1000,
},
media_store_path: "/tmp/media_store",
max_upload_size: "50M",
max_image_pixels: "32M",
dynamic_thumbnails: false,
enable_registration: true,
enable_registration_without_verification: true,
disable_msisdn_registration: false,
registrations_require_3pid: [],
enable_metrics: false,
report_stats: false,
// These placeholders will be replaced with values generated at start
registration_shared_secret: "secret",
macaroon_secret_key: "secret",
form_secret: "secret",
// Signing key must be here: it will be generated to this file
signing_key_path: "/data/localhost.signing.key",
trusted_key_servers: [],
password_config: {
enabled: true,
},
ui_auth: {},
background_updates: {
// Inhibit background updates as this Synapse isn't long-lived
min_batch_size: 100000,
sleep_duration_ms: 100000,
},
enable_authenticated_media: true,
email: undefined as
| undefined
| {
enable_notifs: boolean;
smtp_host: string;
smtp_port: number;
smtp_user: string;
smtp_pass: string;
require_transport_security: false;
notif_from: string;
app_name: string;
notif_template_html: string;
notif_template_text: string;
notif_for_new_users: boolean;
client_base_url: string;
},
user_consent: undefined as
| undefined
| {
template_dir: string;
version: string;
server_notice_content: Record<string, unknown>;
send_server_notice_to_guests: boolean;
block_events_error: string;
require_at_registration: boolean;
},
server_notices: undefined as
| undefined
| {
system_mxid_localpart: string;
system_mxid_display_name: string;
system_mxid_avatar_url: string;
room_name: string;
},
allow_guest_access: false,
experimental_features: {} as Record<string, boolean>,
matrix_rtc: undefined as
| undefined
| {
transports: Array<{ type: string; [field: string]: string }>;
},
oidc_providers: [],
serve_server_wellknown: true,
presence: {
enabled: true,
include_offline_users_on_sync: true,
},
room_list_publication_rules: [{ action: "allow" }],
modules: [] as Array<{ module: string; config?: Record<string, unknown> }>,
matrix_authentication_service: undefined as
| undefined
| {
enabled?: boolean;
endpoint?: string;
secret?: string | null;
secret_path?: string | null;
},
};
/**
* Incomplete type describing the configuration for a Synapse homeserver
*/
export type SynapseConfig = typeof DEFAULT_CONFIG;
/**
* A Synapse testcontainer
*
* Exposes port 8008.
* Waits for HTTP /health 8008 to 200.
*/
export class SynapseContainer extends GenericContainer implements HomeserverContainer<SynapseConfig> {
protected config: SynapseConfig;
protected mas?: StartedMatrixAuthenticationServiceContainer;
public constructor(image = "ghcr.io/element-hq/synapse:develop") {
super(image);
this.config = deepCopy(DEFAULT_CONFIG);
this.config.registration_shared_secret = randB64Bytes(16);
this.config.macaroon_secret_key = randB64Bytes(16);
this.config.form_secret = randB64Bytes(16);
const signingKey = randB64Bytes(32);
this.withWaitStrategy(Wait.forHttp("/health", 8008)).withCopyContentToContainer([
{ target: this.config.signing_key_path, content: `ed25519 x ${signingKey}` },
{
target: this.config.log_config,
content: YAML.stringify({
version: 1,
formatters: {
precise: {
format: "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s",
},
},
handlers: {
console: {
class: "logging.StreamHandler",
formatter: "precise",
},
},
loggers: {
"synapse.storage.SQL": {
level: "DEBUG",
},
"twisted": {
handlers: ["console"],
propagate: false,
},
},
root: {
level: "DEBUG",
handlers: ["console"],
},
disable_existing_loggers: false,
}),
},
]);
}
public withConfigField(key: string, value: unknown): this {
set(this.config, key, value);
return this;
}
public withConfig(config: Partial<SynapseConfig>): this {
this.config = {
...this.config,
...config,
};
return this;
}
public withSmtpServer(mailpit: StartedMailpitContainer): this {
this.config.email = {
enable_notifs: false,
smtp_host: mailpit.internalHost,
smtp_port: mailpit.internalSmtpPort,
smtp_user: "username",
smtp_pass: "password",
require_transport_security: false,
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>",
app_name: "Matrix",
notif_template_html: "notif_mail.html",
notif_template_text: "notif_mail.txt",
notif_for_new_users: true,
client_base_url: "http://localhost/element",
};
return this;
}
public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this {
if (mas) {
this.mas = mas;
this.withConfig({
matrix_authentication_service: {
enabled: true,
endpoint: `http://${mas.getHostname()}:8080/`,
secret: mas.sharedSecret,
},
// Must be disabled when using MAS.
password_config: {
enabled: false,
},
// Must be disabled when using MAS.
enable_registration: false,
});
}
return this;
}
public override async start(): Promise<StartedSynapseContainer> {
// Synapse config public_baseurl needs to know what URL it'll be accessed from, so we have to map the port manually
const port = await getFreePort();
this.withExposedPorts({
container: 8008,
host: port,
})
.withConfig({
public_baseurl: `http://localhost:${port}`,
})
.withCopyContentToContainer([
{
target: "/data/homeserver.yaml",
content: YAML.stringify(this.config),
},
]);
const container = await super.start();
const baseUrl = `http://localhost:${port}`;
if (this.mas) {
return new StartedSynapseWithMasContainer(
container,
baseUrl,
this.config.registration_shared_secret,
this.mas,
);
}
return new StartedSynapseContainer(container, baseUrl, this.config.registration_shared_secret);
}
}
/**
* A started Synapse testcontainer
*/
export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer {
protected adminTokenPromise?: Promise<string>;
protected readonly adminApi: Api;
public readonly csApi: ClientServerApi;
public constructor(
container: StartedTestContainer,
public readonly baseUrl: string,
private readonly registrationSharedSecret: string,
) {
super(container);
this.adminApi = new Api(`${this.baseUrl}/_synapse/admin`);
this.csApi = new ClientServerApi(this.baseUrl);
}
/**
* Restart the container
* Useful to reset the state of the homeserver between tests
* @param options - options to pass to the restart
*/
public restart(options?: Partial<RestartOptions>): Promise<void> {
this.adminTokenPromise = undefined;
return super.restart(options);
}
public setRequest(request: APIRequestContext): void {
this.csApi.setRequest(request);
this.adminApi.setRequest(request);
}
public async onTestFinished(testInfo: TestInfo): Promise<void> {
// Clean up the server to prevent rooms leaking between tests
await this.deletePublicRooms();
}
protected async deletePublicRooms(): Promise<void> {
const token = await this.getAdminToken();
// We hide the rooms from the room directory to save time between tests and for portability between homeservers
const { chunk: rooms } = await this.csApi.request<{
chunk: { room_id: string }[];
}>("GET", "/v3/publicRooms", token, {});
await Promise.all(
rooms.map((room) =>
this.csApi.request("PUT", `/v3/directory/list/room/${room.room_id}`, token, { visibility: "private" }),
),
);
}
private async registerUserInternal(
username: string,
password: string,
displayName?: string,
admin = false,
): Promise<Credentials> {
const path = "/v1/register";
const { nonce } = await this.adminApi.request<{ nonce: string }>("GET", path, undefined, {});
const mac = crypto
.createHmac("sha1", this.registrationSharedSecret)
.update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`)
.digest("hex");
const data = await this.adminApi.request<{
home_server: string;
access_token: string;
user_id: string;
device_id: string;
}>("POST", path, undefined, {
nonce,
username,
password,
mac,
admin,
displayname: displayName,
});
return {
homeServer: data.home_server || data.user_id.split(":").slice(1).join(":"),
homeserverBaseUrl: this.baseUrl,
accessToken: data.access_token,
userId: data.user_id,
deviceId: data.device_id,
password,
displayName,
username,
};
}
protected async getAdminToken(): Promise<string> {
if (this.adminTokenPromise === undefined) {
this.adminTokenPromise = this.registerUserInternal(
"admin",
"totalyinsecureadminpassword",
undefined,
true,
).then((res) => res.accessToken);
}
return this.adminTokenPromise;
}
private async adminRequest<R extends object>(verb: "GET", path: string, data?: never): Promise<R>;
private async adminRequest<R extends object>(verb: Verb, path: string, data?: object): Promise<R>;
private async adminRequest<R extends object>(verb: Verb, path: string, data?: object): Promise<R> {
const adminToken = await this.getAdminToken();
return this.adminApi.request(verb, path, adminToken, data);
}
/**
* Register a user on the given Homeserver using the shared registration secret.
* @param username - the username of the user to register
* @param password - the password of the user to register
* @param displayName - optional display name to set on the newly registered user
*/
public registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
return this.registerUserInternal(username, password, displayName, false);
}
/**
* Logs into synapse with the given username/password
* @param userId - login username
* @param password - login password
*/
public async loginUser(userId: string, password: string): Promise<Credentials> {
return {
...(await this.csApi.loginUser(userId, password)),
homeserverBaseUrl: this.baseUrl,
};
}
/**
* Binds a 3pid
* @param userId - the username of the user to bind the 3pid to
* @param medium - the medium of the 3pid to bind
* @param address - the address of the 3pid to bind
*/
public async setThreepid(userId: string, medium: string, address: string): Promise<void> {
await this.adminRequest("PUT", `/v2/users/${userId}`, {
threepids: [
{
medium,
address,
},
],
});
}
}
/**
* A started Synapse container when delegating auth to MAS
*/
export class StartedSynapseWithMasContainer extends StartedSynapseContainer {
public constructor(
container: StartedTestContainer,
baseUrl: string,
registrationSharedSecret: string,
private readonly mas: StartedMatrixAuthenticationServiceContainer,
) {
super(container, baseUrl, registrationSharedSecret);
}
protected async getAdminToken(): Promise<string> {
if (this.adminTokenPromise === undefined) {
this.adminTokenPromise = this.mas.getAdminToken();
}
return this.adminTokenPromise;
}
/**
* Register a user on the given Homeserver using the shared registration secret.
* @param username - the username of the user to register
* @param password - the password of the user to register
* @param displayName - optional display name to set on the newly registered user
*/
public async registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
const registered = await this.mas.registerUser(username, password, displayName);
return { ...registered, homeserverBaseUrl: this.baseUrl };
}
/**
* Binds a 3pid
* @param userId - the username of the user to bind the 3pid to
* @param medium - the medium of the 3pid to bind
* @param address - the address of the 3pid to bind
*/
public async setThreepid(userId: string, medium: string, address: string): Promise<void> {
return this.mas.setThreepid(userId, medium, address);
}
}
+119
View File
@@ -0,0 +1,119 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type APIRequestContext } from "@playwright/test";
export type Verb = "GET" | "POST" | "PUT" | "DELETE";
/**
* A generic API client.
*/
export class Api {
private _request?: APIRequestContext;
public constructor(private readonly baseUrl: string) {}
/**
* Set the request context to use for making requests.
* @param request - The request context to use.
*/
public setRequest(request: APIRequestContext): void {
this._request = request;
}
/**
* Make a request to the API.
* @param verb - The HTTP verb to use.
* @param path - The path to request.
* @param token - The access token to use for the request.
* @param data - The data to send with the request.
*/
public async request<R extends object>(verb: "GET", path: string, token?: string, data?: never): Promise<R>;
public async request<R extends object>(verb: Verb, path: string, token?: string, data?: object): Promise<R>;
public async request<R extends object>(verb: Verb, path: string, token?: string, data?: object): Promise<R> {
if (!this._request) {
throw new Error("No request context set");
}
const url = `${this.baseUrl}${path}`;
const res = await this._request.fetch(url, {
data,
method: verb,
headers: token
? {
Authorization: `Bearer ${token}`,
}
: undefined,
});
if (!res.ok()) {
throw new Error(
`Request to ${url} failed with status ${res.status()}: ${JSON.stringify(await res.json())}`,
);
}
return res.json();
}
}
/**
* Credentials for a user.
*/
export interface Credentials {
/** The base URL of the homeserver's CS API. */
homeserverBaseUrl: string;
accessToken: string;
userId: string;
deviceId: string;
/** The domain part of the user's matrix ID. */
homeServer: string;
password: string | null; // null for password-less users
displayName?: string;
username: string; // the localpart of the userId
}
/**
* A client-server API for interacting with a Matrix homeserver.
*/
export class ClientServerApi extends Api {
public constructor(baseUrl: string) {
super(`${baseUrl}/_matrix/client`);
}
/**
* Register a user on the homeserver.
* @param userId - The user ID to register.
* @param password - The password to use for the user.
*/
public async loginUser(userId: string, password: string): Promise<Omit<Credentials, "homeserverBaseUrl">> {
const json = await this.request<{
access_token: string;
user_id: string;
device_id: string;
home_server: string;
}>("POST", "/v3/login", undefined, {
type: "m.login.password",
identifier: {
type: "m.id.user",
user: userId,
},
password: password,
});
return {
password,
accessToken: json.access_token,
userId: json.user_id,
deviceId: json.device_id,
homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"),
username: userId.slice(1).split(":")[0],
};
}
}
@@ -0,0 +1,71 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type BrowserContext, type Page } from "@playwright/test";
import { type Config, CONFIG_JSON } from "../index.js";
/** Construct a suitable config.json for the given homeserver
*
* @param homeserverBaseUrl - The `baseUrl` of the homeserver that the client should be configured to connect to.
* @param additionalConfig - Additional config to add to the default config.json.
* @param labsFlags - Lab flags to enable in the client.
* @param disablePresence - Whether to disable presence for the given homeserver.
*/
export function buildConfigJson(
homeserverBaseUrl: string,
additionalConfig: Partial<Config> = {},
labsFlags: string[] = [],
disablePresence: boolean = false,
): Partial<Config> {
const json = {
...CONFIG_JSON,
...additionalConfig,
default_server_config: {
"m.homeserver": {
base_url: homeserverBaseUrl,
},
...additionalConfig.default_server_config,
},
};
json["features"] = {
...json["features"],
// Enable the lab features
...labsFlags.reduce<NonNullable<(typeof CONFIG_JSON)["features"]>>((obj, flag) => {
obj[flag] = true;
return obj;
}, {}),
};
if (disablePresence) {
json["enable_presence_by_hs_url"] = {
[homeserverBaseUrl]: false,
};
}
return json;
}
/**
* Add a route to the browser context/page which will serve a suitable config.json for the given homeserver.
*
* @param context - The browser context or page to route the config.json to.
* @param homeserverBaseUrl - The `baseUrl` of the homeserver that the client should be configured to connect to.
* @param additionalConfig - Additional config to add to the default config.json.
* @param labsFlags - Lab flags to enable in the client.
* @param disablePresence - Whether to disable presence for the given homeserver.
*/
export async function routeConfigJson(
context: BrowserContext | Page,
homeserverBaseUrl: string,
additionalConfig: Partial<Config> = {},
labsFlags: string[] = [],
disablePresence: boolean = false,
): Promise<void> {
await context.route(`http://localhost:8080/config.json*`, async (route) => {
const json = buildConfigJson(homeserverBaseUrl, additionalConfig, labsFlags, disablePresence);
await route.fulfill({ json });
});
}
@@ -0,0 +1,38 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Browser } from "playwright-core";
import { type Page } from "@playwright/test";
import { type Credentials } from "./api.js";
import { type Config } from "../index.js";
import { routeConfigJson } from "./config_json.js";
import { populateLocalStorageWithCredentials } from "../fixtures/user.js";
/** Create a new instance of the application, in a separate browser context, using the given credentials.
*
* @param browser - the browser to use
* @param credentials - the credentials to use for the new instance
* @param additionalConfig - additional config for the `config.json` for the new instance
* @param labsFlags - additional labs flags for the `config.json` for the new instance
* @param disablePresence - whether to disable presence for the new instance
*/
export async function createNewInstance(
browser: Browser,
credentials: Credentials,
additionalConfig: Partial<Config> = {},
labsFlags: string[] = [],
disablePresence: boolean = false,
): Promise<Page> {
const context = await browser.newContext();
await routeConfigJson(context, credentials.homeserverBaseUrl, additionalConfig, labsFlags, disablePresence);
const page = await context.newPage();
await populateLocalStorageWithCredentials(page, credentials);
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
return page;
}
@@ -0,0 +1,79 @@
/*
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type BrowserContext, type Page, type TestInfo } from "@playwright/test";
import { type Readable } from "node:stream";
import stripAnsi from "strip-ansi";
/**
* A logger that captures console logs from pages and testcontainers.
*/
export class Logger {
private pages: Page[] = [];
private logs: Record<string, string> = {};
/**
* Get a consumer function that captures logs for a given container.
* @param container - the human-readable name of the container.
*/
public getConsumer(container: string) {
this.logs[container] = "";
return (stream: Readable) => {
stream.on("data", (chunk) => {
this.logs[container] += chunk.toString();
});
stream.on("err", (chunk) => {
this.logs[container] += "ERR " + chunk.toString();
});
};
}
/**
* Hook to call when a test starts.
* @param context - the browser context of the test.
*/
public async onTestStarted(context: BrowserContext) {
this.pages = [];
for (const id in this.logs) {
if (id.startsWith("page-")) {
delete this.logs[id];
} else {
this.logs[id] = "";
}
}
context.on("console", (msg) => {
const page = msg.page();
if (!page) return;
let pageIdx = this.pages.indexOf(page);
if (pageIdx === -1) {
this.pages.push(page);
pageIdx = this.pages.length - 1;
this.logs[`page-${pageIdx}`] = `Console logs for page with URL: ${page.url()}\n\n`;
}
const type = msg.type();
const text = msg.text();
this.logs[`page-${pageIdx}`] += `${type}: ${text}\n`;
});
}
/**
* Hook to call when a test finishes.
* @param testInfo - the info about the test that just finished.
*/
public async onTestFinished(testInfo: TestInfo) {
if (testInfo.status !== "passed") {
for (const id in this.logs) {
if (!this.logs[id]) continue;
await testInfo.attach(id, {
body: stripAnsi(this.logs[id]),
contentType: "text/plain",
});
}
}
}
}
@@ -0,0 +1,16 @@
/*
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/**
* Deep copy the given object. The object MUST NOT have circular references and
* MUST NOT have functions.
* @param obj - The object to deep copy.
* @returns A copy of the object without any references to the original.
*/
export function deepCopy<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
@@ -0,0 +1,22 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import * as net from "node:net";
/**
* Get a free networking port on the system.
*/
export async function getFreePort(): Promise<number> {
return new Promise<number>((resolve) => {
const srv = net.createServer();
srv.listen(0, () => {
const port = (<net.AddressInfo>srv.address()).port;
srv.close(() => resolve(port));
});
});
}
@@ -0,0 +1,17 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import crypto from "node:crypto";
/**
* Generate a random base64 string of the given number of bytes.
* @param numBytes - The number of bytes to generate.
*/
export function randB64Bytes(numBytes: number): string {
return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, "");
}