From ef5d06eea3533dc984fbe08d134a239158fe1b76 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 22:38:39 -0400 Subject: [PATCH] ci(e2e): tier-3 regression suite against a Synapse the job starts itself (#220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e job now runs scripts/dev-homeserver.sh start + dev-seed.py before Playwright (installing python3-venv if the runner lacks it) and stops it afterwards. e2e/local-homeserver.spec.ts registers its own users and rooms over the CS API and drives the built client — no prod secrets — covering the fixes that were reproduced with scratch scripts this week: login/send/receive, own-message scroll (#212), /kick toast (#216), upload 413 wording (#213), forward provenance, thread panel at 1400px (#218), timeline lightbox (#219), clock-skew banner via page.clock (#158), status save under the presence limit (#226), long-press action sheet on a Pixel 7 emulation (#166). Skips itself when no homeserver answers, so > lotus-chat@4.12.7-lotus test:e2e > playwright test Running 16 tests using 1 worker ✓ 1 [chromium] › e2e/boot.spec.ts:8:3 › boot › client boots to the login screen without errors (1.5s) ✓ 2 [chromium] › e2e/boot.spec.ts:27:3 › boot › service worker script is served and registers (1.4s) ✓ 3 [chromium] › e2e/boot.spec.ts:54:3 › boot › bundled Element Call loads in a frame (3.8s) - 4 [chromium] › e2e/e2ee-composer.spec.ts:67:3 › E2EE composer › logs in with a password and reaches the client - 5 [chromium] › e2e/e2ee-composer.spec.ts:84:3 › E2EE composer › creates a private encrypted room and sends a text message - 6 [chromium] › e2e/e2ee-composer.spec.ts:146:3 › E2EE composer › attaches a compressed image and it is sent encrypted ✓ 7 [chromium] › e2e/local-homeserver.spec.ts:31:3 › local homeserver regression › logs in, opens a room, sends and receives (4.5s) ✓ 8 [chromium] › e2e/local-homeserver.spec.ts:58:3 › local homeserver regression › your own message scrolls into view even after scrolling up (#212) (11.4s) ✓ 9 [chromium] › e2e/local-homeserver.spec.ts:76:3 › local homeserver regression › /kick failure is reported, not swallowed (#216) (5.4s) ✓ 10 [chromium] › e2e/local-homeserver.spec.ts:90:3 › local homeserver regression › upload failure shows a plain sentence, never the raw MatrixError (#213) (3.7s) ✓ 11 [chromium] › e2e/local-homeserver.spec.ts:122:3 › local homeserver regression › forwarded message carries its provenance header (6.7s) ✓ 12 [chromium] › e2e/local-homeserver.spec.ts:143:3 › local homeserver regression › thread panel: opens from the chip and yields the member drawer at 1400px (#218) (4.7s) ✓ 13 [chromium] › e2e/local-homeserver.spec.ts:172:3 › local homeserver regression › timeline image opens the gallery lightbox (#219) (3.9s) ✓ 14 [chromium] › e2e/local-homeserver.spec.ts:204:3 › local homeserver regression › warns when the local clock is far off the server (#158) (6.5s) ✓ 15 [chromium] › e2e/local-homeserver.spec.ts:221:3 › local homeserver regression › status save survives the presence rate limit (#226) (12.6s) ✓ 16 [chromium] › e2e/local-homeserver.spec.ts:265:3 › local homeserver regression › touch: long-press opens the message action sheet (#166) (5.0s) 3 skipped 13 passed (1.2m) still works cold. 13 pass locally against dist + the dev homeserver in 1.8 min. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .gitea/workflows/ci.yml | 21 +++ LOTUS_TESTING.md | 11 +- e2e/helpers.ts | 4 + e2e/local-homeserver.spec.ts | 285 +++++++++++++++++++++++++++++++++++ e2e/localHs.ts | 114 ++++++++++++++ 5 files changed, 430 insertions(+), 5 deletions(-) create mode 100644 e2e/local-homeserver.spec.ts create mode 100644 e2e/localHs.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7ca7e3516..13bdf9483 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -238,6 +238,8 @@ jobs: # as m.room.encrypted. Skips itself unless the E2E_* secrets # below are set (create them under repo → Settings → Actions # → Secrets; they are empty until then). + # local tier — (#220) regression suite against a Synapse this job starts + # itself; skips itself if that homeserver isn't reachable. # dist/ is rebuilt in-job because actions/upload-artifact@v4 does not work # on this Gitea runner (see LOTUS_REFERENCE.md → CI/CD), so `needs: build` only gates on the # main job having passed, not on its artifact. @@ -282,6 +284,21 @@ jobs: NODE_OPTIONS: '--max_old_space_size=6144' VITE_APP_VERSION: ${{ github.sha }} + # ── #220 — tier 3: a throwaway Synapse the job starts itself (SQLite, + # open registration, no rate limits; scripts/dev-homeserver.sh). The + # regression spec (e2e/local-homeserver.spec.ts) registers its own users + # and rooms through the CS API and drives the built client against it — + # no prod secrets involved. If the homeserver fails to come up the spec + # skips itself and the step below still reports why. + - name: Start local homeserver + id: local_hs + continue-on-error: true + run: | + python3 -m venv --help >/dev/null 2>&1 || { (command -v sudo >/dev/null && sudo -n true 2>/dev/null && sudo apt-get update -qq && sudo apt-get install -y -qq python3-venv) || (apt-get update -qq && apt-get install -y -qq python3-venv); } + scripts/dev-homeserver.sh start + python3 scripts/dev-seed.py 20 > .dev-homeserver/seed.json + echo "E2E_LOCAL_HS=http://localhost:8008" >> "$GITHUB_ENV" + - name: Playwright smoke test run: npm run test:e2e env: @@ -289,3 +306,7 @@ jobs: E2E_HOMESERVER: ${{ secrets.E2E_HOMESERVER }} E2E_USER: ${{ secrets.E2E_USER }} E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }} + + - name: Stop local homeserver + if: always() + run: scripts/dev-homeserver.sh stop || true diff --git a/LOTUS_TESTING.md b/LOTUS_TESTING.md index 2a2ae0f30..84b9b4398 100644 --- a/LOTUS_TESTING.md +++ b/LOTUS_TESTING.md @@ -49,12 +49,13 @@ Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgro ## Playwright smoke test (Gitea #90) — `npm run test:e2e` -Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They boot the **built** `dist/` through `vite preview` on port 4173, so run `npm run build` first (one-time: `npm run test:e2e:install` downloads the pinned Chromium). Two tiers: +Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They boot the **built** `dist/` through `vite preview` on port 4173, so run `npm run build` first (one-time: `npm run test:e2e:install` downloads the pinned Chromium). Three tiers: -| Tier | File | When it runs | What it proves | -| :------------------------ | :-------------------------- | :----------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Boot** (always) | `e2e/boot.spec.ts` | every CI run (`e2e` job in `.gitea/workflows/ci.yml`) and locally | login page renders with `#root` populated and **no** `pageerror` / unexpected `console.error` (allowlist in `e2e/helpers.ts`: the README's avatar-thumbnail 404, the login page's `POST /register` 401 probe, offline discovery), `sw.js` is served and registers, bundled Element Call mounts in a frame with every `/public/element-call/` asset returning 200 | -| **E2EE composer** (gated) | `e2e/e2ee-composer.spec.ts` | only when `E2E_HOMESERVER`, `E2E_USER`, `E2E_PASSWORD` are all set | password login → `/home/create/` with the End-to-End Encryption switch on (asserts `createRoom` carries `m.room.encryption`) → text message renders → attach a generated JPEG with "Compress image before uploading" ticked, image renders → every `PUT …/rooms/*/send/*` was `m.room.encrypted` with `ciphertext` and no plaintext `body` / `url` / `file` / `mxc://` | +| Tier | File | When it runs | What it proves | +| :-------------------------------- | :----------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Boot** (always) | `e2e/boot.spec.ts` | every CI run (`e2e` job in `.gitea/workflows/ci.yml`) and locally | login page renders with `#root` populated and **no** `pageerror` / unexpected `console.error` (allowlist in `e2e/helpers.ts`: the README's avatar-thumbnail 404, the login page's `POST /register` 401 probe, offline discovery), `sw.js` is served and registers, bundled Element Call mounts in a frame with every `/public/element-call/` asset returning 200 | +| **E2EE composer** (gated) | `e2e/e2ee-composer.spec.ts` | only when `E2E_HOMESERVER`, `E2E_USER`, `E2E_PASSWORD` are all set | password login → `/home/create/` with the End-to-End Encryption switch on (asserts `createRoom` carries `m.room.encryption`) → text message renders → attach a generated JPEG with "Compress image before uploading" ticked, image renders → every `PUT …/rooms/*/send/*` was `m.room.encrypted` with `ciphertext` and no plaintext `body` / `url` / `file` / `mxc://` | +| **Local homeserver** (Gitea #220) | `e2e/local-homeserver.spec.ts` | when a Synapse answers at `E2E_LOCAL_HS` (default `http://localhost:8008`); the CI `e2e` job starts one with `scripts/dev-homeserver.sh start` + `dev-seed.py`, locally run the same two commands | registers its own `e2e_alice_*`/`e2e_bob_*` users and rooms over the CS API, then drives the built client: login + send/receive, own message scrolls into view (#212), `/kick` failure toast (#216), upload 413 sentence (#213), forward provenance header, thread panel + drawer at 1400 px (#218), timeline image → gallery lightbox (#219), clock-skew banner via `page.clock` (#158), status save under the presence rate limit (#226), long-press action sheet on a Pixel 7 emulation (#166). Helpers in `e2e/localHs.ts`; add a test here whenever a fix was reproduced with a scratch Playwright script | **CI secrets** (Gitea → repo → Settings → Actions → Secrets; the `e2e` job forwards them via `env:`; until they exist the E2EE tier reports `skipped`, the boot tier still runs): diff --git a/e2e/helpers.ts b/e2e/helpers.ts index fb906a5aa..500036fa0 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -14,6 +14,10 @@ const BENIGN_CONSOLE_PATTERNS: RegExp[] = [ // Homeserver discovery pings can fail on a runner with no outbound network. /\/\.well-known\/matrix\/client/i, /Failed to fetch|NetworkError|ERR_NAME_NOT_RESOLVED|ERR_INTERNET_DISCONNECTED/i, + // Tier 3 (local homeserver named "localhost"): the client's well-known + // autodiscovery probes https://localhost/, which is either not listening + // (CI) or a self-signed dev server (local calls stack). + /ERR_CONNECTION_REFUSED|ERR_CERT_AUTHORITY_INVALID|ERR_SSL_PROTOCOL_ERROR/i, // React devtools hint in production bundles. /Download the React DevTools/i, ]; diff --git a/e2e/local-homeserver.spec.ts b/e2e/local-homeserver.spec.ts new file mode 100644 index 000000000..f632cc4c1 --- /dev/null +++ b/e2e/local-homeserver.spec.ts @@ -0,0 +1,285 @@ +import { test, expect, devices } from '@playwright/test'; +import { + HS, + api, + createRoom, + enc, + ensureUser, + hsReachable, + joinRoom, + loginUI, + openRoom, + sendText, + uniq, + TestUser, +} from './localHs'; +import { collectConsole } from './helpers'; + +// Tier 3 — regression suite against the job's own Synapse (Gitea #220). +// Each test seeds what it needs through the CS API and drives the built +// client; nothing here touches a real deployment. +test.describe('local homeserver regression', () => { + let alice: TestUser; + let bob: TestUser; + + test.beforeAll(async () => { + test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`); + alice = await ensureUser(uniq('e2e_alice_')); + bob = await ensureUser(uniq('e2e_bob_')); + }); + + test('logs in, opens a room, sends and receives', async ({ page }) => { + const console_ = collectConsole(page); + const room = await createRoom(alice, 'Regression Room', { invite: [bob.userId] }); + await joinRoom(bob, room); + await sendText(bob, room, 'hello from bob'); + await loginUI(page, alice); + await openRoom(page, room); + await expect(page.getByText('hello from bob')).toBeVisible(); + await page.locator('[data-slate-editor]').first().click(); + await page.keyboard.type('hello from alice'); + await page.keyboard.press('Enter'); + await expect(page.getByText('hello from alice')).toBeVisible(); + await expect + .poll( + async () => + ( + await api<{ chunk: { content: { body: string } }[] }>( + 'GET', + `/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=1`, + bob.token, + ) + ).chunk[0]?.content.body, + ) + .toBe('hello from alice'); + expect(console_.unexpected()).toEqual([]); + }); + + test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => { + const room = await createRoom(alice, 'Scroll Room'); + await Array.from({ length: 40 }).reduce>( + (chain, _, i) => chain.then(() => sendText(alice, room, `filler ${i}`)), + Promise.resolve(), + ); + await loginUI(page, alice); + await openRoom(page, room); + await page.mouse.move(600, 350); + await page.mouse.wheel(0, -600); + await expect(page.getByRole('button', { name: /Jump to Latest/ })).toBeVisible(); + await page.locator('[data-slate-editor]').first().click(); + await page.keyboard.type('sent while scrolled up'); + await page.keyboard.press('Enter'); + await expect(page.getByText('sent while scrolled up')).toBeInViewport(); + await expect(page.getByRole('button', { name: /Jump to Latest/ })).toHaveCount(0); + }); + + test('/kick failure is reported, not swallowed (#216)', async ({ page }) => { + const room = await createRoom(alice, 'Kick Room', { invite: [bob.userId] }); + await joinRoom(bob, room); + await loginUI(page, bob); + await openRoom(page, room); + const editor = page.locator('[data-slate-editor]').first(); + await editor.click(); + await page.keyboard.type('/kick', { delay: 40 }); + await page.keyboard.press('Tab'); // accept the command chip + await page.keyboard.type(` ${alice.userId}`, { delay: 20 }); + await page.keyboard.press('Enter'); + await expect(page.getByText(/Could not kick .*You cannot kick/)).toBeVisible(); + }); + + test('upload failure shows a plain sentence, never the raw MatrixError (#213)', async ({ + page, + }) => { + const room = await createRoom(alice, 'Upload Room'); + await loginUI(page, alice); + await openRoom(page, room); + await page.route(/\/_matrix\/media\/v3\/upload/, (route) => + route.fulfill({ + status: 413, + contentType: 'application/json', + body: JSON.stringify({ errcode: 'M_TOO_LARGE', error: 'nope' }), + }), + ); + const chooser = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Attach file' }).first().click(); + await ( + await chooser + ).setFiles({ + name: 'pic.png', + mimeType: 'image/png', + buffer: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', + ), + }); + const send = page.getByRole('button', { name: /^Send$/ }); + if (await send.count()) await send.first().click(); + else await page.getByRole('button', { name: 'Send message' }).click(); + await expect(page.getByText(/This file is larger than the server allows/)).toBeVisible(); + await expect(page.getByText(/MatrixError|_matrix\/media/)).toHaveCount(0); + }); + + test('forwarded message carries its provenance header', async ({ page }) => { + const src = await createRoom(bob, 'Source Room', { invite: [alice.userId] }); + const dst = await createRoom(alice, 'Destination Room'); + await joinRoom(alice, src); + await sendText(bob, src, 'the original message'); + await loginUI(page, alice); + await openRoom(page, src); + const msg = page.locator('[data-message-item]', { hasText: 'the original message' }).first(); + await msg.hover(); + await msg.getByRole('button', { name: 'More options' }).click(); + await page.getByText('Forward', { exact: true }).click(); + const search = page.getByPlaceholder('Search rooms…'); + await search.fill('Destination Room'); + await page.locator('button', { hasText: 'Destination Room' }).first().click(); + await page.getByRole('button', { name: /^Send to 1 room/ }).click(); + await expect(page.getByText(/Forwarded to/)).toBeVisible(); + await openRoom(page, dst); + await expect(page.getByText(/Forwarded from .* in Source Room/)).toBeVisible(); + await expect(page.getByText('the original message')).toBeVisible(); + }); + + test('thread panel: opens from the chip and yields the member drawer at 1400px (#218)', async ({ + browser, + }) => { + const ctx = await browser.newContext({ viewport: { width: 1400, height: 850 } }); + const page = await ctx.newPage(); + const room = await createRoom(alice, 'Thread Room'); + const root = await sendText(alice, room, 'thread root'); + await sendText(alice, room, 'a reply', { + 'm.relates_to': { + rel_type: 'm.thread', + event_id: root, + is_falling_back: true, + 'm.in_reply_to': { event_id: root }, + }, + }); + await loginUI(page, alice); + await openRoom(page, room); + await page + .locator('[data-message-item]', { hasText: 'thread root' }) + .getByText(/1 reply/) + .click(); + await expect(page.locator('[data-slate-editor]')).toHaveCount(2); + const widths = await page + .locator('[data-slate-editor]') + .evaluateAll((els) => els.map((e) => e.getBoundingClientRect().width)); + expect(Math.min(...widths)).toBeGreaterThan(120); + await ctx.close(); + }); + + test('timeline image opens the gallery lightbox (#219)', async ({ page }) => { + const room = await createRoom(alice, 'Lightbox Room'); + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', + ); + const up = await fetch(`${HS}/_matrix/media/v3/upload?filename=a.png`, { + method: 'POST', + headers: { Authorization: `Bearer ${alice.token}`, 'Content-Type': 'image/png' }, + body: png, + }).then((r) => r.json() as Promise<{ content_uri: string }>); + await api( + 'PUT', + `/_matrix/client/v3/rooms/${enc(room)}/send/m.room.message/img1`, + alice.token, + { + msgtype: 'm.image', + body: 'a.png', + url: up.content_uri, + info: { mimetype: 'image/png', size: png.length, w: 1, h: 1 }, + }, + ); + await loginUI(page, alice); + await openRoom(page, room); + await page.locator('[data-message-item] img[alt="a.png"]').click(); + const viewer = page.getByRole('dialog', { name: 'Media viewer' }); + await expect(viewer).toBeVisible(); + await expect(viewer.getByText('1 / 1')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(viewer).toHaveCount(0); + }); + + test('warns when the local clock is far off the server (#158)', async ({ page }) => { + const room = await createRoom(alice, 'Skew Room', { invite: [bob.userId] }); + await joinRoom(bob, room); + await page.clock.install({ time: Date.now() + 14 * 60 * 1000 }); + await loginUI(page, alice); + await openRoom(page, room); + for (let i = 0; i < 4; i += 1) { + // eslint-disable-next-line no-await-in-loop + await sendText(bob, room, `tick ${i}`); + // eslint-disable-next-line no-await-in-loop + await page.waitForTimeout(500); + } + await expect(page.getByText(/clock is .*14 minutes ahead of the server/)).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss for 24 h' }).click(); + await expect(page.getByText(/clock is .*ahead of the server/)).toHaveCount(0); + }); + + test('status save survives the presence rate limit (#226)', async ({ page }) => { + let lastOk = 0; + await page.route(/\/presence\/[^/]+\/status/, (route) => { + const now = Date.now(); + if (now - lastOk < 10_000) { + return route.fulfill({ + status: 429, + contentType: 'application/json', + body: JSON.stringify({ + errcode: 'M_LIMIT_EXCEEDED', + error: 'Too Many Requests', + retry_after_ms: 10_000 - (now - lastOk), + }), + }); + } + lastOk = now; + return route.continue(); + }); + await loginUI(page, alice); + await page + .locator('button', { hasText: new RegExp(`^${alice.localpart[0]}$`) }) + .first() + .click(); + await page.getByText('Account', { exact: true }).first().click(); + const input = page.getByLabel('Status message'); + const status = uniq('status '); + await input.fill(status); + await input.locator('xpath=ancestor::form[1]').getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText(/Failed to save status/)).toHaveCount(0); + await expect + .poll( + async () => + ( + await api<{ status_msg?: string }>( + 'GET', + `/_matrix/client/v3/presence/${enc(alice.userId)}/status`, + alice.token, + ) + ).status_msg, + { timeout: 30_000 }, + ) + .toBe(status); + }); + + test('touch: long-press opens the message action sheet (#166)', async ({ browser }) => { + const ctx = await browser.newContext({ ...devices['Pixel 7'] }); + const page = await ctx.newPage(); + const room = await createRoom(alice, 'Touch Room'); + await sendText(alice, room, 'press and hold me'); + await loginUI(page, alice); + await openRoom(page, room); + const msg = page.locator('[data-message-item]', { hasText: 'press and hold me' }); + const box = (await msg.boundingBox())!; + const cdp = await ctx.newCDPSession(page); + const x = box.x + 100; + const y = box.y + box.height / 2; + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] }); + await page.waitForTimeout(700); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + const sheet = page.getByRole('dialog', { name: 'Message actions' }); + await expect(sheet).toBeVisible(); + await expect(sheet.getByText('Reply', { exact: true })).toBeVisible(); + await ctx.close(); + }); +}); diff --git a/e2e/localHs.ts b/e2e/localHs.ts new file mode 100644 index 000000000..6599a198c --- /dev/null +++ b/e2e/localHs.ts @@ -0,0 +1,114 @@ +import { Page, expect } from '@playwright/test'; + +/** + * Tier 3 (Gitea #220): regression tests against a throwaway Synapse the job + * starts itself (`scripts/dev-homeserver.sh start`). No prod secrets. Set + * E2E_LOCAL_HS (default http://localhost:8008) — the spec skips when the + * server isn't reachable. + */ +export const HS = process.env.E2E_LOCAL_HS || 'http://localhost:8008'; +export const PASSWORD = 'password123'; +export const enc = encodeURIComponent; + +export async function hsReachable(): Promise { + try { + const res = await fetch(`${HS}/_matrix/client/versions`); + return res.ok; + } catch { + return false; + } +} + +export async function api>( + method: string, + path: string, + token?: string, + body?: unknown, +): Promise { + const res = await fetch(`${HS}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return (await res.json()) as T; +} + +export type TestUser = { localpart: string; userId: string; token: string }; + +/** Registers (open registration) or logs in a test user. */ +export async function ensureUser(localpart: string): Promise { + const reg = await api<{ user_id?: string; access_token?: string }>( + 'POST', + '/_matrix/client/v3/register', + undefined, + { username: localpart, password: PASSWORD, auth: { type: 'm.login.dummy' } }, + ); + if (reg.access_token && reg.user_id) { + return { localpart, userId: reg.user_id, token: reg.access_token }; + } + const login = await api<{ user_id: string; access_token: string }>( + 'POST', + '/_matrix/client/v3/login', + undefined, + { + type: 'm.login.password', + identifier: { type: 'm.id.user', user: localpart }, + password: PASSWORD, + }, + ); + return { localpart, userId: login.user_id, token: login.access_token }; +} + +export async function createRoom( + owner: TestUser, + name: string, + opts: { invite?: string[]; preset?: string } = {}, +): Promise { + const res = await api<{ room_id: string }>('POST', '/_matrix/client/v3/createRoom', owner.token, { + name, + preset: opts.preset ?? 'public_chat', + invite: opts.invite, + }); + return res.room_id; +} + +export async function joinRoom(user: TestUser, roomId: string): Promise { + await api('POST', `/_matrix/client/v3/rooms/${enc(roomId)}/join`, user.token, {}); +} + +let txn = 0; +export async function sendText( + user: TestUser, + roomId: string, + body: string, + extra: Record = {}, +): Promise { + txn += 1; + const res = await api<{ event_id: string }>( + 'PUT', + `/_matrix/client/v3/rooms/${enc(roomId)}/send/m.room.message/e2e${Date.now()}_${txn}`, + user.token, + { msgtype: 'm.text', body, ...extra }, + ); + return res.event_id; +} + +/** Password login through the UI against the local homeserver. */ +export async function loginUI(page: Page, user: TestUser): Promise { + await page.goto(`/login/${enc(HS)}/`); + await page.getByLabel('Username or email').fill(user.localpart); + await page.getByLabel('Password', { exact: true }).fill(PASSWORD); + await page.getByRole('button', { name: 'Login' }).click(); + await page.waitForURL(/\/home/, { timeout: 60_000 }); +} + +export async function openRoom(page: Page, roomId: string): Promise { + await page.goto(`/home/${enc(roomId)}`); + await expect(page.locator('[data-slate-editor]').first()).toBeVisible({ timeout: 30_000 }); +} + +export const uniq = (prefix: string): string => + `${prefix}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;