///
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()
})
})