test(e2e): Playwright smoke test — boot tier always, E2EE composer tier with credentials

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-13 00:56:23 -04:00
co-authored by Claude Opus 5
parent 09562061a4
commit 0cb0f91e43
9 changed files with 497 additions and 1 deletions
+4
View File
@@ -6,3 +6,7 @@ devAssets
.DS_Store .DS_Store
.ideapackage-lock.json .ideapackage-lock.json
public/decorations/ public/decorations/
# Playwright (npm run test:e2e)
playwright-report/
test-results/
+18
View File
@@ -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-<timestamp>` 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) ## A. Calls — new ringtone + notification work (highest priority)
### A1. Ringtone selection — preview in Settings ### A1. Ringtone selection — preview in Settings
+101
View File
@@ -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([]);
});
});
+201
View File
@@ -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<string, unknown> };
/** Records every `PUT .../send/<type>/<txn>` 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<string, unknown> = {};
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<typeof collectConsole>;
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 <input type=file> 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([]);
});
});
+74
View File
@@ -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<Buffer> {
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');
}
+14
View File
@@ -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"]
}
+47 -1
View File
@@ -81,6 +81,7 @@
}, },
"devDependencies": { "devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.1", "@lotusguild/element-call-embedded": "0.25.0-lotus.1",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2", "@types/chroma-js": "3.1.2",
@@ -120,7 +121,7 @@
"vite-plugin-static-copy": "4.1.0" "vite-plugin-static-copy": "4.1.0"
}, },
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@apideck/better-ajv-errors": { "node_modules/@apideck/better-ajv-errors": {
@@ -2973,6 +2974,22 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@react-types/shared": {
"version": "3.34.0", "version": "3.34.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz", "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz",
@@ -10708,6 +10725,35 @@
"pathe": "^2.0.1" "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": { "node_modules/pngjs": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+3
View File
@@ -17,6 +17,8 @@
"fix:prettier": "prettier --write .", "fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "node --import tsx --test $(find src -name '*.test.ts')", "test": "node --import tsx --test $(find src -name '*.test.ts')",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install chromium",
"prepare": "husky", "prepare": "husky",
"commit": "git-cz", "commit": "git-cz",
"postinstall": "node scripts/patch-folds.mjs", "postinstall": "node scripts/patch-folds.mjs",
@@ -106,6 +108,7 @@
}, },
"devDependencies": { "devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.1", "@lotusguild/element-call-embedded": "0.25.0-lotus.1",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2", "@types/chroma-js": "3.1.2",
+35
View File
@@ -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,
},
});