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:
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Generated
+47
-1
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user