Create playwright-common package

This commit is contained in:
Michael Telatynski 2025-03-12 10:15:24 +00:00
commit 5f28c50af8
25 changed files with 3116 additions and 0 deletions

View File

@ -0,0 +1,9 @@
ARG PLAYWRIGHT_VERSION
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble
WORKDIR /work
# fonts-dejavu is needed for the same RTL rendering as on CI
RUN apt-get update && apt-get -y install docker.io fonts-dejavu
ENTRYPOINT ["npx", "playwright", "test", "--update-snapshots", "--reporter", "line"]

View File

@ -0,0 +1,28 @@
# @element-hq/element-web-playwright-common
Set of Playwright & testcontainers utilities to make it easier to write tests for Element Web, Element Web Modules & Element Desktop.
The main export includes a number of fixtures and custom assertions as documented in JSDoc.
The `lib/testcontainers` export contains the following modules:
- `SynapseContainer` - A testcontainer for running a Synapse server
- `MatrixAuthenticationServiceContainer` - A testcontainer for running a Matrix Authentication Service
- `MailpitContainer` - A testcontainer for running a Mailpit SMTP server
There are a number of utils available in the `lib/utils` export.
## Releases
The API is versioned using semver, with the major version incremented for breaking changes.
## Copyright & License
Copyright (c) 2025 New Vector Ltd
This software is multi licensed by New Vector Ltd (Element). It can be used either:
(1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR
(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to).
Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses.

View File

@ -0,0 +1,30 @@
{
"name": "@element-hq/element-web-playwright-common",
"type": "module",
"version": "1.0.0",
"license": "SEE LICENSE IN README.md",
"main": "lib/index.js",
"bin": {
"playwright-screenshots": "./playwright-screenshots.sh"
},
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"typescript": "^5.8.2"
},
"dependencies": {
"@axe-core/playwright": "^4.10.1",
"@testcontainers/postgresql": "^10.18.0",
"lodash-es": "^4.17.21",
"mailpit-api": "^1.2.0",
"strip-ansi": "^7.1.0",
"testcontainers": "^10.18.0",
"yaml": "^2.7.0"
},
"peerDependencies": {
"@playwright/test": "^1.51.0",
"playwright-core": "^1.51.0"
},
"scripts": {
"prepare": "tsc"
}
}

View File

@ -0,0 +1,53 @@
#!/bin/bash
# Handle symlinks here as we tend to be executed as an npm binary
SCRIPT_PATH=$(readlink -f "$0")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
IMAGE_NAME="element-web-playwright-common"
echo "Building $IMAGE_NAME image in $SCRIPT_DIR"
# Build image
PW_VERSION=$(
yarn list \
--pattern @playwright/test \
--depth=0 \
--json \
--non-interactive \
--no-progress | \
jq -r '.data.trees[].name | split("@")[2]' \
)
echo "with Playwright version $PW_VERSION"
docker build -t "$IMAGE_NAME" --build-arg "PLAYWRIGHT_VERSION=$PW_VERSION" "$SCRIPT_DIR"
RUN_ARGS=(
--rm
--network host
# Pass BASE_URL and CI environment variables to the container
-e BASE_URL
-e CI
# Bind mount the working directory into the container
-v $(pwd):/work/
# Bind mount the docker socket so we can run docker commands from the container
-v /var/run/docker.sock:/var/run/docker.sock
# Bind mount /tmp so we can store temporary files
-v /tmp/:/tmp/
-it
)
# Ensure we pass all symlinked node_modules to the container
pushd node_modules || exit > /dev/null
SYMLINKS=$(find . -maxdepth 2 -type l -not -path "./.bin/*")
popd || exit > /dev/null
for LINK in $SYMLINKS; do
TARGET=$(readlink -f "node_modules/$LINK")
if [ -d "$TARGET" ]; then
echo "mounting linked package ${LINK:2} in container"
RUN_ARGS+=( "-v" "$TARGET:/work/node_modules/${LINK:2}" )
fi
done
DEFAULT_ARGS=(--grep @screenshot)
docker run "${RUN_ARGS[@]}" "$IMAGE_NAME" "${DEFAULT_ARGS[@]}" "$@"

View File

@ -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;
}

View File

@ -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" };
},
});

View File

