diff --git a/samples/react-native/e2e/captureMessage.test.android.ts b/samples/react-native/e2e/captureMessage.test.android.ts index ad6d298a02..d08473ac2b 100644 --- a/samples/react-native/e2e/captureMessage.test.android.ts +++ b/samples/react-native/e2e/captureMessage.test.android.ts @@ -1,12 +1,12 @@ import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; -import { Envelope } from '@sentry/core'; +import { Envelope, EventItem } from '@sentry/core'; import { device } from 'detox'; import { createSentryServer, containingEvent, } from './utils/mockedSentryServer'; -import { HEADER, ITEMS } from './utils/consts'; import { tap } from './utils/tap'; +import { getItemOfTypeFrom } from './utils/event'; describe('Capture message', () => { let sentryServer = createSentryServer(); @@ -27,9 +27,7 @@ describe('Capture message', () => { }); it('envelope contains message event', async () => { - const item = (envelope[ITEMS] as [{ type?: string }, unknown][]).find( - i => i[HEADER].type === 'event', - ); + const item = getItemOfTypeFrom(envelope, 'event'); expect(item).toEqual([ { @@ -46,4 +44,103 @@ describe('Capture message', () => { }), ]); }); + + it('contains device context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + device: expect.objectContaining({ + battery_level: expect.any(Number), + battery_temperature: expect.any(Number), + boot_time: expect.any(String), + brand: expect.any(String), + charging: expect.any(Boolean), + connection_type: expect.any(String), + family: expect.any(String), + free_memory: expect.any(Number), + free_storage: expect.any(Number), + id: expect.any(String), + language: expect.any(String), + locale: expect.any(String), + low_memory: expect.any(Boolean), + manufacturer: expect.any(String), + memory_size: expect.any(Number), + model: expect.any(String), + model_id: expect.any(String), + online: expect.any(Boolean), + orientation: expect.any(String), + processor_count: expect.any(Number), + processor_frequency: expect.any(Number), + screen_density: expect.any(Number), + screen_dpi: expect.any(Number), + screen_height_pixels: expect.any(Number), + screen_width_pixels: expect.any(Number), + simulator: expect.any(Boolean), + storage_size: expect.any(Number), + timezone: expect.any(String), + }), + }), + }), + ); + }); + + it('contains app context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + app: expect.objectContaining({ + app_build: expect.any(String), + app_identifier: expect.any(String), + app_name: expect.any(String), + app_start_time: expect.any(String), + app_version: expect.any(String), + in_foreground: expect.any(Boolean), + view_names: ['ErrorsScreen'], + }), + }), + }), + ); + }); + + it('contains os context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + os: { + build: expect.any(String), + kernel_version: expect.any(String), + name: 'Android', + rooted: expect.any(Boolean), + version: expect.any(String), + }, + }), + }), + ); + }); + + it('contains react native context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + react_native_context: { + expo: false, + fabric: expect.any(Boolean), + hermes_debug_info: expect.any(Boolean), + hermes_version: expect.any(String), + js_engine: 'hermes', + react_native_version: expect.any(String), + turbo_module: expect.any(Boolean), + }, + }), + }), + ); + }); }); diff --git a/samples/react-native/e2e/captureMessage.test.ios.ts b/samples/react-native/e2e/captureMessage.test.ios.ts index a379718a68..9e3a804881 100644 --- a/samples/react-native/e2e/captureMessage.test.ios.ts +++ b/samples/react-native/e2e/captureMessage.test.ios.ts @@ -1,12 +1,12 @@ import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; -import { Envelope } from '@sentry/core'; +import { Envelope, EventItem } from '@sentry/core'; import { device } from 'detox'; import { createSentryServer, containingEvent, } from './utils/mockedSentryServer'; -import { HEADER, ITEMS } from './utils/consts'; import { tap } from './utils/tap'; +import { getItemOfTypeFrom } from './utils/event'; describe('Capture message', () => { let sentryServer = createSentryServer(); @@ -27,9 +27,7 @@ describe('Capture message', () => { }); it('envelope contains message event', async () => { - const item = (envelope[ITEMS] as [{ type?: string }, unknown][]).find( - i => i[HEADER].type === 'event', - ); + const item = getItemOfTypeFrom(envelope, 'event'); expect(item).toEqual([ { @@ -43,4 +41,86 @@ describe('Capture message', () => { }), ]); }); + + it('contains device context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + device: expect.objectContaining({ + arch: expect.any(String), + family: expect.any(String), + free_memory: expect.any(Number), + locale: expect.any(String), + memory_size: expect.any(Number), + model: expect.any(String), + model_id: expect.any(String), + processor_count: expect.any(Number), + simulator: expect.any(Boolean), + thermal_state: expect.any(String), + usable_memory: expect.any(Number), + }), + }), + }), + ); + }); + + it('contains app context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + app: expect.objectContaining({ + app_build: expect.any(String), + app_identifier: expect.any(String), + app_name: expect.any(String), + app_start_time: expect.any(String), + app_version: expect.any(String), + in_foreground: expect.any(Boolean), + // view_names: ['ErrorsScreen-jn5qquvH9Nz'], // TODO: fix this generated hash should not be part of the name + }), + }), + }), + ); + }); + + it('contains os context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + os: { + build: expect.any(String), + kernel_version: expect.any(String), + name: 'iOS', + rooted: expect.any(Boolean), + version: expect.any(String), + }, + }), + }), + ); + }); + + it('contains react native context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + react_native_context: { + expo: false, + fabric: expect.any(Boolean), + hermes_debug_info: expect.any(Boolean), + hermes_version: expect.any(String), + js_engine: 'hermes', + react_native_version: expect.any(String), + turbo_module: expect.any(Boolean), + }, + }), + }), + ); + }); }); diff --git a/samples/react-native/e2e/captureTransaction.test.ts b/samples/react-native/e2e/captureTransaction.test.ts new file mode 100644 index 0000000000..95d109c4e4 --- /dev/null +++ b/samples/react-native/e2e/captureTransaction.test.ts @@ -0,0 +1,205 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { EventItem } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingTransactionWithName, +} from './utils/mockedSentryServer'; +import { tap } from './utils/tap'; +import { sleep } from './utils/sleep'; +import { getItemOfTypeFrom } from './utils/event'; + +describe('Capture transaction', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + const getErrorsEnvelope = () => + sentryServer.getEnvelope(containingTransactionWithName('Errors')); + + const getTrackerEnvelope = () => + sentryServer.getEnvelope(containingTransactionWithName('Tracker')); + + beforeAll(async () => { + await device.launchApp(); + + const waitForPerformanceTransaction = sentryServer.waitForEnvelope( + containingTransactionWithName('Tracker'), // The last created and sent transaction + ); + + await sleep(500); + await tap('Performance'); // Bottom tab + await sleep(200); + await tap('Auto Tracing Example'); // Screen with Full Display + + await waitForPerformanceTransaction; + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains transaction context', async () => { + const item = getItemOfTypeFrom( + getErrorsEnvelope(), + 'transaction', + ); + + expect(item).toEqual([ + expect.objectContaining({ + length: expect.any(Number), + type: 'transaction', + }), + expect.objectContaining({ + platform: 'javascript', + transaction: 'ErrorsScreen', + contexts: expect.objectContaining({ + trace: { + data: { + 'route.has_been_seen': false, + 'route.key': expect.stringMatching(/^ErrorsScreen/), + 'route.name': 'ErrorsScreen', + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'ui.load', + 'sentry.origin': 'auto.app.start', + 'sentry.sample_rate': 1, + 'sentry.source': 'component', + }, + op: 'ui.load', + origin: 'auto.app.start', + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }), + }), + ]); + }); + + it('contains app start measurements', async () => { + const item = getItemOfTypeFrom( + getErrorsEnvelope(), + 'transaction', + ); + + expect( + item?.[1].measurements?.app_start_warm || + item?.[1].measurements?.app_start_cold, + ).toBeDefined(); + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + // Expect warm or cold app start measurements + ...(item?.[1].measurements?.app_start_warm && { + app_start_warm: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + ...(item?.[1].measurements?.app_start_cold && { + app_start_cold: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + }), + ); + }); + + it('contains time to initial display measurements', async () => { + const item = getItemOfTypeFrom( + await getErrorsEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + }); + + it('contains JS stall measurements', async () => { + const item = getItemOfTypeFrom( + await getErrorsEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + stall_count: { + unit: 'none', + value: expect.any(Number), + }, + stall_longest_time: { + unit: 'millisecond', + value: expect.any(Number), + }, + stall_total_time: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + }); + + it('contains time to display measurements', async () => { + const item = getItemOfTypeFrom( + getTrackerEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + time_to_full_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + }); + + it('contains at least one xhr breadcrumb of request to the tracker endpoint', async () => { + const item = getItemOfTypeFrom( + getTrackerEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ + category: 'xhr', + data: { + end_timestamp: expect.any(Number), + method: 'GET', + response_body_size: expect.any(Number), + start_timestamp: expect.any(Number), + status_code: expect.any(Number), + url: expect.stringContaining('api.covid19api.com/summary'), + }, + level: 'info', + timestamp: expect.any(Number), + type: 'http', + }), + ]), + }), + ); + }); +}); diff --git a/samples/react-native/e2e/utils/event.ts b/samples/react-native/e2e/utils/event.ts new file mode 100644 index 0000000000..df631feb4e --- /dev/null +++ b/samples/react-native/e2e/utils/event.ts @@ -0,0 +1,11 @@ +import { Envelope, EnvelopeItem } from '@sentry/core'; +import { HEADER, ITEMS } from './consts'; + +export function getItemOfTypeFrom( + envelope: Envelope, + type: string, +): T | undefined { + return (envelope[ITEMS] as [{ type?: string }, unknown][]).find( + i => i[HEADER].type === type, + ) as T | undefined; +} diff --git a/samples/react-native/e2e/utils/mockedSentryServer.ts b/samples/react-native/e2e/utils/mockedSentryServer.ts index 40667b0f9d..7f19a0d24b 100644 --- a/samples/react-native/e2e/utils/mockedSentryServer.ts +++ b/samples/react-native/e2e/utils/mockedSentryServer.ts @@ -1,7 +1,8 @@ import { IncomingMessage, ServerResponse, createServer } from 'node:http'; import { createGunzip } from 'node:zlib'; -import { Envelope } from '@sentry/core'; +import { Envelope, EnvelopeItem } from '@sentry/core'; import { parseEnvelope } from './parseEnvelope'; +import { Event } from '@sentry/core'; type RecordedRequest = { path: string | undefined; @@ -16,6 +17,7 @@ export function createSentryServer({ port = 8961 } = {}): { ) => Promise; close: () => Promise; start: () => void; + getEnvelope: (predicate: (envelope: Envelope) => boolean) => Envelope; } { let onNextRequestCallback: (request: RecordedRequest) => void = () => {}; const requests: RecordedRequest[] = []; @@ -74,11 +76,47 @@ export function createSentryServer({ port = 8961 } = {}): { server.close(() => resolve()); }); }, + getEnvelope: (predicate: (envelope: Envelope) => boolean) => { + const envelope = requests.find( + request => request.envelope && predicate(request.envelope), + )?.envelope; + + if (!envelope) { + throw new Error('Envelope not found'); + } + + return envelope; + }, }; } export function containingEvent(envelope: Envelope) { - return envelope[1].some( - item => (item[0] as { type?: string }).type === 'event', - ); + return envelope[1].some(item => itemHeaderIsType(item[0], 'event')); +} + +export function containingTransactionWithName(name: string) { + return (envelope: Envelope) => + envelope[1].some( + item => + itemHeaderIsType(item[0], 'transaction') && + itemBodyIsEvent(item[1]) && + item[1].transaction && + item[1].transaction.includes(name), + ); +} + +export function itemBodyIsEvent(itemBody: EnvelopeItem[1]): itemBody is Event { + return typeof itemBody === 'object' && 'event_id' in itemBody; +} + +export function itemHeaderIsType(itemHeader: EnvelopeItem[0], type: string) { + if (typeof itemHeader !== 'object' || !('type' in itemHeader)) { + return false; + } + + if (itemHeader.type !== type) { + return false; + } + + return true; } diff --git a/samples/react-native/e2e/utils/sleep.ts b/samples/react-native/e2e/utils/sleep.ts new file mode 100644 index 0000000000..a3b7734163 --- /dev/null +++ b/samples/react-native/e2e/utils/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +}