Skip to content

Commit a09eeab

Browse files
authored
test(replay): Add Integration tests + setup for custom events (#7052)
Add a few Playwright integration test helper functions for Replay integration tests Add tests for some custom events we add to the rrweb recording (breadcrumbs and performance spans)
1 parent 42d4d4b commit a09eeab

File tree

7 files changed

+320
-4
lines changed

7 files changed

+320
-4
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/browser';
2+
import { Replay } from '@sentry/replay';
3+
4+
window.Sentry = Sentry;
5+
window.Replay = new Replay({
6+
flushMinDelay: 500,
7+
flushMaxDelay: 500,
8+
useCompression: false,
9+
});
10+
11+
Sentry.init({
12+
dsn: 'https://[email protected]/1337',
13+
sampleRate: 0,
14+
replaysSessionSampleRate: 1.0,
15+
replaysOnErrorSampleRate: 0.0,
16+
17+
integrations: [window.Replay],
18+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
document.getElementById('go-background').addEventListener('click', () => {
2+
Object.defineProperty(document, 'hidden', { value: true, writable: true });
3+
const ev = document.createEvent('Event');
4+
ev.initEvent('visibilitychange');
5+
document.dispatchEvent(ev);
6+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="go-background">New Tab</button>
8+
</body>
9+
</html>
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import {
5+
expectedClickBreadcrumb,
6+
expectedFCPPerformanceSpan,
7+
expectedFPPerformanceSpan,
8+
expectedLCPPerformanceSpan,
9+
expectedMemoryPerformanceSpan,
10+
expectedNavigationPerformanceSpan,
11+
getExpectedReplayEvent,
12+
} from '../../../utils/replayEventTemplates';
13+
import { getCustomRecordingEvents, getReplayEvent, waitForReplayRequest } from '../../../utils/replayHelpers';
14+
15+
sentryTest(
16+
'replay recording should contain default performance spans',
17+
async ({ getLocalTestPath, page, browserName }) => {
18+
// Replay bundles are es6 only and most performance entries are only available in chromium
19+
if ((process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_es5')) || browserName !== 'chromium') {
20+
sentryTest.skip();
21+
}
22+
23+
const reqPromise0 = waitForReplayRequest(page, 0);
24+
const reqPromise1 = waitForReplayRequest(page, 1);
25+
26+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
27+
return route.fulfill({
28+
status: 200,
29+
contentType: 'application/json',
30+
body: JSON.stringify({ id: 'test-id' }),
31+
});
32+
});
33+
34+
const url = await getLocalTestPath({ testDir: __dirname });
35+
36+
await page.goto(url);
37+
const replayEvent0 = getReplayEvent(await reqPromise0);
38+
const { performanceSpans: performanceSpans0 } = getCustomRecordingEvents(await reqPromise0);
39+
40+
expect(replayEvent0).toEqual(getExpectedReplayEvent({ segment_id: 0 }));
41+
42+
await page.click('button');
43+
44+
const replayEvent1 = getReplayEvent(await reqPromise1);
45+
const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(await reqPromise1);
46+
47+
expect(replayEvent1).toEqual(
48+
getExpectedReplayEvent({ segment_id: 1, urls: [], replay_start_timestamp: undefined }),
49+
);
50+
51+
// We can't guarantee the order of the performance spans, or in which of the two segments they are sent
52+
// So to avoid flakes, we collect them all and check that they are all there
53+
const collectedPerformanceSpans = [...performanceSpans0, ...performanceSpans1];
54+
55+
expect(collectedPerformanceSpans).toEqual(
56+
expect.arrayContaining([
57+
expectedNavigationPerformanceSpan,
58+
expectedLCPPerformanceSpan,
59+
expectedFPPerformanceSpan,
60+
expectedFCPPerformanceSpan,
61+
expectedMemoryPerformanceSpan, // two memory spans - once per flush
62+
expectedMemoryPerformanceSpan,
63+
]),
64+
);
65+
},
66+
);
67+
68+
sentryTest(
69+
'replay recording should contain a click breadcrumb when a button is clicked',
70+
async ({ getLocalTestPath, page }) => {
71+
// Replay bundles are es6 only
72+
if (process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_es5')) {
73+
sentryTest.skip();
74+
}
75+
76+
const reqPromise0 = waitForReplayRequest(page, 0);
77+
const reqPromise1 = waitForReplayRequest(page, 1);
78+
79+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
80+
return route.fulfill({
81+
status: 200,
82+
contentType: 'application/json',
83+
body: JSON.stringify({ id: 'test-id' }),
84+
});
85+
});
86+
87+
const url = await getLocalTestPath({ testDir: __dirname });
88+
89+
await page.goto(url);
90+
const replayEvent0 = getReplayEvent(await reqPromise0);
91+
const { breadcrumbs: breadcrumbs0 } = getCustomRecordingEvents(await reqPromise0);
92+
93+
expect(replayEvent0).toEqual(getExpectedReplayEvent({ segment_id: 0 }));
94+
expect(breadcrumbs0.length).toEqual(0);
95+
96+
await page.click('button');
97+
98+
const replayEvent1 = getReplayEvent(await reqPromise1);
99+
const { breadcrumbs: breadcrumbs1 } = getCustomRecordingEvents(await reqPromise1);
100+
101+
expect(replayEvent1).toEqual(
102+
getExpectedReplayEvent({ segment_id: 1, urls: [], replay_start_timestamp: undefined }),
103+
);
104+
105+
expect(breadcrumbs1).toEqual([expectedClickBreadcrumb]);
106+
},
107+
);

packages/integration-tests/utils/helpers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ export const envelopeParser = (request: Request | null): unknown[] => {
1717
});
1818
};
1919

