Files
cinny/e2e/local-homeserver.spec.ts
Lotus CIandClaude Opus 5.5 f9172cc339
CI / Build & Quality Checks (pull_request) Successful in 2m53s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m12s
fix(polls): you can vote on a poll you just created (#284)
PollContent read mEvent.getId() once at render. For your own new poll
that is the local echo id (~!room:txn), and nothing re-rendered it when
the SDK swapped in the real $id, so the vote related to the local id
and the SDK threw synchronously (getPendingEvents under chronological
ordering): no vote was ever sent. Track the id via LocalEventIdReplaced
and Status, block voting while it is still local, and catch synchronous
throws. Adds the Playwright regression the issue asks for.

Fixes #284

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:54:01 -04:00

391 lines
15 KiB
TypeScript

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 @webkit @ios', 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('you can vote on a poll you just created, without reloading (#284)', async ({ page }) => {
const console_ = collectConsole(page);
const room = await createRoom(alice, 'Poll Room');
await loginUI(page, alice);
await openRoom(page, room);
const question = uniq('Lunch? ');
await page.getByRole('button', { name: 'Create poll' }).first().click();
await page.getByPlaceholder('Ask a question…').fill(question);
await page.getByLabel('Option 1', { exact: true }).fill('Pizza');
await page.getByLabel('Option 2', { exact: true }).fill('Tacos');
await page.getByRole('button', { name: 'Create Poll' }).last().click();
const poll = page.locator('[data-message-id]', { hasText: question }).last();
await expect(poll).toBeVisible();
type Ev = {
type: string;
event_id: string;
content: { 'm.relates_to'?: { event_id?: string } };
};
const events = async () =>
(
await api<{ chunk: Ev[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=10`,
alice.token,
)
).chunk;
// Vote once the poll has reached the server (its row may still carry the local id).
await expect
.poll(async () => (await events()).some((e) => /poll\.start/.test(e.type)))
.toBe(true);
await poll.getByText('Tacos', { exact: true }).first().click();
await expect
.poll(async () => {
const chunk = await events();
const start = chunk.find((e) => /poll\.start/.test(e.type));
return chunk.some(
(e) =>
/poll\.response/.test(e.type) &&
e.content['m.relates_to']?.event_id === start?.event_id &&
start?.event_id.startsWith('$'),
);
})
.toBe(true);
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) @webkit', 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('a thread I started keeps its unread replies while I view the room (#217)', async ({
page,
}) => {
const room = await createRoom(alice, 'Thread Unread Room', { invite: [bob.userId] });
await joinRoom(bob, room);
const inThread = (root: string) => ({
'm.relates_to': {
rel_type: 'm.thread',
event_id: root,
is_falling_back: true,
'm.in_reply_to': { event_id: root },
},
});
const root = await sendText(alice, room, 'my thread root');
await loginUI(page, alice);
await openRoom(page, room);
// While the room is open and at the bottom: a thread reply, then a newer
// main-timeline message. Neither may clear the thread without opening it.
await sendText(bob, room, 'reply in your thread', inThread(root));
await sendText(bob, room, 'newer main message');
await expect(page.getByText('newer main message')).toBeVisible();
const chip = page
.locator('[data-message-item]', { hasText: 'my thread root' })
.getByRole('button', { name: /1 reply/ });
const threadUnread = async () => {
// not_types varies so Synapse's sync response cache can't serve a stale answer.
const filter = {
room: {
rooms: [room],
timeline: { limit: 1, unread_thread_notifications: true, not_types: [uniq('x.')] },
},
};
const sync = await api<{
rooms: {
join: Record<string, { unread_thread_notifications?: Record<string, unknown> }>;
};
}>(
'GET',
`/_matrix/client/v3/sync?timeout=0&filter=${enc(JSON.stringify(filter))}`,
alice.token,
);
return root in (sync.rooms.join[room]?.unread_thread_notifications ?? {});
};
await page.waitForTimeout(2000); // let any receipt the room view would send go out
expect(await threadUnread()).toBe(true);
await expect(chip).toHaveAccessibleName(/unread replies/);
await chip.click();
await expect.poll(threadUnread).toBe(false);
});
test('timeline image opens the gallery lightbox (#219) @webkit', 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);
const ticks = async (from: number, n: number) => {
for (let i = from; i < from + n; 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 ticks(0, 4);
// "Ahead" could be a late delivery (a stalled server), so it is only
// reported once it has held for a minute of fresh samples.
await page.waitForTimeout(2000);
await expect(page.getByText(/clock is .*ahead of the server/)).toHaveCount(0);
await page.clock.fastForward('01:05');
await ticks(4, 2);
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();
});
});