From a30f15c83f2522ede7d2f5789f1fef457ddece0d Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:46:35 +0200 Subject: [PATCH] test(vue): Add test cases for using Options API (mixins) --- .../test-applications/vue-3/package.json | 7 + .../vue-3/tests/performance.test.ts | 128 +++++------ .../test-applications/vue-3/vite.config.ts | 6 + .../integration/mixinRegistration.test.ts | 214 ++++++++++++++++++ 4 files changed, 285 insertions(+), 70 deletions(-) create mode 100644 packages/vue/test/integration/mixinRegistration.test.ts diff --git a/dev-packages/e2e-tests/test-applications/vue-3/package.json b/dev-packages/e2e-tests/test-applications/vue-3/package.json index 7367355d8f1e..be087c8d397d 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -15,6 +15,8 @@ "test:assert": "pnpm test:print-version && playwright test", "test:build-canary": "pnpm install && pnpm test:install-canary && pnpm build", "test:build-latest": "pnpm install && pnpm add vue@latest && pnpm build", + "test:build-no-options-api": "pnpm install && VUE_OPTIONS_API=false pnpm build", + "test:assert-no-options-api": "VUE_OPTIONS_API=false pnpm test:assert", "test:install-canary": "pnpm add vue@$(git ls-remote --tags --sort='v:refname' https://github.com/vuejs/core.git | tail -n1 | awk -F'/' '{print $NF}')", "test:print-version": "node -p \"'Vue version: ' + require('vue/package.json').version\"" }, @@ -52,6 +54,11 @@ { "build-command": "pnpm test:build-canary", "label": "vue-3 (canary)" + }, + { + "build-command": "pnpm test:build-no-options-api", + "assert-command": "pnpm test:assert-no-options-api", + "label": "vue-3 (no Options API)" } ] } 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 index 8570b1a04bb8..876a154cdecb 100644 --- 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 @@ -1,6 +1,9 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +// Set by the `assert-command` of the `vue-3 (no Options API)` variant +const OPTIONS_API_DISABLED = process.env.VUE_OPTIONS_API === 'false'; + 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'; @@ -135,7 +138,10 @@ test('sends a pageload transaction with a route name as transaction name if avai }); }); -test('sends a lifecycle span for each tracked components', async ({ page }) => { +test('sends a lifecycle span for the root and for each tracked component only', async ({ page }) => { + // Vue compiles `app.mixin()` down to a no-op when the Options API is disabled, so the SDK creates no UI spans at all. + test.fail(OPTIONS_API_DISABLED, 'Vue tracing is registered through app.mixin(), which needs the Options API'); + const transactionPromise = waitForTransaction('vue-3', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; }); @@ -144,79 +150,61 @@ test('sends a lifecycle span for each tracked components', async ({ page }) => { const rootSpan = await transactionPromise; - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.source': 'route', - 'sentry.origin': 'auto.pageload.vue', - 'sentry.op': 'pageload', - 'url.template': '/components', - 'url.path': '/components', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/components$/), - }, - op: 'pageload', - origin: 'auto.pageload.vue', - }, + const uiSpans = (rootSpan.spans || []).filter(span => span.origin === 'auto.ui.vue'); + const uiSpanDescriptions = uiSpans.map(span => span.description).sort(); + + expect(uiSpanDescriptions).toEqual([ + 'Application Render', + 'Vue ', + 'Vue ', + 'Vue ', + ]); + + // enabled by default + const applicationRenderSpan = uiSpans.find(span => span.description === 'Application Render'); + expect(applicationRenderSpan).toMatchObject({ + data: { + 'sentry.op': 'ui.render', + 'sentry.origin': 'auto.ui.vue', }, - spans: expect.arrayContaining([ - // enabled by default - expect.objectContaining({ - data: { - 'sentry.op': 'ui.render', - 'sentry.origin': 'auto.ui.vue', - }, - description: 'Application Render', - op: 'ui.render', - origin: 'auto.ui.vue', - }), - // enabled by default - expect.objectContaining({ - data: { - 'sentry.op': 'ui.mount', - 'sentry.origin': 'auto.ui.vue', - }, - description: 'Vue ', - op: 'ui.mount', - origin: 'auto.ui.vue', - }), + op: 'ui.render', + origin: 'auto.ui.vue', + }); - // without `<>` - expect.objectContaining({ - data: { - 'sentry.op': 'ui.mount', - 'sentry.origin': 'auto.ui.vue', - }, - description: 'Vue ', - op: 'ui.mount', - origin: 'auto.ui.vue', - }), + // enabled by default + const rootComponentSpan = uiSpans.find(span => span.description === 'Vue '); + expect(rootComponentSpan).toMatchObject({ + data: { + 'sentry.op': 'ui.mount', + 'sentry.origin': 'auto.ui.vue', + }, + op: 'ui.mount', + origin: 'auto.ui.vue', + }); - // with `<>` - expect.objectContaining({ - data: { - 'sentry.op': 'ui.mount', - 'sentry.origin': 'auto.ui.vue', - }, - description: 'Vue ', - op: 'ui.mount', - origin: 'auto.ui.vue', - }), + // without `<>` + const componentMainViewSpan = uiSpans.find(span => span.description === 'Vue '); + expect(componentMainViewSpan).toMatchObject({ + data: { + 'sentry.op': 'ui.mount', + 'sentry.origin': 'auto.ui.vue', + }, + op: 'ui.mount', + origin: 'auto.ui.vue', + }); - // not tracked - expect.not.objectContaining({ - data: { - 'sentry.op': 'ui.mount', - 'sentry.origin': 'auto.ui.vue', - }, - description: 'Vue ', - op: 'ui.mount', - origin: 'auto.ui.vue', - }), - ]), - transaction: '/components', - transaction_info: { - source: 'route', + // with `<>` + const componentOneViewSpan = uiSpans.find(span => span.description === 'Vue '); + expect(componentOneViewSpan).toMatchObject({ + data: { + 'sentry.op': 'ui.mount', + 'sentry.origin': 'auto.ui.vue', }, + op: 'ui.mount', + origin: 'auto.ui.vue', }); + + // `ComponentTwoView` renders on this route but is absent from `trackComponents` + // not tracked + expect(uiSpanDescriptions).not.toContain('Vue '); }); 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 index 72a15caeae52..e385ddeaadee 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/vite.config.ts @@ -4,9 +4,15 @@ import vue from '@vitejs/plugin-vue'; import vueJsx from '@vitejs/plugin-vue-jsx'; import { defineConfig } from 'vite'; +// Nuxt 5 disables the Options API by default (users can disable it too for smaller bundle size) +const optionsApi = process.env.VUE_OPTIONS_API === 'false' ? 'false' : 'true'; + // https://vitejs.dev/config/ export default defineConfig({ plugins: [vue(), vueJsx()], + define: { + __VUE_OPTIONS_API__: optionsApi, + }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts new file mode 100644 index 000000000000..38fc3383783b --- /dev/null +++ b/packages/vue/test/integration/mixinRegistration.test.ts @@ -0,0 +1,214 @@ +/** + * @vitest-environment jsdom + */ + +import { spanToJSON } from '@sentry/core'; +import { describe, expect, it as baseIt, vi } from 'vitest'; +import type { App } from 'vue'; +import { createApp, h } from 'vue'; +import * as Sentry from '../../src'; +import type { Options, TracingOptions } from '../../src/types'; + +const PUBLIC_DSN = 'https://username@domain/123'; +const ROOT_SPAN_TIMEOUT_MS = 100; + +// Not imported from `@sentry/conventions` to catch changed OPs +const SENTRY_OP_ATTRIBUTE = 'sentry.op'; +const SENTRY_ORIGIN_ATTRIBUTE = 'sentry.origin'; +const VUE_SPAN_ORIGIN = 'auto.ui.vue'; +const UI_MOUNT_SPAN_OP = 'ui.mount'; +const UI_RENDER_SPAN_OP = 'ui.render'; + +interface UiSpan { + name: string; + op: unknown; +} + +/** + * A two-component app where `` renders ``. Both use render functions, so the + * tree mounts the same way with and without the Options API. + */ +function createTestApp(): App { + const child = { name: 'ChildComponent', render: () => h('p', 'child') }; + return createApp({ name: 'RootComponent', render: () => h('div', [h(child)]) }); +} + +/** Reads the mixins Vue accepted. `app.mixin()` is a silent no-op without the Options API. */ +function getRegisteredMixins(app: App): unknown[] { + return (app as unknown as { _context: { mixins: unknown[] } })._context.mixins; +} + +/** Mounts `app` inside an active root span, because the Vue UI spans use `onlyIfParent`. */ +function mountUnderActiveSpan(app: App): HTMLElement { + const container = document.createElement('div'); + + Sentry.startSpan({ name: 'pageload' }, () => { + app.mount(container); + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS + 1); + }); + + return container; +} + +interface Fixtures { + fakeTimers: void; + app: App; + /** Vue UI spans that ended, in the order they ended. `initSentry` starts the recording. */ + uiSpans: UiSpan[]; + initSentry: (overrides?: { tracing?: Partial; sdk?: Partial }) => void; +} + +// Vitest reads the destructured keys of the first parameter to resolve fixture dependencies, so a +// fixture without dependencies has to spell out an empty pattern. +/* oxlint-disable no-empty-pattern */ +const it = baseIt.extend({ + // The root render span ends on a debounce, so the tests need to drive the clock. + fakeTimers: [ + async ({}, use) => { + vi.useFakeTimers(); + await use(); + vi.useRealTimers(); + }, + { auto: true }, + ], + + app: async ({}, use) => { + await use(createTestApp()); + }, + + uiSpans: async ({}, use) => { + await use([]); + }, + + initSentry: async ({ app, uiSpans }, use) => { + await use(({ tracing, sdk } = {}) => { + const client = Sentry.init({ + dsn: PUBLIC_DSN, + defaultIntegrations: false, + traceLifecycle: 'static', + tracesSampleRate: 1, + app, + integrations: [Sentry.vueIntegration({ tracingOptions: { timeout: ROOT_SPAN_TIMEOUT_MS, ...tracing } })], + ...sdk, + }); + + client?.on('spanEnd', span => { + const { name, attributes } = spanToJSON(span); + if (attributes?.[SENTRY_ORIGIN_ATTRIBUTE] === VUE_SPAN_ORIGIN) { + uiSpans.push({ name, op: attributes[SENTRY_OP_ATTRIBUTE] }); + } + }); + }); + }, +}); +/* oxlint-enable no-empty-pattern */ + +describe('tracing mixin registration', () => { + it('registers the tracing mixin on the app when tracing is enabled', ({ app, initSentry }) => { + initSentry(); + + expect(getRegisteredMixins(app)).toHaveLength(1); + }); + + it('registers no mixin when tracing is disabled', ({ app, initSentry }) => { + initSentry({ sdk: { tracesSampleRate: undefined } }); + + expect(getRegisteredMixins(app)).toEqual([]); + }); + + it('registers the tracing mixin on every app when an array of apps is passed', ({ initSentry }) => { + const firstApp = createTestApp(); + const secondApp = createTestApp(); + + initSentry({ sdk: { app: [firstApp, secondApp] } }); + + expect(getRegisteredMixins(firstApp)).toHaveLength(1); + expect(getRegisteredMixins(secondApp)).toHaveLength(1); + }); + + it('registers the tracing mixin on the constructor passed as `Vue` (Vue 2 setup)', ({ app, initSentry }) => { + initSentry({ sdk: { app: undefined, Vue: app } }); + + expect(getRegisteredMixins(app)).toHaveLength(1); + }); +}); + +describe('tracing mixin span creation', () => { + it('creates a root render span and a root component span when trackComponents is off', ({ + app, + uiSpans, + initSentry, + }) => { + initSentry(); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + it('creates a component span for a name listed in trackComponents', ({ app, uiSpans, initSentry }) => { + initSentry({ tracing: { trackComponents: ['ChildComponent'] } }); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + it('creates no component span for a name missing from trackComponents', ({ app, uiSpans, initSentry }) => { + initSentry({ tracing: { trackComponents: ['SomeOtherComponent'] } }); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + // Vue 3 compiles `app.mixin()` down to a no-op returning the app when the `__VUE_OPTIONS_API__` + // build flag is `false`. Nuxt 5 sets that flag by default (nuxt/nuxt#35791), so this stub matches + // what those users run. The real build is covered by the `vue-3 (no Options API)` e2e variant. + describe('when the Options API is disabled', () => { + function disableOptionsApi(app: App): void { + app.mixin = () => app; + } + + // Drop `.fails` once tracing no longer depends on `app.mixin()`. Vitest then reports this as a + // failure, which is the signal to delete the modifier. + it.fails('creates the same UI spans as with the Options API enabled', ({ app, uiSpans, initSentry }) => { + disableOptionsApi(app); + initSentry(); + + mountUnderActiveSpan(app); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + + it('mounts the component tree', ({ app, initSentry }) => { + disableOptionsApi(app); + initSentry(); + + const container = mountUnderActiveSpan(app); + + expect(container.innerHTML).toBe('

child

'); + }); + + it('attaches the Vue error handler', ({ app, initSentry }) => { + disableOptionsApi(app); + + initSentry(); + + expect(app.config.errorHandler).toBeDefined(); + }); + }); +});