diff --git a/packages/integration-tests/suites/replay/customEvents/init.js b/packages/integration-tests/suites/replay/customEvents/init.js new file mode 100644 index 000000000000..f02e43c7235c --- /dev/null +++ b/packages/integration-tests/suites/replay/customEvents/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; +import { Replay } from '@sentry/replay'; + +window.Sentry = Sentry; +window.Replay = new Replay({ + flushMinDelay: 500, + flushMaxDelay: 500, + useCompression: false, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + integrations: [window.Replay], +}); diff --git a/packages/integration-tests/suites/replay/customEvents/subject.js b/packages/integration-tests/suites/replay/customEvents/subject.js new file mode 100644 index 000000000000..7aa69584f070 --- /dev/null +++ b/packages/integration-tests/suites/replay/customEvents/subject.js @@ -0,0 +1,6 @@ +document.getElementById('go-background').addEventListener('click', () => { + Object.defineProperty(document, 'hidden', { value: true, writable: true }); + const ev = document.createEvent('Event'); + ev.initEvent('visibilitychange'); + document.dispatchEvent(ev); +}); diff --git a/packages/integration-tests/suites/replay/customEvents/template.html b/packages/integration-tests/suites/replay/customEvents/template.html new file mode 100644 index 000000000000..31cfc73ec3c3 --- /dev/null +++ b/packages/integration-tests/suites/replay/customEvents/template.html @@ -0,0 +1,9 @@ + + +
+ + + + + + diff --git a/packages/integration-tests/suites/replay/customEvents/test.ts b/packages/integration-tests/suites/replay/customEvents/test.ts new file mode 100644 index 000000000000..ac900cfe0961 --- /dev/null +++ b/packages/integration-tests/suites/replay/customEvents/test.ts @@ -0,0 +1,107 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../utils/fixtures'; +import { + expectedClickBreadcrumb, + expectedFCPPerformanceSpan, + expectedFPPerformanceSpan, + expectedLCPPerformanceSpan, + expectedMemoryPerformanceSpan, + expectedNavigationPerformanceSpan, + getExpectedReplayEvent, +} from '../../../utils/replayEventTemplates'; +import { getCustomRecordingEvents, getReplayEvent, waitForReplayRequest } from '../../../utils/replayHelpers'; + +sentryTest( + 'replay recording should contain default performance spans', + async ({ getLocalTestPath, page, browserName }) => { + // Replay bundles are es6 only and most performance entries are only available in chromium + if ((process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_es5')) || browserName !== 'chromium') { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, 1); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + const replayEvent0 = getReplayEvent(await reqPromise0); + const { performanceSpans: performanceSpans0 } = getCustomRecordingEvents(await reqPromise0); + + expect(replayEvent0).toEqual(getExpectedReplayEvent({ segment_id: 0 })); + + await page.click('button'); + + const replayEvent1 = getReplayEvent(await reqPromise1); + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(await reqPromise1); + + expect(replayEvent1).toEqual( + getExpectedReplayEvent({ segment_id: 1, urls: [], replay_start_timestamp: undefined }), + ); + + // We can't guarantee the order of the performance spans, or in which of the two segments they are sent + // So to avoid flakes, we collect them all and check that they are all there + const collectedPerformanceSpans = [...performanceSpans0, ...performanceSpans1]; + + expect(collectedPerformanceSpans).toEqual( + expect.arrayContaining([ + expectedNavigationPerformanceSpan, + expectedLCPPerformanceSpan, + expectedFPPerformanceSpan, + expectedFCPPerformanceSpan, + expectedMemoryPerformanceSpan, // two memory spans - once per flush + expectedMemoryPerformanceSpan, + ]), + ); + }, +); + +sentryTest( + 'replay recording should contain a click breadcrumb when a button is clicked', + async ({ getLocalTestPath, page }) => { + // Replay bundles are es6 only + if (process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_es5')) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, 1); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + const replayEvent0 = getReplayEvent(await reqPromise0); + const { breadcrumbs: breadcrumbs0 } = getCustomRecordingEvents(await reqPromise0); + + expect(replayEvent0).toEqual(getExpectedReplayEvent({ segment_id: 0 })); + expect(breadcrumbs0.length).toEqual(0); + + await page.click('button'); + + const replayEvent1 = getReplayEvent(await reqPromise1); + const { breadcrumbs: breadcrumbs1 } = getCustomRecordingEvents(await reqPromise1); + + expect(replayEvent1).toEqual( + getExpectedReplayEvent({ segment_id: 1, urls: [], replay_start_timestamp: undefined }), + ); + + expect(breadcrumbs1).toEqual([expectedClickBreadcrumb]); + }, +); diff --git a/packages/integration-tests/utils/helpers.ts b/packages/integration-tests/utils/helpers.ts index 443e3e0e57af..5fb2aafff3e1 100644 --- a/packages/integration-tests/utils/helpers.ts +++ b/packages/integration-tests/utils/helpers.ts @@ -17,8 +17,8 @@ export const envelopeParser = (request: Request | null): unknown[] => { }); }; -export const envelopeRequestParser = (request: Request | null): Event => { - return envelopeParser(request)[2] as Event; +export const envelopeRequestParser = (request: Request | null, envelopeIndex = 2): Event => { + return envelopeParser(request)[envelopeIndex] as Event; }; export const envelopeHeaderRequestParser = (request: Request | null): EventEnvelopeHeaders => { diff --git a/packages/integration-tests/utils/replayEventTemplates.ts b/packages/integration-tests/utils/replayEventTemplates.ts new file mode 100644 index 000000000000..6a86ef8f45c7 --- /dev/null +++ b/packages/integration-tests/utils/replayEventTemplates.ts @@ -0,0 +1,124 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { expect } from '@playwright/test'; +import { SDK_VERSION } from '@sentry/browser'; +import type { ReplayEvent } from '@sentry/types'; + +const DEFAULT_REPLAY_EVENT = { + type: 'replay_event', + timestamp: expect.any(Number), + error_ids: [], + trace_ids: [], + urls: [expect.stringContaining('/dist/index.html')], + replay_id: expect.stringMatching(/\w{32}/), + replay_start_timestamp: expect.any(Number), + segment_id: 0, + replay_type: 'session', + event_id: expect.stringMatching(/\w{32}/), + environment: 'production', + sdk: { + integrations: [ + 'InboundFilters', + 'FunctionToString', + 'TryCatch', + 'Breadcrumbs', + 'GlobalHandlers', + 'LinkedErrors', + 'Dedupe', + 'HttpContext', + 'Replay', + ], + version: SDK_VERSION, + name: 'sentry.javascript.browser', + }, + sdkProcessingMetadata: {}, + request: { + url: expect.stringContaining('/dist/index.html'), + headers: { + 'User-Agent': expect.stringContaining(''), + }, + }, + platform: 'javascript', + contexts: { replay: { session_sample_rate: 1, error_sample_rate: 0 } }, +}; + +/** + * Creates a ReplayEvent object with the default values merged with the customExpectedReplayEvent. + * This is useful for testing multi-segment replays to not repeat most of the properties that don't change + * throughout the replay segments. + * + * Note: The benfit of this approach over expect.objectContaining is that, + * we'll catch if properties we expect to stay the same actually change. + * + * @param customExpectedReplayEvent overwrite the default values with custom values (e.g. segment_id) + */ +export function getExpectedReplayEvent(customExpectedReplayEvent: Partial