diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 8a89431204ca..612bf5a71c10 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -18,7 +18,6 @@ import { handleGlobalEventListener } from './coreHandlers/handleGlobalEvent'; import { handleHistorySpanListener } from './coreHandlers/handleHistory'; import { handleXhrSpanListener } from './coreHandlers/handleXhr'; import { setupPerformanceObserver } from './coreHandlers/performanceObserver'; -import { createPerformanceEntries } from './createPerformanceEntry'; import { createEventBuffer } from './eventBuffer'; import { getSession } from './session/getSession'; import { saveSession } from './session/saveSession'; @@ -39,6 +38,7 @@ import type { import { addEvent } from './util/addEvent'; import { addMemoryEntry } from './util/addMemoryEntry'; import { createBreadcrumb } from './util/createBreadcrumb'; +import { createPerformanceEntries } from './util/createPerformanceEntries'; import { createPerformanceSpans } from './util/createPerformanceSpans'; import { createRecordingData } from './util/createRecordingData'; import { createReplayEnvelope } from './util/createReplayEnvelope'; diff --git a/packages/replay/src/createPerformanceEntry.ts b/packages/replay/src/util/createPerformanceEntries.ts similarity index 81% rename from packages/replay/src/createPerformanceEntry.ts rename to packages/replay/src/util/createPerformanceEntries.ts index e502e4d96247..8088ecc55171 100644 --- a/packages/replay/src/createPerformanceEntry.ts +++ b/packages/replay/src/util/createPerformanceEntries.ts @@ -1,13 +1,13 @@ import { browserPerformanceTimeOrigin } from '@sentry/utils'; import { record } from 'rrweb'; -import { WINDOW } from './constants'; +import { WINDOW } from '../constants'; import type { AllPerformanceEntry, PerformanceNavigationTiming, PerformancePaintTiming, ReplayPerformanceEntry, -} from './types'; +} from '../types'; // Map entryType -> function to normalize data for event // @ts-ignore TODO: entry type does not fit the create* functions entry type @@ -18,7 +18,7 @@ const ENTRY_TYPES: Record null | ReplayP // @ts-ignore TODO: entry type does not fit the create* functions entry type navigation: createNavigationEntry, // @ts-ignore TODO: entry type does not fit the create* functions entry type - 'largest-contentful-paint': createLargestContentfulPaint, + ['largest-contentful-paint']: createLargestContentfulPaint, }; /** @@ -42,7 +42,9 @@ function getAbsoluteTime(time: number): number { return ((browserPerformanceTimeOrigin || WINDOW.performance.timeOrigin) + time) / 1000; } -function createPaintEntry(entry: PerformancePaintTiming): ReplayPerformanceEntry { +// TODO: type definition! +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function createPaintEntry(entry: PerformancePaintTiming) { const { duration, entryType, name, startTime } = entry; const start = getAbsoluteTime(startTime); @@ -54,7 +56,9 @@ function createPaintEntry(entry: PerformancePaintTiming): ReplayPerformanceEntry }; } -function createNavigationEntry(entry: PerformanceNavigationTiming): ReplayPerformanceEntry | null { +// TODO: type definition! +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function createNavigationEntry(entry: PerformanceNavigationTiming) { // TODO: There looks to be some more interesting bits in here (domComplete, domContentLoaded) const { entryType, name, duration, domComplete, startTime, transferSize, type } = entry; @@ -75,7 +79,9 @@ function createNavigationEntry(entry: PerformanceNavigationTiming): ReplayPerfor }; } -function createResourceEntry(entry: PerformanceResourceTiming): ReplayPerformanceEntry | null { +// TODO: type definition! +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function createResourceEntry(entry: PerformanceResourceTiming) { const { entryType, initiatorType, name, responseEnd, startTime, encodedBodySize, transferSize } = entry; // Core SDK handles these @@ -95,9 +101,9 @@ function createResourceEntry(entry: PerformanceResourceTiming): ReplayPerformanc }; } -function createLargestContentfulPaint( - entry: PerformanceEntry & { size: number; element: Node }, -): ReplayPerformanceEntry { +// TODO: type definition! +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function createLargestContentfulPaint(entry: PerformanceEntry & { size: number; element: Node }) { const { duration, entryType, startTime, size } = entry; const start = getAbsoluteTime(startTime); diff --git a/packages/replay/test/integration/autoSaveSession.test.ts b/packages/replay/test/integration/autoSaveSession.test.ts new file mode 100644 index 000000000000..5cdcf3a6525b --- /dev/null +++ b/packages/replay/test/integration/autoSaveSession.test.ts @@ -0,0 +1,57 @@ +import { EventType } from 'rrweb'; + +import type { RecordingEvent } from '../../src/types'; +import { addEvent } from '../../src/util/addEvent'; +import { resetSdkMock } from '../mocks/resetSdkMock'; +import { useFakeTimers } from '../utils/use-fake-timers'; + +useFakeTimers(); + +describe('Integration | autoSaveSession', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test.each([ + ['with stickySession=true', true, 1], + ['with stickySession=false', false, 0], + ])('%s', async (_: string, stickySession: boolean, addSummand: number) => { + let saveSessionSpy; + + jest.mock('../../src/session/saveSession', () => { + saveSessionSpy = jest.fn(); + + return { + saveSession: saveSessionSpy, + }; + }); + + const { replay } = await resetSdkMock({ + replayOptions: { + stickySession, + }, + }); + + // Initially called up to three times: once for start, then once for replay.updateSessionActivity & once for segmentId increase + expect(saveSessionSpy).toHaveBeenCalledTimes(addSummand * 3); + + replay.updateSessionActivity(); + + expect(saveSessionSpy).toHaveBeenCalledTimes(addSummand * 4); + + // In order for runFlush to actually do something, we need to add an event + const event = { + type: EventType.Custom, + data: { + tag: 'test custom', + }, + timestamp: new Date().valueOf(), + } as RecordingEvent; + + addEvent(replay, event); + + await replay.runFlush(); + + expect(saveSessionSpy).toHaveBeenCalledTimes(addSummand * 5); + }); +}); diff --git a/packages/replay/test/unit/index-handleGlobalEvent.test.ts b/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts similarity index 92% rename from packages/replay/test/unit/index-handleGlobalEvent.test.ts rename to packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts index 0103c1ab2240..a3d3ca17ad4f 100644 --- a/packages/replay/test/unit/index-handleGlobalEvent.test.ts +++ b/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts @@ -1,19 +1,22 @@ import { getCurrentHub } from '@sentry/core'; import { Event } from '@sentry/types'; -import { REPLAY_EVENT_NAME } from '../../src/constants'; -import { handleGlobalEventListener } from '../../src/coreHandlers/handleGlobalEvent'; -import { overwriteRecordDroppedEvent, restoreRecordDroppedEvent } from '../../src/util/monkeyPatchRecordDroppedEvent'; -import { ReplayContainer } from './../../src/replay'; -import { Error } from './../fixtures/error'; -import { Transaction } from './../fixtures/transaction'; -import { resetSdkMock } from './../mocks/resetSdkMock'; -import { useFakeTimers } from './../utils/use-fake-timers'; +import { REPLAY_EVENT_NAME } from '../../../src/constants'; +import { handleGlobalEventListener } from '../../../src/coreHandlers/handleGlobalEvent'; +import { ReplayContainer } from '../../../src/replay'; +import { + overwriteRecordDroppedEvent, + restoreRecordDroppedEvent, +} from '../../../src/util/monkeyPatchRecordDroppedEvent'; +import { Error } from '../../fixtures/error'; +import { Transaction } from '../../fixtures/transaction'; +import { resetSdkMock } from '../../mocks/resetSdkMock'; +import { useFakeTimers } from '../../utils/use-fake-timers'; useFakeTimers(); let replay: ReplayContainer; -describe('handleGlobalEvent', () => { +describe('Integration | coreHandlers | handleGlobalEvent', () => { beforeEach(async () => { ({ replay } = await resetSdkMock({ replayOptions: { diff --git a/packages/replay/test/integration/coreHandlers/handleScope.test.ts b/packages/replay/test/integration/coreHandlers/handleScope.test.ts new file mode 100644 index 000000000000..016aba2f9917 --- /dev/null +++ b/packages/replay/test/integration/coreHandlers/handleScope.test.ts @@ -0,0 +1,29 @@ +import { getCurrentHub } from '@sentry/core'; + +import * as HandleScope from '../../../src/coreHandlers/handleScope'; +import { mockSdk } from './../../index'; + +jest.useFakeTimers(); + +describe('Integration | coreHandlers | handleScope', () => { + beforeAll(async function () { + await mockSdk(); + jest.runAllTimers(); + }); + + it('returns a breadcrumb only if last breadcrumb has changed', function () { + const mockHandleScope = jest.spyOn(HandleScope, 'handleScope'); + getCurrentHub().getScope()?.addBreadcrumb({ message: 'testing' }); + + expect(mockHandleScope).toHaveBeenCalledTimes(1); + expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ message: 'testing' })); + + mockHandleScope.mockClear(); + + // This will trigger breadcrumb/scope listener, but handleScope should return + // null because breadcrumbs has not changed + getCurrentHub().getScope()?.setUser({ email: 'foo@foo.com' }); + expect(mockHandleScope).toHaveBeenCalledTimes(1); + expect(mockHandleScope).toHaveReturnedWith(null); + }); +}); diff --git a/packages/replay/test/unit/index-errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts similarity index 97% rename from packages/replay/test/unit/index-errorSampleRate.test.ts rename to packages/replay/test/integration/errorSampleRate.test.ts index a985ff56321d..c8a7237a7951 100644 --- a/packages/replay/test/unit/index-errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -1,14 +1,14 @@ import { captureException } from '@sentry/core'; import { DEFAULT_FLUSH_MIN_DELAY, REPLAY_SESSION_KEY, VISIBILITY_CHANGE_TIMEOUT, WINDOW } from '../../src/constants'; +import { ReplayContainer } from '../../src/replay'; import { addEvent } from '../../src/util/addEvent'; +import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource'; +import { BASE_TIMESTAMP, RecordMock } from '../index'; +import { resetSdkMock } from '../mocks/resetSdkMock'; +import { DomHandler } from '../types'; import { clearSession } from '../utils/clearSession'; -import { ReplayContainer } from './../../src/replay'; -import { PerformanceEntryResource } from './../fixtures/performanceEntry/resource'; -import { BASE_TIMESTAMP, RecordMock } from './../index'; -import { resetSdkMock } from './../mocks/resetSdkMock'; -import { DomHandler } from './../types'; -import { useFakeTimers } from './../utils/use-fake-timers'; +import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); @@ -17,7 +17,7 @@ async function advanceTimers(time: number) { await new Promise(process.nextTick); } -describe('Replay (errorSampleRate)', () => { +describe('Integration | errorSampleRate', () => { let replay: ReplayContainer; let mockRecord: RecordMock; let domHandler: DomHandler; diff --git a/packages/replay/test/integration/eventProcessors.test.ts b/packages/replay/test/integration/eventProcessors.test.ts new file mode 100644 index 000000000000..5b4d96077f08 --- /dev/null +++ b/packages/replay/test/integration/eventProcessors.test.ts @@ -0,0 +1,82 @@ +import { getCurrentHub } from '@sentry/core'; +import { Event, Hub, Scope } from '@sentry/types'; + +import { BASE_TIMESTAMP } from '..'; +import { resetSdkMock } from '../mocks/resetSdkMock'; +import { useFakeTimers } from '../utils/use-fake-timers'; + +useFakeTimers(); + +describe('Integration | eventProcessors', () => { + let hub: Hub; + let scope: Scope; + + beforeEach(() => { + hub = getCurrentHub(); + scope = hub.pushScope(); + }); + + afterEach(() => { + hub.popScope(); + jest.resetAllMocks(); + }); + + it('handles event processors properly', async () => { + const MUTATED_TIMESTAMP = BASE_TIMESTAMP + 3000; + + const { mockRecord } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + }); + + const client = hub.getClient()!; + + jest.runAllTimers(); + const mockTransportSend = jest.spyOn(client.getTransport()!, 'send'); + mockTransportSend.mockReset(); + + const handler1 = jest.fn((event: Event): Event | null => { + event.timestamp = MUTATED_TIMESTAMP; + + return event; + }); + + const handler2 = jest.fn((): Event | null => { + return null; + }); + + scope.addEventProcessor(handler1); + + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; + + mockRecord._emitter(TEST_EVENT); + jest.runAllTimers(); + jest.advanceTimersByTime(1); + await new Promise(process.nextTick); + + expect(mockTransportSend).toHaveBeenCalledTimes(1); + + scope.addEventProcessor(handler2); + + const TEST_EVENT2 = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; + + mockRecord._emitter(TEST_EVENT2); + jest.runAllTimers(); + jest.advanceTimersByTime(1); + await new Promise(process.nextTick); + + expect(mockTransportSend).toHaveBeenCalledTimes(1); + + expect(handler1).toHaveBeenCalledTimes(2); + expect(handler2).toHaveBeenCalledTimes(1); + + // This receives an envelope, which is a deeply nested array + // We only care about the fact that the timestamp was mutated + expect(mockTransportSend).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.arrayContaining([expect.arrayContaining([expect.objectContaining({ timestamp: MUTATED_TIMESTAMP })])]), + ]), + ); + }); +}); diff --git a/packages/replay/test/integration/events.test.ts b/packages/replay/test/integration/events.test.ts new file mode 100644 index 000000000000..e2770101d395 --- /dev/null +++ b/packages/replay/test/integration/events.test.ts @@ -0,0 +1,208 @@ +import { getCurrentHub } from '@sentry/core'; + +import { WINDOW } from '../../src/constants'; +import { ReplayContainer } from '../../src/replay'; +import { addEvent } from '../../src/util/addEvent'; +import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource'; +import { BASE_TIMESTAMP, RecordMock } from '../index'; +import { resetSdkMock } from '../mocks/resetSdkMock'; +import { clearSession } from '../utils/clearSession'; +import { useFakeTimers } from '../utils/use-fake-timers'; + +useFakeTimers(); + +async function advanceTimers(time: number) { + jest.advanceTimersByTime(time); + await new Promise(process.nextTick); +} + +describe('Integration | events', () => { + let replay: ReplayContainer; + let mockRecord: RecordMock; + let mockTransportSend: jest.SpyInstance; + const prevLocation = WINDOW.location; + + type MockSendReplayRequest = jest.MockedFunction; + let mockSendReplayRequest: MockSendReplayRequest; + + beforeAll(async () => { + jest.setSystemTime(new Date(BASE_TIMESTAMP)); + jest.runAllTimers(); + }); + + beforeEach(async () => { + ({ mockRecord, replay } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + })); + + mockTransportSend = jest.spyOn(getCurrentHub().getClient()!.getTransport()!, 'send'); + + jest.spyOn(replay, 'flush'); + jest.spyOn(replay, 'runFlush'); + jest.spyOn(replay, 'sendReplayRequest'); + + // Create a new session and clear mocks because a segment (from initial + // checkout) will have already been uploaded by the time the tests run + clearSession(replay); + replay.loadSession({ expiry: 0 }); + mockTransportSend.mockClear(); + mockSendReplayRequest = replay.sendReplayRequest as MockSendReplayRequest; + mockSendReplayRequest.mockClear(); + }); + + afterEach(async () => { + jest.runAllTimers(); + await new Promise(process.nextTick); + Object.defineProperty(WINDOW, 'location', { + value: prevLocation, + writable: true, + }); + clearSession(replay); + jest.clearAllMocks(); + mockSendReplayRequest.mockRestore(); + mockRecord.takeFullSnapshot.mockClear(); + replay.stop(); + }); + + it('does not create replay event when there are no events to send', async () => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'hidden'; + }, + }); + + document.dispatchEvent(new Event('visibilitychange')); + await new Promise(process.nextTick); + expect(replay).not.toHaveLastSentReplay(); + + // Pretend 5 seconds have passed + const ELAPSED = 5000; + await advanceTimers(ELAPSED); + + const TEST_EVENT = { + data: {}, + timestamp: BASE_TIMESTAMP + ELAPSED, + type: 2, + }; + + addEvent(replay, TEST_EVENT); + WINDOW.dispatchEvent(new Event('blur')); + await new Promise(process.nextTick); + + expect(replay).toHaveLastSentReplay({ + replayEventPayload: expect.objectContaining({ + replay_start_timestamp: BASE_TIMESTAMP / 1000, + urls: ['http://localhost/'], // this doesn't truly test if we are capturing the right URL as we don't change URLs, but good enough + }), + }); + }); + + it('has correct timestamps when there are events earlier than initial timestamp', async function () { + clearSession(replay); + replay.loadSession({ expiry: 0 }); + mockTransportSend.mockClear(); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'hidden'; + }, + }); + + document.dispatchEvent(new Event('visibilitychange')); + await new Promise(process.nextTick); + expect(replay).not.toHaveLastSentReplay(); + + // Pretend 5 seconds have passed + const ELAPSED = 5000; + await advanceTimers(ELAPSED); + + const TEST_EVENT = { + data: {}, + timestamp: BASE_TIMESTAMP + ELAPSED, + type: 2, + }; + + addEvent(replay, TEST_EVENT); + + // Add a fake event that started BEFORE + addEvent(replay, { + data: {}, + timestamp: (BASE_TIMESTAMP - 10000) / 1000, + type: 5, + }); + + WINDOW.dispatchEvent(new Event('blur')); + await new Promise(process.nextTick); + expect(replay).toHaveLastSentReplay({ + replayEventPayload: expect.objectContaining({ + replay_start_timestamp: (BASE_TIMESTAMP - 10000) / 1000, + urls: ['http://localhost/'], // this doesn't truly test if we are capturing the right URL as we don't change URLs, but good enough + tags: expect.objectContaining({ + errorSampleRate: 0, + sessionSampleRate: 1, + }), + }), + }); + }); + + it('does not have stale `replay_start_timestamp` due to an old time origin', async function () { + const ELAPSED = 86400000 * 2; // 2 days + // Add a mock performance event that happens 2 days ago. This can happen in the browser + // when a tab has sat idle for a long period and user comes back to it. + // + // We pass a negative start time as it's a bit difficult to mock + // `@sentry/utils/browserPerformanceTimeOrigin`. This would not happen in + // real world. + replay.performanceEvents.push( + PerformanceEntryResource({ + startTime: -ELAPSED, + }), + ); + + // This should be null because `addEvent` has not been called yet + expect(replay.getContext().earliestEvent).toBe(null); + expect(mockTransportSend).toHaveBeenCalledTimes(0); + + // A new checkout occurs (i.e. a new session was started) + const TEST_EVENT = { + data: {}, + timestamp: BASE_TIMESTAMP, + type: 2, + }; + + addEvent(replay, TEST_EVENT); + // This event will trigger a flush + WINDOW.dispatchEvent(new Event('blur')); + jest.runAllTimers(); + await new Promise(process.nextTick); + + expect(mockTransportSend).toHaveBeenCalledTimes(1); + expect(replay).toHaveLastSentReplay({ + replayEventPayload: expect.objectContaining({ + // Make sure the old performance event is thrown out + replay_start_timestamp: BASE_TIMESTAMP / 1000, + }), + events: JSON.stringify([ + TEST_EVENT, + { + type: 5, + timestamp: BASE_TIMESTAMP / 1000, + data: { + tag: 'breadcrumb', + payload: { + timestamp: BASE_TIMESTAMP / 1000, + type: 'default', + category: 'ui.blur', + }, + }, + }, + ]), + }); + + // This gets reset after sending replay + expect(replay.getContext().earliestEvent).toBe(null); + }); +}); diff --git a/packages/replay/test/integration/flush.test.ts b/packages/replay/test/integration/flush.test.ts new file mode 100644 index 000000000000..c43729d79de1 --- /dev/null +++ b/packages/replay/test/integration/flush.test.ts @@ -0,0 +1,252 @@ +import * as SentryUtils from '@sentry/utils'; + +import { DEFAULT_FLUSH_MIN_DELAY, SESSION_IDLE_DURATION, WINDOW } from '../../src/constants'; +import { ReplayContainer } from '../../src/replay'; +import * as AddMemoryEntry from '../../src/util/addMemoryEntry'; +import { createPerformanceEntries } from '../../src/util/createPerformanceEntries'; +import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; +import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; +import { clearSession } from '../utils/clearSession'; +import { useFakeTimers } from '../utils/use-fake-timers'; + +useFakeTimers(); + +async function advanceTimers(time: number) { + jest.advanceTimersByTime(time); + await new Promise(process.nextTick); +} + +type MockSendReplay = jest.MockedFunction; +type MockAddPerformanceEntries = jest.MockedFunction; +type MockAddMemoryEntry = jest.SpyInstance; +type MockEventBufferFinish = jest.MockedFunction['finish']>; +type MockFlush = jest.MockedFunction; +type MockRunFlush = jest.MockedFunction; + +const prevLocation = WINDOW.location; + +describe('Integration | flush', () => { + let domHandler: (args: any) => any; + + const { record: mockRecord } = mockRrweb(); + + let replay: ReplayContainer; + let mockSendReplay: MockSendReplay; + let mockFlush: MockFlush; + let mockRunFlush: MockRunFlush; + let mockEventBufferFinish: MockEventBufferFinish; + let mockAddMemoryEntry: MockAddMemoryEntry; + let mockAddPerformanceEntries: MockAddPerformanceEntries; + + beforeAll(async () => { + jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => { + if (type === 'dom') { + domHandler = handler; + } + }); + + ({ replay } = await mockSdk()); + jest.spyOn(replay, 'sendReplay'); + mockSendReplay = replay.sendReplay as MockSendReplay; + mockSendReplay.mockImplementation( + jest.fn(async () => { + return; + }), + ); + + jest.spyOn(replay, 'flush'); + mockFlush = replay.flush as MockFlush; + + jest.spyOn(replay, 'runFlush'); + mockRunFlush = replay.runFlush as MockRunFlush; + + jest.spyOn(replay, 'addPerformanceEntries'); + mockAddPerformanceEntries = replay.addPerformanceEntries as MockAddPerformanceEntries; + + mockAddPerformanceEntries.mockImplementation(async () => { + return []; + }); + + mockAddMemoryEntry = jest.spyOn(AddMemoryEntry, 'addMemoryEntry'); + }); + + beforeEach(() => { + jest.runAllTimers(); + jest.setSystemTime(new Date(BASE_TIMESTAMP)); + mockSendReplay.mockClear(); + replay.eventBuffer?.destroy(); + mockAddPerformanceEntries.mockClear(); + mockFlush.mockClear(); + mockRunFlush.mockClear(); + mockAddMemoryEntry.mockClear(); + + if (replay.eventBuffer) { + jest.spyOn(replay.eventBuffer, 'finish'); + } + mockEventBufferFinish = replay.eventBuffer?.finish as MockEventBufferFinish; + mockEventBufferFinish.mockClear(); + }); + + afterEach(async () => { + jest.runAllTimers(); + await new Promise(process.nextTick); + jest.setSystemTime(new Date(BASE_TIMESTAMP)); + sessionStorage.clear(); + clearSession(replay); + replay.loadSession({ expiry: SESSION_IDLE_DURATION }); + mockRecord.takeFullSnapshot.mockClear(); + Object.defineProperty(WINDOW, 'location', { + value: prevLocation, + writable: true, + }); + }); + + afterAll(() => { + replay && replay.stop(); + }); + + it('flushes twice after multiple flush() calls)', async () => { + // blur events cause an immediate flush (as well as a flush due to adding a + // breadcrumb) -- this means that the first blur event will be flushed and + // the following blur events will all call a debounced flush function, which + // should end up queueing a second flush + + WINDOW.dispatchEvent(new Event('blur')); + WINDOW.dispatchEvent(new Event('blur')); + WINDOW.dispatchEvent(new Event('blur')); + WINDOW.dispatchEvent(new Event('blur')); + + expect(replay.flush).toHaveBeenCalledTimes(4); + + jest.runAllTimers(); + await new Promise(process.nextTick); + expect(replay.runFlush).toHaveBeenCalledTimes(1); + + jest.runAllTimers(); + await new Promise(process.nextTick); + expect(replay.runFlush).toHaveBeenCalledTimes(2); + + jest.runAllTimers(); + await new Promise(process.nextTick); + expect(replay.runFlush).toHaveBeenCalledTimes(2); + }); + + it('long first flush enqueues following events', async () => { + // Mock this to resolve after 20 seconds so that we can queue up following flushes + mockAddPerformanceEntries.mockImplementationOnce(async () => { + return await new Promise(resolve => setTimeout(resolve, 20000)); + }); + + expect(mockAddPerformanceEntries).not.toHaveBeenCalled(); + + // flush #1 @ t=0s - due to blur + WINDOW.dispatchEvent(new Event('blur')); + expect(replay.flush).toHaveBeenCalledTimes(1); + expect(replay.runFlush).toHaveBeenCalledTimes(1); + + // This will attempt to flush in 5 seconds (flushMinDelay) + domHandler({ + name: 'click', + }); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + // flush #2 @ t=5s - due to click + expect(replay.flush).toHaveBeenCalledTimes(2); + + await advanceTimers(1000); + // flush #3 @ t=6s - due to blur + WINDOW.dispatchEvent(new Event('blur')); + expect(replay.flush).toHaveBeenCalledTimes(3); + + // NOTE: Blur also adds a breadcrumb which calls `addUpdate`, meaning it will + // flush after `flushMinDelay`, but this gets cancelled by the blur + await advanceTimers(8000); + expect(replay.flush).toHaveBeenCalledTimes(3); + + // flush #4 @ t=14s - due to blur + WINDOW.dispatchEvent(new Event('blur')); + expect(replay.flush).toHaveBeenCalledTimes(4); + + expect(replay.runFlush).toHaveBeenCalledTimes(1); + await advanceTimers(6000); + // t=20s + // addPerformanceEntries is finished, `flushLock` promise is resolved, calls + // debouncedFlush, which will call `flush` in 1 second + expect(replay.flush).toHaveBeenCalledTimes(4); + // sendReplay is called with replayId, events, segment + expect(mockSendReplay).toHaveBeenLastCalledWith({ + events: expect.any(String), + replayId: expect.any(String), + includeReplayStartTimestamp: true, + segmentId: 0, + eventContext: expect.anything(), + }); + + // Add this to test that segment ID increases + mockAddPerformanceEntries.mockImplementationOnce(() => { + createPerformanceSpans( + replay, + createPerformanceEntries([ + { + name: 'https://sentry.io/foo.js', + entryType: 'resource', + startTime: 176.59999990463257, + duration: 5.600000023841858, + initiatorType: 'link', + nextHopProtocol: 'h2', + workerStart: 177.5, + redirectStart: 0, + redirectEnd: 0, + fetchStart: 177.69999992847443, + domainLookupStart: 177.69999992847443, + domainLookupEnd: 177.69999992847443, + connectStart: 177.69999992847443, + connectEnd: 177.69999992847443, + secureConnectionStart: 177.69999992847443, + requestStart: 177.5, + responseStart: 181, + responseEnd: 182.19999992847443, + transferSize: 0, + encodedBodySize: 0, + decodedBodySize: 0, + serverTiming: [], + } as unknown as PerformanceResourceTiming, + ]), + ); + }); + // flush #5 @ t=25s - debounced flush calls `flush` + // 20s + `flushMinDelay` which is 5 seconds + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + expect(replay.flush).toHaveBeenCalledTimes(5); + expect(replay.runFlush).toHaveBeenCalledTimes(2); + expect(mockSendReplay).toHaveBeenLastCalledWith({ + events: expect.any(String), + replayId: expect.any(String), + includeReplayStartTimestamp: false, + segmentId: 1, + eventContext: expect.anything(), + }); + + // Make sure there's no other calls + jest.runAllTimers(); + await new Promise(process.nextTick); + expect(mockSendReplay).toHaveBeenCalledTimes(2); + }); + + it('has single flush when checkout flush and debounce flush happen near simultaneously', async () => { + // click happens first + domHandler({ + name: 'click', + }); + + // checkout + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; + mockRecord._emitter(TEST_EVENT); + + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + expect(replay.flush).toHaveBeenCalledTimes(1); + + // Make sure there's nothing queued up after + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + expect(replay.flush).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/replay/test/unit/index-integrationSettings.test.ts b/packages/replay/test/integration/integrationSettings.test.ts similarity index 98% rename from packages/replay/test/unit/index-integrationSettings.test.ts rename to packages/replay/test/integration/integrationSettings.test.ts index c78214b8c2ea..6c63839b1c06 100644 --- a/packages/replay/test/unit/index-integrationSettings.test.ts +++ b/packages/replay/test/integration/integrationSettings.test.ts @@ -1,7 +1,7 @@ import { MASK_ALL_TEXT_SELECTOR } from '../../src/constants'; -import { mockSdk } from './../index'; +import { mockSdk } from '../index'; -describe('integration settings', () => { +describe('Integration | integrationSettings', () => { beforeEach(() => { jest.resetModules(); }); diff --git a/packages/replay/test/integration/rrweb.test.ts b/packages/replay/test/integration/rrweb.test.ts new file mode 100644 index 000000000000..15c8cdba432b --- /dev/null +++ b/packages/replay/test/integration/rrweb.test.ts @@ -0,0 +1,31 @@ +import { MASK_ALL_TEXT_SELECTOR } from '../../src/constants'; +import { resetSdkMock } from '../mocks/resetSdkMock'; +import { useFakeTimers } from '../utils/use-fake-timers'; + +useFakeTimers(); + +describe('Integration | rrweb', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('calls rrweb.record with custom options', async () => { + const { mockRecord } = await resetSdkMock({ + replayOptions: { + ignoreClass: 'sentry-test-ignore', + stickySession: false, + }, + }); + expect(mockRecord.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "blockClass": "sentry-block", + "blockSelector": "[data-sentry-block],img,image,svg,path,rect,area,video,object,picture,embed,map,audio", + "emit": [Function], + "ignoreClass": "sentry-test-ignore", + "maskAllInputs": true, + "maskTextClass": "sentry-mask", + "maskTextSelector": "${MASK_ALL_TEXT_SELECTOR}", + } + `); + }); +}); diff --git a/packages/replay/test/unit/index-sampling.test.ts b/packages/replay/test/integration/sampling.test.ts similarity index 69% rename from packages/replay/test/unit/index-sampling.test.ts rename to packages/replay/test/integration/sampling.test.ts index 1ce841128895..d17052a4e7f6 100644 --- a/packages/replay/test/unit/index-sampling.test.ts +++ b/packages/replay/test/integration/sampling.test.ts @@ -1,10 +1,9 @@ -// mock functions need to be imported first -import { mockRrweb, mockSdk } from './../index'; -import { useFakeTimers } from './../utils/use-fake-timers'; +import { mockRrweb, mockSdk } from '../index'; +import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); -describe('Replay (sampling)', () => { +describe('Integration | sampling', () => { it('does nothing if not sampled', async () => { const { record: mockRecord } = mockRrweb(); const { replay } = await mockSdk({ @@ -19,13 +18,10 @@ describe('Replay (sampling)', () => { jest.spyOn(replay, 'loadSession'); jest.spyOn(replay, 'addListeners'); - // @ts-ignore private - expect(replay.initialState).toEqual(undefined); jest.runAllTimers(); expect(replay.session?.sampled).toBe(false); - // @ts-ignore private - expect(replay._context).toEqual( + expect(replay.getContext()).toEqual( expect.objectContaining({ initialTimestamp: expect.any(Number), initialUrl: 'http://localhost/', diff --git a/packages/replay/test/integration/sendReplayEvent.test.ts b/packages/replay/test/integration/sendReplayEvent.test.ts new file mode 100644 index 000000000000..1701a0a48826 --- /dev/null +++ b/packages/replay/test/integration/sendReplayEvent.test.ts @@ -0,0 +1,435 @@ +import { getCurrentHub } from '@sentry/core'; +import { Transport } from '@sentry/types'; +import * as SentryUtils from '@sentry/utils'; + +import { DEFAULT_FLUSH_MIN_DELAY, SESSION_IDLE_DURATION, WINDOW } from '../../src/constants'; +import { ReplayContainer } from '../../src/replay'; +import { addEvent } from '../../src/util/addEvent'; +import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; +import { clearSession } from '../utils/clearSession'; +import { useFakeTimers } from '../utils/use-fake-timers'; + +useFakeTimers(); + +async function advanceTimers(time: number) { + jest.advanceTimersByTime(time); + await new Promise(process.nextTick); +} + +type MockTransportSend = jest.MockedFunction; +type MockSendReplayRequest = jest.MockedFunction; + +describe('Integration | sendReplayEvent', () => { + let replay: ReplayContainer; + let mockTransportSend: MockTransportSend; + let mockSendReplayRequest: MockSendReplayRequest; + let domHandler: (args: any) => any; + const { record: mockRecord } = mockRrweb(); + + beforeAll(async () => { + jest.setSystemTime(new Date(BASE_TIMESTAMP)); + jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => { + if (type === 'dom') { + domHandler = handler; + } + }); + + ({ replay } = await mockSdk({ + replayOptions: { + stickySession: false, + }, + })); + + jest.spyOn(replay, 'sendReplayRequest'); + + jest.runAllTimers(); + mockTransportSend = getCurrentHub()?.getClient()?.getTransport()?.send as MockTransportSend; + mockSendReplayRequest = replay.sendReplayRequest as MockSendReplayRequest; + }); + + beforeEach(() => { + jest.setSystemTime(new Date(BASE_TIMESTAMP)); + mockRecord.takeFullSnapshot.mockClear(); + mockTransportSend.mockClear(); + + // Create a new session and clear mocks because a segment (from initial + // checkout) will have already been uploaded by the time the tests run + clearSession(replay); + replay.loadSession({ expiry: 0 }); + + mockSendReplayRequest.mockClear(); + }); + + afterEach(async () => { + jest.runAllTimers(); + await new Promise(process.nextTick); + jest.setSystemTime(new Date(BASE_TIMESTAMP)); + clearSession(replay); + replay.loadSession({ expiry: SESSION_IDLE_DURATION }); + }); + + afterAll(() => { + replay && replay.stop(); + }); + + it('uploads a replay event when document becomes hidden', async () => { + mockRecord.takeFullSnapshot.mockClear(); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'hidden'; + }, + }); + + // Pretend 5 seconds have passed + const ELAPSED = 5000; + jest.advanceTimersByTime(ELAPSED); + + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; + addEvent(replay, TEST_EVENT); + + document.dispatchEvent(new Event('visibilitychange')); + + await new Promise(process.nextTick); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + + expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + + // Session's last activity is not updated because we do not consider + // visibilitystate as user being active + expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); + expect(replay.session?.segmentId).toBe(1); + + // events array should be empty + expect(replay.eventBuffer?.length).toBe(0); + }); + + it('update last activity when user clicks mouse', async () => { + expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); + + domHandler({ + name: 'click', + }); + + expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); + + // Pretend 5 seconds have passed + const ELAPSED = 5000; + jest.advanceTimersByTime(ELAPSED); + + domHandler({ + name: 'click', + }); + + expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP + ELAPSED); + }); + + it('uploads a replay event if 5 seconds have elapsed since the last replay event occurred', async () => { + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; + mockRecord._emitter(TEST_EVENT); + // Pretend 5 seconds have passed + const ELAPSED = 5000; + await advanceTimers(ELAPSED); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + + expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + + // No user activity to trigger an update + expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); + expect(replay.session?.segmentId).toBe(1); + + // events array should be empty + expect(replay.eventBuffer?.length).toBe(0); + }); + + it('uploads a replay event if 15 seconds have elapsed since the last replay upload', async () => { + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; + // Fire a new event every 4 seconds, 4 times + [...Array(4)].forEach(() => { + mockRecord._emitter(TEST_EVENT); + jest.advanceTimersByTime(4000); + }); + + // We are at time = +16seconds now (relative to BASE_TIMESTAMP) + // The next event should cause an upload immediately + mockRecord._emitter(TEST_EVENT); + await new Promise(process.nextTick); + + expect(replay).toHaveLastSentReplay({ + events: JSON.stringify([...Array(5)].map(() => TEST_EVENT)), + }); + + // There should also not be another attempt at an upload 5 seconds after the last replay event + mockTransportSend.mockClear(); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + expect(replay).not.toHaveLastSentReplay(); + + expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); + expect(replay.session?.segmentId).toBe(1); + // events array should be empty + expect(replay.eventBuffer?.length).toBe(0); + + // Let's make sure it continues to work + mockTransportSend.mockClear(); + mockRecord._emitter(TEST_EVENT); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + }); + + it('uploads a replay event when WINDOW is blurred', async () => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'hidden'; + }, + }); + + // Pretend 5 seconds have passed + const ELAPSED = 5000; + jest.advanceTimersByTime(ELAPSED); + + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; + const hiddenBreadcrumb = { + type: 5, + timestamp: +new Date(BASE_TIMESTAMP + ELAPSED) / 1000, + data: { + tag: 'breadcrumb', + payload: { + timestamp: +new Date(BASE_TIMESTAMP + ELAPSED) / 1000, + type: 'default', + category: 'ui.blur', + }, + }, + }; + + addEvent(replay, TEST_EVENT); + WINDOW.dispatchEvent(new Event('blur')); + await new Promise(process.nextTick); + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay).toHaveLastSentReplay({ + events: JSON.stringify([TEST_EVENT, hiddenBreadcrumb]), + }); + // Session's last activity should not be updated + expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); + // events array should be empty + expect(replay.eventBuffer?.length).toBe(0); + }); + + it('uploads a replay event when document becomes hidden', async () => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'hidden'; + }, + }); + // Pretend 5 seconds have passed + const ELAPSED = 5000; + jest.advanceTimersByTime(ELAPSED); + + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; + + addEvent(replay, TEST_EVENT); + document.dispatchEvent(new Event('visibilitychange')); + jest.runAllTimers(); + await new Promise(process.nextTick); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + + // Session's last activity is not updated because we do not consider + // visibilitystate as user being active + expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); + // events array should be empty + expect(replay.eventBuffer?.length).toBe(0); + }); + + it('uploads a replay event if 5 seconds have elapsed since the last replay event occurred', async () => { + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; + mockRecord._emitter(TEST_EVENT); + // Pretend 5 seconds have passed + const ELAPSED = 5000; + await advanceTimers(ELAPSED); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(mockTransportSend).toHaveBeenCalledTimes(1); + expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + + // No user activity to trigger an update + expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); + expect(replay.session?.segmentId).toBe(1); + + // events array should be empty + expect(replay.eventBuffer?.length).toBe(0); + }); + + it('uploads a replay event if 15 seconds have elapsed since the last replay upload', async () => { + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; + // Fire a new event every 4 seconds, 4 times + [...Array(4)].forEach(() => { + mockRecord._emitter(TEST_EVENT); + jest.advanceTimersByTime(4000); + }); + + // We are at time = +16seconds now (relative to BASE_TIMESTAMP) + // The next event should cause an upload immediately + mockRecord._emitter(TEST_EVENT); + await new Promise(process.nextTick); + + expect(replay).toHaveLastSentReplay({ + events: JSON.stringify([...Array(5)].map(() => TEST_EVENT)), + }); + + // There should also not be another attempt at an upload 5 seconds after the last replay event + mockTransportSend.mockClear(); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + + expect(replay).not.toHaveLastSentReplay(); + + expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); + expect(replay.session?.segmentId).toBe(1); + // events array should be empty + expect(replay.eventBuffer?.length).toBe(0); + + // Let's make sure it continues to work + mockTransportSend.mockClear(); + mockRecord._emitter(TEST_EVENT); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); + }); + + it('uploads a dom breadcrumb 5 seconds after listener receives an event', async () => { + domHandler({ + name: 'click', + }); + + // Pretend 5 seconds have passed + const ELAPSED = 5000; + await advanceTimers(ELAPSED); + + expect(replay).toHaveLastSentReplay({ + events: JSON.stringify([ + { + type: 5, + timestamp: BASE_TIMESTAMP, + data: { + tag: 'breadcrumb', + payload: { + timestamp: BASE_TIMESTAMP / 1000, + type: 'default', + category: 'ui.click', + message: '', + data: {}, + }, + }, + }, + ]), + }); + + expect(replay.session?.segmentId).toBe(1); + }); + + it('fails to upload data on first two calls and succeeds on the third', async () => { + expect(replay.session?.segmentId).toBe(0); + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; + + // Suppress console.errors + const mockConsole = jest.spyOn(console, 'error').mockImplementation(jest.fn()); + + // fail the first and second requests and pass the third one + mockTransportSend.mockImplementationOnce(() => { + throw new Error('Something bad happened'); + }); + mockRecord._emitter(TEST_EVENT); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + mockTransportSend.mockImplementationOnce(() => { + throw new Error('Something bad happened'); + }); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + + // next tick should retry and succeed + mockConsole.mockRestore(); + + await advanceTimers(8000); + await advanceTimers(2000); + + expect(replay).toHaveLastSentReplay({ + replayEventPayload: expect.objectContaining({ + error_ids: [], + replay_id: expect.any(String), + replay_start_timestamp: BASE_TIMESTAMP / 1000, + // 20seconds = Add up all of the previous `advanceTimers()` + timestamp: (BASE_TIMESTAMP + 20000) / 1000 + 0.02, + trace_ids: [], + urls: ['http://localhost/'], + }), + recordingPayloadHeader: { segment_id: 0 }, + events: JSON.stringify([TEST_EVENT]), + }); + + mockTransportSend.mockClear(); + // No activity has occurred, session's last activity should remain the same + expect(replay.session?.lastActivity).toBeGreaterThanOrEqual(BASE_TIMESTAMP); + expect(replay.session?.segmentId).toBe(1); + + // next tick should do nothing + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + expect(replay).not.toHaveLastSentReplay(); + }); + + it('fails to upload data and hits retry max and stops', async () => { + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; + jest.spyOn(replay, 'sendReplay'); + + // Suppress console.errors + const mockConsole = jest.spyOn(console, 'error').mockImplementation(jest.fn()); + + // Check errors + const spyHandleException = jest.spyOn(replay, 'handleException'); + + expect(replay.session?.segmentId).toBe(0); + + // fail the first and second requests and pass the third one + mockSendReplayRequest.mockReset(); + mockSendReplayRequest.mockImplementation(() => { + throw new Error('Something bad happened'); + }); + mockRecord._emitter(TEST_EVENT); + + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay.sendReplayRequest).toHaveBeenCalledTimes(1); + + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + expect(replay.sendReplayRequest).toHaveBeenCalledTimes(2); + + await advanceTimers(10000); + expect(replay.sendReplayRequest).toHaveBeenCalledTimes(3); + + await advanceTimers(30000); + expect(replay.sendReplayRequest).toHaveBeenCalledTimes(4); + expect(replay.sendReplay).toHaveBeenCalledTimes(4); + + mockConsole.mockReset(); + + // Make sure it doesn't retry again + jest.runAllTimers(); + expect(replay.sendReplayRequest).toHaveBeenCalledTimes(4); + expect(replay.sendReplay).toHaveBeenCalledTimes(4); + + // Retries = 3 (total tries = 4 including initial attempt) + // + last exception is max retries exceeded + expect(spyHandleException).toHaveBeenCalledTimes(5); + expect(spyHandleException).toHaveBeenLastCalledWith(new Error('Unable to send Replay - max retries exceeded')); + + // No activity has occurred, session's last activity should remain the same + expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); + + // segmentId increases despite error + expect(replay.session?.segmentId).toBe(1); + }); +}); diff --git a/packages/replay/test/integration/session.test.ts b/packages/replay/test/integration/session.test.ts new file mode 100644 index 000000000000..61b08e292582 --- /dev/null +++ b/packages/replay/test/integration/session.test.ts @@ -0,0 +1,486 @@ +import { getCurrentHub } from '@sentry/core'; +import { Transport } from '@sentry/types'; + +import { + DEFAULT_FLUSH_MIN_DELAY, + MAX_SESSION_LIFE, + REPLAY_SESSION_KEY, + VISIBILITY_CHANGE_TIMEOUT, + WINDOW, +} from '../../src/constants'; +import { ReplayContainer } from '../../src/replay'; +import { addEvent } from '../../src/util/addEvent'; +import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; +import { BASE_TIMESTAMP } from '../index'; +import { RecordMock } from '../mocks/mockRrweb'; +import { resetSdkMock } from '../mocks/resetSdkMock'; +import { clearSession } from '../utils/clearSession'; +import { useFakeTimers } from '../utils/use-fake-timers'; + +useFakeTimers(); + +async function advanceTimers(time: number) { + jest.advanceTimersByTime(time); + await new Promise(process.nextTick); +} + +const prevLocation = WINDOW.location; + +describe('Integration | session', () => { + let replay: ReplayContainer; + let domHandler: (args: any) => any; + let mockRecord: RecordMock; + + beforeEach(async () => { + ({ mockRecord, domHandler, replay } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + })); + + const mockTransport = getCurrentHub()?.getClient()?.getTransport()?.send as jest.MockedFunction; + mockTransport?.mockClear(); + }); + + afterEach(async () => { + replay.stop(); + + jest.runAllTimers(); + await new Promise(process.nextTick); + jest.setSystemTime(new Date(BASE_TIMESTAMP)); + + Object.defineProperty(WINDOW, 'location', { + value: prevLocation, + writable: true, + }); + }); + + it('creates a new session and triggers a full dom snapshot when document becomes visible after [VISIBILITY_CHANGE_TIMEOUT]ms', () => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'visible'; + }, + }); + + const initialSession = replay.session; + + jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1); + + document.dispatchEvent(new Event('visibilitychange')); + + expect(mockRecord.takeFullSnapshot).toHaveBeenLastCalledWith(true); + + // Should have created a new session + expect(replay).not.toHaveSameSession(initialSession); + }); + + it('does not create a new session if user hides the tab and comes back within [VISIBILITY_CHANGE_TIMEOUT] seconds', () => { + const initialSession = replay.session; + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'hidden'; + }, + }); + document.dispatchEvent(new Event('visibilitychange')); + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay).toHaveSameSession(initialSession); + + // User comes back before `VISIBILITY_CHANGE_TIMEOUT` elapses + jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT - 1); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'visible'; + }, + }); + document.dispatchEvent(new Event('visibilitychange')); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + // Should NOT have created a new session + expect(replay).toHaveSameSession(initialSession); + }); + + it('creates a new session if user has been idle for more than 15 minutes and comes back to move their mouse', async () => { + const initialSession = replay.session; + + expect(initialSession?.id).toBeDefined(); + + // Idle for 15 minutes + const FIFTEEN_MINUTES = 15 * 60000; + jest.advanceTimersByTime(FIFTEEN_MINUTES); + + // TBD: We are currently deciding that this event will get dropped, but + // this could/should change in the future. + const TEST_EVENT = { + data: { name: 'lost event' }, + timestamp: BASE_TIMESTAMP, + type: 3, + }; + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + + await new Promise(process.nextTick); + + // Instead of recording the above event, a full snapshot will occur. + // + // TODO: We could potentially figure out a way to save the last session, + // and produce a checkout based on a previous checkout + updates, and then + // replay the event on top. Or maybe replay the event on top of a refresh + // snapshot. + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledWith(true); + + // Should be a new session + expect(replay).not.toHaveSameSession(initialSession); + + // Replay does not send immediately because checkout was due to expired session + expect(replay).not.toHaveLastSentReplay(); + + // Now do a click + domHandler({ + name: 'click', + }); + + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + + const newTimestamp = BASE_TIMESTAMP + FIFTEEN_MINUTES; + const breadcrumbTimestamp = newTimestamp + 20; // I don't know where this 20ms comes from + + expect(replay).toHaveLastSentReplay({ + events: JSON.stringify([ + { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, + { + type: 5, + timestamp: breadcrumbTimestamp, + data: { + tag: 'breadcrumb', + payload: { + timestamp: breadcrumbTimestamp / 1000, + type: 'default', + category: 'ui.click', + message: '', + data: {}, + }, + }, + }, + ]), + }); + }); + + it('should have a session after setup', () => { + expect(replay.session).toMatchObject({ + lastActivity: BASE_TIMESTAMP, + started: BASE_TIMESTAMP, + }); + expect(replay.session?.id).toBeDefined(); + expect(replay.session?.segmentId).toBeDefined(); + }); + + it('clears session', () => { + clearSession(replay); + expect(WINDOW.sessionStorage.getItem(REPLAY_SESSION_KEY)).toBe(null); + expect(replay.session).toBe(undefined); + }); + + it('creates a new session and triggers a full dom snapshot when document becomes visible after [VISIBILITY_CHANGE_TIMEOUT]ms', () => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'visible'; + }, + }); + + const initialSession = replay.session; + + jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1); + + document.dispatchEvent(new Event('visibilitychange')); + + expect(mockRecord.takeFullSnapshot).toHaveBeenLastCalledWith(true); + + // Should have created a new session + expect(replay).not.toHaveSameSession(initialSession); + }); + + it('creates a new session and triggers a full dom snapshot when document becomes focused after [VISIBILITY_CHANGE_TIMEOUT]ms', () => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'visible'; + }, + }); + + const initialSession = replay.session; + + jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1); + + WINDOW.dispatchEvent(new Event('focus')); + + expect(mockRecord.takeFullSnapshot).toHaveBeenLastCalledWith(true); + + // Should have created a new session + expect(replay).not.toHaveSameSession(initialSession); + }); + + it('does not create a new session if user hides the tab and comes back within [VISIBILITY_CHANGE_TIMEOUT] seconds', () => { + const initialSession = replay.session; + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'hidden'; + }, + }); + document.dispatchEvent(new Event('visibilitychange')); + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay).toHaveSameSession(initialSession); + + // User comes back before `VISIBILITY_CHANGE_TIMEOUT` elapses + jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT - 1); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'visible'; + }, + }); + document.dispatchEvent(new Event('visibilitychange')); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + // Should NOT have created a new session + expect(replay).toHaveSameSession(initialSession); + }); + + it('creates a new session if user has been idle for 15 minutes and comes back to click their mouse', async () => { + const initialSession = replay.session; + + expect(initialSession?.id).toBeDefined(); + expect(replay.getContext()).toEqual( + expect.objectContaining({ + initialUrl: 'http://localhost/', + initialTimestamp: BASE_TIMESTAMP, + }), + ); + + const url = 'http://dummy/'; + Object.defineProperty(WINDOW, 'location', { + value: new URL(url), + }); + + // Idle for 15 minutes + const FIFTEEN_MINUTES = 15 * 60000; + jest.advanceTimersByTime(FIFTEEN_MINUTES); + + // TBD: We are currently deciding that this event will get dropped, but + // this could/should change in the future. + const TEST_EVENT = { + data: { name: 'lost event' }, + timestamp: BASE_TIMESTAMP, + type: 3, + }; + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + + await new Promise(process.nextTick); + + // Instead of recording the above event, a full snapshot will occur. + // + // TODO: We could potentially figure out a way to save the last session, + // and produce a checkout based on a previous checkout + updates, and then + // replay the event on top. Or maybe replay the event on top of a refresh + // snapshot. + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledWith(true); + + expect(replay).not.toHaveLastSentReplay(); + + // Should be a new session + expect(replay).not.toHaveSameSession(initialSession); + + // Now do a click + domHandler({ + name: 'click', + }); + + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + + const newTimestamp = BASE_TIMESTAMP + FIFTEEN_MINUTES; + const breadcrumbTimestamp = newTimestamp + 20; // I don't know where this 20ms comes from + + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + events: JSON.stringify([ + { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, + { + type: 5, + timestamp: breadcrumbTimestamp, + data: { + tag: 'breadcrumb', + payload: { + timestamp: breadcrumbTimestamp / 1000, + type: 'default', + category: 'ui.click', + message: '', + data: {}, + }, + }, + }, + ]), + }); + + // `_context` should be reset when a new session is created + expect(replay.getContext()).toEqual( + expect.objectContaining({ + initialUrl: 'http://dummy/', + initialTimestamp: newTimestamp, + }), + ); + }); + + it('does not record if user has been idle for more than MAX_SESSION_LIFE and only starts a new session after a user action', async () => { + jest.clearAllMocks(); + + const initialSession = replay.session; + + expect(initialSession?.id).toBeDefined(); + expect(replay.getContext()).toEqual( + expect.objectContaining({ + initialUrl: 'http://localhost/', + initialTimestamp: BASE_TIMESTAMP, + }), + ); + + const url = 'http://dummy/'; + Object.defineProperty(WINDOW, 'location', { + value: new URL(url), + }); + + // Idle for MAX_SESSION_LIFE + jest.advanceTimersByTime(MAX_SESSION_LIFE); + + // These events will not get flushed and will eventually be dropped because user is idle and session is expired + const TEST_EVENT = { + data: { name: 'lost event' }, + timestamp: MAX_SESSION_LIFE, + type: 3, + }; + mockRecord._emitter(TEST_EVENT); + // performance events can still be collected while recording is stopped + // TODO: we may want to prevent `addEvent` from adding to buffer when user is inactive + replay.addUpdate(() => { + createPerformanceSpans(replay, [ + { + type: 'navigation.navigate', + name: 'foo', + start: BASE_TIMESTAMP + MAX_SESSION_LIFE, + end: BASE_TIMESTAMP + MAX_SESSION_LIFE + 100, + }, + ]); + return true; + }); + + WINDOW.dispatchEvent(new Event('blur')); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay).not.toHaveLastSentReplay(); + // Should be the same session because user has been idle and no events have caused a new session to be created + expect(replay).toHaveSameSession(initialSession); + + // @ts-ignore private + expect(replay._stopRecording).toBeUndefined(); + + // Now do a click + domHandler({ + name: 'click', + }); + // This should still be thrown away + mockRecord._emitter(TEST_EVENT); + + const NEW_TEST_EVENT = { + data: { name: 'test' }, + timestamp: BASE_TIMESTAMP + MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 20, + type: 3, + }; + + mockRecord._emitter(NEW_TEST_EVENT); + + // new session is created + jest.runAllTimers(); + await new Promise(process.nextTick); + + expect(replay).not.toHaveSameSession(initialSession); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + + const newTimestamp = BASE_TIMESTAMP + MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 20; // I don't know where this 20ms comes from + const breadcrumbTimestamp = newTimestamp; + + jest.runAllTimers(); + await new Promise(process.nextTick); + + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + events: JSON.stringify([ + { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, + { + type: 5, + timestamp: breadcrumbTimestamp, + data: { + tag: 'breadcrumb', + payload: { + timestamp: breadcrumbTimestamp / 1000, + type: 'default', + category: 'ui.click', + message: '', + data: {}, + }, + }, + }, + NEW_TEST_EVENT, + ]), + }); + + // `_context` should be reset when a new session is created + expect(replay.getContext()).toEqual( + expect.objectContaining({ + initialUrl: 'http://dummy/', + initialTimestamp: newTimestamp, + }), + ); + }); + + it('increases segment id after each event', async () => { + clearSession(replay); + replay.loadSession({ expiry: 0 }); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'hidden'; + }, + }); + + // Pretend 5 seconds have passed + const ELAPSED = 5000; + await advanceTimers(ELAPSED); + + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; + + addEvent(replay, TEST_EVENT); + WINDOW.dispatchEvent(new Event('blur')); + await new Promise(process.nextTick); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + }); + expect(replay.session?.segmentId).toBe(1); + + addEvent(replay, TEST_EVENT); + WINDOW.dispatchEvent(new Event('blur')); + jest.runAllTimers(); + await new Promise(process.nextTick); + expect(replay.session?.segmentId).toBe(2); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 1 }, + }); + }); +}); diff --git a/packages/replay/test/unit/stop.test.ts b/packages/replay/test/integration/stop.test.ts similarity index 95% rename from packages/replay/test/unit/stop.test.ts rename to packages/replay/test/integration/stop.test.ts index 7aa5469c1d7d..46c43e8afea5 100644 --- a/packages/replay/test/unit/stop.test.ts +++ b/packages/replay/test/integration/stop.test.ts @@ -1,17 +1,17 @@ import * as SentryUtils from '@sentry/utils'; +import { Replay } from '../../src'; import { SESSION_IDLE_DURATION, WINDOW } from '../../src/constants'; import { ReplayContainer } from '../../src/replay'; import { addEvent } from '../../src/util/addEvent'; -import { clearSession } from '../utils/clearSession'; -import { Replay } from './../../src'; // mock functions need to be imported first -import { BASE_TIMESTAMP, mockRrweb, mockSdk } from './../index'; -import { useFakeTimers } from './../utils/use-fake-timers'; +import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; +import { clearSession } from '../utils/clearSession'; +import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); -describe('Replay - stop', () => { +describe('Integration | stop', () => { let replay: ReplayContainer; let integration: Replay; const prevLocation = WINDOW.location; diff --git a/packages/replay/test/unit/coreHandlers/handleFetch.test.ts b/packages/replay/test/unit/coreHandlers/handleFetch.test.ts index 0d3386aee15a..e936b693af56 100644 --- a/packages/replay/test/unit/coreHandlers/handleFetch.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleFetch.test.ts @@ -1,9 +1,4 @@ import { handleFetch } from '../../../src/coreHandlers/handleFetch'; -import { mockSdk } from './../../index'; - -beforeAll(function () { - mockSdk(); -}); const DEFAULT_DATA = { args: ['/api/0/organizations/sentry/', { method: 'GET', headers: {}, credentials: 'include' }] as Parameters< @@ -24,28 +19,30 @@ const DEFAULT_DATA = { startTimestamp: 10000, }; -it('formats fetch calls from core SDK to replay breadcrumbs', function () { - expect(handleFetch(DEFAULT_DATA)).toEqual({ - type: 'resource.fetch', - name: '/api/0/organizations/sentry/', - start: 10, - end: 15, - data: { - method: 'GET', - statusCode: 200, - }, +describe('Unit | coreHandlers | handleFetch', () => { + it('formats fetch calls from core SDK to replay breadcrumbs', function () { + expect(handleFetch(DEFAULT_DATA)).toEqual({ + type: 'resource.fetch', + name: '/api/0/organizations/sentry/', + start: 10, + end: 15, + data: { + method: 'GET', + statusCode: 200, + }, + }); }); -}); -it('ignores fetches that have not completed yet', function () { - const data = { - ...DEFAULT_DATA, - }; + it('ignores fetches that have not completed yet', function () { + const data = { + ...DEFAULT_DATA, + }; - // @ts-ignore: The operand of a 'delete' operator must be optional.ts(2790) - delete data.endTimestamp; - // @ts-ignore: The operand of a 'delete' operator must be optional.ts(2790) - delete data.response; + // @ts-ignore: The operand of a 'delete' operator must be optional.ts(2790) + delete data.endTimestamp; + // @ts-ignore: The operand of a 'delete' operator must be optional.ts(2790) + delete data.response; - expect(handleFetch(data)).toEqual(null); + expect(handleFetch(data)).toEqual(null); + }); }); diff --git a/packages/replay/test/unit/coreHandlers/handleScope-unit.test.ts b/packages/replay/test/unit/coreHandlers/handleScope-unit.test.ts deleted file mode 100644 index cec834472029..000000000000 --- a/packages/replay/test/unit/coreHandlers/handleScope-unit.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getCurrentHub } from '@sentry/core'; - -import * as HandleScope from '../../../src/coreHandlers/handleScope'; -import { mockSdk } from './../../index'; - -let mockHandleScope: jest.MockedFunction; - -jest.useFakeTimers(); - -beforeAll(async function () { - await mockSdk(); - jest.spyOn(HandleScope, 'handleScope'); - mockHandleScope = HandleScope.handleScope as jest.MockedFunction; - - jest.runAllTimers(); -}); - -it('returns a breadcrumb only if last breadcrumb has changed (integration)', function () { - getCurrentHub().getScope()?.addBreadcrumb({ message: 'testing' }); - - expect(mockHandleScope).toHaveBeenCalledTimes(1); - expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ message: 'testing' })); - - mockHandleScope.mockClear(); - - // This will trigger breadcrumb/scope listener, but handleScope should return - // null because breadcrumbs has not changed - getCurrentHub().getScope()?.setUser({ email: 'foo@foo.com' }); - expect(mockHandleScope).toHaveBeenCalledTimes(1); - expect(mockHandleScope).toHaveReturnedWith(null); -}); diff --git a/packages/replay/test/unit/coreHandlers/handleScope.test.ts b/packages/replay/test/unit/coreHandlers/handleScope.test.ts index dd650a685293..db562a7b98aa 100644 --- a/packages/replay/test/unit/coreHandlers/handleScope.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleScope.test.ts @@ -2,48 +2,49 @@ import type { Breadcrumb, Scope } from '@sentry/types'; import * as HandleScope from '../../../src/coreHandlers/handleScope'; -jest.spyOn(HandleScope, 'handleScope'); -const mockHandleScope = HandleScope.handleScope as jest.MockedFunction; - -it('returns a breadcrumb only if last breadcrumb has changed (unit)', function () { - const scope = { - _breadcrumbs: [], - getLastBreadcrumb() { - return this._breadcrumbs[this._breadcrumbs.length - 1]; - }, - } as unknown as Scope; - - function addBreadcrumb(breadcrumb: Breadcrumb) { - // @ts-ignore using private member - scope._breadcrumbs.push(breadcrumb); - } - - const testMsg = { - timestamp: new Date().getTime() / 1000, - message: 'testing', - category: 'console', - }; - - addBreadcrumb(testMsg); - // integration testing here is a bit tricky, because the core SDK can - // interfere with console output from test runner - HandleScope.handleScope(scope); - expect(mockHandleScope).toHaveBeenCalledTimes(1); - expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ message: 'testing', category: 'console' })); - - // This will trigger breadcrumb/scope listener, but handleScope should return - // null because breadcrumbs has not changed - mockHandleScope.mockClear(); - HandleScope.handleScope(scope); - expect(mockHandleScope).toHaveBeenCalledTimes(1); - expect(mockHandleScope).toHaveReturnedWith(null); - - mockHandleScope.mockClear(); - addBreadcrumb({ - message: 'f00', - category: 'console', +describe('Unit | coreHandlers | handleScope', () => { + const mockHandleScope = jest.spyOn(HandleScope, 'handleScope'); + + it('returns a breadcrumb only if last breadcrumb has changed (unit)', function () { + const scope = { + _breadcrumbs: [], + getLastBreadcrumb() { + return this._breadcrumbs[this._breadcrumbs.length - 1]; + }, + } as unknown as Scope; + + function addBreadcrumb(breadcrumb: Breadcrumb) { + // @ts-ignore using private member + scope._breadcrumbs.push(breadcrumb); + } + + const testMsg = { + timestamp: new Date().getTime() / 1000, + message: 'testing', + category: 'console', + }; + + addBreadcrumb(testMsg); + // integration testing here is a bit tricky, because the core SDK can + // interfere with console output from test runner + HandleScope.handleScope(scope); + expect(mockHandleScope).toHaveBeenCalledTimes(1); + expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ message: 'testing', category: 'console' })); + + // This will trigger breadcrumb/scope listener, but handleScope should return + // null because breadcrumbs has not changed + mockHandleScope.mockClear(); + HandleScope.handleScope(scope); + expect(mockHandleScope).toHaveBeenCalledTimes(1); + expect(mockHandleScope).toHaveReturnedWith(null); + + mockHandleScope.mockClear(); + addBreadcrumb({ + message: 'f00', + category: 'console', + }); + HandleScope.handleScope(scope); + expect(mockHandleScope).toHaveBeenCalledTimes(1); + expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ message: 'f00', category: 'console' })); }); - HandleScope.handleScope(scope); - expect(mockHandleScope).toHaveBeenCalledTimes(1); - expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ message: 'f00', category: 'console' })); }); diff --git a/packages/replay/test/unit/createPerformanceEntry.test.ts b/packages/replay/test/unit/createPerformanceEntry.test.ts deleted file mode 100644 index ecd0d746806e..000000000000 --- a/packages/replay/test/unit/createPerformanceEntry.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { createPerformanceEntries } from '../../src/createPerformanceEntry'; -import { mockSdk } from './../index'; - -beforeAll(function () { - mockSdk(); -}); - -it('ignores sdks own requests', function () { - const data = { - name: 'https://ingest.f00.f00/api/1/envelope/?sentry_key=dsn&sentry_version=7', - entryType: 'resource', - startTime: 234462.69999998808, - duration: 55.70000001788139, - initiatorType: 'fetch', - nextHopProtocol: '', - workerStart: 0, - redirectStart: 0, - redirectEnd: 0, - fetchStart: 234462.69999998808, - domainLookupStart: 0, - domainLookupEnd: 0, - connectStart: 0, - connectEnd: 0, - secureConnectionStart: 0, - requestStart: 0, - responseStart: 0, - responseEnd: 234518.40000000596, - transferSize: 0, - encodedBodySize: 0, - decodedBodySize: 0, - serverTiming: [], - workerTiming: [], - } as const; - - // @ts-ignore Needs a PerformanceEntry mock - expect(createPerformanceEntries([data])).toEqual([]); -}); diff --git a/packages/replay/test/unit/eventBuffer.test.ts b/packages/replay/test/unit/eventBuffer.test.ts index a34230138fea..ac5a3be70183 100644 --- a/packages/replay/test/unit/eventBuffer.test.ts +++ b/packages/replay/test/unit/eventBuffer.test.ts @@ -6,114 +6,115 @@ import { createEventBuffer, EventBufferCompressionWorker } from './../../src/eve import { BASE_TIMESTAMP } from './../index'; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; +describe('Unit | eventBuffer', () => { + it('adds events to normal event buffer', async function () { + const buffer = createEventBuffer({ useCompression: false }); -it('adds events to normal event buffer', async function () { - const buffer = createEventBuffer({ useCompression: false }); + buffer.addEvent(TEST_EVENT); + buffer.addEvent(TEST_EVENT); - buffer.addEvent(TEST_EVENT); - buffer.addEvent(TEST_EVENT); + const result = await buffer.finish(); - const result = await buffer.finish(); + expect(result).toEqual(JSON.stringify([TEST_EVENT, TEST_EVENT])); + }); - expect(result).toEqual(JSON.stringify([TEST_EVENT, TEST_EVENT])); -}); + it('adds checkout event to normal event buffer', async function () { + const buffer = createEventBuffer({ useCompression: false }); -it('adds checkout event to normal event buffer', async function () { - const buffer = createEventBuffer({ useCompression: false }); + buffer.addEvent(TEST_EVENT); + buffer.addEvent(TEST_EVENT); + buffer.addEvent(TEST_EVENT); - buffer.addEvent(TEST_EVENT); - buffer.addEvent(TEST_EVENT); - buffer.addEvent(TEST_EVENT); + buffer.addEvent(TEST_EVENT, true); + const result = await buffer.finish(); - buffer.addEvent(TEST_EVENT, true); - const result = await buffer.finish(); + expect(result).toEqual(JSON.stringify([TEST_EVENT])); + }); - expect(result).toEqual(JSON.stringify([TEST_EVENT])); -}); + it('calling `finish()` multiple times does not result in duplicated events', async function () { + const buffer = createEventBuffer({ useCompression: false }); -it('calling `finish()` multiple times does not result in duplicated events', async function () { - const buffer = createEventBuffer({ useCompression: false }); + buffer.addEvent(TEST_EVENT); - buffer.addEvent(TEST_EVENT); + const promise1 = buffer.finish(); + const promise2 = buffer.finish(); - const promise1 = buffer.finish(); - const promise2 = buffer.finish(); + const result1 = (await promise1) as Uint8Array; + const result2 = (await promise2) as Uint8Array; - const result1 = (await promise1) as Uint8Array; - const result2 = (await promise2) as Uint8Array; + expect(result1).toEqual(JSON.stringify([TEST_EVENT])); + expect(result2).toEqual(JSON.stringify([])); + }); - expect(result1).toEqual(JSON.stringify([TEST_EVENT])); - expect(result2).toEqual(JSON.stringify([])); -}); + it('adds events to event buffer with compression worker', async function () { + const buffer = createEventBuffer({ + useCompression: true, + }) as EventBufferCompressionWorker; -it('adds events to event buffer with compression worker', async function () { - const buffer = createEventBuffer({ - useCompression: true, - }) as EventBufferCompressionWorker; + buffer.addEvent(TEST_EVENT); + buffer.addEvent(TEST_EVENT); - buffer.addEvent(TEST_EVENT); - buffer.addEvent(TEST_EVENT); + const result = await buffer.finish(); + const restored = pako.inflate(result, { to: 'string' }); - const result = await buffer.finish(); - const restored = pako.inflate(result, { to: 'string' }); + expect(restored).toEqual(JSON.stringify([TEST_EVENT, TEST_EVENT])); + }); - expect(restored).toEqual(JSON.stringify([TEST_EVENT, TEST_EVENT])); -}); + it('adds checkout events to event buffer with compression worker', async function () { + const buffer = createEventBuffer({ + useCompression: true, + }) as EventBufferCompressionWorker; -it('adds checkout events to event buffer with compression worker', async function () { - const buffer = createEventBuffer({ - useCompression: true, - }) as EventBufferCompressionWorker; + await buffer.addEvent(TEST_EVENT); + await buffer.addEvent(TEST_EVENT); - await buffer.addEvent(TEST_EVENT); - await buffer.addEvent(TEST_EVENT); + // This should clear previous buffer + await buffer.addEvent({ ...TEST_EVENT, type: 2 }, true); - // This should clear previous buffer - await buffer.addEvent({ ...TEST_EVENT, type: 2 }, true); + const result = await buffer.finish(); + const restored = pako.inflate(result, { to: 'string' }); - const result = await buffer.finish(); - const restored = pako.inflate(result, { to: 'string' }); + expect(restored).toEqual(JSON.stringify([{ ...TEST_EVENT, type: 2 }])); + }); - expect(restored).toEqual(JSON.stringify([{ ...TEST_EVENT, type: 2 }])); -}); - -it('calling `finish()` multiple times does not result in duplicated events', async function () { - const buffer = createEventBuffer({ - useCompression: true, - }) as EventBufferCompressionWorker; + it('calling `finish()` multiple times does not result in duplicated events', async function () { + const buffer = createEventBuffer({ + useCompression: true, + }) as EventBufferCompressionWorker; - buffer.addEvent(TEST_EVENT); + buffer.addEvent(TEST_EVENT); - const promise1 = buffer.finish(); - const promise2 = buffer.finish(); + const promise1 = buffer.finish(); + const promise2 = buffer.finish(); - const result1 = (await promise1) as Uint8Array; - const result2 = (await promise2) as Uint8Array; - const restored1 = pako.inflate(result1, { to: 'string' }); - const restored2 = pako.inflate(result2, { to: 'string' }); + const result1 = (await promise1) as Uint8Array; + const result2 = (await promise2) as Uint8Array; + const restored1 = pako.inflate(result1, { to: 'string' }); + const restored2 = pako.inflate(result2, { to: 'string' }); - expect(restored1).toEqual(JSON.stringify([TEST_EVENT])); - expect(restored2).toEqual(JSON.stringify([])); -}); + expect(restored1).toEqual(JSON.stringify([TEST_EVENT])); + expect(restored2).toEqual(JSON.stringify([])); + }); -it('calling `finish()` multiple times, with events in between, does not result in duplicated or dropped events', async function () { - const buffer = createEventBuffer({ - useCompression: true, - }) as EventBufferCompressionWorker; + it('calling `finish()` multiple times, with events in between, does not result in duplicated or dropped events', async function () { + const buffer = createEventBuffer({ + useCompression: true, + }) as EventBufferCompressionWorker; - buffer.addEvent(TEST_EVENT); + buffer.addEvent(TEST_EVENT); - const promise1 = buffer.finish(); + const promise1 = buffer.finish(); - buffer.addEvent({ ...TEST_EVENT, type: 5 }); - const promise2 = buffer.finish(); + buffer.addEvent({ ...TEST_EVENT, type: 5 }); + const promise2 = buffer.finish(); - const result1 = (await promise1) as Uint8Array; - const result2 = (await promise2) as Uint8Array; + const result1 = (await promise1) as Uint8Array; + const result2 = (await promise2) as Uint8Array; - const restored1 = pako.inflate(result1, { to: 'string' }); - const restored2 = pako.inflate(result2, { to: 'string' }); + const restored1 = pako.inflate(result1, { to: 'string' }); + const restored2 = pako.inflate(result2, { to: 'string' }); - expect(restored1).toEqual(JSON.stringify([TEST_EVENT])); - expect(restored2).toEqual(JSON.stringify([{ ...TEST_EVENT, type: 5 }])); + expect(restored1).toEqual(JSON.stringify([TEST_EVENT])); + expect(restored2).toEqual(JSON.stringify([{ ...TEST_EVENT, type: 5 }])); + }); }); diff --git a/packages/replay/test/unit/flush.test.ts b/packages/replay/test/unit/flush.test.ts deleted file mode 100644 index 4397fee40d18..000000000000 --- a/packages/replay/test/unit/flush.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import * as SentryUtils from '@sentry/utils'; - -import { DEFAULT_FLUSH_MIN_DELAY, SESSION_IDLE_DURATION, WINDOW } from '../../src/constants'; -import * as AddMemoryEntry from '../../src/util/addMemoryEntry'; -import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; -import { clearSession } from '../utils/clearSession'; -import { createPerformanceEntries } from './../../src/createPerformanceEntry'; -import { ReplayContainer } from './../../src/replay'; -import { useFakeTimers } from './../../test/utils/use-fake-timers'; -import { BASE_TIMESTAMP, mockRrweb, mockSdk } from './../index'; - -useFakeTimers(); - -async function advanceTimers(time: number) { - jest.advanceTimersByTime(time); - await new Promise(process.nextTick); -} - -type MockSendReplay = jest.MockedFunction; -type MockAddPerformanceEntries = jest.MockedFunction; -type MockAddMemoryEntry = jest.SpyInstance; -type MockEventBufferFinish = jest.MockedFunction['finish']>; -type MockFlush = jest.MockedFunction; -type MockRunFlush = jest.MockedFunction; - -const prevLocation = WINDOW.location; -let domHandler: (args: any) => any; - -const { record: mockRecord } = mockRrweb(); - -let replay: ReplayContainer; -let mockSendReplay: MockSendReplay; -let mockFlush: MockFlush; -let mockRunFlush: MockRunFlush; -let mockEventBufferFinish: MockEventBufferFinish; -let mockAddMemoryEntry: MockAddMemoryEntry; -let mockAddPerformanceEntries: MockAddPerformanceEntries; - -beforeAll(async () => { - jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => { - if (type === 'dom') { - domHandler = handler; - } - }); - - ({ replay } = await mockSdk()); - jest.spyOn(replay, 'sendReplay'); - mockSendReplay = replay.sendReplay as MockSendReplay; - mockSendReplay.mockImplementation( - jest.fn(async () => { - return; - }), - ); - - jest.spyOn(replay, 'flush'); - mockFlush = replay.flush as MockFlush; - - jest.spyOn(replay, 'runFlush'); - mockRunFlush = replay.runFlush as MockRunFlush; - - jest.spyOn(replay, 'addPerformanceEntries'); - mockAddPerformanceEntries = replay.addPerformanceEntries as MockAddPerformanceEntries; - - mockAddPerformanceEntries.mockImplementation(async () => { - return []; - }); - - mockAddMemoryEntry = jest.spyOn(AddMemoryEntry, 'addMemoryEntry'); -}); - -beforeEach(() => { - jest.runAllTimers(); - jest.setSystemTime(new Date(BASE_TIMESTAMP)); - mockSendReplay.mockClear(); - replay.eventBuffer?.destroy(); - mockAddPerformanceEntries.mockClear(); - mockFlush.mockClear(); - mockRunFlush.mockClear(); - mockAddMemoryEntry.mockClear(); - - if (replay.eventBuffer) { - jest.spyOn(replay.eventBuffer, 'finish'); - } - mockEventBufferFinish = replay.eventBuffer?.finish as MockEventBufferFinish; - mockEventBufferFinish.mockClear(); -}); - -afterEach(async () => { - jest.runAllTimers(); - await new Promise(process.nextTick); - jest.setSystemTime(new Date(BASE_TIMESTAMP)); - sessionStorage.clear(); - clearSession(replay); - replay.loadSession({ expiry: SESSION_IDLE_DURATION }); - mockRecord.takeFullSnapshot.mockClear(); - Object.defineProperty(WINDOW, 'location', { - value: prevLocation, - writable: true, - }); -}); - -afterAll(() => { - replay && replay.stop(); -}); - -it('flushes twice after multiple flush() calls)', async () => { - // blur events cause an immediate flush (as well as a flush due to adding a - // breadcrumb) -- this means that the first blur event will be flushed and - // the following blur events will all call a debounced flush function, which - // should end up queueing a second flush - - WINDOW.dispatchEvent(new Event('blur')); - WINDOW.dispatchEvent(new Event('blur')); - WINDOW.dispatchEvent(new Event('blur')); - WINDOW.dispatchEvent(new Event('blur')); - - expect(replay.flush).toHaveBeenCalledTimes(4); - - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay.runFlush).toHaveBeenCalledTimes(1); - - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay.runFlush).toHaveBeenCalledTimes(2); - - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay.runFlush).toHaveBeenCalledTimes(2); -}); - -it('long first flush enqueues following events', async () => { - // Mock this to resolve after 20 seconds so that we can queue up following flushes - mockAddPerformanceEntries.mockImplementationOnce(async () => { - return await new Promise(resolve => setTimeout(resolve, 20000)); - }); - - expect(mockAddPerformanceEntries).not.toHaveBeenCalled(); - - // flush #1 @ t=0s - due to blur - WINDOW.dispatchEvent(new Event('blur')); - expect(replay.flush).toHaveBeenCalledTimes(1); - expect(replay.runFlush).toHaveBeenCalledTimes(1); - - // This will attempt to flush in 5 seconds (flushMinDelay) - domHandler({ - name: 'click', - }); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - // flush #2 @ t=5s - due to click - expect(replay.flush).toHaveBeenCalledTimes(2); - - await advanceTimers(1000); - // flush #3 @ t=6s - due to blur - WINDOW.dispatchEvent(new Event('blur')); - expect(replay.flush).toHaveBeenCalledTimes(3); - - // NOTE: Blur also adds a breadcrumb which calls `addUpdate`, meaning it will - // flush after `flushMinDelay`, but this gets cancelled by the blur - await advanceTimers(8000); - expect(replay.flush).toHaveBeenCalledTimes(3); - - // flush #4 @ t=14s - due to blur - WINDOW.dispatchEvent(new Event('blur')); - expect(replay.flush).toHaveBeenCalledTimes(4); - - expect(replay.runFlush).toHaveBeenCalledTimes(1); - await advanceTimers(6000); - // t=20s - // addPerformanceEntries is finished, `flushLock` promise is resolved, calls - // debouncedFlush, which will call `flush` in 1 second - expect(replay.flush).toHaveBeenCalledTimes(4); - // sendReplay is called with replayId, events, segment - expect(mockSendReplay).toHaveBeenLastCalledWith({ - events: expect.any(String), - replayId: expect.any(String), - includeReplayStartTimestamp: true, - segmentId: 0, - eventContext: expect.anything(), - }); - - // Add this to test that segment ID increases - mockAddPerformanceEntries.mockImplementationOnce(() => { - createPerformanceSpans( - replay, - createPerformanceEntries([ - { - name: 'https://sentry.io/foo.js', - entryType: 'resource', - startTime: 176.59999990463257, - duration: 5.600000023841858, - initiatorType: 'link', - nextHopProtocol: 'h2', - workerStart: 177.5, - redirectStart: 0, - redirectEnd: 0, - fetchStart: 177.69999992847443, - domainLookupStart: 177.69999992847443, - domainLookupEnd: 177.69999992847443, - connectStart: 177.69999992847443, - connectEnd: 177.69999992847443, - secureConnectionStart: 177.69999992847443, - requestStart: 177.5, - responseStart: 181, - responseEnd: 182.19999992847443, - transferSize: 0, - encodedBodySize: 0, - decodedBodySize: 0, - serverTiming: [], - } as unknown as PerformanceResourceTiming, - ]), - ); - }); - // flush #5 @ t=25s - debounced flush calls `flush` - // 20s + `flushMinDelay` which is 5 seconds - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - expect(replay.flush).toHaveBeenCalledTimes(5); - expect(replay.runFlush).toHaveBeenCalledTimes(2); - expect(mockSendReplay).toHaveBeenLastCalledWith({ - events: expect.any(String), - replayId: expect.any(String), - includeReplayStartTimestamp: false, - segmentId: 1, - eventContext: expect.anything(), - }); - - // Make sure there's no other calls - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(mockSendReplay).toHaveBeenCalledTimes(2); -}); diff --git a/packages/replay/test/unit/index-noSticky.test.ts b/packages/replay/test/unit/index-noSticky.test.ts deleted file mode 100644 index 56c327871cdc..000000000000 --- a/packages/replay/test/unit/index-noSticky.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { getCurrentHub } from '@sentry/core'; -import { Transport } from '@sentry/types'; -import * as SentryUtils from '@sentry/utils'; - -import { DEFAULT_FLUSH_MIN_DELAY, SESSION_IDLE_DURATION, VISIBILITY_CHANGE_TIMEOUT } from '../../src/constants'; -import { addEvent } from '../../src/util/addEvent'; -import { clearSession } from '../utils/clearSession'; -import { ReplayContainer } from './../../src/replay'; -import { BASE_TIMESTAMP, mockRrweb, mockSdk } from './../index'; -import { useFakeTimers } from './../utils/use-fake-timers'; - -useFakeTimers(); - -async function advanceTimers(time: number) { - jest.advanceTimersByTime(time); - await new Promise(process.nextTick); -} - -type MockTransport = jest.MockedFunction; - -describe('Replay (no sticky)', () => { - let replay: ReplayContainer; - let mockTransport: MockTransport; - let domHandler: (args: any) => any; - const { record: mockRecord } = mockRrweb(); - - beforeAll(async () => { - jest.setSystemTime(new Date(BASE_TIMESTAMP)); - jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => { - if (type === 'dom') { - domHandler = handler; - } - }); - - ({ replay } = await mockSdk({ - replayOptions: { - stickySession: false, - }, - })); - jest.runAllTimers(); - mockTransport = getCurrentHub()?.getClient()?.getTransport()?.send as MockTransport; - }); - - beforeEach(() => { - jest.setSystemTime(new Date(BASE_TIMESTAMP)); - mockRecord.takeFullSnapshot.mockClear(); - mockTransport.mockClear(); - }); - - afterEach(async () => { - jest.runAllTimers(); - await new Promise(process.nextTick); - jest.setSystemTime(new Date(BASE_TIMESTAMP)); - clearSession(replay); - replay.loadSession({ expiry: SESSION_IDLE_DURATION }); - }); - - afterAll(() => { - replay && replay.stop(); - }); - - it('creates a new session and triggers a full dom snapshot when document becomes visible after [VISIBILITY_CHANGE_TIMEOUT]ms', () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'visible'; - }, - }); - - const initialSession = replay.session; - - jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1); - - document.dispatchEvent(new Event('visibilitychange')); - - expect(mockRecord.takeFullSnapshot).toHaveBeenLastCalledWith(true); - - // Should have created a new session - expect(replay).not.toHaveSameSession(initialSession); - }); - - it('does not create a new session if user hides the tab and comes back within [VISIBILITY_CHANGE_TIMEOUT] seconds', () => { - const initialSession = replay.session; - - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - document.dispatchEvent(new Event('visibilitychange')); - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).toHaveSameSession(initialSession); - - // User comes back before `VISIBILITY_CHANGE_TIMEOUT` elapses - jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT - 1); - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'visible'; - }, - }); - document.dispatchEvent(new Event('visibilitychange')); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - // Should NOT have created a new session - expect(replay).toHaveSameSession(initialSession); - }); - - it('uploads a replay event when document becomes hidden', async () => { - mockRecord.takeFullSnapshot.mockClear(); - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - - // Pretend 5 seconds have passed - const ELAPSED = 5000; - jest.advanceTimersByTime(ELAPSED); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; - addEvent(replay, TEST_EVENT); - - document.dispatchEvent(new Event('visibilitychange')); - - await new Promise(process.nextTick); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); - - // Session's last activity is not updated because we do not consider - // visibilitystate as user being active - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); - expect(replay.session?.segmentId).toBe(1); - - // events array should be empty - expect(replay.eventBuffer?.length).toBe(0); - }); - - it('update last activity when user clicks mouse', async () => { - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); - - domHandler({ - name: 'click', - }); - - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); - - // Pretend 5 seconds have passed - const ELAPSED = 5000; - jest.advanceTimersByTime(ELAPSED); - - domHandler({ - name: 'click', - }); - - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP + ELAPSED); - }); - - it('uploads a replay event if 5 seconds have elapsed since the last replay event occurred', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - // Pretend 5 seconds have passed - const ELAPSED = 5000; - await advanceTimers(ELAPSED); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); - - // No user activity to trigger an update - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); - expect(replay.session?.segmentId).toBe(1); - - // events array should be empty - expect(replay.eventBuffer?.length).toBe(0); - }); - - it('uploads a replay event if 15 seconds have elapsed since the last replay upload', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - // Fire a new event every 4 seconds, 4 times - [...Array(4)].forEach(() => { - mockRecord._emitter(TEST_EVENT); - jest.advanceTimersByTime(4000); - }); - - // We are at time = +16seconds now (relative to BASE_TIMESTAMP) - // The next event should cause an upload immediately - mockRecord._emitter(TEST_EVENT); - await new Promise(process.nextTick); - - expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([...Array(5)].map(() => TEST_EVENT)), - }); - - // There should also not be another attempt at an upload 5 seconds after the last replay event - mockTransport.mockClear(); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - expect(replay).not.toHaveLastSentReplay(); - - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); - expect(replay.session?.segmentId).toBe(1); - // events array should be empty - expect(replay.eventBuffer?.length).toBe(0); - - // Let's make sure it continues to work - mockTransport.mockClear(); - mockRecord._emitter(TEST_EVENT); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); - }); - - it('creates a new session if user has been idle for more than 15 minutes and comes back to move their mouse', async () => { - const initialSession = replay.session; - - expect(initialSession?.id).toBeDefined(); - - // Idle for 15 minutes - const FIFTEEN_MINUTES = 15 * 60000; - jest.advanceTimersByTime(FIFTEEN_MINUTES); - - // TBD: We are currently deciding that this event will get dropped, but - // this could/should change in the future. - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: BASE_TIMESTAMP, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - - await new Promise(process.nextTick); - - // Instead of recording the above event, a full snapshot will occur. - // - // TODO: We could potentially figure out a way to save the last session, - // and produce a checkout based on a previous checkout + updates, and then - // replay the event on top. Or maybe replay the event on top of a refresh - // snapshot. - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledWith(true); - - // Should be a new session - expect(replay).not.toHaveSameSession(initialSession); - - // Replay does not send immediately because checkout was due to expired session - expect(replay).not.toHaveLastSentReplay(); - - // Now do a click - domHandler({ - name: 'click', - }); - - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - - const newTimestamp = BASE_TIMESTAMP + FIFTEEN_MINUTES; - const breadcrumbTimestamp = newTimestamp + 20; // I don't know where this 20ms comes from - - expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([ - { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, - { - type: 5, - timestamp: breadcrumbTimestamp, - data: { - tag: 'breadcrumb', - payload: { - timestamp: breadcrumbTimestamp / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - }); -}); diff --git a/packages/replay/test/unit/index.test.ts b/packages/replay/test/unit/index.test.ts deleted file mode 100644 index cdd0567609e8..000000000000 --- a/packages/replay/test/unit/index.test.ts +++ /dev/null @@ -1,951 +0,0 @@ -import { getCurrentHub, Hub } from '@sentry/core'; -import { Event, Scope } from '@sentry/types'; -import { EventType } from 'rrweb'; - -import { - DEFAULT_FLUSH_MIN_DELAY, - MASK_ALL_TEXT_SELECTOR, - MAX_SESSION_LIFE, - REPLAY_SESSION_KEY, - VISIBILITY_CHANGE_TIMEOUT, - WINDOW, -} from '../../src/constants'; -import { ReplayContainer } from '../../src/replay'; -import type { RecordingEvent } from '../../src/types'; -import { addEvent } from '../../src/util/addEvent'; -import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; -import { clearSession } from '../utils/clearSession'; -import { useFakeTimers } from '../utils/use-fake-timers'; -import { PerformanceEntryResource } from './../fixtures/performanceEntry/resource'; -import { BASE_TIMESTAMP, RecordMock } from './../index'; -import { resetSdkMock } from './../mocks/resetSdkMock'; -import { DomHandler } from './../types'; - -useFakeTimers(); - -async function advanceTimers(time: number) { - jest.advanceTimersByTime(time); - await new Promise(process.nextTick); -} - -describe('Replay with custom mock', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('calls rrweb.record with custom options', async () => { - const { mockRecord } = await resetSdkMock({ - replayOptions: { - ignoreClass: 'sentry-test-ignore', - stickySession: false, - }, - }); - expect(mockRecord.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "blockClass": "sentry-block", - "blockSelector": "[data-sentry-block],img,image,svg,path,rect,area,video,object,picture,embed,map,audio", - "emit": [Function], - "ignoreClass": "sentry-test-ignore", - "maskAllInputs": true, - "maskTextClass": "sentry-mask", - "maskTextSelector": "${MASK_ALL_TEXT_SELECTOR}", - } - `); - }); - - describe('auto save session', () => { - test.each([ - ['with stickySession=true', true, 1], - ['with stickySession=false', false, 0], - ])('%s', async (_: string, stickySession: boolean, addSummand: number) => { - let saveSessionSpy; - - jest.mock('../../src/session/saveSession', () => { - saveSessionSpy = jest.fn(); - - return { - saveSession: saveSessionSpy, - }; - }); - - const { replay } = await resetSdkMock({ - replayOptions: { - stickySession, - }, - }); - - // Initially called up to three times: once for start, then once for replay.updateSessionActivity & once for segmentId increase - expect(saveSessionSpy).toHaveBeenCalledTimes(addSummand * 3); - - replay.updateSessionActivity(); - - expect(saveSessionSpy).toHaveBeenCalledTimes(addSummand * 4); - - // In order for runFlush to actually do something, we need to add an event - const event = { - type: EventType.Custom, - data: { - tag: 'test custom', - }, - timestamp: new Date().valueOf(), - } as RecordingEvent; - - addEvent(replay, event); - - await replay.runFlush(); - - expect(saveSessionSpy).toHaveBeenCalledTimes(addSummand * 5); - }); - }); -}); - -describe('Replay', () => { - let replay: ReplayContainer; - let mockRecord: RecordMock; - let mockTransportSend: jest.SpyInstance; - let domHandler: DomHandler; - const prevLocation = WINDOW.location; - - type MockSendReplayRequest = jest.MockedFunction; - let mockSendReplayRequest: MockSendReplayRequest; - - beforeAll(async () => { - jest.setSystemTime(new Date(BASE_TIMESTAMP)); - jest.runAllTimers(); - }); - - beforeEach(async () => { - ({ mockRecord, domHandler, replay } = await resetSdkMock({ - replayOptions: { - stickySession: false, - }, - })); - - mockTransportSend = jest.spyOn(getCurrentHub().getClient()!.getTransport()!, 'send'); - - jest.spyOn(replay, 'flush'); - jest.spyOn(replay, 'runFlush'); - jest.spyOn(replay, 'sendReplayRequest'); - - // Create a new session and clear mocks because a segment (from initial - // checkout) will have already been uploaded by the time the tests run - clearSession(replay); - replay.loadSession({ expiry: 0 }); - mockTransportSend.mockClear(); - mockSendReplayRequest = replay.sendReplayRequest as MockSendReplayRequest; - mockSendReplayRequest.mockClear(); - }); - - afterEach(async () => { - jest.runAllTimers(); - await new Promise(process.nextTick); - Object.defineProperty(WINDOW, 'location', { - value: prevLocation, - writable: true, - }); - clearSession(replay); - jest.clearAllMocks(); - mockSendReplayRequest.mockRestore(); - mockRecord.takeFullSnapshot.mockClear(); - replay.stop(); - }); - - it('should have a session after setup', () => { - expect(replay.session).toMatchObject({ - lastActivity: BASE_TIMESTAMP, - started: BASE_TIMESTAMP, - }); - expect(replay.session?.id).toBeDefined(); - expect(replay.session?.segmentId).toBeDefined(); - }); - - it('clears session', () => { - clearSession(replay); - expect(WINDOW.sessionStorage.getItem(REPLAY_SESSION_KEY)).toBe(null); - expect(replay.session).toBe(undefined); - }); - - it('creates a new session and triggers a full dom snapshot when document becomes visible after [VISIBILITY_CHANGE_TIMEOUT]ms', () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'visible'; - }, - }); - - const initialSession = replay.session; - - jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1); - - document.dispatchEvent(new Event('visibilitychange')); - - expect(mockRecord.takeFullSnapshot).toHaveBeenLastCalledWith(true); - - // Should have created a new session - expect(replay).not.toHaveSameSession(initialSession); - }); - - it('creates a new session and triggers a full dom snapshot when document becomes focused after [VISIBILITY_CHANGE_TIMEOUT]ms', () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'visible'; - }, - }); - - const initialSession = replay.session; - - jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1); - - WINDOW.dispatchEvent(new Event('focus')); - - expect(mockRecord.takeFullSnapshot).toHaveBeenLastCalledWith(true); - - // Should have created a new session - expect(replay).not.toHaveSameSession(initialSession); - }); - - it('does not create a new session if user hides the tab and comes back within [VISIBILITY_CHANGE_TIMEOUT] seconds', () => { - const initialSession = replay.session; - - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - document.dispatchEvent(new Event('visibilitychange')); - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).toHaveSameSession(initialSession); - - // User comes back before `VISIBILITY_CHANGE_TIMEOUT` elapses - jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT - 1); - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'visible'; - }, - }); - document.dispatchEvent(new Event('visibilitychange')); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - // Should NOT have created a new session - expect(replay).toHaveSameSession(initialSession); - }); - - it('uploads a replay event when WINDOW is blurred', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - - // Pretend 5 seconds have passed - const ELAPSED = 5000; - jest.advanceTimersByTime(ELAPSED); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; - const hiddenBreadcrumb = { - type: 5, - timestamp: +new Date(BASE_TIMESTAMP + ELAPSED) / 1000, - data: { - tag: 'breadcrumb', - payload: { - timestamp: +new Date(BASE_TIMESTAMP + ELAPSED) / 1000, - type: 'default', - category: 'ui.blur', - }, - }, - }; - - addEvent(replay, TEST_EVENT); - WINDOW.dispatchEvent(new Event('blur')); - await new Promise(process.nextTick); - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([TEST_EVENT, hiddenBreadcrumb]), - }); - // Session's last activity should not be updated - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); - // events array should be empty - expect(replay.eventBuffer?.length).toBe(0); - }); - - it('uploads a replay event when document becomes hidden', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - // Pretend 5 seconds have passed - const ELAPSED = 5000; - jest.advanceTimersByTime(ELAPSED); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; - - addEvent(replay, TEST_EVENT); - document.dispatchEvent(new Event('visibilitychange')); - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); - - // Session's last activity is not updated because we do not consider - // visibilitystate as user being active - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); - // events array should be empty - expect(replay.eventBuffer?.length).toBe(0); - }); - - it('uploads a replay event if 5 seconds have elapsed since the last replay event occurred', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - // Pretend 5 seconds have passed - const ELAPSED = 5000; - await advanceTimers(ELAPSED); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(mockTransportSend).toHaveBeenCalledTimes(1); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); - - // No user activity to trigger an update - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); - expect(replay.session?.segmentId).toBe(1); - - // events array should be empty - expect(replay.eventBuffer?.length).toBe(0); - }); - - it('uploads a replay event if 15 seconds have elapsed since the last replay upload', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - // Fire a new event every 4 seconds, 4 times - [...Array(4)].forEach(() => { - mockRecord._emitter(TEST_EVENT); - jest.advanceTimersByTime(4000); - }); - - // We are at time = +16seconds now (relative to BASE_TIMESTAMP) - // The next event should cause an upload immediately - mockRecord._emitter(TEST_EVENT); - await new Promise(process.nextTick); - - expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([...Array(5)].map(() => TEST_EVENT)), - }); - - // There should also not be another attempt at an upload 5 seconds after the last replay event - mockTransportSend.mockClear(); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - - expect(replay).not.toHaveLastSentReplay(); - - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); - expect(replay.session?.segmentId).toBe(1); - // events array should be empty - expect(replay.eventBuffer?.length).toBe(0); - - // Let's make sure it continues to work - mockTransportSend.mockClear(); - mockRecord._emitter(TEST_EVENT); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - expect(replay).toHaveLastSentReplay({ events: JSON.stringify([TEST_EVENT]) }); - }); - - it('creates a new session if user has been idle for 15 minutes and comes back to click their mouse', async () => { - const initialSession = replay.session; - - expect(initialSession?.id).toBeDefined(); - expect(replay.getContext()).toEqual( - expect.objectContaining({ - initialUrl: 'http://localhost/', - initialTimestamp: BASE_TIMESTAMP, - }), - ); - - const url = 'http://dummy/'; - Object.defineProperty(WINDOW, 'location', { - value: new URL(url), - }); - - // Idle for 15 minutes - const FIFTEEN_MINUTES = 15 * 60000; - jest.advanceTimersByTime(FIFTEEN_MINUTES); - - // TBD: We are currently deciding that this event will get dropped, but - // this could/should change in the future. - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: BASE_TIMESTAMP, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - - await new Promise(process.nextTick); - - // Instead of recording the above event, a full snapshot will occur. - // - // TODO: We could potentially figure out a way to save the last session, - // and produce a checkout based on a previous checkout + updates, and then - // replay the event on top. Or maybe replay the event on top of a refresh - // snapshot. - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledWith(true); - - expect(replay).not.toHaveLastSentReplay(); - - // Should be a new session - expect(replay).not.toHaveSameSession(initialSession); - - // Now do a click - domHandler({ - name: 'click', - }); - - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - - const newTimestamp = BASE_TIMESTAMP + FIFTEEN_MINUTES; - const breadcrumbTimestamp = newTimestamp + 20; // I don't know where this 20ms comes from - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - events: JSON.stringify([ - { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, - { - type: 5, - timestamp: breadcrumbTimestamp, - data: { - tag: 'breadcrumb', - payload: { - timestamp: breadcrumbTimestamp / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - - // `_context` should be reset when a new session is created - expect(replay.getContext()).toEqual( - expect.objectContaining({ - initialUrl: 'http://dummy/', - initialTimestamp: newTimestamp, - }), - ); - }); - - it('does not record if user has been idle for more than MAX_SESSION_LIFE and only starts a new session after a user action', async () => { - jest.clearAllMocks(); - const initialSession = replay.session; - - expect(initialSession?.id).toBeDefined(); - expect(replay.getContext()).toEqual( - expect.objectContaining({ - initialUrl: 'http://localhost/', - initialTimestamp: BASE_TIMESTAMP, - }), - ); - - const url = 'http://dummy/'; - Object.defineProperty(WINDOW, 'location', { - value: new URL(url), - }); - - // Idle for MAX_SESSION_LIFE - jest.advanceTimersByTime(MAX_SESSION_LIFE); - - // These events will not get flushed and will eventually be dropped because user is idle and session is expired - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: MAX_SESSION_LIFE, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - // performance events can still be collected while recording is stopped - // TODO: we may want to prevent `addEvent` from adding to buffer when user is inactive - replay.addUpdate(() => { - createPerformanceSpans(replay, [ - { - type: 'navigation.navigate', - name: 'foo', - start: BASE_TIMESTAMP + MAX_SESSION_LIFE, - end: BASE_TIMESTAMP + MAX_SESSION_LIFE + 100, - }, - ]); - return true; - }); - - WINDOW.dispatchEvent(new Event('blur')); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - // Should be the same session because user has been idle and no events have caused a new session to be created - expect(replay).toHaveSameSession(initialSession); - - // @ts-ignore private - expect(replay._stopRecording).toBeUndefined(); - - // Now do a click - domHandler({ - name: 'click', - }); - // This should still be thrown away - mockRecord._emitter(TEST_EVENT); - - const NEW_TEST_EVENT = { - data: { name: 'test' }, - timestamp: BASE_TIMESTAMP + MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 20, - type: 3, - }; - - mockRecord._emitter(NEW_TEST_EVENT); - - // new session is created - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveSameSession(initialSession); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - - const newTimestamp = BASE_TIMESTAMP + MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 20; // I don't know where this 20ms comes from - const breadcrumbTimestamp = newTimestamp; - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - events: JSON.stringify([ - { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, - { - type: 5, - timestamp: breadcrumbTimestamp, - data: { - tag: 'breadcrumb', - payload: { - timestamp: breadcrumbTimestamp / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - NEW_TEST_EVENT, - ]), - }); - - // `_context` should be reset when a new session is created - expect(replay.getContext()).toEqual( - expect.objectContaining({ - initialUrl: 'http://dummy/', - initialTimestamp: newTimestamp, - }), - ); - }); - - it('uploads a dom breadcrumb 5 seconds after listener receives an event', async () => { - domHandler({ - name: 'click', - }); - - // Pretend 5 seconds have passed - const ELAPSED = 5000; - await advanceTimers(ELAPSED); - - expect(replay).toHaveLastSentReplay({ - events: JSON.stringify([ - { - type: 5, - timestamp: BASE_TIMESTAMP, - data: { - tag: 'breadcrumb', - payload: { - timestamp: BASE_TIMESTAMP / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - - expect(replay.session?.segmentId).toBe(1); - }); - - it('fails to upload data on first two calls and succeeds on the third', async () => { - expect(replay.session?.segmentId).toBe(0); - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - - // Suppress console.errors - const mockConsole = jest.spyOn(console, 'error').mockImplementation(jest.fn()); - - // fail the first and second requests and pass the third one - mockTransportSend.mockImplementationOnce(() => { - throw new Error('Something bad happened'); - }); - mockRecord._emitter(TEST_EVENT); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - mockTransportSend.mockImplementationOnce(() => { - throw new Error('Something bad happened'); - }); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - - // next tick should retry and succeed - mockConsole.mockRestore(); - - await advanceTimers(8000); - await advanceTimers(2000); - - expect(replay).toHaveLastSentReplay({ - replayEventPayload: expect.objectContaining({ - error_ids: [], - replay_id: expect.any(String), - replay_start_timestamp: BASE_TIMESTAMP / 1000, - // 20seconds = Add up all of the previous `advanceTimers()` - timestamp: (BASE_TIMESTAMP + 20000) / 1000 + 0.02, - trace_ids: [], - urls: ['http://localhost/'], - }), - recordingPayloadHeader: { segment_id: 0 }, - events: JSON.stringify([TEST_EVENT]), - }); - - mockTransportSend.mockClear(); - // No activity has occurred, session's last activity should remain the same - expect(replay.session?.lastActivity).toBeGreaterThanOrEqual(BASE_TIMESTAMP); - expect(replay.session?.segmentId).toBe(1); - - // next tick should do nothing - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('fails to upload data and hits retry max and stops', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - jest.spyOn(replay, 'sendReplay'); - - // Suppress console.errors - const mockConsole = jest.spyOn(console, 'error').mockImplementation(jest.fn()); - - // Check errors - const spyHandleException = jest.spyOn(replay, 'handleException'); - - expect(replay.session?.segmentId).toBe(0); - - // fail the first and second requests and pass the third one - mockSendReplayRequest.mockReset(); - mockSendReplayRequest.mockImplementation(() => { - throw new Error('Something bad happened'); - }); - mockRecord._emitter(TEST_EVENT); - - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay.sendReplayRequest).toHaveBeenCalledTimes(1); - - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - expect(replay.sendReplayRequest).toHaveBeenCalledTimes(2); - - await advanceTimers(10000); - expect(replay.sendReplayRequest).toHaveBeenCalledTimes(3); - - await advanceTimers(30000); - expect(replay.sendReplayRequest).toHaveBeenCalledTimes(4); - expect(replay.sendReplay).toHaveBeenCalledTimes(4); - - mockConsole.mockReset(); - - // Make sure it doesn't retry again - jest.runAllTimers(); - expect(replay.sendReplayRequest).toHaveBeenCalledTimes(4); - expect(replay.sendReplay).toHaveBeenCalledTimes(4); - - // Retries = 3 (total tries = 4 including initial attempt) - // + last exception is max retries exceeded - expect(spyHandleException).toHaveBeenCalledTimes(5); - expect(spyHandleException).toHaveBeenLastCalledWith(new Error('Unable to send Replay - max retries exceeded')); - - // No activity has occurred, session's last activity should remain the same - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); - - // segmentId increases despite error - expect(replay.session?.segmentId).toBe(1); - }); - - it('increases segment id after each event', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - - // Pretend 5 seconds have passed - const ELAPSED = 5000; - await advanceTimers(ELAPSED); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; - - addEvent(replay, TEST_EVENT); - WINDOW.dispatchEvent(new Event('blur')); - await new Promise(process.nextTick); - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - }); - expect(replay.session?.segmentId).toBe(1); - - addEvent(replay, TEST_EVENT); - WINDOW.dispatchEvent(new Event('blur')); - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay.session?.segmentId).toBe(2); - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - }); - }); - - it('does not create replay event when there are no events to send', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - - document.dispatchEvent(new Event('visibilitychange')); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - - // Pretend 5 seconds have passed - const ELAPSED = 5000; - await advanceTimers(ELAPSED); - - const TEST_EVENT = { - data: {}, - timestamp: BASE_TIMESTAMP + ELAPSED, - type: 2, - }; - - addEvent(replay, TEST_EVENT); - WINDOW.dispatchEvent(new Event('blur')); - await new Promise(process.nextTick); - - expect(replay).toHaveLastSentReplay({ - replayEventPayload: expect.objectContaining({ - replay_start_timestamp: BASE_TIMESTAMP / 1000, - urls: ['http://localhost/'], // this doesn't truly test if we are capturing the right URL as we don't change URLs, but good enough - }), - }); - }); - - it('has correct timestamps when there events earlier than initial timestamp', async function () { - clearSession(replay); - replay.loadSession({ expiry: 0 }); - mockTransportSend.mockClear(); - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - - document.dispatchEvent(new Event('visibilitychange')); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - - // Pretend 5 seconds have passed - const ELAPSED = 5000; - await advanceTimers(ELAPSED); - - const TEST_EVENT = { - data: {}, - timestamp: BASE_TIMESTAMP + ELAPSED, - type: 2, - }; - - addEvent(replay, TEST_EVENT); - - // Add a fake event that started BEFORE - addEvent(replay, { - data: {}, - timestamp: (BASE_TIMESTAMP - 10000) / 1000, - type: 5, - }); - - WINDOW.dispatchEvent(new Event('blur')); - await new Promise(process.nextTick); - expect(replay).toHaveLastSentReplay({ - replayEventPayload: expect.objectContaining({ - replay_start_timestamp: (BASE_TIMESTAMP - 10000) / 1000, - urls: ['http://localhost/'], // this doesn't truly test if we are capturing the right URL as we don't change URLs, but good enough - replay_type: 'session', - tags: expect.objectContaining({ - errorSampleRate: 0, - sessionSampleRate: 1, - }), - }), - }); - }); - - it('does not have stale `replay_start_timestamp` due to an old time origin', async function () { - const ELAPSED = 86400000 * 2; // 2 days - // Add a mock performance event that happens 2 days ago. This can happen in the browser - // when a tab has sat idle for a long period and user comes back to it. - // - // We pass a negative start time as it's a bit difficult to mock - // `@sentry/utils/browserPerformanceTimeOrigin`. This would not happen in - // real world. - replay.performanceEvents.push( - PerformanceEntryResource({ - startTime: -ELAPSED, - }), - ); - - // This should be null because `addEvent` has not been called yet - expect(replay.getContext().earliestEvent).toBe(null); - expect(mockTransportSend).toHaveBeenCalledTimes(0); - - // A new checkout occurs (i.e. a new session was started) - const TEST_EVENT = { - data: {}, - timestamp: BASE_TIMESTAMP, - type: 2, - }; - - addEvent(replay, TEST_EVENT); - // This event will trigger a flush - WINDOW.dispatchEvent(new Event('blur')); - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(mockTransportSend).toHaveBeenCalledTimes(1); - expect(replay).toHaveLastSentReplay({ - replayEventPayload: expect.objectContaining({ - // Make sure the old performance event is thrown out - replay_start_timestamp: BASE_TIMESTAMP / 1000, - }), - events: JSON.stringify([ - TEST_EVENT, - { - type: 5, - timestamp: BASE_TIMESTAMP / 1000, - data: { - tag: 'breadcrumb', - payload: { - timestamp: BASE_TIMESTAMP / 1000, - type: 'default', - category: 'ui.blur', - }, - }, - }, - ]), - }); - - // This gets reset after sending replay - expect(replay.getContext().earliestEvent).toBe(null); - }); - - it('has single flush when checkout flush and debounce flush happen near simultaneously', async () => { - // click happens first - domHandler({ - name: 'click', - }); - - // checkout - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; - mockRecord._emitter(TEST_EVENT); - - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - expect(replay.flush).toHaveBeenCalledTimes(1); - - // Make sure there's nothing queued up after - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - expect(replay.flush).toHaveBeenCalledTimes(1); - }); -}); - -describe('eventProcessors', () => { - let hub: Hub; - let scope: Scope; - - beforeEach(() => { - hub = getCurrentHub(); - scope = hub.pushScope(); - }); - - afterEach(() => { - hub.popScope(); - jest.resetAllMocks(); - }); - - it('handles event processors properly', async () => { - const MUTATED_TIMESTAMP = BASE_TIMESTAMP + 3000; - - const { mockRecord } = await resetSdkMock({ - replayOptions: { - stickySession: false, - }, - }); - - const client = hub.getClient()!; - - jest.runAllTimers(); - const mockTransportSend = jest.spyOn(client.getTransport()!, 'send'); - mockTransportSend.mockReset(); - - const handler1 = jest.fn((event: Event): Event | null => { - event.timestamp = MUTATED_TIMESTAMP; - - return event; - }); - - const handler2 = jest.fn((): Event | null => { - return null; - }); - - scope.addEventProcessor(handler1); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - - mockRecord._emitter(TEST_EVENT); - jest.runAllTimers(); - jest.advanceTimersByTime(1); - await new Promise(process.nextTick); - - expect(mockTransportSend).toHaveBeenCalledTimes(1); - - scope.addEventProcessor(handler2); - - const TEST_EVENT2 = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - - mockRecord._emitter(TEST_EVENT2); - jest.runAllTimers(); - jest.advanceTimersByTime(1); - await new Promise(process.nextTick); - - expect(mockTransportSend).toHaveBeenCalledTimes(1); - - expect(handler1).toHaveBeenCalledTimes(2); - expect(handler2).toHaveBeenCalledTimes(1); - - // This receives an envelope, which is a deeply nested array - // We only care about the fact that the timestamp was mutated - expect(mockTransportSend).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.arrayContaining([expect.arrayContaining([expect.objectContaining({ timestamp: MUTATED_TIMESTAMP })])]), - ]), - ); - }); -}); diff --git a/packages/replay/test/unit/multiple-instances.test.ts b/packages/replay/test/unit/multiple-instances.test.ts deleted file mode 100644 index 9ae622605590..000000000000 --- a/packages/replay/test/unit/multiple-instances.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Replay } from './../../src'; - -it('throws on creating multiple instances', function () { - expect(() => { - new Replay(); - new Replay(); - }).toThrow(); -}); diff --git a/packages/replay/test/unit/multipleInstances.test.ts b/packages/replay/test/unit/multipleInstances.test.ts new file mode 100644 index 000000000000..a271801936ff --- /dev/null +++ b/packages/replay/test/unit/multipleInstances.test.ts @@ -0,0 +1,10 @@ +import { Replay } from '../../src'; + +describe('Unit | multipleInstances', () => { + it('throws on creating multiple instances', function () { + expect(() => { + new Replay(); + new Replay(); + }).toThrow(); + }); +}); diff --git a/packages/replay/test/unit/session/Session.test.ts b/packages/replay/test/unit/session/Session.test.ts deleted file mode 100644 index ca34e72b4172..000000000000 --- a/packages/replay/test/unit/session/Session.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -jest.mock('./../../../src/session/saveSession'); - -jest.mock('@sentry/browser', () => { - const originalModule = jest.requireActual('@sentry/browser'); - - return { - ...originalModule, - getCurrentHub: jest.fn(() => { - return { - captureEvent: jest.fn(), - getClient: jest.fn(() => ({ getDsn: jest.fn() })), - }; - }), - }; -}); - -jest.mock('@sentry/utils', () => { - const originalModule = jest.requireActual('@sentry/utils'); - - return { - ...originalModule, - uuid4: jest.fn(() => 'test_session_id'), - }; -}); - -import * as Sentry from '@sentry/browser'; - -import { WINDOW } from '../../../src/constants'; -import { getSessionSampleType, makeSession } from '../../../src/session/Session'; - -type CaptureEventMockType = jest.MockedFunction; - -beforeEach(() => { - WINDOW.sessionStorage.clear(); -}); - -afterEach(() => { - (Sentry.getCurrentHub().captureEvent as CaptureEventMockType).mockReset(); -}); - -it('does not sample', function () { - const newSession = makeSession({ - sampled: getSessionSampleType(0, 0), - }); - - expect(newSession.sampled).toBe(false); -}); - -it('samples using `sessionSampleRate`', function () { - const newSession = makeSession({ - sampled: getSessionSampleType(1.0, 0), - }); - - expect(newSession.sampled).toBe('session'); -}); - -it('samples using `errorSampleRate`', function () { - const newSession = makeSession({ - sampled: getSessionSampleType(0, 1), - }); - - expect(newSession.sampled).toBe('error'); -}); - -it('does not run sampling function if existing session was sampled', function () { - const newSession = makeSession({ - sampled: 'session', - }); - - expect(newSession.sampled).toBe('session'); -}); diff --git a/packages/replay/test/unit/session/createSession.test.ts b/packages/replay/test/unit/session/createSession.test.ts index f6942f1f6252..afe2ce3df374 100644 --- a/packages/replay/test/unit/session/createSession.test.ts +++ b/packages/replay/test/unit/session/createSession.test.ts @@ -15,53 +15,55 @@ jest.mock('@sentry/utils', () => { type CaptureEventMockType = jest.MockedFunction; -const captureEventMock: CaptureEventMockType = jest.fn(); +describe('Unit | session | createSession', () => { + const captureEventMock: CaptureEventMockType = jest.fn(); -beforeAll(() => { - WINDOW.sessionStorage.clear(); - jest.spyOn(Sentry, 'getCurrentHub'); - (Sentry.getCurrentHub as jest.Mock).mockImplementation(() => ({ - captureEvent: captureEventMock, - })); -}); - -afterEach(() => { - captureEventMock.mockReset(); -}); + beforeAll(() => { + WINDOW.sessionStorage.clear(); + jest.spyOn(Sentry, 'getCurrentHub'); + (Sentry.getCurrentHub as jest.Mock).mockImplementation(() => ({ + captureEvent: captureEventMock, + })); + }); -it('creates a new session with no sticky sessions', function () { - const newSession = createSession({ - stickySession: false, - sessionSampleRate: 1.0, - errorSampleRate: 0, + afterEach(() => { + captureEventMock.mockReset(); }); - expect(captureEventMock).not.toHaveBeenCalled(); - expect(saveSession).not.toHaveBeenCalled(); + it('creates a new session with no sticky sessions', function () { + const newSession = createSession({ + stickySession: false, + sessionSampleRate: 1.0, + errorSampleRate: 0, + }); + expect(captureEventMock).not.toHaveBeenCalled(); - expect(newSession.id).toBe('test_session_id'); - expect(newSession.started).toBeGreaterThan(0); - expect(newSession.lastActivity).toEqual(newSession.started); -}); + expect(saveSession).not.toHaveBeenCalled(); -it('creates a new session with sticky sessions', function () { - const newSession = createSession({ - stickySession: true, - sessionSampleRate: 1.0, - errorSampleRate: 0, + expect(newSession.id).toBe('test_session_id'); + expect(newSession.started).toBeGreaterThan(0); + expect(newSession.lastActivity).toEqual(newSession.started); }); - expect(captureEventMock).not.toHaveBeenCalled(); - expect(saveSession).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'test_session_id', - segmentId: 0, - started: expect.any(Number), - lastActivity: expect.any(Number), - }), - ); + it('creates a new session with sticky sessions', function () { + const newSession = createSession({ + stickySession: true, + sessionSampleRate: 1.0, + errorSampleRate: 0, + }); + expect(captureEventMock).not.toHaveBeenCalled(); + + expect(saveSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test_session_id', + segmentId: 0, + started: expect.any(Number), + lastActivity: expect.any(Number), + }), + ); - expect(newSession.id).toBe('test_session_id'); - expect(newSession.started).toBeGreaterThan(0); - expect(newSession.lastActivity).toEqual(newSession.started); + expect(newSession.id).toBe('test_session_id'); + expect(newSession.started).toBeGreaterThan(0); + expect(newSession.lastActivity).toEqual(newSession.started); + }); }); diff --git a/packages/replay/test/unit/session/fetchSession.test.ts b/packages/replay/test/unit/session/fetchSession.test.ts index 9f7e7694a5e0..526c9c7969d1 100644 --- a/packages/replay/test/unit/session/fetchSession.test.ts +++ b/packages/replay/test/unit/session/fetchSession.test.ts @@ -3,67 +3,69 @@ import { fetchSession } from '../../../src/session/fetchSession'; const oldSessionStorage = WINDOW.sessionStorage; -beforeAll(() => { - WINDOW.sessionStorage.clear(); -}); +describe('Unit | session | fetchSession', () => { + beforeAll(() => { + WINDOW.sessionStorage.clear(); + }); -afterEach(() => { - Object.defineProperty(WINDOW, 'sessionStorage', { - writable: true, - value: oldSessionStorage, + afterEach(() => { + Object.defineProperty(WINDOW, 'sessionStorage', { + writable: true, + value: oldSessionStorage, + }); + WINDOW.sessionStorage.clear(); }); - WINDOW.sessionStorage.clear(); -}); -it('fetches a valid and sampled session', function () { - WINDOW.sessionStorage.setItem( - REPLAY_SESSION_KEY, - '{"id":"fd09adfc4117477abc8de643e5a5798a","sampled": "session","started":1648827162630,"lastActivity":1648827162658}', - ); + it('fetches a valid and sampled session', function () { + WINDOW.sessionStorage.setItem( + REPLAY_SESSION_KEY, + '{"id":"fd09adfc4117477abc8de643e5a5798a","sampled": "session","started":1648827162630,"lastActivity":1648827162658}', + ); - expect(fetchSession()).toEqual({ - id: 'fd09adfc4117477abc8de643e5a5798a', - lastActivity: 1648827162658, - segmentId: 0, - sampled: 'session', - started: 1648827162630, + expect(fetchSession()).toEqual({ + id: 'fd09adfc4117477abc8de643e5a5798a', + lastActivity: 1648827162658, + segmentId: 0, + sampled: 'session', + started: 1648827162630, + }); }); -}); -it('fetches an unsampled session', function () { - WINDOW.sessionStorage.setItem( - REPLAY_SESSION_KEY, - '{"id":"fd09adfc4117477abc8de643e5a5798a","sampled": false,"started":1648827162630,"lastActivity":1648827162658}', - ); + it('fetches an unsampled session', function () { + WINDOW.sessionStorage.setItem( + REPLAY_SESSION_KEY, + '{"id":"fd09adfc4117477abc8de643e5a5798a","sampled": false,"started":1648827162630,"lastActivity":1648827162658}', + ); - expect(fetchSession()).toEqual({ - id: 'fd09adfc4117477abc8de643e5a5798a', - lastActivity: 1648827162658, - segmentId: 0, - sampled: false, - started: 1648827162630, + expect(fetchSession()).toEqual({ + id: 'fd09adfc4117477abc8de643e5a5798a', + lastActivity: 1648827162658, + segmentId: 0, + sampled: false, + started: 1648827162630, + }); }); -}); -it('fetches a session that does not exist', function () { - expect(fetchSession()).toBe(null); -}); + it('fetches a session that does not exist', function () { + expect(fetchSession()).toBe(null); + }); -it('fetches an invalid session', function () { - WINDOW.sessionStorage.setItem(REPLAY_SESSION_KEY, '{"id":"fd09adfc4117477abc8de643e5a5798a",'); + it('fetches an invalid session', function () { + WINDOW.sessionStorage.setItem(REPLAY_SESSION_KEY, '{"id":"fd09adfc4117477abc8de643e5a5798a",'); - expect(fetchSession()).toBe(null); -}); + expect(fetchSession()).toBe(null); + }); -it('safely attempts to fetch session when Session Storage is disabled', function () { - Object.defineProperty(WINDOW, 'sessionStorage', { - writable: true, - value: { - getItem: () => { - throw new Error('No Session Storage for you'); + it('safely attempts to fetch session when Session Storage is disabled', function () { + Object.defineProperty(WINDOW, 'sessionStorage', { + writable: true, + value: { + getItem: () => { + throw new Error('No Session Storage for you'); + }, }, - }, - }); + }); - expect(fetchSession()).toEqual(null); + expect(fetchSession()).toEqual(null); + }); }); diff --git a/packages/replay/test/unit/session/getSession.test.ts b/packages/replay/test/unit/session/getSession.test.ts index fd1606820c99..54c259fb7772 100644 --- a/packages/replay/test/unit/session/getSession.test.ts +++ b/packages/replay/test/unit/session/getSession.test.ts @@ -8,7 +8,7 @@ import { makeSession } from '../../../src/session/Session'; jest.mock('@sentry/utils', () => { return { ...(jest.requireActual('@sentry/utils') as { string: unknown }), - uuid4: jest.fn(() => 'test_session_id'), + uuid4: jest.fn(() => 'test_session_uuid'), }; }); @@ -27,166 +27,168 @@ function createMockSession(when: number = new Date().getTime()) { }); } -beforeAll(() => { - jest.spyOn(CreateSession, 'createSession'); - jest.spyOn(FetchSession, 'fetchSession'); - WINDOW.sessionStorage.clear(); -}); - -afterEach(() => { - WINDOW.sessionStorage.clear(); - (CreateSession.createSession as jest.MockedFunction).mockClear(); - (FetchSession.fetchSession as jest.MockedFunction).mockClear(); -}); - -it('creates a non-sticky session when one does not exist', function () { - const { session } = getSession({ - expiry: 900000, - stickySession: false, - ...SAMPLE_RATES, +describe('Unit | session | getSession', () => { + beforeAll(() => { + jest.spyOn(CreateSession, 'createSession'); + jest.spyOn(FetchSession, 'fetchSession'); + WINDOW.sessionStorage.clear(); }); - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session).toEqual({ - id: 'test_session_id', - segmentId: 0, - lastActivity: expect.any(Number), - sampled: 'session', - started: expect.any(Number), + afterEach(() => { + WINDOW.sessionStorage.clear(); + (CreateSession.createSession as jest.MockedFunction).mockClear(); + (FetchSession.fetchSession as jest.MockedFunction).mockClear(); }); - // Should not have anything in storage - expect(FetchSession.fetchSession()).toBe(null); -}); + it('creates a non-sticky session when one does not exist', function () { + const { session } = getSession({ + expiry: 900000, + stickySession: false, + ...SAMPLE_RATES, + }); -it('creates a non-sticky session, regardless of session existing in sessionStorage', function () { - saveSession(createMockSession(new Date().getTime() - 10000)); - - const { session } = getSession({ - expiry: 1000, - stickySession: false, - ...SAMPLE_RATES, - }); + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).toHaveBeenCalled(); - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session).toBeDefined(); -}); - -it('creates a non-sticky session, when one is expired', function () { - const { session } = getSession({ - expiry: 1000, - stickySession: false, - ...SAMPLE_RATES, - currentSession: makeSession({ - id: 'old_session_id', - lastActivity: new Date().getTime() - 1001, - started: new Date().getTime() - 1001, + expect(session).toEqual({ + id: 'test_session_uuid', segmentId: 0, + lastActivity: expect.any(Number), sampled: 'session', - }), + started: expect.any(Number), + }); + + // Should not have anything in storage + expect(FetchSession.fetchSession()).toBe(null); }); - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); + it('creates a non-sticky session, regardless of session existing in sessionStorage', function () { + saveSession(createMockSession(new Date().getTime() - 10000)); - expect(session).toBeDefined(); - expect(session.id).not.toBe('old_session_id'); -}); + const { session } = getSession({ + expiry: 1000, + stickySession: false, + ...SAMPLE_RATES, + }); -it('creates a sticky session when one does not exist', function () { - expect(FetchSession.fetchSession()).toBe(null); + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).toHaveBeenCalled(); - const { session } = getSession({ - expiry: 900000, - stickySession: true, - sessionSampleRate: 1.0, - errorSampleRate: 0.0, + expect(session).toBeDefined(); }); - expect(FetchSession.fetchSession).toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session).toEqual({ - id: 'test_session_id', - segmentId: 0, - lastActivity: expect.any(Number), - sampled: 'session', - started: expect.any(Number), + it('creates a non-sticky session, when one is expired', function () { + const { session } = getSession({ + expiry: 1000, + stickySession: false, + ...SAMPLE_RATES, + currentSession: makeSession({ + id: 'old_session_id', + lastActivity: new Date().getTime() - 1001, + started: new Date().getTime() - 1001, + segmentId: 0, + sampled: 'session', + }), + }); + + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).toHaveBeenCalled(); + + expect(session).toBeDefined(); + expect(session.id).not.toBe('old_session_id'); }); - // Should not have anything in storage - expect(FetchSession.fetchSession()).toEqual({ - id: 'test_session_id', - segmentId: 0, - lastActivity: expect.any(Number), - sampled: 'session', - started: expect.any(Number), - }); -}); + it('creates a sticky session when one does not exist', function () { + expect(FetchSession.fetchSession()).toBe(null); -it('fetches an existing sticky session', function () { - const now = new Date().getTime(); - saveSession(createMockSession(now)); + const { session } = getSession({ + expiry: 900000, + stickySession: true, + sessionSampleRate: 1.0, + errorSampleRate: 0.0, + }); - const { session } = getSession({ - expiry: 1000, - stickySession: true, - sessionSampleRate: 1.0, - errorSampleRate: 0.0, - }); + expect(FetchSession.fetchSession).toHaveBeenCalled(); + expect(CreateSession.createSession).toHaveBeenCalled(); - expect(FetchSession.fetchSession).toHaveBeenCalled(); - expect(CreateSession.createSession).not.toHaveBeenCalled(); + expect(session).toEqual({ + id: 'test_session_uuid', + segmentId: 0, + lastActivity: expect.any(Number), + sampled: 'session', + started: expect.any(Number), + }); - expect(session).toEqual({ - id: 'test_session_id', - segmentId: 0, - lastActivity: now, - sampled: 'session', - started: now, + // Should not have anything in storage + expect(FetchSession.fetchSession()).toEqual({ + id: 'test_session_uuid', + segmentId: 0, + lastActivity: expect.any(Number), + sampled: 'session', + started: expect.any(Number), + }); }); -}); -it('fetches an expired sticky session', function () { - const now = new Date().getTime(); - saveSession(createMockSession(new Date().getTime() - 2000)); + it('fetches an existing sticky session', function () { + const now = new Date().getTime(); + saveSession(createMockSession(now)); - const { session } = getSession({ - expiry: 1000, - stickySession: true, - ...SAMPLE_RATES, - }); + const { session } = getSession({ + expiry: 1000, + stickySession: true, + sessionSampleRate: 1.0, + errorSampleRate: 0.0, + }); - expect(FetchSession.fetchSession).toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session.id).toBe('test_session_id'); - expect(session.lastActivity).toBeGreaterThanOrEqual(now); - expect(session.started).toBeGreaterThanOrEqual(now); - expect(session.segmentId).toBe(0); -}); + expect(FetchSession.fetchSession).toHaveBeenCalled(); + expect(CreateSession.createSession).not.toHaveBeenCalled(); -it('fetches a non-expired non-sticky session', function () { - const { session } = getSession({ - expiry: 1000, - stickySession: false, - ...SAMPLE_RATES, - currentSession: makeSession({ - id: 'test_session_id_2', - lastActivity: +new Date() - 500, - started: +new Date() - 500, + expect(session).toEqual({ + id: 'test_session_id', segmentId: 0, + lastActivity: now, sampled: 'session', - }), + started: now, + }); }); - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).not.toHaveBeenCalled(); + it('fetches an expired sticky session', function () { + const now = new Date().getTime(); + saveSession(createMockSession(new Date().getTime() - 2000)); - expect(session.id).toBe('test_session_id_2'); - expect(session.segmentId).toBe(0); + const { session } = getSession({ + expiry: 1000, + stickySession: true, + ...SAMPLE_RATES, + }); + + expect(FetchSession.fetchSession).toHaveBeenCalled(); + expect(CreateSession.createSession).toHaveBeenCalled(); + + expect(session.id).toBe('test_session_uuid'); + expect(session.lastActivity).toBeGreaterThanOrEqual(now); + expect(session.started).toBeGreaterThanOrEqual(now); + expect(session.segmentId).toBe(0); + }); + + it('fetches a non-expired non-sticky session', function () { + const { session } = getSession({ + expiry: 1000, + stickySession: false, + ...SAMPLE_RATES, + currentSession: makeSession({ + id: 'test_session_uuid_2', + lastActivity: +new Date() - 500, + started: +new Date() - 500, + segmentId: 0, + sampled: 'session', + }), + }); + + expect(FetchSession.fetchSession).not.toHaveBeenCalled(); + expect(CreateSession.createSession).not.toHaveBeenCalled(); + + expect(session.id).toBe('test_session_uuid_2'); + expect(session.segmentId).toBe(0); + }); }); diff --git a/packages/replay/test/unit/session/saveSession.test.ts b/packages/replay/test/unit/session/saveSession.test.ts index c1a884bca84f..ff8b8e15f204 100644 --- a/packages/replay/test/unit/session/saveSession.test.ts +++ b/packages/replay/test/unit/session/saveSession.test.ts @@ -2,23 +2,25 @@ import { REPLAY_SESSION_KEY, WINDOW } from '../../../src/constants'; import { saveSession } from '../../../src/session/saveSession'; import { makeSession } from '../../../src/session/Session'; -beforeAll(() => { - WINDOW.sessionStorage.clear(); -}); - -afterEach(() => { - WINDOW.sessionStorage.clear(); -}); +describe('Unit | session | saveSession', () => { + beforeAll(() => { + WINDOW.sessionStorage.clear(); + }); -it('saves a valid session', function () { - const session = makeSession({ - id: 'fd09adfc4117477abc8de643e5a5798a', - segmentId: 0, - started: 1648827162630, - lastActivity: 1648827162658, - sampled: 'session', + afterEach(() => { + WINDOW.sessionStorage.clear(); }); - saveSession(session); - expect(WINDOW.sessionStorage.getItem(REPLAY_SESSION_KEY)).toEqual(JSON.stringify(session)); + it('saves a valid session', function () { + const session = makeSession({ + id: 'fd09adfc4117477abc8de643e5a5798a', + segmentId: 0, + started: 1648827162630, + lastActivity: 1648827162658, + sampled: 'session', + }); + saveSession(session); + + expect(WINDOW.sessionStorage.getItem(REPLAY_SESSION_KEY)).toEqual(JSON.stringify(session)); + }); }); diff --git a/packages/replay/test/unit/session/sessionSampling.test.ts b/packages/replay/test/unit/session/sessionSampling.test.ts new file mode 100644 index 000000000000..cb730006d572 --- /dev/null +++ b/packages/replay/test/unit/session/sessionSampling.test.ts @@ -0,0 +1,35 @@ +import { getSessionSampleType, makeSession } from '../../../src/session/Session'; + +describe('Unit | session | sessionSampling', () => { + it('does not sample', function () { + const newSession = makeSession({ + sampled: getSessionSampleType(0, 0), + }); + + expect(newSession.sampled).toBe(false); + }); + + it('samples using `sessionSampleRate`', function () { + const newSession = makeSession({ + sampled: getSessionSampleType(1.0, 0), + }); + + expect(newSession.sampled).toBe('session'); + }); + + it('samples using `errorSampleRate`', function () { + const newSession = makeSession({ + sampled: getSessionSampleType(0, 1), + }); + + expect(newSession.sampled).toBe('error'); + }); + + it('does not run sampling function if existing session was sampled', function () { + const newSession = makeSession({ + sampled: 'session', + }); + + expect(newSession.sampled).toBe('session'); + }); +}); diff --git a/packages/replay/test/unit/util/createPerformanceEntry.test.ts b/packages/replay/test/unit/util/createPerformanceEntry.test.ts new file mode 100644 index 000000000000..cd250f7db64c --- /dev/null +++ b/packages/replay/test/unit/util/createPerformanceEntry.test.ts @@ -0,0 +1,34 @@ +import { createPerformanceEntries } from '../../../src/util/createPerformanceEntries'; + +describe('Unit | util | createPerformanceEntries', () => { + it('ignores sdks own requests', function () { + const data = { + name: 'https://ingest.f00.f00/api/1/envelope/?sentry_key=dsn&sentry_version=7', + entryType: 'resource', + startTime: 234462.69999998808, + duration: 55.70000001788139, + initiatorType: 'fetch', + nextHopProtocol: '', + workerStart: 0, + redirectStart: 0, + redirectEnd: 0, + fetchStart: 234462.69999998808, + domainLookupStart: 0, + domainLookupEnd: 0, + connectStart: 0, + connectEnd: 0, + secureConnectionStart: 0, + requestStart: 0, + responseStart: 0, + responseEnd: 234518.40000000596, + transferSize: 0, + encodedBodySize: 0, + decodedBodySize: 0, + serverTiming: [], + workerTiming: [], + } as const; + + // @ts-ignore Needs a PerformanceEntry mock + expect(createPerformanceEntries([data])).toEqual([]); + }); +}); diff --git a/packages/replay/test/unit/util/createReplayEnvelope.test.ts b/packages/replay/test/unit/util/createReplayEnvelope.test.ts index 1e5ec5334784..d5c5f2e5b75f 100644 --- a/packages/replay/test/unit/util/createReplayEnvelope.test.ts +++ b/packages/replay/test/unit/util/createReplayEnvelope.test.ts @@ -3,7 +3,7 @@ import { makeDsn } from '@sentry/utils'; import { createReplayEnvelope } from '../../../src/util/createReplayEnvelope'; -describe('createReplayEnvelope', () => { +describe('Unit | util | createReplayEnvelope', () => { const REPLAY_ID = 'MY_REPLAY_ID'; const replayEvent: ReplayEvent = { diff --git a/packages/replay/test/unit/util/debounce.test.ts b/packages/replay/test/unit/util/debounce.test.ts index 27f95c96a4c6..b75adc6bf3bd 100644 --- a/packages/replay/test/unit/util/debounce.test.ts +++ b/packages/replay/test/unit/util/debounce.test.ts @@ -1,6 +1,6 @@ import { debounce } from '../../../src/util/debounce'; -describe('debounce', () => { +describe('Unit | util | debounce', () => { jest.useFakeTimers(); it('delay the execution of the passed callback function by the passed minDelay', () => { const callback = jest.fn(); diff --git a/packages/replay/test/unit/util/dedupePerformanceEntries.test.ts b/packages/replay/test/unit/util/dedupePerformanceEntries.test.ts index 15e66421829b..08884a19407a 100644 --- a/packages/replay/test/unit/util/dedupePerformanceEntries.test.ts +++ b/packages/replay/test/unit/util/dedupePerformanceEntries.test.ts @@ -1,66 +1,67 @@ -// eslint-disable-next-line import/no-unresolved import { dedupePerformanceEntries } from '../../../src/util/dedupePerformanceEntries'; import { PerformanceEntryLcp } from './../../fixtures/performanceEntry/lcp'; import { PerformanceEntryNavigation } from './../../fixtures/performanceEntry/navigation'; import { PerformanceEntryResource } from './../../fixtures/performanceEntry/resource'; -it('does nothing with a single entry', function () { - const entries = [PerformanceEntryNavigation()]; - expect(dedupePerformanceEntries([], entries)).toEqual(entries); -}); +describe('Unit | util | dedupePerformanceEntries', () => { + it('does nothing with a single entry', function () { + const entries = [PerformanceEntryNavigation()]; + expect(dedupePerformanceEntries([], entries)).toEqual(entries); + }); -it('dedupes 2 duplicate entries correctly', function () { - const entries = [PerformanceEntryNavigation(), PerformanceEntryNavigation()]; - expect(dedupePerformanceEntries([], entries)).toEqual([entries[0]]); -}); + it('dedupes 2 duplicate entries correctly', function () { + const entries = [PerformanceEntryNavigation(), PerformanceEntryNavigation()]; + expect(dedupePerformanceEntries([], entries)).toEqual([entries[0]]); + }); -it('dedupes multiple+mixed entries from new list', function () { - const a = PerformanceEntryNavigation({ startTime: 0 }); - const b = PerformanceEntryNavigation({ - startTime: 1, - name: 'https://foo.bar/', + it('dedupes multiple+mixed entries from new list', function () { + const a = PerformanceEntryNavigation({ startTime: 0 }); + const b = PerformanceEntryNavigation({ + startTime: 1, + name: 'https://foo.bar/', + }); + const c = PerformanceEntryNavigation({ startTime: 2, type: 'reload' }); + const d = PerformanceEntryResource({ startTime: 1.5 }); + const entries = [a, a, b, d, b, c]; + expect(dedupePerformanceEntries([], entries)).toEqual([a, b, d, c]); }); - const c = PerformanceEntryNavigation({ startTime: 2, type: 'reload' }); - const d = PerformanceEntryResource({ startTime: 1.5 }); - const entries = [a, a, b, d, b, c]; - expect(dedupePerformanceEntries([], entries)).toEqual([a, b, d, c]); -}); -it('dedupes from initial list and new list', function () { - const a = PerformanceEntryNavigation({ startTime: 0 }); - const b = PerformanceEntryNavigation({ - startTime: 1, - name: 'https://foo.bar/', + it('dedupes from initial list and new list', function () { + const a = PerformanceEntryNavigation({ startTime: 0 }); + const b = PerformanceEntryNavigation({ + startTime: 1, + name: 'https://foo.bar/', + }); + const c = PerformanceEntryNavigation({ startTime: 2, type: 'reload' }); + const d = PerformanceEntryNavigation({ startTime: 1000 }); + const entries = [a, a, b, b, c]; + expect(dedupePerformanceEntries([a, d], entries)).toEqual([a, b, c, d]); }); - const c = PerformanceEntryNavigation({ startTime: 2, type: 'reload' }); - const d = PerformanceEntryNavigation({ startTime: 1000 }); - const entries = [a, a, b, b, c]; - expect(dedupePerformanceEntries([a, d], entries)).toEqual([a, b, c, d]); -}); -it('selects the latest lcp value given multiple lcps in new list', function () { - const a = PerformanceEntryResource({ startTime: 0 }); - const b = PerformanceEntryLcp({ startTime: 100, name: 'b' }); - const c = PerformanceEntryLcp({ startTime: 200, name: 'c' }); - const d = PerformanceEntryLcp({ startTime: 5, name: 'd' }); // don't assume they are ordered - const entries = [a, b, c, d]; - expect(dedupePerformanceEntries([], entries)).toEqual([a, c]); -}); + it('selects the latest lcp value given multiple lcps in new list', function () { + const a = PerformanceEntryResource({ startTime: 0 }); + const b = PerformanceEntryLcp({ startTime: 100, name: 'b' }); + const c = PerformanceEntryLcp({ startTime: 200, name: 'c' }); + const d = PerformanceEntryLcp({ startTime: 5, name: 'd' }); // don't assume they are ordered + const entries = [a, b, c, d]; + expect(dedupePerformanceEntries([], entries)).toEqual([a, c]); + }); -it('selects the latest lcp value from new list, given multiple lcps in new list with an existing lcp', function () { - const a = PerformanceEntryResource({ startTime: 0 }); - const b = PerformanceEntryLcp({ startTime: 100, name: 'b' }); - const c = PerformanceEntryLcp({ startTime: 200, name: 'c' }); - const d = PerformanceEntryLcp({ startTime: 5, name: 'd' }); // don't assume they are ordered - const entries = [b, c, d]; - expect(dedupePerformanceEntries([a, d], entries)).toEqual([a, c]); -}); + it('selects the latest lcp value from new list, given multiple lcps in new list with an existing lcp', function () { + const a = PerformanceEntryResource({ startTime: 0 }); + const b = PerformanceEntryLcp({ startTime: 100, name: 'b' }); + const c = PerformanceEntryLcp({ startTime: 200, name: 'c' }); + const d = PerformanceEntryLcp({ startTime: 5, name: 'd' }); // don't assume they are ordered + const entries = [b, c, d]; + expect(dedupePerformanceEntries([a, d], entries)).toEqual([a, c]); + }); -it('selects the existing lcp value given multiple lcps in new list with an existing lcp having the latest startTime', function () { - const a = PerformanceEntryResource({ startTime: 0 }); - const b = PerformanceEntryLcp({ startTime: 100, name: 'b' }); - const c = PerformanceEntryLcp({ startTime: 200, name: 'c' }); - const d = PerformanceEntryLcp({ startTime: 5, name: 'd' }); // don't assume they are ordered - const entries = [b, d]; - expect(dedupePerformanceEntries([a, c], entries)).toEqual([a, c]); + it('selects the existing lcp value given multiple lcps in new list with an existing lcp having the latest startTime', function () { + const a = PerformanceEntryResource({ startTime: 0 }); + const b = PerformanceEntryLcp({ startTime: 100, name: 'b' }); + const c = PerformanceEntryLcp({ startTime: 200, name: 'c' }); + const d = PerformanceEntryLcp({ startTime: 5, name: 'd' }); // don't assume they are ordered + const entries = [b, d]; + expect(dedupePerformanceEntries([a, c], entries)).toEqual([a, c]); + }); }); diff --git a/packages/replay/test/unit/util/getReplayEvent.test.ts b/packages/replay/test/unit/util/getReplayEvent.test.ts new file mode 100644 index 000000000000..f9f6dcdfc79e --- /dev/null +++ b/packages/replay/test/unit/util/getReplayEvent.test.ts @@ -0,0 +1,62 @@ +import { BrowserClient } from '@sentry/browser'; +import { getCurrentHub, Hub, Scope } from '@sentry/core'; +import { Client, ReplayEvent } from '@sentry/types'; + +import { REPLAY_EVENT_NAME } from '../../../src/constants'; +import { prepareReplayEvent } from '../../../src/util/prepareReplayEvent'; +import { getDefaultBrowserClientOptions } from '../../utils/getDefaultBrowserClientOptions'; + +describe('Unit | util | getReplayEvent', () => { + let hub: Hub; + let client: Client; + let scope: Scope; + + beforeEach(() => { + hub = getCurrentHub(); + client = new BrowserClient(getDefaultBrowserClientOptions()); + hub.bindClient(client); + + client = hub.getClient()!; + scope = hub.getScope()!; + }); + + it('works', async () => { + expect(client).toBeDefined(); + expect(scope).toBeDefined(); + + const replayId = 'replay-ID'; + const event: ReplayEvent = { + // @ts-ignore private api + type: REPLAY_EVENT_NAME, + timestamp: 1670837008.634, + error_ids: ['error-ID'], + trace_ids: ['trace-ID'], + urls: ['https://sentry.io/'], + replay_id: replayId, + replay_type: 'session', + segment_id: 3, + }; + + const replayEvent = await prepareReplayEvent({ scope, client, replayId, event }); + + expect(replayEvent).toEqual({ + type: 'replay_event', + timestamp: 1670837008.634, + error_ids: ['error-ID'], + trace_ids: ['trace-ID'], + urls: ['https://sentry.io/'], + replay_id: 'replay-ID', + replay_type: 'session', + segment_id: 3, + platform: 'javascript', + event_id: 'replay-ID', + environment: 'production', + sdk: { + name: 'sentry.javascript.browser', + version: 'version:Test', + }, + sdkProcessingMetadata: {}, + breadcrumbs: undefined, + }); + }); +}); diff --git a/packages/replay/test/unit/util/isExpired.test.ts b/packages/replay/test/unit/util/isExpired.test.ts index a83677cf856e..2914cf1198d1 100644 --- a/packages/replay/test/unit/util/isExpired.test.ts +++ b/packages/replay/test/unit/util/isExpired.test.ts @@ -1,23 +1,25 @@ import { isExpired } from '../../../src/util/isExpired'; -it('is expired', function () { - expect(isExpired(0, 150, 200)).toBe(true); // expired at ts = 150 -}); +describe('Unit | util | isExpired', () => { + it('is expired', function () { + expect(isExpired(0, 150, 200)).toBe(true); // expired at ts = 150 + }); -it('is not expired', function () { - expect(isExpired(100, 150, 200)).toBe(false); // expires at ts >= 250 -}); + it('is not expired', function () { + expect(isExpired(100, 150, 200)).toBe(false); // expires at ts >= 250 + }); -it('is expired when target time reaches exactly the expiry time', function () { - expect(isExpired(100, 150, 250)).toBe(true); // expires at ts >= 250 -}); + it('is expired when target time reaches exactly the expiry time', function () { + expect(isExpired(100, 150, 250)).toBe(true); // expires at ts >= 250 + }); -it('never expires if expiry is 0', function () { - expect(isExpired(300, 0, 200)).toBe(false); - expect(isExpired(0, 0, 200)).toBe(false); -}); + it('never expires if expiry is 0', function () { + expect(isExpired(300, 0, 200)).toBe(false); + expect(isExpired(0, 0, 200)).toBe(false); + }); -it('always expires if expiry is < 0', function () { - expect(isExpired(300, -1, 200)).toBe(true); - expect(isExpired(0, -1, 200)).toBe(true); + it('always expires if expiry is < 0', function () { + expect(isExpired(300, -1, 200)).toBe(true); + expect(isExpired(0, -1, 200)).toBe(true); + }); }); diff --git a/packages/replay/test/unit/util/isSampled.test.ts b/packages/replay/test/unit/util/isSampled.test.ts index 6b26f1d11046..97a824cb0e9d 100644 --- a/packages/replay/test/unit/util/isSampled.test.ts +++ b/packages/replay/test/unit/util/isSampled.test.ts @@ -13,10 +13,9 @@ const cases: [number, number, boolean][] = [ [0.5, 0.0, true], ]; -jest.spyOn(Math, 'random'); -const mockRandom = Math.random as jest.MockedFunction; +describe('Unit | util | isSampled', () => { + const mockRandom = jest.spyOn(Math, 'random'); -describe('isSampled', () => { test.each(cases)( 'given sample rate of %p and RNG returns %p, result should be %p', (sampleRate: number, mockRandomValue: number, expectedResult: boolean) => { diff --git a/packages/replay/test/unit/util/isSessionExpired.test.ts b/packages/replay/test/unit/util/isSessionExpired.test.ts index 0db371e8e4ef..381b8ffe6428 100644 --- a/packages/replay/test/unit/util/isSessionExpired.test.ts +++ b/packages/replay/test/unit/util/isSessionExpired.test.ts @@ -12,18 +12,20 @@ function createSession(extra?: Record) { }); } -it('session last activity is older than expiry time', function () { - expect(isSessionExpired(createSession(), 100, 200)).toBe(true); // Session expired at ts = 100 -}); +describe('Unit | util | isSessionExpired', () => { + it('session last activity is older than expiry time', function () { + expect(isSessionExpired(createSession(), 100, 200)).toBe(true); // Session expired at ts = 100 + }); -it('session last activity is not older than expiry time', function () { - expect(isSessionExpired(createSession({ lastActivity: 100 }), 150, 200)).toBe(false); // Session expires at ts >= 250 -}); + it('session last activity is not older than expiry time', function () { + expect(isSessionExpired(createSession({ lastActivity: 100 }), 150, 200)).toBe(false); // Session expires at ts >= 250 + }); -it('session age is not older than max session life', function () { - expect(isSessionExpired(createSession(), 1_800_000, 50_000)).toBe(false); -}); + it('session age is not older than max session life', function () { + expect(isSessionExpired(createSession(), 1_800_000, 50_000)).toBe(false); + }); -it('session age is older than max session life', function () { - expect(isSessionExpired(createSession(), 1_800_000, 1_800_001)).toBe(true); // Session expires at ts >= 1_800_000 + it('session age is older than max session life', function () { + expect(isSessionExpired(createSession(), 1_800_000, 1_800_001)).toBe(true); // Session expires at ts >= 1_800_000 + }); }); diff --git a/packages/replay/test/unit/util/prepareReplayEvent.test.ts b/packages/replay/test/unit/util/prepareReplayEvent.test.ts index 4c7785bf3735..1213548cddb2 100644 --- a/packages/replay/test/unit/util/prepareReplayEvent.test.ts +++ b/packages/replay/test/unit/util/prepareReplayEvent.test.ts @@ -6,7 +6,7 @@ import { REPLAY_EVENT_NAME } from '../../../src/constants'; import { prepareReplayEvent } from '../../../src/util/prepareReplayEvent'; import { getDefaultBrowserClientOptions } from '../../utils/getDefaultBrowserClientOptions'; -describe('prepareReplayEvent', () => { +describe('Unit | util | prepareReplayEvent', () => { let hub: Hub; let client: Client; let scope: Scope; diff --git a/packages/replay/test/unit/worker/Compressor.test.ts b/packages/replay/test/unit/worker/Compressor.test.ts index 77b95a07439b..afafa52da8cb 100644 --- a/packages/replay/test/unit/worker/Compressor.test.ts +++ b/packages/replay/test/unit/worker/Compressor.test.ts @@ -2,7 +2,7 @@ import pako from 'pako'; import { Compressor } from '../../../worker/src/Compressor'; -describe('Compressor', () => { +describe('Unit | worker | Compressor', () => { it('compresses multiple events', () => { const compressor = new Compressor();