add tests for the chat messages event bus
Covers error recovery (re-init when no cursor was ever seeded, resume from the existing cursor after a poll failure), subscriber exception isolation, rev filtering and cursor advancement, and poll lifecycle across background/suspend/resume and requested poll intervals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,504 @@
|
|||||||
|
import {type AtpAgent, type ChatBskyConvoGetLog} from '@atproto/api'
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals'
|
||||||
|
|
||||||
|
import {MessagesEventBus} from '#/state/messages/events/agent'
|
||||||
|
import {
|
||||||
|
BACKGROUND_POLL_INTERVAL,
|
||||||
|
DEFAULT_POLL_INTERVAL,
|
||||||
|
} from '#/state/messages/events/const'
|
||||||
|
import {
|
||||||
|
MessagesEventBusErrorCode,
|
||||||
|
type MessagesEventBusEvent,
|
||||||
|
} from '#/state/messages/events/types'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tests for MessagesEventBus, the poll-based DM firehose. These focus on the
|
||||||
|
* lifecycle/state-machine and the two recent fixes on this branch:
|
||||||
|
* 1. error-state recovery that re-inits when no cursor was ever seeded, and
|
||||||
|
* resumes cursor-aware when a poll failed mid-session
|
||||||
|
* 2. log emission moved outside poll()'s try/catch so a throwing subscriber
|
||||||
|
* is not misreported as a poll failure
|
||||||
|
*
|
||||||
|
* We drive the async poll loop with fake timers. init() and poll() are async,
|
||||||
|
* so after advancing timers we flush microtasks. networkRetry(2, fn) means 2
|
||||||
|
* total attempts, so a failing call must reject twice in a row, and the
|
||||||
|
* rejection must classify as a network error (string includes "Network request
|
||||||
|
* failed") so the bus does not log an unexpected error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type GetLogFn = jest.MockedFunction<
|
||||||
|
(
|
||||||
|
params?: {cursor?: string},
|
||||||
|
opts?: unknown,
|
||||||
|
) => Promise<{data: ChatBskyConvoGetLog.OutputSchema}>
|
||||||
|
>
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A network error per isNetworkError() in #/lib/strings/errors - matched
|
||||||
|
* substring is "Network request failed".
|
||||||
|
*/
|
||||||
|
function networkError() {
|
||||||
|
return new Error('Network request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Minimal log fixture. The bus only checks `'rev' in ev` and reads ev.rev /
|
||||||
|
* ev.convoId, so we keep these tiny. Revs are zero-padded so lexical compare
|
||||||
|
* matches numeric order.
|
||||||
|
*/
|
||||||
|
function logEvent(rev: string, convoId = 'abc') {
|
||||||
|
return {
|
||||||
|
$type: 'chat.bsky.convo.defs#logCreateMessage',
|
||||||
|
rev,
|
||||||
|
convoId,
|
||||||
|
} as unknown as ChatBskyConvoGetLog.OutputSchema['logs'][number]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogResponse(
|
||||||
|
cursor: string | undefined,
|
||||||
|
logs: ChatBskyConvoGetLog.OutputSchema['logs'] = [],
|
||||||
|
) {
|
||||||
|
return {data: {cursor, logs}}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The constructor synchronously calls init(), which invokes getLog() before we
|
||||||
|
* get a chance to configure per-call mocks. So callers pass the desired init
|
||||||
|
* behavior up front: either a resolved getLog response, or `'fail'` to reject
|
||||||
|
* twice (networkRetry attempts) and force InitFailed.
|
||||||
|
*/
|
||||||
|
function createBus(
|
||||||
|
init: {data: ChatBskyConvoGetLog.OutputSchema} | 'fail' = getLogResponse(
|
||||||
|
'0000005',
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
const getLog = jest.fn() as GetLogFn
|
||||||
|
if (init === 'fail') {
|
||||||
|
getLog.mockRejectedValueOnce(networkError())
|
||||||
|
getLog.mockRejectedValueOnce(networkError())
|
||||||
|
} else {
|
||||||
|
// init response, then a benign persistent default so the immediate
|
||||||
|
// post-init poll (from resetPoll -> startPoll) does not fail. Tests queue
|
||||||
|
// mockResolvedValueOnce for specific poll responses before advancing time.
|
||||||
|
getLog.mockResolvedValueOnce(init)
|
||||||
|
getLog.mockResolvedValue(getLogResponse(init.data.cursor, []))
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = {
|
||||||
|
chat: {
|
||||||
|
bsky: {
|
||||||
|
convo: {
|
||||||
|
getLog,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as AtpAgent
|
||||||
|
|
||||||
|
const bus = new MessagesEventBus({agent})
|
||||||
|
const events: MessagesEventBusEvent[] = []
|
||||||
|
const unsub = bus.on(e => events.push(e), {convoId: undefined})
|
||||||
|
|
||||||
|
return {bus, getLog, events, unsub}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Flush queued microtasks. init()/poll() chain a few awaits (networkRetry, then
|
||||||
|
* response handling), so flush a handful of times to settle them.
|
||||||
|
*/
|
||||||
|
async function flushMicrotasks(times = 10) {
|
||||||
|
for (let i = 0; i < times; i++) {
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The constructor synchronously kicks off init(); settle it (and its first
|
||||||
|
* synchronous poll, which happens inside resetPoll -> startPoll) before
|
||||||
|
* assertions.
|
||||||
|
*/
|
||||||
|
async function settle() {
|
||||||
|
await flushMicrotasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastCursorArg(getLog: GetLogFn): string | undefined {
|
||||||
|
const call = getLog.mock.calls.at(-1)
|
||||||
|
return call?.[0]?.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTypes(events: MessagesEventBusEvent[]) {
|
||||||
|
return events.map(e => e.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorEvents(events: MessagesEventBusEvent[]) {
|
||||||
|
return events.filter(
|
||||||
|
(e): e is Extract<MessagesEventBusEvent, {type: 'error'}> =>
|
||||||
|
e.type === 'error',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logsEvents(events: MessagesEventBusEvent[]) {
|
||||||
|
return events.filter(
|
||||||
|
(e): e is Extract<MessagesEventBusEvent, {type: 'logs'}> =>
|
||||||
|
e.type === 'logs',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(`#/state/messages/events`, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllTimers()
|
||||||
|
jest.useRealTimers()
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`regression: error recovery`, () => {
|
||||||
|
it(`zombie firehose - failed init re-inits (without cursor) on resume, then polls with seeded cursor`, async () => {
|
||||||
|
// init() runs networkRetry(2): reject twice -> InitFailed
|
||||||
|
const {bus, getLog, events} = createBus('fail')
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
const errs = errorEvents(events)
|
||||||
|
expect(errs).toHaveLength(1)
|
||||||
|
expect(errs[0].error.code).toBe(MessagesEventBusErrorCode.InitFailed)
|
||||||
|
|
||||||
|
const callsAfterFailedInit = getLog.mock.calls.length
|
||||||
|
|
||||||
|
// Recovery: latestRev is still undefined, so resume() must re-run init()
|
||||||
|
// WITHOUT a cursor (the old bug went straight to Ready and polled with an
|
||||||
|
// undefined cursor forever).
|
||||||
|
getLog.mockResolvedValueOnce(getLogResponse('0000005', []))
|
||||||
|
bus.resume()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
const reinitCall = getLog.mock.calls[callsAfterFailedInit]
|
||||||
|
expect(reinitCall).toBeDefined()
|
||||||
|
// re-init is cursor-less
|
||||||
|
expect((reinitCall[0] as {cursor?: string}).cursor).toBeUndefined()
|
||||||
|
|
||||||
|
// success path dispatches Ready -> emits connect
|
||||||
|
expect(eventTypes(events)).toContain('connect')
|
||||||
|
|
||||||
|
// subsequent poll uses the seeded cursor
|
||||||
|
getLog.mockResolvedValueOnce(getLogResponse('0000005', []))
|
||||||
|
await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL)
|
||||||
|
await flushMicrotasks()
|
||||||
|
expect(lastCursorArg(getLog)).toBe('0000005')
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`cursor preserved on poll-failure recovery via resume()`, async () => {
|
||||||
|
// init seeds latestRev '0000005' (default createBus response)
|
||||||
|
const {bus, getLog, events} = createBus()
|
||||||
|
await settle()
|
||||||
|
expect(eventTypes(events)).toContain('connect')
|
||||||
|
|
||||||
|
// next poll fails (reject twice) -> PollFailed
|
||||||
|
getLog.mockRejectedValueOnce(networkError())
|
||||||
|
getLog.mockRejectedValueOnce(networkError())
|
||||||
|
await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL)
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
const errs = errorEvents(events)
|
||||||
|
expect(errs).toHaveLength(1)
|
||||||
|
expect(errs[0].error.code).toBe(MessagesEventBusErrorCode.PollFailed)
|
||||||
|
|
||||||
|
const callsBeforeRecovery = getLog.mock.calls.length
|
||||||
|
|
||||||
|
// resume() should resume from the existing cursor, NOT re-init cursor-less
|
||||||
|
getLog.mockResolvedValueOnce(getLogResponse('0000005', []))
|
||||||
|
bus.resume()
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
const recoveryCall = getLog.mock.calls[callsBeforeRecovery]
|
||||||
|
expect(recoveryCall).toBeDefined()
|
||||||
|
expect((recoveryCall[0] as {cursor?: string}).cursor).toBe('0000005')
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`cursor preserved on poll-failure recovery via requestPollInterval() (UpdatePoll arm)`, async () => {
|
||||||
|
const {bus, getLog, events} = createBus()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
// poll fails -> PollFailed, bus enters Error state
|
||||||
|
getLog.mockRejectedValueOnce(networkError())
|
||||||
|
getLog.mockRejectedValueOnce(networkError())
|
||||||
|
await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL)
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
events.filter(
|
||||||
|
e =>
|
||||||
|
e.type === 'error' &&
|
||||||
|
e.error.code === MessagesEventBusErrorCode.PollFailed,
|
||||||
|
),
|
||||||
|
).toHaveLength(1)
|
||||||
|
|
||||||
|
const callsBeforeRecovery = getLog.mock.calls.length
|
||||||
|
|
||||||
|
// requestPollInterval dispatches UpdatePoll. In Error state that now
|
||||||
|
// routes to recoverFromError() which, with a cursor present, resumes
|
||||||
|
// cursor-aware. The OLD bug wiped latestRev and re-init'd cursor-less.
|
||||||
|
getLog.mockResolvedValueOnce(getLogResponse('0000005', []))
|
||||||
|
bus.requestPollInterval(1000)
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
const recoveryCall = getLog.mock.calls[callsBeforeRecovery]
|
||||||
|
expect(recoveryCall).toBeDefined()
|
||||||
|
expect((recoveryCall[0] as {cursor?: string}).cursor).toBe('0000005')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`regression: throwing subscriber`, () => {
|
||||||
|
it(`a throwing 'logs' subscriber does not emit error / fake PollFailed, and polling continues with advanced cursor`, async () => {
|
||||||
|
const getLog = jest.fn() as GetLogFn
|
||||||
|
// init seeds '0000001' (configure before construction - init() calls
|
||||||
|
// getLog synchronously), then a benign persistent default for the
|
||||||
|
// immediate post-init poll.
|
||||||
|
getLog.mockResolvedValueOnce(getLogResponse('0000001', []))
|
||||||
|
getLog.mockResolvedValue(getLogResponse('0000001', []))
|
||||||
|
const agent = {
|
||||||
|
chat: {bsky: {convo: {getLog}}},
|
||||||
|
} as unknown as AtpAgent
|
||||||
|
|
||||||
|
const bus = new MessagesEventBus({agent})
|
||||||
|
|
||||||
|
// Subscriber A throws on 'logs'. Subscriber B records all events.
|
||||||
|
const aReceived: MessagesEventBusEvent[] = []
|
||||||
|
const bReceived: MessagesEventBusEvent[] = []
|
||||||
|
bus.on(
|
||||||
|
e => {
|
||||||
|
aReceived.push(e)
|
||||||
|
if (e.type === 'logs') {
|
||||||
|
throw new Error('subscriber A boom')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{convoId: undefined},
|
||||||
|
)
|
||||||
|
bus.on(e => bReceived.push(e), {convoId: undefined})
|
||||||
|
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
// poll delivers a log with a higher rev -> needsEmit, emit throws via A
|
||||||
|
getLog.mockResolvedValueOnce(
|
||||||
|
getLogResponse('0000003', [logEvent('0000003')]),
|
||||||
|
)
|
||||||
|
await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL)
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
// No error event (no fake PollFailed) from the throwing subscriber.
|
||||||
|
expect(bReceived.filter(e => e.type === 'error')).toHaveLength(0)
|
||||||
|
expect(aReceived.filter(e => e.type === 'error')).toHaveLength(0)
|
||||||
|
|
||||||
|
// A received the logs event (and threw on it).
|
||||||
|
expect(aReceived.filter(e => e.type === 'logs')).toHaveLength(1)
|
||||||
|
|
||||||
|
// NOTE: eventemitter3 invokes handlers in registration order, and the
|
||||||
|
// bus wraps the WHOLE emit() in one try/catch. So A's throw aborts the
|
||||||
|
// emit before B is invoked - B does NOT receive that 'logs' event. This
|
||||||
|
// pins current behavior (not necessarily desirable).
|
||||||
|
expect(bReceived.filter(e => e.type === 'logs')).toHaveLength(0)
|
||||||
|
|
||||||
|
// The rev was consumed despite the throw, so the next poll uses the
|
||||||
|
// advanced cursor and the isPolling flag did not wedge.
|
||||||
|
getLog.mockResolvedValueOnce(getLogResponse('0000003', []))
|
||||||
|
await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL)
|
||||||
|
await flushMicrotasks()
|
||||||
|
expect(lastCursorArg(getLog)).toBe('0000003')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`core: polling behavior`, () => {
|
||||||
|
it(`happy path - seeds rev from cursor, emits only newer revs, advances to max, passes it next poll`, async () => {
|
||||||
|
// init seeds latestRev = '0000002'
|
||||||
|
const {getLog, events} = createBus(getLogResponse('0000002', []))
|
||||||
|
await settle()
|
||||||
|
expect(eventTypes(events)).toContain('connect')
|
||||||
|
|
||||||
|
// poll returns a stale (==), a stale (<), and two newer revs
|
||||||
|
getLog.mockResolvedValueOnce(
|
||||||
|
getLogResponse('0000004', [
|
||||||
|
logEvent('0000001'), // < latestRev, filtered
|
||||||
|
logEvent('0000002'), // == latestRev, filtered
|
||||||
|
logEvent('0000003'), // > latestRev, emitted
|
||||||
|
logEvent('0000004'), // > latestRev, emitted
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL)
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
const logEvents = logsEvents(events)
|
||||||
|
expect(logEvents).toHaveLength(1)
|
||||||
|
expect(logEvents[0].logs.map(l => (l as {rev: string}).rev)).toEqual([
|
||||||
|
'0000003',
|
||||||
|
'0000004',
|
||||||
|
])
|
||||||
|
|
||||||
|
// latestRev advanced to the max emitted rev -> next poll uses it
|
||||||
|
getLog.mockResolvedValueOnce(getLogResponse('0000004', []))
|
||||||
|
await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL)
|
||||||
|
await flushMicrotasks()
|
||||||
|
expect(lastCursorArg(getLog)).toBe('0000004')
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`no 'logs' event when all logs are stale (rev <= latestRev)`, async () => {
|
||||||
|
// init seeds latestRev = '0000005' (default)
|
||||||
|
const {getLog, events} = createBus()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
getLog.mockResolvedValueOnce(
|
||||||
|
getLogResponse('0000005', [logEvent('0000003'), logEvent('0000005')]),
|
||||||
|
)
|
||||||
|
await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL)
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
expect(events.filter(e => e.type === 'logs')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`logs without a rev field are skipped`, async () => {
|
||||||
|
// init seeds latestRev = '0000002'
|
||||||
|
const {getLog, events} = createBus(getLogResponse('0000002', []))
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
const noRev = {
|
||||||
|
$type: 'chat.bsky.convo.defs#logBeginConvo',
|
||||||
|
convoId: 'abc',
|
||||||
|
} as unknown as ChatBskyConvoGetLog.OutputSchema['logs'][number]
|
||||||
|
|
||||||
|
getLog.mockResolvedValueOnce(
|
||||||
|
getLogResponse('0000003', [noRev, logEvent('0000003')]),
|
||||||
|
)
|
||||||
|
await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL)
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
const logEvents = logsEvents(events)
|
||||||
|
expect(logEvents).toHaveLength(1)
|
||||||
|
// only the rev-bearing log survives
|
||||||
|
expect(logEvents[0].logs).toHaveLength(1)
|
||||||
|
expect((logEvents[0].logs[0] as {rev: string}).rev).toBe('0000003')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`core: lifecycle transitions`, () => {
|
||||||
|
it(`background()/resume() do not drop the cursor`, async () => {
|
||||||
|
const {bus, getLog} = createBus()
|
||||||
|
getLog.mockResolvedValue(getLogResponse('0000005', []))
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
// background uses the longer interval; poll still happens with cursor
|
||||||
|
bus.background()
|
||||||
|
await flushMicrotasks()
|
||||||
|
expect(lastCursorArg(getLog)).toBe('0000005')
|
||||||
|
|
||||||
|
// resume keeps the cursor
|
||||||
|
bus.resume()
|
||||||
|
await flushMicrotasks()
|
||||||
|
expect(lastCursorArg(getLog)).toBe('0000005')
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`suspend() stops polling; resume() restarts it`, async () => {
|
||||||
|
const {bus, getLog} = createBus()
|
||||||
|
|
||||||
|
getLog.mockResolvedValue(getLogResponse('0000005', []))
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
bus.suspend()
|
||||||
|
const callsAfterSuspend = getLog.mock.calls.length
|
||||||
|
|
||||||
|
// advance well past several intervals - no further polls
|
||||||
|
await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL * 3)
|
||||||
|
await flushMicrotasks()
|
||||||
|
expect(getLog.mock.calls.length).toBe(callsAfterSuspend)
|
||||||
|
|
||||||
|
// resume restarts polling (resetPoll -> immediate poll)
|
||||||
|
bus.resume()
|
||||||
|
await flushMicrotasks()
|
||||||
|
expect(getLog.mock.calls.length).toBeGreaterThan(callsAfterSuspend)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`background() polls at the background cadence`, async () => {
|
||||||
|
const {bus, getLog} = createBus()
|
||||||
|
|
||||||
|
getLog.mockResolvedValue(getLogResponse('0000005', []))
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
bus.background()
|
||||||
|
await flushMicrotasks()
|
||||||
|
const callsAfterBackground = getLog.mock.calls.length
|
||||||
|
|
||||||
|
// advancing by the default interval should NOT trigger another poll,
|
||||||
|
// because background cadence is longer
|
||||||
|
await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL)
|
||||||
|
await flushMicrotasks()
|
||||||
|
expect(getLog.mock.calls.length).toBe(callsAfterBackground)
|
||||||
|
|
||||||
|
// advancing to the background interval triggers a poll
|
||||||
|
await jest.advanceTimersByTimeAsync(
|
||||||
|
BACKGROUND_POLL_INTERVAL - DEFAULT_POLL_INTERVAL,
|
||||||
|
)
|
||||||
|
await flushMicrotasks()
|
||||||
|
expect(getLog.mock.calls.length).toBeGreaterThan(callsAfterBackground)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`core: requestPollInterval`, () => {
|
||||||
|
it(`lowers cadence while requested, returns a cleanup that restores default`, async () => {
|
||||||
|
const {bus, getLog} = createBus()
|
||||||
|
|
||||||
|
getLog.mockResolvedValue(getLogResponse('0000005', []))
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
const LOW = 1000
|
||||||
|
|
||||||
|
// Request a lower interval. resetPoll runs an immediate poll, so count
|
||||||
|
// from after the request settles.
|
||||||
|
const cleanup = bus.requestPollInterval(LOW)
|
||||||
|
await flushMicrotasks()
|
||||||
|
const callsAfterRequest = getLog.mock.calls.length
|
||||||
|
|
||||||
|
// At the low cadence, several polls happen within one default interval.
|
||||||
|
await jest.advanceTimersByTimeAsync(LOW * 5)
|
||||||
|
await flushMicrotasks()
|
||||||
|
const lowCadenceCalls = getLog.mock.calls.length - callsAfterRequest
|
||||||
|
expect(lowCadenceCalls).toBeGreaterThanOrEqual(4)
|
||||||
|
|
||||||
|
// Cleanup restores the default interval (resetPoll -> immediate poll).
|
||||||
|
cleanup()
|
||||||
|
await flushMicrotasks()
|
||||||
|
const callsAfterCleanup = getLog.mock.calls.length
|
||||||
|
|
||||||
|
// Advancing by the low interval should no longer produce many polls;
|
||||||
|
// within one default interval there should be at most the single
|
||||||
|
// resetPoll-immediate poll already counted.
|
||||||
|
await jest.advanceTimersByTimeAsync(LOW * 5)
|
||||||
|
await flushMicrotasks()
|
||||||
|
expect(getLog.mock.calls.length - callsAfterCleanup).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`core: connect / retry`, () => {
|
||||||
|
it(`emits 'connect' on initial Ready`, async () => {
|
||||||
|
const {events} = createBus()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(events.filter(e => e.type === 'connect')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`error payload retry() triggers recovery and re-emits 'connect'`, async () => {
|
||||||
|
// init fails -> InitFailed with a retry callback
|
||||||
|
const {getLog, events} = createBus('fail')
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
const errs = errorEvents(events)
|
||||||
|
expect(errs).toHaveLength(1)
|
||||||
|
|
||||||
|
// invoking the payload's retry() (which dispatches Resume) recovers
|
||||||
|
getLog.mockResolvedValueOnce(getLogResponse('0000005', []))
|
||||||
|
errs[0].error.retry()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(events.filter(e => e.type === 'connect')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user