Merge pull request #33088 from element-hq/t3chguy/monorepo-playwright-common

Absorb remainder of playwright-common from element-modules
This commit is contained in:
Michael Telatynski 2026-04-10 17:38:27 +00:00 committed by GitHub
commit a210d3c29e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 3658 additions and 81 deletions

View File

@ -163,7 +163,7 @@ jobs:
- name: Run Playwright tests
working-directory: apps/web
run: |
pnpm playwright test \
pnpm test:playwright \
--shard "$SHARD" \
--project="${{ matrix.project }}" \
${{ (github.event_name == 'pull_request' && matrix.runAllTests == false ) && '--grep-invert @mergequeue' || '' }}

View File

@ -30,9 +30,9 @@
"lint:types": "nx lint:types",
"lint:style": "stylelint \"res/css/**/*.pcss\"",
"test": "nx test:unit",
"test:playwright": "playwright test",
"test:playwright:open": "pnpm test:playwright --ui",
"test:playwright:screenshots": "playwright-screenshots-experimental pnpm playwright test --update-snapshots --project=Chrome --grep @screenshot",
"test:playwright": "nx test:playwright --",
"test:playwright:open": "nx test:playwright -- --ui",
"test:playwright:screenshots": "nx test:playwright:screenshots --",
"coverage": "pnpm test --coverage",
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp"
},
@ -127,8 +127,7 @@
"@babel/preset-typescript": "^7.12.7",
"@casualbot/jest-sonar-reporter": "2.5.0",
"@element-hq/element-call-embedded": "0.18.0",
"@element-hq/element-web-playwright-common": "catalog:",
"@element-hq/element-web-playwright-common-local": "workspace:*",
"@element-hq/element-web-playwright-common": "workspace:*",
"@fetch-mock/jest": "^0.2.20",
"@jest/globals": "^30.2.0",
"@peculiar/webcrypto": "^1.4.3",

View File

@ -44,7 +44,7 @@
"parallel": false,
"cwd": "apps/web"
},
"dependsOn": ["^build"]
"dependsOn": ["^build", "^build:playwright"]
},
"test:unit": {
"executor": "@nx/jest:jest",
@ -53,6 +53,16 @@
"cwd": "apps/web"
},
"dependsOn": ["^build"]
},
"test:playwright": {
"command": "playwright test",
"options": { "cwd": "apps/web" },
"dependsOn": ["^build:playwright"]
},
"test:playwright:screenshots": {
"command": "playwright-screenshots nx test:playwright --update-snapshots --project=Chrome --grep @screenshot",
"options": { "cwd": "apps/web" },
"dependsOn": ["^build:playwright"]
}
}
}

View File

