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