@ -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,
Expectations as ScreenshotExpectations,
ToMatchScreenshotOptions,
} from "./screenshot.js";
import { expect as axeExpectations, Expectations as AxeExpectations } from "./axe.js";
export const expect = mergeExpects(screenshotExpectations, axeExpectations) as Expect<
ScreenshotExpectations & AxeExpectations
>;
export type { ToMatchScreenshotOptions };

View File

@ -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,
ElementHandle,
type ExpectMatcherState,
type Locator,
type Page,
type PageAssertionsToHaveScreenshotOptions,
} from "@playwright/test";
import { type MatcherReturnType } from "playwright/types/test";
import { sanitizeForFilePath } from "playwright-core/lib/utils";
import { extname } from "node:path";
// 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({
// `_` prefix hides it from the HTML reporter
type: "_screenshot",
// include a path relative to `playwright/snapshots/`
description: testInfo.snapshotPath(screenshotName).split("/playwright/snapshots/", 2)[1],
});
return { pass: true, message: (): string => "", name: "toMatchScreenshot" };
},
});

View File

@ -0,0 +1,22 @@
/*
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";
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);
},
});

View File

@ -0,0 +1,15 @@
/*
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 { mergeTests } from "@playwright/test";
import { test as axe } from "./axe.js";
import { test as user } from "./user.js";
export { type Services, type WorkerOptions } from "./services.js";
export const test = mergeTests(axe, user);

View File

@ -0,0 +1,188 @@
/*
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 { test as base } from "@playwright/test";
import { type MailpitClient } from "mailpit-api";
import { Network, type StartedNetwork } from "testcontainers";
import { PostgreSqlContainer, 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";
/**
* 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 new PostgreSqlContainer()
.withNetwork(network)
.withNetworkAliases("postgres")
.withLogConsumer(logger.getConsumer("postgres"))
.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();
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);
},
});

View File

@ -0,0 +1,93 @@
/*
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";
import { test as base } from "./services.js";
import { Credentials } from "../utils/api.js";
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 #homeserver}, and then loads the front page of the
* app.
*/
user: Credentials;
}>({
displayName: undefined,
credentials: async ({ 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, homeserver, credentials }, use) => {
await page.addInitScript(
({ baseUrl, credentials }) => {
// Seed the localStorage with the required credentials
window.localStorage.setItem("mx_hs_url", baseUrl);
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",
}),
);
},
{ baseUrl: homeserver.baseUrl, credentials },
);
await use(page);
},
user: async ({ pageWithCredentials: page, credentials }, use) => {
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await use(credentials);
},
});

View File