@ -5,13 +5,9 @@ process.env.GITHUB_ACTIONS = "1";
export default {
workspaces: {
"packages/shared-components": {
ignoreDependencies: [
// Used for vitest browser tests
"@playwright/test",
],
},
"packages/shared-components": {},
"packages/playwright-common": {
entry: ["src/fixtures/index.ts", "src/testcontainers/index.ts"],
ignoreDependencies: [
// Used in playwright-screenshots.sh
"wait-on",

View File

@ -27,7 +27,6 @@
"devDependencies": {
"@action-validator/cli": "^0.6.0",
"@action-validator/core": "^0.6.0",
"@element-hq/element-web-playwright-common": "catalog:",
"@nx-tools/nx-container": "^7.2.1",
"@nx/jest": "^22.5.0",
"@playwright/test": "catalog:",

1
packages/playwright-common/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
lib/

View File

@ -1,10 +1,16 @@
# @element-hq/element-web-playwright-common
Set of Playwright utilities to make it easier to write tests for Element Web, Element Web Modules & Element Desktop.
Set of Playwright & testcontainers utilities to make it easier to write tests for Element Web, Element Web Modules & Element Desktop.
# This is a partial clone of https://github.com/element-hq/element-modules/tree/main/packages/element-web-playwright-common
The main export includes a number of fixtures and custom assertions as documented in JSDoc.
In the future the rest of the package will be brought into this monorepo, for now it serves as an experimental alternative to https://github.com/element-hq/element-modules/pull/188
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

View File

@ -1,5 +1,5 @@
{
"name": "@element-hq/element-web-playwright-common-local",
"name": "@element-hq/element-web-playwright-common",
"type": "module",
"version": "3.0.0",
"license": "SEE LICENSE IN README.md",
@ -12,10 +12,34 @@
"engines": {
"node": ">=20.0.0"
},
"main": "lib/index.js",
"types": "lib/index.d.ts",
"bin": {
"playwright-screenshots-experimental": "playwright-screenshots.sh"
"playwright-screenshots": "playwright-screenshots.sh"
},
"scripts": {
"prepack": "nx build:playwright",
"lint:types": "tsc --noEmit"
},
"devDependencies": {
"@element-hq/element-web-module-api": "*",
"@types/lodash-es": "^4.17.12",
"typescript": "^5.8.2",
"wait-on": "^9.0.4"
},
"dependencies": {
"@axe-core/playwright": "^4.10.1",
"@testcontainers/postgresql": "^11.0.0",
"glob": "^13.0.5",
"lodash-es": "^4.17.23",
"mailpit-api": "^1.2.0",
"strip-ansi": "^7.1.0",
"testcontainers": "^11.0.0",
"yaml": "^2.7.0"
},
"peerDependencies": {
"@element-hq/element-web-module-api": "*",
"@playwright/test": "catalog:",
"playwright-core": "catalog:"
}
}

View File

@ -3,6 +3,13 @@
"projectType": "library",
"root": "packages/playwright-common",
"targets": {
"build:playwright": {
"cache": true,
"command": "tsc",
"inputs": ["src"],
"outputs": ["{projectRoot}/lib"],
"options": { "cwd": "packages/playwright-common" }
},
"docker:prebuild": {
"cache": true,
"command": "echo PLAYWRIGHT_VERSION=$(pnpm --silent -- playwright --version | awk '{print $2}') > .env.docker:build",

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

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

View File

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

View File

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

View File

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

View File

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

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";

View File

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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],
};
}
}

View File

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

View File

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

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,17 @@
{
"$schema": "http://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "es2022", "esnext"],
"esModuleInterop": true,
"strict": true,
"declaration": true,
"outDir": "lib",
"declarationMap": true,
"module": "es2022",
"moduleResolution": "bundler",
"types": [],
"allowImportingTsExtensions": false
},
"include": ["src"]
}

View File

@ -69,7 +69,7 @@
"temporal-polyfill": "^0.3.0"
},
"devDependencies": {
"@element-hq/element-web-playwright-common-local": "workspace:*",
"@element-hq/element-web-playwright-common": "workspace:*",
"@fetch-mock/vitest": "^0.2.18",
"@fontsource/inter": "catalog:",
"@matrix-org/react-sdk-module-api": "^2.5.0",

View File

@ -40,17 +40,17 @@
"test:storybook": {
"command": "vitest --project=storybook",
"options": { "cwd": "packages/shared-components" },
"dependsOn": ["typedoc"]
"dependsOn": ["typedoc", "^build:playwright"]
},
"test:storybook:update": {
"command": "playwright-screenshots-experimental nx test:storybook --run --update",
"command": "playwright-screenshots nx test:storybook --run --update",
"options": {
"env": {
"CI": "1"
},
"cwd": "packages/shared-components"
},
"dependsOn": ["typedoc"]
"dependsOn": ["typedoc", "^build:playwright"]
}
}
}

View File

