Consumer-side tests for #123: RoomOutbox as rendered; hung send to watchdog reload to resend #124

Merged
void merged 8 commits from void/stall-consumer-tests into dev 2026-11-13 04:11:33 +00:00
5 changed files with 434 additions and 0 deletions

View file

@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { enqueueOutbox, failOutbox, loadOutbox } from '../../state/room/composerStore';
import { RoomOutbox } from './RoomOutbox';
// The component's hooks are held still (state is its initial value, effects do not run) so the element tree
// it returns can be read and its click handlers called without a DOM.
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>();
const useState = (init: unknown) => [
typeof init === 'function' ? (init as () => unknown)() : init,
() => undefined,
];
return {
...actual,
default: { ...actual, useState, useEffect: () => undefined },
useState,
useEffect: () => undefined,
};
});
type El = { props?: { children?: unknown; onClick?: () => void } };
const flatten = (node: unknown): El[] => {
if (Array.isArray(node)) return node.flatMap(flatten);
if (node && typeof node === 'object') {
const el = node as El;
return [el, ...flatten(el.props?.children)];
}
return [];
};
const textOf = (node: unknown): string => {
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textOf).join('');
if (node && typeof node === 'object') return textOf((node as El).props?.children);
return '';
};
/** The onClick of the chip whose label is `label`. */
const clickOf = (tree: unknown, label: string) => {
const chip = flatten(tree).find((e) => e.props?.onClick && textOf(e) === label);
if (!chip?.props?.onClick) throw new Error(`no ${label} chip`);
return chip.props.onClick;
};
describe('RoomOutbox actions', () => {
beforeEach(() => {
const m = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => m.get(k) ?? null,
setItem: (k: string, v: string) => {
m.set(k, v);
},
removeItem: (k: string) => {
m.delete(k);
},
});
enqueueOutbox('@a:x', { txnId: 'keep', roomId: '!r:x', content: { body: 'stay' } });
enqueueOutbox('@a:x', { txnId: 'bad', roomId: '!r:x', content: { body: 'did not go' } });
failOutbox('@a:x', 'bad', 'ECONNRESET');
});
afterEach(() => {
vi.unstubAllGlobals();
});
const mount = () => {
const mx = {
getUserId: () => '@a:x',
makeTxnId: () => 'txn-new',
getRoom: () => undefined,
sendEvent: vi.fn().mockResolvedValue({ event_id: '$e' }),
};
const tree = (RoomOutbox as unknown as (p: unknown) => unknown)({ mx, roomId: '!r:x' });
return { mx, tree };
};
it('Discard removes that message from the outbox and no other', () => {
const { mx, tree } = mount();
// both entries are shown; the failed one is the one with Discard
clickOf(tree, 'Discard')();
expect(loadOutbox('@a:x').map((e) => e.txnId)).toEqual(['keep']);
expect(mx.sendEvent).not.toHaveBeenCalled();
});
it('Resend sends that message again and does not discard it first', async () => {
const { mx, tree } = mount();
// the server has not answered yet: the resend is in flight
mx.sendEvent.mockReturnValue(
new Promise<never>(() => {
// never settles
})
);
clickOf(tree, 'Resend')();
await Promise.resolve();
expect(mx.sendEvent).toHaveBeenCalledTimes(1);
expect(mx.sendEvent).toHaveBeenCalledWith(
'!r:x',
'm.room.message',
{ body: 'did not go' },
'bad'
);
// still in the outbox, pending again under the same id, until the server confirms it
expect(loadOutbox('@a:x').find((e) => e.txnId === 'bad')).toMatchObject({ status: 'pending' });
});
});

View file

