286 lines
11 KiB
TypeScript
286 lines
11 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', 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();
|
||
|
|
});
|
||
|
|
});
|