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