@ -0,0 +1,64 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { enqueueOutbox, failOutbox } from '../../state/room/composerStore';
import { RoomOutbox } from './RoomOutbox';
// What the person sees under the timeline for sends the server has not confirmed.
const text = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
describe('RoomOutbox as rendered', () => {
beforeEach(() => {
const m = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => m.get(k) ?? null,
setItem: (k: string, v: string) => {
m.set(k, v);
},
removeItem: (k: string) => {
m.delete(k);
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const mx = { getUserId: () => '@a:x' } as never;
const entry = (txnId: string, roomId: string, body: string) => ({
txnId,
roomId,
content: { body },
});
it('renders nothing when the room has no unconfirmed sends', () => {
enqueueOutbox('@a:x', entry('t1', '!other:x', 'elsewhere'));
expect(renderToStaticMarkup(<RoomOutbox mx={mx} roomId="!r:x" />)).toBe('');
});
it('shows a fresh send as Sending with its text and no Resend', () => {
enqueueOutbox('@a:x', entry('t1', '!r:x', 'on its way'), Date.now() + 1000);
const html = renderToStaticMarkup(<RoomOutbox mx={mx} roomId="!r:x" />);
expect(html).toContain('data-outbox-status="pending"');
expect(text(html)).toContain('Sending…');
expect(text(html)).toContain('on its way');
expect(text(html)).not.toContain('Resend');
});
it('shows a failed send with its error, Resend and Discard', () => {
enqueueOutbox('@a:x', entry('t2', '!r:x', 'did not go'));
failOutbox('@a:x', 't2', 'ECONNRESET');
const html = renderToStaticMarkup(<RoomOutbox mx={mx} roomId="!r:x" />);
expect(html).toContain('data-outbox-status="failed"');
expect(text(html)).toContain('Not sent: ECONNRESET');
expect(text(html)).toContain('Resend');
expect(text(html)).toContain('Discard');
});
it('shows a send left over from before this page load as interrupted, resendable', () => {
enqueueOutbox('@a:x', entry('t3', '!r:x', 'lost to a reload'), 5);
const html = renderToStaticMarkup(<RoomOutbox mx={mx} roomId="!r:x" />);
expect(text(html)).toContain('Interrupted by a reload');
expect(text(html)).toContain('Resend');
});
});

View file

@ -17,6 +17,11 @@ describe('outboxView', () => {
});
});
it('offers Resend to a pending send at 30 s and not a millisecond before', () => {
const at = (ms: number) => outboxView({ status: 'pending', ts: 100 }, 100 + ms, 0).canResend;
expect([at(29_999), at(30_000)]).toEqual([false, true]);
});
it('an entry older than this page load reads as interrupted', () => {
expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/);
});

View file

