From abd79b8557bbf1411a74119cd19cb8d6eff1ed17 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 5 May 2026 14:48:19 +0200 Subject: [PATCH 1/9] test(e2e): Add span streaming test app for React 19 Closes https://github.com/getsentry/sentry-javascript/issues/20671 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../react-19-streaming/.gitignore | 29 ++++++++ .../react-19-streaming/package.json | 52 ++++++++++++++ .../react-19-streaming/playwright.config.mjs | 7 ++ .../react-19-streaming/public/index.html | 24 +++++++ .../react-19-streaming/src/globals.d.ts | 5 ++ .../react-19-streaming/src/index.tsx | 28 ++++++++ .../react-19-streaming/src/pages/Index.jsx | 59 ++++++++++++++++ .../react-19-streaming/src/react-app-env.d.ts | 1 + .../react-19-streaming/start-event-proxy.mjs | 6 ++ .../react-19-streaming/tests/errors.test.ts | 70 +++++++++++++++++++ .../tests/hoist-non-react-statics.test.ts | 45 ++++++++++++ .../react-19-streaming/tests/pageload.test.ts | 16 +++++ .../react-19-streaming/tsconfig.json | 20 ++++++ 13 files changed, 362 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/package.json create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/public/index.html create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/src/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/src/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/src/pages/Index.jsx create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/src/react-app-env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/tests/hoist-non-react-statics.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/react-19-streaming/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-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-19-streaming/package.json b/dev-packages/e2e-tests/test-applications/react-19-streaming/package.json new file mode 100644 index 000000000000..452776a830d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/package.json @@ -0,0 +1,52 @@ +{ + "name": "react-19-streaming-test-app", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "file:../../packed/sentry-react-packed.tgz", + "history": "4.9.0", + "@types/history": "4.7.11", + "@types/node": "^18.19.1", + "@types/react": "npm:types-react@rc", + "@types/react-dom": "npm:types-react-dom@rc", + "react": "19.0.0-rc-935180c7e0-20240524", + "react-dom": "19.0.0-rc-935180c7e0-20240524", + "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: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-19-streaming/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-19-streaming/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/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-19-streaming/public/index.html b/dev-packages/e2e-tests/test-applications/react-19-streaming/public/index.html new file mode 100644 index 000000000000..68f33b623e38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/public/index.html @@ -0,0 +1,24 @@ + + + + + + + + React App + + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-19-streaming/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-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-19-streaming/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-19-streaming/src/index.tsx new file mode 100644 index 000000000000..e57c7feaaea7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/src/index.tsx @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import Index from './pages/Index'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.REACT_APP_E2E_TEST_DSN, + release: 'e2e-test', + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1.0, + integrations: [Sentry.spanStreamingIntegration()], +}); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement, { + onUncaughtError: Sentry.reactErrorHandler((error, errorInfo) => { + console.warn(error, errorInfo); + }), + onCaughtError: Sentry.reactErrorHandler((error, errorInfo) => { + console.warn(error, errorInfo); + }), +}); + +root.render( +
+ +
, +); diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/src/pages/Index.jsx b/dev-packages/e2e-tests/test-applications/react-19-streaming/src/pages/Index.jsx new file mode 100644 index 000000000000..d6ff558b9e34 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/src/pages/Index.jsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { withProfiler } from '@sentry/react'; + +function ProfilerTestComponent() { + return
withProfiler works
; +} +ProfilerTestComponent.customStaticMethod = () => 'static method works'; +const ProfiledComponent = withProfiler(ProfilerTestComponent); + +const Index = () => { + const [caughtError, setCaughtError] = React.useState(false); + const [uncaughtError, setUncaughtError] = React.useState(false); + + return ( + <> +
+ + +

React 19

+ {caughtError && } + +
+
+
+ {uncaughtError && } + +
+ + ); +}; + +function Throw({ error }) { + throw new Error(`${error} error`); +} + +class SampleErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { error: null }; + } + + componentDidCatch(error, errorInfo) { + this.setState({ error }); + // no-op + } + + render() { + if (this.state.error) { + return
Caught an error: {JSON.stringify(this.state.error)}
; + } + return this.props.children; + } +} + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-19-streaming/src/react-app-env.d.ts new file mode 100644 index 000000000000..6431bc5fc6b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-19-streaming/start-event-proxy.mjs new file mode 100644 index 000000000000..404433b90d8e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-19-streaming', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/errors.test.ts new file mode 100644 index 000000000000..a64d1073a304 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/errors.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Catches errors caught by error boundary', async ({ page }) => { + page.on('console', message => { + expect(message.text()).toContain('caught error'); + }); + + const errorEventPromise = waitForError('react-19-streaming', event => { + return !event.type && event.exception?.values?.[0]?.value === 'caught error'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=caughtError-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(2); + expect(errorEvent.exception?.values?.[0]?.value).toBe('caught error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.function.react.error_handler', + handled: true, // true because a callback was provided + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(errorEvent.exception?.values?.[1]?.value).toBe('caught error'); + expect(errorEvent.exception?.values?.[1]?.mechanism).toEqual({ + type: 'generic', + handled: true, // true because a callback was provided + exception_id: 0, + }); +}); + +test('Catches errors uncaught by error boundary', async ({ page }) => { + page.on('console', message => { + expect(message.text()).toContain('uncaught error'); + }); + + const errorEventPromise = waitForError('react-19-streaming', event => { + return !event.type && event.exception?.values?.[0]?.value === 'uncaught error'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=uncaughtError-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(2); + expect(errorEvent.exception?.values?.[0]?.value).toBe('uncaught error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.function.react.error_handler', + handled: true, // true because a callback was provided + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(errorEvent.exception?.values?.[1]?.value).toBe('uncaught error'); + expect(errorEvent.exception?.values?.[1]?.mechanism).toEqual({ + type: 'generic', + handled: true, // true because a callback was provided + exception_id: 0, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/hoist-non-react-statics.test.ts b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/hoist-non-react-statics.test.ts new file mode 100644 index 000000000000..1ecf2f2f6a82 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/hoist-non-react-statics.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; + +test('withProfiler does not throw Symbol conversion error when String() is patched to simulate minifier', async ({ + page, +}) => { + const errors: string[] = []; + + // Listen for any page errors (including the Symbol conversion error) + page.on('pageerror', error => { + errors.push(error.message); + }); + + // Listen for console errors + page.on('console', msg => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }); + + await page.addInitScript(() => { + const OriginalString = String; + // @ts-expect-error - intentionally replacing String to simulate minifier behavior + window.String = function (value: unknown) { + if (typeof value === 'symbol') { + throw new TypeError('Cannot convert a Symbol value to a string'); + } + return OriginalString(value); + } as StringConstructor; + + Object.setPrototypeOf(window.String, OriginalString); + window.String.prototype = OriginalString.prototype; + window.String.fromCharCode = OriginalString.fromCharCode; + window.String.fromCodePoint = OriginalString.fromCodePoint; + window.String.raw = OriginalString.raw; + }); + + await page.goto('/'); + + const profilerTest = page.locator('#profiler-test'); + await expect(profilerTest).toBeVisible(); + await expect(profilerTest).toHaveText('withProfiler works'); + + const symbolErrors = errors.filter(e => e.includes('Cannot convert a Symbol value to a string')); + expect(symbolErrors).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts new file mode 100644 index 000000000000..064bdd60e4f4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('Sends a streamed pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-19-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'); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-19-streaming/tsconfig.json new file mode 100644 index 000000000000..4cc95dc2689a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/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" + }, + "include": ["src", "tests"] +} From 34af043da89aebfca707e7e5f91f397994ae6c1d Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 5 May 2026 14:53:35 +0200 Subject: [PATCH 2/9] test(e2e): Make pageload span assertions more exhaustive Co-Authored-By: Claude Opus 4.6 (1M context) --- .../react-19-streaming/tests/pageload.test.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts index 064bdd60e4f4..3600a69c0664 100644 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; -test('Sends a streamed pageload span', async ({ page }) => { +test('Sends a streamed pageload span with correct attributes', async ({ page }) => { const spanPromise = waitForStreamedSpan('react-19-streaming', span => { return getSpanOp(span) === 'pageload' && span.is_segment; }); @@ -12,5 +12,34 @@ test('Sends a streamed pageload span', async ({ page }) => { expect(span.name).toBe('/'); expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.span_id).toMatch(/[a-f0-9]{16}/); + expect(span.is_segment).toBe(true); expect(span.status).toBe('ok'); + expect(span.start_timestamp).toBeGreaterThan(0); + expect(span.end_timestamp).toBeGreaterThanOrEqual(span.start_timestamp); + + expect(span.attributes?.['sentry.op']?.value).toBe('pageload'); + expect(span.attributes?.['sentry.origin']?.value).toBe('auto.pageload.browser'); + expect(span.attributes?.['sentry.source']?.value).toBe('url'); + expect(span.attributes?.['sentry.idle_span_finish_reason']?.value).toEqual(expect.any(String)); +}); + +test('Pageload span includes child spans', async ({ page }) => { + const spansPromise = waitForStreamedSpans('react-19-streaming', spans => { + return spans.some(span => getSpanOp(span) === 'pageload' && span.is_segment); + }); + + await page.goto('/'); + + const spans = await spansPromise; + + const pageloadSpan = spans.find(span => getSpanOp(span) === 'pageload' && span.is_segment); + expect(pageloadSpan).toBeDefined(); + + const childSpans = spans.filter(span => !span.is_segment); + expect(childSpans.length).toBeGreaterThan(0); + + for (const child of childSpans) { + expect(child.trace_id).toBe(pageloadSpan!.trace_id); + } }); From 6be1ded3f45d21835cff4a8d67b30a4ed72090cb Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 5 May 2026 14:56:19 +0200 Subject: [PATCH 3/9] test(e2e): Simplify pageload test to match existing patterns Co-Authored-By: Claude Opus 4.6 (1M context) --- .../react-19-streaming/tests/pageload.test.ts | 31 ++----------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts index 3600a69c0664..a300bdecc24a 100644 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('Sends a streamed pageload span with correct attributes', async ({ page }) => { +test('Sends a streamed pageload span', async ({ page }) => { const spanPromise = waitForStreamedSpan('react-19-streaming', span => { return getSpanOp(span) === 'pageload' && span.is_segment; }); @@ -12,34 +12,7 @@ test('Sends a streamed pageload span with correct attributes', async ({ page }) expect(span.name).toBe('/'); expect(span.trace_id).toMatch(/[a-f0-9]{32}/); - expect(span.span_id).toMatch(/[a-f0-9]{16}/); - expect(span.is_segment).toBe(true); expect(span.status).toBe('ok'); - expect(span.start_timestamp).toBeGreaterThan(0); - expect(span.end_timestamp).toBeGreaterThanOrEqual(span.start_timestamp); - - expect(span.attributes?.['sentry.op']?.value).toBe('pageload'); expect(span.attributes?.['sentry.origin']?.value).toBe('auto.pageload.browser'); expect(span.attributes?.['sentry.source']?.value).toBe('url'); - expect(span.attributes?.['sentry.idle_span_finish_reason']?.value).toEqual(expect.any(String)); -}); - -test('Pageload span includes child spans', async ({ page }) => { - const spansPromise = waitForStreamedSpans('react-19-streaming', spans => { - return spans.some(span => getSpanOp(span) === 'pageload' && span.is_segment); - }); - - await page.goto('/'); - - const spans = await spansPromise; - - const pageloadSpan = spans.find(span => getSpanOp(span) === 'pageload' && span.is_segment); - expect(pageloadSpan).toBeDefined(); - - const childSpans = spans.filter(span => !span.is_segment); - expect(childSpans.length).toBeGreaterThan(0); - - for (const child of childSpans) { - expect(child.trace_id).toBe(pageloadSpan!.trace_id); - } }); From 631165d143a639b91cfe99c888fee23716e36670 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 5 May 2026 15:13:18 +0200 Subject: [PATCH 4/9] fix(e2e): Add browserTracingIntegration to react-19-streaming Without it no pageload span is created, so spanStreamingIntegration has nothing to stream. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test-applications/react-19-streaming/src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-19-streaming/src/index.tsx index e57c7feaaea7..25f52dfdf106 100644 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-19-streaming/src/index.tsx @@ -9,7 +9,7 @@ Sentry.init({ release: 'e2e-test', tunnel: 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, - integrations: [Sentry.spanStreamingIntegration()], + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], }); const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement, { From e2433e27d4fd5174d60d4fea1db2e2cde317b224 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 5 May 2026 15:50:42 +0200 Subject: [PATCH 5/9] test(e2e): Replace react-19-streaming with react-router-7-spa-streaming Switch to react-router-7-spa as the base app for React span streaming tests. This gives us pageload, navigation, and INP span tests instead of just error handling. Closes https://github.com/getsentry/sentry-javascript/issues/20671 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../react-19-streaming/public/index.html | 24 ------ .../react-19-streaming/src/index.tsx | 28 ------ .../react-19-streaming/src/pages/Index.jsx | 59 ------------- .../react-19-streaming/src/react-app-env.d.ts | 1 - .../react-19-streaming/tests/errors.test.ts | 70 --------------- .../tests/hoist-non-react-statics.test.ts | 45 ---------- .../react-19-streaming/tests/pageload.test.ts | 18 ---- .../.gitignore | 0 .../react-router-7-spa-streaming/index.html | 13 +++ .../package.json | 38 +++++---- .../playwright.config.mjs | 3 +- .../src/globals.d.ts | 0 .../react-router-7-spa-streaming/src/main.tsx | 57 +++++++++++++ .../src/pages/Index.tsx | 22 +++++ .../src/pages/SSE.tsx | 58 +++++++++++++ .../src/pages/User.tsx | 7 ++ .../start-event-proxy.mjs | 2 +- .../tests/errors.test.ts | 59 +++++++++++++ .../tests/transactions.test.ts | 85 +++++++++++++++++++ .../tsconfig.json | 3 +- .../vite.config.ts | 8 ++ 21 files changed, 335 insertions(+), 265 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/public/index.html delete mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/src/index.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/src/pages/Index.jsx delete mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/src/react-app-env.d.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/tests/errors.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/tests/hoist-non-react-statics.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts rename dev-packages/e2e-tests/test-applications/{react-19-streaming => react-router-7-spa-streaming}/.gitignore (100%) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/index.html rename dev-packages/e2e-tests/test-applications/{react-19-streaming => react-router-7-spa-streaming}/package.json (63%) rename dev-packages/e2e-tests/test-applications/{react-19-streaming => react-router-7-spa-streaming}/playwright.config.mjs (69%) rename dev-packages/e2e-tests/test-applications/{react-19-streaming => react-router-7-spa-streaming}/src/globals.d.ts (100%) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/main.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/Index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/SSE.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/pages/User.tsx rename dev-packages/e2e-tests/test-applications/{react-19-streaming => react-router-7-spa-streaming}/start-event-proxy.mjs (68%) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/transactions.test.ts rename dev-packages/e2e-tests/test-applications/{react-19-streaming => react-router-7-spa-streaming}/tsconfig.json (90%) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/public/index.html b/dev-packages/e2e-tests/test-applications/react-19-streaming/public/index.html deleted file mode 100644 index 68f33b623e38..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/public/index.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - React App - - - -
- - - diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-19-streaming/src/index.tsx deleted file mode 100644 index 25f52dfdf106..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/src/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import * as Sentry from '@sentry/react'; -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import Index from './pages/Index'; - -Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.REACT_APP_E2E_TEST_DSN, - release: 'e2e-test', - tunnel: 'http://localhost:3031/', // proxy server - tracesSampleRate: 1.0, - integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], -}); - -const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement, { - onUncaughtError: Sentry.reactErrorHandler((error, errorInfo) => { - console.warn(error, errorInfo); - }), - onCaughtError: Sentry.reactErrorHandler((error, errorInfo) => { - console.warn(error, errorInfo); - }), -}); - -root.render( -
- -
, -); diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/src/pages/Index.jsx b/dev-packages/e2e-tests/test-applications/react-19-streaming/src/pages/Index.jsx deleted file mode 100644 index d6ff558b9e34..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/src/pages/Index.jsx +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from 'react'; -import { withProfiler } from '@sentry/react'; - -function ProfilerTestComponent() { - return
withProfiler works
; -} -ProfilerTestComponent.customStaticMethod = () => 'static method works'; -const ProfiledComponent = withProfiler(ProfilerTestComponent); - -const Index = () => { - const [caughtError, setCaughtError] = React.useState(false); - const [uncaughtError, setUncaughtError] = React.useState(false); - - return ( - <> -
- - -

React 19

- {caughtError && } - -
-
-
- {uncaughtError && } - -
- - ); -}; - -function Throw({ error }) { - throw new Error(`${error} error`); -} - -class SampleErrorBoundary extends React.Component { - constructor(props) { - super(props); - this.state = { error: null }; - } - - componentDidCatch(error, errorInfo) { - this.setState({ error }); - // no-op - } - - render() { - if (this.state.error) { - return
Caught an error: {JSON.stringify(this.state.error)}
; - } - return this.props.children; - } -} - -export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-19-streaming/src/react-app-env.d.ts deleted file mode 100644 index 6431bc5fc6b2..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/src/react-app-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/errors.test.ts deleted file mode 100644 index a64d1073a304..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/errors.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('Catches errors caught by error boundary', async ({ page }) => { - page.on('console', message => { - expect(message.text()).toContain('caught error'); - }); - - const errorEventPromise = waitForError('react-19-streaming', event => { - return !event.type && event.exception?.values?.[0]?.value === 'caught error'; - }); - - await page.goto('/'); - - const exceptionButton = page.locator('id=caughtError-button'); - await exceptionButton.click(); - - const errorEvent = await errorEventPromise; - - expect(errorEvent.exception?.values).toHaveLength(2); - expect(errorEvent.exception?.values?.[0]?.value).toBe('caught error'); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ - type: 'auto.function.react.error_handler', - handled: true, // true because a callback was provided - exception_id: 1, - parent_id: 0, - source: 'cause', - }); - - expect(errorEvent.exception?.values?.[1]?.value).toBe('caught error'); - expect(errorEvent.exception?.values?.[1]?.mechanism).toEqual({ - type: 'generic', - handled: true, // true because a callback was provided - exception_id: 0, - }); -}); - -test('Catches errors uncaught by error boundary', async ({ page }) => { - page.on('console', message => { - expect(message.text()).toContain('uncaught error'); - }); - - const errorEventPromise = waitForError('react-19-streaming', event => { - return !event.type && event.exception?.values?.[0]?.value === 'uncaught error'; - }); - - await page.goto('/'); - - const exceptionButton = page.locator('id=uncaughtError-button'); - await exceptionButton.click(); - - const errorEvent = await errorEventPromise; - - expect(errorEvent.exception?.values).toHaveLength(2); - expect(errorEvent.exception?.values?.[0]?.value).toBe('uncaught error'); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ - type: 'auto.function.react.error_handler', - handled: true, // true because a callback was provided - exception_id: 1, - parent_id: 0, - source: 'cause', - }); - - expect(errorEvent.exception?.values?.[1]?.value).toBe('uncaught error'); - expect(errorEvent.exception?.values?.[1]?.mechanism).toEqual({ - type: 'generic', - handled: true, // true because a callback was provided - exception_id: 0, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/hoist-non-react-statics.test.ts b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/hoist-non-react-statics.test.ts deleted file mode 100644 index 1ecf2f2f6a82..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/hoist-non-react-statics.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('withProfiler does not throw Symbol conversion error when String() is patched to simulate minifier', async ({ - page, -}) => { - const errors: string[] = []; - - // Listen for any page errors (including the Symbol conversion error) - page.on('pageerror', error => { - errors.push(error.message); - }); - - // Listen for console errors - page.on('console', msg => { - if (msg.type() === 'error') { - errors.push(msg.text()); - } - }); - - await page.addInitScript(() => { - const OriginalString = String; - // @ts-expect-error - intentionally replacing String to simulate minifier behavior - window.String = function (value: unknown) { - if (typeof value === 'symbol') { - throw new TypeError('Cannot convert a Symbol value to a string'); - } - return OriginalString(value); - } as StringConstructor; - - Object.setPrototypeOf(window.String, OriginalString); - window.String.prototype = OriginalString.prototype; - window.String.fromCharCode = OriginalString.fromCharCode; - window.String.fromCodePoint = OriginalString.fromCodePoint; - window.String.raw = OriginalString.raw; - }); - - await page.goto('/'); - - const profilerTest = page.locator('#profiler-test'); - await expect(profilerTest).toBeVisible(); - await expect(profilerTest).toHaveText('withProfiler works'); - - const symbolErrors = errors.filter(e => e.includes('Cannot convert a Symbol value to a string')); - expect(symbolErrors).toHaveLength(0); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts b/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts deleted file mode 100644 index a300bdecc24a..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/tests/pageload.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; - -test('Sends a streamed pageload span', async ({ page }) => { - const spanPromise = waitForStreamedSpan('react-19-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.browser'); - expect(span.attributes?.['sentry.source']?.value).toBe('url'); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/.gitignore similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-19-streaming/.gitignore rename to dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/.gitignore 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-19-streaming/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json similarity index 63% rename from dev-packages/e2e-tests/test-applications/react-19-streaming/package.json rename to dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json index 452776a830d5..2ffe30d2a751 100644 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/package.json @@ -1,26 +1,30 @@ { - "name": "react-19-streaming-test-app", + "name": "react-router-7-spa-streaming", "version": "0.1.0", "private": true, "dependencies": { "@sentry/react": "file:../../packed/sentry-react-packed.tgz", - "history": "4.9.0", - "@types/history": "4.7.11", - "@types/node": "^18.19.1", - "@types/react": "npm:types-react@rc", - "@types/react-dom": "npm:types-react-dom@rc", - "react": "19.0.0-rc-935180c7e0-20240524", - "react-dom": "19.0.0-rc-935180c7e0-20240524", - "react-scripts": "5.0.1", + "@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": "react-scripts build", - "dev": "react-scripts start", - "start": "serve -s build", + "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-canary": "pnpm install && pnpm add react@canary react-dom@canary && pnpm build", "test:assert": "pnpm test" }, "eslintConfig": { @@ -41,12 +45,12 @@ "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" + }, + "pnpm": { + "overrides": { + "esbuild": "0.24.0" + } } } diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs similarity index 69% rename from dev-packages/e2e-tests/test-applications/react-19-streaming/playwright.config.mjs rename to dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs index 31f2b913b58b..7fda76df18ae 100644 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/playwright.config.mjs @@ -1,7 +1,8 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ - startCommand: `pnpm start`, + startCommand: `pnpm preview --port 3030`, + port: 3030, }); export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/globals.d.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-19-streaming/src/globals.d.ts rename to dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/src/globals.d.ts 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-19-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/start-event-proxy.mjs similarity index 68% rename from dev-packages/e2e-tests/test-applications/react-19-streaming/start-event-proxy.mjs rename to dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/start-event-proxy.mjs index 404433b90d8e..202974be53c4 100644 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'react-19-streaming', + 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/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/transactions.test.ts new file mode 100644 index 000000000000..6b7612ca6197 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/transactions.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForEnvelopeItem, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload transaction 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 transaction with a parameterized URL', async ({ page }) => { + 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 = waitForEnvelopeItem('react-router-7-spa-streaming', item => { + return item[0].type === 'span'; + }); + + 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[1]).toEqual({ + data: { + 'sentry.origin': 'auto.http.browser.inp', + 'sentry.op': 'ui.interaction.click', + release: 'e2e-test', + environment: 'qa', + transaction: '/', + 'sentry.exclusive_time': expect.any(Number), + replay_id: expect.any(String), + 'user_agent.original': expect.stringContaining('Chrome'), + 'client.address': '{{auto}}', + }, + description: 'body > div#root > input#exception-button[type="button"]', + op: 'ui.interaction.click', + 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.http.browser.inp', + exclusive_time: expect.any(Number), + measurements: { inp: { unit: 'millisecond', value: expect.any(Number) } }, + segment_id: expect.any(String), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-19-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json similarity index 90% rename from dev-packages/e2e-tests/test-applications/react-19-streaming/tsconfig.json rename to dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json index 4cc95dc2689a..7af258198f12 100644 --- a/dev-packages/e2e-tests/test-applications/react-19-streaming/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tsconfig.json @@ -14,7 +14,8 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react" + "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_', +}); From 89c9526eb0b998ccc4cc9059732e361c69808af3 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 5 May 2026 15:53:11 +0200 Subject: [PATCH 6/9] fix(e2e): Restore dropped sentryTest variants and console.log line Co-Authored-By: Claude Opus 4.6 (1M context) --- .../react-router-7-spa-streaming/package.json | 9 +++++++++ .../tests/transactions.test.ts | 1 + 2 files changed, 10 insertions(+) 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 index 2ffe30d2a751..4fddbfa60945 100644 --- 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 @@ -24,6 +24,7 @@ "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" }, @@ -48,6 +49,14 @@ "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/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/transactions.test.ts index 6b7612ca6197..4c948ed7ed2a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/transactions.test.ts @@ -18,6 +18,7 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = }); test('sends a navigation transaction 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; }); From 201a7e00ef4d4446f1d9c4150a0f60ed4c5a1544 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 5 May 2026 15:57:52 +0200 Subject: [PATCH 7/9] fix(e2e): Rename transactions.test.ts to spans.test.ts, update test names Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/{transactions.test.ts => spans.test.ts} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/{transactions.test.ts => spans.test.ts} (94%) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts similarity index 94% rename from dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts index 4c948ed7ed2a..4e39b26cc17f 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForEnvelopeItem, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { +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; }); @@ -17,7 +17,7 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = expect(span.attributes?.['sentry.source']?.value).toBe('route'); }); -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { +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; From 9cf6621c11596dd52a244758769854fd574c5e01 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 5 May 2026 16:05:41 +0200 Subject: [PATCH 8/9] fix(e2e): Fix INP test to use waitForStreamedSpan instead of waitForEnvelopeItem The old filter item[0].type === 'span' would match streamed pageload spans first, causing the test to resolve with the wrong data. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/spans.test.ts | 37 +++++-------------- 1 file changed, 10 insertions(+), 27 deletions(-) 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 index 4e39b26cc17f..0080a584463a 100644 --- 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 @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForEnvelopeItem, waitForStreamedSpan } from '@sentry-internal/test-utils'; +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 => { @@ -42,8 +42,8 @@ test('sends a navigation span with a parameterized URL', async ({ page }) => { }); test('sends an INP span', async ({ page }) => { - const inpSpanPromise = waitForEnvelopeItem('react-router-7-spa-streaming', item => { - return item[0].type === 'span'; + const inpSpanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => { + return getSpanOp(span) === 'ui.interaction.click'; }); await page.goto(`/`); @@ -59,28 +59,11 @@ test('sends an INP span', async ({ page }) => { const inpSpan = await inpSpanPromise; - expect(inpSpan[1]).toEqual({ - data: { - 'sentry.origin': 'auto.http.browser.inp', - 'sentry.op': 'ui.interaction.click', - release: 'e2e-test', - environment: 'qa', - transaction: '/', - 'sentry.exclusive_time': expect.any(Number), - replay_id: expect.any(String), - 'user_agent.original': expect.stringContaining('Chrome'), - 'client.address': '{{auto}}', - }, - description: 'body > div#root > input#exception-button[type="button"]', - op: 'ui.interaction.click', - 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.http.browser.inp', - exclusive_time: expect.any(Number), - measurements: { inp: { unit: 'millisecond', value: expect.any(Number) } }, - segment_id: expect.any(String), - }); + 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)); }); From c5c46657cc2fab3dd5f22c6121800df8ce470701 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 5 May 2026 16:14:01 +0200 Subject: [PATCH 9/9] abc