Skip to content

Commit 214be84

Browse files
committed
test(vue/e2e): Add tests for Vue UI spans
1 parent 1a1b643 commit 214be84

5 files changed

Lines changed: 217 additions & 7 deletions

File tree

‎dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,45 @@ test('sends a navigation root span with a parameterized URL', async ({ page }) =
6969
});
7070
});
7171

72+
test('sends an application render span and a root component span on pageload', async ({ page }) => {
73+
const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => {
74+
return transactionEvent.transaction === '/client-error' && transactionEvent.contexts?.trace?.op === 'pageload';
75+
});
76+
77+
await page.goto(`/client-error`);
78+
79+
const rootSpan = await transactionPromise;
80+
const uiSpans = rootSpan.spans.filter(span => span.origin === 'auto.ui.vue');
81+
82+
const applicationRenderSpans = uiSpans.filter(span => span.description === 'Application Render');
83+
expect(applicationRenderSpans).toHaveLength(1);
84+
expect(applicationRenderSpans[0]).toMatchObject({
85+
data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.render' },
86+
description: 'Application Render',
87+
op: 'ui.render',
88+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
89+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
90+
start_timestamp: expect.any(Number),
91+
timestamp: expect.any(Number),
92+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
93+
origin: 'auto.ui.vue',
94+
});
95+
96+
const rootComponentSpans = uiSpans.filter(span => span.description === 'Vue <Root>');
97+
expect(rootComponentSpans).toHaveLength(1);
98+
expect(rootComponentSpans[0]).toMatchObject({
99+
data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.mount' },
100+
description: 'Vue <Root>',
101+
op: 'ui.mount',
102+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
103+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
104+
start_timestamp: expect.any(Number),
105+
timestamp: expect.any(Number),
106+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
107+
origin: 'auto.ui.vue',
108+
});
109+
});
110+
72111
test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => {
73112
const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => {
74113
return transactionEvent.transaction === '/client-error';

‎dev-packages/e2e-tests/test-applications/nuxt-5/nuxt.config.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,6 @@ export default defineNuxtConfig({
33
compatibilityDate: '2025-07-15',
44
imports: { autoImport: false },
55

6-
// FIXME: Remove once component tracking no longer relies on Vue's Options API.
7-
// Nuxt 5 disables it by default: https://github.com/nuxt/nuxt/pull/35791
8-
vue: { optionsApi: true },
9-
106
routeRules: {
117
'/rendering-modes/client-side-only-page': { ssr: false },
128
'/rendering-modes/isr-cached-page': { isr: true },

‎dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,12 @@ test('sends a navigation root span with a parameterized URL', async ({ page }) =
6969
});
7070
});
7171

72-
// fixme: note that this test only works because we explictly enabled Vue’s Options API in the nuxt config
7372
test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => {
73+
// Nuxt 5 disables the Options API by default (nuxt/nuxt#35791), which turns `app.mixin()` into a
74+
// no-op, and that mixin is where the SDK creates every UI span. Flips to passing once component
75+
// tracking works without it.
76+
test.fail(true, 'Vue tracing is registered through app.mixin(), which needs the Options API');
77+
7478
const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => {
7579
return transactionEvent.transaction === '/client-error';
7680
});
@@ -94,3 +98,36 @@ test('sends component tracking spans when `trackComponents` is enabled', async (
9498

9599
expect(errorButtonSpan).toMatchObject(expected);
96100
});
101+
102+
test('sends an application render span and a root component span on pageload', async ({ page }) => {
103+
// Same root cause as above: no Options API, no `app.mixin()`, no UI spans. Flips to passing once
104+
// the root spans stop depending on the mixin.
105+
test.fail(true, 'Vue tracing is registered through app.mixin(), which needs the Options API');
106+
107+
const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => {
108+
return transactionEvent.transaction === '/client-error' && transactionEvent.contexts?.trace?.op === 'pageload';
109+
});
110+
111+
await page.goto(`/client-error`);
112+
113+
const rootSpan = await transactionPromise;
114+
const uiSpans = rootSpan.spans.filter(span => span.origin === 'auto.ui.vue');
115+
116+
const applicationRenderSpans = uiSpans.filter(span => span.description === 'Application Render');
117+
expect(applicationRenderSpans).toHaveLength(1);
118+
expect(applicationRenderSpans[0]).toMatchObject({
119+
data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.render' },
120+
description: 'Application Render',
121+
op: 'ui.render',
122+
origin: 'auto.ui.vue',
123+
});
124+
125+
const rootComponentSpans = uiSpans.filter(span => span.description === 'Vue <Root>');
126+
expect(rootComponentSpans).toHaveLength(1);
127+
expect(rootComponentSpans[0]).toMatchObject({
128+
data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.mount' },
129+
description: 'Vue <Root>',
130+
op: 'ui.mount',
131+
origin: 'auto.ui.vue',
132+
});
133+
});

‎dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,69 @@ test('sends a pageload transaction with a route name as transaction name if avai
138138
});
139139
});
140140

