From e6228d5bd1d5bdbd38e4b1b246232cc4aed52d4f Mon Sep 17 00:00:00 2001 From: Manuel Stahl Date: Sun, 8 Mar 2026 12:35:07 +0100 Subject: [PATCH] Add Synapse end-to-end test workflow --- .github/workflows/e2e-test.yml | 57 ++++++++++++++++++++++++++ .gitignore | 3 ++ docker-compose.yml | 31 ++++++++++++-- e2e/synapse-login.spec.ts | 22 ++++++++++ package.json | 5 ++- playwright.config.ts | 14 +++++++ scripts/e2e/configure-synapse.mjs | 31 ++++++++++++++ scripts/e2e/prepare-synapse.sh | 19 +++++++++ scripts/e2e/register-admin.mjs | 68 +++++++++++++++++++++++++++++++ scripts/e2e/run.sh | 37 +++++++++++++++++ scripts/e2e/wait-for-url.mjs | 25 ++++++++++++ vite.config.ts | 1 + yarn.lock | 55 +++++++++++++++++++++++++ 13 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/e2e-test.yml create mode 100644 e2e/synapse-login.spec.ts create mode 100644 playwright.config.ts create mode 100644 scripts/e2e/configure-synapse.mjs create mode 100755 scripts/e2e/prepare-synapse.sh create mode 100644 scripts/e2e/register-admin.mjs create mode 100755 scripts/e2e/run.sh create mode 100644 scripts/e2e/wait-for-url.mjs diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 0000000..0598b61 --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,57 @@ +name: e2e-test + +on: + workflow_dispatch: + push: + branches: ["master"] + pull_request: + +permissions: + contents: read + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install dependencies + run: yarn --immutable + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install Playwright browser + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: yarn playwright install --with-deps chromium + + - name: Run Synapse end-to-end test + run: yarn test:e2e:ci + + - name: Collect docker logs on failure + if: failure() + run: docker compose -f docker-compose.yml --project-name synapse-admin-e2e logs --no-color > e2e-compose.log + + - name: Upload failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-synapse-artifacts + path: | + e2e-compose.log + playwright-report + test-results diff --git a/.gitignore b/.gitignore index fe709bf..43ba75b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ lib-cov # Coverage directory used by tools like istanbul coverage *.lcov +playwright-report/ +test-results/ +e2e-compose.log # nyc test coverage .nyc_output diff --git a/docker-compose.yml b/docker-compose.yml index 0fdbf31..8b3c5a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,9 +2,11 @@ services: synapse-admin: container_name: synapse-admin hostname: synapse-admin - image: awesometechnologies/synapse-admin:latest - # build: - # context: . + # Use a prebuilt image: + #image: awesometechnologies/synapse-admin:latest + # or build from source: + build: + context: . # to use the docker-compose as standalone without a local repo clone, # replace the context definition with this: @@ -16,6 +18,9 @@ services: # to define a maximum ram for node. otherwise the build will fail. # - NODE_OPTIONS="--max_old_space_size=1024" # - BASE_PATH="/synapse-admin" + depends_on: + synapse: + condition: service_healthy ports: - "8080:80" restart: unless-stopped @@ -23,3 +28,23 @@ services: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/config.json >/dev/null 2>&1 || exit 1"] interval: 5s timeout: 5s + + synapse: + image: matrixdotorg/synapse:v1.141.0 + environment: + SYNAPSE_CONFIG_PATH: /data/homeserver.yaml + SYNAPSE_REPORT_STATS: "no" + SYNAPSE_SERVER_NAME: localhost + ports: + - "8008:8008" + volumes: + - ${SYNAPSE_DATA_DIR:-/tmp/synapse}:/data + restart: unless-stopped + healthcheck: + test: + [ + "CMD-SHELL", + "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8008/_matrix/client/versions', timeout=5)\"", + ] + interval: 5s + timeout: 5s diff --git a/e2e/synapse-login.spec.ts b/e2e/synapse-login.spec.ts new file mode 100644 index 0000000..576a8d7 --- /dev/null +++ b/e2e/synapse-login.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from "@playwright/test"; + +test("logs into Synapse and loads the users list", async ({ page }) => { + // Navigate to start page + await page.goto("/"); + // Wait for redirect to login page + await page.waitForURL("**/login"); + + // Fill login data + await page.getByLabel("Username").fill("admin"); + await page.locator('input[name="password"]').fill("supersecret"); + await page.getByLabel("Homeserver URL").fill("http://localhost:8008"); + // Expect server version + await expect (page.getByText("1.141.0")).toBeVisible(); + + // Sign in + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + // Expect users table + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "@admin:localhost" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "admin", exact: true })).toBeVisible(); +}); diff --git a/package.json b/package.json index d6f232d..e725176 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "devDependencies": { "@mui/system": "^7.1.0", "@mui/utils": "^7.1.0", + "@playwright/test": "^1.58.2", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -89,7 +90,9 @@ "lint": "eslint --ignore-path .gitignore --ext .ts,.tsx,.yml,.yaml .", "fix": "yarn lint --fix", "test": "vitest run", - "test:watch": "vitest watch" + "test:watch": "vitest watch", + "test:e2e": "playwright test", + "test:e2e:ci": "./scripts/e2e/run.sh" }, "eslintConfig": { "env": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..04b35b6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + reporter: [["html", { open: "never" }]], + retries: process.env.CI ? 1 : 0, + use: { + baseURL: process.env.E2E_BASE_URL ?? "http://127.0.0.1:8080", + headless: true, + locale: "en-US", + trace: "retain-on-failure", + }, +}); diff --git a/scripts/e2e/configure-synapse.mjs b/scripts/e2e/configure-synapse.mjs new file mode 100644 index 0000000..9430a48 --- /dev/null +++ b/scripts/e2e/configure-synapse.mjs @@ -0,0 +1,31 @@ +import fs from "node:fs"; + +const filePath = process.argv[2]; + +if (!filePath) { + throw new Error("Usage: node configure-synapse.mjs "); +} + +const upsertScalar = (content, key, value) => { + const pattern = new RegExp(`^${key}:.*$`, "m"); + const line = `${key}: ${value}`; + return pattern.test(content) ? content.replace(pattern, line) : `${content.trimEnd()}\n${line}\n`; +}; + +const removeScalar = (content, key) => content.replace(new RegExp(`^${key}:.*\n`, "m"), ""); + +const replaceTrustedKeyServers = content => { + const pattern = /^trusted_key_servers:\n(?:[ \t].*\n)*/m; + const block = "trusted_key_servers: []\n"; + return pattern.test(content) ? content.replace(pattern, block) : `${content.trimEnd()}\n${block}`; +}; + +let content = fs.readFileSync(filePath, "utf8"); + +content = removeScalar(content, "registration_shared_secret"); +content = upsertScalar(content, "enable_registration", "false"); +content = upsertScalar(content, "registration_shared_secret_path", '"/data/registration_shared_secret"'); +content = upsertScalar(content, "suppress_key_server_warning", "true"); +content = replaceTrustedKeyServers(content); + +fs.writeFileSync(filePath, content); diff --git a/scripts/e2e/prepare-synapse.sh b/scripts/e2e/prepare-synapse.sh new file mode 100755 index 0000000..f1ac42b --- /dev/null +++ b/scripts/e2e/prepare-synapse.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +export SYNAPSE_DATA_DIR="${SYNAPSE_DATA_DIR:-/tmp/synapse}" + +mkdir -p "${SYNAPSE_DATA_DIR}" + +if [[ ! -f "${SYNAPSE_DATA_DIR}/homeserver.yaml" ]]; then + docker compose -f "${ROOT_DIR}/docker-compose.yml" --project-name synapse-admin-e2e run --rm synapse generate +fi + +docker run --rm \ + -v "${ROOT_DIR}:/workspace" \ + -v "${SYNAPSE_DATA_DIR}:/data" \ + -w /workspace \ + node:lts \ + node ./scripts/e2e/configure-synapse.mjs /data/homeserver.yaml diff --git a/scripts/e2e/register-admin.mjs b/scripts/e2e/register-admin.mjs new file mode 100644 index 0000000..c79559f --- /dev/null +++ b/scripts/e2e/register-admin.mjs @@ -0,0 +1,68 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const baseUrl = process.env.SYNAPSE_BASE_URL ?? "http://127.0.0.1:8008"; +const dataDir = process.env.SYNAPSE_DATA_DIR ?? "/tmp/synapse"; +const username = process.env.SYNAPSE_E2E_ADMIN_USER ?? "admin"; +const password = process.env.SYNAPSE_E2E_ADMIN_PASSWORD ?? "supersecret"; +const sharedSecretPath = process.env.SYNAPSE_E2E_SHARED_SECRET_PATH ?? path.join(dataDir, "registration_shared_secret"); + +const loginPayload = { + type: "m.login.password", + user: username, + password, + identifier: { + type: "m.id.user", + user: username, + }, +}; + +const loginResponse = await fetch(`${baseUrl}/_matrix/client/r0/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(loginPayload), +}); + +if (loginResponse.ok) { + process.exit(0); +} + +const sharedSecret = fs.readFileSync(sharedSecretPath, "utf8").trim(); +const nonceResponse = await fetch(`${baseUrl}/_synapse/admin/v1/register`); + +if (!nonceResponse.ok) { + throw new Error(`Failed to fetch Synapse registration nonce: HTTP ${nonceResponse.status}`); +} + +const { nonce } = await nonceResponse.json(); +const mac = crypto + .createHmac("sha1", sharedSecret) + .update(nonce) + .update("\u0000") + .update(username) + .update("\u0000") + .update(password) + .update("\u0000") + .update("admin") + .digest("hex"); + +const registerResponse = await fetch(`${baseUrl}/_synapse/admin/v1/register`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + nonce, + username, + password, + admin: true, + mac, + }), +}); + +if (!registerResponse.ok) { + throw new Error(`Failed to register Synapse admin user: HTTP ${registerResponse.status}`); +} diff --git a/scripts/e2e/run.sh b/scripts/e2e/run.sh new file mode 100755 index 0000000..15eac97 --- /dev/null +++ b/scripts/e2e/run.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +export SYNAPSE_DATA_DIR="${SYNAPSE_DATA_DIR:-/tmp/synapse}" +export SYNAPSE_ADMIN_BASE_URL="${SYNAPSE_BASE_URL:-http://127.0.0.1:8080}" +export SYNAPSE_BASE_URL="${SYNAPSE_BASE_URL:-http://127.0.0.1:8008}" +export E2E_BASE_URL="${E2E_BASE_URL:-${SYNAPSE_ADMIN_BASE_URL}}" + +cleanup() { + if [[ "${KEEP_E2E_STACK:-0}" == "1" ]]; then return; fi + if [[ "${E2E_RUN_STATUS:-0}" != "0" ]]; then return; fi + docker compose -f "${ROOT_DIR}/docker-compose.yml" --project-name synapse-admin-e2e down -v --remove-orphans +} + +trap cleanup EXIT + +E2E_RUN_STATUS=1 + +rm -rf "${SYNAPSE_DATA_DIR}" + +"${ROOT_DIR}/scripts/e2e/prepare-synapse.sh" + +docker compose -f "${ROOT_DIR}/docker-compose.yml" --project-name synapse-admin-e2e up -d --build + +echo "Wait for ${SYNAPSE_ADMIN_BASE_URL}/config.json" +node "${ROOT_DIR}/scripts/e2e/wait-for-url.mjs" "${SYNAPSE_ADMIN_BASE_URL}/config.json" 120000 +echo "Wait for ${SYNAPSE_BASE_URL}/_matrix/client/versions" +node "${ROOT_DIR}/scripts/e2e/wait-for-url.mjs" "${SYNAPSE_BASE_URL}/_matrix/client/versions" 120000 +echo "Register admin account" +node "${ROOT_DIR}/scripts/e2e/register-admin.mjs" + +echo "Start playwright tests" +yarn playwright test + +E2E_RUN_STATUS=0 diff --git a/scripts/e2e/wait-for-url.mjs b/scripts/e2e/wait-for-url.mjs new file mode 100644 index 0000000..be17881 --- /dev/null +++ b/scripts/e2e/wait-for-url.mjs @@ -0,0 +1,25 @@ +const [url, timeoutArg] = process.argv.slice(2); + +if (!url) { + throw new Error("Usage: node wait-for-url.mjs [timeoutMs]"); +} + +const timeoutMs = Number(timeoutArg ?? "60000"); +const startedAt = Date.now(); + +for (;;) { + try { + const response = await fetch(url); + if (response.ok) { + process.exit(0); + } + } catch { + // Keep polling until the timeout is reached. + } + + if (Date.now() - startedAt > timeoutMs) { + throw new Error(`Timed out waiting for ${url}`); + } + + await new Promise(resolve => setTimeout(resolve, 1000)); +} diff --git a/vite.config.ts b/vite.config.ts index eb8ce7b..a9c7adf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => ({ globals: true, environment: 'happy-dom', setupFiles: "./src/vitest.setup.ts", + exclude: ["e2e/**"], }, ssr: { noExternal: ['react-dropzone', 'react-admin', 'ra-ui-materialui'], diff --git a/yarn.lock b/yarn.lock index f229cbc..fb4f548 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1148,6 +1148,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.58.2": + version: 1.58.2 + resolution: "@playwright/test@npm:1.58.2" + dependencies: + playwright: "npm:1.58.2" + bin: + playwright: cli.js + checksum: 10c0/2164c03ad97c3653ff02e8818a71f3b2bbc344ac07924c9d8e31cd57505d6d37596015a41f51396b3ed8de6840f59143eaa9c21bf65515963da20740119811da + languageName: node + linkType: hard + "@popperjs/core@npm:^2.11.8": version: 2.11.8 resolution: "@popperjs/core@npm:2.11.8" @@ -3527,6 +3538,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -3537,6 +3558,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -5138,6 +5168,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.58.2": + version: 1.58.2 + resolution: "playwright-core@npm:1.58.2" + bin: + playwright-core: cli.js + checksum: 10c0/5aa15b2b764e6ffe738293a09081a6f7023847a0dbf4cd05fe10eed2e25450d321baf7482f938f2d2eb330291e197fa23e57b29a5b552b89927ceb791266225b + languageName: node + linkType: hard + +"playwright@npm:1.58.2": + version: 1.58.2 + resolution: "playwright@npm:1.58.2" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.58.2" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/d060d9b7cc124bd8b5dffebaab5e84f6b34654a553758fe7b19cc598dfbee93f6ecfbdc1832b40a6380ae04eade86ef3285ba03aa0b136799e83402246dc0727 + languageName: node + linkType: hard + "possible-typed-array-names@npm:^1.0.0": version: 1.0.0 resolution: "possible-typed-array-names@npm:1.0.0" @@ -6270,6 +6324,7 @@ __metadata: "@mui/material": "npm:^7.1.0" "@mui/system": "npm:^7.1.0" "@mui/utils": "npm:^7.1.0" + "@playwright/test": "npm:^1.58.2" "@tanstack/react-query": "npm:5.90.20" "@testing-library/dom": "npm:^10.4.1" "@testing-library/react": "npm:^16.3.2"