/// import { Lib } from '../src/Lib' import { setLocation } from './testUtils' const PROJECT_ID = 'test-project-id' const RRWEB_URL = 'https://cdn.jsdelivr.net/npm/swetrix@latest/dist/replaylibrary.min.js' const mockRrwebRecord = jest.fn() jest.mock('rrweb', () => ({ record: mockRrwebRecord, })) const loadTracker = async () => { jest.resetModules() return import('../src/index') } const resetReplayGlobals = () => { delete (window as any).rrweb delete (window as any).__SWETRIX_RRWEB_LOADING__ document.head.innerHTML = '' document.body.innerHTML = '' } const usePackageRrweb = () => { const stopRecording = jest.fn() mockRrwebRecord.mockImplementation((recordOptions) => { ;(mockRrwebRecord as any).options = recordOptions return stopRecording }) return { recordOptions: () => (mockRrwebRecord as any).options, stopRecording, } } describe('Session replay tracking', () => { let fetchMock: jest.Mock beforeEach(() => { jest.clearAllMocks() mockRrwebRecord.mockReset() delete (mockRrwebRecord as any).options jest.useRealTimers() resetReplayGlobals() setLocation({ hostname: 'example.com', pathname: '/checkout' }) Object.defineProperty(navigator, 'doNotTrack', { value: null, writable: true, configurable: true, }) fetchMock = jest.fn().mockResolvedValue({ ok: true }) Object.defineProperty(globalThis, 'fetch', { value: fetchMock, writable: true, configurable: true, }) }) test('init with preloadSessionReplay loads npm rrweb but does not record', async () => { const { init } = await loadTracker() init(PROJECT_ID, { devMode: true, preloadSessionReplay: true }) await (window as any).__SWETRIX_RRWEB_LOADING__ expect((window as any).rrweb.record).toBe(mockRrwebRecord) expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull() expect(mockRrwebRecord).not.toHaveBeenCalled() expect(fetchMock).not.toHaveBeenCalled() }) test('session replay script uses jsDelivr for the public swetrix.org loader', async () => { const script = document.createElement('script') script.src = 'https://swetrix.org/swetrix.js' document.head.appendChild(script) const { init } = await loadTracker() init(PROJECT_ID, { devMode: true, preloadSessionReplay: true }) expect( document.querySelector( `script[src="${RRWEB_URL}"]`, ), ).toBeTruthy() expect(mockRrwebRecord).not.toHaveBeenCalled() }) test('preloadSessionReplay can load rrweb from a custom script URL', async () => { const rrwebUrl = 'https://cdn.example.com/rrweb.min.js' const { init } = await loadTracker() init(PROJECT_ID, { devMode: true, preloadSessionReplay: { rrwebUrl }, }) expect( document.querySelector(`script[src="${rrwebUrl}"]`), ).toBeTruthy() expect(mockRrwebRecord).not.toHaveBeenCalled() }) test('startSessionReplay imports npm rrweb, records events, and flushes chunks', async () => { const { recordOptions } = usePackageRrweb() const { init, startSessionReplay } = await loadTracker() init(PROJECT_ID, { devMode: true }) const actions = await startSessionReplay({ flushIntervalMs: 60_000, maxEventsPerChunk: 2, }) const options = recordOptions() expect(options.maskTextSelector).toBe('*') expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull() const startCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/session-replay/start'), ) expect(startCall).toBeTruthy() expect(JSON.parse(startCall![1].body as string)).toEqual( expect.objectContaining({ privacy: 'total' }), ) options.emit({ type: 2, timestamp: 100 }) options.emit({ type: 3, timestamp: 200 }) await actions.flush() expect(fetchMock).toHaveBeenCalledWith( 'https://api.swetrix.com/log/session-replay/start', expect.objectContaining({ method: 'POST' }), ) expect(fetchMock).toHaveBeenCalledWith( 'https://api.swetrix.com/log/session-replay/chunk', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"chunkIndex":0'), }), ) await actions.stop() }) test('concurrent startSessionReplay calls share one recorder', async () => { const { stopRecording } = usePackageRrweb() const { init, startSessionReplay } = await loadTracker() init(PROJECT_ID, { devMode: true }) const [firstActions, secondActions] = await Promise.all([ startSessionReplay({ flushIntervalMs: 60_000 }), startSessionReplay({ flushIntervalMs: 10_000 }), ]) expect(firstActions).toBe(secondActions) expect(mockRrwebRecord).toHaveBeenCalledTimes(1) expect( fetchMock.mock.calls.filter(([url]) => String(url).includes('/session-replay/start'), ), ).toHaveLength(1) await firstActions.stop() expect(stopRecording).toHaveBeenCalledTimes(1) }) test('uses backend replay id and chunk index across page loads without browser storage', async () => { const replayId = 'server-replay-id' const setItemSpy = jest.spyOn(Storage.prototype, 'setItem') const getItemSpy = jest.spyOn(Storage.prototype, 'getItem') let startCount = 0 fetchMock.mockImplementation((url) => { if (String(url).includes('/session-replay/start')) { const nextChunkIndex = startCount === 0 ? 0 : 1200 startCount += 1 return Promise.resolve({ ok: true, json: async () => ({ replayId, nextChunkIndex }), }) } return Promise.resolve({ ok: true }) }) const { recordOptions } = usePackageRrweb() const { init, startSessionReplay } = await loadTracker() init(PROJECT_ID, { devMode: true }) const firstActions = await startSessionReplay({ flushIntervalMs: 60_000 }) recordOptions().emit({ type: 2, timestamp: 100 }) await firstActions.flush() const firstChunkCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/session-replay/chunk'), ) const firstChunkBody = JSON.parse(firstChunkCall![1].body as string) expect(firstChunkBody).toEqual( expect.objectContaining({ replayId, chunkIndex: 0, }), ) const secondModule = await loadTracker() secondModule.init(PROJECT_ID, { devMode: true }) const secondActions = await secondModule.startSessionReplay({ flushIntervalMs: 60_000, }) const startCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes('/session-replay/start'), ) expect(startCalls).toHaveLength(2) recordOptions().emit({ type: 2, timestamp: 200 }) await secondActions.flush() const chunkCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes('/session-replay/chunk'), ) const secondChunkBody = JSON.parse(chunkCalls[1][1].body as string) expect(secondChunkBody).toEqual( expect.objectContaining({ replayId, chunkIndex: 1200, }), ) expect(setItemSpy).not.toHaveBeenCalled() expect(getItemSpy).not.toHaveBeenCalled() await secondActions.stop() await firstActions.stop() setItemSpy.mockRestore() getItemSpy.mockRestore() }) test('sampleRate can skip recording before loading rrweb', async () => { const { init, startSessionReplay } = await loadTracker() init(PROJECT_ID, { devMode: true }) const actions = await startSessionReplay({ sampleRate: 0 }) await actions.flush() await actions.stop() expect(fetchMock).not.toHaveBeenCalled() expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull() expect(mockRrwebRecord).not.toHaveBeenCalled() }) test('maxDurationMs stops recording and flushes buffered events', async () => { jest.useFakeTimers() const { recordOptions, stopRecording } = usePackageRrweb() const { init, startSessionReplay } = await loadTracker() init(PROJECT_ID, { devMode: true }) await startSessionReplay({ flushIntervalMs: 60_000, maxDurationMs: 1000, }) recordOptions().emit({ type: 2, timestamp: 100 }) await jest.advanceTimersByTimeAsync(1000) await Promise.resolve() expect(stopRecording).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledWith( 'https://api.swetrix.com/log/session-replay/chunk', expect.objectContaining({ body: expect.stringContaining('"timestamp":100'), }), ) }) test('idleTimeoutMs stops after inactivity and resets on activity', async () => { jest.useFakeTimers() const { recordOptions, stopRecording } = usePackageRrweb() const { init, startSessionReplay } = await loadTracker() init(PROJECT_ID, { devMode: true }) await startSessionReplay({ flushIntervalMs: 60_000, idleTimeoutMs: 1000, }) recordOptions().emit({ type: 2, timestamp: 200 }) await jest.advanceTimersByTimeAsync(900) window.dispatchEvent(new Event('mousemove')) await jest.advanceTimersByTimeAsync(900) expect(stopRecording).not.toHaveBeenCalled() await jest.advanceTimersByTimeAsync(100) await Promise.resolve() expect(stopRecording).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledWith( 'https://api.swetrix.com/log/session-replay/chunk', expect.objectContaining({ body: expect.stringContaining('"timestamp":200'), }), ) }) test('privacy modes map to rrweb options and keep internal emit', () => { const lib = new Lib(PROJECT_ID, { devMode: true }) const emit = jest.fn() const total = (lib as any).getSessionReplayRecordOptions( 'total', { blockSelector: '.secret', emit: jest.fn() }, emit, false, ) expect(total.maskAllInputs).toBe(true) expect(total.maskTextSelector).toBe('*') expect(total.blockSelector).toContain('.secret') expect(total.blockSelector).toContain('iframe') expect(total.blockSelector).toContain('img') expect(total.recordCanvas).toBe(false) expect(total.recordCrossOriginIframes).toBe(false) expect(total.collectFonts).toBe(false) expect(total.inlineImages).toBe(false) expect(total.sampling).toEqual({ mousemove: 50, scroll: 150, input: 'last', }) expect(total.slimDOMOptions).toEqual( expect.objectContaining({ script: true, comment: true, headMetaSocial: true, }), ) expect(total.emit).toBe(emit) const normal = (lib as any).getSessionReplayRecordOptions( 'normal', {}, emit, false, ) expect(normal.maskAllInputs).toBe(true) expect(normal.blockSelector).toBe('iframe') expect(normal.emit).toBe(emit) const freeLove = (lib as any).getSessionReplayRecordOptions( 'none', { maskInputOptions: { email: false } }, emit, false, ) expect(freeLove.blockSelector).toBe('iframe') expect(freeLove.maskInputOptions).toEqual({ email: false, password: true, }) expect(freeLove.emit).toBe(emit) }) test('recordIframes opt-in keeps iframe capture available', () => { const lib = new Lib(PROJECT_ID, { devMode: true }) const emit = jest.fn() const options = (lib as any).getSessionReplayRecordOptions( 'normal', { blockSelector: '.secret', recordCrossOriginIframes: true, sampling: { scroll: 500 }, }, emit, true, ) expect(options.blockSelector).toBe('.secret') expect(options.recordCrossOriginIframes).toBe(true) expect(options.sampling).toEqual({ mousemove: 50, scroll: 500, input: 'last', }) expect(options.emit).toBe(emit) }) test('maskAllText can be enabled outside total privacy', () => { const lib = new Lib(PROJECT_ID, { devMode: true }) const emit = jest.fn() const options = (lib as any).getSessionReplayRecordOptions( 'normal', {}, emit, false, true, ) expect(options.maskAllInputs).toBe(true) expect(options.maskTextSelector).toBe('*') expect(options.emit).toBe(emit) }) test('maskAllText can be disabled for total privacy', () => { const lib = new Lib(PROJECT_ID, { devMode: true }) const emit = jest.fn() const options = (lib as any).getSessionReplayRecordOptions( 'total', { maskTextSelector: '.masked' }, emit, false, false, ) expect(options.maskAllInputs).toBe(true) expect(options.maskTextSelector).toBe('.masked') expect(options.blockSelector).toContain('img') expect(options.emit).toBe(emit) }) test('invalid privacy values fall back to total privacy', async () => { const { recordOptions } = usePackageRrweb() const { init, startSessionReplay } = await loadTracker() init(PROJECT_ID, { devMode: true }) const actions = await startSessionReplay({ privacy: 'totl' as any, }) const startCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/session-replay/start'), ) expect(startCall).toBeTruthy() expect(JSON.parse(startCall![1].body as string)).toEqual( expect.objectContaining({ privacy: 'total' }), ) expect(recordOptions().maskTextSelector).toBe('*') await actions.stop() }) test('oversized replay events are dropped before upload', async () => { const { recordOptions } = usePackageRrweb() const { init, startSessionReplay } = await loadTracker() init(PROJECT_ID, { devMode: true }) const actions = await startSessionReplay({ flushIntervalMs: 60_000, maxBytesPerEvent: 30, }) recordOptions().emit({ type: 3, timestamp: 400, data: { text: 'x'.repeat(200) }, }) await actions.flush() expect( fetchMock.mock.calls.filter(([url]) => String(url).includes('/session-replay/chunk'), ), ).toHaveLength(0) await actions.stop() }) test('fractional byte limits below one fall back to defaults', async () => { const { recordOptions } = usePackageRrweb() const { init, startSessionReplay } = await loadTracker() init(PROJECT_ID, { devMode: true }) const actions = await startSessionReplay({ flushIntervalMs: 60_000, maxBytesPerChunk: 0.5, maxBytesPerEvent: 0.5, }) recordOptions().emit({ type: 3, timestamp: 500 }) await Promise.resolve() await Promise.resolve() expect( fetchMock.mock.calls.filter(([url]) => String(url).includes('/session-replay/chunk'), ), ).toHaveLength(0) await actions.flush() expect(fetchMock).toHaveBeenCalledWith( 'https://api.swetrix.com/log/session-replay/chunk', expect.objectContaining({ body: expect.stringContaining('"timestamp":500'), }), ) await actions.stop() }) test('script rrweb loader clears failed loads so startSessionReplay can retry', async () => { const record = jest.fn(() => jest.fn()) const trackerScript = document.createElement('script') trackerScript.src = 'https://example.com/swetrix.js' document.head.appendChild(trackerScript) const rrwebUrl = 'https://example.com/replaylibrary.min.js' const { init, startSessionReplay } = await loadTracker() init(PROJECT_ID, { devMode: true }) const failedStart = startSessionReplay() const failedScript = document.querySelector( `script[src="${rrwebUrl}"]`, ) expect(failedScript).toBeTruthy() failedScript!.dispatchEvent(new Event('error')) await failedStart expect((window as any).__SWETRIX_RRWEB_LOADING__).toBeUndefined() const retryStart = startSessionReplay() const scripts = document.querySelectorAll( `script[src="${rrwebUrl}"]`, ) expect(scripts[1]).toBeTruthy() expect(scripts[1]).not.toBe(failedScript) ;(window as any).rrweb = { record } scripts[1].dispatchEvent(new Event('load')) const actions = await retryStart expect((window as any).rrweb.record).toBe(record) expect(record).toHaveBeenCalled() await actions.stop() }) test('user rrweb emit is composed with Swetrix uploads', async () => { const { recordOptions } = usePackageRrweb() const userEmit = jest.fn() const { init, startSessionReplay } = await loadTracker() init(PROJECT_ID, { devMode: true }) const actions = await startSessionReplay({ rrweb: { emit: userEmit, maskAllInputs: true }, }) const event = { type: 2, timestamp: 300 } recordOptions().emit(event) await actions.flush() expect(userEmit).toHaveBeenCalledWith(event) expect(fetchMock).toHaveBeenCalledWith( 'https://api.swetrix.com/log/session-replay/chunk', expect.objectContaining({ body: expect.stringContaining('"timestamp":300'), }), ) await actions.stop() }) test('DNT and disabled tracking return no-op controls without uploading', async () => { Object.defineProperty(navigator, 'doNotTrack', { value: '1', writable: true, configurable: true, }) const { init, startSessionReplay } = await loadTracker() init(PROJECT_ID, { devMode: true, respectDNT: true }) const actions = await startSessionReplay() await actions.flush() await actions.stop() expect(fetchMock).not.toHaveBeenCalled() expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull() expect(mockRrwebRecord).not.toHaveBeenCalled() resetReplayGlobals() mockRrwebRecord.mockReset() Object.defineProperty(navigator, 'doNotTrack', { value: null, writable: true, configurable: true, }) const disabledModule = await loadTracker() disabledModule.init(PROJECT_ID, { devMode: true, disabled: true }) const disabledActions = await disabledModule.startSessionReplay() await disabledActions.flush() expect(fetchMock).not.toHaveBeenCalled() expect(document.querySelector(`script[src="${RRWEB_URL}"]`)).toBeNull() expect(mockRrwebRecord).not.toHaveBeenCalled() }) })