CI / Build & Quality Checks (pull_request) Successful in 6m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 10m28s
Until now a send that failed (offline, homeserver down, a blip) went straight to "Failed to send": nothing retried it, and a reload dropped it without trace (chronological pending ordering keeps local echoes in memory only). - Outbox (utils/outbox.ts + features/outbox/OutboxFeature): own message sends (text, stickers, reactions, polls; not call signalling or redactions) are mirrored to localStorage from their first local echo until the server confirms them or the user cancels. - After a reload they come back as local echoes via room.addPendingEvent, same shape as the SDK's own. Recent ones (< 1 h) are sent again with the same txnId; older ones come back as "Failed to send" for the user to retry or cancel. Ones the server already has (transaction id seen in /sync) are dropped, so no duplicates. - Retries: network failures (ConnectionError, 408/429/5xx) are re-sent when the connection returns (sync recovers or the browser goes back online), and after a blip while online (5 s, backing off, max 10 per message). Oldest first, in order per room. 4xx / consent / encryption failures are left to the user. - UI: a network failure while offline shows a clock, "Queued. Will send when you're back online" (thread view too), not the red ✕. The ✕ is now a button: click to retry. - Logout wipes the outbox with the other plaintext caches (the content is decrypted, like drafts). Tested end to end against a local Synapse (Chromium): offline → queued → sent once on reconnect; homeserver unreachable → queued → sent once; failed send → reload → sent once and shown once; server accepted but response lost → reload → no duplicate; 2 h old entry → failed, not sent, click ✕ → sent; cancel → gone after reload; encrypted room → restored message goes out as m.room.encrypted with no plaintext and decrypts; one-off failure retried by itself in ~5 s; no page errors. Unit tests for the pure parts; Playwright 20 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
132 lines
4.6 KiB
TypeScript
132 lines
4.6 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
OUTBOX_AUTOSEND_MS,
|
|
OUTBOX_MAX_ENTRIES,
|
|
OutboxEntry,
|
|
isRetryableSendError,
|
|
outboxRetryDelayMs,
|
|
parseOutbox,
|
|
planRestore,
|
|
shouldKeepInOutbox,
|
|
withEntry,
|
|
withoutEntry,
|
|
} from './outbox';
|
|
|
|
const entry = (txnId: string, ts: number, roomId = '!a:hs'): OutboxEntry => ({
|
|
txnId,
|
|
roomId,
|
|
threadId: null,
|
|
type: 'm.room.message',
|
|
content: { msgtype: 'm.text', body: txnId },
|
|
ts,
|
|
});
|
|
|
|
test('keeps message-like events, not call signalling or redactions', () => {
|
|
assert.equal(shouldKeepInOutbox('m.room.message', { body: 'hi' }), true);
|
|
assert.equal(shouldKeepInOutbox('m.reaction', { 'm.relates_to': { event_id: '$x' } }), true);
|
|
assert.equal(shouldKeepInOutbox('org.matrix.msc3381.poll.start', {}), true);
|
|
assert.equal(shouldKeepInOutbox('org.matrix.msc4075.rtc.notification', {}), false);
|
|
assert.equal(shouldKeepInOutbox('m.call.invite', {}), false);
|
|
assert.equal(shouldKeepInOutbox('m.room.redaction', {}), false);
|
|
});
|
|
|
|
test('skips events that point at another unsent message (local id)', () => {
|
|
assert.equal(
|
|
shouldKeepInOutbox('m.reaction', { 'm.relates_to': { event_id: '~!a:hs:m123' } }),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
shouldKeepInOutbox('m.room.message', {
|
|
body: 'reply',
|
|
'm.relates_to': { 'm.in_reply_to': { event_id: '~!a:hs:m1' } },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
shouldKeepInOutbox('m.room.message', {
|
|
body: 'reply',
|
|
'm.relates_to': { 'm.in_reply_to': { event_id: '$real' } },
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('retryable: connection loss, timeouts, rate limits, server errors', () => {
|
|
const connectionError = new Error('fetch failed');
|
|
Object.defineProperty(connectionError, 'name', { value: 'ConnectionError' });
|
|
assert.equal(isRetryableSendError(connectionError), true);
|
|
assert.equal(isRetryableSendError({ httpStatus: 429 }), true);
|
|
assert.equal(isRetryableSendError({ httpStatus: 408 }), true);
|
|
assert.equal(isRetryableSendError({ httpStatus: 502 }), true);
|
|
});
|
|
|
|
test('not retryable: client errors, consent, unknown or missing errors', () => {
|
|
assert.equal(isRetryableSendError({ httpStatus: 403, errcode: 'M_FORBIDDEN' }), false);
|
|
assert.equal(isRetryableSendError({ httpStatus: 403, errcode: 'M_CONSENT_NOT_GIVEN' }), false);
|
|
assert.equal(isRetryableSendError({ httpStatus: 400 }), false);
|
|
assert.equal(isRetryableSendError(new Error('encryption failed')), false);
|
|
assert.equal(isRetryableSendError(undefined), false);
|
|
assert.equal(isRetryableSendError(null), false);
|
|
});
|
|
|
|
test('parse: only this user, only well-formed entries, junk tolerated', () => {
|
|
const good = entry('m1', 1);
|
|
const raw = JSON.stringify({ userId: '@me:hs', entries: [good, { txnId: 5 }, null] });
|
|
assert.deepEqual(parseOutbox(raw, '@me:hs'), [good]);
|
|
assert.deepEqual(parseOutbox(raw, '@other:hs'), []);
|
|
assert.deepEqual(parseOutbox('{not json', '@me:hs'), []);
|
|
assert.deepEqual(parseOutbox(null, '@me:hs'), []);
|
|
});
|
|
|
|
test('withEntry: first write wins, sorted, capped (oldest dropped)', () => {
|
|
const a = entry('a', 2);
|
|
let list = withEntry([], a);
|
|
assert.equal(withEntry(list, { ...a, content: { body: 'changed' } }), list);
|
|
list = withEntry(list, entry('b', 1));
|
|
assert.deepEqual(
|
|
list.map((e) => e.txnId),
|
|
['b', 'a'],
|
|
);
|
|
let many: OutboxEntry[] = [];
|
|
for (let i = 0; i < OUTBOX_MAX_ENTRIES + 5; i += 1) many = withEntry(many, entry(`t${i}`, i));
|
|
assert.equal(many.length, OUTBOX_MAX_ENTRIES);
|
|
assert.equal(many[0].txnId, 't5');
|
|
});
|
|
|
|
test('withoutEntry: removes, and returns the same list when absent', () => {
|
|
const list = [entry('a', 1), entry('b', 2)];
|
|
assert.deepEqual(
|
|
withoutEntry(list, 'a').map((e) => e.txnId),
|
|
['b'],
|
|
);
|
|
assert.equal(withoutEntry(list, 'zzz'), list);
|
|
});
|
|
|
|
test('restore plan: drops delivered / left rooms, auto-sends only recent ones', () => {
|
|
const now = 10 * OUTBOX_AUTOSEND_MS;
|
|
const recent = entry('recent', now - 60_000);
|
|
const old = entry('old', now - OUTBOX_AUTOSEND_MS - 1);
|
|
const delivered = entry('delivered', now - 1000);
|
|
const left = entry('left', now - 1000, '!left:hs');
|
|
const plan = planRestore(
|
|
[recent, left, old, delivered],
|
|
now,
|
|
(roomId) => roomId !== '!left:hs',
|
|
(e) => e.txnId === 'delivered',
|
|
);
|
|
assert.deepEqual(
|
|
plan.restore.map((e) => e.txnId),
|
|
['old', 'recent'],
|
|
);
|
|
assert.deepEqual([...plan.autoSend], ['recent']);
|
|
assert.deepEqual(plan.drop.map((e) => e.txnId).sort(), ['delivered', 'left']);
|
|
});
|
|
|
|
test('blip retry delay backs off and is capped at a minute', () => {
|
|
assert.deepEqual(
|
|
[0, 1, 2, 3, 4, 9].map(outboxRetryDelayMs),
|
|
[5000, 10000, 20000, 40000, 60000, 60000],
|
|
);
|
|
});
|