From 0cb0f91e43abe49ea6b7baa29c0bc5f5644eee7f Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 13 Sep 2026 00:56:23 -0400 Subject: [PATCH] =?UTF-8?q?test(e2e):=20Playwright=20smoke=20test=20?= =?UTF-8?q?=E2=80=94=20boot=20tier=20always,=20E2EE=20composer=20tier=20wi?= =?UTF-8?q?th=20credentials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot tier (runs against vite preview of dist/): login screen renders with no page or console errors, sw.js is served and registers, the bundled Element Call mounts in a frame with no failed asset requests. E2EE tier (skipped without E2E_HOMESERVER/E2E_USER/E2E_PASSWORD): password login, create an encrypted room, send text, attach a compressed JPEG, and assert at the network level that every send is m.room.encrypted with no plaintext body/url/file — the regression test #6/#7/#11 lacked. Secrets and local usage documented in LOTUS_TESTING.md. Fixes #90 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .gitignore | 4 + LOTUS_TESTING.md | 18 ++++ e2e/boot.spec.ts | 101 +++++++++++++++++++ e2e/e2ee-composer.spec.ts | 201 ++++++++++++++++++++++++++++++++++++++ e2e/helpers.ts | 74 ++++++++++++++ e2e/tsconfig.json | 14 +++ package-lock.json | 48 ++++++++- package.json | 3 + playwright.config.ts | 35 +++++++ 9 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 e2e/boot.spec.ts create mode 100644 e2e/e2ee-composer.spec.ts create mode 100644 e2e/helpers.ts create mode 100644 e2e/tsconfig.json create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index 6f39e04e9..8893be2e5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ devAssets .DS_Store .ideapackage-lock.json public/decorations/ + +# Playwright (npm run test:e2e) +playwright-report/ +test-results/ diff --git a/LOTUS_TESTING.md b/LOTUS_TESTING.md index d677ff0c5..c0c3bb7f6 100644 --- a/LOTUS_TESTING.md +++ b/LOTUS_TESTING.md @@ -52,6 +52,24 @@ 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: + +| 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://` | + +**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): + +- `E2E_HOMESERVER` — server name as typed on the login page (e.g. `matrix.example.org`). Must offer `m.login.password`; a next-gen-auth (MAS/OIDC-issuer) server shows only the OIDC button and the tier will fail at the username field. +- `E2E_USER` / `E2E_PASSWORD` — a **throwaway** account: each run logs in as a new device and creates a new `e2e-smoke-` room. Prune devices/rooms occasionally. + +The `e2e` job is `continue-on-error: true` for now because `playwright install --with-deps` needs `apt` on the runner image — promote it to a hard gate once it is green on the runner. Locally: `npm run test:e2e` (boot tier only), or `E2E_HOMESERVER=… E2E_USER=… E2E_PASSWORD=… npm run test:e2e` for both; on failure look in `test-results/` (screenshot + trace) and `playwright-report/`. + +--- + ## A. Calls — new ringtone + notification work (highest priority) ### A1. Ringtone selection — preview in Settings diff --git a/e2e/boot.spec.ts b/e2e/boot.spec.ts new file mode 100644 index 000000000..c8d986027 --- /dev/null +++ b/e2e/boot.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from '@playwright/test'; +import { collectConsole } from './helpers'; + +// Tier 1 — boot smoke (Gitea #90). Runs against the built dist/ served by +// `vite preview` (see playwright.config.ts webServer). No homeserver needed. + +test.describe('boot', () => { + test('client boots to the login screen without errors', async ({ page }) => { + const consoleLog = collectConsole(page); + + await page.goto('/'); + + // The auth page is what an unauthenticated visitor lands on. + await expect(page).toHaveURL(/\/login\//); + await expect(page.getByLabel('Username or email')).toBeVisible(); + await expect(page.getByLabel('Password', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Login' })).toBeVisible(); + + // The React root rendered something (a blank #root is the classic + // "bundle built but doesn't run" failure). + const rootChildren = await page.locator('#root > *').count(); + expect(rootChildren, '#root should have rendered children').toBeGreaterThan(0); + + expect(consoleLog.unexpected(), 'unexpected console/page errors during boot').toEqual([]); + }); + + test('service worker script is served and registers', async ({ page }) => { + const swResponse = await page.request.get('/sw.js'); + expect(swResponse.status(), 'GET /sw.js').toBe(200); + expect(swResponse.headers()['content-type'] ?? '').toMatch(/javascript/); + + await page.goto('/'); + await expect(page.getByLabel('Username or email')).toBeVisible(); + + // src/index.tsx registers sw.js on load; wait for the registration to + // exist (localhost counts as a secure context so this works in CI). + const registered = await page.evaluate(async () => { + if (!('serviceWorker' in navigator)) return 'unsupported'; + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + // eslint-disable-next-line no-await-in-loop + const reg = await navigator.serviceWorker.getRegistration(); + if (reg) return 'registered'; + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => { + setTimeout(r, 250); + }); + } + return 'timeout'; + }); + expect(registered).toBe('registered'); + }); + + test('bundled Element Call loads in a frame', async ({ page }) => { + const consoleLog = collectConsole(page); + // Any EC asset that fails to come back (wrong base path, missing chunk) + // is the regression this test exists to catch. + const failedEcRequests: string[] = []; + page.on('response', (res) => { + if (res.url().includes('/public/element-call/') && res.status() >= 400) { + failedEcRequests.push(`${res.status()} ${res.url()}`); + } + }); + page.on('requestfailed', (req) => { + if (req.url().includes('/public/element-call/')) { + failedEcRequests.push(`${req.failure()?.errorText ?? 'failed'} ${req.url()}`); + } + }); + + // Same-origin host page so the iframe is served exactly as the client + // embeds it. + await page.goto('/'); + await expect(page.getByLabel('Username or email')).toBeVisible(); + + const ecResponse = await page.request.get('/public/element-call/index.html'); + expect(ecResponse.status(), 'GET /public/element-call/index.html').toBe(200); + + await page.evaluate(() => { + const frame = document.createElement('iframe'); + frame.id = 'e2e-ec-frame'; + frame.src = '/public/element-call/index.html'; + frame.style.width = '800px'; + frame.style.height = '600px'; + document.body.appendChild(frame); + }); + + const frame = page.frameLocator('#e2e-ec-frame'); + // EC mounts into its own #root; rendering anything at all proves the + // bundle resolved its assets from the /public/element-call/ base. + await expect(frame.locator('#root > *').first()).toBeAttached({ timeout: 30_000 }); + // Let EC finish its initial render/requests before inspecting the logs. + await page.waitForTimeout(2_000); + + expect(failedEcRequests, 'Element Call asset requests that failed').toEqual([]); + // Loaded bare (no widget params / no homeserver) EC runs in standalone + // mode and logs a caught React error about its missing config — that is + // console noise, not a broken bundle. Uncaught page errors are still + // fatal, and so is anything the boot test would reject on the host page. + expect(consoleLog.pageErrors, 'uncaught page errors while loading Element Call').toEqual([]); + }); +}); diff --git a/e2e/e2ee-composer.spec.ts b/e2e/e2ee-composer.spec.ts new file mode 100644 index 000000000..829d8a630 --- /dev/null +++ b/e2e/e2ee-composer.spec.ts @@ -0,0 +1,201 @@ +import { test, expect, Page, Request } from '@playwright/test'; +import { collectConsole, generateJpeg } from './helpers'; + +// Tier 2 — E2EE composer smoke (Gitea #90). Needs a real homeserver account +// that supports `m.login.password`, supplied via env (CI secrets, see +// LOTUS_TESTING.md). Skips cleanly when unset so the boot tier still gates CI. +// +// E2E_HOMESERVER server name as typed in the login page, e.g. matrix.example.org +// E2E_USER localpart or full MXID +// E2E_PASSWORD password +// +// Every run logs in as a fresh device (fresh browser context), so the account +// accumulates one device per run — use a throwaway test account. +const HOMESERVER = process.env.E2E_HOMESERVER; +const USER = process.env.E2E_USER; +const PASSWORD = process.env.E2E_PASSWORD; +const HAS_CREDENTIALS = Boolean(HOMESERVER && USER && PASSWORD); + +type SentEvent = { url: string; body: Record }; + +/** Records every `PUT .../send//` the client makes. */ +function recordSentEvents(page: Page): SentEvent[] { + const sent: SentEvent[] = []; + page.on('request', (req: Request) => { + if ( + req.method() !== 'PUT' || + !/\/_matrix\/client\/[^/]+\/rooms\/[^/]+\/send\//.test(req.url()) + ) { + return; + } + let body: Record = {}; + try { + body = JSON.parse(req.postData() ?? '{}'); + } catch { + // leave empty; the assertion below will surface it + } + sent.push({ url: req.url(), body }); + }); + return sent; +} + +const eventTypeOf = (url: string): string => + decodeURIComponent(url.match(/\/send\/([^/]+)\//)?.[1] ?? ''); + +test.describe('E2EE composer', () => { + test.skip(!HAS_CREDENTIALS, 'needs E2E_HOMESERVER / E2E_USER / E2E_PASSWORD'); + // The three scenarios build on one another (login → room → messages), so + // share a single page and run them in order. + test.describe.configure({ mode: 'serial' }); + test.setTimeout(120_000); + + let page: Page; + let sentEvents: SentEvent[]; + let consoleLog: ReturnType; + let roomUrl: string; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + consoleLog = collectConsole(page); + sentEvents = recordSentEvents(page); + }); + + test.afterAll(async () => { + await page?.close(); + }); + + test('logs in with a password and reaches the client', async () => { + await page.goto(`/login/${encodeURIComponent(HOMESERVER as string)}/`); + + await page.getByLabel('Username or email').fill(USER as string); + await page.getByLabel('Password', { exact: true }).fill(PASSWORD as string); + await page.getByRole('button', { name: 'Login' }).click(); + + // Leaving /login/ means the session was stored and the client mounted. + await expect(page).not.toHaveURL(/\/login\//, { timeout: 60_000 }); + // The client shell mounts at /home/ (or the last-visited space) once the + // session is restored and initial sync starts. + await expect(page).toHaveURL(/\/(home|direct|explore|inbox|!|#)/, { timeout: 60_000 }); + await expect(page.locator('#root > *').first()).toBeAttached(); + + expect(consoleLog.pageErrors, 'uncaught page errors during login').toEqual([]); + }); + + test('creates a private encrypted room and sends a text message', async () => { + const roomName = `e2e-smoke-${Date.now()}`; + + const createRoomRequest = page.waitForRequest( + (req) => req.method() === 'POST' && /\/_matrix\/client\/[^/]+\/createRoom/.test(req.url()), + ); + + await page.goto('/home/create/'); + const form = page.locator('form').filter({ has: page.locator('input[name="nameInput"]') }); + await expect(form).toBeVisible(); + + await form.locator('input[name="nameInput"]').fill(roomName); + // Default access is Private (or Restricted, which also allows E2EE); the + // encryption switch lives in the "End-to-End Encryption" setting tile. + const encryptionSwitch = form + .getByText('End-to-End Encryption', { exact: true }) + .locator('xpath=ancestor::div[.//*[@role="switch"]][1]') + .getByRole('switch'); + await expect(encryptionSwitch).toBeVisible(); + if ((await encryptionSwitch.getAttribute('aria-checked')) !== 'true') { + await encryptionSwitch.click(); + } + await expect(encryptionSwitch).toHaveAttribute('aria-checked', 'true'); + + await form.getByRole('button', { name: 'Create' }).click(); + + // The createRoom request itself must ask for encryption up front. + const createBody = JSON.parse((await createRoomRequest).postData() ?? '{}') as { + initial_state?: { type: string; content?: { algorithm?: string } }[]; + }; + const encryptionState = createBody.initial_state?.find((s) => s.type === 'm.room.encryption'); + expect(encryptionState?.content?.algorithm, 'createRoom initial_state m.room.encryption').toBe( + 'm.megolm.v1.aes-sha2', + ); + + // Landed in the new room. + await expect(page).toHaveURL(/\/home\/!/, { timeout: 30_000 }); + roomUrl = page.url(); + await expect(page.getByText(roomName, { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + + const text = `hello from playwright ${Date.now()}`; + const composer = page.getByRole('textbox', { name: 'Send a message...' }); + await expect(composer).toBeVisible(); + await composer.click(); + await composer.fill(text); + await composer.press('Enter'); + + await expect(page.getByText(text, { exact: true })).toBeVisible({ timeout: 30_000 }); + + const messageSends = sentEvents.filter((e) => eventTypeOf(e.url).startsWith('m.room.')); + expect(messageSends.length, 'at least one room event sent').toBeGreaterThan(0); + for (const e of messageSends) { + expect(eventTypeOf(e.url), `event type for ${e.url}`).toBe('m.room.encrypted'); + expect(e.body).toHaveProperty('ciphertext'); + expect(e.body).not.toHaveProperty('body'); + expect(JSON.stringify(e.body)).not.toContain(text); + } + expect(consoleLog.pageErrors, 'uncaught page errors while sending text').toEqual([]); + }); + + test('attaches a compressed image and it is sent encrypted', async () => { + await expect(page).toHaveURL(roomUrl); + const fileName = `lotus-e2e-${Date.now()}.jpg`; + const jpeg = await generateJpeg(page); + const sentBefore = sentEvents.length; + + // The composer opens a detached via selectFile(); the + // file chooser event is the hook Playwright gives us for that. + const fileChooser = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Attach file' }).click(); + await (await fileChooser).setFiles({ name: fileName, mimeType: 'image/jpeg', buffer: jpeg }); + + // Upload board: tick "Compress image before uploading", then Send. + const compressSwitch = page + .getByText('Compress image before uploading', { exact: true }) + .locator('xpath=ancestor::div[.//*[@role="switch"]][1]') + .getByRole('switch'); + await expect(compressSwitch).toBeVisible({ timeout: 30_000 }); + if ((await compressSwitch.getAttribute('aria-checked')) !== 'true') { + await compressSwitch.click(); + } + await expect(compressSwitch).toHaveAttribute('aria-checked', 'true'); + // compressImage() runs asynchronously once ticked; wait for it to settle + // so the Send picks up the compressed result. + await expect(page.getByText('compressing…')).toHaveCount(0, { timeout: 30_000 }); + + await page.getByRole('button', { name: 'Send', exact: true }).click(); + + // The timeline shows the image (alt/title = file body; compression + // renames to .jpg which our name already is). + const image = page.locator(`img[alt="${fileName}"]`); + const viewButton = page.getByRole('button', { name: 'View', exact: true }); + await expect(image.or(viewButton).first()).toBeVisible({ timeout: 60_000 }); + if (!(await image.count())) { + // Media auto-load disabled — click through and wait for the image. + await viewButton.first().click(); + } + await expect(image.first()).toBeVisible({ timeout: 60_000 }); + + // Every room event sent for the image was encrypted: no plaintext + // m.room.message with a `url`/`file`/`body`. + const newSends = sentEvents + .slice(sentBefore) + .filter((e) => eventTypeOf(e.url).startsWith('m.room.')); + expect(newSends.length, 'image produced at least one room event').toBeGreaterThan(0); + for (const e of newSends) { + expect(eventTypeOf(e.url), `event type for ${e.url}`).toBe('m.room.encrypted'); + expect(e.body).toHaveProperty('ciphertext'); + expect(e.body).not.toHaveProperty('url'); + expect(e.body).not.toHaveProperty('file'); + expect(e.body).not.toHaveProperty('body'); + expect(JSON.stringify(e.body)).not.toContain('mxc://'); + } + expect(consoleLog.pageErrors, 'uncaught page errors while sending image').toEqual([]); + }); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 000000000..fb906a5aa --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,74 @@ +import { Page } from '@playwright/test'; + +// Console noise that is expected on a clean boot and must not fail the smoke +// test. Keep this list short and specific — every entry should name a known, +// understood source. +const BENIGN_CONSOLE_PATTERNS: RegExp[] = [ + // README: after login you may see a 404 for a missing avatar thumbnail — + // "not a login failure". Also covers the generic resource-404 console line. + /_matrix\/(client|media)\/v\d+\/(media\/)?thumbnail/i, + /Failed to load resource: the server responded with a status of 404/i, + // The login page probes `POST /_matrix/client/v3/register` to learn whether + // registration is open; the homeserver answers 401 + UIA flows by design. + /Failed to load resource: the server responded with a status of 401/i, + // 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, + // React devtools hint in production bundles. + /Download the React DevTools/i, +]; + +export type ConsoleCollector = { + errors: string[]; + pageErrors: string[]; + /** Errors not matched by the benign allowlist. */ + unexpected: () => string[]; +}; + +/** + * Records console.error lines and uncaught page errors for the given page. + * Attach BEFORE navigating so nothing emitted during boot is missed. + */ +export function collectConsole(page: Page): ConsoleCollector { + const errors: string[] = []; + const pageErrors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + page.on('pageerror', (err) => { + pageErrors.push(err.message); + }); + return { + errors, + pageErrors, + unexpected: () => [ + ...pageErrors.map((m) => `pageerror: ${m}`), + ...errors.filter((m) => !BENIGN_CONSOLE_PATTERNS.some((re) => re.test(m))), + ], + }; +} + +/** + * Generates a small JPEG in the browser (canvas.toBlob) and returns its bytes. + * JPEG rather than PNG so the composer's "Compress image" path actually + * re-encodes (compressImage() deliberately skips PNG to preserve alpha). + */ +export async function generateJpeg(page: Page, size = 96): Promise { + const dataUrl = await page.evaluate((px) => { + const canvas = document.createElement('canvas'); + canvas.width = px; + canvas.height = px; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('canvas 2d context unavailable'); + const grad = ctx.createLinearGradient(0, 0, px, px); + grad.addColorStop(0, '#7c3aed'); + grad.addColorStop(1, '#f59e0b'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, px, px); + ctx.fillStyle = '#fff'; + ctx.font = `${Math.floor(px / 4)}px sans-serif`; + ctx.fillText('e2e', px / 8, px / 2); + return canvas.toDataURL('image/jpeg', 0.95); + }, size); + return Buffer.from(dataUrl.split(',')[1], 'base64'); +} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 000000000..8cad196e6 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2022", "DOM"], + "types": ["node"] + }, + "include": ["./**/*.ts", "../playwright.config.ts"] +} diff --git a/package-lock.json b/package-lock.json index 875ecf89b..627b16ffa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ }, "devDependencies": { "@lotusguild/element-call-embedded": "0.25.0-lotus.1", + "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", "@types/chroma-js": "3.1.2", @@ -120,7 +121,7 @@ "vite-plugin-static-copy": "4.1.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=20.0.0" } }, "node_modules/@apideck/better-ajv-errors": { @@ -2973,6 +2974,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@react-types/shared": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz", @@ -10708,6 +10725,35 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/package.json b/package.json index f8561478a..d18483b42 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "fix:prettier": "prettier --write .", "typecheck": "tsc --noEmit", "test": "node --import tsx --test $(find src -name '*.test.ts')", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install chromium", "prepare": "husky", "commit": "git-cz", "postinstall": "node scripts/patch-folds.mjs", @@ -106,6 +108,7 @@ }, "devDependencies": { "@lotusguild/element-call-embedded": "0.25.0-lotus.1", + "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", "@types/chroma-js": "3.1.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..227273f98 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +// Playwright smoke tests (Gitea #90). Two tiers live under e2e/: +// - boot.spec.ts always runs; serves the built dist/ via `vite preview` +// and checks the client actually boots in a real browser. +// - e2ee-composer.spec.ts skips itself unless E2E_HOMESERVER/E2E_USER/E2E_PASSWORD +// are set (CI secrets — see LOTUS_TESTING.md). +// `npm run build` must have produced dist/ before `npm run test:e2e`. +const PORT = 4173; +const BASE_URL = `http://localhost:${PORT}/`; + +export default defineConfig({ + testDir: './e2e', + timeout: 60_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']], + outputDir: 'test-results', + use: { + baseURL: BASE_URL, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + ...devices['Desktop Chrome'], + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: `npx vite preview --port ${PORT} --strictPort`, + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +});