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([]); }); });