Skip to content

Commit a7b96e5

Browse files
committed
add ability to capture customer events before library init
1 parent 45cfa21 commit a7b96e5

File tree

11 files changed

+469
-57
lines changed

11 files changed

+469
-57
lines changed

.eslintrc

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
{
2-
"ignorePatterns": ["/node_modules/", "/dist/", "/example/", "/e2e-tests/", "/qa/"],
2+
"ignorePatterns": [
3+
"/node_modules/",
4+
"/dist/",
5+
"/example/",
6+
"/e2e-tests/",
7+
"/qa/"
8+
],
39
"parserOptions": {
410
"ecmaVersion": 2019
511
},
612
"env": {
713
"node": true
814
},
9-
"extends": ["eslint:recommended", "prettier"],
15+
"extends": [
16+
"eslint:recommended",
17+
"prettier"
18+
],
1019
"overrides": [
1120
{
12-
"files": ["*.{ts,tsx}"],
21+
"files": [
22+
"*.{ts,tsx}"
23+
],
1324
"parser": "@typescript-eslint/parser",
1425
"parserOptions": {
1526
"project": "./tsconfig.json"
@@ -25,10 +36,15 @@
2536
"@typescript-eslint/ban-ts-ignore": "off",
2637
"@typescript-eslint/no-unused-vars": "off",
2738
"@typescript-eslint/no-non-null-assertion": "off",
28-
"@typescript-eslint/no-floating-promises": "error",
39+
"@typescript-eslint/no-floating-promises": [
40+
"error",
41+
{
42+
"ignoreVoid": true
43+
}
44+
],
2945
"@typescript-eslint/require-await": "off",
3046
"@typescript-eslint/no-empty-function": "off"
3147
}
3248
}
3349
]
34-
}
50+
}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
stats.json
66
/coverage
77
e2e-tests/data/requests/*.json
8+
**/*.tmp.*

