From 2a1f2c6b172d63c569bae7260b1b9716204ed891 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:02:08 +0000 Subject: [PATCH 1/8] Consumer-side tests: RoomOutbox as rendered; hung send to watchdog reload to resend Co-Authored-By: Claude Sonnet 5.5 --- .../features/room/RoomOutbox.render.test.tsx | 64 +++++++++++++++++ src/client/stallRecovery.test.ts | 70 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/app/features/room/RoomOutbox.render.test.tsx create mode 100644 src/client/stallRecovery.test.ts diff --git a/src/app/features/room/RoomOutbox.render.test.tsx b/src/app/features/room/RoomOutbox.render.test.tsx new file mode 100644 index 00000000..e4c258df --- /dev/null +++ b/src/app/features/room/RoomOutbox.render.test.tsx @@ -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(); + 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()).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(); + 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(); + 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(); + expect(text(html)).toContain('Interrupted by a reload'); + expect(text(html)).toContain('Resend'); + }); +}); diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts new file mode 100644 index 00000000..bbeefd63 --- /dev/null +++ b/src/client/stallRecovery.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { 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 = () => { + const m = new Map(); + return { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }; +}; + +describe('a send that hangs on a dead connection', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('localStorage', memoryStore()); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('is kept in the outbox, reloads the page once after the stall, and is resent under the same txn id', async () => { + const hung = vi.fn(() => new Promise(() => {})); + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'txn-1', + sendEvent: hung, + getRoom: () => undefined, + } as never; + const reload = vi.fn(); + const session = memoryStore(); + const content = { msgtype: 'm.text', body: 'hello from a dead connection' }; + + const stop = startWatchdog({ + pendingAges: () => ownPendingAges('@a:x'), + isVisible: () => true, + isOnline: () => true, + recover: () => reloadToRecoverSync(Date.now(), () => session, reload), + }); + // intentionally never settles: the request is hung + sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', 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 entry is older than the page, the network works again + const send = vi.fn().mockResolvedValue({ event_id: '$e' }); + const reloaded = { ...mx, sendEvent: send } as never; + await resendInterrupted(reloaded, Date.now() + 1); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-1'); + expect(loadOutbox('@a:x')).toEqual([]); + }); +}); -- 2.47.3 From 76c1f56bd8ec1d3b7d3adb5d1e8f995e26d8d682 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:02:17 +0000 Subject: [PATCH 2/8] stallRecovery test: lint --- src/client/stallRecovery.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index bbeefd63..aae6f2fe 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -30,7 +30,7 @@ describe('a send that hangs on a dead connection', () => { }); it('is kept in the outbox, reloads the page once after the stall, and is resent under the same txn id', async () => { - const hung = vi.fn(() => new Promise(() => {})); + const hung = vi.fn(() => new Promise(() => undefined)); const mx = { getUserId: () => '@a:x', makeTxnId: () => 'txn-1', -- 2.47.3 From 9d1d39922c81d999ec0cdeb698e4f1a42f0d8101 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:02:33 +0000 Subject: [PATCH 3/8] stallRecovery test: lint clean --- src/client/stallRecovery.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index aae6f2fe..f2f35bf0 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -30,7 +30,12 @@ describe('a send that hangs on a dead connection', () => { }); it('is kept in the outbox, reloads the page once after the stall, and is resent under the same txn id', async () => { - const hung = vi.fn(() => new Promise(() => undefined)); + const hung = vi.fn( + () => + new Promise(() => { + // never settles + }) + ); const mx = { getUserId: () => '@a:x', makeTxnId: () => 'txn-1', -- 2.47.3 From 09b062d7be2b09a4ec3d0eca739369884b72c25e Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:25:35 +0000 Subject: [PATCH 4/8] stallRecovery test: distinct txn ids per send; pin visible/online guards and own-tab pending filter; fix TS2698 Co-Authored-By: Claude Sonnet 5.5 --- src/client/stallRecovery.test.ts | 121 ++++++++++++++++++++++--------- 1 file changed, 85 insertions(+), 36 deletions(-) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index f2f35bf0..39ebb2b8 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -1,60 +1,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadOutbox } from '../app/state/room/composerStore'; +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 = () => { - const m = new Map(); - return { - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - removeItem: (k: string) => { - m.delete(k); - }, - }; -}; +const memoryStore = (m = new Map()) => ({ + 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; beforeEach(() => { vi.useFakeTimers(); - vi.stubGlobal('localStorage', memoryStore()); + storage = new Map(); + vi.stubGlobal('localStorage', memoryStore(storage)); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); - it('is kept in the outbox, reloads the page once after the stall, and is resent under the same txn id', async () => { - const hung = vi.fn( - () => - new Promise(() => { - // never settles - }) - ); - const mx = { + // 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: () => 'txn-1', - sendEvent: hung, + makeTxnId: () => { + n += 1; + return `txn-${n}`; + }, + sendEvent: vi.fn(() => new Promise(() => undefined)), // never settles: the request is hung getRoom: () => undefined, - } as never; + }; + }; + const watch = (env: { visible: boolean; online: boolean }) => { const reload = vi.fn(); const session = memoryStore(); - const content = { msgtype: 'm.text', body: 'hello from a dead connection' }; - const stop = startWatchdog({ pendingAges: () => ownPendingAges('@a:x'), - isVisible: () => true, - isOnline: () => true, + isVisible: () => env.visible, + isOnline: () => env.online, recover: () => reloadToRecoverSync(Date.now(), () => session, reload), }); - // intentionally never settles: the request is hung - sendTracked(mx, '!r:x', content); - expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending' }]); + 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(); @@ -65,11 +79,46 @@ describe('a send that hangs on a dead connection', () => { expect(reload).toHaveBeenCalledTimes(1); stop(); - // the reloaded page: the entry is older than the page, the network works again + // 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 } as never; - await resendInterrupted(reloaded, Date.now() + 1); - expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-1'); + 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.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(); + }); }); -- 2.47.3 From fab6a04894ceff2972ed5dbd75de7b72c94672bd Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:25:46 +0000 Subject: [PATCH 5/8] stallRecovery test: lint clean Co-Authored-By: Claude Sonnet 5.5 --- src/client/stallRecovery.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index 39ebb2b8..148ebb31 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -40,7 +40,12 @@ describe('a send that hangs on a dead connection', () => { n += 1; return `txn-${n}`; }, - sendEvent: vi.fn(() => new Promise(() => undefined)), // never settles: the request is hung + sendEvent: vi.fn( + () => + new Promise(() => { + // never settles: the request is hung + }) + ), getRoom: () => undefined, }; }; -- 2.47.3 From e04c7c074f48a879122466cdec3851e294619f1c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:48:49 +0000 Subject: [PATCH 6/8] stallRecovery test: a held SDK echo makes the resend use a fresh txn id Co-Authored-By: Claude Sonnet 5.5 --- src/client/stallRecovery.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index 148ebb31..ae1e5217 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -94,6 +94,25 @@ describe('a send that hangs on a dead connection', () => { 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 }], -- 2.47.3 From da9ef70c03b336bd1e636ff9a5bb08220a8159a7 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 04:04:45 +0000 Subject: [PATCH 7/8] Consumer tests: startClient wiring, Resend/Discard handlers, the 30 s slow-send threshold Co-Authored-By: Claude Sonnet 5.5 --- .../features/room/RoomOutbox.actions.test.tsx | 94 ++++++++++++++ src/app/features/room/RoomOutbox.test.ts | 5 + src/client/startClient.test.ts | 115 ++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 src/app/features/room/RoomOutbox.actions.test.tsx create mode 100644 src/client/startClient.test.ts diff --git a/src/app/features/room/RoomOutbox.actions.test.tsx b/src/app/features/room/RoomOutbox.actions.test.tsx new file mode 100644 index 00000000..c762220f --- /dev/null +++ b/src/app/features/room/RoomOutbox.actions.test.tsx @@ -0,0 +1,94 @@ +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(); + 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(); + 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(); + 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' + ); + }); +}); diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts index 55597694..30d7c0bb 100644 --- a/src/app/features/room/RoomOutbox.test.ts +++ b/src/app/features/room/RoomOutbox.test.ts @@ -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/); }); diff --git a/src/client/startClient.test.ts b/src/client/startClient.test.ts new file mode 100644 index 00000000..11b488e3 --- /dev/null +++ b/src/client/startClient.test.ts @@ -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()), + startWatchdog: vi.fn(() => () => undefined), +})); + +const memoryStore = (m = new Map()) => ({ + 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 & { + sendEvent: ReturnType; + off: ReturnType; + }; + 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).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'); + }); +}); -- 2.47.3 From ae3760fcc79e8bed793e5a806ffb574ed44214e8 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 04:11:15 +0000 Subject: [PATCH 8/8] RoomOutbox actions test: Resend keeps the entry in the outbox, pending again Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomOutbox.actions.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/features/room/RoomOutbox.actions.test.tsx b/src/app/features/room/RoomOutbox.actions.test.tsx index c762220f..0dbd4f5d 100644 --- a/src/app/features/room/RoomOutbox.actions.test.tsx +++ b/src/app/features/room/RoomOutbox.actions.test.tsx @@ -81,6 +81,12 @@ describe('RoomOutbox actions', () => { 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 settles + }) + ); clickOf(tree, 'Resend')(); await Promise.resolve(); expect(mx.sendEvent).toHaveBeenCalledTimes(1); @@ -90,5 +96,7 @@ describe('RoomOutbox actions', () => { { 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' }); }); }); -- 2.47.3