@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PAGE_ID, loadOutbox } from '../app/state/room/composerStore';
import { resendInterrupted, sendTracked } from '../app/state/room/outboxSend';
import { RECOVER_RELOAD_MIN_GAP_MS, ownPendingAges, reloadToRecoverSync } from './initMatrix';
import { SEND_STALL_MS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog';
// The dead-connection story from the user's side: a send hangs, the page notices and reloads
// itself once, and the message goes out again from the outbox under its own txn id.
const memoryStore = (m = new Map<string, string>()) => ({
getItem: (k: string) => m.get(k) ?? null,
setItem: (k: string, v: string) => {
m.set(k, v);
},
removeItem: (k: string) => {
m.delete(k);
},
});
const OUTBOX_KEY = 'ratatoskr.outbox.@a:x';
describe('a send that hangs on a dead connection', () => {
let storage: Map<string, string>;
beforeEach(() => {
vi.useFakeTimers();
storage = new Map();
vi.stubGlobal('localStorage', memoryStore(storage));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
// Every send gets its own txn id, as the SDK's makeTxnId gives; a client that hands out one
// constant id could not tell a resend of the right message from a resend of the wrong one.
const hungClient = () => {
let n = 0;
return {
getUserId: () => '@a:x',
makeTxnId: () => {
n += 1;
return `txn-${n}`;
},
sendEvent: vi.fn(
() =>
new Promise<never>(() => {
// never settles: the request is hung
})
),
getRoom: () => undefined,
};
};
const watch = (env: { visible: boolean; online: boolean }) => {
const reload = vi.fn();
const session = memoryStore();
const stop = startWatchdog({
pendingAges: () => ownPendingAges('@a:x'),
isVisible: () => env.visible,
isOnline: () => env.online,
recover: () => reloadToRecoverSync(Date.now(), () => session, reload),
});
return { reload, stop };
};
const stalled = () => vi.advanceTimersByTime(SEND_STALL_MS + 2 * WATCHDOG_INTERVAL_MS);
it('is kept in the outbox, reloads the page once after the stall, and each message is resent under its own txn id', async () => {
const mx = hungClient();
const first = { msgtype: 'm.text', body: 'hello from a dead connection' };
const second = { msgtype: 'm.text', body: 'and a second one' };
const { reload, stop } = watch({ visible: true, online: true });
sendTracked(mx as never, '!r:x', first);
sendTracked(mx as never, '!q:x', second);
expect(loadOutbox('@a:x')).toMatchObject([
{ txnId: 'txn-1', status: 'pending' },
{ txnId: 'txn-2', status: 'pending' },
]);
vi.advanceTimersByTime(SEND_STALL_MS - WATCHDOG_INTERVAL_MS);
expect(reload).not.toHaveBeenCalled();
vi.advanceTimersByTime(2 * WATCHDOG_INTERVAL_MS);
expect(reload).toHaveBeenCalledTimes(1);
// the stall persists across the reload guard window: no reload loop
vi.advanceTimersByTime(RECOVER_RELOAD_MIN_GAP_MS - 2 * WATCHDOG_INTERVAL_MS);
expect(reload).toHaveBeenCalledTimes(1);
stop();
// the reloaded page: the entries are older than the page, the network works again
const send = vi.fn().mockResolvedValue({ event_id: '$e' });
const reloaded = { ...mx, sendEvent: send };
await resendInterrupted(reloaded as never, Date.now() + 1);
expect(send).toHaveBeenCalledTimes(2);
expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', first, 'txn-1');
expect(send).toHaveBeenCalledWith('!q:x', 'm.room.message', second, 'txn-2');
expect(loadOutbox('@a:x')).toEqual([]);
});
it('resends under a fresh txn id when the SDK still holds an echo for the old one', async () => {
const mx = hungClient();
const content = { msgtype: 'm.text', body: 'echo still held' };
sendTracked(mx as never, '!r:x', content);
expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1' }]);
const send = vi.fn().mockResolvedValue({ event_id: '$e' });
const held = {
...mx,
makeTxnId: () => 'txn-fresh',
sendEvent: send,
getRoom: () => ({ getEventForTxnId: (id: string) => (id === 'txn-1' ? {} : undefined) }),
};
await resendInterrupted(held as never, Date.now() + 1);
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-fresh');
expect(loadOutbox('@a:x')).toEqual([]);
});
it.each([
['hidden', { visible: false, online: true }],
['offline', { visible: true, online: false }],
])('does not reload a stalled page while it is %s, and does once it is back', (_name, env) => {
const mx = hungClient();
const state = { ...env };
const { reload, stop } = watch(state);
sendTracked(mx as never, '!r:x', { body: 'x' });
stalled();
vi.advanceTimersByTime(10 * SEND_STALL_MS);
expect(reload).not.toHaveBeenCalled();
state.visible = true;
state.online = true;
vi.advanceTimersByTime(2 * WATCHDOG_INTERVAL_MS);
expect(reload).toHaveBeenCalledTimes(1);
stop();
});
it('does not reload for a send that belongs to another tab, or one that already failed', () => {
const mx = hungClient();
const { reload, stop } = watch({ visible: true, online: true });
sendTracked(mx as never, '!r:x', { body: 'theirs' });
sendTracked(mx as never, '!r:x', { body: 'failed' });
const entries = JSON.parse(storage.get(OUTBOX_KEY) as string);
entries[0].tab = `${PAGE_ID}-other`;
entries[1].status = 'failed';
storage.set(OUTBOX_KEY, JSON.stringify(entries));
stalled();
vi.advanceTimersByTime(10 * SEND_STALL_MS);
expect(reload).not.toHaveBeenCalled();
stop();
});
});

View file

@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { enqueueOutbox } from '../app/state/room/composerStore';
import { PAGE_START } from '../app/state/room/outboxSend';
import { startClient } from './initMatrix';
import { startResumeSync } from './resumeSync';
import { startWatchdog } from './watchdog';
vi.mock('./resumeSync', () => ({ startResumeSync: vi.fn() }));
vi.mock('./watchdog', async (importOriginal) => ({
...(await importOriginal<typeof import('./watchdog')>()),
startWatchdog: vi.fn(() => () => undefined),
}));
const memoryStore = (m = new Map<string, string>()) => ({
getItem: (k: string) => m.get(k) ?? null,
setItem: (k: string, v: string) => {
m.set(k, v);
},
removeItem: (k: string) => {
m.delete(k);
},
});
// startClient is the one place the dead-connection pieces are wired to the page: what the watchdog reads,
// when it may fire, what it does, and what runs after the reload. These tests call it and exercise that wiring.
describe('startClient wiring', () => {
let handlers: Array<(state: string) => void>;
let mx: Record<string, unknown> & {
sendEvent: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.clearAllMocks();
handlers = [];
vi.stubGlobal('localStorage', memoryStore());
mx = {
startClient: vi.fn().mockResolvedValue(undefined),
getUserId: () => '@a:x',
makeTxnId: () => 'txn-new',
getRoom: () => undefined,
sendEvent: vi.fn().mockResolvedValue({ event_id: '$e' }),
on: (_ev: string, h: (state: string) => void) => {
handlers.push(h);
},
off: vi.fn(),
};
});
afterEach(() => {
vi.unstubAllGlobals();
});
const watchdogOptions = () =>
(startWatchdog as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as Parameters<
typeof startWatchdog
>[0];
it('starts sync, the resume nudge and the watchdog once per client', async () => {
await startClient(mx as never);
await startClient(mx as never);
expect(mx.startClient).toHaveBeenCalledTimes(2);
expect(startResumeSync).toHaveBeenCalledTimes(1);
expect(startResumeSync).toHaveBeenCalledWith(mx);
expect(startWatchdog).toHaveBeenCalledTimes(1);
});
it("feeds the watchdog this page's pending sends for this user", async () => {
await startClient(mx as never);
const { pendingAges } = watchdogOptions();
expect(pendingAges()).toEqual([]);
enqueueOutbox('@a:x', { txnId: 't', roomId: '!r:x', content: {} }, Date.now() - 7000);
enqueueOutbox('@b:x', { txnId: 'u', roomId: '!r:x', content: {} }, Date.now() - 1000);
const ages = pendingAges();
expect(ages).toHaveLength(1);
expect(ages[0]).toBeGreaterThanOrEqual(7000);
});
it('reads visibility and connectivity from the page', async () => {
const doc = { visibilityState: 'visible' };
const nav = { onLine: true };
vi.stubGlobal('document', doc);
vi.stubGlobal('navigator', nav);
await startClient(mx as never);
const { isVisible, isOnline } = watchdogOptions();
expect([isVisible(), isOnline()]).toEqual([true, true]);
doc.visibilityState = 'hidden';
nav.onLine = false;
expect([isVisible(), isOnline()]).toEqual([false, false]);
});
it('recovers by a guarded reload, at most once a minute', async () => {
const reload = vi.fn();
vi.stubGlobal('window', { location: { reload } });
vi.stubGlobal('sessionStorage', memoryStore());
await startClient(mx as never);
const { recover } = watchdogOptions();
recover();
recover();
expect(reload).toHaveBeenCalledTimes(1);
});
it('resends what a reload interrupted once sync is up, and only once', async () => {
enqueueOutbox(
'@a:x',
{ txnId: 'old', roomId: '!r:x', content: { body: 'x' } },
PAGE_START - 5000
);
await startClient(mx as never);
expect(handlers).toHaveLength(1);
handlers[0]('CONNECTING');
expect(mx.sendEvent).not.toHaveBeenCalled();
handlers[0]('SYNCING');
expect(mx.off).toHaveBeenCalledWith('sync', handlers[0]);
expect(mx.sendEvent).toHaveBeenCalledWith('!r:x', 'm.room.message', { body: 'x' }, 'old');
});
});