20-
export const envelopeRequestParser = (request: Request | null): Event => {
21-
return envelopeParser(request)[2] as Event;
20+
export const envelopeRequestParser = (request: Request | null, envelopeIndex = 2): Event => {
21+
return envelopeParser(request)[envelopeIndex] as Event;
2222
};
2323

2424
export const envelopeHeaderRequestParser = (request: Request | null): EventEnvelopeHeaders => {
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/* eslint-disable @typescript-eslint/explicit-function-return-type */
2+
import { expect } from '@playwright/test';
3+
import { SDK_VERSION } from '@sentry/browser';
4+
import type { ReplayEvent } from '@sentry/types';
5+
6+
const DEFAULT_REPLAY_EVENT = {
7+
type: 'replay_event',
8+
timestamp: expect.any(Number),
9+
error_ids: [],
10+
trace_ids: [],
11+
urls: [expect.stringContaining('/dist/index.html')],
12+
replay_id: expect.stringMatching(/\w{32}/),
13+
replay_start_timestamp: expect.any(Number),
14+
segment_id: 0,
15+
replay_type: 'session',
16+
event_id: expect.stringMatching(/\w{32}/),
17+
environment: 'production',
18+
sdk: {
19+
integrations: [
20+
'InboundFilters',
21+
'FunctionToString',
22+
'TryCatch',
23+
'Breadcrumbs',
24+
'GlobalHandlers',
25+
'LinkedErrors',
26+
'Dedupe',
27+
'HttpContext',
28+
'Replay',
29+
],
30+
version: SDK_VERSION,
31+
name: 'sentry.javascript.browser',
32+
},
33+
sdkProcessingMetadata: {},
34+
request: {
35+
url: expect.stringContaining('/dist/index.html'),
36+
headers: {
37+
'User-Agent': expect.stringContaining(''),
38+
},
39+
},
40+
platform: 'javascript',
41+
contexts: { replay: { session_sample_rate: 1, error_sample_rate: 0 } },
42+
};
43+
44+
/**
45+
* Creates a ReplayEvent object with the default values merged with the customExpectedReplayEvent.
46+
* This is useful for testing multi-segment replays to not repeat most of the properties that don't change
47+
* throughout the replay segments.
48+
*
49+
* Note: The benfit of this approach over expect.objectContaining is that,
50+
* we'll catch if properties we expect to stay the same actually change.
51+
*
52+
* @param customExpectedReplayEvent overwrite the default values with custom values (e.g. segment_id)
53+
*/
54+
export function getExpectedReplayEvent(customExpectedReplayEvent: Partial<ReplayEvent> & Record<string, unknown> = {}) {
55+
return {
56+
...DEFAULT_REPLAY_EVENT,
57+
...customExpectedReplayEvent,
58+
};
59+
}
60+
61+
/* This is how we expect different kinds of navigation performance span to look: */
62+
63+
export const expectedNavigationPerformanceSpan = {
64+
op: 'navigation.navigate',
65+
description: '',
66+
startTimestamp: expect.any(Number),
67+
endTimestamp: expect.any(Number),
68+
data: {
69+
duration: expect.any(Number),
70+
size: expect.any(Number),
71+
},
72+
};
73+
74+
export const expectedMemoryPerformanceSpan = {
75+
op: 'memory',
76+
description: 'memory',
77+
startTimestamp: expect.any(Number),
78+
endTimestamp: expect.any(Number),
79+
data: {
80+
memory: {
81+
jsHeapSizeLimit: expect.any(Number),
82+
totalJSHeapSize: expect.any(Number),
83+
usedJSHeapSize: expect.any(Number),
84+
},
85+
},
86+
};
87+
88+
export const expectedLCPPerformanceSpan = {
89+
op: 'largest-contentful-paint',
90+
description: 'largest-contentful-paint',
91+
startTimestamp: expect.any(Number),
92+
endTimestamp: expect.any(Number),
93+
data: {
94+
duration: expect.any(Number),
95+
nodeId: expect.any(Number),
96+
size: expect.any(Number),
97+
},
98+
};
99+
100+
export const expectedFCPPerformanceSpan = {
101+
op: 'paint',
102+
description: 'first-contentful-paint',
103+
startTimestamp: expect.any(Number),
104+
endTimestamp: expect.any(Number),
105+
};
106+
107+
export const expectedFPPerformanceSpan = {
108+
op: 'paint',
109+
description: 'first-paint',
110+
startTimestamp: expect.any(Number),
111+
endTimestamp: expect.any(Number),
112+
};
113+
114+
/* Breadcrumbs */
115+
116+
export const expectedClickBreadcrumb = {
117+
timestamp: expect.any(Number),
118+
type: 'default',
119+
category: 'ui.click',
120+
message: expect.any(String),
121+
data: {
122+
nodeId: expect.any(Number),
123+
},
124+
};

packages/integration-tests/utils/replayHelpers.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1-
import type { ReplayContainer } from '@sentry/replay/build/npm/types/types';
2-
import type { Event, ReplayEvent } from '@sentry/types';
1+
import type { RecordingEvent, ReplayContainer } from '@sentry/replay/build/npm/types/types';
2+
import type { Breadcrumb, Event, ReplayEvent } from '@sentry/types';
33
import type { Page, Request } from 'playwright';
44

55
import { envelopeRequestParser } from './helpers';
66

7+
type CustomRecordingEvent = { tag: string; payload: Record<string, unknown> };
8+
type PerformanceSpan = {
9+
op: string;
10+
description: string;
11+
startTimestamp: number;
12+
endTimestamp: number;
13+
data: Record<string, number>;
14+
};
15+
716
/**
817
* Waits for a replay request to be sent by the page and returns it.
918
*
@@ -56,3 +65,46 @@ export async function getReplaySnapshot(page: Page): Promise<ReplayContainer> {
5665
}
5766

5867
export const REPLAY_DEFAULT_FLUSH_MAX_DELAY = 5_000;
68+
69+
export function getReplayEvent(replayRequest: Request): ReplayEvent {
70+
const event = envelopeRequestParser(replayRequest);
71+
if (!isReplayEvent(event)) {
72+
throw new Error('Request is not a replay event');
73+
}
74+
return event;
75+
}
76+
77+
/**
78+
* Takes an uncompressed replay request and returns the custom recording events,
79+
* i.e. the events we emit as type 5 rrweb events
80+
*
81+
* @param replayRequest
82+
* @returns an object containing the replay breadcrumbs and performance spans
83+
*/
84+
export function getCustomRecordingEvents(replayRequest: Request): {
85+
breadcrumbs: Breadcrumb[];
86+
performanceSpans: PerformanceSpan[];
87+
} {
88+
const recordingEvents = envelopeRequestParser(replayRequest, 5) as RecordingEvent[];
89+
90+
const breadcrumbs = getReplayBreadcrumbs(recordingEvents);
91+
const performanceSpans = getReplayPerformanceSpans(recordingEvents);
92+
return { breadcrumbs, performanceSpans };
93+
}
94+
95+
function getAllCustomRrwebRecordingEvents(recordingEvents: RecordingEvent[]): CustomRecordingEvent[] {
96+
return recordingEvents.filter(event => event.type === 5).map(event => event.data as CustomRecordingEvent);
97+
}
98+
99+
function getReplayBreadcrumbs(recordingEvents: RecordingEvent[], category?: string): Breadcrumb[] {
100+
return getAllCustomRrwebRecordingEvents(recordingEvents)
101+
.filter(data => data.tag === 'breadcrumb')
102+
.map(data => data.payload)
103+
.filter(payload => !category || payload.category === category);
104+
}
105+
106+
function getReplayPerformanceSpans(recordingEvents: RecordingEvent[]): PerformanceSpan[] {
107+
return getAllCustomRrwebRecordingEvents(recordingEvents)
108+
.filter(data => data.tag === 'performanceSpan')
109+
.map(data => data.payload) as PerformanceSpan[];
110+
}

0 commit comments

Comments
 (0)