@ -14,7 +14,7 @@ export default defineConfig({
testDir: "apps/web/playwright/e2e",
reporter: [
["html", { open: "never" }],
["./packages/playwright-common/flaky-reporter.ts"],
["@element-hq/element-web-playwright-common/lib/stale-screenshot-reporter.js"],
["./packages/playwright-common/src/flaky-reporter.ts"],
["./packages/playwright-common/src/stale-screenshot-reporter.ts"],
],
});

116
pnpm-lock.yaml generated
View File

@ -9,9 +9,6 @@ catalogs:
'@element-hq/element-web-module-api':
specifier: 1.13.0
version: 1.13.0
'@element-hq/element-web-playwright-common':
specifier: 2.4.0
version: 2.4.0
'@fontsource/inter':
specifier: 5.2.8
version: 5.2.8
@ -138,9 +135,6 @@ importers:
'@action-validator/core':
specifier: ^0.6.0
version: 0.6.0
'@element-hq/element-web-playwright-common':
specifier: 'catalog:'
version: 2.4.0(@element-hq/element-web-module-api@1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.59.1)(playwright-core@1.59.1)
'@nx-tools/nx-container':
specifier: ^7.2.1
version: 7.2.1(@nx/devkit@22.5.3(nx@22.5.4))(@nx/js@22.5.3(@babel/traverse@7.29.0)(nx@22.5.4))(dotenv@17.4.0)(nx@22.5.4)(tslib@2.8.1)
@ -188,10 +182,10 @@ importers:
version: 5.9.3
vitepress:
specifier: ^1.6.4
version: 1.6.4(@algolia/client-search@5.50.0)(@types/node@18.19.130)(@types/react@19.2.10)(axios@1.13.5)(jwt-decode@4.0.0)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(react@19.2.4)(search-insights@2.17.3)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(typescript@5.9.3)
version: 1.6.4(@algolia/client-search@5.50.0)(@types/node@18.19.130)(@types/react@19.2.10)(axios@1.13.5)(jwt-decode@4.0.0)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(typescript@5.9.3)
vitepress-plugin-mermaid:
specifier: ^2.0.17
version: 2.0.17(mermaid@11.14.0)(vitepress@1.6.4(@algolia/client-search@5.50.0)(@types/node@18.19.130)(@types/react@19.2.10)(axios@1.13.5)(jwt-decode@4.0.0)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(react@19.2.4)(search-insights@2.17.3)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(typescript@5.9.3))
version: 2.0.17(mermaid@11.14.0)(vitepress@1.6.4(@algolia/client-search@5.50.0)(@types/node@18.19.130)(@types/react@19.2.10)(axios@1.13.5)(jwt-decode@4.0.0)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(typescript@5.9.3))
yaml:
specifier: 2.8.3
version: 2.8.3
@ -465,7 +459,7 @@ importers:
version: 1.0.3
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#develop
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f17f013f1e0d68622e9dd2f863689b7cd0ae09d1
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b6ea6e105e5a2d95cfbafc75cfcc0903a6378ef3
matrix-widget-api:
specifier: ^1.17.0
version: 1.17.0
@ -597,9 +591,6 @@ importers:
specifier: 0.18.0
version: 0.18.0
'@element-hq/element-web-playwright-common':
specifier: 'catalog:'
version: 2.4.0(@element-hq/element-web-module-api@1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.59.1)(playwright-core@1.59.1)
'@element-hq/element-web-playwright-common-local':
specifier: workspace:*
version: link:../../packages/playwright-common
'@fetch-mock/jest':
@ -928,7 +919,47 @@ importers:
version: 2.8.3
packages/playwright-common:
dependencies:
'@axe-core/playwright':
specifier: ^4.10.1
version: 4.11.1(playwright-core@1.59.1)
'@playwright/test':
specifier: 'catalog:'
version: 1.59.1
'@testcontainers/postgresql':
specifier: ^11.0.0
version: 11.11.0
glob:
specifier: ^13.0.5
version: 13.0.6
lodash-es:
specifier: ^4.17.23
version: 4.18.1
mailpit-api:
specifier: ^1.2.0
version: 1.7.0
playwright-core:
specifier: 'catalog:'
version: 1.59.1
strip-ansi:
specifier: ^7.1.0
version: 7.2.0
testcontainers:
specifier: ^11.0.0
version: 11.12.0
yaml:
specifier: 2.8.3
version: 2.8.3
devDependencies:
'@element-hq/element-web-module-api':
specifier: '*'
version: 1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
'@types/lodash-es':
specifier: ^4.17.12
version: 4.17.12
typescript:
specifier: ^5.8.2
version: 5.9.3
wait-on:
specifier: ^9.0.4
version: 9.0.4
@ -984,7 +1015,7 @@ importers:
specifier: ^0.3.0
version: 0.3.2
devDependencies:
'@element-hq/element-web-playwright-common-local':
'@element-hq/element-web-playwright-common':
specifier: workspace:*
version: link:../playwright-common
'@fetch-mock/vitest':
@ -2410,15 +2441,6 @@ packages:
matrix-web-i18n:
optional: true
'@element-hq/element-web-playwright-common@2.4.0':
resolution: {integrity: sha512-6zXRkeTlCEEBfUKgE42+5+RYGQloQB4p/HBdTBxjnMArAnZY/+8GE8pY1CeoJrmRISxGdDbdWkMEyT7bwIBx/g==}
engines: {node: '>=20.0.0'}
hasBin: true
peerDependencies:
'@element-hq/element-web-module-api': '*'
'@playwright/test': ^1.52.0
playwright-core: ^1.52.0
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@ -5405,6 +5427,9 @@ packages:
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
@ -9845,8 +9870,8 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f17f013f1e0d68622e9dd2f863689b7cd0ae09d1:
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f17f013f1e0d68622e9dd2f863689b7cd0ae09d1}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b6ea6e105e5a2d95cfbafc75cfcc0903a6378ef3:
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b6ea6e105e5a2d95cfbafc75cfcc0903a6378ef3}
version: 41.3.0
engines: {node: '>=22.0.0'}
@ -14774,9 +14799,9 @@ snapshots:
'@docsearch/css@3.8.2': {}
'@docsearch/js@3.8.2(@algolia/client-search@5.50.0)(@types/react@19.2.10)(react@19.2.4)(search-insights@2.17.3)':
'@docsearch/js@3.8.2(@algolia/client-search@5.50.0)(@types/react@19.2.10)(search-insights@2.17.3)':
dependencies:
'@docsearch/react': 3.8.2(@algolia/client-search@5.50.0)(@types/react@19.2.10)(react@19.2.4)(search-insights@2.17.3)
'@docsearch/react': 3.8.2(@algolia/client-search@5.50.0)(@types/react@19.2.10)(search-insights@2.17.3)
preact: 10.28.3
transitivePeerDependencies:
- '@algolia/client-search'
@ -14785,7 +14810,7 @@ snapshots:
- react-dom
- search-insights
'@docsearch/react@3.8.2(@algolia/client-search@5.50.0)(@types/react@19.2.10)(react@19.2.4)(search-insights@2.17.3)':
'@docsearch/react@3.8.2(@algolia/client-search@5.50.0)(@types/react@19.2.10)(search-insights@2.17.3)':
dependencies:
'@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.50.0)(algoliasearch@5.50.0)(search-insights@2.17.3)
'@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.50.0)(algoliasearch@5.50.0)
@ -14793,7 +14818,6 @@ snapshots:
algoliasearch: 5.50.0
optionalDependencies:
'@types/react': 19.2.10
react: 19.2.4
search-insights: 2.17.3
transitivePeerDependencies:
- '@algolia/client-search'
@ -14916,28 +14940,6 @@ snapshots:
'@matrix-org/react-sdk-module-api': 2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4)
matrix-web-i18n: 3.6.0
'@element-hq/element-web-playwright-common@2.4.0(@element-hq/element-web-module-api@1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.59.1)(playwright-core@1.59.1)':
dependencies:
'@axe-core/playwright': 4.11.1(playwright-core@1.59.1)
'@element-hq/element-web-module-api': 1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
'@playwright/test': 1.59.1
'@testcontainers/postgresql': 11.11.0
glob: 13.0.6
lodash-es: 4.18.1
mailpit-api: 1.7.0
playwright-core: 1.59.1
strip-ansi: 7.2.0
testcontainers: 11.12.0
yaml: 2.8.3
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- bufferutil
- debug
- react-native-b4a
- supports-color
- utf-8-validate
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@ -18158,6 +18160,10 @@ snapshots:
'@types/linkify-it@5.0.0': {}
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.24
'@types/lodash@4.17.24': {}
'@types/markdown-it@14.1.2':
@ -23464,7 +23470,7 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f17f013f1e0d68622e9dd2f863689b7cd0ae09d1:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b6ea6e105e5a2d95cfbafc75cfcc0903a6378ef3:
dependencies:
'@babel/runtime': 7.28.6
'@matrix-org/matrix-sdk-crypto-wasm': 18.0.0
@ -27112,17 +27118,17 @@ snapshots:
- '@emnapi/core'
- '@emnapi/runtime'
vitepress-plugin-mermaid@2.0.17(mermaid@11.14.0)(vitepress@1.6.4(@algolia/client-search@5.50.0)(@types/node@18.19.130)(@types/react@19.2.10)(axios@1.13.5)(jwt-decode@4.0.0)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(react@19.2.4)(search-insights@2.17.3)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(typescript@5.9.3)):
vitepress-plugin-mermaid@2.0.17(mermaid@11.14.0)(vitepress@1.6.4(@algolia/client-search@5.50.0)(@types/node@18.19.130)(@types/react@19.2.10)(axios@1.13.5)(jwt-decode@4.0.0)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(typescript@5.9.3)):
dependencies:
mermaid: 11.14.0
vitepress: 1.6.4(@algolia/client-search@5.50.0)(@types/node@18.19.130)(@types/react@19.2.10)(axios@1.13.5)(jwt-decode@4.0.0)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(react@19.2.4)(search-insights@2.17.3)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(typescript@5.9.3)
vitepress: 1.6.4(@algolia/client-search@5.50.0)(@types/node@18.19.130)(@types/react@19.2.10)(axios@1.13.5)(jwt-decode@4.0.0)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(typescript@5.9.3)
optionalDependencies:
'@mermaid-js/mermaid-mindmap': 9.3.0
vitepress@1.6.4(@algolia/client-search@5.50.0)(@types/node@18.19.130)(@types/react@19.2.10)(axios@1.13.5)(jwt-decode@4.0.0)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(react@19.2.4)(search-insights@2.17.3)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(typescript@5.9.3):
vitepress@1.6.4(@algolia/client-search@5.50.0)(@types/node@18.19.130)(@types/react@19.2.10)(axios@1.13.5)(jwt-decode@4.0.0)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(typescript@5.9.3):
dependencies:
'@docsearch/css': 3.8.2
'@docsearch/js': 3.8.2(@algolia/client-search@5.50.0)(@types/react@19.2.10)(react@19.2.4)(search-insights@2.17.3)
'@docsearch/js': 3.8.2(@algolia/client-search@5.50.0)(@types/react@19.2.10)(search-insights@2.17.3)
'@iconify-json/simple-icons': 1.2.75
'@shikijs/core': 2.5.0
'@shikijs/transformers': 2.5.0

View File

@ -13,7 +13,6 @@ catalog:
"@types/react": ^19.2.10
"@types/react-dom": ^19.2.3
# playwright
"@element-hq/element-web-playwright-common": 2.4.0
"@playwright/test": 1.59.1
"playwright-core": 1.59.1
# Module API