diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 1c66b02414..0ae746d438 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -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' || '' }} diff --git a/apps/web/package.json b/apps/web/package.json index 98b6f8c876..6640de4ff9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/project.json b/apps/web/project.json index 95f9a6828d..59fa9f3a66 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -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"] } } } diff --git a/knip.ts b/knip.ts index 43ba17061b..6efd0ee3f2 100644 --- a/knip.ts +++ b/knip.ts @@ -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", diff --git a/package.json b/package.json index e3d619d109..19497482b8 100644 --- a/package.json +++ b/package.json @@ -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:", diff --git a/packages/playwright-common/.gitignore b/packages/playwright-common/.gitignore new file mode 100644 index 0000000000..c3af857904 --- /dev/null +++ b/packages/playwright-common/.gitignore @@ -0,0 +1 @@ +lib/ diff --git a/packages/playwright-common/README.md b/packages/playwright-common/README.md index ab7c192315..47355e1dc8 100644 --- a/packages/playwright-common/README.md +++ b/packages/playwright-common/README.md @@ -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 diff --git a/packages/playwright-common/package.json b/packages/playwright-common/package.json index 26b5447f70..46a11f94a2 100644 --- a/packages/playwright-common/package.json +++ b/packages/playwright-common/package.json @@ -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:" } } diff --git a/packages/playwright-common/project.json b/packages/playwright-common/project.json index 8bb084571d..b3e491b9d6 100644 --- a/packages/playwright-common/project.json +++ b/packages/playwright-common/project.json @@ -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", diff --git a/packages/playwright-common/src/@types/playwright-core.d.ts b/packages/playwright-common/src/@types/playwright-core.d.ts new file mode 100644 index 0000000000..3db7bb9c4c --- /dev/null +++ b/packages/playwright-common/src/@types/playwright-core.d.ts @@ -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; +} diff --git a/packages/playwright-common/src/expect/axe.ts b/packages/playwright-common/src/expect/axe.ts new file mode 100644 index 0000000000..ab798d9a96 --- /dev/null +++ b/packages/playwright-common/src/expect/axe.ts @@ -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; +}; + +export const expect = baseExpect.extend({ + 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" }; + }, +}); diff --git a/packages/playwright-common/src/expect/index.ts b/packages/playwright-common/src/expect/index.ts new file mode 100644 index 0000000000..3ef2a00bf1 --- /dev/null +++ b/packages/playwright-common/src/expect/index.ts @@ -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 }; diff --git a/packages/playwright-common/src/expect/screenshot.ts b/packages/playwright-common/src/expect/screenshot.ts new file mode 100644 index 0000000000..9936ae982b --- /dev/null +++ b/packages/playwright-common/src/expect/screenshot.ts @@ -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; +}; + +/** + * Provides an upgrade to the `toHaveScreenshot` expectation. + * Unfortunately, we can't just extend the existing `toHaveScreenshot` expectation + */ +export const expect = baseExpect.extend({ + 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 | undefined; + if (options?.css) { + // We add a custom style tag before taking screenshots + style = (await page.addStyleTag({ + content: options.css, + })) as ElementHandle; + } + + 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" }; + }, +}); diff --git a/packages/playwright-common/src/fixtures/axe.ts b/packages/playwright-common/src/fixtures/axe.ts new file mode 100644 index 0000000000..6f99b489c0 --- /dev/null +++ b/packages/playwright-common/src/fixtures/axe.ts @@ -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); + }, +}); diff --git a/packages/playwright-common/src/fixtures/index.ts b/packages/playwright-common/src/fixtures/index.ts new file mode 100644 index 0000000000..04dfb50189 --- /dev/null +++ b/packages/playwright-common/src/fixtures/index.ts @@ -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"; diff --git a/packages/playwright-common/src/fixtures/services.ts b/packages/playwright-common/src/fixtures/services.ts new file mode 100644 index 0000000000..de5e9af680 --- /dev/null +++ b/packages/playwright-common/src/fixtures/services.ts @@ -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; +} + +/** + * 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; + /** + * 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({ + 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); + }, +}); diff --git a/packages/playwright-common/src/fixtures/user.ts b/packages/playwright-common/src/fixtures/user.ts new file mode 100644 index 0000000000..073b6149d8 --- /dev/null +++ b/packages/playwright-common/src/fixtures/user.ts @@ -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); + }, +}); diff --git a/packages/playwright-common/flaky-reporter.ts b/packages/playwright-common/src/flaky-reporter.ts similarity index 100% rename from packages/playwright-common/flaky-reporter.ts rename to packages/playwright-common/src/flaky-reporter.ts diff --git a/packages/playwright-common/src/index.ts b/packages/playwright-common/src/index.ts new file mode 100644 index 0000000000..afc92244e4 --- /dev/null +++ b/packages/playwright-common/src/index.ts @@ -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; + setting_defaults: Record; + map_style_url?: string; + features: Record; + 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 = { + 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; + + 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({ + // 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"; diff --git a/packages/playwright-common/src/stale-screenshot-reporter.ts b/packages/playwright-common/src/stale-screenshot-reporter.ts new file mode 100644 index 0000000000..2d7b1ec095 --- /dev/null +++ b/packages/playwright-common/src/stale-screenshot-reporter.ts @@ -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(); + private readonly screenshots = new Set(); + private readonly failing = new Set(); + 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 { + if (!this.snapshotRoots.size) { + this.error("No snapshot directories found, did you set the snapshotDir in your Playwright config?", ""); + return; + } + + const screenshotFiles = new Set(); + 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 { + 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; diff --git a/packages/playwright-common/src/testcontainers/HomeserverContainer.ts b/packages/playwright-common/src/testcontainers/HomeserverContainer.ts new file mode 100644 index 0000000000..dcb5efa386 --- /dev/null +++ b/packages/playwright-common/src/testcontainers/HomeserverContainer.ts @@ -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; + + /** + * Logs into synapse with the given username/password + * @param userId login username + * @param password login password + */ + loginUser(userId: string, password: string): Promise; + + /** + * 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; +} + +export interface HomeserverContainer extends GenericContainer { + /** + * Set a configuration field in the config + * @param key - the key to set + * @param value - the value to set + */ + withConfigField(key: Key, value: Config[Key]): this; + + /** + * Merge a partial configuration into the config + * @param config - the partial configuration to merge + */ + withConfig(config: Partial): 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; +} + +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; +} diff --git a/packages/playwright-common/src/testcontainers/index.ts b/packages/playwright-common/src/testcontainers/index.ts new file mode 100644 index 0000000000..c7dbb4fd68 --- /dev/null +++ b/packages/playwright-common/src/testcontainers/index.ts @@ -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"; diff --git a/packages/playwright-common/src/testcontainers/mailpit.ts b/packages/playwright-common/src/testcontainers/mailpit.ts new file mode 100644 index 0000000000..cfa8ad2b27 --- /dev/null +++ b/packages/playwright-common/src/testcontainers/mailpit.ts @@ -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 { + 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; + } +} diff --git a/packages/playwright-common/src/testcontainers/mas-config.ts b/packages/playwright-common/src/testcontainers/mas-config.ts new file mode 100644 index 0000000000..1f3a911ecd --- /dev/null +++ b/packages/playwright-common/src/testcontainers/mas-config.ts @@ -0,0 +1,1383 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Authentication method used by clients + */ +export type ClientAuthMethodConfig = + | "none" + | "client_secret_basic" + | "client_secret_post" + | "client_secret_jwt" + | "private_key_jwt"; +export type JsonWebKeyFor_JsonWebKeyPublicParameters = { + "use"?: JsonWebKeyUse; + "key_ops"?: JsonWebKeyOperation[]; + "alg"?: JsonWebSignatureAlg; + "kid"?: string; + "x5u"?: string; + "x5c"?: string[]; + "x5t"?: string; + "x5t#S256"?: string; + [k: string]: unknown; +} & JsonWebKeyFor_JsonWebKeyPublicParameters1; +/** + * JSON Web Key Use + */ +export type JsonWebKeyUse = "sig" | "enc"; +/** + * JSON Web Key Operation + */ +export type JsonWebKeyOperation = + | "sign" + | "verify" + | "encrypt" + | "decrypt" + | "wrapKey" + | "unwrapKey" + | "deriveKey" + | "deriveBits"; +/** + * JSON Web Signature "alg" parameter + */ +export type JsonWebSignatureAlg = + | "HS256" + | "HS384" + | "HS512" + | "RS256" + | "RS384" + | "RS512" + | "ES256" + | "ES384" + | "ES512" + | "PS256" + | "PS384" + | "PS512" + | "none" + | "EdDSA" + | "ES256K" + | "Ed25519" + | "Ed448"; +export type JsonWebKeyFor_JsonWebKeyPublicParameters1 = + | { + kty: "RSA"; + n: string; + e: string; + [k: string]: unknown; + } + | { + kty: "EC"; + crv: JsonWebKeyEcEllipticCurve; + x: string; + y: string; + [k: string]: unknown; + } + | { + kty: "OKP"; + crv: JsonWebKeyOkpEllipticCurve; + x: string; + [k: string]: unknown; + }; +/** + * JSON Web Key EC Elliptic Curve + */ +export type JsonWebKeyEcEllipticCurve = "P-256" | "P-384" | "P-521" | "secp256k1"; +/** + * JSON Web Key OKP Elliptic Curve + */ +export type JsonWebKeyOkpEllipticCurve = "Ed25519" | "Ed448" | "X25519" | "X448"; +/** + * HTTP resources to mount + */ +export type Resource = + | { + name: "health"; + [k: string]: unknown; + } + | { + name: "prometheus"; + [k: string]: unknown; + } + | { + name: "discovery"; + [k: string]: unknown; + } + | { + name: "human"; + [k: string]: unknown; + } + | { + name: "graphql"; + /** + * Enabled the GraphQL playground + */ + playground?: boolean; + /** + * Allow access for OAuth 2.0 clients (undocumented) + */ + undocumented_oauth2_access?: boolean; + [k: string]: unknown; + } + | { + name: "oauth"; + [k: string]: unknown; + } + | { + name: "compat"; + [k: string]: unknown; + } + | { + name: "assets"; + /** + * Path to the directory to serve. + */ + path?: string; + [k: string]: unknown; + } + | { + name: "adminapi"; + [k: string]: unknown; + } + | { + name: "connection-info"; + [k: string]: unknown; + }; +/** + * Configuration of a single listener + */ +export type BindConfig = + | { + /** + * Host on which to listen. + * + * Defaults to listening on all addresses + */ + host?: string; + /** + * Port on which to listen. + */ + port: number; + [k: string]: unknown; + } + | { + /** + * Host and port on which to listen + */ + address: string; + [k: string]: unknown; + } + | { + /** + * Path to the socket + */ + socket: string; + [k: string]: unknown; + } + | { + /** + * Index of the file descriptor. Note that this is offseted by 3 because of the standard input/output sockets, so setting here a value of `0` will grab the file descriptor `3` + */ + fd?: number; + /** + * Whether the socket is a TCP socket or a UNIX domain socket. Defaults to TCP. + */ + kind?: UnixOrTcp & string; + [k: string]: unknown; + }; +/** + * Kind of socket + */ +export type UnixOrTcp = "unix" | "tcp"; +export type IpNetwork = V4 | V6; +export type V4 = Ipv4Network; +export type Ipv4Network = string; +export type V6 = Ipv6Network; +export type Ipv6Network = string; +export type Hostname = string; +/** + * Options for controlling the level of protection provided for PostgreSQL SSL connections. + */ +export type PgSslMode = "disable" | "allow" | "prefer" | "require" | "verify-ca" | "verify-full"; +/** + * Exporter to use when exporting traces + */ +export type TracingExporterKind = "none" | "stdout" | "otlp"; +/** + * Propagation format for incoming and outgoing requests + */ +export type Propagator = "tracecontext" | "baggage" | "jaeger"; +/** + * Exporter to use when exporting metrics + */ +export type MetricsExporterKind = "none" | "stdout" | "otlp" | "prometheus"; +/** + * What backend should be used when sending emails + */ +export type EmailTransportKind = "blackhole" | "smtp" | "sendmail"; +/** + * Encryption mode to use + */ +export type EmailSmtpMode = "plain" | "starttls" | "tls"; +/** + * A hashing algorithm + */ +export type Algorithm = "bcrypt" | "argon2id" | "pbkdf2"; +/** + * The kind of homeserver it is. + */ +export type HomeserverKind = "synapse" | "synapse_read_only" | "synapse_legacy" | "synapse_modern"; +/** + * Authentication methods used against the OAuth 2.0 provider + */ +export type TokenAuthMethod = + | "none" + | "client_secret_basic" + | "client_secret_post" + | "client_secret_jwt" + | "private_key_jwt" + | "sign_in_with_apple"; +/** + * How to discover the provider's configuration + */ +export type DiscoveryMode = "oidc" | "insecure" | "disabled"; +/** + * Whether to use proof key for code exchange (PKCE) when requesting and exchanging the token. + */ +export type PkceMethod = "auto" | "always" | "never"; +/** + * The response mode we ask the provider to use for the callback + */ +export type ResponseMode = "query" | "form_post"; +/** + * How to handle a claim + */ +export type ImportAction = "ignore" | "suggest" | "force" | "require"; +/** + * How to handle an existing localpart claim + */ +export type OnConflict = "fail" | "add"; +/** + * What to do when receiving an OIDC Backchannel logout request. + */ +export type OnBackchannelLogout = "do_nothing" | "logout_browser_only" | "logout_all"; +/** + * Which service should be used for CAPTCHA protection + */ +export type CaptchaServiceKind = "recaptcha_v2" | "cloudflare_turnstile" | "hcaptcha"; + +/** + * Application configuration root + */ +export interface RootConfig { + /** + * List of OAuth 2.0/OIDC clients config + */ + clients?: ClientConfig[]; + /** + * Configuration of the HTTP server + */ + http?: HttpConfig; + /** + * Database connection configuration + */ + database?: DatabaseConfig; + /** + * Configuration related to sending monitoring data + */ + telemetry?: TelemetryConfig; + /** + * Configuration related to templates + */ + templates?: TemplatesConfig; + /** + * Configuration related to sending emails + */ + email?: EmailConfig; + /** + * Application secrets + */ + secrets: SecretsConfig; + /** + * Configuration related to user passwords + */ + passwords?: PasswordsConfig; + /** + * Configuration related to the homeserver + */ + matrix: MatrixConfig; + /** + * Configuration related to the OPA policies + */ + policy?: PolicyConfig; + /** + * Configuration related to limiting the rate of user actions to prevent abuse + */ + rate_limiting?: RateLimitingConfig; + /** + * Configuration related to upstream OAuth providers + */ + upstream_oauth2?: UpstreamOAuth2Config; + /** + * Configuration section for tweaking the branding of the service + */ + branding?: BrandingConfig; + /** + * Configuration section to setup CAPTCHA protection on a few operations + */ + captcha?: CaptchaConfig; + /** + * Configuration section to configure features related to account management + */ + account?: AccountConfig; + /** + * Experimental configuration options + */ + experimental?: ExperimentalConfig; + [k: string]: unknown; +} +/** + * An OAuth 2.0 client configuration + */ +export interface ClientConfig { + /** + * A ULID as per https://github.com/ulid/spec + */ + client_id: string; + /** + * Authentication method used for this client + */ + client_auth_method: ClientAuthMethodConfig; + /** + * Name of the `OAuth2` client + */ + client_name?: string; + /** + * The client secret, used by the `client_secret_basic`, `client_secret_post` and `client_secret_jwt` authentication methods + */ + client_secret?: string; + /** + * The JSON Web Key Set (JWKS) used by the `private_key_jwt` authentication method. Mutually exclusive with `jwks_uri` + */ + jwks?: JsonWebKeySetFor_JsonWebKeyPublicParameters; + /** + * The URL of the JSON Web Key Set (JWKS) used by the `private_key_jwt` authentication method. Mutually exclusive with `jwks` + */ + jwks_uri?: string; + /** + * List of allowed redirect URIs + */ + redirect_uris?: string[]; + [k: string]: unknown; +} +export interface JsonWebKeySetFor_JsonWebKeyPublicParameters { + keys: JsonWebKeyFor_JsonWebKeyPublicParameters[]; + [k: string]: unknown; +} +/** + * Configuration related to the web server + */ +export interface HttpConfig { + /** + * List of listeners to run + */ + listeners?: ListenerConfig[]; + /** + * List of trusted reverse proxies that can set the `X-Forwarded-For` header + */ + trusted_proxies?: IpNetwork[]; + /** + * Public URL base from where the authentication service is reachable + */ + public_base: string; + /** + * OIDC issuer URL. Defaults to `public_base` if not set. + */ + issuer?: string; + [k: string]: unknown; +} +/** + * Configuration of a listener + */ +export interface ListenerConfig { + /** + * A unique name for this listener which will be shown in traces and in metrics labels + */ + name?: string; + /** + * List of resources to mount + */ + resources: Resource[]; + /** + * HTTP prefix to mount the resources on + */ + prefix?: string; + /** + * List of sockets to bind + */ + binds: BindConfig[]; + /** + * Accept `HAProxy`'s Proxy Protocol V1 + */ + proxy_protocol?: boolean; + /** + * If set, makes the listener use TLS with the provided certificate and key + */ + tls?: TlsConfig; + [k: string]: unknown; +} +/** + * Configuration related to TLS on a listener + */ +export interface TlsConfig { + /** + * PEM-encoded X509 certificate chain + * + * Exactly one of `certificate` or `certificate_file` must be set. + */ + certificate?: string; + /** + * File containing the PEM-encoded X509 certificate chain + * + * Exactly one of `certificate` or `certificate_file` must be set. + */ + certificate_file?: string; + /** + * PEM-encoded private key + * + * Exactly one of `key` or `key_file` must be set. + */ + key?: string; + /** + * File containing a PEM or DER-encoded private key + * + * Exactly one of `key` or `key_file` must be set. + */ + key_file?: string; + /** + * Password used to decode the private key + * + * One of `password` or `password_file` must be set if the key is encrypted. + */ + password?: string; + /** + * Password file used to decode the private key + * + * One of `password` or `password_file` must be set if the key is encrypted. + */ + password_file?: string; + [k: string]: unknown; +} +/** + * Database connection configuration + */ +export interface DatabaseConfig { + /** + * Connection URI + * + * This must not be specified if `host`, `port`, `socket`, `username`, `password`, or `database` are specified. + */ + uri?: string; + /** + * Name of host to connect to + * + * This must not be specified if `uri` is specified. + */ + host?: Hostname; + /** + * Port number to connect at the server host + * + * This must not be specified if `uri` is specified. + */ + port?: number; + /** + * Directory containing the UNIX socket to connect to + * + * This must not be specified if `uri` is specified. + */ + socket?: string; + /** + * PostgreSQL user name to connect as + * + * This must not be specified if `uri` is specified. + */ + username?: string; + /** + * Password to be used if the server demands password authentication + * + * This must not be specified if `uri` is specified. + */ + password?: string; + /** + * The database name + * + * This must not be specified if `uri` is specified. + */ + database?: string; + /** + * How to handle SSL connections + */ + ssl_mode?: PgSslMode; + /** + * The PEM-encoded root certificate for SSL connections + * + * This must not be specified if the `ssl_ca_file` option is specified. + */ + ssl_ca?: string; + /** + * Path to the root certificate for SSL connections + * + * This must not be specified if the `ssl_ca` option is specified. + */ + ssl_ca_file?: string; + /** + * The PEM-encoded client certificate for SSL connections + * + * This must not be specified if the `ssl_certificate_file` option is specified. + */ + ssl_certificate?: string; + /** + * Path to the client certificate for SSL connections + * + * This must not be specified if the `ssl_certificate` option is specified. + */ + ssl_certificate_file?: string; + /** + * The PEM-encoded client key for SSL connections + * + * This must not be specified if the `ssl_key_file` option is specified. + */ + ssl_key?: string; + /** + * Path to the client key for SSL connections + * + * This must not be specified if the `ssl_key` option is specified. + */ + ssl_key_file?: string; + /** + * Set the maximum number of connections the pool should maintain + */ + max_connections?: number; + /** + * Set the minimum number of connections the pool should maintain + */ + min_connections?: number; + /** + * Set the amount of time to attempt connecting to the database + */ + connect_timeout?: number; + /** + * Set a maximum idle duration for individual connections + */ + idle_timeout?: number; + /** + * Set the maximum lifetime of individual connections + */ + max_lifetime?: number; + [k: string]: unknown; +} +/** + * Configuration related to sending monitoring data + */ +export interface TelemetryConfig { + /** + * Configuration related to exporting traces + */ + tracing?: TracingConfig; + /** + * Configuration related to exporting metrics + */ + metrics?: MetricsConfig; + /** + * Configuration related to the Sentry integration + */ + sentry?: SentryConfig; + [k: string]: unknown; +} +/** + * Configuration related to exporting traces + */ +export interface TracingConfig { + /** + * Exporter to use when exporting traces + */ + exporter?: TracingExporterKind & string; + /** + * OTLP exporter: OTLP over HTTP compatible endpoint + */ + endpoint?: string; + /** + * List of propagation formats to use for incoming and outgoing requests + */ + propagators?: Propagator[]; + /** + * Sample rate for traces + * + * Defaults to `1.0` if not set. + */ + sample_rate?: number; + [k: string]: unknown; +} +/** + * Configuration related to exporting metrics + */ +export interface MetricsConfig { + /** + * Exporter to use when exporting metrics + */ + exporter?: MetricsExporterKind & string; + /** + * OTLP exporter: OTLP over HTTP compatible endpoint + */ + endpoint?: string; + [k: string]: unknown; +} +/** + * Configuration related to the Sentry integration + */ +export interface SentryConfig { + /** + * Sentry DSN + */ + dsn?: string; + /** + * Environment to use when sending events to Sentry + * + * Defaults to `production` if not set. + */ + environment?: string; + /** + * Sample rate for event submissions + * + * Defaults to `1.0` if not set. + */ + sample_rate?: number; + /** + * Sample rate for tracing transactions + * + * Defaults to `0.0` if not set. + */ + traces_sample_rate?: number; + [k: string]: unknown; +} +/** + * Configuration related to templates + */ +export interface TemplatesConfig { + /** + * Path to the folder which holds the templates + */ + path?: string; + /** + * Path to the assets manifest + */ + assets_manifest?: string; + /** + * Path to the translations + */ + translations_path?: string; + [k: string]: unknown; +} +/** + * Configuration related to sending emails + */ +export interface EmailConfig { + /** + * Email address to use as From when sending emails + */ + from?: string; + /** + * Email address to use as Reply-To when sending emails + */ + reply_to?: string; + /** + * What backend should be used when sending emails + */ + transport: EmailTransportKind; + /** + * SMTP transport: Connection mode to the relay + */ + mode?: EmailSmtpMode; + /** + * SMTP transport: Hostname to connect to + */ + hostname?: Hostname; + /** + * SMTP transport: Port to connect to. Default is 25 for plain, 465 for TLS and 587 for `StartTLS` + */ + port?: number; + /** + * SMTP transport: Username for use to authenticate when connecting to the SMTP server + * + * Must be set if the `password` field is set + */ + username?: string; + /** + * SMTP transport: Password for use to authenticate when connecting to the SMTP server + * + * Must be set if the `username` field is set + */ + password?: string; + /** + * Sendmail transport: Command to use to send emails + */ + command?: string; + [k: string]: unknown; +} +/** + * Application secrets + */ +export interface SecretsConfig { + /** + * List of private keys to use for signing and encrypting payloads + */ + keys?: KeyConfig[]; + /** + * File containing the encryption key for secure cookies. + */ + encryption_file?: string; + /** + * Encryption key for secure cookies. + */ + encryption?: string; + [k: string]: unknown; +} +/** + * A single key with its key ID and optional password. + */ +export interface KeyConfig { + kid: string; + password_file?: string; + password?: string; + key_file?: string; + key?: string; + [k: string]: unknown; +} +/** + * User password hashing config + */ +export interface PasswordsConfig { + /** + * Whether password-based authentication is enabled + */ + enabled?: boolean; + /** + * The hashing schemes to use for hashing and validating passwords + * + * The hashing scheme with the highest version number will be used for hashing new passwords. + */ + schemes?: HashingScheme[]; + /** + * Score between 0 and 4 determining the minimum allowed password complexity. Scores are based on the ESTIMATED number of guesses needed to guess the password. + * + * - 0: less than 10^2 (100) - 1: less than 10^4 (10'000) - 2: less than 10^6 (1'000'000) - 3: less than 10^8 (100'000'000) - 4: any more than that + */ + minimum_complexity?: number; + [k: string]: unknown; +} +/** + * Parameters for a password hashing scheme + */ +export interface HashingScheme { + /** + * The version of the hashing scheme. They must be unique, and the highest version will be used for hashing new passwords. + */ + version: number; + /** + * The hashing algorithm to use + */ + algorithm: Algorithm; + /** + * Whether to apply Unicode normalization to the password before hashing + * + * Defaults to `false`, and generally recommended to stay false. This is although recommended when importing password hashs from Synapse, as it applies an NFKC normalization to the password before hashing it. + */ + unicode_normalization?: boolean; + /** + * Cost for the bcrypt algorithm + */ + cost?: number; + /** + * An optional secret to use when hashing passwords. This makes it harder to brute-force the passwords in case of a database leak. + */ + secret?: string; + /** + * Same as `secret`, but read from a file. + */ + secret_file?: string; + [k: string]: unknown; +} +/** + * Configuration related to the Matrix homeserver + */ +export interface MatrixConfig { + /** + * The kind of homeserver it is. + */ + kind?: HomeserverKind & string; + /** + * The server name of the homeserver. + */ + homeserver?: string; + /** + * Shared secret to use for calls to the admin API + */ + secret: string; + /** + * The base URL of the homeserver's client API + */ + endpoint?: string; + [k: string]: unknown; +} +/** + * Application secrets + */ +export interface PolicyConfig { + /** + * Path to the WASM module + */ + wasm_module?: string; + /** + * Entrypoint to use when evaluating client registrations + */ + client_registration_entrypoint?: string; + /** + * Entrypoint to use when evaluating user registrations + */ + register_entrypoint?: string; + /** + * Entrypoint to use when evaluating authorization grants + */ + authorization_grant_entrypoint?: string; + /** + * Entrypoint to use when changing password + */ + password_entrypoint?: string; + /** + * Entrypoint to use when adding an email address + */ + email_entrypoint?: string; + /** + * Arbitrary data to pass to the policy + */ + data?: { + [k: string]: unknown; + }; + [k: string]: unknown; +} +/** + * Configuration related to sending emails + */ +export interface RateLimitingConfig { + /** + * Account Recovery-specific rate limits + */ + account_recovery?: AccountRecoveryRateLimitingConfig; + /** + * Login-specific rate limits + */ + login?: LoginRateLimitingConfig; + /** + * Controls how many registrations attempts are permitted based on source address. + */ + registration?: RateLimiterConfiguration; + /** + * Email authentication-specific rate limits + */ + email_authentication?: EmailauthenticationRateLimitingConfig; + [k: string]: unknown; +} +export interface AccountRecoveryRateLimitingConfig { + /** + * Controls how many account recovery attempts are permitted based on source IP address. This can protect against causing e-mail spam to many targets. + * + * Note: this limit also applies to re-sends. + */ + per_ip?: RateLimiterConfiguration; + /** + * Controls how many account recovery attempts are permitted based on the e-mail address entered into the recovery form. This can protect against causing e-mail spam to one target. + * + * Note: this limit also applies to re-sends. + */ + per_address?: RateLimiterConfiguration; + [k: string]: unknown; +} +export interface RateLimiterConfiguration { + /** + * A one-off burst of actions that the user can perform in one go without waiting. + */ + burst: number; + /** + * How quickly the allowance replenishes, in number of actions per second. Can be fractional to replenish slower. + */ + per_second: number; + [k: string]: unknown; +} +export interface LoginRateLimitingConfig { + /** + * Controls how many login attempts are permitted based on source IP address. This can protect against brute force login attempts. + * + * Note: this limit also applies to password checks when a user attempts to change their own password. + */ + per_ip?: RateLimiterConfiguration; + /** + * Controls how many login attempts are permitted based on the account that is being attempted to be logged into. This can protect against a distributed brute force attack but should be set high enough to prevent someone's account being casually locked out. + * + * Note: this limit also applies to password checks when a user attempts to change their own password. + */ + per_account?: RateLimiterConfiguration; + [k: string]: unknown; +} +export interface EmailauthenticationRateLimitingConfig { + /** + * Controls how many email authentication attempts are permitted based on the source IP address. This can protect against causing e-mail spam to many targets. + */ + per_ip?: RateLimiterConfiguration; + /** + * Controls how many email authentication attempts are permitted based on the e-mail address entered into the authentication form. This can protect against causing e-mail spam to one target. + * + * Note: this limit also applies to re-sends. + */ + per_address?: RateLimiterConfiguration; + /** + * Controls how many authentication emails are permitted to be sent per authentication session. This ensures not too many authentication codes are created for the same authentication session. + */ + emails_per_session?: RateLimiterConfiguration; + /** + * Controls how many code authentication attempts are permitted per authentication session. This can protect against brute-forcing the code. + */ + attempt_per_session?: RateLimiterConfiguration; + [k: string]: unknown; +} +/** + * Upstream OAuth 2.0 providers configuration + */ +export interface UpstreamOAuth2Config { + /** + * List of OAuth 2.0 providers + */ + providers: Provider[]; + [k: string]: unknown; +} +/** + * Configuration for one upstream OAuth 2 provider. + */ +export interface Provider { + /** + * Whether this provider is enabled. + * + * Defaults to `true` + */ + enabled?: boolean; + /** + * A ULID as per https://github.com/ulid/spec + */ + id: string; + /** + * The ID of the provider that was used by Synapse. In order to perform a Synapse-to-MAS migration, this must be specified. + * + * ## For providers that used OAuth 2.0 or OpenID Connect in Synapse + * + * ### For `oidc_providers`: This should be specified as `oidc-` followed by the ID that was configured as `idp_id` in one of the `oidc_providers` in the Synapse configuration. For example, if Synapse's configuration contained `idp_id: wombat` for this provider, then specify `oidc-wombat` here. + * + * ### For `oidc_config` (legacy): Specify `oidc` here. + */ + synapse_idp_id?: string; + /** + * The OIDC issuer URL + * + * This is required if OIDC discovery is enabled (which is the default) + */ + issuer?: string; + /** + * A human-readable name for the provider, that will be shown to users + */ + human_name?: string; + /** + * A brand identifier used to customise the UI, e.g. `apple`, `google`, `github`, etc. + * + * Values supported by the default template are: + * + * - `apple` - `google` - `facebook` - `github` - `gitlab` - `twitter` - `discord` + */ + brand_name?: string; + /** + * The client ID to use when authenticating with the provider + */ + client_id: string; + /** + * The client secret to use when authenticating with the provider + * + * Used by the `client_secret_basic`, `client_secret_post`, and `client_secret_jwt` methods + */ + client_secret?: string; + /** + * The method to authenticate the client with the provider + */ + token_endpoint_auth_method: TokenAuthMethod; + /** + * Additional parameters for the `sign_in_with_apple` method + */ + sign_in_with_apple?: SignInWithApple; + /** + * The JWS algorithm to use when authenticating the client with the provider + * + * Used by the `client_secret_jwt` and `private_key_jwt` methods + */ + token_endpoint_auth_signing_alg?: JsonWebSignatureAlg; + /** + * Expected signature for the JWT payload returned by the token authentication endpoint. + * + * Defaults to `RS256`. + */ + id_token_signed_response_alg?: JsonWebSignatureAlg; + /** + * The scopes to request from the provider + * + * Defaults to `openid`. + */ + scope?: string; + /** + * How to discover the provider's configuration + * + * Defaults to `oidc`, which uses OIDC discovery with strict metadata verification + */ + discovery_mode?: DiscoveryMode; + /** + * Whether to use proof key for code exchange (PKCE) when requesting and exchanging the token. + * + * Defaults to `auto`, which uses PKCE if the provider supports it. + */ + pkce_method?: PkceMethod; + /** + * Whether to fetch the user profile from the userinfo endpoint, or to rely on the data returned in the `id_token` from the `token_endpoint`. + * + * Defaults to `false`. + */ + fetch_userinfo?: boolean; + /** + * Expected signature for the JWT payload returned by the userinfo endpoint. + * + * If not specified, the response is expected to be an unsigned JSON payload. + */ + userinfo_signed_response_alg?: JsonWebSignatureAlg; + /** + * The URL to use for the provider's authorization endpoint + * + * Defaults to the `authorization_endpoint` provided through discovery + */ + authorization_endpoint?: string; + /** + * The URL to use for the provider's userinfo endpoint + * + * Defaults to the `userinfo_endpoint` provided through discovery + */ + userinfo_endpoint?: string; + /** + * The URL to use for the provider's token endpoint + * + * Defaults to the `token_endpoint` provided through discovery + */ + token_endpoint?: string; + /** + * The URL to use for getting the provider's public keys + * + * Defaults to the `jwks_uri` provided through discovery + */ + jwks_uri?: string; + /** + * The response mode we ask the provider to use for the callback + */ + response_mode?: ResponseMode; + /** + * How claims should be imported from the `id_token` provided by the provider + */ + claims_imports?: ClaimsImports; + /** + * Additional parameters to include in the authorization request + * + * Orders of the keys are not preserved. + */ + additional_authorization_parameters?: { + [k: string]: string; + }; + /** + * Whether the `login_hint` should be forwarded to the provider in the authorization request. + * + * Defaults to `false`. + */ + forward_login_hint?: boolean; + /** + * What to do when receiving an OIDC Backchannel logout request. + * + * Defaults to "do_nothing". + */ + on_backchannel_logout?: OnBackchannelLogout; + [k: string]: unknown; +} +export interface SignInWithApple { + /** + * The private key file used to sign the `id_token` + */ + private_key_file?: string; + /** + * The private key used to sign the `id_token` + */ + private_key?: string; + /** + * The Team ID of the Apple Developer Portal + */ + team_id: string; + /** + * The key ID of the Apple Developer Portal + */ + key_id: string; + [k: string]: unknown; +} +/** + * How claims should be imported + */ +export interface ClaimsImports { + /** + * How to determine the subject of the user + */ + subject?: SubjectImportPreference; + /** + * Import the localpart of the MXID + */ + localpart?: LocalpartImportPreference; + /** + * Import the displayname of the user. + */ + displayname?: DisplaynameImportPreference; + /** + * Import the email address of the user based on the `email` and `email_verified` claims + */ + email?: EmailImportPreference; + /** + * Set a human-readable name for the upstream account for display purposes + */ + account_name?: AccountNameImportPreference; + [k: string]: unknown; +} +/** + * What should be done for the subject attribute + */ +export interface SubjectImportPreference { + /** + * The Jinja2 template to use for the subject attribute + * + * If not provided, the default template is `{{ user.sub }}` + */ + template?: string; + [k: string]: unknown; +} +/** + * What should be done for the localpart attribute + */ +export interface LocalpartImportPreference { + /** + * How to handle the attribute + */ + action?: ImportAction; + /** + * The Jinja2 template to use for the localpart attribute + * + * If not provided, the default template is `{{ user.preferred_username }}` + */ + template?: string; + /** + * How to handle conflicts on the claim, default value is `Fail` + */ + on_conflict?: OnConflict; + [k: string]: unknown; +} +/** + * What should be done for the displayname attribute + */ +export interface DisplaynameImportPreference { + /** + * How to handle the attribute + */ + action?: ImportAction; + /** + * The Jinja2 template to use for the displayname attribute + * + * If not provided, the default template is `{{ user.name }}` + */ + template?: string; + [k: string]: unknown; +} +/** + * What should be done with the email attribute + */ +export interface EmailImportPreference { + /** + * How to handle the claim + */ + action?: ImportAction; + /** + * The Jinja2 template to use for the email address attribute + * + * If not provided, the default template is `{{ user.email }}` + */ + template?: string; + [k: string]: unknown; +} +/** + * What should be done for the account name attribute + */ +export interface AccountNameImportPreference { + /** + * The Jinja2 template to use for the account name. This name is only used for display purposes. + * + * If not provided, it will be ignored. + */ + template?: string; + [k: string]: unknown; +} +/** + * Configuration section for tweaking the branding of the service + */ +export interface BrandingConfig { + /** + * A human-readable name. Defaults to the server's address. + */ + service_name?: string; + /** + * Link to a privacy policy, displayed in the footer of web pages and emails. It is also advertised to clients through the `op_policy_uri` OIDC provider metadata. + */ + policy_uri?: string; + /** + * Link to a terms of service document, displayed in the footer of web pages and emails. It is also advertised to clients through the `op_tos_uri` OIDC provider metadata. + */ + tos_uri?: string; + /** + * Legal imprint, displayed in the footer in the footer of web pages and emails. + */ + imprint?: string; + /** + * Logo displayed in some web pages. + */ + logo_uri?: string; + [k: string]: unknown; +} +/** + * Configuration section to setup CAPTCHA protection on a few operations + */ +export interface CaptchaConfig { + /** + * Which service should be used for CAPTCHA protection + */ + service?: CaptchaServiceKind; + /** + * The site key to use + */ + site_key?: string; + /** + * The secret key to use + */ + secret_key?: string; + [k: string]: unknown; +} +/** + * Configuration section to configure features related to account management + */ +export interface AccountConfig { + /** + * Whether users are allowed to change their email addresses. Defaults to `true`. + */ + email_change_allowed?: boolean; + /** + * Whether users are allowed to change their display names. Defaults to `true`. + * + * This should be in sync with the policy in the homeserver configuration. + */ + displayname_change_allowed?: boolean; + /** + * Whether to enable self-service password registration. Defaults to `false` if password authentication is enabled. + * + * This has no effect if password login is disabled. + */ + password_registration_enabled?: boolean; + /** + * Whether users are allowed to change their passwords. Defaults to `true`. + * + * This has no effect if password login is disabled. + */ + password_change_allowed?: boolean; + /** + * Whether email-based password recovery is enabled. Defaults to `false`. + * + * This has no effect if password login is disabled. + */ + password_recovery_enabled?: boolean; + /** + * Whether users are allowed to delete their own account. Defaults to `true`. + */ + account_deactivation_allowed?: boolean; + /** + * Whether users can log in with their email address. Defaults to `false`. + * + * This has no effect if password login is disabled. + */ + login_with_email_allowed?: boolean; + /** + * Whether registration tokens are required for password registrations. Defaults to `false`. + * + * When enabled, users must provide a valid registration token during password registration. This has no effect if password registration is disabled. + */ + registration_token_required?: boolean; + [k: string]: unknown; +} +/** + * Configuration sections for experimental options + * + * Do not change these options unless you know what you are doing. + */ +export interface ExperimentalConfig { + /** + * Time-to-live of access tokens in seconds. Defaults to 5 minutes. + */ + access_token_ttl?: number; + /** + * Time-to-live of compatibility access tokens in seconds. Defaults to 5 minutes. + */ + compat_token_ttl?: number; + /** + * Experimetal feature to automatically expire inactive sessions + * + * Disabled by default + */ + inactive_session_expiration?: InactiveSessionExpirationConfig; + /** + * Experimental feature to show a plan management tab and iframe. This value is passed through "as is" to the client without any validation. + */ + plan_management_iframe_uri?: string; + [k: string]: unknown; +} +/** + * Configuration options for the inactive session expiration feature + */ +export interface InactiveSessionExpirationConfig { + /** + * Time after which an inactive session is automatically finished + */ + ttl: number; + /** + * Should compatibility sessions expire after inactivity + */ + expire_compat_sessions?: boolean; + /** + * Should OAuth 2.0 sessions expire after inactivity + */ + expire_oauth_sessions?: boolean; + /** + * Should user sessions expire after inactivity + */ + expire_user_sessions?: boolean; + [k: string]: unknown; +} diff --git a/packages/playwright-common/src/testcontainers/mas.ts b/packages/playwright-common/src/testcontainers/mas.ts new file mode 100644 index 0000000000..8e64db59f8 --- /dev/null +++ b/packages/playwright-common/src/testcontainers/mas.ts @@ -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" ', + reply_to: '"Authentication Service" ', + 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): this { + this.config = { + ...this.config, + ...config, + }; + return this; + } + + /** + * Starts the MAS container + */ + public override async start(): Promise { + // 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; + + 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 { + 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 { + 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 { + 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> { + 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> { + 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 { + 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, + name = "mas", +): Promise { + const container = await new MatrixAuthenticationServiceContainer(postgres) + .withNetwork(network) + .withNetworkAliases(name) + .withLogConsumer(logger.getConsumer(name)) + .withConfig(config) + .start(); + return container; +} diff --git a/packages/playwright-common/src/testcontainers/postgres.ts b/packages/playwright-common/src/testcontainers/postgres.ts new file mode 100644 index 0000000000..0dd0eab27d --- /dev/null +++ b/packages/playwright-common/src/testcontainers/postgres.ts @@ -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 { + 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; +} diff --git a/packages/playwright-common/src/testcontainers/synapse.ts b/packages/playwright-common/src/testcontainers/synapse.ts new file mode 100644 index 0000000000..65d92c0ab9 --- /dev/null +++ b/packages/playwright-common/src/testcontainers/synapse.ts @@ -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; + 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, + 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 }>, + 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 { + 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): 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 ", + 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 { + // 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; + 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): Promise { + 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 { + // Clean up the server to prevent rooms leaking between tests + await this.deletePublicRooms(); + } + + protected async deletePublicRooms(): Promise { + 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 { + 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 { + if (this.adminTokenPromise === undefined) { + this.adminTokenPromise = this.registerUserInternal( + "admin", + "totalyinsecureadminpassword", + undefined, + true, + ).then((res) => res.accessToken); + } + return this.adminTokenPromise; + } + + private async adminRequest(verb: "GET", path: string, data?: never): Promise; + private async adminRequest(verb: Verb, path: string, data?: object): Promise; + private async adminRequest(verb: Verb, path: string, data?: object): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.mas.setThreepid(userId, medium, address); + } +} diff --git a/packages/playwright-common/src/utils/api.ts b/packages/playwright-common/src/utils/api.ts new file mode 100644 index 0000000000..8ce6f2f5dc --- /dev/null +++ b/packages/playwright-common/src/utils/api.ts @@ -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(verb: "GET", path: string, token?: string, data?: never): Promise; + public async request(verb: Verb, path: string, token?: string, data?: object): Promise; + public async request(verb: Verb, path: string, token?: string, data?: object): Promise { + 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> { + 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], + }; + } +} diff --git a/packages/playwright-common/src/utils/config_json.ts b/packages/playwright-common/src/utils/config_json.ts new file mode 100644 index 0000000000..cc97a07be5 --- /dev/null +++ b/packages/playwright-common/src/utils/config_json.ts @@ -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 = {}, + labsFlags: string[] = [], + disablePresence: boolean = false, +): Partial { + 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>((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 = {}, + labsFlags: string[] = [], + disablePresence: boolean = false, +): Promise { + await context.route(`http://localhost:8080/config.json*`, async (route) => { + const json = buildConfigJson(homeserverBaseUrl, additionalConfig, labsFlags, disablePresence); + await route.fulfill({ json }); + }); +} diff --git a/packages/playwright-common/src/utils/context.ts b/packages/playwright-common/src/utils/context.ts new file mode 100644 index 0000000000..c11477fe9c --- /dev/null +++ b/packages/playwright-common/src/utils/context.ts @@ -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 = {}, + labsFlags: string[] = [], + disablePresence: boolean = false, +): Promise { + 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; +} diff --git a/packages/playwright-common/src/utils/logger.ts b/packages/playwright-common/src/utils/logger.ts new file mode 100644 index 0000000000..294a87067b --- /dev/null +++ b/packages/playwright-common/src/utils/logger.ts @@ -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 = {}; + + /** + * 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", + }); + } + } + } +} diff --git a/packages/playwright-common/src/utils/object.ts b/packages/playwright-common/src/utils/object.ts new file mode 100644 index 0000000000..645e50bbad --- /dev/null +++ b/packages/playwright-common/src/utils/object.ts @@ -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(obj: T): T { + return JSON.parse(JSON.stringify(obj)); +} diff --git a/packages/playwright-common/src/utils/port.ts b/packages/playwright-common/src/utils/port.ts new file mode 100644 index 0000000000..d39594caac --- /dev/null +++ b/packages/playwright-common/src/utils/port.ts @@ -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 { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(0, () => { + const port = (srv.address()).port; + srv.close(() => resolve(port)); + }); + }); +} diff --git a/packages/playwright-common/src/utils/rand.ts b/packages/playwright-common/src/utils/rand.ts new file mode 100644 index 0000000000..debb5bc69a --- /dev/null +++ b/packages/playwright-common/src/utils/rand.ts @@ -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(/=*$/, ""); +} diff --git a/packages/playwright-common/tsconfig.json b/packages/playwright-common/tsconfig.json new file mode 100644 index 0000000000..c1f4957828 --- /dev/null +++ b/packages/playwright-common/tsconfig.json @@ -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"] +} diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 390010a2a6..29a3ded4d6 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -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", diff --git a/packages/shared-components/project.json b/packages/shared-components/project.json index 13d072b225..0c9dc50527 100644 --- a/packages/shared-components/project.json +++ b/packages/shared-components/project.json @@ -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"] } } } diff --git a/playwright-merge.config.ts b/playwright-merge.config.ts index c4498527a3..3a30236e14 100644 --- a/playwright-merge.config.ts +++ b/playwright-merge.config.ts @@ -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"], ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 220a6247e4..0bf4434d24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 44f4f651b6..5d458e9b97 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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