Merge pull request 'test(e2e): scheduled messages and offline outbox; retry rate-limited scheduled-message calls' (#476) from test/lotus-feature-e2e into lotus
CI / Build & Quality Checks (push) Successful in 3m3s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 24m26s

This commit was merged in pull request #476.
This commit is contained in:
2026-10-28 07:03:36 -04:00
4 changed files with 287 additions and 29 deletions
+72
View File
@@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
import {
api,
createRoom,
enc,
ensureUser,
hsReachable,
loginUI,
openRoom,
sendText,
uniq,
} from './localHs';
// Offline outbox (#112): messages sent while offline are queued, then
// delivered once each, in order, when the connection returns. Checked on the
// server, so duplicates or reordering would show.
test.describe('offline outbox', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('queued while offline, delivered once each and in order when back', async ({
page,
context,
}) => {
test.setTimeout(120_000);
const me = await ensureUser(uniq('outbox'));
const room = await createRoom(me, 'Outbox Room');
await sendText(me, room, 'before going offline');
await loginUI(page, me);
await openRoom(page, room);
await expect(page.getByText('before going offline')).toBeVisible();
await context.setOffline(true);
const composer = page.getByRole('textbox', { name: /message/i }).last();
const texts = [uniq('one '), uniq('two '), uniq('three ')];
for (const text of texts) {
// eslint-disable-next-line no-await-in-loop
await composer.fill(text);
// eslint-disable-next-line no-await-in-loop
await composer.press('Enter');
}
for (const text of texts) {
const item = page.locator('[data-message-item]', { hasText: text });
// eslint-disable-next-line no-await-in-loop
await expect(item).toHaveCount(1);
// eslint-disable-next-line no-await-in-loop
await expect(item.getByLabel(/^Queued\. Will send when you're back online/)).toBeVisible({
timeout: 20_000,
});
}
await context.setOffline(false);
const bodiesOnServer = async () => {
const res = await api<{ chunk: { type: string; content?: { body?: string } }[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=f&limit=100`,
me.token,
);
return res.chunk
.filter((e) => e.type === 'm.room.message' && texts.includes(e.content?.body ?? ''))
.map((e) => e.content?.body);
};
await expect.poll(bodiesOnServer, { timeout: 60_000, intervals: [1000] }).toEqual(texts);
for (const text of texts) {
// eslint-disable-next-line no-await-in-loop
await expect(
page.locator('[data-message-item]', { hasText: text }).getByLabel(/^Queued/),
).toHaveCount(0, { timeout: 20_000 });
}
});
});
+102
View File
@@ -0,0 +1,102 @@
import { test, expect, Page } from '@playwright/test';
import {
api,
createRoom,
enc,
ensureUser,
hsReachable,
loginUI,
openRoom,
sendText,
uniq,
} from './localHs';
// Scheduled messages (MSC4140 delayed events). Each step is checked on the
// server too, so this proves the delayed event really exists, fires and is
// cancelled, not just that the tray looks right.
type Delayed = { delayed_events?: { delay_id: string; room_id: string }[] };
const delayedOnServer = (token: string) =>
api<Delayed>('GET', '/_matrix/client/unstable/org.matrix.msc4140/delayed_events', token).then(
(r) => r.delayed_events ?? [],
);
// The tray remembers whether it is open; open it only if its rows are hidden.
const openTray = async (page: Page) => {
const rowButton = page.getByRole('button', { name: /^Cancel scheduled message: / });
if (await rowButton.count()) return;
await page.getByRole('button', { name: /^\d+ scheduled messages?$/ }).click();
await expect(rowButton.first()).toBeVisible();
};
const schedule = async (page: Page, text: string) => {
await page.getByRole('button', { name: 'Schedule message' }).click();
const dialog = page.getByRole('dialog', { name: 'Schedule message' });
await dialog.locator('#schedule-message-body').fill(text);
// Two hours ahead, in the browser's own time zone.
const { date, time } = await page.evaluate(() => {
const d = new Date(Date.now() + 2 * 60 * 60 * 1000);
const p = (n: number) => String(n).padStart(2, '0');
return {
date: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`,
time: `${p(d.getHours())}:${p(d.getMinutes())}`,
};
});
await dialog.locator('#schedule-date').fill(date);
await dialog.locator('#schedule-time').fill(time);
await dialog.getByRole('button', { name: 'Schedule', exact: true }).click();
await expect(dialog).toHaveCount(0);
};
test.describe('scheduled messages', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('schedule, send now, and cancel — the server agrees at each step', async ({ page }) => {
test.setTimeout(120_000);
const me = await ensureUser(uniq('sched'));
const room = await createRoom(me, 'Schedule Room');
await sendText(me, room, 'room is ready');
await loginUI(page, me);
await openRoom(page, room);
// 1. Schedule: tray shows it, the server holds one delayed event.
const first = uniq('later ');
await schedule(page, first);
await expect(page.getByRole('button', { name: '1 scheduled message' })).toBeVisible();
await expect
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
.toBe(1);
expect((await delayedOnServer(me.token))[0].room_id).toBe(room);
await expect(page.locator('[data-message-item]', { hasText: first })).toHaveCount(0);
// 2. Send now: it arrives once, the delayed event is gone.
await openTray(page);
await page.getByRole('button', { name: /^Send scheduled message now: / }).click();
await expect(page.locator('[data-message-item]', { hasText: first })).toHaveCount(1, {
timeout: 20_000,
});
await expect
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
.toBe(0);
// 3. Cancel: removed from the server and never sent.
const second = uniq('never ');
await schedule(page, second);
await expect
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
.toBe(1);
await openTray(page);
await page.getByRole('button', { name: /^Cancel scheduled message: / }).click();
await expect
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
.toBe(0);
await expect(page.getByRole('button', { name: /scheduled message/ })).toHaveCount(0);
const msgs = await api<{ chunk: { content?: { body?: string } }[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=50`,
me.token,
);
expect(msgs.chunk.some((e) => e.content?.body === second)).toBe(false);
});
});
+40
View File
@@ -191,3 +191,43 @@ test('[#275] the server limit maps to friendly text; other errors keep theirs',
assert.equal(describeScheduleError(new Error('Boom')), 'Boom'); assert.equal(describeScheduleError(new Error('Boom')), 'Boom');
assert.equal(describeScheduleError('nope'), 'Failed to schedule message.'); assert.equal(describeScheduleError('nope'), 'Failed to schedule message.');
}); });
test('withRateLimitRetry: waits out a 429 then succeeds; other errors and over-budget waits throw', async () => {
const { withRateLimitRetry } = await import('./scheduledMessages');
const { MatrixError } = await import('matrix-js-sdk');
const limited = () => new MatrixError({ errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 1500 }, 429);
const sleeps: number[] = [];
const sleepFn = async (ms: number) => {
sleeps.push(ms);
};
let calls = 0;
const ok = await withRateLimitRetry(
async () => {
calls += 1;
if (calls < 3) throw limited();
return 'done';
},
{ sleepFn },
);
assert.equal(ok, 'done');
assert.deepEqual(sleeps, [1500, 1500]);
await assert.rejects(
withRateLimitRetry(
async () => {
throw new MatrixError({ errcode: 'M_FORBIDDEN' }, 403);
},
{ sleepFn },
),
/M_FORBIDDEN|403/,
);
await assert.rejects(
withRateLimitRetry(
async () => {
throw limited();
},
{ sleepFn, maxWaitMs: 2000 },
),
);
});
+73 -29
View File
@@ -1,4 +1,4 @@
import { IContent, MatrixClient, Method } from 'matrix-js-sdk'; import { IContent, MatrixClient, MatrixError, Method } from 'matrix-js-sdk';
import type { ScheduledMessage } from '../state/scheduledMessages'; import type { ScheduledMessage } from '../state/scheduledMessages';
/** /**
@@ -17,6 +17,40 @@ export const describeScheduleError = (err: unknown): string => {
return err instanceof Error ? err.message : 'Failed to schedule message.'; return err instanceof Error ? err.message : 'Failed to schedule message.';
}; };
/**
* Synapse rate-limits delayed-event management per user (listing, send now,
* cancel, restart all count). A quick "Send now" then "Cancel" could get a 429
* and show "Could not cancel" although nothing was wrong. Wait out the
* Retry-After (bounded) and try again. Retrying is safe: a 429 means the
* request was not processed, and scheduling reuses its txnId.
*/
export async function withRateLimitRetry<T>(
fn: () => Promise<T>,
opts: { maxWaitMs?: number; sleepFn?: (ms: number) => Promise<void> } = {},
): Promise<T> {
const maxWaitMs = opts.maxWaitMs ?? 15_000;
const sleepFn =
opts.sleepFn ??
((ms: number) =>
new Promise<void>((r) => {
setTimeout(r, ms);
}));
let waited = 0;
for (;;) {
try {
// eslint-disable-next-line no-await-in-loop
return await fn();
} catch (e) {
if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e;
const wait = Math.min(Math.max(e.getRetryAfterMs() ?? 1000, 200), 10_000);
if (waited + wait > maxWaitMs) throw e;
waited += wait;
// eslint-disable-next-line no-await-in-loop
await sleepFn(wait);
}
}
}
/** /**
* Schedule a message via MSC4140 (delayed messages). * Schedule a message via MSC4140 (delayed messages).
* Synapse implementation: pass org.matrix.msc4140.delay on the standard * Synapse implementation: pass org.matrix.msc4140.delay on the standard
@@ -42,13 +76,15 @@ export async function scheduleMessage(
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`; const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`; const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
// Use standard v3 prefix with the MSC4140 delay query param. // Use standard v3 prefix with the MSC4140 delay query param.
const res = (await mx.http.authedRequest( const res = (await withRateLimitRetry(() =>
Method.Put, mx.http.authedRequest(
path, Method.Put,
{ path,
'org.matrix.msc4140.delay': delayMs, {
}, 'org.matrix.msc4140.delay': delayMs,
content, },
content,
),
)) as { delay_id: string }; )) as { delay_id: string };
return res.delay_id; return res.delay_id;
} }
@@ -58,23 +94,27 @@ export async function scheduleMessage(
*/ */
export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> { export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`; const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest( await withRateLimitRetry(() =>
Method.Post, mx.http.authedRequest(
path, Method.Post,
undefined, path,
{ action: 'cancel' }, undefined,
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' }, { action: 'cancel' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
),
); );
} }
export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> { export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`; const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest( await withRateLimitRetry(() =>
Method.Post, mx.http.authedRequest(
path, Method.Post,
undefined, path,
{ action: 'restart' }, undefined,
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' }, { action: 'restart' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
),
); );
} }
@@ -85,12 +125,14 @@ export async function restartScheduledMessage(mx: MatrixClient, delayId: string)
*/ */
export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string): Promise<void> { export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`; const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest( await withRateLimitRetry(() =>
Method.Post, mx.http.authedRequest(
path, Method.Post,
undefined, path,
{ action: 'send' }, undefined,
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' }, { action: 'send' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
),
); );
} }
@@ -138,9 +180,11 @@ export function parseDelayedEvents(res: unknown): ScheduledMessage[] {
/** The user's pending scheduled messages, as the server has them. */ /** The user's pending scheduled messages, as the server has them. */
export async function fetchScheduledMessages(mx: MatrixClient): Promise<ScheduledMessage[]> { export async function fetchScheduledMessages(mx: MatrixClient): Promise<ScheduledMessage[]> {
const res = await mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, { const res = await withRateLimitRetry(() =>
prefix: '/_matrix/client/unstable/org.matrix.msc4140', mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
}); prefix: '/_matrix/client/unstable/org.matrix.msc4140',
}),
);
return parseDelayedEvents(res); return parseDelayedEvents(res);
} }