diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json b/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json index a93ca8f463fb..f028794e9830 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json @@ -46,6 +46,7 @@ ] }, "devDependencies": { + "@sentry-internal/event-proxy-server": "link:../../../event-proxy-server", "@playwright/test": "1.26.1", "serve": "14.0.1" }, diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.mjs similarity index 80% rename from dev-packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts rename to dev-packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.mjs index 5f93f826ebf0..4ac124380c0d 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.mjs @@ -1,10 +1,12 @@ -import type { PlaywrightTestConfig } from '@playwright/test'; import { devices } from '@playwright/test'; +const serverPort = 3030; +const eventProxyPort = 3031; + /** * See https://playwright.dev/docs/test-configuration. */ -const config: PlaywrightTestConfig = { +const config = { testDir: './tests', /* Maximum time one test can run for. */ timeout: 150_000, @@ -32,6 +34,9 @@ const config: PlaywrightTestConfig = { /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', + + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${serverPort}`, }, /* Configure projects for major browsers */ @@ -58,13 +63,19 @@ const config: PlaywrightTestConfig = { ], /* Run your local dev server before starting the tests */ - webServer: { - command: 'pnpm start', - port: 3030, - env: { - PORT: '3030', + webServer: [ + { + command: 'node start-event-proxy.mjs', + port: eventProxyPort, }, - }, + { + command: 'pnpm start', + port: serverPort, + env: { + PORT: '3030', + }, + }, + ], }; export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx index 579b6f8e1cfd..dc27c8fb9ac1 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx @@ -32,6 +32,8 @@ Sentry.init({ tracesSampleRate: 1.0, release: 'e2e-test', + tunnel: 'http://localhost:3031', + // Always capture replays, so we can test this properly replaysSessionSampleRate: 1.0, replaysOnErrorSampleRate: 0.0, @@ -39,27 +41,6 @@ Sentry.init({ debug: true, }); -Object.defineProperty(window, 'sentryReplayId', { - get() { - return replay['_replay'].session.id; - }, -}); - -Sentry.addEventProcessor(event => { - if ( - event.type === 'transaction' && - (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation') - ) { - const eventId = event.event_id; - if (eventId) { - window.recordedTransactions = window.recordedTransactions || []; - window.recordedTransactions.push(eventId); - } - } - - return event; -}); - const sentryCreateHashRouter = Sentry.wrapCreateBrowserRouter(createHashRouter); const router = sentryCreateHashRouter([ diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx index 7789a2773224..d6b71a1d1279 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/react'; // biome-ignore lint/nursery/noUnusedImports: Need React import for JSX import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -11,8 +10,7 @@ const Index = () => { value="Capture Exception" id="exception-button" onClick={() => { - const eventId = Sentry.captureException(new Error('I am an error!')); - window.capturedExceptionId = eventId; + throw new Error('I am an error!'); }} /> diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-create-hash-router/start-event-proxy.mjs new file mode 100644 index 000000000000..0a802ff33b16 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-create-hash-router', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts deleted file mode 100644 index 2e74b6d481ba..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { ReplayRecordingData } from './fixtures/ReplayRecordingData'; - -const EVENT_POLLING_TIMEOUT = 90_000; - -const authToken = process.env.E2E_TEST_AUTH_TOKEN; -const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; -const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; - -test('Sends an exception to Sentry', async ({ page }) => { - await page.goto('/'); - - const exceptionButton = page.locator('id=exception-button'); - await exceptionButton.click(); - - const exceptionIdHandle = await page.waitForFunction(() => window.capturedExceptionId); - const exceptionEventId = await exceptionIdHandle.jsonValue(); - - console.log(`Polling for error eventId: ${exceptionEventId}`); - - await expect - .poll( - async () => { - const response = await fetch( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); - return response.status; - }, - { - timeout: EVENT_POLLING_TIMEOUT, - }, - ) - .toBe(200); -}); - -test('Sends a pageload transaction to Sentry', async ({ page }) => { - await page.goto('/'); - - const recordedTransactionsHandle = await page.waitForFunction(() => { - if (window.recordedTransactions && window.recordedTransactions?.length >= 1) { - return window.recordedTransactions; - } else { - return undefined; - } - }); - const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); - - if (recordedTransactionEventIds === undefined) { - throw new Error("Application didn't record any transaction event IDs."); - } - - let hadPageLoadTransaction = false; - - console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); - - await Promise.all( - recordedTransactionEventIds.map(async transactionEventId => { - await expect - .poll( - async () => { - const response = await fetch( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); - - if (response.ok) { - const data = await response.json(); - - if (data.contexts.trace.op === 'pageload') { - expect(data.title).toBe('/'); - hadPageLoadTransaction = true; - } - } - - return response.status; - }, - { - timeout: EVENT_POLLING_TIMEOUT, - }, - ) - .toBe(200); - }), - ); - - expect(hadPageLoadTransaction).toBe(true); -}); - -test('Sends a navigation transaction to Sentry', async ({ page }) => { - await page.goto('/'); - - // Give pageload transaction time to finish - page.waitForTimeout(4000); - - const linkElement = page.locator('id=navigation'); - await linkElement.click(); - - const recordedTransactionsHandle = await page.waitForFunction(() => { - if (window.recordedTransactions && window.recordedTransactions?.length >= 2) { - return window.recordedTransactions; - } else { - return undefined; - } - }); - const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); - - if (recordedTransactionEventIds === undefined) { - throw new Error("Application didn't record any transaction event IDs."); - } - - let hadPageNavigationTransaction = false; - - console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); - - await Promise.all( - recordedTransactionEventIds.map(async transactionEventId => { - await expect - .poll( - async () => { - const response = await fetch( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); - - if (response.ok) { - const data = await response.json(); - - if (data.contexts.trace.op === 'navigation') { - expect(data.title).toBe('/user/:id'); - hadPageNavigationTransaction = true; - } - } - - return response.status; - }, - { - timeout: EVENT_POLLING_TIMEOUT, - }, - ) - .toBe(200); - }), - ); - - expect(hadPageNavigationTransaction).toBe(true); -}); - -test('Sends a Replay recording to Sentry', async ({ browser }) => { - const context = await browser.newContext(); - const page = await context.newPage(); - - await page.goto('/'); - - const replayId = await page.waitForFunction(() => { - return window.sentryReplayId; - }); - - // Keypress event ensures LCP is finished - await page.type('body', 'Y'); - - // Wait for replay to be sent - - if (replayId === undefined) { - throw new Error("Application didn't set a replayId"); - } - - console.log(`Polling for replay with ID: ${replayId}`); - - await expect - .poll( - async () => { - const response = await fetch( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/replays/${replayId}/`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); - return response.status; - }, - { - timeout: EVENT_POLLING_TIMEOUT, - }, - ) - .toBe(200); - - // now fetch the first recording segment - await expect - .poll( - async () => { - const response = await fetch( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/replays/${replayId}/recording-segments/?cursor=100%3A0%3A1`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); - - if (response.ok) { - const data = await response.json(); - return data[0]; - } - - return response.status; - }, - { - timeout: EVENT_POLLING_TIMEOUT, - }, - ) - .toEqual(ReplayRecordingData); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/errors.test.ts new file mode 100644 index 000000000000..80dab4ba949c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/errors.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/event-proxy-server'; + +test('Captures exception correctly', async ({ page }) => { + const errorEventPromise = waitForError('react-create-hash-router', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts deleted file mode 100644 index 0b454ba12214..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { expect } from '@playwright/test'; - -export const ReplayRecordingData = [ - { - type: 4, - data: { href: expect.stringMatching(/http:\/\/localhost:\d+\//), width: 1280, height: 720 }, - timestamp: expect.any(Number), - }, - { - data: { - payload: { - blockAllMedia: true, - errorSampleRate: 0, - maskAllInputs: true, - maskAllText: true, - networkCaptureBodies: true, - networkDetailHasUrls: false, - networkRequestHasHeaders: true, - networkResponseHasHeaders: true, - sessionSampleRate: 1, - shouldRecordCanvas: false, - useCompression: false, - useCompressionOption: true, - }, - tag: 'options', - }, - timestamp: expect.any(Number), - type: 5, - }, - { - type: 2, - data: { - node: { - type: 0, - childNodes: [ - { type: 1, name: 'html', publicId: '', systemId: '', id: 2 }, - { - type: 2, - tagName: 'html', - attributes: { lang: 'en' }, - childNodes: [ - { - type: 2, - tagName: 'head', - attributes: {}, - childNodes: [ - { type: 2, tagName: 'meta', attributes: { charset: 'utf-8' }, childNodes: [], id: 5 }, - { - type: 2, - tagName: 'meta', - attributes: { name: 'viewport', content: 'width=device-width,initial-scale=1' }, - childNodes: [], - id: 6, - }, - { - type: 2, - tagName: 'meta', - attributes: { name: 'theme-color', content: '#000000' }, - childNodes: [], - id: 7, - }, - { - type: 2, - tagName: 'title', - attributes: {}, - childNodes: [{ type: 3, textContent: '***** ***', id: 9 }], - id: 8, - }, - ], - id: 4, - }, - { - type: 2, - tagName: 'body', - attributes: {}, - childNodes: [ - { - type: 2, - tagName: 'noscript', - attributes: {}, - childNodes: [{ type: 3, textContent: '*** **** ** ****** ********** ** *** **** ****', id: 12 }], - id: 11, - }, - { type: 2, tagName: 'div', attributes: { id: 'root' }, childNodes: [], id: 13 }, - ], - id: 10, - }, - ], - id: 3, - }, - ], - id: 1, - }, - initialOffset: { left: 0, top: 0 }, - }, - timestamp: expect.any(Number), - }, - { - type: 3, - data: { - source: 0, - texts: [], - attributes: [], - removes: [], - adds: [ - { - parentId: 13, - nextId: null, - node: { - type: 2, - tagName: 'a', - attributes: { id: 'navigation', href: expect.stringMatching(/http:\/\/localhost:\d+\/user\/5/) }, - childNodes: [], - id: 14, - }, - }, - { parentId: 14, nextId: null, node: { type: 3, textContent: '********', id: 15 } }, - { - parentId: 13, - nextId: 14, - node: { - type: 2, - tagName: 'input', - attributes: { type: 'button', id: 'exception-button', value: '******* *********' }, - childNodes: [], - id: 16, - }, - }, - ], - }, - timestamp: expect.any(Number), - }, - { - type: 3, - data: { source: 5, text: 'Capture Exception', isChecked: false, id: 16 }, - timestamp: expect.any(Number), - }, - { - type: 5, - timestamp: expect.any(Number), - data: { - tag: 'performanceSpan', - payload: { - op: 'navigation.navigate', - description: expect.stringMatching(/http:\/\/localhost:\d+\//), - startTimestamp: expect.any(Number), - endTimestamp: expect.any(Number), - data: { - decodedBodySize: expect.any(Number), - encodedBodySize: expect.any(Number), - duration: expect.any(Number), - domInteractive: expect.any(Number), - domContentLoadedEventEnd: expect.any(Number), - domContentLoadedEventStart: expect.any(Number), - loadEventStart: expect.any(Number), - loadEventEnd: expect.any(Number), - domComplete: expect.any(Number), - redirectCount: expect.any(Number), - size: expect.any(Number), - }, - }, - }, - }, - { - type: 5, - timestamp: expect.any(Number), - data: { - tag: 'performanceSpan', - payload: { - op: 'resource.script', - description: expect.stringMatching(/http:\/\/localhost:\d+\/static\/js\/main.(\w+).js/), - startTimestamp: expect.any(Number), - endTimestamp: expect.any(Number), - data: { - decodedBodySize: expect.any(Number), - encodedBodySize: expect.any(Number), - size: expect.any(Number), - }, - }, - }, - }, - { - type: 5, - timestamp: expect.any(Number), - data: { - tag: 'performanceSpan', - payload: { - op: 'paint', - description: 'first-paint', - startTimestamp: expect.any(Number), - endTimestamp: expect.any(Number), - }, - }, - }, - { - type: 5, - timestamp: expect.any(Number), - data: { - tag: 'performanceSpan', - payload: { - op: 'paint', - description: 'first-contentful-paint', - startTimestamp: expect.any(Number), - endTimestamp: expect.any(Number), - }, - }, - }, - { - type: 5, - timestamp: expect.any(Number), - data: { - tag: 'performanceSpan', - payload: { - op: 'largest-contentful-paint', - description: 'largest-contentful-paint', - startTimestamp: expect.any(Number), - endTimestamp: expect.any(Number), - data: { - value: expect.any(Number), - size: expect.any(Number), - nodeId: 16, - }, - }, - }, - }, - { - type: 5, - timestamp: expect.any(Number), - data: { - tag: 'performanceSpan', - payload: { - op: 'memory', - description: 'memory', - startTimestamp: expect.any(Number), - endTimestamp: expect.any(Number), - data: { - memory: { - jsHeapSizeLimit: expect.any(Number), - totalJSHeapSize: expect.any(Number), - usedJSHeapSize: expect.any(Number), - }, - }, - }, - }, - }, -]; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts new file mode 100644 index 000000000000..971d4f31521b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts @@ -0,0 +1,149 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/event-proxy-server'; + +test('Captures a pageload transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + + const transactionEvent = await transactionEventPromise; + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + deviceMemory: expect.any(String), + effectiveConnectionType: expect.any(String), + hardwareConcurrency: expect.any(String), + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.react.reactrouter_v6', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + }), + op: 'pageload', + span_id: expect.any(String), + trace_id: expect.any(String), + origin: 'auto.pageload.react.reactrouter_v6', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(transactionEvent.spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.ui.browser.metrics', + 'sentry.op': 'browser', + }, + description: 'domContentLoadedEvent', + op: 'browser', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'auto.ui.browser.metrics', + }); + expect(transactionEvent.spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.ui.browser.metrics', + 'sentry.op': 'browser', + }, + description: 'loadEvent', + op: 'browser', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'auto.ui.browser.metrics', + }); + expect(transactionEvent.spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.ui.browser.metrics', + 'sentry.op': 'browser', + }, + description: 'connect', + op: 'browser', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'auto.ui.browser.metrics', + }); + expect(transactionEvent.spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.ui.browser.metrics', + 'sentry.op': 'browser', + }, + description: 'request', + op: 'browser', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'auto.ui.browser.metrics', + }); + expect(transactionEvent.spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.ui.browser.metrics', + 'sentry.op': 'browser', + }, + description: 'response', + op: 'browser', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'auto.ui.browser.metrics', + }); +}); + +test('Captures a navigation transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + deviceMemory: expect.any(String), + effectiveConnectionType: expect.any(String), + hardwareConcurrency: expect.any(String), + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'navigation', + 'sentry.origin': 'auto.navigation.react.reactrouter_v6', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + }), + op: 'navigation', + span_id: expect.any(String), + trace_id: expect.any(String), + origin: 'auto.navigation.react.reactrouter_v6', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/user/:id', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(transactionEvent.spans).toEqual([]); +});