Consumer-side tests for #123: RoomOutbox as rendered; hung send to watchdog reload to resend #124
5 changed files with 434 additions and 0 deletions
102
src/app/features/room/RoomOutbox.actions.test.tsx
Normal file
102
src/app/features/room/RoomOutbox.actions.test.tsx
Normal 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' });
|
||||
});
|
||||
});
|
||||
64
src/app/features/room/RoomOutbox.render.test.tsx
Normal file
64
src/app/features/room/RoomOutbox.render.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
148
src/client/stallRecovery.test.ts
Normal file
148
src/client/stallRecovery.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
115
src/client/startClient.test.ts
Normal file
115
src/client/startClient.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue