Skip to content

Commit 4644afc

Browse files
authored
add dispatch unit tests and minor refactoring (#602)
* fix bug around invokeCallback Co-authored-by: Seth Silesky <silesky@users.noreply.github.com>
1 parent a570922 commit 4644afc

File tree

5 files changed

+151
-18
lines changed

5 files changed

+151
-18
lines changed

.changeset/brown-islands-judge.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@segment/analytics-core': patch
3+
---
4+
5+
Fix bug where delay and pTimeout are coupled
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Properly type mocked functions to make it easy to do assertions
3+
* for example, myModule.mock.calls[0] will have the typed parameters instead of any.
4+
*
5+
* TODO: share with rest of project
6+
*/
7+
type JestMockedFn<Fn> = Fn extends (...args: infer Args) => infer ReturnT
8+
? jest.Mock<ReturnT, Args>
9+
: never
10+
11+
const isOnline = jest.fn().mockReturnValue(true)
12+
const isOffline = jest.fn().mockReturnValue(false)
13+
14+
jest.mock('../../connection', () => ({
15+
isOnline,
16+
isOffline,
17+
}))
18+
19+
const fetcher: JestMockedFn<typeof import('node-fetch')['default']> = jest.fn()
20+
jest.mock('node-fetch', () => fetcher)
21+
22+
const invokeCallback: JestMockedFn<
23+
typeof import('../../callback')['invokeCallback']
24+
> = jest.fn()
25+
jest.mock('../../callback', () => ({
26+
invokeCallback: invokeCallback,
27+
}))
28+
29+
import { EventQueue } from '../../queue/event-queue'
30+
import { Emitter } from '../../emitter'
31+
import { dispatch, getDelay } from '../dispatch'
32+
import { PriorityQueue } from '../../priority-queue'
33+
import { CoreContext } from '../../context'
34+
35+
let emitter!: Emitter
36+
let queue!: EventQueue
37+
const dispatchSingleSpy = jest.spyOn(EventQueue.prototype, 'dispatchSingle')
38+
const dispatchSpy = jest.spyOn(EventQueue.prototype, 'dispatch')
39+
const screenCtxMatcher = expect.objectContaining<Partial<CoreContext>>({
40+
event: { type: 'screen' },
41+
})
42+
describe('Dispatch', () => {
43+
beforeEach(() => {
44+
jest.resetAllMocks()
45+
dispatchSingleSpy.mockImplementationOnce((ctx) => Promise.resolve(ctx))
46+
invokeCallback.mockImplementationOnce((ctx) => Promise.resolve(ctx))
47+
dispatchSpy.mockImplementationOnce((ctx) => Promise.resolve(ctx))
48+
queue = new EventQueue(new PriorityQueue(4, []))
49+
queue.isEmpty = jest.fn().mockReturnValue(false)
50+
emitter = new Emitter()
51+
})
52+
53+
it('should not dispatch if client is currently offline and retries are *disabled* for the main event queue', async () => {
54+
isOnline.mockReturnValue(false)
55+
isOffline.mockReturnValue(true)
56+
57+
const ctx = await dispatch({ type: 'screen' }, queue, emitter, {
58+
retryQueue: false,
59+
})
60+
expect(ctx).toEqual(screenCtxMatcher)
61+
const called = Boolean(
62+
dispatchSingleSpy.mock.calls.length || dispatchSpy.mock.calls.length
63+
)
64+
expect(called).toBeFalsy()
65+
})
66+
67+
it('should be allowed to dispatch if client is currently offline and retries are *enabled* for the main event queue', async () => {
68+
isOnline.mockReturnValue(false)
69+
isOffline.mockReturnValue(true)
70+
71+
await dispatch({ type: 'screen' }, queue, emitter, {
72+
retryQueue: true,
73+
})
74+
const called = Boolean(
75+
dispatchSingleSpy.mock.calls.length || dispatchSpy.mock.calls.length
76+
)
77+
expect(called).toBeTruthy()
78+
})
79+
80+
it('should call dispatchSingle correctly if queue is empty', async () => {
81+
queue.isEmpty = jest.fn().mockReturnValue(true)
82+
await dispatch({ type: 'screen' }, queue, emitter)
83+
expect(dispatchSingleSpy).toBeCalledWith(screenCtxMatcher)
84+
expect(dispatchSpy).not.toBeCalled()
85+
})
86+
87+
it('should call dispatch correctly if queue has items', async () => {
88+
await dispatch({ type: 'screen' }, queue, emitter)
89+
expect(dispatchSpy).toBeCalledWith(screenCtxMatcher)
90+
expect(dispatchSingleSpy).not.toBeCalled()
91+
})
92+
93+
it('should only call invokeCallback if callback is passed', async () => {
94+
await dispatch({ type: 'screen' }, queue, emitter)
95+
expect(invokeCallback).not.toBeCalled()
96+
97+
const cb = jest.fn()
98+
await dispatch({ type: 'screen' }, queue, emitter, { callback: cb })
99+
expect(invokeCallback).toBeCalledTimes(1)
100+
})
101+
it('should call invokeCallback with correct args', async () => {
102+
const cb = jest.fn()
103+
await dispatch({ type: 'screen' }, queue, emitter, {
104+
callback: cb,
105+
})
106+
expect(dispatchSpy).toBeCalledWith(screenCtxMatcher)
107+
expect(invokeCallback).toBeCalledTimes(1)
108+
const [ctx, _cb] = invokeCallback.mock.calls[0]
109+
expect(ctx).toEqual(screenCtxMatcher)
110+
expect(_cb).toBe(cb)
111+
})
112+
})
113+
114+
describe(getDelay, () => {
115+
it('should calculate the amount of time to delay before invoking the callback', () => {
116+
const aShortTimeAgo = Date.now() - 200
117+
const timeout = 5000
118+
expect(Math.round(getDelay(aShortTimeAgo, timeout))).toBe(4800)
119+
})
120+
121+
it('should have a sensible default', () => {
122+
const aShortTimeAgo = Date.now() - 200
123+
expect(Math.round(getDelay(aShortTimeAgo))).toBe(100)
124+
})
125+
})

packages/core/src/analytics/dispatch.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ type DispatchOptions = {
1212
retryQueue?: boolean
1313
}
1414

15+
/* The amount of time in ms to wait before invoking the callback. */
16+
export const getDelay = (startTimeInEpochMS: number, timeoutInMS?: number) => {
17+
const elapsedTime = Date.now() - startTimeInEpochMS
18+
// increasing the timeout increases the delay by almost the same amount -- this is weird legacy behavior.
19+
return Math.max((timeoutInMS ?? 300) - elapsedTime, 0)
20+
}
1521
/**
1622
* Push an event into the dispatch queue and invoke any callbacks.
1723
*
@@ -40,15 +46,12 @@ export async function dispatch(
4046
} else {
4147
dispatched = await queue.dispatch(ctx)
4248
}
43-
const elapsedTime = Date.now() - startTime
44-
const timeoutInMs = options?.timeout
4549

4650
if (options?.callback) {
4751
dispatched = await invokeCallback(
4852
dispatched,
4953
options.callback,
50-
Math.max((timeoutInMs ?? 300) - elapsedTime, 0),
51-
timeoutInMs
54+
getDelay(startTime, options.timeout)
5255
)
5356
}
5457
if (options?.debug) {

packages/core/src/callback/__tests__/index.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,37 +19,37 @@ describe(invokeCallback, () => {
1919
})
2020

2121
// Fixes GitHub issue: https://github.com/segmentio/analytics-next/issues/409
22-
// A.JS classic waited for the timeout before invoking callback,
22+
// A.JS classic waited for the timeout/delay before invoking callback,
2323
// so keep same behavior in A.JS next.
24-
it('calls the callback after a timeout', async () => {
24+
it('calls the callback after a delay', async () => {
2525
const ctx = new CoreContext({
2626
type: 'track',
2727
})
2828

2929
const fn = jest.fn()
30-
const timeout = 100
30+
const delay = 100
3131

3232
const startTime = Date.now()
33-
const returned = await invokeCallback(ctx, fn, timeout)
33+
const returned = await invokeCallback(ctx, fn, delay)
3434
const endTime = Date.now()
3535

3636
expect(fn).toHaveBeenCalled()
37-
expect(endTime - startTime).toBeGreaterThanOrEqual(timeout - 1)
37+
expect(endTime - startTime).toBeGreaterThanOrEqual(delay - 1)
3838
expect(returned).toBe(ctx)
3939
})
4040

41-
it('ignores the callback after a timeout', async () => {
41+
it('ignores the callback if it takes too long to resolve', async () => {
4242
const ctx = new CoreContext({
4343
type: 'track',
4444
})
4545

4646
const slow = (_ctx: CoreContext): Promise<void> => {
4747
return new Promise((resolve) => {
48-
setTimeout(resolve, 200)
48+
setTimeout(resolve, 1100)
4949
})
5050
}
5151

52-
const returned = await invokeCallback(ctx, slow, 0, 50)
52+
const returned = await invokeCallback(ctx, slow, 0)
5353
expect(returned).toBe(ctx)
5454

5555
const logs = returned.logs()

packages/core/src/callback/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ function sleep(timeoutInMs: number): Promise<void> {
2222
}
2323

2424
/**
25-
* @param delayTimeout - The amount of time in ms to wait before invoking the callback.
26-
* @param timeout - The maximum amount of time in ms to allow the callback to run for.
25+
* @param ctx
26+
* @param callback - the function to invoke
27+
* @param delay - aka "timeout". The amount of time in ms to wait before invoking the callback.
2728
*/
2829
export function invokeCallback(
2930
ctx: CoreContext,
3031
callback: Callback,
31-
delayTimeout: number,
32-
timeout?: number
32+
delay: number
3333
): Promise<CoreContext> {
3434
const cb = () => {
3535
try {
@@ -40,9 +40,9 @@ export function invokeCallback(
4040
}
4141

4242
return (
43-
sleep(delayTimeout)
43+
sleep(delay)
4444
// pTimeout ensures that the callback can't cause the context to hang
45-
.then(() => pTimeout(cb(), timeout ?? 1000))
45+
.then(() => pTimeout(cb(), 1000))
4646
.catch((err) => {
4747
ctx?.log('warn', 'Callback Error', { error: err })
4848
ctx?.stats?.increment('callback_error')

0 commit comments

Comments
 (0)