141+
// The root component is always tracked, even when it is missing from `trackComponents`. `/` mounts
142+
// the eagerly imported `HomeView` synchronously, `/components` loads its view through a dynamic
143+
// `import()`. The root spans have to show up with both timings.
144+
[
145+
{ route: '/', transaction: '/', description: 'a route with a synchronously mounted component' },
146+
{ route: '/components', transaction: '/components', description: 'a route with an async component' },
147+
].forEach(({ route, transaction, description }) => {
148+
test(`sends an application render span and a root component span on ${description}`, async ({ page }) => {
149+
// Vue compiles `app.mixin()` down to a no-op when the Options API is disabled, so the SDK creates no UI spans at all.
150+
test.fail(OPTIONS_API_DISABLED, 'Vue tracing is registered through app.mixin(), which needs the Options API');
151+
152+
const transactionPromise = waitForTransaction('vue-3', async transactionEvent => {
153+
return transactionEvent.transaction === transaction && transactionEvent.contexts?.trace?.op === 'pageload';
154+
});
155+
156+
await page.goto(route);
157+
158+
const rootSpan = await transactionPromise;
159+
const uiSpans = (rootSpan.spans || []).filter(span => span.origin === 'auto.ui.vue');
160+
161+
const applicationRenderSpans = uiSpans.filter(span => span.description === 'Application Render');
162+
expect(applicationRenderSpans).toHaveLength(1);
163+
expect(applicationRenderSpans[0]).toMatchObject({
164+
data: {
165+
'sentry.op': 'ui.render',
166+
'sentry.origin': 'auto.ui.vue',
167+
},
168+
op: 'ui.render',
169+
origin: 'auto.ui.vue',
170+
});
171+
172+
const rootComponentSpans = uiSpans.filter(span => span.description === 'Vue <Root>');
173+
expect(rootComponentSpans).toHaveLength(1);
174+
expect(rootComponentSpans[0]).toMatchObject({
175+
data: {
176+
'sentry.op': 'ui.mount',
177+
'sentry.origin': 'auto.ui.vue',
178+
},
179+
op: 'ui.mount',
180+
origin: 'auto.ui.vue',
181+
});
182+
});
183+
});
184+
185+
// `HomeView` renders on `/` but is missing from `trackComponents`, so only the two root spans exist.
186+
test('sends no component spans on a route without tracked components', async ({ page }) => {
187+
test.fail(OPTIONS_API_DISABLED, 'Vue tracing is registered through app.mixin(), which needs the Options API');
188+
189+
const transactionPromise = waitForTransaction('vue-3', async transactionEvent => {
190+
return transactionEvent.transaction === '/' && transactionEvent.contexts?.trace?.op === 'pageload';
191+
});
192+
193+
await page.goto(`/`);
194+
195+
const rootSpan = await transactionPromise;
196+
const uiSpanDescriptions = (rootSpan.spans || [])
197+
.filter(span => span.origin === 'auto.ui.vue')
198+
.map(span => span.description)
199+
.sort();
200+
201+
expect(uiSpanDescriptions).toEqual(['Application Render', 'Vue <Root>']);
202+
});
203+
141204
test('sends a lifecycle span for the root and for each tracked component only', async ({ page }) => {
142205
// Vue compiles `app.mixin()` down to a no-op when the Options API is disabled, so the SDK creates no UI spans at all.
143206
test.fail(OPTIONS_API_DISABLED, 'Vue tracing is registered through app.mixin(), which needs the Options API');

‎packages/vue/test/integration/mixinRegistration.test.ts‎

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import { spanToJSON } from '@sentry/core';
66
import type { MockInstance } from 'vitest';
77
import { afterEach, beforeEach, describe, expect, it as baseIt, vi } from 'vitest';
8-
import type { App } from 'vue';
9-
import { createApp, h } from 'vue';
8+
import type { App, Ref } from 'vue';
9+
import { createApp, h, nextTick, ref } from 'vue';
1010
import * as Sentry from '../../src';
1111
import type { Options, TracingOptions } from '../../src/types';
1212

@@ -19,6 +19,7 @@ const SENTRY_ORIGIN_ATTRIBUTE = 'sentry.origin';
1919
const VUE_SPAN_ORIGIN = 'auto.ui.vue';
2020
const UI_MOUNT_SPAN_OP = 'ui.mount';
2121
const UI_RENDER_SPAN_OP = 'ui.render';
22+
const UI_UPDATE_SPAN_OP = 'ui.update';
2223

2324
interface UiSpan {
2425
name: string;
@@ -34,6 +35,14 @@ function createTestApp(): App {
3435
return createApp({ name: 'RootComponent', render: () => h('div', [h(child)]) });
3536
}
3637

38+
/** Like `createTestApp`, but the root render reads a ref, so mutating it re-renders the root. */
39+
function createReactiveTestApp(): { app: App; message: Ref<string> } {
40+
const message = ref('initial message');
41+
const child = { name: 'ChildComponent', render: () => h('p', 'child') };
42+
const app = createApp({ name: 'RootComponent', render: () => h('div', [h('span', message.value), h(child)]) });
43+
return { app, message };
44+
}
45+
3746
/** Reads the mixins Vue accepted. `app.mixin()` is a silent no-op without the Options API. */
3847
function getRegisteredMixins(app: App): unknown[] {
3948
return (app as unknown as { _context: { mixins: unknown[] } })._context.mixins;
@@ -173,6 +182,72 @@ describe('tracing mixin span creation', () => {
173182
]);
174183
});
175184

185+
// The mixin always tracks the root component: `isRootComponent || …` short-circuits before the
186+
// `trackComponents` filter runs. The next four tests record what that means for each hook, so a
187+
// mixin replacement can prove which parts it keeps.
188+
189+
it('tracks the root component for update hooks without trackComponents', async ({ uiSpans, initSentry }) => {
190+
const { app, message } = createReactiveTestApp();
191+
initSentry({ tracing: { hooks: ['update'] }, sdk: { app } });
192+
const container = document.createElement('div');
193+
194+
await Sentry.startSpan({ name: 'pageload' }, async () => {
195+
app.mount(container);
196+
message.value = 'updated message';
197+
// Works under fake timers: Vue flushes re-renders through microtasks, not timers.
198+
await nextTick();
199+
vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS + 1);
200+
});
201+
202+
expect(uiSpans).toEqual([
203+
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP },
204+
{ name: 'Vue <Root>', op: UI_UPDATE_SPAN_OP },
205+
{ name: 'Application Render', op: UI_RENDER_SPAN_OP },
206+
]);
207+
});
208+
209+
// `beforeCreate` fires very early in `app.mount()`, but the mixin creates the root render span
210+
// first, in the same handler. So the `create` span has a parent and is emitted, as `ui.mount`,
211+
// which is the op the `create` operation maps to.
212+
it('tracks the root component for create hooks without trackComponents', ({ app, uiSpans, initSentry }) => {
213+
initSentry({ tracing: { hooks: ['create'] } });
214+
215+
mountUnderActiveSpan(app);
216+
217+
expect(uiSpans).toEqual([
218+
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP }, // create
219+
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP }, // mount (DEFAULT_HOOKS is always merged in)
220+
{ name: 'Application Render', op: UI_RENDER_SPAN_OP },
221+
]);
222+
});
223+
224+
// `activate` maps to the `activated`/`deactivated` hooks, which Vue only calls inside
225+
// `<KeepAlive>`. A root component is never kept alive, so `activate` produces no root span.
226+
it('does not track the root component for activate hooks', ({ app, uiSpans, initSentry }) => {
227+
initSentry({ tracing: { hooks: ['activate'] } });
228+
229+
mountUnderActiveSpan(app);
230+
231+
expect(uiSpans).toEqual([
232+
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP },
233+
{ name: 'Application Render', op: UI_RENDER_SPAN_OP },
234+
]);
235+
});
236+
237+
// Component spans are keyed by operation, not by span op, so `create` and `mount` each emit their
238+
// own root span even though both map to `ui.mount`.
239+
it('emits two root mount spans when both create and mount hooks are enabled', ({ app, uiSpans, initSentry }) => {
240+
initSentry({ tracing: { hooks: ['create', 'mount'] } });
241+
242+
mountUnderActiveSpan(app);
243+
244+
expect(uiSpans).toEqual([
245+
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP }, // create
246+
{ name: 'Vue <Root>', op: UI_MOUNT_SPAN_OP }, // mount
247+
{ name: 'Application Render', op: UI_RENDER_SPAN_OP },
248+
]);
249+
});
250+
176251
// Vue 3 compiles `app.mixin()` down to a no-op returning the app when the `__VUE_OPTIONS_API__`
177252
// build flag is `false`. Nuxt 5 sets that flag by default (nuxt/nuxt#35791), so this stub matches
178253
// what those users run. The real build is covered by the `vue-3 (no Options API)` e2e variant.

0 commit comments

Comments
 (0)