diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 74ccf23dc669..c87ef1782865 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1031,7 +1031,8 @@ jobs: 'node-experimental-fastify-app', 'node-hapi-app', 'node-exports-test-app', - 'node-profiling' + 'node-profiling', + 'vue-3' ] build-command: - false diff --git a/biome.json b/biome.json index c18c0720b6d1..ccb69e4746db 100644 --- a/biome.json +++ b/biome.json @@ -41,7 +41,9 @@ ".next/**", ".svelte-kit/**", ".angular/**", - "angular.json" + "angular.json", + "ember/instance-initializers/**", + "ember/types.d.ts" ] }, "files": { @@ -65,7 +67,9 @@ ".svelte-kit/**", ".angular/**", "angular.json", - "**/profiling-node/lib/**" + "**/profiling-node/lib/**", + "ember/instance-initializers/**", + "ember/types.d.ts" ] }, "javascript": { diff --git a/dev-packages/e2e-tests/test-applications/vue-3/.gitignore b/dev-packages/e2e-tests/test-applications/vue-3/.gitignore new file mode 100644 index 000000000000..8ee54e8d343e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo diff --git a/dev-packages/e2e-tests/test-applications/vue-3/.npmrc b/dev-packages/e2e-tests/test-applications/vue-3/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/vue-3/README.md b/dev-packages/e2e-tests/test-applications/vue-3/README.md new file mode 100644 index 000000000000..6af7bb60b866 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/README.md @@ -0,0 +1,3 @@ +# Vue 3 E2E Test App + +E2E test app for Vue 3 and `@sentry/vue`. diff --git a/dev-packages/e2e-tests/test-applications/vue-3/env.d.ts b/dev-packages/e2e-tests/test-applications/vue-3/env.d.ts new file mode 100644 index 000000000000..11f02fe2a006 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dev-packages/e2e-tests/test-applications/vue-3/event-proxy-server.ts b/dev-packages/e2e-tests/test-applications/vue-3/event-proxy-server.ts new file mode 100644 index 000000000000..4c2df32399f0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/event-proxy-server.ts @@ -0,0 +1,253 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import type { AddressInfo } from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import * as util from 'util'; +import * as zlib from 'zlib'; +import type { Envelope, EnvelopeItem, SerializedEvent } from '@sentry/types'; +import { parseEnvelope } from '@sentry/utils'; + +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + +interface EventProxyServerOptions { + /** Port to start the event proxy server at. */ + port: number; + /** The name for the proxy server used for referencing it with listener functions */ + proxyServerName: string; +} + +interface SentryRequestCallbackData { + envelope: Envelope; + rawProxyRequestBody: string; + rawSentryResponseBody: string; + sentryResponseStatusCode?: number; +} + +/** + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` + * option to this server (like this `tunnel: http://localhost:${port option}/`). + */ +export async function startEventProxyServer(options: EventProxyServerOptions): Promise { + const eventCallbackListeners: Set<(data: string) => void> = new Set(); + + const proxyServer = http.createServer((proxyRequest, proxyResponse) => { + const proxyRequestChunks: Uint8Array[] = []; + + proxyRequest.addListener('data', (chunk: Buffer) => { + proxyRequestChunks.push(chunk); + }); + + proxyRequest.addListener('error', err => { + throw err; + }); + + proxyRequest.addListener('end', () => { + const proxyRequestBody = + proxyRequest.headers['content-encoding'] === 'gzip' + ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() + : Buffer.concat(proxyRequestChunks).toString(); + + let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]); + + if (!envelopeHeader.dsn) { + throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.'); + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn); + + const projectId = pathname.substring(1); + const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; + + proxyRequest.headers.host = host; + + const sentryResponseChunks: Uint8Array[] = []; + + const sentryRequest = https.request( + sentryIngestUrl, + { headers: proxyRequest.headers, method: proxyRequest.method }, + sentryResponse => { + sentryResponse.addListener('data', (chunk: Buffer) => { + proxyResponse.write(chunk, 'binary'); + sentryResponseChunks.push(chunk); + }); + + sentryResponse.addListener('end', () => { + eventCallbackListeners.forEach(listener => { + const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString(); + + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody, new TextEncoder(), new TextDecoder()), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: sentryResponse.statusCode, + }; + + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + proxyResponse.end(); + }); + + sentryResponse.addListener('error', err => { + throw err; + }); + + proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); + }, + ); + + sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); + sentryRequest.end(); + }); + }); + + const proxyServerStartupPromise = new Promise(resolve => { + proxyServer.listen(options.port, () => { + resolve(); + }); + }); + + const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => { + eventCallbackResponse.statusCode = 200; + eventCallbackResponse.setHeader('connection', 'keep-alive'); + + const callbackListener = (data: string): void => { + eventCallbackResponse.write(data.concat('\n'), 'utf8'); + }; + + eventCallbackListeners.add(callbackListener); + + eventCallbackRequest.on('close', () => { + eventCallbackListeners.delete(callbackListener); + }); + + eventCallbackRequest.on('error', () => { + eventCallbackListeners.delete(callbackListener); + }); + }); + + const eventCallbackServerStartupPromise = new Promise(resolve => { + eventCallbackServer.listen(0, () => { + const port = String((eventCallbackServer.address() as AddressInfo).port); + void registerCallbackServerPort(options.proxyServerName, port).then(resolve); + }); + }); + + await eventCallbackServerStartupPromise; + await proxyServerStartupPromise; + return; +} + +export async function waitForRequest( + proxyServerName: string, + callback: (eventData: SentryRequestCallbackData) => Promise | boolean, +): Promise { + const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName); + + return new Promise((resolve, reject) => { + const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => { + let eventContents = ''; + + response.on('error', err => { + reject(err); + }); + + response.on('data', (chunk: Buffer) => { + const chunkString = chunk.toString('utf8'); + chunkString.split('').forEach(char => { + if (char === '\n') { + const eventCallbackData: SentryRequestCallbackData = JSON.parse( + Buffer.from(eventContents, 'base64').toString('utf8'), + ); + const callbackResult = callback(eventCallbackData); + if (typeof callbackResult !== 'boolean') { + callbackResult.then( + match => { + if (match) { + response.destroy(); + resolve(eventCallbackData); + } + }, + err => { + throw err; + }, + ); + } else if (callbackResult) { + response.destroy(); + resolve(eventCallbackData); + } + eventContents = ''; + } else { + eventContents = eventContents.concat(char); + } + }); + }); + }); + + request.end(); + }); +} + +export function waitForEnvelopeItem( + proxyServerName: string, + callback: (envelopeItem: EnvelopeItem) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForRequest(proxyServerName, async eventData => { + const envelopeItems = eventData.envelope[1]; + for (const envelopeItem of envelopeItems) { + if (await callback(envelopeItem)) { + resolve(envelopeItem); + return true; + } + } + return false; + }).catch(reject); + }); +} + +export function waitForError( + proxyServerName: string, + callback: (transactionEvent: SerializedEvent) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as SerializedEvent))) { + resolve(envelopeItemBody as SerializedEvent); + return true; + } + return false; + }).catch(reject); + }); +} + +export function waitForTransaction( + proxyServerName: string, + callback: (transactionEvent: SerializedEvent) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as SerializedEvent))) { + resolve(envelopeItemBody as SerializedEvent); + return true; + } + return false; + }).catch(reject); + }); +} + +const TEMP_FILE_PREFIX = 'event-proxy-server-'; + +async function registerCallbackServerPort(serverName: string, port: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + await writeFile(tmpFilePath, port, { encoding: 'utf8' }); +} + +function retrieveCallbackServerPort(serverName: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + return readFile(tmpFilePath, 'utf8'); +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/index.html b/dev-packages/e2e-tests/test-applications/vue-3/index.html new file mode 100644 index 000000000000..a888544898a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite App + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/package.json b/dev-packages/e2e-tests/test-applications/vue-3/package.json new file mode 100644 index 000000000000..1fa4cbcf3882 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -0,0 +1,42 @@ +{ + "name": "vue-3-tmp", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "clean": "npx rimraf node_modules,pnpm-lock.yaml,dist", + "dev": "vite", + "build": "run-p type-check \"build-only {@}\" --", + "preview": "vite preview", + "build-only": "vite build", + "type-check": "vue-tsc --build --force", + "test": "playwright test", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:assert": "playwright test" + }, + "dependencies": { + "@sentry/vue": "latest || *", + "vue": "^3.4.15", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@playwright/test": "^1.41.1", + "@sentry/types": "^7.99.0", + "@sentry/utils": "^7.99.0", + "@tsconfig/node20": "^20.1.2", + "@types/node": "^20.11.10", + "@vitejs/plugin-vue": "^5.0.3", + "@vitejs/plugin-vue-jsx": "^3.1.0", + "@vue/tsconfig": "^0.5.1", + "http-server": "^14.1.1", + "npm-run-all2": "^6.1.1", + "ts-node": "10.9.1", + "typescript": "~5.3.0", + "vite": "^5.0.11", + "vue-tsc": "^1.8.27", + "wait-port": "1.0.4" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/playwright.config.ts b/dev-packages/e2e-tests/test-applications/vue-3/playwright.config.ts new file mode 100644 index 000000000000..16dd640e58ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/playwright.config.ts @@ -0,0 +1,77 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +// Fix urls not resolving to localhost on Node v17+ +// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575 +import { setDefaultResultOrder } from 'dns'; +setDefaultResultOrder('ipv4first'); + +const testEnv = process.env['TEST_ENV'] || 'production'; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const vuePort = 4173; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 10000, + }, + fullyParallel: false, + workers: 1, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* `next dev` is incredibly buggy with the app dir */ + retries: testEnv === 'development' ? 3 : 0, + /* 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, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${vuePort}`, + + /* 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'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'pnpm ts-node-script --project tsconfig.proxy.json start-event-proxy.ts', + port: eventProxyPort, + }, + { + command: + testEnv === 'development' + ? `pnpm wait-port ${eventProxyPort} && pnpm preview --port ${vuePort}` + : `pnpm wait-port ${eventProxyPort} && pnpm preview --port ${vuePort}`, + port: vuePort, + }, + ], +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/vue-3/public/favicon.ico b/dev-packages/e2e-tests/test-applications/vue-3/public/favicon.ico new file mode 100644 index 000000000000..df36fcfb7258 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/vue-3/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/App.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/App.vue new file mode 100644 index 000000000000..08c38cecfda9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/App.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/assets/base.css b/dev-packages/e2e-tests/test-applications/vue-3/src/assets/base.css new file mode 100644 index 000000000000..8816868a41b6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/assets/base.css @@ -0,0 +1,86 @@ +/* color palette from */ +:root { + --vt-c-white: #ffffff; + --vt-c-white-soft: #f8f8f8; + --vt-c-white-mute: #f2f2f2; + + --vt-c-black: #181818; + --vt-c-black-soft: #222222; + --vt-c-black-mute: #282828; + + --vt-c-indigo: #2c3e50; + + --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); + --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); + --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); + --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); + + --vt-c-text-light-1: var(--vt-c-indigo); + --vt-c-text-light-2: rgba(60, 60, 60, 0.66); + --vt-c-text-dark-1: var(--vt-c-white); + --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); +} + +/* semantic color variables for this project */ +:root { + --color-background: var(--vt-c-white); + --color-background-soft: var(--vt-c-white-soft); + --color-background-mute: var(--vt-c-white-mute); + + --color-border: var(--vt-c-divider-light-2); + --color-border-hover: var(--vt-c-divider-light-1); + + --color-heading: var(--vt-c-text-light-1); + --color-text: var(--vt-c-text-light-1); + + --section-gap: 160px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-background: var(--vt-c-black); + --color-background-soft: var(--vt-c-black-soft); + --color-background-mute: var(--vt-c-black-mute); + + --color-border: var(--vt-c-divider-dark-2); + --color-border-hover: var(--vt-c-divider-dark-1); + + --color-heading: var(--vt-c-text-dark-1); + --color-text: var(--vt-c-text-dark-2); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + transition: + color 0.5s, + background-color 0.5s; + line-height: 1.6; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Fira Sans', + 'Droid Sans', + 'Helvetica Neue', + sans-serif; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/assets/logo.svg b/dev-packages/e2e-tests/test-applications/vue-3/src/assets/logo.svg new file mode 100644 index 000000000000..7565660356e5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/assets/logo.svg @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/assets/main.css b/dev-packages/e2e-tests/test-applications/vue-3/src/assets/main.css new file mode 100644 index 000000000000..36fb845b5232 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/assets/main.css @@ -0,0 +1,35 @@ +@import './base.css'; + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + font-weight: normal; +} + +a, +.green { + text-decoration: none; + color: hsla(160, 100%, 37%, 1); + transition: 0.4s; + padding: 3px; +} + +@media (hover: hover) { + a:hover { + background-color: hsla(160, 100%, 37%, 0.2); + } +} + +@media (min-width: 1024px) { + body { + display: flex; + place-items: center; + } + + #app { + display: grid; + grid-template-columns: 1fr 1fr; + padding: 0 2rem; + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts new file mode 100644 index 000000000000..503a9e44d14f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts @@ -0,0 +1,25 @@ +import './assets/main.css'; + +import { createApp } from 'vue'; +import App from './App.vue'; +import router from './router'; + +import * as Sentry from '@sentry/vue'; + +const app = createApp(App); + +Sentry.init({ + app, + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, + tracesSampleRate: 1.0, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.vueRouterInstrumentation(router), + }), + ], + tunnel: `http://localhost:3031/`, // proxy server + debug: true, +}); + +app.use(router); +app.mount('#app'); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts new file mode 100644 index 000000000000..a17208711eff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts @@ -0,0 +1,23 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import HomeView from '../views/HomeView.vue'; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + component: HomeView, + }, + { + path: '/about', + name: 'AboutView', + component: () => import('../views/AboutView.vue'), + }, + { + path: '/users/:id', + component: () => import('../views/UserIdView.vue'), + }, + ], +}); + +export default router; diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/AboutView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/AboutView.vue new file mode 100644 index 000000000000..8c706352120a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/AboutView.vue @@ -0,0 +1,3 @@ + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/HomeView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/HomeView.vue new file mode 100644 index 000000000000..92b38c308a6d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/HomeView.vue @@ -0,0 +1,14 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/UserIdView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/UserIdView.vue new file mode 100644 index 000000000000..a6c973ef6e35 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/UserIdView.vue @@ -0,0 +1,3 @@ + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/start-event-proxy.ts b/dev-packages/e2e-tests/test-applications/vue-3/start-event-proxy.ts new file mode 100644 index 000000000000..6435984ad069 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/start-event-proxy.ts @@ -0,0 +1,6 @@ +import { startEventProxyServer } from './event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'vue-3', +}); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/errors.test.ts new file mode 100644 index 000000000000..508fe738bbc5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '../event-proxy-server'; + +test('sends an error', async ({ page }) => { + const errorPromise = waitForError('vue-3', async errorEvent => { + return !errorEvent?.transaction; + }); + + await page.goto(`/`); + + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'This is a Vue test error', + mechanism: { + type: 'generic', + handled: false, + }, + }, + ], + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts new file mode 100644 index 000000000000..732ec98a54f4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts @@ -0,0 +1,102 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '../event-proxy-server'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('vue-3', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/users/456`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + params: { + id: '456', + }, + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.vue', + 'sentry.op': 'pageload', + }, + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + transaction: '/users/:id', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('vue-3', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('vue-3', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + + const [_, navigationTxn] = await Promise.all([page.locator('#navLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + data: { + params: { + id: '123', + }, + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.vue', + 'sentry.op': 'navigation', + }, + op: 'navigation', + origin: 'auto.navigation.vue', + }, + }, + transaction: '/users/:id', + transaction_info: { + // So this is weird. The source is set to custom although the route doesn't have a name. + // This also only happens during a navigation. A pageload will set the source as 'route'. + // TODO: Figure out what's going on here. + source: 'custom', + }, + }); +}); + +test('sends a pageload transaction with a route name as transaction name if available', async ({ page }) => { + const transactionPromise = waitForTransaction('vue-3', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/about`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'custom', + 'sentry.origin': 'auto.pageload.vue', + 'sentry.op': 'pageload', + }, + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + transaction: 'AboutView', + transaction_info: { + source: 'custom', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.app.json b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.app.json new file mode 100644 index 000000000000..e14c754d3ae5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.json b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.json new file mode 100644 index 000000000000..78f134a16dca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.app.json" + } + ], +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.node.json new file mode 100644 index 000000000000..2c669eeb8e8a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*"], + "compilerOptions": { + "composite": true, + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.proxy.json b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.proxy.json new file mode 100644 index 000000000000..7ccdde196a3b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tsconfig.proxy.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "moduleResolution": "Node", + "target": "ES2022", + "module": "ES2022", + }, + "ts-node": { + "esm": true, + "experimentalSpecifierResolution": "node", + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/vue-3/vite.config.ts new file mode 100644 index 000000000000..72a15caeae52 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/vite.config.ts @@ -0,0 +1,16 @@ +import { URL, fileURLToPath } from 'node:url'; + +import vue from '@vitejs/plugin-vue'; +import vueJsx from '@vitejs/plugin-vue-jsx'; +import { defineConfig } from 'vite'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue(), vueJsx()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + envPrefix: 'PUBLIC_', +});