diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/.gitignore b/packages/e2e-tests/test-applications/react-create-hash-router/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/.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/packages/e2e-tests/test-applications/react-create-hash-router/.npmrc b/packages/e2e-tests/test-applications/react-create-hash-router/.npmrc new file mode 100644 index 000000000000..c6b3ef9b3eaa --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://localhost:4873 +@sentry-internal:registry=http://localhost:4873 diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/package.json b/packages/e2e-tests/test-applications/react-create-hash-router/package.json new file mode 100644 index 000000000000..bac46c9562d0 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/package.json @@ -0,0 +1,53 @@ +{ + "name": "react-create-hash-router-test", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "*", + "@testing-library/jest-dom": "5.14.1", + "@testing-library/react": "13.0.0", + "@testing-library/user-event": "13.2.1", + "@types/jest": "27.0.1", + "@types/node": "16.7.13", + "@types/react": "18.0.0", + "@types/react-dom": "18.0.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "^6.4.1", + "react-scripts": "5.0.1", + "typescript": "4.4.2", + "web-vitals": "2.1.0" + }, + "scripts": { + "build": "react-scripts build", + "start": "serve -s build", + "test": "playwright 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.26.1", + "axios": "1.1.2", + "serve": "14.0.1" + }, + "volta": { + "node": "16.19.0", + "yarn": "1.22.19" + } +} diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts b/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts new file mode 100644 index 000000000000..a24d7bc1c742 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts @@ -0,0 +1,70 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 60 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...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: Number(process.env.BASE_PORT) + Number(process.env.PORT_MODULO), + env: { + PORT: String(Number(process.env.BASE_PORT) + Number(process.env.PORT_MODULO)), + }, + }, +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/public/index.html b/packages/e2e-tests/test-applications/react-create-hash-router/public/index.html new file mode 100644 index 000000000000..39da76522bea --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/public/index.html @@ -0,0 +1,24 @@ + + + + + + + + React App + + + +
+ + + diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/globals.d.ts b/packages/e2e-tests/test-applications/react-create-hash-router/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/globals.d.ts @@ -0,0 +1,5 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + sentryReplayId?: string; +} diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx b/packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx new file mode 100644 index 000000000000..aef574bce3c4 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import * as Sentry from '@sentry/react'; +import { + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + RouterProvider, + createHashRouter, +} from 'react-router-dom'; +import Index from './pages/Index'; +import User from './pages/User'; + +const replay = new Sentry.Replay(); + +Sentry.init({ + // environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.REACT_APP_E2E_TEST_DSN, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.reactRouterV6Instrumentation( + 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, +}); + +Object.defineProperty(window, 'sentryReplayId', { + get() { + return replay['_replay'].session.id; + }, +}); + +Sentry.addGlobalEventProcessor(event => { + if ( + event.type === 'transaction' && + (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation') + ) { + const eventId = event.event_id; + if (eventId) { + window.recordedTransactions = window.recordedTransactions || []; + window.recordedTransactions.push(eventId); + } + } + + return event; +}); + +const sentryCreateHashRouter = Sentry.wrapCreateBrowserRouter(createHashRouter); + +const router = sentryCreateHashRouter([ + { + path: '/', + element: , + }, + { + path: '/user/:id', + element: , + }, +]); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); + +root.render(); diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx b/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx new file mode 100644 index 000000000000..2f683c63ed84 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import * as Sentry from '@sentry/react'; +import { Link } from 'react-router-dom'; + +const Index = () => { + return ( + <> + { + const eventId = Sentry.captureException(new Error('I am an error!')); + window.capturedExceptionId = eventId; + }} + /> + + navigate + + + ); +}; + +export default Index; diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/User.tsx b/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/User.tsx new file mode 100644 index 000000000000..671455a92fff --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/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/packages/e2e-tests/test-applications/react-create-hash-router/src/react-app-env.d.ts b/packages/e2e-tests/test-applications/react-create-hash-router/src/react-app-env.d.ts new file mode 100644 index 000000000000..6431bc5fc6b2 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/test-recipe.json b/packages/e2e-tests/test-applications/react-create-hash-router/test-recipe.json new file mode 100644 index 000000000000..7955a96ea1d0 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/test-recipe.json @@ -0,0 +1,19 @@ +{ + "$schema": "../../test-recipe-schema.json", + "testApplicationName": "react-create-hash-router", + "buildCommand": "pnpm install && npx playwright install && pnpm build", + "tests": [ + { + "testName": "Playwright tests", + "testCommand": "pnpm test" + } + ], + "canaryVersions": [ + { + "dependencyOverrides": { + "react": "latest", + "react-dom": "latest" + } + } + ] +} diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts b/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts new file mode 100644 index 000000000000..fb2d291dd70d --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts @@ -0,0 +1,254 @@ +import { test, expect } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; +import { ReplayRecordingData } from './fixtures/ReplayRecordingData'; + +const EVENT_POLLING_TIMEOUT = 30_000; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; + +test('Sends an exception to Sentry', async ({ page }) => { + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const exceptionIdHandle = await page.waitForFunction(() => window.capturedExceptionId); + const exceptionEventId = await exceptionIdHandle.jsonValue(); + + console.log(`Polling for error eventId: ${exceptionEventId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Sends a pageload transaction to Sentry', async ({ page }) => { + await page.goto('/'); + + const recordedTransactionsHandle = await page.waitForFunction(() => { + if (window.recordedTransactions && window.recordedTransactions?.length >= 1) { + return window.recordedTransactions; + } else { + return undefined; + } + }); + const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + + if (recordedTransactionEventIds === undefined) { + throw new Error("Application didn't record any transaction event IDs."); + } + + let hadPageLoadTransaction = false; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); + + await Promise.all( + recordedTransactionEventIds.map(async transactionEventId => { + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + if (response.data.contexts.trace.op === 'pageload') { + hadPageLoadTransaction = true; + } + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + }), + ); + + expect(hadPageLoadTransaction).toBe(true); +}); + +test('Sends a navigation transaction to Sentry', async ({ page }) => { + await page.goto('/'); + + // Give pageload transaction time to finish + page.waitForTimeout(4000); + + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const recordedTransactionsHandle = await page.waitForFunction(() => { + if (window.recordedTransactions && window.recordedTransactions?.length >= 2) { + return window.recordedTransactions; + } else { + return undefined; + } + }); + const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + + if (recordedTransactionEventIds === undefined) { + throw new Error("Application didn't record any transaction event IDs."); + } + + let hadPageNavigationTransaction = false; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); + + await Promise.all( + recordedTransactionEventIds.map(async transactionEventId => { + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + if (response.data.contexts.trace.op === 'navigation') { + hadPageNavigationTransaction = true; + } + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + }), + ); + + expect(hadPageNavigationTransaction).toBe(true); +}); + +test('Sends a Replay recording to Sentry', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto('/'); + + const replayId = await page.waitForFunction(() => { + return window.sentryReplayId; + }); + + // Wait for replay to be sent + + if (replayId === undefined) { + throw new Error("Application didn't set a replayId"); + } + + console.log(`Polling for replay with ID: ${replayId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/replays/${replayId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + + // now fetch the first recording segment + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/replays/${replayId}/recording-segments/?cursor=100%3A0%3A1`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return { + status: response.status, + data: response.data, + }; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toEqual({ + status: 200, + data: ReplayRecordingData, + }); +}); diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts b/packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts new file mode 100644 index 000000000000..a22694a64304 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts @@ -0,0 +1,265 @@ +import { expect } from '@playwright/test'; + +export const ReplayRecordingData = [ + [ + { + type: 4, + data: { href: expect.stringMatching(/http:\/\/localhost:\d+\//), width: 1280, height: 720 }, + timestamp: expect.any(Number), + }, + { + data: { + payload: { + blockAllMedia: true, + errorSampleRate: 0, + maskAllInputs: true, + maskAllText: true, + networkCaptureBodies: true, + networkDetailHasUrls: false, + networkRequestHasHeaders: true, + networkResponseHasHeaders: true, + sessionSampleRate: 1, + useCompression: false, + useCompressionOption: true, + }, + tag: 'options', + }, + timestamp: expect.any(Number), + type: 5, + }, + { + type: 2, + data: { + node: { + type: 0, + childNodes: [ + { type: 1, name: 'html', publicId: '', systemId: '', id: 2 }, + { + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { type: 2, tagName: 'meta', attributes: { charset: 'utf-8' }, childNodes: [], id: 5 }, + { + type: 2, + tagName: 'meta', + attributes: { name: 'viewport', content: 'width=device-width,initial-scale=1' }, + childNodes: [], + id: 6, + }, + { + type: 2, + tagName: 'meta', + attributes: { name: 'theme-color', content: '#000000' }, + childNodes: [], + id: 7, + }, + { + type: 2, + tagName: 'title', + attributes: {}, + childNodes: [{ type: 3, textContent: '***** ***', id: 9 }], + id: 8, + }, + ], + id: 4, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'noscript', + attributes: {}, + childNodes: [{ type: 3, textContent: '*** **** ** ****** ********** ** *** **** ****', id: 12 }], + id: 11, + }, + { type: 2, tagName: 'div', attributes: { id: 'root' }, childNodes: [], id: 13 }, + ], + id: 10, + }, + ], + id: 3, + }, + ], + id: 1, + }, + initialOffset: { left: 0, top: 0 }, + }, + timestamp: expect.any(Number), + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'memory', + description: 'memory', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + memory: { + jsHeapSizeLimit: expect.any(Number), + totalJSHeapSize: expect.any(Number), + usedJSHeapSize: expect.any(Number), + }, + }, + }, + }, + }, + { + type: 3, + data: { + source: 0, + texts: [], + attributes: [], + removes: [], + adds: [ + { + parentId: 13, + nextId: null, + node: { + type: 2, + tagName: 'a', + attributes: { id: 'navigation', href: expect.stringMatching(/http:\/\/localhost:\d+\/user\/5/) }, + childNodes: [], + id: 14, + }, + }, + { parentId: 14, nextId: null, node: { type: 3, textContent: '********', id: 15 } }, + { + parentId: 13, + nextId: 14, + node: { + type: 2, + tagName: 'input', + attributes: { type: 'button', id: 'exception-button', value: '******* *********' }, + childNodes: [], + id: 16, + }, + }, + ], + }, + timestamp: expect.any(Number), + }, + { + type: 3, + data: { source: 5, text: 'Capture Exception', isChecked: false, id: 16 }, + timestamp: expect.any(Number), + }, + ], + [ + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'navigation.navigate', + description: expect.stringMatching(/http:\/\/localhost:\d+\//), + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + decodedBodySize: expect.any(Number), + encodedBodySize: expect.any(Number), + duration: expect.any(Number), + domInteractive: expect.any(Number), + domContentLoadedEventEnd: expect.any(Number), + domContentLoadedEventStart: expect.any(Number), + loadEventStart: expect.any(Number), + loadEventEnd: expect.any(Number), + domComplete: expect.any(Number), + redirectCount: expect.any(Number), + size: expect.any(Number), + }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'resource.script', + description: expect.stringMatching(/http:\/\/localhost:\d+\/static\/js\/main.(\w+).js/), + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + decodedBodySize: expect.any(Number), + encodedBodySize: expect.any(Number), + size: expect.any(Number), + }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'largest-contentful-paint', + description: 'largest-contentful-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { value: expect.any(Number), size: expect.any(Number), nodeId: 16 }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'paint', + description: 'first-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'paint', + description: 'first-contentful-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'memory', + description: 'memory', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + memory: { + jsHeapSizeLimit: expect.any(Number), + totalJSHeapSize: expect.any(Number), + usedJSHeapSize: expect.any(Number), + }, + }, + }, + }, + }, + ], +]; diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/tsconfig.json b/packages/e2e-tests/test-applications/react-create-hash-router/tsconfig.json new file mode 100644 index 000000000000..c8df41dcf4b5 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "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"] +}