@ -0,0 +1,92 @@
/*
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";
// 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;
};
};
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[];
}
export const test = base.extend<TestFixtures>({
config: {}, // We merge this atop the default CONFIG_JSON in the page fixture to make extending it easier
labsFlags: [],
page: async ({ homeserver, context, page, config, labsFlags }, use) => {
await context.route(`http://localhost:8080/config.json*`, async (route) => {
const json = {
...CONFIG_JSON,
...config,
default_server_config: {
"m.homeserver": {
base_url: homeserver.baseUrl,
},
...config.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;
}, {}),
};
await route.fulfill({ json });
});
await use(page);
},
});
export { expect, type ToMatchScreenshotOptions } from "./expect/index.js";

View File

@ -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";
import { ClientServerApi, Credentials } from "../utils/api";
import { StartedMailpitContainer } from "./mailpit";
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>;
}

View File

@ -0,0 +1,15 @@
/*
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 { HomeserverInstance, HomeserverContainer, StartedHomeserverContainer } from "./HomeserverContainer.js";
export { type SynapseConfig, SynapseContainer, StartedSynapseContainer } from "./synapse.js";
export {
type MasConfig,
MatrixAuthenticationServiceContainer,
StartedMatrixAuthenticationServiceContainer,
} from "./mas.js";
export { MailpitClient, MailpitContainer, StartedMailpitContainer } from "./mailpit.js";

View File

@ -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;
}
}

View File

@ -0,0 +1,382 @@
/*
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,
} 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";
const DEFAULT_CONFIG = {
http: {
listeners: [
{
name: "web",
resources: [
{ name: "discovery" },
{ name: "human" },
{ name: "oauth" },
{ name: "compat" },
{
name: "graphql",
playground: true,
},
{
name: "assets",
path: "/usr/local/share/mas-cli/assets/",
},
],
binds: [
{
address: "[::]:8080",
},
],
proxy_protocol: false,
},
{
name: "internal",
resources: [
{
name: "health",
},
],
binds: [
{
address: "[::]:8081",
},
],
proxy_protocol: false,
},
],
trusted_proxies: ["192.128.0.0/16", "172.16.0.0/12", "10.0.0.0/10", "127.0.0.1/8", "fd00::/8", "::1/128"],
public_base: "", // Needs to be set
issuer: "", // Needs to be set
},
database: {
host: "postgres",
port: 5432,
database: "postgres",
username: "postgres",
password: "p4S5w0rD",
max_connections: 10,
min_connections: 0,
connect_timeout: 30,
idle_timeout: 600,
max_lifetime: 1800,
},
telemetry: {
tracing: {
exporter: "none",
propagators: [],
},
metrics: {
exporter: "none",
},
sentry: {
dsn: null,
},
},
templates: {
path: "/usr/local/share/mas-cli/templates/",
assets_manifest: "/usr/local/share/mas-cli/manifest.json",
translations_path: "/usr/local/share/mas-cli/translations/",
},
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: {
wasm_module: "/usr/local/share/mas-cli/policy.wasm",
client_registration_entrypoint: "client_registration/violation",
register_entrypoint: "register/violation",
authorization_grant_entrypoint: "authorization_grant/violation",
password_entrypoint: "password/violation",
email_entrypoint: "email/violation",
data: {
client_registration: {
// allow non-SSL and localhost URIs
allow_insecure_uris: true,
// EW doesn't have contacts at this time
allow_missing_contacts: true,
},
},
},
upstream_oauth2: {
providers: [],
},
branding: {
service_name: null,
policy_uri: null,
tos_uri: null,
imprint: null,
logo_uri: null,
},
account: {
password_registration_enabled: true,
},
experimental: {
access_token_ttl: 300,
compat_token_ttl: 300,
},
rate_limiting: {
login: {
burst: 10,
per_second: 1,
},
registration: {
burst: 10,
per_second: 1,
},
},
};
/**
* Incomplete type for the MAS configuration.
*/
export type MasConfig = typeof DEFAULT_CONFIG;
/**
* 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) {
// We rely on `mas-cli manage add-email` which isn't in a release yet
// https://github.com/element-hq/matrix-authentication-service/pull/3235
super("ghcr.io/element-hq/matrix-authentication-service:sha-0b90c33");
this.config = deepCopy(DEFAULT_CONFIG);
this.config.database.username = db.getUsername();
this.config.database.password = db.getPassword();
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: object): 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.public_base = `http://localhost:${port}/`;
this.config.http.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,
);
}
}
/**
* 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[],
) {
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;
}
private 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<Credentials> {
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<Credentials> {
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);
}
}

View File

@ -0,0 +1,493 @@
/*
Copyright 2024 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 { StartedMailpitContainer } from "./mailpit.js";
const TAG = "develop@sha256:8d0049e8e0524ad6817cf7737453fe47de1ed3b8d04704f0c2fd6c136414c9d7";
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,
},
},
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: {},
oidc_providers: [],
serve_server_wellknown: true,
presence: {
enabled: true,
include_offline_users_on_sync: true,
},
room_list_publication_rules: [{ action: "allow" }],
};
/**
* 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> {
private config: SynapseConfig;
private mas?: StartedMatrixAuthenticationServiceContainer;
public constructor() {
super(`ghcr.io/element-hq/synapse:${TAG}`);
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 {
this.mas = mas;
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(":"),
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 this.csApi.loginUser(userId, password);
}
/**
* 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 registerUser(username: string, password: string, displayName?: string): Promise<Credentials> {
return this.mas.registerUser(username, password, displayName);
}
/**
* 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);
}
}

View File

@ -0,0 +1,113 @@
/*
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 {
accessToken: string;
userId: string;
deviceId: string;
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<Credentials> {
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],
};
}
}

View File

@ -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",
});
}
}
}
}

View File

@ -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));
}

View File

@ -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));
});
});
}

View File

@ -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(/=*$/, "");
}

View File

@ -0,0 +1,10 @@
{
"$schema": "http://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "lib",
"declarationMap": true,
"allowImportingTsExtensions": false
},
"include": ["src"]
}

File diff suppressed because it is too large Load Diff