diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/.gitignore b/dev-packages/e2e-tests/test-applications/react-17-static/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17-static/.gitignore @@ -0,0 +1,29 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/package.json b/dev-packages/e2e-tests/test-applications/react-17-static/package.json new file mode 100644 index 000000000000..71df50a6bc4e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17-static/package.json @@ -0,0 +1,51 @@ +{ + "name": "react-17-static", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "file:../../packed/sentry-react-packed.tgz", + "@types/react": "17.0.2", + "@types/react-dom": "17.0.2", + "react": "17.0.2", + "react-dom": "17.0.2", + "react-router-dom": "~6.3.0", + "react-scripts": "5.0.1", + "typescript": "~5.0.0" + }, + "scripts": { + "build": "react-scripts build", + "dev": "react-scripts start", + "start": "serve -s build", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && pnpm build", + "test:assert": "pnpm test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "serve": "14.0.1" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-17-static/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17-static/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/public/index.html b/dev-packages/e2e-tests/test-applications/react-17-static/public/index.html new file mode 100644 index 000000000000..68f33b623e38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17-static/public/index.html @@ -0,0 +1,24 @@ + + + + + + + + React App + + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-17-static/src/index.tsx new file mode 100644 index 000000000000..c303ff60fe80 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17-static/src/index.tsx @@ -0,0 +1,59 @@ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { + BrowserRouter, + Route, + Routes, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router-dom'; +import Index from './pages/Index'; +import User from './pages/User'; + +const replay = Sentry.replayIntegration(); + +Sentry.init({ + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.REACT_APP_E2E_TEST_DSN, + integrations: [ + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + replay, + ], + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + release: 'e2e-test', + + // Always capture replays, so we can test this properly + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + tunnel: 'http://localhost:3031', +}); + +const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); + +function App() { + return ( + + + + } /> + } /> + + + + ); +} + +ReactDOM.render(, document.getElementById('root')); diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-17-static/src/pages/Index.tsx new file mode 100644 index 000000000000..754af82320e9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17-static/src/pages/Index.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { Link } from 'react-router-dom'; + +const Index = () => { + return ( + <> + { + throw new Error('I am an error!'); + }} + /> + + navigate to user + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-17-static/src/pages/User.tsx new file mode 100644 index 000000000000..671455a92fff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17-static/src/pages/User.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const User = () => { + return

I am a blank page :)

; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-17-static/src/react-app-env.d.ts new file mode 100644 index 000000000000..6431bc5fc6b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17-static/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-17-static/start-event-proxy.mjs new file mode 100644 index 000000000000..77b36ea71c6f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17-static/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-17-static', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-17-static/tests/errors.test.ts new file mode 100644 index 000000000000..dded4dfe2dbb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17-static/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page }) => { + const errorEventPromise = waitForError('react-17-static', 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.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); + +test('Sets correct transactionName', async ({ page }) => { + const transactionPromise = waitForTransaction('react-17-static', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const errorEventPromise = waitForError('react-17-static', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const transactionEvent = await transactionPromise; + + // Only capture error once transaction was sent + 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.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: transactionEvent.contexts?.trace?.trace_id, + span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts new file mode 100644 index 000000000000..e78693bdfd2c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('react-17-static', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.react.reactrouter_v6', + data: { + 'sentry.segment.name.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, + }, + }, + transaction: '/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + page.on('console', msg => console.log(msg.text())); + const pageloadTxnPromise = waitForTransaction('react-17-static', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('react-17-static', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react.reactrouter_v6', + data: { + 'sentry.segment.name.source': 'route', + 'url.template': '/user/:id', + 'url.path': '/user/5', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), + }, + }, + }, + transaction: '/user/:id', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends an INP span', async ({ page }) => { + const inpSpanPromise = waitForStreamedSpan('react-17-static', span => { + return getSpanOp(span) === 'ui.interaction.click'; + }); + + await page.goto(`/`); + + await page.click('#exception-button'); + + await page.waitForTimeout(500); + + // Page hide to trigger INP + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + const inpSpan = await inpSpanPromise; + + const inpValue = inpSpan.attributes['browser.web_vital.inp.value']?.value as number; + expect(inpValue).toBeGreaterThan(0); + + const pageloadSpanId = inpSpan.parent_span_id; + + expect(inpSpan).toEqual( + expect.objectContaining({ + name: 'body > div#root > input#exception-button[type="button"]', + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + is_segment: false, + status: 'ok', + }), + ); + expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp); + + // `client.address` and replay/user attributes are added by the server or vary by run, so we assert + // the stable subset rather than the exhaustive attribute set. + expect(inpSpan.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { value: 'ui.interaction.click', type: 'string' }, + 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' }, + 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, + 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) }, + 'sentry.transaction': { value: '/', type: 'string' }, + 'sentry.segment.name': { value: '/', type: 'string' }, + 'sentry.segment.id': { value: pageloadSpanId, type: 'string' }, + 'sentry.pageload.span_id': { value: pageloadSpanId, type: 'string' }, + 'sentry.trace_lifecycle': { value: 'stream', type: 'string' }, + 'sentry.release': { value: 'e2e-test', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' }, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-17-static/tsconfig.json new file mode 100644 index 000000000000..76ffed0e7ed2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-17-static/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src", "tests"] +}