swetrix 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -14
- package/dist/esnext/Lib.d.ts +79 -11
- package/dist/esnext/Lib.js +417 -12
- package/dist/esnext/Lib.js.map +1 -1
- package/dist/esnext/index.d.ts +18 -15
- package/dist/esnext/index.js +23 -15
- package/dist/esnext/index.js.map +1 -1
- package/dist/esnext/utils.d.ts +9 -0
- package/dist/esnext/utils.js +20 -0
- package/dist/esnext/utils.js.map +1 -1
- package/dist/replaylibrary.min.js +173 -0
- package/dist/swetrix.cjs.js +463 -25
- package/dist/swetrix.cjs.js.map +1 -1
- package/dist/swetrix.es5.js +463 -26
- package/dist/swetrix.es5.js.map +1 -1
- package/dist/swetrix.js +1 -1
- package/dist/swetrix.js.map +1 -1
- package/jest.config.js +3 -1
- package/package.json +43 -40
- package/rollup.config.mjs +20 -0
- package/src/Lib.ts +589 -12
- package/src/index.ts +29 -14
- package/src/types/rrweb-shim.d.ts +11 -0
- package/src/utils.ts +22 -0
- package/tests/errors.test.ts +2 -9
- package/tests/events.test.ts +2 -9
- package/tests/experiments.test.ts +2 -9
- package/tests/initialisation.test.ts +5 -18
- package/tests/jsdomEnvironment.ts +20 -0
- package/tests/pageview.test.ts +3 -9
- package/tests/sessionReplay.test.ts +389 -0
- package/tests/testUtils.ts +27 -0
- package/tests/utils.test.ts +37 -114
- package/tsconfig.esnext.json +5 -1
- package/tsconfig.json +6 -1
- package/tsconfig.test.json +7 -0
- package/.github/funding.yml +0 -2
- package/.github/workflows/test.yml +0 -32
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/// <reference types="jest" />
|
|
2
|
+
|
|
3
|
+
import { Lib } from '../src/Lib'
|
|
4
|
+
import { setLocation } from './testUtils'
|
|
5
|
+
|
|
6
|
+
const PROJECT_ID = 'test-project-id'
|
|
7
|
+
const RRWEB_URL = 'https://cdn.jsdelivr.net/npm/swetrix@latest/dist/replaylibrary.min.js'
|
|
8
|
+
const mockRrwebRecord = jest.fn()
|
|
9
|
+
|
|
10
|
+
jest.mock('rrweb', () => ({
|
|
11
|
+
record: mockRrwebRecord,
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
const loadTracker = async () => {
|
|
15
|
+
jest.resetModules()
|
|
16
|
+
return import('../src/index')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const resetReplayGlobals = () => {
|
|
20
|
+
delete (window as any).rrweb
|
|
21
|
+
delete (window as any).__SWETRIX_RRWEB_LOADING__
|
|
22
|
+
document.head.innerHTML = ''
|
|
23
|
+
document.body.innerHTML = ''
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const usePackageRrweb = () => {
|
|
27
|
+
const stopRecording = jest.fn()
|
|
28
|
+
mockRrwebRecord.mockImplementation((recordOptions) => {
|
|
29
|
+
;(mockRrwebRecord as any).options = recordOptions
|
|
30
|
+
return stopRecording
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
recordOptions: () => (mockRrwebRecord as any).options,
|
|
35
|
+
stopRecording,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('Session replay tracking', () => {
|
|
40
|
+
let fetchMock: jest.Mock
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
jest.clearAllMocks()
|
|
44
|
+
mockRrwebRecord.mockReset()
|
|
45
|
+
delete (mockRrwebRecord as any).options
|
|
46
|
+
jest.useRealTimers()
|
|
47
|
+
resetReplayGlobals()
|
|
48
|
+
setLocation({ hostname: 'example.com', pathname: '/checkout' })
|
|
49
|
+
|
|
50
|
+
Object.defineProperty(navigator, 'doNotTrack', {
|
|
51
|
+
value: null,
|
|
52
|
+
writable: true,
|
|
53
|
+
configurable: true,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
fetchMock = jest.fn().mockResolvedValue({ ok: true })
|
|
57
|
+
Object.defineProperty(globalThis, 'fetch', {
|
|
58
|
+
value: fetchMock,
|
|
59
|
+
writable: true,
|
|
60
|
+
configurable: true,
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('init with preloadSessionReplay loads npm rrweb but does not record', async () => {
|
|
65
|
+
const { init } = await loadTracker()
|
|
66
|
+
|
|
67
|
+
init(PROJECT_ID, { devMode: true, preloadSessionReplay: true })
|
|
68
|
+
await (window as any).__SWETRIX_RRWEB_LOADING__
|
|
69
|
+
|
|
70
|
+
expect((window as any).rrweb.record).toBe(mockRrwebRecord)
|
|
71
|
+
expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull()
|
|
72
|
+
expect(mockRrwebRecord).not.toHaveBeenCalled()
|
|
73
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('session replay script uses jsDelivr for the public swetrix.org loader', async () => {
|
|
77
|
+
const script = document.createElement('script')
|
|
78
|
+
script.src = 'https://swetrix.org/swetrix.js'
|
|
79
|
+
document.head.appendChild(script)
|
|
80
|
+
|
|
81
|
+
const { init } = await loadTracker()
|
|
82
|
+
init(PROJECT_ID, { devMode: true, preloadSessionReplay: true })
|
|
83
|
+
|
|
84
|
+
expect(
|
|
85
|
+
document.querySelector<HTMLScriptElement>(
|
|
86
|
+
`script[src="${RRWEB_URL}"]`,
|
|
87
|
+
),
|
|
88
|
+
).toBeTruthy()
|
|
89
|
+
expect(mockRrwebRecord).not.toHaveBeenCalled()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('preloadSessionReplay can load rrweb from a custom script URL', async () => {
|
|
93
|
+
const rrwebUrl = 'https://cdn.example.com/rrweb.min.js'
|
|
94
|
+
const { init } = await loadTracker()
|
|
95
|
+
|
|
96
|
+
init(PROJECT_ID, {
|
|
97
|
+
devMode: true,
|
|
98
|
+
preloadSessionReplay: { rrwebUrl },
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(
|
|
102
|
+
document.querySelector<HTMLScriptElement>(`script[src="${rrwebUrl}"]`),
|
|
103
|
+
).toBeTruthy()
|
|
104
|
+
expect(mockRrwebRecord).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('startSessionReplay imports npm rrweb, records events, and flushes chunks', async () => {
|
|
108
|
+
const { recordOptions } = usePackageRrweb()
|
|
109
|
+
const { init, startSessionReplay } = await loadTracker()
|
|
110
|
+
|
|
111
|
+
init(PROJECT_ID, { devMode: true })
|
|
112
|
+
const actions = await startSessionReplay({
|
|
113
|
+
flushIntervalMs: 60_000,
|
|
114
|
+
maxEventsPerChunk: 2,
|
|
115
|
+
})
|
|
116
|
+
const options = recordOptions()
|
|
117
|
+
expect(options.maskTextSelector).toBe('*')
|
|
118
|
+
expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull()
|
|
119
|
+
|
|
120
|
+
const startCall = fetchMock.mock.calls.find(([url]) =>
|
|
121
|
+
String(url).includes('/session-replay/start'),
|
|
122
|
+
)
|
|
123
|
+
expect(startCall).toBeTruthy()
|
|
124
|
+
expect(JSON.parse(startCall![1].body as string)).toEqual(
|
|
125
|
+
expect.objectContaining({ privacy: 'total' }),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
options.emit({ type: 2, timestamp: 100 })
|
|
129
|
+
options.emit({ type: 3, timestamp: 200 })
|
|
130
|
+
|
|
131
|
+
await actions.flush()
|
|
132
|
+
|
|
133
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
134
|
+
'https://api.swetrix.com/log/session-replay/start',
|
|
135
|
+
expect.objectContaining({ method: 'POST' }),
|
|
136
|
+
)
|
|
137
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
138
|
+
'https://api.swetrix.com/log/session-replay/chunk',
|
|
139
|
+
expect.objectContaining({
|
|
140
|
+
method: 'POST',
|
|
141
|
+
body: expect.stringContaining('"chunkIndex":0'),
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
await actions.stop()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('concurrent startSessionReplay calls share one recorder', async () => {
|
|
149
|
+
const { stopRecording } = usePackageRrweb()
|
|
150
|
+
const { init, startSessionReplay } = await loadTracker()
|
|
151
|
+
|
|
152
|
+
init(PROJECT_ID, { devMode: true })
|
|
153
|
+
const [firstActions, secondActions] = await Promise.all([
|
|
154
|
+
startSessionReplay({ flushIntervalMs: 60_000 }),
|
|
155
|
+
startSessionReplay({ flushIntervalMs: 10_000 }),
|
|
156
|
+
])
|
|
157
|
+
|
|
158
|
+
expect(firstActions).toBe(secondActions)
|
|
159
|
+
expect(mockRrwebRecord).toHaveBeenCalledTimes(1)
|
|
160
|
+
expect(
|
|
161
|
+
fetchMock.mock.calls.filter(([url]) =>
|
|
162
|
+
String(url).includes('/session-replay/start'),
|
|
163
|
+
),
|
|
164
|
+
).toHaveLength(1)
|
|
165
|
+
|
|
166
|
+
await firstActions.stop()
|
|
167
|
+
expect(stopRecording).toHaveBeenCalledTimes(1)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('sampleRate can skip recording before loading rrweb', async () => {
|
|
171
|
+
const { init, startSessionReplay } = await loadTracker()
|
|
172
|
+
|
|
173
|
+
init(PROJECT_ID, { devMode: true })
|
|
174
|
+
const actions = await startSessionReplay({ sampleRate: 0 })
|
|
175
|
+
await actions.flush()
|
|
176
|
+
await actions.stop()
|
|
177
|
+
|
|
178
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
179
|
+
expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull()
|
|
180
|
+
expect(mockRrwebRecord).not.toHaveBeenCalled()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('maxDurationMs stops recording and flushes buffered events', async () => {
|
|
184
|
+
jest.useFakeTimers()
|
|
185
|
+
const { recordOptions, stopRecording } = usePackageRrweb()
|
|
186
|
+
const { init, startSessionReplay } = await loadTracker()
|
|
187
|
+
|
|
188
|
+
init(PROJECT_ID, { devMode: true })
|
|
189
|
+
await startSessionReplay({
|
|
190
|
+
flushIntervalMs: 60_000,
|
|
191
|
+
maxDurationMs: 1000,
|
|
192
|
+
})
|
|
193
|
+
recordOptions().emit({ type: 2, timestamp: 100 })
|
|
194
|
+
|
|
195
|
+
await jest.advanceTimersByTimeAsync(1000)
|
|
196
|
+
await Promise.resolve()
|
|
197
|
+
|
|
198
|
+
expect(stopRecording).toHaveBeenCalledTimes(1)
|
|
199
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
200
|
+
'https://api.swetrix.com/log/session-replay/chunk',
|
|
201
|
+
expect.objectContaining({
|
|
202
|
+
body: expect.stringContaining('"timestamp":100'),
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('idleTimeoutMs stops after inactivity and resets on activity', async () => {
|
|
208
|
+
jest.useFakeTimers()
|
|
209
|
+
const { recordOptions, stopRecording } = usePackageRrweb()
|
|
210
|
+
const { init, startSessionReplay } = await loadTracker()
|
|
211
|
+
|
|
212
|
+
init(PROJECT_ID, { devMode: true })
|
|
213
|
+
await startSessionReplay({
|
|
214
|
+
flushIntervalMs: 60_000,
|
|
215
|
+
idleTimeoutMs: 1000,
|
|
216
|
+
})
|
|
217
|
+
recordOptions().emit({ type: 2, timestamp: 200 })
|
|
218
|
+
|
|
219
|
+
await jest.advanceTimersByTimeAsync(900)
|
|
220
|
+
window.dispatchEvent(new Event('mousemove'))
|
|
221
|
+
await jest.advanceTimersByTimeAsync(900)
|
|
222
|
+
|
|
223
|
+
expect(stopRecording).not.toHaveBeenCalled()
|
|
224
|
+
|
|
225
|
+
await jest.advanceTimersByTimeAsync(100)
|
|
226
|
+
await Promise.resolve()
|
|
227
|
+
|
|
228
|
+
expect(stopRecording).toHaveBeenCalledTimes(1)
|
|
229
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
230
|
+
'https://api.swetrix.com/log/session-replay/chunk',
|
|
231
|
+
expect.objectContaining({
|
|
232
|
+
body: expect.stringContaining('"timestamp":200'),
|
|
233
|
+
}),
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('privacy modes map to rrweb options and keep internal emit', () => {
|
|
238
|
+
const lib = new Lib(PROJECT_ID, { devMode: true })
|
|
239
|
+
const emit = jest.fn()
|
|
240
|
+
|
|
241
|
+
const total = (lib as any).getSessionReplayRecordOptions(
|
|
242
|
+
'total',
|
|
243
|
+
{ blockSelector: '.secret', emit: jest.fn() },
|
|
244
|
+
emit,
|
|
245
|
+
)
|
|
246
|
+
expect(total.maskAllInputs).toBe(true)
|
|
247
|
+
expect(total.maskTextSelector).toBe('*')
|
|
248
|
+
expect(total.blockSelector).toContain('.secret')
|
|
249
|
+
expect(total.blockSelector).toContain('img')
|
|
250
|
+
expect(total.recordCanvas).toBe(false)
|
|
251
|
+
expect(total.inlineImages).toBe(false)
|
|
252
|
+
expect(total.emit).toBe(emit)
|
|
253
|
+
|
|
254
|
+
const normal = (lib as any).getSessionReplayRecordOptions(
|
|
255
|
+
'normal',
|
|
256
|
+
{},
|
|
257
|
+
emit,
|
|
258
|
+
)
|
|
259
|
+
expect(normal.maskAllInputs).toBe(true)
|
|
260
|
+
expect(normal.emit).toBe(emit)
|
|
261
|
+
|
|
262
|
+
const freeLove = (lib as any).getSessionReplayRecordOptions(
|
|
263
|
+
'none',
|
|
264
|
+
{ maskInputOptions: { email: false } },
|
|
265
|
+
emit,
|
|
266
|
+
)
|
|
267
|
+
expect(freeLove.maskInputOptions).toEqual({
|
|
268
|
+
email: false,
|
|
269
|
+
password: true,
|
|
270
|
+
})
|
|
271
|
+
expect(freeLove.emit).toBe(emit)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('invalid privacy values fall back to total privacy', async () => {
|
|
275
|
+
const { recordOptions } = usePackageRrweb()
|
|
276
|
+
const { init, startSessionReplay } = await loadTracker()
|
|
277
|
+
|
|
278
|
+
init(PROJECT_ID, { devMode: true })
|
|
279
|
+
const actions = await startSessionReplay({
|
|
280
|
+
privacy: 'totl' as any,
|
|
281
|
+
})
|
|
282
|
+
const startCall = fetchMock.mock.calls.find(([url]) =>
|
|
283
|
+
String(url).includes('/session-replay/start'),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
expect(startCall).toBeTruthy()
|
|
287
|
+
expect(JSON.parse(startCall![1].body as string)).toEqual(
|
|
288
|
+
expect.objectContaining({ privacy: 'total' }),
|
|
289
|
+
)
|
|
290
|
+
expect(recordOptions().maskTextSelector).toBe('*')
|
|
291
|
+
|
|
292
|
+
await actions.stop()
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('script rrweb loader clears failed loads so startSessionReplay can retry', async () => {
|
|
296
|
+
const record = jest.fn(() => jest.fn())
|
|
297
|
+
const trackerScript = document.createElement('script')
|
|
298
|
+
trackerScript.src = 'https://example.com/swetrix.js'
|
|
299
|
+
document.head.appendChild(trackerScript)
|
|
300
|
+
const rrwebUrl = 'https://example.com/replaylibrary.min.js'
|
|
301
|
+
const { init, startSessionReplay } = await loadTracker()
|
|
302
|
+
|
|
303
|
+
init(PROJECT_ID, { devMode: true })
|
|
304
|
+
const failedStart = startSessionReplay()
|
|
305
|
+
const failedScript = document.querySelector<HTMLScriptElement>(
|
|
306
|
+
`script[src="${rrwebUrl}"]`,
|
|
307
|
+
)
|
|
308
|
+
expect(failedScript).toBeTruthy()
|
|
309
|
+
failedScript!.dispatchEvent(new Event('error'))
|
|
310
|
+
|
|
311
|
+
await failedStart
|
|
312
|
+
expect((window as any).__SWETRIX_RRWEB_LOADING__).toBeUndefined()
|
|
313
|
+
|
|
314
|
+
const retryStart = startSessionReplay()
|
|
315
|
+
const scripts = document.querySelectorAll<HTMLScriptElement>(
|
|
316
|
+
`script[src="${rrwebUrl}"]`,
|
|
317
|
+
)
|
|
318
|
+
expect(scripts[1]).toBeTruthy()
|
|
319
|
+
expect(scripts[1]).not.toBe(failedScript)
|
|
320
|
+
;(window as any).rrweb = { record }
|
|
321
|
+
scripts[1].dispatchEvent(new Event('load'))
|
|
322
|
+
|
|
323
|
+
const actions = await retryStart
|
|
324
|
+
expect((window as any).rrweb.record).toBe(record)
|
|
325
|
+
expect(record).toHaveBeenCalled()
|
|
326
|
+
|
|
327
|
+
await actions.stop()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('user rrweb emit is composed with Swetrix uploads', async () => {
|
|
331
|
+
const { recordOptions } = usePackageRrweb()
|
|
332
|
+
const userEmit = jest.fn()
|
|
333
|
+
const { init, startSessionReplay } = await loadTracker()
|
|
334
|
+
|
|
335
|
+
init(PROJECT_ID, { devMode: true })
|
|
336
|
+
const actions = await startSessionReplay({
|
|
337
|
+
rrweb: { emit: userEmit, maskAllInputs: true },
|
|
338
|
+
})
|
|
339
|
+
const event = { type: 2, timestamp: 300 }
|
|
340
|
+
recordOptions().emit(event)
|
|
341
|
+
await actions.flush()
|
|
342
|
+
|
|
343
|
+
expect(userEmit).toHaveBeenCalledWith(event)
|
|
344
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
345
|
+
'https://api.swetrix.com/log/session-replay/chunk',
|
|
346
|
+
expect.objectContaining({
|
|
347
|
+
body: expect.stringContaining('"timestamp":300'),
|
|
348
|
+
}),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
await actions.stop()
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test('DNT and disabled tracking return no-op controls without uploading', async () => {
|
|
355
|
+
Object.defineProperty(navigator, 'doNotTrack', {
|
|
356
|
+
value: '1',
|
|
357
|
+
writable: true,
|
|
358
|
+
configurable: true,
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
const { init, startSessionReplay } = await loadTracker()
|
|
362
|
+
init(PROJECT_ID, { devMode: true, respectDNT: true })
|
|
363
|
+
|
|
364
|
+
const actions = await startSessionReplay()
|
|
365
|
+
await actions.flush()
|
|
366
|
+
await actions.stop()
|
|
367
|
+
|
|
368
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
369
|
+
expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull()
|
|
370
|
+
expect(mockRrwebRecord).not.toHaveBeenCalled()
|
|
371
|
+
|
|
372
|
+
resetReplayGlobals()
|
|
373
|
+
mockRrwebRecord.mockReset()
|
|
374
|
+
Object.defineProperty(navigator, 'doNotTrack', {
|
|
375
|
+
value: null,
|
|
376
|
+
writable: true,
|
|
377
|
+
configurable: true,
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
const disabledModule = await loadTracker()
|
|
381
|
+
disabledModule.init(PROJECT_ID, { devMode: true, disabled: true })
|
|
382
|
+
const disabledActions = await disabledModule.startSessionReplay()
|
|
383
|
+
await disabledActions.flush()
|
|
384
|
+
|
|
385
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
386
|
+
expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull()
|
|
387
|
+
expect(mockRrwebRecord).not.toHaveBeenCalled()
|
|
388
|
+
})
|
|
389
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
interface LocationParts {
|
|
2
|
+
hostname?: string
|
|
3
|
+
pathname?: string
|
|
4
|
+
hash?: string
|
|
5
|
+
search?: string
|
|
6
|
+
protocol?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sets `window.location` by reconfiguring the underlying jsdom instance via the
|
|
11
|
+
* global helper installed by `jsdomEnvironment.ts`. Defaults preserve a stable
|
|
12
|
+
* "example.com/" base so individual fields can be set in isolation.
|
|
13
|
+
*/
|
|
14
|
+
export const setLocation = (parts: LocationParts = {}): void => {
|
|
15
|
+
const protocol = parts.protocol ?? 'http:'
|
|
16
|
+
const hostname = parts.hostname ?? 'example.com'
|
|
17
|
+
const pathname = parts.pathname ?? '/'
|
|
18
|
+
const search = parts.search ?? ''
|
|
19
|
+
const hash = parts.hash ?? ''
|
|
20
|
+
|
|
21
|
+
const setter = (globalThis as any).__setLocation as ((url: string) => void) | undefined
|
|
22
|
+
if (typeof setter !== 'function') {
|
|
23
|
+
throw new Error('__setLocation is not available - check that the custom jsdom environment is configured')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setter(`${protocol}//${hostname}${pathname}${search}${hash}`)
|
|
27
|
+
}
|
package/tests/utils.test.ts
CHANGED
|
@@ -1,35 +1,32 @@
|
|
|
1
1
|
import * as utils from '../src/utils'
|
|
2
|
+
import { setLocation } from './testUtils'
|
|
2
3
|
|
|
3
4
|
describe('Utility Functions', () => {
|
|
4
5
|
beforeEach(() => {
|
|
5
|
-
|
|
6
|
-
value: {
|
|
7
|
-
hostname: 'example.com',
|
|
8
|
-
pathname: '/test-page',
|
|
9
|
-
hash: '',
|
|
10
|
-
search: '',
|
|
11
|
-
},
|
|
12
|
-
writable: true,
|
|
13
|
-
})
|
|
6
|
+
setLocation({ hostname: 'example.com', pathname: '/test-page' })
|
|
14
7
|
|
|
15
8
|
Object.defineProperty(document, 'referrer', {
|
|
16
9
|
value: 'https://google.com',
|
|
17
10
|
writable: true,
|
|
11
|
+
configurable: true,
|
|
18
12
|
})
|
|
19
13
|
|
|
20
14
|
Object.defineProperty(navigator, 'language', {
|
|
21
15
|
value: 'en-US',
|
|
22
16
|
writable: true,
|
|
17
|
+
configurable: true,
|
|
23
18
|
})
|
|
24
19
|
|
|
25
20
|
Object.defineProperty(navigator, 'languages', {
|
|
26
21
|
value: ['en-US', 'en'],
|
|
27
22
|
writable: true,
|
|
23
|
+
configurable: true,
|
|
28
24
|
})
|
|
29
25
|
|
|
30
26
|
Object.defineProperty(navigator, 'webdriver', {
|
|
31
27
|
value: false,
|
|
32
28
|
writable: true,
|
|
29
|
+
configurable: true,
|
|
33
30
|
})
|
|
34
31
|
})
|
|
35
32
|
|
|
@@ -38,50 +35,28 @@ describe('Utility Functions', () => {
|
|
|
38
35
|
})
|
|
39
36
|
|
|
40
37
|
test('isLocalhost should detect localhost', () => {
|
|
41
|
-
|
|
42
|
-
Object.defineProperty(window, 'location', {
|
|
43
|
-
value: {
|
|
44
|
-
hostname: 'localhost',
|
|
45
|
-
},
|
|
46
|
-
writable: true,
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
// Act & Assert
|
|
38
|
+
setLocation({ hostname: 'localhost' })
|
|
50
39
|
expect(utils.isLocalhost()).toBe(true)
|
|
51
40
|
|
|
52
|
-
|
|
53
|
-
Object.defineProperty(window, 'location', {
|
|
54
|
-
value: {
|
|
55
|
-
hostname: '127.0.0.1',
|
|
56
|
-
},
|
|
57
|
-
writable: true,
|
|
58
|
-
})
|
|
41
|
+
setLocation({ hostname: '127.0.0.1' })
|
|
59
42
|
expect(utils.isLocalhost()).toBe(true)
|
|
60
43
|
|
|
61
|
-
|
|
62
|
-
Object.defineProperty(window, 'location', {
|
|
63
|
-
value: {
|
|
64
|
-
hostname: 'example.com',
|
|
65
|
-
},
|
|
66
|
-
writable: true,
|
|
67
|
-
})
|
|
44
|
+
setLocation({ hostname: 'example.com' })
|
|
68
45
|
expect(utils.isLocalhost()).toBe(false)
|
|
69
46
|
})
|
|
70
47
|
|
|
71
48
|
test('isAutomated should detect webdriver', () => {
|
|
72
|
-
// Arrange
|
|
73
49
|
Object.defineProperty(navigator, 'webdriver', {
|
|
74
50
|
value: true,
|
|
75
51
|
writable: true,
|
|
52
|
+
configurable: true,
|
|
76
53
|
})
|
|
77
|
-
|
|
78
|
-
// Act & Assert
|
|
79
54
|
expect(utils.isAutomated()).toBe(true)
|
|
80
55
|
|
|
81
|
-
// Test non-automated
|
|
82
56
|
Object.defineProperty(navigator, 'webdriver', {
|
|
83
57
|
value: false,
|
|
84
58
|
writable: true,
|
|
59
|
+
configurable: true,
|
|
85
60
|
})
|
|
86
61
|
expect(utils.isAutomated()).toBe(false)
|
|
87
62
|
})
|
|
@@ -89,129 +64,77 @@ describe('Utility Functions', () => {
|
|
|
89
64
|
test('getLocale should return the browser language', () => {
|
|
90
65
|
expect(utils.getLocale()).toBe('en-US')
|
|
91
66
|
|
|
92
|
-
// Test with navigator.languages undefined
|
|
93
67
|
const originalLanguages = navigator.languages
|
|
94
|
-
// Instead of deleting, set to undefined
|
|
95
68
|
Object.defineProperty(navigator, 'languages', {
|
|
96
69
|
value: undefined,
|
|
97
70
|
writable: true,
|
|
71
|
+
configurable: true,
|
|
98
72
|
})
|
|
99
|
-
expect(utils.getLocale()).toBe('en-US')
|
|
73
|
+
expect(utils.getLocale()).toBe('en-US')
|
|
100
74
|
|
|
101
|
-
// Restore the original value
|
|
102
75
|
Object.defineProperty(navigator, 'languages', {
|
|
103
76
|
value: originalLanguages,
|
|
104
77
|
writable: true,
|
|
78
|
+
configurable: true,
|
|
105
79
|
})
|
|
106
80
|
})
|
|
107
81
|
|
|
108
82
|
test('getReferrer should return the document referrer', () => {
|
|
109
83
|
expect(utils.getReferrer()).toBe('https://google.com')
|
|
110
84
|
|
|
111
|
-
// Test with empty referrer
|
|
112
85
|
Object.defineProperty(document, 'referrer', {
|
|
113
86
|
value: '',
|
|
114
87
|
writable: true,
|
|
88
|
+
configurable: true,
|
|
115
89
|
})
|
|
116
90
|
expect(utils.getReferrer()).toBeUndefined()
|
|
117
91
|
})
|
|
118
92
|
|
|
119
|
-
test('
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
value: {
|
|
123
|
-
pathname: '/test-page',
|
|
124
|
-
hash: '',
|
|
125
|
-
search: '',
|
|
126
|
-
},
|
|
127
|
-
writable: true,
|
|
128
|
-
})
|
|
93
|
+
test('getQueryString should return the URL query string without the leading ?', () => {
|
|
94
|
+
setLocation({ pathname: '/landing', search: '?fbclid=AbCdEf123&utm_source=newsletter' })
|
|
95
|
+
expect(utils.getQueryString()).toBe('fbclid=AbCdEf123&utm_source=newsletter')
|
|
129
96
|
|
|
130
|
-
|
|
97
|
+
setLocation({ pathname: '/landing' })
|
|
98
|
+
expect(utils.getQueryString()).toBeUndefined()
|
|
99
|
+
|
|
100
|
+
// Hash-routed SPAs sometimes carry the query string after the `#`.
|
|
101
|
+
setLocation({ pathname: '/', hash: '#/landing?gclid=xyz' })
|
|
102
|
+
expect(utils.getQueryString()).toBe('gclid=xyz')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('getPath should handle different URL formats', () => {
|
|
106
|
+
setLocation({ pathname: '/test-page' })
|
|
131
107
|
expect(utils.getPath({})).toBe('/test-page')
|
|
132
108
|
|
|
133
|
-
|
|
134
|
-
Object.defineProperty(window, 'location', {
|
|
135
|
-
value: {
|
|
136
|
-
pathname: '/test-page',
|
|
137
|
-
hash: '#section1',
|
|
138
|
-
search: '',
|
|
139
|
-
},
|
|
140
|
-
writable: true,
|
|
141
|
-
})
|
|
109
|
+
setLocation({ pathname: '/test-page', hash: '#section1' })
|
|
142
110
|
expect(utils.getPath({ hash: true })).toBe('/test-page#section1')
|
|
143
111
|
|
|
144
|
-
|
|
145
|
-
Object.defineProperty(window, 'location', {
|
|
146
|
-
value: {
|
|
147
|
-
pathname: '/test-page',
|
|
148
|
-
hash: '',
|
|
149
|
-
search: '?param=value',
|
|
150
|
-
},
|
|
151
|
-
writable: true,
|
|
152
|
-
})
|
|
112
|
+
setLocation({ pathname: '/test-page', search: '?param=value' })
|
|
153
113
|
expect(utils.getPath({ search: true })).toBe('/test-page?param=value')
|
|
154
114
|
|
|
155
|
-
|
|
156
|
-
Object.defineProperty(window, 'location', {
|
|
157
|
-
value: {
|
|
158
|
-
pathname: '/test-page',
|
|
159
|
-
hash: '#section1',
|
|
160
|
-
search: '?param=value',
|
|
161
|
-
},
|
|
162
|
-
writable: true,
|
|
163
|
-
})
|
|
115
|
+
setLocation({ pathname: '/test-page', hash: '#section1', search: '?param=value' })
|
|
164
116
|
expect(utils.getPath({ hash: true, search: true })).toBe('/test-page#section1?param=value')
|
|
165
117
|
|
|
166
|
-
|
|
167
|
-
Object.defineProperty(window, 'location', {
|
|
168
|
-
value: {
|
|
169
|
-
pathname: '/test-page',
|
|
170
|
-
hash: '#section1?param=value',
|
|
171
|
-
search: '',
|
|
172
|
-
},
|
|
173
|
-
writable: true,
|
|
174
|
-
})
|
|
118
|
+
setLocation({ pathname: '/test-page', hash: '#section1?param=value' })
|
|
175
119
|
expect(utils.getPath({ hash: true, search: true })).toBe('/test-page#section1?param=value')
|
|
176
120
|
})
|
|
177
121
|
|
|
178
122
|
test('getUTM* functions should extract UTM parameters', () => {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
pathname: '/landing',
|
|
183
|
-
hash: '',
|
|
184
|
-
search: '?utm_source=google&utm_medium=cpc&utm_campaign=summer&utm_term=analytics&utm_content=ad1',
|
|
185
|
-
},
|
|
186
|
-
writable: true,
|
|
123
|
+
setLocation({
|
|
124
|
+
pathname: '/landing',
|
|
125
|
+
search: '?utm_source=google&utm_medium=cpc&utm_campaign=summer&utm_term=analytics&utm_content=ad1',
|
|
187
126
|
})
|
|
188
127
|
|
|
189
|
-
// Act & Assert
|
|
190
128
|
expect(utils.getUTMSource()).toBe('google')
|
|
191
129
|
expect(utils.getUTMMedium()).toBe('cpc')
|
|
192
130
|
expect(utils.getUTMCampaign()).toBe('summer')
|
|
193
131
|
expect(utils.getUTMTerm()).toBe('analytics')
|
|
194
132
|
expect(utils.getUTMContent()).toBe('ad1')
|
|
195
133
|
|
|
196
|
-
|
|
197
|
-
Object.defineProperty(window, 'location', {
|
|
198
|
-
value: {
|
|
199
|
-
pathname: '/landing',
|
|
200
|
-
hash: '',
|
|
201
|
-
search: '?ref=twitter',
|
|
202
|
-
},
|
|
203
|
-
writable: true,
|
|
204
|
-
})
|
|
134
|
+
setLocation({ pathname: '/landing', search: '?ref=twitter' })
|
|
205
135
|
expect(utils.getUTMSource()).toBe('twitter')
|
|
206
136
|
|
|
207
|
-
|
|
208
|
-
value: {
|
|
209
|
-
pathname: '/landing',
|
|
210
|
-
hash: '',
|
|
211
|
-
search: '?source=newsletter',
|
|
212
|
-
},
|
|
213
|
-
writable: true,
|
|
214
|
-
})
|
|
137
|
+
setLocation({ pathname: '/landing', search: '?source=newsletter' })
|
|
215
138
|
expect(utils.getUTMSource()).toBe('newsletter')
|
|
216
139
|
})
|
|
217
140
|
})
|
package/tsconfig.esnext.json
CHANGED
package/tsconfig.json
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
"strict": true,
|
|
8
8
|
"sourceMap": true,
|
|
9
9
|
"declaration": false,
|
|
10
|
-
"allowSyntheticDefaultImports": true
|
|
10
|
+
"allowSyntheticDefaultImports": true,
|
|
11
|
+
"baseUrl": ".",
|
|
12
|
+
"paths": {
|
|
13
|
+
"rrweb": ["src/types/rrweb-shim.d.ts"]
|
|
14
|
+
},
|
|
15
|
+
"types": []
|
|
11
16
|
},
|
|
12
17
|
"include": ["src"]
|
|
13
18
|
}
|