example/pages/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export default function Home(): React.ReactElement {
8585

8686
async function fetchAnalytics() {
8787
try {
88+
debugger
8889
const [response, ctx] = await AnalyticsBrowser.load({
8990
...settings,
9091
writeKey,
@@ -112,7 +113,7 @@ export default function Home(): React.ReactElement {
112113
}
113114

114115
const evt = JSON.parse(event)
115-
const ctx = await analytics.track(evt?.event ?? 'Track Event', evt)
116+
const ctx = await AnalyticsBrowser.track(evt?.event ?? 'Track Event', evt)
116117
setCtx(ctx)
117118

118119
ctx.flush()
@@ -127,7 +128,7 @@ export default function Home(): React.ReactElement {
127128

128129
const evt = JSON.parse(event)
129130
const { userId = 'Test User', ...traits } = evt
130-
const ctx = await analytics.identify(userId, traits)
131+
const ctx = await AnalyticsBrowser.identify(userId, traits)
131132

132133
setCtx(ctx)
133134

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"size-limit": [
3232
{
3333
"path": "dist/umd/index.js",
34-
"limit": "25.0 KB"
34+
"limit": "26.0 KB"
3535
}
3636
],
3737
"prettier": {

src/__tests__/cdn.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { AnalyticsBrowser } from '..'
22
import { mocked } from 'ts-jest/utils'
33
import unfetch from 'unfetch'
4-
import { setGlobalCDNUrl } from '../lib/parse-cdn'
54

65
jest.mock('unfetch', () => {
76
return jest.fn()
@@ -16,8 +15,8 @@ const settingsResponse = Promise.resolve({
1615
}),
1716
}) as Promise<Response>
1817

19-
afterEach(() => {
20-
setGlobalCDNUrl(undefined as any)
18+
beforeEach(() => {
19+
AnalyticsBrowser._resetGlobalState()
2120
})
2221

2322
mocked(unfetch).mockImplementation(() => settingsResponse)

src/__tests__/integration.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable @typescript-eslint/no-floating-promises */
12
import { Context } from '@/core/context'
23
import { Plugin } from '@/core/plugin'
34
import { JSDOM } from 'jsdom'
@@ -83,6 +84,10 @@ const enrichBilling: Plugin = {
8384

8485
const writeKey = TEST_WRITEKEY
8586

87+
beforeEach(() => {
88+
AnalyticsBrowser._resetGlobalState()
89+
})
90+
8691
describe('Initialization', () => {
8792
beforeEach(async () => {
8893
jest.resetAllMocks()
@@ -177,6 +182,7 @@ describe('Initialization', () => {
177182
await sleep(200)
178183
expect(ready).toHaveBeenCalled()
179184
})
185+
180186
describe('cdn', () => {
181187
it('should get the correct CDN in plugins if the CDN overridden', async () => {
182188
const overriddenCDNUrl = 'http://cdn.segment.com' // http instead of https
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { AnalyticsBrowser } from '..'
2+
import unfetch from 'unfetch'
3+
import { mocked } from 'ts-jest/utils'
4+
import { Analytics } from '../analytics'
5+
6+
jest.mock('unfetch')
7+
8+
const mockFetchSettingsResponse = () => {
9+
const settingsResponse = Promise.resolve({
10+
json: () =>
11+
Promise.resolve({
12+
integrations: {},
13+
}),
14+
}) as Promise<Response>
15+
mocked(unfetch).mockImplementationOnce(() => settingsResponse)
16+
}
17+
18+
const writeKey = 'foo'
19+
20+
const sleep = (time: number): Promise<void> =>
21+
new Promise((resolve) => {
22+
setTimeout(resolve, time)
23+
})
24+
25+
describe('Pre-initialization', () => {
26+
beforeEach(() => {
27+
AnalyticsBrowser._resetGlobalState()
28+
mockFetchSettingsResponse()
29+
delete window.analytics
30+
})
31+
test('load should instantiate an analytics object', async () => {
32+
expect(AnalyticsBrowser.instance).toBeUndefined()
33+
await AnalyticsBrowser.load({ writeKey })
34+
expect(AnalyticsBrowser.instance).toBeDefined()
35+
})
36+
test('If a user sends a single preinitiozed track event, that event gets flushed', async () => {
37+
const trackSpy = jest.spyOn(Analytics.prototype, 'track')
38+
const trackCtxPromise = AnalyticsBrowser.track('foo', { name: 'john' })
39+
void AnalyticsBrowser.load({ writeKey })
40+
await trackCtxPromise
41+
expect(trackSpy).toBeCalledWith('foo', { name: 'john' })
42+
expect(trackSpy).toBeCalledTimes(1)
43+
})
44+
45+
test('If a user sends multiple events, all of those event gets flushed', async () => {
46+
const trackSpy = jest.spyOn(Analytics.prototype, 'track')
47+
const identifySpy = jest.spyOn(Analytics.prototype, 'identify')
48+
49+
const trackCtxPromise = AnalyticsBrowser.track('foo', { name: 'john' })
50+
const trackCtxPromise2 = AnalyticsBrowser.track('bar', { age: 123 })
51+
const identifyCtxPromise = AnalyticsBrowser.identify('hello')
52+
53+
void AnalyticsBrowser.load({ writeKey })
54+
await Promise.all([trackCtxPromise, trackCtxPromise2, identifyCtxPromise])
55+
56+
expect(trackSpy).toBeCalledWith('foo', { name: 'john' })
57+
expect(trackSpy).toBeCalledWith('bar', { age: 123 })
58+
expect(trackSpy).toBeCalledTimes(2)
59+
60+
expect(identifySpy).toBeCalledWith('hello')
61+
expect(identifySpy).toBeCalledTimes(1)
62+
})
63+
describe('snippet API with standalone', () => {
64+
test('If a snippet user sends multiple events, all of those event gets flushed', async () => {
65+
const onTrackCb = jest.fn()
66+
const onTrack = ['on', 'track', onTrackCb]
67+
const track = ['track', 'foo']
68+
const track2 = ['track', 'bar']
69+
const identify = ['identify']
70+
71+
;(window as any).analytics = [onTrack, track, track2, identify]
72+
73+
const onSpy = jest.spyOn(Analytics.prototype, 'on')
74+
const trackSpy = jest.spyOn(Analytics.prototype, 'track')
75+
const identifySpy = jest.spyOn(Analytics.prototype, 'identify')
76+
77+
await AnalyticsBrowser.standalone(writeKey)
78+
79+
await sleep(100) // the snippet does not return a promise (pre-initialization) ... it sometimes has a callback as the third argument.
80+
expect(trackSpy).toBeCalledWith('foo')
81+
expect(trackSpy).toBeCalledWith('bar')
82+
expect(trackSpy).toBeCalledTimes(2)
83+
84+
expect(identifySpy).toBeCalledWith()
85+
expect(identifySpy).toBeCalledTimes(1)
86+
87+
expect(onSpy).toBeCalledTimes(1)
88+
89+
expect(onTrackCb).toBeCalledTimes(2) // gets called once for each track event
90+
expect(onTrackCb).toBeCalledWith('foo', {}, undefined)
91+
expect(onTrackCb).toBeCalledWith('bar', {}, undefined)
92+
})
93+
})
94+
95+
describe('AnalyticsBrowser.on track', () => {
96+
test('If, before initialization, .on("track") is called, the .on method should be called after analytics load', async () => {
97+
const onSpy = jest.spyOn(Analytics.prototype, 'on')
98+
99+
const args = ['track', jest.fn()] as const
100+
const promise = AnalyticsBrowser.on(...args)
101+
expect(onSpy).not.toHaveBeenCalledWith(...args)
102+
void AnalyticsBrowser.load({ writeKey })
103+
104+
await promise
105+
expect(onSpy).toBeCalledWith(...args)
106+
expect(onSpy).toHaveBeenCalledTimes(1)
107+
})
108+
109+
test('If, before initialization .on("track") is called and then .track is called, the callback method should be called after analytics loads', async () => {
110+
const onFnCb = jest.fn()
111+
const onSpy = jest.spyOn(Analytics.prototype, 'on')
112+
const analyticsPromise = AnalyticsBrowser.on('track', onFnCb)
113+
const trackCtxPromise = AnalyticsBrowser.track('foo', { name: 123 })
114+
115+
expect(onFnCb).not.toHaveBeenCalled()
116+
void AnalyticsBrowser.load({ writeKey })
117+
118+
await Promise.all([analyticsPromise, trackCtxPromise])
119+
120+
expect(onSpy).toBeCalledWith('track', onFnCb)
121+
expect(onSpy).toHaveBeenCalledTimes(1)
122+
123+
expect(onFnCb).toHaveBeenCalledWith('foo', { name: 123 }, undefined)
124+
expect(onFnCb).toHaveBeenCalledTimes(1)
125+
})
126+
127+
test('If, before initialization, .ready is called, the callback method should be called after analytics loads', async () => {
128+
const onReadyCb = jest.fn()
129+
const onReadySpy = jest.spyOn(Analytics.prototype, 'ready')
130+
const onReadyPromise = AnalyticsBrowser.ready(onReadyCb)
131+
expect(onReadyCb).not.toHaveBeenCalled()
132+
void AnalyticsBrowser.load({ writeKey })
133+
await onReadyPromise
134+
expect(onReadySpy).toHaveBeenCalledTimes(1) // ERROR
135+
expect(onReadyCb).toHaveBeenCalledTimes(1) // ERROR
136+
})
137+
138+
test('Should work with "on" events if a track event is called after load', async () => {
139+
const onTrackCb = jest.fn()
140+
const analyticsPromise = AnalyticsBrowser.on('track', onTrackCb)
141+
void AnalyticsBrowser.load({ writeKey })
142+
const trackCtxPromise = AnalyticsBrowser.track('foo', { name: 123 })
143+
144+
await Promise.all([analyticsPromise, trackCtxPromise])
145+
expect(onTrackCb).toBeCalledTimes(1)
146+
})
147+
148+
test('Should work with "on" events if a track event is called after load is complete', async () => {
149+
const onTrackCb = jest.fn()
150+
const analyticsPromise = AnalyticsBrowser.on('track', onTrackCb)
151+
await AnalyticsBrowser.load({ writeKey })
152+
const trackCtxPromise = AnalyticsBrowser.track('foo', { name: 123 })
153+
154+
await Promise.all([analyticsPromise, trackCtxPromise])
155+
expect(onTrackCb).toBeCalledTimes(1)
156+
})
157+
})
158+
})

src/__tests__/query-string.integration.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('queryString', () => {
3333
windowSpy.mockImplementation(
3434
() => (jsd.window as unknown) as Window & typeof globalThis
3535
)
36+
AnalyticsBrowser._resetGlobalState()
3637
})
3738

3839
it('applies query string logic if window.location.search is present', async () => {
@@ -68,6 +69,7 @@ describe('queryString', () => {
6869
url: 'https://localhost/#about?ajs_id=123',
6970
})
7071

72+
AnalyticsBrowser._resetGlobalState()
7173
await AnalyticsBrowser.load({ writeKey })
7274
expect(mockQueryString).toHaveBeenCalledWith('?ajs_id=123')
7375
})

src/__tests__/standalone-analytics.test.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,7 @@ describe('standalone bundle', () => {
4747
const segmentDotCom = `foo`
4848

4949
beforeEach(async () => {
50-
jest.restoreAllMocks()
51-
jest.resetAllMocks()
50+
delete window.analytics
5251
const html = `
5352
<!DOCTYPE html>
5453
<head>
@@ -220,26 +219,23 @@ describe('standalone bundle', () => {
220219
}, 0)
221220
})
222221
it('sets buffered event emitters before loading destinations', async (done) => {
223-
// @ts-ignore ignore Response required fields
224-
mocked(unfetch).mockImplementation((): Promise<Response> => fetchSettings)
222+
mocked(unfetch).mockImplementation(() => fetchSettings as Promise<Response>)
225223

226224
const operations: string[] = []
227225

228226
track.mockImplementationOnce(() => operations.push('track'))
229-
on.mockImplementationOnce(() => operations.push('on', 'on'))
227+
on.mockImplementationOnce(() => operations.push('on'))
230228
register.mockImplementationOnce(() => operations.push('register'))
231229

232230
await install()
233231

234232
setTimeout(() => {
235-
expect(on).toHaveBeenCalledTimes(2)
236-
expect(on).toHaveBeenCalledWith('initialize', expect.any(Function))
233+
expect(on).toHaveBeenCalledTimes(1)
237234
expect(on).toHaveBeenCalledWith('initialize', expect.any(Function))
238235

239236
expect(operations).toEqual([
240237
// should run before any plugin is registered
241238
'on',
242-
'on',
243239
// should run before any events are sent downstream
244240
'register',
245241
// should run after all plugins have been registered

0 commit comments

Comments
 (0)