diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/.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-router-7-spa-streaming/index.html b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/index.html new file mode 100644 index 000000000000..e4b78eae1230 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json new file mode 100644 index 000000000000..4fddbfa60945 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json @@ -0,0 +1,65 @@ +{ + "name": "react-router-7-spa-streaming", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "file:../../packed/sentry-react-packed.tgz", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router": "^7.13.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "vite": "^6.4.2", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.0.0" + }, + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build-ts3.8": "pnpm install && pnpm add typescript@3.8 && 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" + ] + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build-ts3.8", + "label": "react-router-7-spa-streaming (TS 3.8)" + } + ] + }, + "pnpm": { + "overrides": { + "esbuild": "0.24.0" + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs new file mode 100644 index 000000000000..7fda76df18ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm preview --port 3030`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/globals.d.ts @@ -0,0 +1,5 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + sentryReplayId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/main.tsx new file mode 100644 index 000000000000..9f69427b101d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/main.tsx @@ -0,0 +1,57 @@ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { + BrowserRouter, + Route, + Routes, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router'; +import Index from './pages/Index'; +import SSE from './pages/SSE'; +import User from './pages/User'; + +const replay = Sentry.replayIntegration(); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, + integrations: [ + Sentry.reactRouterV7BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + trackFetchStreamPerformance: true, + }), + Sentry.spanStreamingIntegration(), + 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', + sendDefaultPii: true, +}); + +const SentryRoutes = Sentry.withSentryReactRouterV7Routing(Routes); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render( + + + } /> + } /> + } /> + + , +); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/Index.tsx new file mode 100644 index 000000000000..688cba53fb70 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/Index.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { Link } from 'react-router'; + +const Index = () => { + return ( + <> + { + throw new Error('I am an error!'); + }} + /> + + navigate + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/SSE.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/SSE.tsx new file mode 100644 index 000000000000..4c0ae97036ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/SSE.tsx @@ -0,0 +1,58 @@ +import * as Sentry from '@sentry/react'; +import * as React from 'react'; + +const fetchSSE = async ({ timeout, abort = false }: { timeout: boolean; abort?: boolean }) => { + Sentry.startSpanManual({ name: 'sse stream using fetch' }, async span => { + const controller = new AbortController(); + + const res = await Sentry.startSpan({ name: 'sse fetch call' }, async () => { + const endpoint = `http://localhost:8080/${timeout ? 'sse-timeout' : 'sse'}`; + + const signal = controller.signal; + return await fetch(endpoint, { signal }); + }); + + const stream = res.body; + const reader = stream?.getReader(); + + const readChunk = async () => { + if (abort) { + controller.abort(); + } + const readRes = await reader?.read(); + if (readRes?.done) { + return; + } + + new TextDecoder().decode(readRes?.value); + + await readChunk(); + }; + + try { + await readChunk(); + } catch (error) { + console.error('Could not fetch sse', error); + } + + span.end(); + }); +}; + +const SSE = () => { + return ( + <> + + + + + ); +}; + +export default SSE; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/User.tsx new file mode 100644 index 000000000000..671455a92fff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/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-router-7-spa-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/start-event-proxy.mjs new file mode 100644 index 000000000000..202974be53c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-spa-streaming', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/errors.test.ts new file mode 100644 index 000000000000..bd60e80c0246 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page, baseURL }) => { + const errorEventPromise = waitForError('react-router-7-spa-streaming', 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), + }); +}); + +test('Sets correct transactionName', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const errorEventPromise = waitForError('react-router-7-spa-streaming', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + // Only capture error once pageload span 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: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id || ''), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts new file mode 100644 index 000000000000..0080a584463a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/`); + + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.status).toBe('ok'); + expect(span.attributes?.['sentry.origin']?.value).toBe('auto.pageload.react.reactrouter_v7'); + expect(span.attributes?.['sentry.source']?.value).toBe('route'); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + page.on('console', msg => console.log(msg.text())); + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.trace_id).toMatch(/[a-f0-9]{32}/); + expect(navigationSpan.status).toBe('ok'); + expect(navigationSpan.attributes?.['sentry.origin']?.value).toBe('auto.navigation.react.reactrouter_v7'); + expect(navigationSpan.attributes?.['sentry.source']?.value).toBe('route'); +}); + +test('sends an INP span', async ({ page }) => { + const inpSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', 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(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + const inpSpan = await inpSpanPromise; + + expect(inpSpan.name).toBe('body > div#root > input#exception-button[type="button"]'); + expect(inpSpan.trace_id).toMatch(/[a-f0-9]{32}/); + expect(inpSpan.span_id).toMatch(/[a-f0-9]{16}/); + expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp); + expect(inpSpan.attributes?.['sentry.op']?.value).toBe('ui.interaction.click'); + expect(inpSpan.attributes?.['sentry.origin']?.value).toBe('auto.http.browser.inp'); + expect(inpSpan.attributes?.['sentry.exclusive_time']?.value).toEqual(expect.any(Number)); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json new file mode 100644 index 000000000000..7af258198f12 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json @@ -0,0 +1,21 @@ +{ + "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", + "types": ["vite/client"] + }, + "include": ["src", "tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/vite.config.ts new file mode 100644 index 000000000000..63c2c4317df7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/vite.config.ts @@ -0,0 +1,8 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + envPrefix: 'PUBLIC_', +});