From defe516c982de9d03ce2fb7369d22e2b86746ebe Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 12 Mar 2024 15:46:51 +0100 Subject: [PATCH 1/3] test(e2e/react): Add e2e tests for Scope `transactionName` changes --- .../event-proxy-server.ts | 253 ++++++++++++++++++ .../standard-frontend-react/package.json | 6 +- .../playwright.config.ts | 46 ++-- .../standard-frontend-react/src/index.tsx | 4 +- .../src/pages/User.tsx | 14 +- .../start-event-proxy.ts | 6 + .../tests/error-test.spec.ts | 91 +++++++ .../standard-frontend-react/tsconfig.json | 7 +- 8 files changed, 403 insertions(+), 24 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/standard-frontend-react/event-proxy-server.ts create mode 100644 dev-packages/e2e-tests/test-applications/standard-frontend-react/start-event-proxy.ts create mode 100644 dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/error-test.spec.ts diff --git a/dev-packages/e2e-tests/test-applications/standard-frontend-react/event-proxy-server.ts b/dev-packages/e2e-tests/test-applications/standard-frontend-react/event-proxy-server.ts new file mode 100644 index 000000000000..d14ca5cb5e72 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/standard-frontend-react/event-proxy-server.ts @@ -0,0 +1,253 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import type { AddressInfo } from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import * as util from 'util'; +import * as zlib from 'zlib'; +import type { Envelope, EnvelopeItem, Event } from '@sentry/types'; +import { parseEnvelope } from '@sentry/utils'; + +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + +interface EventProxyServerOptions { + /** Port to start the event proxy server at. */ + port: number; + /** The name for the proxy server used for referencing it with listener functions */ + proxyServerName: string; +} + +interface SentryRequestCallbackData { + envelope: Envelope; + rawProxyRequestBody: string; + rawSentryResponseBody: string; + sentryResponseStatusCode?: number; +} + +/** + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` + * option to this server (like this `tunnel: http://localhost:${port option}/`). + */ +export async function startEventProxyServer(options: EventProxyServerOptions): Promise { + const eventCallbackListeners: Set<(data: string) => void> = new Set(); + + const proxyServer = http.createServer((proxyRequest, proxyResponse) => { + const proxyRequestChunks: Uint8Array[] = []; + + proxyRequest.addListener('data', (chunk: Buffer) => { + proxyRequestChunks.push(chunk); + }); + + proxyRequest.addListener('error', err => { + throw err; + }); + + proxyRequest.addListener('end', () => { + const proxyRequestBody = + proxyRequest.headers['content-encoding'] === 'gzip' + ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() + : Buffer.concat(proxyRequestChunks).toString(); + + let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]); + + if (!envelopeHeader.dsn) { + throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.'); + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn); + + const projectId = pathname.substring(1); + const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; + + proxyRequest.headers.host = host; + + const sentryResponseChunks: Uint8Array[] = []; + + const sentryRequest = https.request( + sentryIngestUrl, + { headers: proxyRequest.headers, method: proxyRequest.method }, + sentryResponse => { + sentryResponse.addListener('data', (chunk: Buffer) => { + proxyResponse.write(chunk, 'binary'); + sentryResponseChunks.push(chunk); + }); + + sentryResponse.addListener('end', () => { + eventCallbackListeners.forEach(listener => { + const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString(); + + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: sentryResponse.statusCode, + }; + + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + proxyResponse.end(); + }); + + sentryResponse.addListener('error', err => { + throw err; + }); + + proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); + }, + ); + + sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); + sentryRequest.end(); + }); + }); + + const proxyServerStartupPromise = new Promise(resolve => { + proxyServer.listen(options.port, () => { + resolve(); + }); + }); + + const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => { + eventCallbackResponse.statusCode = 200; + eventCallbackResponse.setHeader('connection', 'keep-alive'); + + const callbackListener = (data: string): void => { + eventCallbackResponse.write(data.concat('\n'), 'utf8'); + }; + + eventCallbackListeners.add(callbackListener); + + eventCallbackRequest.on('close', () => { + eventCallbackListeners.delete(callbackListener); + }); + + eventCallbackRequest.on('error', () => { + eventCallbackListeners.delete(callbackListener); + }); + }); + + const eventCallbackServerStartupPromise = new Promise(resolve => { + eventCallbackServer.listen(0, () => { + const port = String((eventCallbackServer.address() as AddressInfo).port); + void registerCallbackServerPort(options.proxyServerName, port).then(resolve); + }); + }); + + await eventCallbackServerStartupPromise; + await proxyServerStartupPromise; + return; +} + +export async function waitForRequest( + proxyServerName: string, + callback: (eventData: SentryRequestCallbackData) => Promise | boolean, +): Promise { + const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName); + + return new Promise((resolve, reject) => { + const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => { + let eventContents = ''; + + response.on('error', err => { + reject(err); + }); + + response.on('data', (chunk: Buffer) => { + const chunkString = chunk.toString('utf8'); + chunkString.split('').forEach(char => { + if (char === '\n') { + const eventCallbackData: SentryRequestCallbackData = JSON.parse( + Buffer.from(eventContents, 'base64').toString('utf8'), + ); + const callbackResult = callback(eventCallbackData); + if (typeof callbackResult !== 'boolean') { + callbackResult.then( + match => { + if (match) { + response.destroy(); + resolve(eventCallbackData); + } + }, + err => { + throw err; + }, + ); + } else if (callbackResult) { + response.destroy(); + resolve(eventCallbackData); + } + eventContents = ''; + } else { + eventContents = eventContents.concat(char); + } + }); + }); + }); + + request.end(); + }); +} + +export function waitForEnvelopeItem( + proxyServerName: string, + callback: (envelopeItem: EnvelopeItem) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForRequest(proxyServerName, async eventData => { + const envelopeItems = eventData.envelope[1]; + for (const envelopeItem of envelopeItems) { + if (await callback(envelopeItem)) { + resolve(envelopeItem); + return true; + } + } + return false; + }).catch(reject); + }); +} + +export function waitForError( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +export function waitForTransaction( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +const TEMP_FILE_PREFIX = 'event-proxy-server-'; + +async function registerCallbackServerPort(serverName: string, port: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + await writeFile(tmpFilePath, port, { encoding: 'utf8' }); +} + +function retrieveCallbackServerPort(serverName: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + return readFile(tmpFilePath, 'utf8'); +} diff --git a/dev-packages/e2e-tests/test-applications/standard-frontend-react/package.json b/dev-packages/e2e-tests/test-applications/standard-frontend-react/package.json index 10ace7b83893..e9c3376d9393 100644 --- a/dev-packages/e2e-tests/test-applications/standard-frontend-react/package.json +++ b/dev-packages/e2e-tests/test-applications/standard-frontend-react/package.json @@ -47,9 +47,11 @@ ] }, "devDependencies": { - "@playwright/test": "1.26.1", + "@playwright/test": "1.41.2", "axios": "1.6.0", - "serve": "14.0.1" + "serve": "14.0.1", + "ts-node": "10.9.1", + "wait-port": "1.0.4" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/standard-frontend-react/playwright.config.ts b/dev-packages/e2e-tests/test-applications/standard-frontend-react/playwright.config.ts index 5f93f826ebf0..ba023d84471a 100644 --- a/dev-packages/e2e-tests/test-applications/standard-frontend-react/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/standard-frontend-react/playwright.config.ts @@ -1,6 +1,20 @@ import type { PlaywrightTestConfig } from '@playwright/test'; import { devices } from '@playwright/test'; +// Fix urls not resolving to localhost on Node v17+ +// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575 +import { setDefaultResultOrder } from 'dns'; +setDefaultResultOrder('ipv4first'); + +const testEnv = process.env['TEST_ENV'] || 'production'; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const reactPort = 3030; +const eventProxyPort = 3031; + /** * See https://playwright.dev/docs/test-configuration. */ @@ -32,6 +46,7 @@ const config: PlaywrightTestConfig = { /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', + baseURL: `http://localhost:${reactPort}`, }, /* Configure projects for major browsers */ @@ -42,29 +57,22 @@ const config: PlaywrightTestConfig = { ...devices['Desktop Chrome'], }, }, - // For now we only test Chrome! - // { - // name: 'firefox', - // use: { - // ...devices['Desktop Firefox'], - // }, - // }, - // { - // name: 'webkit', - // use: { - // ...devices['Desktop Safari'], - // }, - // }, ], /* Run your local dev server before starting the tests */ - webServer: { - command: 'pnpm start', - port: 3030, - env: { - PORT: '3030', + webServer: [ + { + command: 'pnpm ts-node-script start-event-proxy.ts', + port: eventProxyPort, }, - }, + { + command: `pnpm wait-port ${eventProxyPort} && pnpm start`, + port: reactPort, + env: { + PORT: reactPort.toString(), + }, + }, + ], }; export default config; diff --git a/dev-packages/e2e-tests/test-applications/standard-frontend-react/src/index.tsx b/dev-packages/e2e-tests/test-applications/standard-frontend-react/src/index.tsx index 3a87a53ffdfa..6e93a2e51135 100644 --- a/dev-packages/e2e-tests/test-applications/standard-frontend-react/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/standard-frontend-react/src/index.tsx @@ -13,11 +13,13 @@ import { import Index from './pages/Index'; import User from './pages/User'; -const replay = Sentry.replayIntegration(); +const replay = Sentry.replayIntegration({ useCompression: false }); Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + debug: true, integrations: [ Sentry.reactRouterV6BrowserTracingIntegration({ useEffect: React.useEffect, diff --git a/dev-packages/e2e-tests/test-applications/standard-frontend-react/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/standard-frontend-react/src/pages/User.tsx index 62f0c2d17533..60076bd0d8ed 100644 --- a/dev-packages/e2e-tests/test-applications/standard-frontend-react/src/pages/User.tsx +++ b/dev-packages/e2e-tests/test-applications/standard-frontend-react/src/pages/User.tsx @@ -2,7 +2,19 @@ import * as React from 'react'; const User = () => { - return

I am a blank page :)

; + return ( + <> +

I am a blank page :)

+ + + ); }; export default User; diff --git a/dev-packages/e2e-tests/test-applications/standard-frontend-react/start-event-proxy.ts b/dev-packages/e2e-tests/test-applications/standard-frontend-react/start-event-proxy.ts new file mode 100644 index 000000000000..02032b328616 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/standard-frontend-react/start-event-proxy.ts @@ -0,0 +1,6 @@ +import { startEventProxyServer } from './event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'standard-frontend-react', +}); diff --git a/dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/error-test.spec.ts b/dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/error-test.spec.ts new file mode 100644 index 000000000000..886c2eb97823 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/error-test.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '../event-proxy-server'; + +test('Sends an exception to Sentry after a pageload and attaches the transaction info', async ({ page }) => { + const errorPromise = waitForError('standard-frontend-react', async errorEvent => { + return !errorEvent.type; + }); + + await page.goto(`/`); + + const [, error] = await Promise.all([page.locator('#exception-button').click(), errorPromise]); + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'I am an error!', + mechanism: { + type: 'generic', + handled: true, // called via captureException, this makes sense + }, + }, + ], + }, + transaction: '/', + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.react.reactrouter_v6', + 'sentry.op': 'pageload', + 'sentry.sample_rate': 1, + }, + op: 'pageload', + span_id: expect.any(String), + trace_id: expect.any(String), + origin: 'auto.pageload.react.reactrouter_v6', + }, + }, + }); +}); + +test('Sends an exception to Sentry after a navigation and attaches the transaction info', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('standard-frontend-react', async txnEvent => { + return txnEvent.type === 'transaction' && txnEvent.contexts?.trace?.op === 'pageload'; + }); + + const errorPromise = waitForError('standard-frontend-react', async errorEvent => { + return !errorEvent.type; + }); + + await page.goto(`/`); + + await pageloadTxnPromise; + + await page.locator('#navigation').click(); + + const [, error] = await Promise.all([page.locator('#userErrorBtn').click(), errorPromise]); + + console.log(JSON.stringify(error, null, 2)); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'User page error', + mechanism: { + type: 'instrument', + handled: false, + }, + }, + ], + }, + transaction: '/user/:id', + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.react.reactrouter_v6', + 'sentry.op': 'navigation', + 'sentry.sample_rate': 1, + }, + op: 'navigation', + span_id: expect.any(String), + trace_id: expect.any(String), + origin: 'auto.navigation.react.reactrouter_v6', + }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/standard-frontend-react/tsconfig.json b/dev-packages/e2e-tests/test-applications/standard-frontend-react/tsconfig.json index 75ae036f46b0..91e7545fb1a9 100644 --- a/dev-packages/e2e-tests/test-applications/standard-frontend-react/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/standard-frontend-react/tsconfig.json @@ -16,5 +16,10 @@ "noEmit": true, "jsx": "react" }, - "include": ["src", "tests"] + "include": ["src", "tests"], + "ts-node": { + "compilerOptions": { + "module": "CommonJS" + } + } } From 1e5dd9ca2b24fd61d7bac817d8ce32b6799f09a6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 13 Mar 2024 12:05:06 +0100 Subject: [PATCH 2/3] fix replay e2e test --- .../tests/fixtures/ReplayRecordingData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts b/dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts index 0b454ba12214..9d2d84444fdc 100644 --- a/dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts +++ b/dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts @@ -20,7 +20,7 @@ export const ReplayRecordingData = [ sessionSampleRate: 1, shouldRecordCanvas: false, useCompression: false, - useCompressionOption: true, + useCompressionOption: false, }, tag: 'options', }, From 6b161bcaac80c70737fc39d0592fa8aa22cf450d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 13 Mar 2024 13:31:48 +0100 Subject: [PATCH 3/3] please work now? --- .../tests/fixtures/ReplayRecordingData.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts b/dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts index 9d2d84444fdc..e98210822b74 100644 --- a/dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts +++ b/dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts @@ -175,6 +175,7 @@ export const ReplayRecordingData = [ decodedBodySize: expect.any(Number), encodedBodySize: expect.any(Number), size: expect.any(Number), + statusCode: expect.any(Number), }, }, },