diff --git a/packages/replay-internal/test.setup.ts b/packages/replay-internal/test.setup.ts deleted file mode 100644 index acee6fb8f3de..000000000000 --- a/packages/replay-internal/test.setup.ts +++ /dev/null @@ -1,254 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ - -import type { ReplayRecordingData, Transport } from '@sentry/core'; -import { getClient } from '@sentry/core'; -import * as SentryUtils from '@sentry/core'; -import { printDiffOrStringify } from 'jest-matcher-utils'; -import type { Mocked, MockedFunction } from 'vitest'; -import { expect, vi } from 'vitest'; -import type { ReplayContainer, Session } from './src/types'; - -type MockTransport = MockedFunction; - -vi.spyOn(SentryUtils, 'isBrowser').mockImplementation(() => true); - -type EnvelopeHeader = { - event_id: string; - sent_at: string; - sdk: { - name: string; - version?: string; - }; -}; - -type ReplayEventHeader = { type: 'replay_event' }; -type ReplayEventPayload = Record; -type RecordingHeader = { type: 'replay_recording'; length: number }; -type RecordingPayloadHeader = Record; -type SentReplayExpected = { - envelopeHeader?: EnvelopeHeader; - replayEventHeader?: ReplayEventHeader; - replayEventPayload?: ReplayEventPayload; - recordingHeader?: RecordingHeader; - recordingPayloadHeader?: RecordingPayloadHeader; - recordingData?: ReplayRecordingData; -}; - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const toHaveSameSession = function (received: Mocked, expected: undefined | Session) { - const pass = this.equals(received.session?.id, expected?.id) as boolean; - - const options = { - isNot: this.isNot, - promise: this.promise, - }; - - return { - pass, - message: () => - `${this.utils.matcherHint('toHaveSameSession', undefined, undefined, options)}\n\n${printDiffOrStringify( - expected, - received.session, - 'Expected', - 'Received', - )}`, - }; -}; - -type Result = { - passed: boolean; - key: string; - expectedVal: SentReplayExpected[keyof SentReplayExpected]; - actualVal: SentReplayExpected[keyof SentReplayExpected]; -}; -type Call = [ - EnvelopeHeader, - [ - [ReplayEventHeader | undefined, ReplayEventPayload | undefined], - [RecordingHeader | undefined, RecordingPayloadHeader | undefined], - ], -]; -type CheckCallForSentReplayResult = { pass: boolean; call: Call | undefined; results: Result[] }; - -function checkCallForSentReplay( - call: Call | undefined, - expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, -): CheckCallForSentReplayResult { - const envelopeHeader = call?.[0]; - const envelopeItems = call?.[1] || [[], []]; - const [[replayEventHeader, replayEventPayload], [recordingHeader, recordingPayload] = []] = envelopeItems; - - // @ts-expect-error recordingPayload is always a string in our tests - const [recordingPayloadHeader, recordingData] = recordingPayload?.split('\n') || []; - - const actualObj: Required = { - // @ts-expect-error Custom envelope - envelopeHeader: envelopeHeader, - // @ts-expect-error Custom envelope - replayEventHeader: replayEventHeader, - // @ts-expect-error Custom envelope - replayEventPayload: replayEventPayload, - // @ts-expect-error Custom envelope - recordingHeader: recordingHeader, - recordingPayloadHeader: recordingPayloadHeader && JSON.parse(recordingPayloadHeader), - recordingData, - }; - - const isObjectContaining = expected && 'sample' in expected && 'inverse' in expected; - const expectedObj = isObjectContaining - ? (expected as { sample: SentReplayExpected }).sample - : (expected as SentReplayExpected); - - if (isObjectContaining) { - // eslint-disable-next-line no-console - console.warn('`expect.objectContaining` is unnecessary when using the `toHaveSentReplay` matcher'); - } - - const results = expected - ? Object.keys(expectedObj) - .map(key => { - const actualVal = actualObj[key as keyof SentReplayExpected]; - const expectedVal = expectedObj[key as keyof SentReplayExpected]; - const passed = !expectedVal || this.equals(actualVal, expectedVal); - - return { passed, key, expectedVal, actualVal }; - }) - .filter(({ passed }) => !passed) - : []; - - const pass = Boolean(call && (!expected || results.length === 0)); - - return { - pass, - call, - results, - }; -} - -/** - * Only want calls that send replay events, i.e. ignore error events - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function getReplayCalls(calls: any[][][]): any[][][] { - return calls - .map(call => { - const arg = call[0]; - if (arg.length !== 2) { - return []; - } - - if (!arg[1][0].find(({ type }: { type: string }) => ['replay_event', 'replay_recording'].includes(type))) { - return []; - } - - return [arg]; - }) - .filter(Boolean); -} - -/** - * Checks all calls to `fetch` and ensures a replay was uploaded by - * checking the `fetch()` request's body. - */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const toHaveSentReplay = function ( - _received: Mocked, - expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, -) { - const { calls } = (getClient()?.getTransport()?.send as MockTransport).mock; - - let result: CheckCallForSentReplayResult; - - const expectedKeysLength = expected - ? ('sample' in expected ? Object.keys(expected.sample) : Object.keys(expected)).length - : 0; - - const replayCalls = getReplayCalls(calls); - - for (const currentCall of replayCalls) { - result = checkCallForSentReplay.call(this, currentCall[0], expected); - if (result.pass) { - break; - } - - // stop on the first call where any of the expected obj passes - if (result.results.length < expectedKeysLength) { - break; - } - } - - // @ts-expect-error use before assigned - const { results, call, pass } = result; - - const options = { - isNot: this.isNot, - promise: this.promise, - }; - - return { - pass, - message: () => - !call - ? pass - ? 'Expected Replay to not have been sent, but a request was attempted' - : 'Expected Replay to have been sent, but a request was not attempted' - : `${this.utils.matcherHint('toHaveSentReplay', undefined, undefined, options)}\n\n${results - .map(({ key, expectedVal, actualVal }: Result) => - printDiffOrStringify(expectedVal, actualVal, `Expected (key: ${key})`, `Received (key: ${key})`), - ) - .join('\n')}`, - }; -}; - -/** - * Checks the last call to `fetch` and ensures a replay was uploaded by - * checking the `fetch()` request's body. - */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const toHaveLastSentReplay = function ( - _received: Mocked, - expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, -) { - const { calls } = (getClient()?.getTransport()?.send as MockTransport).mock; - const replayCalls = getReplayCalls(calls); - - const lastCall = replayCalls[calls.length - 1]?.[0]; - - const { results, call, pass } = checkCallForSentReplay.call(this, lastCall, expected); - - const options = { - isNot: this.isNot, - promise: this.promise, - }; - - return { - pass, - message: () => - !call - ? pass - ? 'Expected Replay to not have been sent, but a request was attempted' - : 'Expected Replay to have last been sent, but a request was not attempted' - : `${this.utils.matcherHint('toHaveSentReplay', undefined, undefined, options)}\n\n${results - .map(({ key, expectedVal, actualVal }: Result) => - printDiffOrStringify(expectedVal, actualVal, `Expected (key: ${key})`, `Received (key: ${key})`), - ) - .join('\n')}`, - }; -}; - -expect.extend({ - toHaveSameSession, - toHaveSentReplay, - toHaveLastSentReplay, -}); - -interface CustomMatchers { - toHaveSentReplay(expected?: SentReplayExpected): R; - toHaveLastSentReplay(expected?: SentReplayExpected): R; - toHaveSameSession(expected: undefined | Session): R; -} - -declare module 'vitest' { - type Assertion = CustomMatchers; - type AsymmetricMatchersContaining = CustomMatchers; -} diff --git a/packages/replay-internal/test/integration/errorSampleRate.test.ts b/packages/replay-internal/test/integration/errorSampleRate.test.ts index 15edb6c3431f..f79e393df7e3 100644 --- a/packages/replay-internal/test/integration/errorSampleRate.test.ts +++ b/packages/replay-internal/test/integration/errorSampleRate.test.ts @@ -4,6 +4,7 @@ import '../utils/mock-internal-setTimeout'; import { captureException, getClient } from '@sentry/core'; +import type { MockInstance } from 'vitest'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { BUFFER_CHECKOUT_TIME, @@ -34,15 +35,15 @@ async function waitForFlush() { } describe('Integration | errorSampleRate', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + describe('basic', () => { let replay: ReplayContainer; let mockRecord: RecordMock; let domHandler: DomHandler; - beforeAll(() => { - vi.useFakeTimers(); - }); - beforeEach(async () => { ({ mockRecord, domHandler, replay } = await resetSdkMock({ replayOptions: { @@ -163,14 +164,14 @@ describe('Integration | errorSampleRate', () => { const ADVANCED_TIME = 86400000; const optionsEvent = createOptionsEvent(replay); - expect(replay.session.started).toBe(BASE_TIMESTAMP); + expect(replay.session?.started).toBe(BASE_TIMESTAMP); // advance time to make sure replay duration is invalid vi.advanceTimersByTime(ADVANCED_TIME); // full snapshot should update session start time mockRecord.takeFullSnapshot(true); - expect(replay.session.started).toBe(BASE_TIMESTAMP + ADVANCED_TIME); + expect(replay.session?.started).toBe(BASE_TIMESTAMP + ADVANCED_TIME); expect(replay.recordingMode).toBe('buffer'); // advance so we can flush @@ -253,7 +254,7 @@ describe('Integration | errorSampleRate', () => { await vi.advanceTimersToNextTimerAsync(); - expect(replay).toHaveSentReplay({ + expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', @@ -339,7 +340,7 @@ describe('Integration | errorSampleRate', () => { await vi.advanceTimersToNextTimerAsync(); - expect(replay).toHaveSentReplay({ + expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', @@ -364,24 +365,6 @@ describe('Integration | errorSampleRate', () => { }, ]), }); - - vi.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - // Check that click will not get captured - domHandler({ - name: 'click', - event: new Event('click'), - }); - - await waitForFlush(); - - // This is still the last replay sent since we passed `continueRecording: - // false`. - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); }); // This tests a regression where we were calling flush indiscriminantly in `stop()` @@ -672,7 +655,7 @@ describe('Integration | errorSampleRate', () => { expect(replay.session?.id).toBe(oldSessionId); // buffered events - expect(replay).toHaveSentReplay({ + expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', @@ -709,7 +692,7 @@ describe('Integration | errorSampleRate', () => { vi.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); await vi.advanceTimersToNextTimerAsync(); - expect(replay).toHaveSentReplay({ + expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, optionsEvent, @@ -762,7 +745,7 @@ describe('Integration | errorSampleRate', () => { expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED); // Does not capture mouse click - expect(replay).toHaveSentReplay({ + expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ // Make sure the old performance event is thrown out @@ -846,7 +829,7 @@ describe('Integration | errorSampleRate', () => { await waitForFlush(); expect(replay.session?.id).toBe(sessionId); - expect(replay).toHaveSentReplay({ + expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, }); @@ -1047,7 +1030,7 @@ describe('Integration | errorSampleRate', () => { await vi.advanceTimersToNextTimerAsync(); // Buffered events before error - expect(replay).toHaveSentReplay({ + expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, diff --git a/packages/replay-internal/test/test.setup.ts b/packages/replay-internal/test/test.setup.ts new file mode 100644 index 000000000000..4d6f63d0447d --- /dev/null +++ b/packages/replay-internal/test/test.setup.ts @@ -0,0 +1,164 @@ +import type { ReplayRecordingData, Transport } from '@sentry/core'; +import { getClient } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import type { Mocked, MockedFunction } from 'vitest'; +import { expect, vi } from 'vitest'; +import type { ReplayContainer, Session } from '../src/types'; + +type MockTransport = MockedFunction; + +vi.spyOn(SentryCore, 'isBrowser').mockImplementation(() => true); + +type ReplayEventHeader = { type: 'replay_event' }; +type RecordingHeader = { type: 'replay_recording'; length: number }; +type RecordingPayloadHeader = Record; +export type SentReplayExpected = { + envelopeHeader?: SentryCore.BaseEnvelopeHeaders; + replayEventHeader?: ReplayEventHeader; + replayEventPayload?: SentryCore.ReplayEvent; + recordingHeader?: RecordingHeader; + recordingPayloadHeader?: RecordingPayloadHeader; + recordingData?: ReplayRecordingData; +}; + +expect.extend({ + toHaveSameSession(received: Mocked, expected: Session | undefined) { + return { + pass: this.equals(received.session?.id, expected?.id), + message: () => + this.utils.matcherHint('toHaveSameSession', undefined, undefined, { + isNot: this.isNot, + promise: this.promise, + }), + actual: received.session, + expected, + }; + }, + + toHaveLastSentReplay(_received, expected?: SentReplayExpected) { + const { calls } = (getClient()?.getTransport()?.send as MockTransport).mock; + const lastSentReplayEnvelope = getLastSentReplayEnvelope(calls); + + const actual = getActual(lastSentReplayEnvelope); + const hasAnyLastSentReplay = !!lastSentReplayEnvelope; + + const { isNot } = this; + + // We only want to check if _something_ was sent + if (!expected) { + return { + pass: hasAnyLastSentReplay, + message: () => + isNot + ? 'Expected Replay to not have been sent, but a request was attempted' + : 'Expected Replay to have last been sent, but a request was not attempted', + expected: undefined, + actual, + }; + } + + // Only include expected values in actual object + Object.keys(actual).forEach(key => { + if (!(key in expected)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete actual[key as keyof SentReplayExpected]; + } + }); + + const getMessage = (key?: string) => () => { + if (isNot && hasAnyLastSentReplay) { + return 'Expected Replay to not have been sent, but a request was attempted'; + } + + if (!isNot && !hasAnyLastSentReplay) { + return 'Expected Replay to have last been sent, but a request was not attempted'; + } + + return key + ? `Last sent replay did not match the expected values for "${key}".` + : 'Last sent replay did not match the expected values.'; + }; + + // eslint-disable-next-line guard-for-in + for (const k in expected) { + const expectedValue = expected[k as keyof SentReplayExpected]; + const actualValue = actual[k as keyof SentReplayExpected]; + + const pass = this.equals(actualValue, expectedValue); + + if (!pass) { + return { + pass, + message: getMessage(k), + expected: expectedValue, + actual: actualValue, + }; + } + } + + return { + pass: this.equals(actual, expected), + message: getMessage(), + expected, + actual, + }; + }, +}); + +function getLastSentReplayEnvelope(calls: MockTransport['mock']['calls']) { + for (let i = calls.length - 1; i >= 0; i--) { + const envelope = calls[i]![0] as SentryCore.ReplayEnvelope; + + if (!mayBeReplayEnvelope(envelope)) { + continue; + } + + const envelopeItems = envelope[1]; + const replayEventItem = envelopeItems[0]; + const replayEventItemHeaders = replayEventItem[0]; + + if (replayEventItemHeaders.type === 'replay_event') { + return envelope; + } + } + + return undefined; +} + +function mayBeReplayEnvelope(envelope: SentryCore.Envelope): envelope is SentryCore.ReplayEnvelope { + // Replay envelopes have 2 items + if (envelope.length !== 2) { + return false; + } + + const envelopeItems = envelope[1]; + + // Replay envelope items have 2 items + if (envelopeItems.length !== 2) { + return false; + } + + return true; +} + +function getActual(call: SentryCore.ReplayEnvelope | undefined): SentReplayExpected { + if (!call) { + return {}; + } + + const envelopeHeader = call[0]; + const envelopeItems = call[1] || [[], []]; + const [[replayEventHeader, replayEventPayload], [recordingHeader, recordingPayload] = []] = envelopeItems; + + // @ts-expect-error recordingPayload is always a string in our tests + const [recordingPayloadHeader, recordingData] = recordingPayload?.split('\n') || []; + + return { + envelopeHeader, + replayEventHeader, + replayEventPayload, + recordingHeader, + recordingPayloadHeader: recordingPayloadHeader && JSON.parse(recordingPayloadHeader), + recordingData, + } satisfies SentReplayExpected; +} diff --git a/packages/replay-internal/test/vitest-custom-matchers.d.ts b/packages/replay-internal/test/vitest-custom-matchers.d.ts new file mode 100644 index 000000000000..bf7e88be8fe9 --- /dev/null +++ b/packages/replay-internal/test/vitest-custom-matchers.d.ts @@ -0,0 +1,14 @@ +import 'vitest'; +import type { Session } from '../src/types'; +import type { SentReplayExpected } from './test.setup'; + +interface CustomMatchers { + toHaveLastSentReplay(expected?: SentReplayExpected): R; + toHaveSameSession(expected: undefined | Session): R; +} + +// This is so that TS & Vscode recognize the custom matchers +declare module 'vitest' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + export interface Assertion extends CustomMatchers {} +} diff --git a/packages/replay-internal/tsconfig.test.json b/packages/replay-internal/tsconfig.test.json index bb7130d948c0..444b17b93aa6 100644 --- a/packages/replay-internal/tsconfig.test.json +++ b/packages/replay-internal/tsconfig.test.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", - "include": ["test/**/*.ts", "vitest.config.ts", "test.setup.ts"], + "include": ["test/**/*.ts", "vitest.config.ts", "test/vitest-custom-matchers.d.ts"], "compilerOptions": { "types": ["node"], diff --git a/packages/replay-internal/vitest.config.ts b/packages/replay-internal/vitest.config.ts index 93cf567e3ace..3ee8de264fd2 100644 --- a/packages/replay-internal/vitest.config.ts +++ b/packages/replay-internal/vitest.config.ts @@ -5,6 +5,6 @@ export default defineConfig({ ...baseConfig, test: { ...baseConfig.test, - setupFiles: ['./test.setup.ts'], + setupFiles: ['./test/test.setup.ts'], }, });