diff --git a/package.json b/package.json index 6581f0c2e0..7843a43c27 100644 --- a/package.json +++ b/package.json @@ -62,19 +62,19 @@ "test": "jest", "test:playwright": "playwright test", "test:playwright:open": "yarn test:playwright --ui", - "test:playwright:screenshots": "yarn test:playwright:screenshots:build && yarn test:playwright:screenshots:run", - "test:playwright:screenshots:build": "docker build playwright -t element-web-playwright", - "test:playwright:screenshots:run": "docker run --rm --network host -e BASE_URL -e CI -v $(pwd):/work/ -v $(node -e 'console.log(require(`path`).dirname(require.resolve(`matrix-js-sdk/package.json`)))'):/work/node_modules/matrix-js-sdk -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/:/tmp/ -it element-web-playwright --grep @screenshot --project=Chrome", + "test:playwright:screenshots": "playwright-screenshots --project=Chrome", "coverage": "yarn test --coverage", "analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp", "update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js" }, "resolutions": { + "@playwright/test": "1.50.1", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "oidc-client-ts": "3.1.0", "jwt-decode": "4.0.0", "caniuse-lite": "1.0.30001701", + "testcontainers": "10.20.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0", "wrap-ansi": "npm:wrap-ansi@^7.0.0" }, @@ -158,7 +158,6 @@ "devDependencies": { "@action-validator/cli": "^0.6.0", "@action-validator/core": "^0.6.0", - "@axe-core/playwright": "^4.8.1", "@babel/core": "^7.12.10", "@babel/eslint-parser": "^7.12.10", "@babel/eslint-plugin": "^7.12.10", @@ -178,13 +177,13 @@ "@babel/preset-typescript": "^7.12.7", "@babel/runtime": "^7.12.5", "@casualbot/jest-sonar-reporter": "2.2.7", + "@element-hq/element-web-playwright-common": "^1.1.5", "@peculiar/webcrypto": "^1.4.3", - "@playwright/test": "^1.40.1", + "@playwright/test": "^1.50.1", "@principalstudio/html-webpack-inject-preload": "^1.2.7", "@sentry/webpack-plugin": "^3.0.0", "@stylistic/eslint-plugin": "^3.0.0", "@svgr/webpack": "^8.0.0", - "@testcontainers/postgresql": "^10.16.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", @@ -259,13 +258,12 @@ "jsqr": "^1.4.0", "knip": "^5.36.2", "lint-staged": "^15.0.2", - "mailpit-api": "^1.0.5", "matrix-web-i18n": "^3.2.1", "mini-css-extract-plugin": "2.9.2", "minimist": "^1.2.6", "modernizr": "^3.12.0", "node-fetch": "^2.6.7", - "playwright-core": "^1.45.1", + "playwright-core": "^1.51.0", "postcss": "8.4.46", "postcss-easings": "^4.0.0", "postcss-hexrgba": "2.1.0", @@ -282,13 +280,12 @@ "rimraf": "^6.0.0", "semver": "^7.5.2", "source-map-loader": "^5.0.0", - "strip-ansi": "^7.1.0", "stylelint": "^16.13.0", "stylelint-config-standard": "^37.0.0", "stylelint-scss": "^6.0.0", "stylelint-value-no-unknown-custom-properties": "^6.0.1", "terser-webpack-plugin": "^5.3.9", - "testcontainers": "^10.16.0", + "testcontainers": "^10.20.0", "ts-node": "^10.9.1", "typescript": "5.8.2", "util": "^0.12.5", diff --git a/playwright.config.ts b/playwright.config.ts index 09bd07bb3b..519e2d9c3b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import { defineConfig, devices } from "@playwright/test"; -import { Options } from "./playwright/services"; +import { type WorkerOptions } from "./playwright/services"; const baseURL = process.env["BASE_URL"] ?? "http://localhost:8080"; @@ -21,7 +21,7 @@ const chromeProject = { }, }; -export default defineConfig({ +export default defineConfig({ projects: [ { name: "Chrome", @@ -83,6 +83,7 @@ export default defineConfig({ url: `${baseURL}/config.json`, reuseExistingServer: true, timeout: (process.env.CI ? 30 : 120) * 1000, + stdout: "pipe", }, testDir: "playwright/e2e", outputDir: "playwright/test-results", diff --git a/playwright/@types/playwright-core.d.ts b/playwright/@types/playwright-core.d.ts deleted file mode 100644 index 244f3c91d4..0000000000 --- a/playwright/@types/playwright-core.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -declare module "playwright-core/lib/utils" { - // This type is not public in playwright-core utils - export function sanitizeForFilePath(filePath: string): string; -} diff --git a/playwright/Dockerfile b/playwright/Dockerfile deleted file mode 100644 index 6d812037de..0000000000 --- a/playwright/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM mcr.microsoft.com/playwright:v1.51.0-noble - -WORKDIR /work - -# fonts-dejavu is needed for the same RTL rendering as on CI -RUN apt-get update && apt-get -y install docker.io fonts-dejavu - -COPY docker-entrypoint.sh /opt/docker-entrypoint.sh -ENTRYPOINT ["bash", "/opt/docker-entrypoint.sh"] diff --git a/playwright/docker-entrypoint.sh b/playwright/docker-entrypoint.sh deleted file mode 100644 index 241528a29a..0000000000 --- a/playwright/docker-entrypoint.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -e - -npx playwright test --update-snapshots --reporter line $@ diff --git a/playwright/e2e/csAPI.ts b/playwright/e2e/csAPI.ts index 4f12076139..c622ac99ce 100644 --- a/playwright/e2e/csAPI.ts +++ b/playwright/e2e/csAPI.ts @@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import { type APIRequestContext } from "playwright-core"; +import { type APIRequestContext } from "@playwright/test"; import { type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; +import { ClientServerApi } from "@element-hq/element-web-playwright-common/lib/utils/api.js"; import { type HomeserverInstance } from "../plugins/homeserver"; -import { ClientServerApi } from "../plugins/utils/api.ts"; /** * A small subset of the Client-Server API used to manipulate the state of the diff --git a/playwright/e2e/editing/editing.spec.ts b/playwright/e2e/editing/editing.spec.ts index 528bcc5962..6f8e68bbc3 100644 --- a/playwright/e2e/editing/editing.spec.ts +++ b/playwright/e2e/editing/editing.spec.ts @@ -267,7 +267,6 @@ test.describe("Editing", () => { app, room, axe, - checkA11y, }) => { axe.disableRules("color-contrast"); // XXX: We have some known contrast issues here @@ -282,7 +281,7 @@ test.describe("Editing", () => { const line = tile.locator(".mx_EventTile_line"); await line.hover(); await line.getByRole("button", { name: "Edit" }).click(); - await checkA11y(); + await expect(axe).toHaveNoViolations(); const editComposer = page.getByRole("textbox", { name: "Edit message" }); await editComposer.pressSequentially("Foo"); await editComposer.press("Backspace"); @@ -290,7 +289,7 @@ test.describe("Editing", () => { await editComposer.press("Backspace"); await editComposer.press("Enter"); await app.getComposerField().hover(); // XXX: move the hover to get rid of the "Edit" tooltip - await checkA11y(); + await expect(axe).toHaveNoViolations(); } await expect( page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: "Message" }), @@ -305,7 +304,6 @@ test.describe("Editing", () => { user, app, axe, - checkA11y, bot: bob, }) => { // This tests the behaviour when a message has been edited some time after it has been sent, and we diff --git a/playwright/e2e/login/login-consent.spec.ts b/playwright/e2e/login/login-consent.spec.ts index ec6dfbf7c2..28948dc3b9 100644 --- a/playwright/e2e/login/login-consent.spec.ts +++ b/playwright/e2e/login/login-consent.spec.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type Page } from "playwright-core"; +import { type Page } from "@playwright/test"; import { expect, test } from "../../element-web-test"; import { selectHomeserver } from "../utils"; @@ -120,7 +120,7 @@ test.describe("Login", () => { credentials, page, homeserver, - checkA11y, + axe, }) => { await page.goto("/"); @@ -149,7 +149,7 @@ test.describe("Login", () => { await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible(); // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 // cy.percySnapshot("Login"); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await page.getByRole("textbox", { name: "Username" }).fill(credentials.username); await page.getByPlaceholder("Password").fill(credentials.password); diff --git a/playwright/e2e/messages/messages.spec.ts b/playwright/e2e/messages/messages.spec.ts index e5d65caff5..f430d6b18b 100644 --- a/playwright/e2e/messages/messages.spec.ts +++ b/playwright/e2e/messages/messages.spec.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. /* See readme.md for tips on writing these tests. */ -import { type Locator, type Page } from "playwright-core"; +import { type Locator, type Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; diff --git a/playwright/e2e/oidc/index.ts b/playwright/e2e/oidc/index.ts index b1635fbd78..1989e8764f 100644 --- a/playwright/e2e/oidc/index.ts +++ b/playwright/e2e/oidc/index.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type MailpitClient } from "mailpit-api"; +import { type MailpitClient } from "@element-hq/element-web-playwright-common/lib/testcontainers"; import { type Page } from "@playwright/test"; import { expect } from "../../element-web-test"; diff --git a/playwright/e2e/register/email.spec.ts b/playwright/e2e/register/email.spec.ts index d351893f8b..bf8a2157f5 100644 --- a/playwright/e2e/register/email.spec.ts +++ b/playwright/e2e/register/email.spec.ts @@ -34,7 +34,7 @@ test.describe("Email Registration", async () => { test( "registers an account and lands on the home page", { tag: "@screenshot" }, - async ({ page, mailpitClient, request, checkA11y }) => { + async ({ page, mailpitClient, request, axe }) => { await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible(); // Hide the server text as it contains the randomly allocated Homeserver port const screenshotOptions = { mask: [page.locator(".mx_ServerPicker_server")] }; @@ -47,7 +47,7 @@ test.describe("Email Registration", async () => { await expect(page.getByText("Check your email to continue")).toBeVisible(); await expect(page).toMatchScreenshot("registration_check_your_email.png", screenshotOptions); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await expect(page.getByText("An error was encountered when sending the email")).not.toBeVisible(); diff --git a/playwright/e2e/register/register.spec.ts b/playwright/e2e/register/register.spec.ts index 3df1d01678..c481b6ac43 100644 --- a/playwright/e2e/register/register.spec.ts +++ b/playwright/e2e/register/register.spec.ts @@ -33,12 +33,12 @@ test.describe("Registration", () => { test( "registers an account and lands on the home screen", { tag: "@screenshot" }, - async ({ homeserver, page, checkA11y, crypto }) => { + async ({ homeserver, page, axe, crypto }) => { await page.getByRole("button", { name: "Edit", exact: true }).click(); await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeVisible(); await expect(page.locator(".mx_Dialog")).toMatchScreenshot("server-picker.png"); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.baseUrl); await page.getByRole("button", { name: "Continue", exact: true }).click(); @@ -52,7 +52,7 @@ test.describe("Registration", () => { includeDialogBackground: true, }; await expect(page).toMatchScreenshot("registration.png", screenshotOptions); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await page.getByRole("textbox", { name: "Username", exact: true }).fill("alice"); await page.getByPlaceholder("Password", { exact: true }).fill("totally a great password"); @@ -62,12 +62,12 @@ test.describe("Registration", () => { const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await expect(page).toMatchScreenshot("email-prompt.png", screenshotOptions); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await dialog.getByRole("button", { name: "Continue", exact: true }).click(); await expect(page.locator(".mx_InteractiveAuthEntryComponents_termsPolicy")).toBeVisible(); await expect(page).toMatchScreenshot("terms-prompt.png", screenshotOptions); - await checkA11y(); + await expect(axe).toHaveNoViolations(); const termsPolicy = page.locator(".mx_InteractiveAuthEntryComponents_termsPolicy"); await termsPolicy.getByRole("checkbox").click(); // Click the checkbox before terms of service anchor link diff --git a/playwright/e2e/spaces/spaces.spec.ts b/playwright/e2e/spaces/spaces.spec.ts index 01a385c9df..dce7515ef0 100644 --- a/playwright/e2e/spaces/spaces.spec.ts +++ b/playwright/e2e/spaces/spaces.spec.ts @@ -227,7 +227,7 @@ test.describe("Spaces", () => { test( "should render subspaces in the space panel only when expanded", { tag: "@screenshot" }, - async ({ page, app, user, axe, checkA11y }) => { + async ({ page, app, user, axe }) => { axe.disableRules([ // Disable this check as it triggers on nested roving tab index elements which are in practice fine "nested-interactive", @@ -249,7 +249,7 @@ test.describe("Spaces", () => { await expect(spaceTree.getByRole("button", { name: "Root Space" })).toBeVisible(); await expect(spaceTree.getByRole("button", { name: "Child Space" })).not.toBeVisible(); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await expect(page.locator(".mx_SpacePanel")).toMatchScreenshot("space-panel-collapsed.png"); // This finds the expand button with the class name "mx_SpaceButton_toggleCollapse". Note there is another @@ -261,7 +261,7 @@ test.describe("Spaces", () => { await expect(item).toBeVisible(); await expect(item.locator(".mx_SpaceItem", { hasText: "Child Space" })).toBeVisible(); - await checkA11y(); + await expect(axe).toHaveNoViolations(); await expect(page.locator(".mx_SpacePanel")).toMatchScreenshot("space-panel-expanded.png"); }, ); diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index 5c1f99511e..7b13d1ccb1 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -277,7 +277,7 @@ test.describe("Timeline", () => { test( "should add inline start margin to an event line on IRC layout", { tag: "@screenshot" }, - async ({ page, app, room, axe, checkA11y }) => { + async ({ page, app, room, axe }) => { axe.disableRules("color-contrast"); await page.goto(`/#/room/${room.roomId}`); @@ -318,7 +318,7 @@ test.describe("Timeline", () => { `, }, ); - await checkA11y(); + await expect(axe).toHaveNoViolations(); }, ); }); @@ -743,68 +743,64 @@ test.describe("Timeline", () => { ).toBeVisible(); }); - test( - "should render url previews", - { tag: "@screenshot" }, - async ({ page, app, room, axe, checkA11y, context }) => { - axe.disableRules("color-contrast"); + test("should render url previews", { tag: "@screenshot" }, async ({ page, app, room, axe, context }) => { + axe.disableRules("color-contrast"); - // Element Web uses a Service Worker to rewrite unauthenticated media requests to authenticated ones, but - // the page can't see this happening. We intercept the route at the BrowserContext to ensure we get it - // post-worker, but we can't waitForResponse on that, so the page context is still used there. Because - // the page doesn't see the rewrite, it waits for the unauthenticated route. This is only confusing until - // the js-sdk (and thus the app as a whole) switches to using authenticated endpoints by default, hopefully. - await context.route( - "**/_matrix/client/v1/media/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*", - async (route) => { - await route.fulfill({ - path: "playwright/sample-files/riot.png", - }); - }, - ); - await page.route( - "**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*", - async (route) => { - await route.fulfill({ - json: { - "og:title": "Element Call", - "og:description": null, - "og:image:width": 48, - "og:image:height": 48, - "og:image": "mxc://matrix.org/2022-08-16_yaiSVSRIsNFfxDnV", - "og:image:type": "image/png", - "matrix:image:size": 2121, - }, - }); - }, - ); + // Element Web uses a Service Worker to rewrite unauthenticated media requests to authenticated ones, but + // the page can't see this happening. We intercept the route at the BrowserContext to ensure we get it + // post-worker, but we can't waitForResponse on that, so the page context is still used there. Because + // the page doesn't see the rewrite, it waits for the unauthenticated route. This is only confusing until + // the js-sdk (and thus the app as a whole) switches to using authenticated endpoints by default, hopefully. + await context.route( + "**/_matrix/client/v1/media/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*", + async (route) => { + await route.fulfill({ + path: "playwright/sample-files/riot.png", + }); + }, + ); + await page.route( + "**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*", + async (route) => { + await route.fulfill({ + json: { + "og:title": "Element Call", + "og:description": null, + "og:image:width": 48, + "og:image:height": 48, + "og:image": "mxc://matrix.org/2022-08-16_yaiSVSRIsNFfxDnV", + "og:image:type": "image/png", + "matrix:image:size": 2121, + }, + }); + }, + ); - const requestPromises: Promise[] = [ - page.waitForResponse("**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*"), - // see context.route above for why we listen for the unauthenticated endpoint - page.waitForResponse("**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*"), - ]; + const requestPromises: Promise[] = [ + page.waitForResponse("**/_matrix/media/v3/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*"), + // see context.route above for why we listen for the unauthenticated endpoint + page.waitForResponse("**/_matrix/media/v3/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*"), + ]; - await app.client.sendMessage(room.roomId, "https://call.element.io/"); - await page.goto(`/#/room/${room.roomId}`); + await app.client.sendMessage(room.roomId, "https://call.element.io/"); + await page.goto(`/#/room/${room.roomId}`); - await expect(page.locator(".mx_LinkPreviewWidget").getByText("Element Call")).toBeVisible(); - await Promise.all(requestPromises); + await expect(page.locator(".mx_LinkPreviewWidget").getByText("Element Call")).toBeVisible(); + await Promise.all(requestPromises); - await checkA11y(); + await expect(axe).toHaveNoViolations(); - await app.timeline.scrollToBottom(); - await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot("url-preview.png", { - // Exclude timestamp and read marker from snapshot - mask: [page.locator(".mx_MessageTimestamp")], - css: ` + await app.timeline.scrollToBottom(); + await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot("url-preview.png", { + // Exclude timestamp and read marker from snapshot + mask: [page.locator(".mx_MessageTimestamp")], + css: ` .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { display: none !important; } `, - }); - }, - ); + }); + }); test.describe("on search results panel", () => { test( diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 24124d5474..8520533461 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -7,18 +7,18 @@ Please see LICENSE files in the repository root for full details. */ import { - expect as baseExpect, - type Locator, - type Page, type ExpectMatcherState, - type ElementHandle, + type MatcherReturnType, + type Page, + type Locator, type PlaywrightTestArgs, type Fixtures as _Fixtures, } from "@playwright/test"; -import { sanitizeForFilePath } from "playwright-core/lib/utils"; -import AxeBuilder from "@axe-core/playwright"; -import _ from "lodash"; -import { extname } from "node:path"; +import { + type TestFixtures as BaseTestFixtures, + expect as baseExpect, + type ToMatchScreenshotOptions, +} from "@element-hq/element-web-playwright-common"; import type { IConfigOptions } from "../src/IConfigOptions"; import { type Credentials } from "./plugins/homeserver"; @@ -27,71 +27,22 @@ import { Crypto } from "./pages/crypto"; import { Toasts } from "./pages/toasts"; import { Bot, type CreateBotOpts } from "./pages/bot"; import { Webserver } from "./plugins/webserver"; -import { type Options, type Services, test as base } from "./services.ts"; +import { type WorkerOptions, type Services, test as base } from "./services"; // 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"; -// This is deliberately quite a minimal config.json, so that we can test that the default settings actually work. -const CONFIG_JSON: Partial = { - // 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, - }, -}; +declare module "@element-hq/element-web-playwright-common" { + // Improve the type for the config fixture based on the real type + export interface Config extends Omit {} +} export interface CredentialsWithDisplayName extends Credentials { displayName: string; } -export interface TestFixtures { - axe: AxeBuilder; - checkA11y: () => Promise; - - /** - * The contents of the config.json to send when the client requests it. - */ - config: typeof CONFIG_JSON; - - /** - * 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: CredentialsWithDisplayName; - - /** - * The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`}, - * but adds an initScript which will populate localStorage with the user's details from - * {@link #credentials} and {@link #homeserver}. - * - * Similar to {@link #user}, but doesn't load the app. - */ - pageWithCredentials: Page; - - /** - * A (rather poorly-named) test fixture which registers a user per {@link #credentials}, stores - * the credentials into localStorage per {@link #homeserver}, and then loads the front page of the - * app. - */ - user: CredentialsWithDisplayName; - +export interface TestFixtures extends BaseTestFixtures { /** * The same as {@link https://playwright.dev/docs/api/class-fixtures#fixtures-page|`page`}, * but wraps the returned `Page` in a class of utilities for interacting with the Element-Web UI, @@ -105,13 +56,11 @@ export interface TestFixtures { uut?: Locator; // Unit Under Test, useful place to refer a prepared locator botCreateOpts: CreateBotOpts; bot: Bot; - labsFlags: string[]; webserver: Webserver; - disablePresence: boolean; } type CombinedTestFixtures = PlaywrightTestArgs & TestFixtures; -export type Fixtures = _Fixtures; +export type Fixtures = _Fixtures; export const test = base.extend({ context: async ({ context }, use, testInfo) => { // We skip tests instead of using grep-invert to still surface the counts in the html report @@ -121,102 +70,12 @@ export const test = base.extend({ ); await use(context); }, - disablePresence: false, - config: {}, // We merge this atop the default CONFIG_JSON in the page fixture to make extending it easier - page: async ({ homeserver, context, page, config, labsFlags, disablePresence }, use) => { - await context.route(`http://localhost:8080/config.json*`, async (route) => { - const json = { - ...CONFIG_JSON, - ...config, - default_server_config: { - "m.homeserver": { - base_url: homeserver.baseUrl, - }, - ...config.default_server_config, - }, - }; - json["features"] = { - ...json["features"], - // Enable the lab features - ...labsFlags.reduce((obj, flag) => { - obj[flag] = true; - return obj; - }, {}), - }; - if (disablePresence) { - json["enable_presence_by_hs_url"] = { - [homeserver.baseUrl]: false, - }; - } - await route.fulfill({ json }); - }); - await use(page); + + axe: async ({ axe }, use) => { + // Exclude floating UI for now + await use(axe.exclude("[data-floating-ui-portal]")); }, - displayName: undefined, - 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, - }); - }, - labsFlags: [], - - pageWithCredentials: async ({ page, homeserver, credentials }, use) => { - await page.addInitScript( - ({ baseUrl, credentials }) => { - // Seed the localStorage with the required credentials - window.localStorage.setItem("mx_hs_url", baseUrl); - window.localStorage.setItem("mx_user_id", credentials.userId); - window.localStorage.setItem("mx_access_token", credentials.accessToken); - window.localStorage.setItem("mx_device_id", credentials.deviceId); - window.localStorage.setItem("mx_is_guest", "false"); - window.localStorage.setItem("mx_has_pickle_key", "false"); - window.localStorage.setItem("mx_has_access_token", "true"); - - window.localStorage.setItem( - "mx_local_settings", - JSON.stringify({ - // Retain any other settings which may have already been set - ...JSON.parse(window.localStorage.getItem("mx_local_settings") || "{}"), - // Ensure the language is set to a consistent value - language: "en", - }), - ); - }, - { baseUrl: homeserver.baseUrl, credentials }, - ); - await use(page); - }, - - user: async ({ pageWithCredentials: page, credentials }, use) => { - await page.goto("/"); - await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 }); - await use(credentials); - }, - - axe: async ({ page }, use) => { - await use(new AxeBuilder({ page }).exclude("[data-floating-ui-portal]")); - }, - checkA11y: async ({ axe }, use, testInfo) => - use(async () => { - const results = await axe.analyze(); - - await testInfo.attach("accessibility-scan-results", { - body: JSON.stringify(results, null, 2), - contentType: "application/json", - }); - - expect(results.violations).toEqual([]); - }), - app: async ({ page }, use) => { const app = new ElementAppPage(page); await use(app); @@ -244,35 +103,23 @@ export const test = base.extend({ }, }); -// 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; +interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions { + includeDialogBackground?: boolean; + showTooltips?: boolean; + timeout?: number; } -export const expect = baseExpect.extend({ - async toMatchScreenshot( +type Expectations = { + toMatchScreenshot: ( this: ExpectMatcherState, receiver: Page | Locator, name: `${string}.png`, - options?: { - mask?: Array; - includeDialogBackground?: boolean; - showTooltips?: boolean; - timeout?: number; - css?: string; - }, - ) { - 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; + options?: ExtendedToMatchScreenshotOptions, + ) => Promise; +}; +export const expect = baseExpect.extend({ + async toMatchScreenshot(receiver, name, options) { let css = ` .mx_MessagePanel_myReadMarker { display: none !important; @@ -322,21 +169,9 @@ export const expect = baseExpect.extend({ css += options.css; } - // We add a custom style tag before taking screenshots - const style = (await page.addStyleTag({ - content: css, - })) as ElementHandle; - - const screenshotName = sanitizeFilePathBeforeExtension(name); - await baseExpect(receiver).toHaveScreenshot(screenshotName, options); - - await style.evaluate((tag) => tag.remove()); - - testInfo.annotations.push({ - // `_` prefix hides it from the HTML reporter - type: "_screenshot", - // include a path relative to `playwright/snapshots/` - description: testInfo.snapshotPath(screenshotName).split("/playwright/snapshots/", 2)[1], + await baseExpect(receiver).toMatchScreenshot(name, { + ...options, + css, }); return { pass: true, message: () => "", name: "toMatchScreenshot" }; diff --git a/playwright/logger.ts b/playwright/logger.ts deleted file mode 100644 index da70582c38..0000000000 --- a/playwright/logger.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { type BrowserContext, type Page, type TestInfo } from "@playwright/test"; -import { type Readable } from "stream"; -import stripAnsi from "strip-ansi"; - -export class Logger { - private pages: Page[] = []; - private logs: Record = {}; - - 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(); - }); - }; - } - - 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(); - 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`; - }); - } - - public async onTestFinished(testInfo: TestInfo) { - if (testInfo.status !== "passed") { - for (const id in this.logs) { - if (!this.logs[id]) continue; - await testInfo.attach(id, { - body: stripAnsi(this.logs[id]), - contentType: "text/plain", - }); - } - } - } -} diff --git a/playwright/plugins/homeserver/dendrite/index.ts b/playwright/plugins/homeserver/dendrite/index.ts index bad52a869d..c139650405 100644 --- a/playwright/plugins/homeserver/dendrite/index.ts +++ b/playwright/plugins/homeserver/dendrite/index.ts @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type Options } from "../../../services.ts"; +import { type WorkerOptions } from "../../../services.ts"; -export const isDendrite = ({ homeserverType }: Options): boolean => { +export const isDendrite = ({ homeserverType }: WorkerOptions): boolean => { return homeserverType === "dendrite" || homeserverType === "pinecone"; }; diff --git a/playwright/plugins/homeserver/index.ts b/playwright/plugins/homeserver/index.ts index 04b1ad77f3..0571cd9615 100644 --- a/playwright/plugins/homeserver/index.ts +++ b/playwright/plugins/homeserver/index.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type ClientServerApi } from "../utils/api.ts"; +import { type ClientServerApi } from "@element-hq/element-web-playwright-common/lib/utils/api.js"; export interface HomeserverInstance { readonly baseUrl: string; diff --git a/playwright/plugins/homeserver/synapse/consentHomeserver.ts b/playwright/plugins/homeserver/synapse/consentHomeserver.ts index 83d47512cf..9b3316bf57 100644 --- a/playwright/plugins/homeserver/synapse/consentHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/consentHomeserver.ts @@ -6,30 +6,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { type SynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; + import { type Fixtures } from "../../../element-web-test.ts"; export const consentHomeserver: Fixtures = { _homeserver: [ async ({ _homeserver: container, mailpit }, use) => { - container + (container as SynapseContainer) .withCopyDirectoriesToContainer([ { source: "playwright/plugins/homeserver/synapse/res", target: "/data/res" }, ]) + .withSmtpServer(mailpit) .withConfig({ - email: { - enable_notifs: false, - smtp_host: "mailpit", - smtp_port: 1025, - smtp_user: "username", - smtp_pass: "password", - require_transport_security: false, - notif_from: "Your Friendly %(app)s homeserver ", - app_name: "Matrix", - notif_template_html: "notif_mail.html", - notif_template_text: "notif_mail.txt", - notif_for_new_users: true, - client_base_url: "http://localhost/element", - }, user_consent: { template_dir: "/data/res/templates/privacy", version: "1.0", diff --git a/playwright/plugins/homeserver/synapse/masHomeserver.ts b/playwright/plugins/homeserver/synapse/masHomeserver.ts index 96fcc80bdd..342737d80d 100644 --- a/playwright/plugins/homeserver/synapse/masHomeserver.ts +++ b/playwright/plugins/homeserver/synapse/masHomeserver.ts @@ -6,7 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { MatrixAuthenticationServiceContainer } from "../../../testcontainers/mas.ts"; +import { MatrixAuthenticationServiceContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; + import { type Fixtures } from "../../../element-web-test.ts"; export const masHomeserver: Fixtures = { diff --git a/playwright/plugins/oauth_server/index.ts b/playwright/plugins/oauth_server/index.ts index 8a7e9cd8ba..446426a9c1 100644 --- a/playwright/plugins/oauth_server/index.ts +++ b/playwright/plugins/oauth_server/index.ts @@ -10,8 +10,7 @@ import http from "http"; import express from "express"; import { type AddressInfo } from "net"; import { type TestInfo } from "@playwright/test"; - -import { randB64Bytes } from "../utils/rand.ts"; +import { randB64Bytes } from "@element-hq/element-web-playwright-common/lib/utils/rand.js"; export class OAuthServer { private server?: http.Server; diff --git a/playwright/plugins/utils/api.ts b/playwright/plugins/utils/api.ts deleted file mode 100644 index 90c0e2739f..0000000000 --- a/playwright/plugins/utils/api.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* -Copyright 2025 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { type APIRequestContext } from "@playwright/test"; - -import { type Credentials } from "../homeserver"; - -export type Verb = "GET" | "POST" | "PUT" | "DELETE"; - -export class Api { - private _request?: APIRequestContext; - - constructor(private readonly baseUrl: string) {} - - public setRequest(request: APIRequestContext): void { - this._request = request; - } - - public async request(verb: "GET", path: string, token?: string, data?: never): Promise; - public async request(verb: Verb, path: string, token?: string, data?: object): Promise; - public async request(verb: Verb, path: string, token?: string, data?: object): Promise { - 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(); - } -} - -export class ClientServerApi extends Api { - constructor(baseUrl: string) { - super(`${baseUrl}/_matrix/client`); - } - - public async loginUser(userId: string, password: string): Promise { - const json = await this.request<{ - access_token: string; - user_id: string; - device_id: string; - home_server: string; - }>("POST", "/v3/login", undefined, { - type: "m.login.password", - identifier: { - type: "m.id.user", - user: userId, - }, - password: password, - }); - - return { - password, - accessToken: json.access_token, - userId: json.user_id, - deviceId: json.device_id, - homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"), - username: userId.slice(1).split(":")[0], - }; - } -} diff --git a/playwright/plugins/utils/object.ts b/playwright/plugins/utils/object.ts deleted file mode 100644 index bfb92fecec..0000000000 --- a/playwright/plugins/utils/object.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -/** - * Deep copy the given object. The object MUST NOT have circular references and - * MUST NOT have functions. - * @param obj - The object to deep copy. - * @returns A copy of the object without any references to the original. - */ -export function deepCopy(obj: T): T { - return JSON.parse(JSON.stringify(obj)); -} diff --git a/playwright/plugins/utils/port.ts b/playwright/plugins/utils/port.ts deleted file mode 100644 index b54e251f2f..0000000000 --- a/playwright/plugins/utils/port.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 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. -*/ - -import * as net from "net"; - -export async function getFreePort(): Promise { - return new Promise((resolve) => { - const srv = net.createServer(); - srv.listen(0, () => { - const port = (srv.address()).port; - srv.close(() => resolve(port)); - }); - }); -} diff --git a/playwright/plugins/utils/rand.ts b/playwright/plugins/utils/rand.ts deleted file mode 100644 index 94f723f0a6..0000000000 --- a/playwright/plugins/utils/rand.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 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. -*/ - -import crypto from "node:crypto"; - -export function randB64Bytes(numBytes: number): string { - return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, ""); -} diff --git a/playwright/plugins/webserver/index.ts b/playwright/plugins/webserver/index.ts index ba9b9e9706..fe236116bc 100644 --- a/playwright/plugins/webserver/index.ts +++ b/playwright/plugins/webserver/index.ts @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import * as http from "http"; -import { type AddressInfo } from "net"; +import * as http from "node:http"; +import { type AddressInfo } from "node:net"; export class Webserver { private server?: http.Server; diff --git a/playwright/services.ts b/playwright/services.ts index 162f4e9fbb..8ecf10e20e 100644 --- a/playwright/services.ts +++ b/playwright/services.ts @@ -5,113 +5,32 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { test as base } from "@playwright/test"; -import { type MailpitClient } from "mailpit-api"; -import { Network, type StartedNetwork } from "testcontainers"; -import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; +import { test as base } from "@element-hq/element-web-playwright-common"; +import { + type Services as BaseServices, + type WorkerOptions as BaseWorkerOptions, +} from "@element-hq/element-web-playwright-common/lib/fixtures"; +import { type HomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; -import { type SynapseConfig, SynapseContainer } from "./testcontainers/synapse.ts"; -import { Logger } from "./logger.ts"; -import { type StartedMatrixAuthenticationServiceContainer } from "./testcontainers/mas.ts"; -import { type HomeserverContainer, type StartedHomeserverContainer } from "./testcontainers/HomeserverContainer.ts"; -import { MailhogContainer, type StartedMailhogContainer } from "./testcontainers/mailpit.ts"; import { type OAuthServer } from "./plugins/oauth_server"; -import { DendriteContainer, PineconeContainer } from "./testcontainers/dendrite.ts"; +import { DendriteContainer, PineconeContainer } from "./testcontainers/dendrite"; import { type HomeserverType } from "./plugins/homeserver"; +import { SynapseContainer } from "./testcontainers/synapse"; -export interface TestFixtures { - mailpitClient: MailpitClient; -} - -export interface Services { - logger: Logger; - - network: StartedNetwork; - postgres: StartedPostgreSqlContainer; - mailpit: StartedMailhogContainer; - - synapseConfig: SynapseConfig; - _homeserver: HomeserverContainer; - homeserver: StartedHomeserverContainer; - // Set in masHomeserver only - mas?: StartedMatrixAuthenticationServiceContainer; +export interface Services extends BaseServices { // Set in legacyOAuthHomeserver only oAuthServer?: OAuthServer; } -export interface Options { +export interface WorkerOptions extends BaseWorkerOptions { homeserverType: HomeserverType; } -export const test = base.extend({ - logger: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use) => { - const logger = new Logger(); - await use(logger); - }, - { scope: "worker" }, - ], - network: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use) => { - const network = await new Network().start(); - await use(network); - await network.stop(); - }, - { scope: "worker" }, - ], - postgres: [ - async ({ logger, network }, use) => { - const container = await new PostgreSqlContainer() - .withNetwork(network) - .withNetworkAliases("postgres") - .withLogConsumer(logger.getConsumer("postgres")) - .withTmpFs({ - "/dev/shm/pgdata/data": "", - }) - .withEnvironment({ - PG_DATA: "/dev/shm/pgdata/data", - }) - .withCommand([ - "-c", - "shared_buffers=128MB", - "-c", - `fsync=off`, - "-c", - `synchronous_commit=off`, - "-c", - "full_page_writes=off", - ]) - .start(); - await use(container); - await container.stop(); - }, - { scope: "worker" }, - ], - - mailpit: [ - async ({ logger, network }, use) => { - const container = await new MailhogContainer() - .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" }], +export const test = base.extend<{}, Services & WorkerOptions>({ homeserverType: ["synapse", { option: true, scope: "worker" }], _homeserver: [ async ({ homeserverType }, use) => { - let container: HomeserverContainer; + let container: HomeserverContainer; switch (homeserverType) { case "synapse": container = new SynapseContainer(); @@ -128,46 +47,12 @@ export const test = base.extend({ }, { scope: "worker" }, ], - homeserver: [ - async ({ homeserverType, 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(homeserverType)) - .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 ( - { homeserverType, synapseConfig, logger, context, request, _homeserver, homeserver }, - use, - testInfo, - ) => { + context: async ({ homeserverType, synapseConfig, context, _homeserver }, use, testInfo) => { testInfo.skip( !(_homeserver instanceof SynapseContainer) && Object.keys(synapseConfig).length > 0, `Test specifies Synapse config options so is unsupported with ${homeserverType}`, ); - homeserver.setRequest(request); - await logger.onTestStarted(context); await use(context); - await logger.onTestFinished(testInfo); - await homeserver.onTestFinished(testInfo); }, }); diff --git a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png index d8bab27faa..a5db88aae6 100644 Binary files a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png and b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png differ diff --git a/playwright/testcontainers/HomeserverContainer.ts b/playwright/testcontainers/HomeserverContainer.ts deleted file mode 100644 index d5be8c5301..0000000000 --- a/playwright/testcontainers/HomeserverContainer.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { type AbstractStartedContainer, type GenericContainer } from "testcontainers"; -import { type APIRequestContext, type TestInfo } from "@playwright/test"; - -import { type HomeserverInstance } from "../plugins/homeserver"; -import { type StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; - -export interface HomeserverContainer extends GenericContainer { - withConfigField(key: string, value: any): this; - withConfig(config: Partial): this; - withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this; - start(): Promise; -} - -export interface StartedHomeserverContainer extends AbstractStartedContainer, HomeserverInstance { - setRequest(request: APIRequestContext): void; - onTestFinished(testInfo: TestInfo): Promise; -} diff --git a/playwright/testcontainers/dendrite.ts b/playwright/testcontainers/dendrite.ts index edf9dba91d..55938778cd 100644 --- a/playwright/testcontainers/dendrite.ts +++ b/playwright/testcontainers/dendrite.ts @@ -8,12 +8,13 @@ Please see LICENSE files in the repository root for full details. import { GenericContainer, Wait } from "testcontainers"; import * as YAML from "yaml"; import { set } from "lodash"; - -import { randB64Bytes } from "../plugins/utils/rand.ts"; -import { StartedSynapseContainer } from "./synapse.ts"; -import { deepCopy } from "../plugins/utils/object.ts"; -import { type HomeserverContainer } from "./HomeserverContainer.ts"; -import { type StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; +import { randB64Bytes } from "@element-hq/element-web-playwright-common/lib/utils/rand.js"; +import { deepCopy } from "@element-hq/element-web-playwright-common/lib/utils/object.js"; +import { + StartedSynapseContainer, + type HomeserverContainer, + type StartedMatrixAuthenticationServiceContainer, +} from "@element-hq/element-web-playwright-common/lib/testcontainers"; const DEFAULT_CONFIG = { version: 2, @@ -223,7 +224,7 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon .withWaitStrategy(Wait.forHttp("/_matrix/client/versions", 8008)); } - public withConfigField(key: string, value: any): this { + public withConfigField(key: string, value: unknown): this { set(this.config, key, value); return this; } @@ -236,6 +237,11 @@ export class DendriteContainer extends GenericContainer implements HomeserverCon return this; } + // Dendrite does not support SMTP at this time - https://github.com/element-hq/dendrite/issues/1298 + public withSmtpServer(): this { + return this; + } + // Dendrite does not support MAS at this time public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this { return this; diff --git a/playwright/testcontainers/mailpit.ts b/playwright/testcontainers/mailpit.ts deleted file mode 100644 index 6c47f9ab37..0000000000 --- a/playwright/testcontainers/mailpit.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { AbstractStartedContainer, GenericContainer, type StartedTestContainer, Wait } from "testcontainers"; -import { MailpitClient } from "mailpit-api"; - -export class MailhogContainer extends GenericContainer { - constructor() { - super("axllent/mailpit:latest"); - - this.withExposedPorts(8025).withWaitStrategy(Wait.forListeningPorts()).withEnvironment({ - MP_SMTP_AUTH_ALLOW_INSECURE: "true", - MP_SMTP_AUTH_ACCEPT_ANY: "true", - }); - } - - public override async start(): Promise { - return new StartedMailhogContainer(await super.start()); - } -} - -export class StartedMailhogContainer extends AbstractStartedContainer { - public readonly client: MailpitClient; - - constructor(container: StartedTestContainer) { - super(container); - this.client = new MailpitClient(`http://${container.getHost()}:${container.getMappedPort(8025)}`); - } -} diff --git a/playwright/testcontainers/mas.ts b/playwright/testcontainers/mas.ts deleted file mode 100644 index bdd071ebc2..0000000000 --- a/playwright/testcontainers/mas.ts +++ /dev/null @@ -1,346 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { - AbstractStartedContainer, - GenericContainer, - type StartedTestContainer, - Wait, - type ExecResult, -} from "testcontainers"; -import { type StartedPostgreSqlContainer } from "@testcontainers/postgresql"; -import * as YAML from "yaml"; - -import { getFreePort } from "../plugins/utils/port.ts"; -import { deepCopy } from "../plugins/utils/object.ts"; -import { type Credentials } from "../plugins/homeserver"; - -const DEFAULT_CONFIG = { - http: { - listeners: [ - { - name: "web", - resources: [ - { name: "discovery" }, - { name: "human" }, - { name: "oauth" }, - { name: "compat" }, - { - name: "graphql", - playground: true, - }, - { - name: "assets", - path: "/usr/local/share/mas-cli/assets/", - }, - ], - binds: [ - { - address: "[::]:8080", - }, - ], - proxy_protocol: false, - }, - { - name: "internal", - resources: [ - { - name: "health", - }, - ], - binds: [ - { - address: "[::]:8081", - }, - ], - proxy_protocol: false, - }, - ], - trusted_proxies: ["192.128.0.0/16", "172.16.0.0/12", "10.0.0.0/10", "127.0.0.1/8", "fd00::/8", "::1/128"], - public_base: "", // Needs to be set - issuer: "", // Needs to be set - }, - database: { - host: "postgres", - port: 5432, - database: "postgres", - username: "postgres", - password: "p4S5w0rD", - max_connections: 10, - min_connections: 0, - connect_timeout: 30, - idle_timeout: 600, - max_lifetime: 1800, - }, - telemetry: { - tracing: { - exporter: "none", - propagators: [], - }, - metrics: { - exporter: "none", - }, - sentry: { - dsn: null, - }, - }, - templates: { - path: "/usr/local/share/mas-cli/templates/", - assets_manifest: "/usr/local/share/mas-cli/manifest.json", - translations_path: "/usr/local/share/mas-cli/translations/", - }, - email: { - from: '"Authentication Service" ', - reply_to: '"Authentication Service" ', - transport: "smtp", - mode: "plain", - hostname: "mailpit", - port: 1025, - username: "username", - password: "password", - }, - secrets: { - encryption: "984b18e207c55ad5fbb2a49b217481a722917ee87b2308d4cf314c83fed8e3b5", - keys: [ - { - kid: "YEAhzrKipJ", - key: "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAuIV+AW5vx52I4CuumgSxp6yvKfIAnRdALeZZCoFkIGxUli1B\nS79NJ3ls46oLh1pSD9RrhaMp6HTNoi4K3hnP9Q9v77pD7KwdFKG3UdG1zksIB0s/\n+/Ey/DmX4LPluwBBS7r/LkQ1jk745lENA++oiDqZf2D/uP8jCHlvaSNyVKTqi1ki\nOXPd4T4xBUjzuas9ze5jQVSYtfOidgnv1EzUipbIxgvH1jNt4raRlmP8mOq7xEnW\nR+cF5x6n/g17PdSEfrwO4kz6aKGZuMP5lVlDEEnMHKabFSQDBl7+Mpok6jXutbtA\nuiBnsKEahF9eoj4na4fpbRNPdIVyoaN5eGvm5wIDAQABAoIBAApyFCYEmHNWaa83\nCdVSOrRhRDE9r+c0r79pcNT1ajOjrk4qFa4yEC4R46YntCtfY5Hd1pBkIjU0l4d8\nz8Su9WTMEOwjQUEepS7L0NLi6kXZXYT8L40VpGs+32grBvBFHW0qEtQNrHJ36gMv\nx2rXoFTF7HaXiSJx3wvVxAbRqOE9tBXLsmNHaWaAdWQG5o77V9+zvMri3cAeEg2w\nVkKokb0dza7es7xG3tqS26k69SrwGeeuKo7qCHPH2cfyWmY5Yhv8iOoA59JzzbiK\nUdxyzCHskrPSpRKVkVVwmY3RBt282TmSRG7td7e5ESSj50P2e5BI5uu1Hp/dvU4F\nvYjV7kECgYEA6WqYoUpVsgQiqhvJwJIc/8gRm0mUy8TenI36z4Iim01Nt7fibWH7\nXnsFqLGjXtYNVWvBcCrUl9doEnRbJeG2eRGbGKYAWVrOeFvwM4fYvw9GoOiJdDj4\ncgWDe7eHbHE+UTqR7Nnr/UBfipoNWDh6X68HRBuXowh0Q6tOfxsrRFECgYEAyl/V\n4b8bFp3pKZZCb+KPSYsQf793cRmrBexPcLWcDPYbMZQADEZ/VLjbrNrpTOWxUWJT\nhr8MrWswnHO+l5AFu5CNO+QgV2dHLk+2w8qpdpFRPJCfXfo2D3wZ0c4cv3VCwv1V\n5y7f6XWVjDWZYV4wj6c3shxZJjZ+9Hbhf3/twbcCgYA6fuRRR3fCbRbi2qPtBrEN\nyO3gpMgNaQEA6vP4HPzfPrhDWmn8T5nXS61XYW03zxz4U1De81zj0K/cMBzHmZFJ\nNghQXQmpWwBzWVcREvJWr1Vb7erEnaJlsMwKrSvbGWYspSj82oAxr3hCG+lMOpsw\nb4S6pM+TpAK/EqdRY1WsgQKBgQCGoMaaTRXqL9bC0bEU2XVVCWxKb8c3uEmrwQ7/\n/fD4NmjUzI5TnDps1CVfkqoNe+hAKddDFqmKXHqUOfOaxDbsFje+lf5l5tDVoDYH\nfjTKKdYPIm7CiAeauYY7qpA5Vfq52Opixy4yEwUPp0CII67OggFtPaqY3zwJyWQt\n+57hdQKBgGCXM/KKt7ceUDcNJxSGjvu0zD9D5Sv2ihYlEBT/JLaTCCJdvzREevaJ\n1d+mpUAt0Lq6A8NWOMq8HPaxAik3rMQ0WtM5iG+XgsUqvTSb7NcshArDLuWGnW3m\nMC4rM0UBYAS4QweduUSH1imrwH/1Gu5+PxbiecceRMMggWpzu0Bq\n-----END RSA PRIVATE KEY-----\n", - }, - { - kid: "8J1AxrlNZT", - key: "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIF1cjfIOEdy3BXJ72x6fKpEB8WP1ddZAUJAaqqr/6CpToAoGCCqGSM49\nAwEHoUQDQgAEfHdNuI1Yeh3/uOq2PlnW2vymloOVpwBYebbw4VVsna9xhnutIdQW\ndE8hkX8Yb0pIDasrDiwllVLzSvsWJAI0Kw==\n-----END EC PRIVATE KEY-----\n", - }, - { - kid: "3BW6un1EBi", - key: "-----BEGIN EC PRIVATE KEY-----\nMIGkAgEBBDA+3ZV17r8TsiMdw1cpbTSNbyEd5SMy3VS1Mk/kz6O2Ev/3QZut8GE2\nq3eGtLBoVQigBwYFK4EEACKhZANiAASs8Wxjk/uRimRKXnPr2/wDaXkN9wMDjYQK\nmZULb+0ZP1/cXmuXuri8hUGhQvIU8KWY9PkpV+LMPEdpE54mHPKSLjq5CDXoSZ/P\n9f7cdRaOZ000KQPZfIFR9ujJTtDN7Vs=\n-----END EC PRIVATE KEY-----\n", - }, - { - kid: "pkZ0pTKK0X", - key: "-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIHenfsXYPc5yzjZKUfvmydDR1YRwdsfZYvwHf/2wsYxooAcGBSuBBAAK\noUQDQgAEON1x7Vlu+nA0KvC5vYSOHhDUkfLYNZwYSLPFVT02h9E13yFFMIJegIBl\nAer+6PMZpPc8ycyeH9N+U9NAyliBhQ==\n-----END EC PRIVATE KEY-----\n", - }, - ], - }, - passwords: { - enabled: true, - schemes: [ - { - version: 1, - algorithm: "argon2id", - }, - ], - minimum_complexity: 0, - }, - policy: { - wasm_module: "/usr/local/share/mas-cli/policy.wasm", - client_registration_entrypoint: "client_registration/violation", - register_entrypoint: "register/violation", - authorization_grant_entrypoint: "authorization_grant/violation", - password_entrypoint: "password/violation", - email_entrypoint: "email/violation", - data: { - client_registration: { - // allow non-SSL and localhost URIs - allow_insecure_uris: true, - // EW doesn't have contacts at this time - allow_missing_contacts: true, - }, - }, - }, - upstream_oauth2: { - providers: [], - }, - branding: { - service_name: null, - policy_uri: null, - tos_uri: null, - imprint: null, - logo_uri: null, - }, - account: { - password_registration_enabled: true, - }, - experimental: { - access_token_ttl: 300, - compat_token_ttl: 300, - }, - rate_limiting: { - login: { - burst: 10, - per_second: 1, - }, - registration: { - burst: 10, - per_second: 1, - }, - }, -}; - -export class MatrixAuthenticationServiceContainer extends GenericContainer { - private config: typeof DEFAULT_CONFIG; - private readonly args = ["-c", "/config/config.yaml"]; - - constructor(db: StartedPostgreSqlContainer) { - // We rely on `mas-cli manage add-email` which isn't in a release yet - // https://github.com/element-hq/matrix-authentication-service/pull/3235 - super("ghcr.io/element-hq/matrix-authentication-service:sha-0b90c33"); - - this.config = deepCopy(DEFAULT_CONFIG); - this.config.database.username = db.getUsername(); - this.config.database.password = db.getPassword(); - - this.withExposedPorts(8080, 8081) - .withWaitStrategy(Wait.forHttp("/health", 8081)) - .withCommand(["server", ...this.args]); - } - - public withConfig(config: object): this { - this.config = { - ...this.config, - ...config, - }; - return this; - } - - public override async start(): Promise { - // MAS config issuer needs to know what URL it'll be accessed from, so we have to map the port manually - const port = await getFreePort(); - - this.config.http.public_base = `http://localhost:${port}/`; - this.config.http.issuer = `http://localhost:${port}/`; - - this.withExposedPorts({ - container: 8080, - host: port, - }).withCopyContentToContainer([ - { - target: "/config/config.yaml", - content: YAML.stringify(this.config), - }, - ]); - - return new StartedMatrixAuthenticationServiceContainer( - await super.start(), - `http://localhost:${port}`, - this.args, - ); - } -} - -export class StartedMatrixAuthenticationServiceContainer extends AbstractStartedContainer { - private adminTokenPromise?: Promise; - - constructor( - container: StartedTestContainer, - public readonly baseUrl: string, - private readonly args: string[], - ) { - super(container); - } - - public async getAdminToken(): Promise { - if (this.adminTokenPromise === undefined) { - this.adminTokenPromise = this.registerUserInternal( - "admin", - "totalyinsecureadminpassword", - undefined, - true, - ).then((res) => res.accessToken); - } - return this.adminTokenPromise; - } - - private async manage(cmd: string, ...args: string[]): Promise { - const result = await this.exec(["mas-cli", "manage", cmd, ...this.args, ...args]); - if (result.exitCode !== 0) { - throw new Error(`Failed mas-cli manage ${cmd}: ${result.output}`); - } - return result; - } - - private async manageRegisterUser( - username: string, - password: string, - displayName?: string, - admin = false, - ): Promise { - const args: string[] = []; - if (admin) args.push("-a"); - const result = await this.manage( - "register-user", - ...args, - "-y", - "-p", - password, - "-d", - displayName ?? "", - username, - ); - - const registerLines = result.output.trim().split("\n"); - const userId = registerLines - .find((line) => line.includes("Matrix ID: ")) - ?.split(": ") - .pop(); - - if (!userId) { - throw new Error(`Failed to register user: ${result.output}`); - } - - return userId; - } - - private async manageIssueCompatibilityToken( - username: string, - admin = false, - ): Promise<{ accessToken: string; deviceId: string }> { - const args: string[] = []; - if (admin) args.push("--yes-i-want-to-grant-synapse-admin-privileges"); - const result = await this.manage("issue-compatibility-token", ...args, username); - - const parts = result.output.trim().split(/\s+/); - const accessToken = parts.find((part) => part.startsWith("mct_")); - const deviceId = parts.find((part) => part.startsWith("compat_session.device="))?.split("=")[1]; - - if (!accessToken || !deviceId) { - throw new Error(`Failed to issue compatibility token: ${result.output}`); - } - - return { accessToken, deviceId }; - } - - private async registerUserInternal( - username: string, - password: string, - displayName?: string, - admin = false, - ): Promise { - const userId = await this.manageRegisterUser(username, password, displayName, admin); - const { deviceId, accessToken } = await this.manageIssueCompatibilityToken(username, admin); - - return { - userId, - accessToken, - deviceId, - homeServer: userId.slice(1).split(":").slice(1).join(":"), - displayName, - username, - password, - }; - } - - public async registerUser(username: string, password: string, displayName?: string): Promise { - return this.registerUserInternal(username, password, displayName, false); - } - - public async setThreepid(username: string, medium: string, address: string): Promise { - if (medium !== "email") { - throw new Error("Only email threepids are supported by MAS"); - } - - await this.manage("add-email", username, address); - } -} diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 47c2e708e2..2e841604be 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -1,395 +1,20 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024-2025 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import { - 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"; - -import { getFreePort } from "../plugins/utils/port.ts"; -import { randB64Bytes } from "../plugins/utils/rand.ts"; -import { type Credentials } from "../plugins/homeserver"; -import { deepCopy } from "../plugins/utils/object.ts"; -import { type HomeserverContainer, type StartedHomeserverContainer } from "./HomeserverContainer.ts"; -import { type StartedMatrixAuthenticationServiceContainer } from "./mas.ts"; -import { Api, ClientServerApi, type Verb } from "../plugins/utils/api.ts"; +import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; const TAG = "develop@sha256:2ea87d45fc7ff3327c671b3b4447e6b2032d4f5ca07d62d8aef0d900e105c2f4"; -const DEFAULT_CONFIG = { - server_name: "localhost", - public_baseurl: "", // set by start method - pid_file: "/homeserver.pid", - web_client: false, - soft_file_limit: 0, - // Needs to be configured to log to the console like a good docker process - log_config: "/data/log.config", - listeners: [ - { - // Listener is always port 8008 (configured in the container) - port: 8008, - tls: false, - bind_addresses: ["::"], - type: "http", - x_forwarded: true, - resources: [ - { - names: ["client"], - compress: false, - }, - ], - }, - ], - database: { - // An sqlite in-memory database is fast & automatically wipes each time - name: "sqlite3", - args: { - database: ":memory:", - }, - }, - rc_messages_per_second: 10000, - rc_message_burst_count: 10000, - rc_registration: { - per_second: 10000, - burst_count: 10000, - }, - rc_joins: { - local: { - per_second: 9999, - burst_count: 9999, - }, - remote: { - per_second: 9999, - burst_count: 9999, - }, - }, - rc_joins_per_room: { - per_second: 9999, - burst_count: 9999, - }, - rc_3pid_validation: { - per_second: 1000, - burst_count: 1000, - }, - rc_invites: { - per_room: { - per_second: 1000, - burst_count: 1000, - }, - per_user: { - per_second: 1000, - burst_count: 1000, - }, - }, - rc_login: { - address: { - per_second: 10000, - burst_count: 10000, - }, - account: { - per_second: 10000, - burst_count: 10000, - }, - failed_attempts: { - per_second: 10000, - burst_count: 10000, - }, - }, - media_store_path: "/tmp/media_store", - max_upload_size: "50M", - max_image_pixels: "32M", - dynamic_thumbnails: false, - enable_registration: true, - enable_registration_without_verification: true, - disable_msisdn_registration: false, - registrations_require_3pid: [], - enable_metrics: false, - report_stats: false, - // These placeholders will be replaced with values generated at start - registration_shared_secret: "secret", - macaroon_secret_key: "secret", - form_secret: "secret", - // Signing key must be here: it will be generated to this file - signing_key_path: "/data/localhost.signing.key", - trusted_key_servers: [], - password_config: { - enabled: true, - }, - ui_auth: {}, - background_updates: { - // Inhibit background updates as this Synapse isn't long-lived - min_batch_size: 100000, - sleep_duration_ms: 100000, - }, - enable_authenticated_media: true, - email: undefined, - user_consent: undefined, - server_notices: undefined, - allow_guest_access: false, - experimental_features: {}, - oidc_providers: [], - serve_server_wellknown: true, - presence: { - enabled: true, - include_offline_users_on_sync: true, - }, - room_list_publication_rules: [{ action: "allow" }], -}; - -export type SynapseConfig = Partial; - -export class SynapseContainer extends GenericContainer implements HomeserverContainer { - private config: typeof DEFAULT_CONFIG; - private mas?: StartedMatrixAuthenticationServiceContainer; - - constructor() { +/** + * SynapseContainer which freezes the docker digest to stabilise tests, + * updated periodically by the `playwright-image-updates.yaml` workflow. + */ +export class SynapseContainer extends BaseSynapseContainer { + public constructor() { super(`ghcr.io/element-hq/synapse:${TAG}`); - - this.config = deepCopy(DEFAULT_CONFIG); - this.config.registration_shared_secret = randB64Bytes(16); - this.config.macaroon_secret_key = randB64Bytes(16); - this.config.form_secret = randB64Bytes(16); - - const signingKey = randB64Bytes(32); - this.withWaitStrategy(Wait.forHttp("/health", 8008)).withCopyContentToContainer([ - { target: this.config.signing_key_path, content: `ed25519 x ${signingKey}` }, - { - target: this.config.log_config, - content: YAML.stringify({ - version: 1, - formatters: { - precise: { - format: "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s", - }, - }, - handlers: { - console: { - class: "logging.StreamHandler", - formatter: "precise", - }, - }, - loggers: { - "synapse.storage.SQL": { - level: "DEBUG", - }, - "twisted": { - handlers: ["console"], - propagate: false, - }, - }, - root: { - level: "DEBUG", - handlers: ["console"], - }, - disable_existing_loggers: false, - }), - }, - ]); - } - - public withConfigField(key: string, value: any): this { - set(this.config, key, value); - return this; - } - - public withConfig(config: Partial): this { - this.config = { - ...this.config, - ...config, - }; - return this; - } - - public withMatrixAuthenticationService(mas?: StartedMatrixAuthenticationServiceContainer): this { - this.mas = mas; - return this; - } - - public override async start(): Promise { - // Synapse config public_baseurl needs to know what URL it'll be accessed from, so we have to map the port manually - const port = await getFreePort(); - - this.withExposedPorts({ - container: 8008, - host: port, - }) - .withConfig({ - public_baseurl: `http://localhost:${port}`, - }) - .withCopyContentToContainer([ - { - target: "/data/homeserver.yaml", - content: YAML.stringify(this.config), - }, - ]); - - const container = await super.start(); - const baseUrl = `http://localhost:${port}`; - if (this.mas) { - return new StartedSynapseWithMasContainer( - container, - baseUrl, - this.config.registration_shared_secret, - this.mas, - ); - } - - return new StartedSynapseContainer(container, baseUrl, this.config.registration_shared_secret); - } -} - -export class StartedSynapseContainer extends AbstractStartedContainer implements StartedHomeserverContainer { - protected adminTokenPromise?: Promise; - protected readonly adminApi: Api; - public readonly csApi: ClientServerApi; - - 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); - } - - public restart(options?: Partial): Promise { - this.adminTokenPromise = undefined; - return super.restart(options); - } - - public setRequest(request: APIRequestContext): void { - this.csApi.setRequest(request); - this.adminApi.setRequest(request); - } - - public async onTestFinished(testInfo: TestInfo): Promise { - // Clean up the server to prevent rooms leaking between tests - await this.deletePublicRooms(); - } - - protected async deletePublicRooms(): Promise { - const token = await this.getAdminToken(); - // We hide the rooms from the room directory to save time between tests and for portability between homeservers - const { chunk: rooms } = await this.csApi.request<{ - chunk: { room_id: string }[]; - }>("GET", "/v3/publicRooms", token, {}); - await Promise.all( - rooms.map((room) => - this.csApi.request("PUT", `/v3/directory/list/room/${room.room_id}`, token, { visibility: "private" }), - ), - ); - } - - private async registerUserInternal( - username: string, - password: string, - displayName?: string, - admin = false, - ): Promise { - const path = "/v1/register"; - const { nonce } = await this.adminApi.request<{ nonce: string }>("GET", path, undefined, {}); - const mac = crypto - .createHmac("sha1", this.registrationSharedSecret) - .update(`${nonce}\0${username}\0${password}\0${admin ? "" : "not"}admin`) - .digest("hex"); - const data = await this.adminApi.request<{ - home_server: string; - access_token: string; - user_id: string; - device_id: string; - }>("POST", path, undefined, { - nonce, - username, - password, - mac, - admin, - displayname: displayName, - }); - - return { - homeServer: data.home_server || data.user_id.split(":").slice(1).join(":"), - accessToken: data.access_token, - userId: data.user_id, - deviceId: data.device_id, - password, - displayName, - username, - }; - } - - protected async getAdminToken(): Promise { - if (this.adminTokenPromise === undefined) { - this.adminTokenPromise = this.registerUserInternal( - "admin", - "totalyinsecureadminpassword", - undefined, - true, - ).then((res) => res.accessToken); - } - return this.adminTokenPromise; - } - - private async adminRequest(verb: "GET", path: string, data?: never): Promise; - private async adminRequest(verb: Verb, path: string, data?: object): Promise; - private async adminRequest(verb: Verb, path: string, data?: object): Promise { - const adminToken = await this.getAdminToken(); - return this.adminApi.request(verb, path, adminToken, data); - } - - public registerUser(username: string, password: string, displayName?: string): Promise { - return this.registerUserInternal(username, password, displayName, false); - } - - public async loginUser(userId: string, password: string): Promise { - return this.csApi.loginUser(userId, password); - } - - public async setThreepid(userId: string, medium: string, address: string): Promise { - await this.adminRequest("PUT", `/v2/users/${userId}`, { - threepids: [ - { - medium, - address, - }, - ], - }); - } -} - -export class StartedSynapseWithMasContainer extends StartedSynapseContainer { - constructor( - container: StartedTestContainer, - baseUrl: string, - registrationSharedSecret: string, - private readonly mas: StartedMatrixAuthenticationServiceContainer, - ) { - super(container, baseUrl, registrationSharedSecret); - } - - protected async getAdminToken(): Promise { - if (this.adminTokenPromise === undefined) { - this.adminTokenPromise = this.mas.getAdminToken(); - } - return this.adminTokenPromise; - } - - public registerUser(username: string, password: string, displayName?: string): Promise { - return this.mas.registerUser(username, password, displayName); - } - - public async setThreepid(userId: string, medium: string, address: string): Promise { - return this.mas.setThreepid(userId, medium, address); } } diff --git a/yarn.lock b/yarn.lock index bab0fd1c35..600521f2b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27,7 +27,7 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@axe-core/playwright@^4.8.1": +"@axe-core/playwright@^4.10.1": version "4.10.1" resolved "https://registry.yarnpkg.com/@axe-core/playwright/-/playwright-4.10.1.tgz#c811ba8bfa244833cce422c4131e0043828c42cc" integrity sha512-EV5t39VV68kuAfMKqb/RL+YjYKhfuGim9rgIaQ6Vntb2HgaCaau0h98Y3WEUqW1+PbdzxDtDNjFAipbtZuBmEA== @@ -1551,6 +1551,19 @@ resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-0.1.1.tgz#e2b24aa38aa9f7b6af3c4993e6402a8b7e2f3cb5" integrity sha512-qtEQD5nFaRJ+vfAis7uhKB66SyCjrz7O+qGz/hKJjgNhBLT/6C5DK90waKINXSw0J3stFR43IWzEk5GBOrTMow== +"@element-hq/element-web-playwright-common@^1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@element-hq/element-web-playwright-common/-/element-web-playwright-common-1.1.5.tgz#e9d4e24f0f284d16b7dc8cb9cd893521e1645be0" + integrity sha512-Cw2PqaU9YUx/qHq8nybjfxMhSGshkvZiRBPMhYnamjMTjUiSEEF74061gjIecxLF/3hgV6I0dGp5Km3FhRbTPQ== + dependencies: + "@axe-core/playwright" "^4.10.1" + "@testcontainers/postgresql" "^10.18.0" + lodash-es "^4.17.21" + mailpit-api "^1.2.0" + strip-ansi "^7.1.0" + testcontainers "^10.18.0" + yaml "^2.7.0" + "@eslint-community/eslint-utils@^4.2.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -2167,7 +2180,14 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@playwright/test@^1.40.1": +"@playwright/test@1.50.1": + version "1.50.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.50.1.tgz#027d00ca77ec79688764eb765cfe9a688807bf0b" + integrity sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ== + dependencies: + playwright "1.50.1" + +"@playwright/test@^1.50.1": version "1.51.0" resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.51.0.tgz#8d5c8400b465a0bfdbcf993e390ceecb903ea6d2" integrity sha512-dJ0dMbZeHhI+wb77+ljx/FeC8VBP6j/rj9OAojO08JI80wTZy6vRk9KvHKiDCUh4iMpEiseMgqRBIeW+eKX6RA== @@ -2743,7 +2763,7 @@ "@svgr/plugin-jsx" "8.1.0" "@svgr/plugin-svgo" "8.1.0" -"@testcontainers/postgresql@^10.16.0": +"@testcontainers/postgresql@^10.18.0": version "10.19.0" resolved "https://registry.yarnpkg.com/@testcontainers/postgresql/-/postgresql-10.19.0.tgz#e1ff9fbfee76c23bc899865524ee8e2ee297bdf2" integrity sha512-3+yQJHCWEtp4hylfZgRxCWN1P6dGqKhFM7Bypg22NpJqq1x/dcmamVCvD+4eTdm1uHV1Ta0BkHRWejxGOyTnrw== @@ -3592,15 +3612,16 @@ classnames "^2.5.1" vaul "^1.0.0" -"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.2-3fa19a2a17fd12d955ef1e14fd63aecbcf3b95e8-integrity/node_modules/bindings/wysiwyg-wasm": +"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.2-3fa19a2a17fd12d955ef1e14fd63aecbcf3b95e8-integrity/node_modules/bindings/wysiwyg-wasm": version "0.0.0" + uid "" "@vector-im/matrix-wysiwyg@2.38.2": version "2.38.2" resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.38.2.tgz#3fa19a2a17fd12d955ef1e14fd63aecbcf3b95e8" integrity sha512-TUnLPgZ8/zGUccQZxjIP3MVHjqybgV4u0r6kXibs35wlXgomXjwcN5gchl3FpgGkiHbi8g3D2ao0oHaqi2GaIw== dependencies: - "@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.2-3fa19a2a17fd12d955ef1e14fd63aecbcf3b95e8-integrity/node_modules/bindings/wysiwyg-wasm" + "@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.2-3fa19a2a17fd12d955ef1e14fd63aecbcf3b95e8-integrity/node_modules/bindings/wysiwyg-wasm" "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": version "1.14.1" @@ -3805,7 +3826,7 @@ acorn-walk@^8.0.0, acorn-walk@^8.0.2, acorn-walk@^8.1.1: dependencies: acorn "^8.11.0" -acorn@^8.0.4, acorn@^8.1.0, acorn@^8.11.0, acorn@^8.4.1, acorn@^8.9.0: +acorn@^8.0.4, acorn@^8.1.0, acorn@^8.11.0, acorn@^8.9.0: version "8.13.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.13.0.tgz#2a30d670818ad16ddd6a35d3842dacec9e5d7ca3" integrity sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w== @@ -3815,6 +3836,11 @@ acorn@^8.14.0, acorn@^8.8.1, acorn@^8.8.2: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== +acorn@^8.4.1: + version "8.14.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" + integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -4185,15 +4211,20 @@ await-lock@^2.1.0: resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef" integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw== -axe-core@^4.10.0, axe-core@~4.10.2: +axe-core@^4.10.0: version "4.10.2" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df" integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w== +axe-core@~4.10.2: + version "4.10.3" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.3.tgz#04145965ac7894faddbac30861e5d8f11bfd14fc" + integrity sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg== + axios@^1.8.1: - version "1.8.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979" - integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg== + version "1.8.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.1.tgz#7c118d2146e9ebac512b7d1128771cdd738d11e3" + integrity sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g== dependencies: follow-redirects "^1.15.6" form-data "^4.0.0" @@ -8568,6 +8599,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -8672,7 +8708,7 @@ magic-string@0.30.8: dependencies: "@jridgewell/sourcemap-codec" "^1.4.15" -mailpit-api@^1.0.5: +mailpit-api@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mailpit-api/-/mailpit-api-1.2.0.tgz#6cbd7c5c091fd74b000385790a1fe0c9f2a83fba" integrity sha512-oni/IwQhtbwk3ERwJ6IarKIFgz2U5684SK6Bbkau2GBo2FLoiT14UGkL3CXleYPBH5SCsnymHap1eevEOLwqaA== @@ -8762,8 +8798,8 @@ matrix-events-sdk@0.0.1: integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "37.1.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f552370c2625e20a921a5dbf5284491bb6c22861" + version "37.0.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d81929de4c9526e7d68ab7226804726cdef6387f" dependencies: "@babel/runtime" "^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm" "^14.0.1" @@ -9559,11 +9595,25 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -playwright-core@1.51.0, playwright-core@^1.45.1: +playwright-core@1.50.1: + version "1.50.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.50.1.tgz#6a0484f1f1c939168f40f0ab3828c4a1592c4504" + integrity sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ== + +playwright-core@1.51.0, playwright-core@^1.51.0: version "1.51.0" resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.51.0.tgz#bb23ea6bb6298242d088ae5e966ffcf8dc9827e8" integrity sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg== +playwright@1.50.1: + version "1.50.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.50.1.tgz#2f93216511d65404f676395bfb97b41aa052b180" + integrity sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw== + dependencies: + playwright-core "1.50.1" + optionalDependencies: + fsevents "2.3.2" + playwright@1.51.0: version "1.51.0" resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.51.0.tgz#9ba154497ba62bc6dc199c58ee19295eb35a4707" @@ -11951,10 +12001,10 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -testcontainers@^10.16.0, testcontainers@^10.19.0: - version "10.19.0" - resolved "https://registry.yarnpkg.com/testcontainers/-/testcontainers-10.19.0.tgz#007138559c6de68c80334232a259f4e94fa19955" - integrity sha512-/mbcCOaj6jj2IPMMmt+YrBi71MZ4BqEzqicjAInsfEox4pVVMnYIW4CkWOdCLiuZ9nVUkoBtxFSJDTqggJNB5A== +testcontainers@10.20.0, testcontainers@^10.18.0, testcontainers@^10.19.0, testcontainers@^10.20.0: + version "10.20.0" + resolved "https://registry.yarnpkg.com/testcontainers/-/testcontainers-10.20.0.tgz#45c524ae4be9b1ffe2fb42b701f6c0a04ee2d90a" + integrity sha512-pOPm/OUIT41aMijAZ9RsYg5xOq9ciy93+pCf2D9qDI0oV8rwk91XpPoUlizll4qwxmmHsLmfZFHJTpeB+BIfmw== dependencies: "@balena/dockerignore" "^1.0.2" "@types/dockerode" "^3.3.29"