ci(e2e): tier-3 regression suite against a Synapse the job starts itself (#220)
CI / Build & Quality Checks (push) Successful in 1m29s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 4m17s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 22:38:39 -04:00
co-authored by Claude Opus 5
parent 7f17940d34
commit ef5d06eea3
5 changed files with 430 additions and 5 deletions
+21
View File
@@ -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
+3 -2
View File
@@ -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://` |
| **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):
+4
View File
@@ -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,
];
+285
View File
@@ -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<Promise<unknown>>(
(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();
});
});
+114
View File
@@ -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<boolean> {
try {
const res = await fetch(`${HS}/_matrix/client/versions`);
return res.ok;
} catch {
return false;
}
}
export async function api<T = Record<string, unknown>>(
method: string,
path: string,
token?: string,
body?: unknown,
): Promise<T> {
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<TestUser> {
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<string> {
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<void> {
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<string, unknown> = {},
): Promise<string> {
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<void> {
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<void> {
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)}`;