From be38a35fd6cbe0924eb04a7bbf933306a24a7bf4 Mon Sep 17 00:00:00 2001 From: Abd Alrahaman Date: Wed, 14 Oct 2026 06:24:43 +0300 Subject: [PATCH 1/4] test(nuxt): cover the client plugin's pages-router instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills in the Nuxt client-plugin unit tests requested in #13813. Supersedes the dormant draft in #24583 (last activity 2026-02; author pinged 2026-10-12 by @JPeer264 with no reply). Covers the five code paths in `src/runtime/plugins/sentry.client.ts`: 1. Vue route provider setup (installs, respects user provider, skips without client, skips without `$router`) 2. `browserTracingIntegration` (adds with router+path label, skips on no client, skips on no router) 3. `app:created` hook — `vueIntegration` with `attachErrorHandler: false`, including client-torn-down path 4. `app:error` hook — 4xx/3xx status-code skip for Nuxt errors, 5xx and non-Nuxt errors still reported 5. `vue:error` hook — forwards error + instance + info to `reportNuxtError` Follows the mock pattern already used in `captureErrorHook.test.ts` (vi.hoisted + vi.mock(importOriginal) + spyOn on local utils). Pure vitest, no native deps. Refs #13813. Signed-off-by: Abd Alrahaman --- .../nuxt/test/runtime/plugins/client.test.ts | 309 ++++++++++++++++-- 1 file changed, 274 insertions(+), 35 deletions(-) diff --git a/packages/nuxt/test/runtime/plugins/client.test.ts b/packages/nuxt/test/runtime/plugins/client.test.ts index 14b75383d1cb..d36422d29b49 100644 --- a/packages/nuxt/test/runtime/plugins/client.test.ts +++ b/packages/nuxt/test/runtime/plugins/client.test.ts @@ -1,58 +1,297 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { reportNuxtError } from '../../../src/runtime/utils'; +import type { Client } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import clientPlugin from '../../../src/runtime/plugins/sentry.client'; +import * as utils from '../../../src/runtime/utils'; -vi.mock('nuxt/app', () => ({ - defineNuxtPlugin: (plugin: unknown) => plugin, - isNuxtError: (error: unknown) => !!error && typeof error === 'object' && '__nuxt_error' in error, -})); +const getClient = vi.hoisted(() => vi.fn<() => Client | undefined>()); +const browserTracingIntegration = vi.hoisted(() => vi.fn()); +const vueIntegration = vi.hoisted(() => vi.fn()); +const createVueRouteProvider = vi.hoisted(() => vi.fn()); +const getRouteProvider = vi.hoisted(() => vi.fn()); +const setRouteProvider = vi.hoisted(() => vi.fn()); + +vi.mock(import('@sentry/core'), async importOriginal => { + const mod = await importOriginal(); + return { ...mod, getClient, GLOBAL_OBJ: {} as never }; +}); -vi.mock('../../../src/runtime/utils', () => ({ - reportNuxtError: vi.fn(), +vi.mock(import('@sentry/vue'), async importOriginal => { + const mod = await importOriginal(); + return { + ...mod, + browserTracingIntegration, + vueIntegration, + _INTERNAL_createVueRouteProvider: createVueRouteProvider, + getRouteProvider, + setRouteProvider, + }; +}); + +vi.mock(import('nuxt/app'), () => ({ + defineNuxtPlugin: vi.fn(plugin => plugin), + isNuxtError: vi.fn(() => false), })); -type Hook = (...args: unknown[]) => void; +type PluginSetup = (nuxtApp: { + $router?: unknown; + hook: ReturnType; +}) => Promise; + +type NuxtPluginShape = { setup: PluginSetup }; -async function setupHook(hookName: string): Promise { - const { default: plugin } = await import('../../../src/runtime/plugins/sentry.client'); - const hooks: Record = {}; - const nuxtApp = { hook: (name: string, hook: Hook) => (hooks[name] = hook) }; - await (plugin as unknown as { setup: (app: unknown) => Promise }).setup(nuxtApp); - expect(hooks).toHaveProperty(hookName); - return hooks[hookName]!; +function makeClient(overrides: Partial = {}): Client { + return { + addIntegration: vi.fn(), + getOptions: vi.fn(() => ({ attachProps: true })), + ...overrides, + } as unknown as Client; } -function nuxtError(statusCode: number): Error { - return Object.assign(new Error(`status ${statusCode}`), { __nuxt_error: true, statusCode }); +function makeNuxtApp(router?: unknown): { $router?: unknown; hook: ReturnType } { + return router === undefined ? { hook: vi.fn() } : { $router: router, hook: vi.fn() }; } -describe('sentry client plugin error hooks', () => { - beforeEach(() => { - vi.clearAllMocks(); +describe('Sentry Nuxt client plugin', () => { + afterEach(() => { + vi.resetAllMocks(); }); - it.each(['app:error', 'vue:error'])('does not report 3xx/4xx Nuxt errors from %s', async hookName => { - const hook = await setupHook(hookName); + describe('route provider setup', () => { + it('installs a Vue route provider when a Nuxt router is present and none is set', async () => { + const router = { beforeEach: vi.fn(), onError: vi.fn() }; + const client = makeClient(); + const provider = { resolveRoute: vi.fn(), resolveCurrentRoute: vi.fn() }; + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + createVueRouteProvider.mockReturnValue(provider); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router)); + + expect(createVueRouteProvider).toHaveBeenCalledTimes(1); + // The provider is a lazy router getter — confirm it resolves to our router. + const routerGetter = createVueRouteProvider.mock.calls[0]![0] as () => unknown; + expect(routerGetter()).toBe(router); + expect(setRouteProvider).toHaveBeenCalledWith(provider, client); + }); + + it('does not override a user-supplied route provider', async () => { + const client = makeClient(); + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue({ resolveRoute: () => '/u', resolveCurrentRoute: () => '/u' }); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({})); + + expect(createVueRouteProvider).not.toHaveBeenCalled(); + expect(setRouteProvider).not.toHaveBeenCalled(); + }); + + it('skips route provider setup without an initialized client', async () => { + getClient.mockReturnValue(undefined); - hook(nuxtError(404)); - hook(nuxtError(301)); + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({})); - expect(reportNuxtError).not.toHaveBeenCalled(); + expect(createVueRouteProvider).not.toHaveBeenCalled(); + expect(setRouteProvider).not.toHaveBeenCalled(); + }); + + it('skips route provider setup without a $router on the Nuxt app', async () => { + const client = makeClient(); + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp()); + + expect(createVueRouteProvider).not.toHaveBeenCalled(); + expect(setRouteProvider).not.toHaveBeenCalled(); + }); }); - it.each(['app:error', 'vue:error'])('reports 5xx Nuxt errors from %s', async hookName => { - const hook = await setupHook(hookName); + describe('browser tracing integration', () => { + it('adds browserTracingIntegration with the Nuxt router and path-based labels', async () => { + const router = { beforeEach: vi.fn(), onError: vi.fn() }; + const integration = { name: 'BrowserTracing' }; + const client = makeClient(); + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + browserTracingIntegration.mockReturnValue(integration); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router)); + + expect(browserTracingIntegration).toHaveBeenCalledWith({ router, routeLabel: 'path' }); + expect(client.addIntegration).toHaveBeenCalledWith(integration); + }); + + it('does not add browserTracingIntegration without an initialized client', async () => { + getClient.mockReturnValue(undefined); - hook(nuxtError(503)); + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({ beforeEach: vi.fn() })); - expect(reportNuxtError).toHaveBeenCalledTimes(1); + expect(browserTracingIntegration).not.toHaveBeenCalled(); + }); + + it('does not add browserTracingIntegration when the Nuxt app has no router', async () => { + const client = makeClient(); + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp()); + + expect(browserTracingIntegration).not.toHaveBeenCalled(); + }); }); - it('reports errors that are not Nuxt errors from vue:error', async () => { - const hook = await setupHook('vue:error'); - const error = Object.assign(new Error('boom'), { statusCode: 404 }); + describe('app:created hook — Vue integration', () => { + it('adds the Nuxt-specific Vue integration with attachErrorHandler disabled', async () => { + const client = makeClient(); + const integration = { name: 'NuxtVueIntegration' }; + const vueApp = { Symbol: 'app' }; + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + vueIntegration.mockReturnValue(integration); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const appCreatedCall = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:created'); + expect(appCreatedCall).toBeTruthy(); + const appCreatedCb = appCreatedCall![1] as (app: unknown) => void; + appCreatedCb(vueApp); + + expect(vueIntegration).toHaveBeenCalledTimes(1); + const vueCallArgs = vueIntegration.mock.calls[0]![0] as { + app: unknown; + attachErrorHandler: boolean; + }; + expect(vueCallArgs.app).toBe(vueApp); + expect(vueCallArgs.attachErrorHandler).toBe(false); + expect(client.addIntegration).toHaveBeenCalledWith(integration); + }); + + it('does not add the Vue integration when the client is gone by the time the hook fires', async () => { + const client = makeClient(); + getClient.mockReturnValueOnce(client); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + + // Second getClient call (inside the hook) returns undefined — simulates teardown. + getClient.mockReturnValueOnce(undefined); + const appCreatedCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:created')?.[1] as (app: unknown) => void; + appCreatedCb({}); + + expect(vueIntegration).not.toHaveBeenCalled(); + }); + }); + + describe('app:error hook — status-code filtering', () => { + it('skips reporting when a Nuxt error carries a 4xx status code', async () => { + const { isNuxtError } = await import('nuxt/app'); + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + vi.mocked(isNuxtError).mockReturnValue(true); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const errorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:error')?.[1] as (e: unknown) => void; + errorCb({ statusCode: 404 }); + + expect(reportSpy).not.toHaveBeenCalled(); + }); + + it('reports a Nuxt error when the status code is 5xx', async () => { + const { isNuxtError } = await import('nuxt/app'); + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + vi.mocked(isNuxtError).mockReturnValue(true); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const errorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:error')?.[1] as (e: unknown) => void; + const err = { statusCode: 503, message: 'bad gateway' }; + errorCb(err); + + expect(reportSpy).toHaveBeenCalledWith({ error: err }); + }); + + it('reports a non-Nuxt error regardless of status code shape', async () => { + const { isNuxtError } = await import('nuxt/app'); + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + vi.mocked(isNuxtError).mockReturnValue(false); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const errorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:error')?.[1] as (e: unknown) => void; + const err = new Error('boom'); + errorCb(err); + + expect(reportSpy).toHaveBeenCalledWith({ error: err }); + }); + }); + + describe('vue:error hook', () => { + it('forwards the error, component instance and info to reportNuxtError', async () => { + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const vueErrorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'vue:error')?.[1] as ( + e: unknown, + instance: unknown, + info: string, + ) => void; + const err = new Error('vue boom'); + const instance = { $props: { foo: 1 } }; + vueErrorCb(err, instance, 'render'); + + expect(reportSpy).toHaveBeenCalledWith({ error: err, instance, info: 'render' }); + }); + + // Mirror the status-code filtering from app:error so both hooks are + // covered symmetrically (upstream #25341 asserts this across both + // hook names via `it.each`). + it('skips reporting when a Nuxt error carries a 4xx status code', async () => { + const { isNuxtError } = await import('nuxt/app'); + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + vi.mocked(isNuxtError).mockReturnValue(true); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const vueErrorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'vue:error')?.[1] as ( + e: unknown, + instance: unknown, + info: string, + ) => void; + vueErrorCb({ statusCode: 404 }, undefined, 'setup'); + + expect(reportSpy).not.toHaveBeenCalled(); + }); + + it('reports a Nuxt error when the status code is 5xx', async () => { + const { isNuxtError } = await import('nuxt/app'); + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + vi.mocked(isNuxtError).mockReturnValue(true); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); - hook(error, undefined, 'setup function'); + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const vueErrorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'vue:error')?.[1] as ( + e: unknown, + instance: unknown, + info: string, + ) => void; + const err = { statusCode: 503, message: 'bad gateway' }; + vueErrorCb(err, undefined, 'setup'); - expect(reportNuxtError).toHaveBeenCalledWith({ error, instance: undefined, info: 'setup function' }); + expect(reportSpy).toHaveBeenCalledWith({ error: err, instance: undefined, info: 'setup' }); + }); }); }); From 1b2645857797ecf721dff96ab71d50bfca5e46a9 Mon Sep 17 00:00:00 2001 From: Abd Alrahaman Date: Thu, 22 Oct 2026 14:30:31 +0300 Subject: [PATCH 2/4] test(nuxt): close coverage gaps flagged by Cursor Bugbot on #25348 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three concrete gaps against the suite this PR replaced: - Nuxt-error status filter now also exercises 3xx (301), not just 4xx. Both `app:error` and `vue:error` use `it.each([404, 301])` to match upstream #25341's pattern. - The "non-Nuxt error" case now carries a 4xx statusCode on the error, which is what actually exercises the `isNuxtError` guard — without the guard the error would be skipped as 4xx. Previous shape passed a plain Error with no statusCode, so the guard was not actually under test. - Added the equivalent non-Nuxt-with-statusCode case for `vue:error` (previously only present under `app:error`). No logic change in the plugin — just test coverage. Signed-off-by: Abd Alrahaman --- .../nuxt/test/runtime/plugins/client.test.ts | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/nuxt/test/runtime/plugins/client.test.ts b/packages/nuxt/test/runtime/plugins/client.test.ts index d36422d29b49..a2aede723c95 100644 --- a/packages/nuxt/test/runtime/plugins/client.test.ts +++ b/packages/nuxt/test/runtime/plugins/client.test.ts @@ -184,7 +184,7 @@ describe('Sentry Nuxt client plugin', () => { }); describe('app:error hook — status-code filtering', () => { - it('skips reporting when a Nuxt error carries a 4xx status code', async () => { + it.each([404, 301])('skips reporting when a Nuxt error carries a %dxx status code', async statusCode => { const { isNuxtError } = await import('nuxt/app'); const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); vi.mocked(isNuxtError).mockReturnValue(true); @@ -194,7 +194,7 @@ describe('Sentry Nuxt client plugin', () => { const nuxtApp = makeNuxtApp(); await (clientPlugin as NuxtPluginShape).setup(nuxtApp); const errorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:error')?.[1] as (e: unknown) => void; - errorCb({ statusCode: 404 }); + errorCb({ statusCode }); expect(reportSpy).not.toHaveBeenCalled(); }); @@ -215,7 +215,9 @@ describe('Sentry Nuxt client plugin', () => { expect(reportSpy).toHaveBeenCalledWith({ error: err }); }); - it('reports a non-Nuxt error regardless of status code shape', async () => { + // Non-Nuxt error carrying a 4xx statusCode: exercises the `isNuxtError` + // guard directly (without the guard this would be skipped as 4xx). + it('reports a non-Nuxt error even when it carries a 4xx statusCode', async () => { const { isNuxtError } = await import('nuxt/app'); const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); vi.mocked(isNuxtError).mockReturnValue(false); @@ -225,7 +227,7 @@ describe('Sentry Nuxt client plugin', () => { const nuxtApp = makeNuxtApp(); await (clientPlugin as NuxtPluginShape).setup(nuxtApp); const errorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:error')?.[1] as (e: unknown) => void; - const err = new Error('boom'); + const err = Object.assign(new Error('boom'), { statusCode: 404 }); errorCb(err); expect(reportSpy).toHaveBeenCalledWith({ error: err }); @@ -253,9 +255,9 @@ describe('Sentry Nuxt client plugin', () => { }); // Mirror the status-code filtering from app:error so both hooks are - // covered symmetrically (upstream #25341 asserts this across both - // hook names via `it.each`). - it('skips reporting when a Nuxt error carries a 4xx status code', async () => { + // covered symmetrically (matches upstream #25341's `it.each` across + // both hook names). + it.each([404, 301])('skips reporting when a Nuxt error carries a %dxx status code', async statusCode => { const { isNuxtError } = await import('nuxt/app'); const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); vi.mocked(isNuxtError).mockReturnValue(true); @@ -269,7 +271,7 @@ describe('Sentry Nuxt client plugin', () => { instance: unknown, info: string, ) => void; - vueErrorCb({ statusCode: 404 }, undefined, 'setup'); + vueErrorCb({ statusCode }, undefined, 'setup'); expect(reportSpy).not.toHaveBeenCalled(); }); @@ -293,5 +295,27 @@ describe('Sentry Nuxt client plugin', () => { expect(reportSpy).toHaveBeenCalledWith({ error: err, instance: undefined, info: 'setup' }); }); + + // Non-Nuxt error carrying a 4xx statusCode: exercises the `isNuxtError` + // guard directly (without the guard this would be skipped as 4xx). + it('reports a non-Nuxt error even when it carries a 4xx statusCode', async () => { + const { isNuxtError } = await import('nuxt/app'); + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + vi.mocked(isNuxtError).mockReturnValue(false); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const vueErrorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'vue:error')?.[1] as ( + e: unknown, + instance: unknown, + info: string, + ) => void; + const err = Object.assign(new Error('boom'), { statusCode: 404 }); + vueErrorCb(err, undefined, 'setup'); + + expect(reportSpy).toHaveBeenCalledWith({ error: err, instance: undefined, info: 'setup' }); + }); }); }); From 29e7fce42a4c2195e6d105df252dc67711a46a5f Mon Sep 17 00:00:00 2001 From: Abd Alrahaman Date: Tue, 27 Oct 2026 13:34:49 +0300 Subject: [PATCH 3/4] test(nuxt): tighten client plugin assertions per review Four review items from the client.test.ts pass: 1. Replace mock.calls read with an expect.toHaveBeenCalledWith(expect.any(Function)) assertion for the createVueRouteProvider router-getter case. 2. Mock GLOBAL_OBJ with _sentryNuxtVueIntegrationOptions so the Nuxt-specific forwarding path into vueIntegration is actually covered. Previously GLOBAL_OBJ was stubbed to {} and the merge was invisible. 3. Replace vueIntegration mock.calls read with expect.toHaveBeenCalledWith(expect.objectContaining({...})), and assert the GLOBAL_OBJ options flow through. 4. Collapse the duplicated status-code tests on app:error and vue:error into one describe.each(['app:error', 'vue:error']) via a small fireHook helper. Keep the vue:error-specific instance/info passthrough test as its own describe. Local run (packages/nuxt): 18/18 passed, sentry.client.ts coverage 95.83%. Signed-off-by: Abd Alrahaman --- .../nuxt/test/runtime/plugins/client.test.ts | 598 ++++++++---------- 1 file changed, 277 insertions(+), 321 deletions(-) diff --git a/packages/nuxt/test/runtime/plugins/client.test.ts b/packages/nuxt/test/runtime/plugins/client.test.ts index a2aede723c95..b2ac07e79b16 100644 --- a/packages/nuxt/test/runtime/plugins/client.test.ts +++ b/packages/nuxt/test/runtime/plugins/client.test.ts @@ -1,321 +1,277 @@ -import type { Client } from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import clientPlugin from '../../../src/runtime/plugins/sentry.client'; -import * as utils from '../../../src/runtime/utils'; - -const getClient = vi.hoisted(() => vi.fn<() => Client | undefined>()); -const browserTracingIntegration = vi.hoisted(() => vi.fn()); -const vueIntegration = vi.hoisted(() => vi.fn()); -const createVueRouteProvider = vi.hoisted(() => vi.fn()); -const getRouteProvider = vi.hoisted(() => vi.fn()); -const setRouteProvider = vi.hoisted(() => vi.fn()); - -vi.mock(import('@sentry/core'), async importOriginal => { - const mod = await importOriginal(); - return { ...mod, getClient, GLOBAL_OBJ: {} as never }; -}); - -vi.mock(import('@sentry/vue'), async importOriginal => { - const mod = await importOriginal(); - return { - ...mod, - browserTracingIntegration, - vueIntegration, - _INTERNAL_createVueRouteProvider: createVueRouteProvider, - getRouteProvider, - setRouteProvider, - }; -}); - -vi.mock(import('nuxt/app'), () => ({ - defineNuxtPlugin: vi.fn(plugin => plugin), - isNuxtError: vi.fn(() => false), -})); - -type PluginSetup = (nuxtApp: { - $router?: unknown; - hook: ReturnType; -}) => Promise; - -type NuxtPluginShape = { setup: PluginSetup }; - -function makeClient(overrides: Partial = {}): Client { - return { - addIntegration: vi.fn(), - getOptions: vi.fn(() => ({ attachProps: true })), - ...overrides, - } as unknown as Client; -} - -function makeNuxtApp(router?: unknown): { $router?: unknown; hook: ReturnType } { - return router === undefined ? { hook: vi.fn() } : { $router: router, hook: vi.fn() }; -} - -describe('Sentry Nuxt client plugin', () => { - afterEach(() => { - vi.resetAllMocks(); - }); - - describe('route provider setup', () => { - it('installs a Vue route provider when a Nuxt router is present and none is set', async () => { - const router = { beforeEach: vi.fn(), onError: vi.fn() }; - const client = makeClient(); - const provider = { resolveRoute: vi.fn(), resolveCurrentRoute: vi.fn() }; - getClient.mockReturnValue(client); - getRouteProvider.mockReturnValue(undefined); - createVueRouteProvider.mockReturnValue(provider); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router)); - - expect(createVueRouteProvider).toHaveBeenCalledTimes(1); - // The provider is a lazy router getter — confirm it resolves to our router. - const routerGetter = createVueRouteProvider.mock.calls[0]![0] as () => unknown; - expect(routerGetter()).toBe(router); - expect(setRouteProvider).toHaveBeenCalledWith(provider, client); - }); - - it('does not override a user-supplied route provider', async () => { - const client = makeClient(); - getClient.mockReturnValue(client); - getRouteProvider.mockReturnValue({ resolveRoute: () => '/u', resolveCurrentRoute: () => '/u' }); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({})); - - expect(createVueRouteProvider).not.toHaveBeenCalled(); - expect(setRouteProvider).not.toHaveBeenCalled(); - }); - - it('skips route provider setup without an initialized client', async () => { - getClient.mockReturnValue(undefined); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({})); - - expect(createVueRouteProvider).not.toHaveBeenCalled(); - expect(setRouteProvider).not.toHaveBeenCalled(); - }); - - it('skips route provider setup without a $router on the Nuxt app', async () => { - const client = makeClient(); - getClient.mockReturnValue(client); - getRouteProvider.mockReturnValue(undefined); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp()); - - expect(createVueRouteProvider).not.toHaveBeenCalled(); - expect(setRouteProvider).not.toHaveBeenCalled(); - }); - }); - - describe('browser tracing integration', () => { - it('adds browserTracingIntegration with the Nuxt router and path-based labels', async () => { - const router = { beforeEach: vi.fn(), onError: vi.fn() }; - const integration = { name: 'BrowserTracing' }; - const client = makeClient(); - getClient.mockReturnValue(client); - getRouteProvider.mockReturnValue(undefined); - browserTracingIntegration.mockReturnValue(integration); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router)); - - expect(browserTracingIntegration).toHaveBeenCalledWith({ router, routeLabel: 'path' }); - expect(client.addIntegration).toHaveBeenCalledWith(integration); - }); - - it('does not add browserTracingIntegration without an initialized client', async () => { - getClient.mockReturnValue(undefined); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({ beforeEach: vi.fn() })); - - expect(browserTracingIntegration).not.toHaveBeenCalled(); - }); - - it('does not add browserTracingIntegration when the Nuxt app has no router', async () => { - const client = makeClient(); - getClient.mockReturnValue(client); - getRouteProvider.mockReturnValue(undefined); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp()); - - expect(browserTracingIntegration).not.toHaveBeenCalled(); - }); - }); - - describe('app:created hook — Vue integration', () => { - it('adds the Nuxt-specific Vue integration with attachErrorHandler disabled', async () => { - const client = makeClient(); - const integration = { name: 'NuxtVueIntegration' }; - const vueApp = { Symbol: 'app' }; - getClient.mockReturnValue(client); - getRouteProvider.mockReturnValue(undefined); - vueIntegration.mockReturnValue(integration); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - const appCreatedCall = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:created'); - expect(appCreatedCall).toBeTruthy(); - const appCreatedCb = appCreatedCall![1] as (app: unknown) => void; - appCreatedCb(vueApp); - - expect(vueIntegration).toHaveBeenCalledTimes(1); - const vueCallArgs = vueIntegration.mock.calls[0]![0] as { - app: unknown; - attachErrorHandler: boolean; - }; - expect(vueCallArgs.app).toBe(vueApp); - expect(vueCallArgs.attachErrorHandler).toBe(false); - expect(client.addIntegration).toHaveBeenCalledWith(integration); - }); - - it('does not add the Vue integration when the client is gone by the time the hook fires', async () => { - const client = makeClient(); - getClient.mockReturnValueOnce(client); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - - // Second getClient call (inside the hook) returns undefined — simulates teardown. - getClient.mockReturnValueOnce(undefined); - const appCreatedCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:created')?.[1] as (app: unknown) => void; - appCreatedCb({}); - - expect(vueIntegration).not.toHaveBeenCalled(); - }); - }); - - describe('app:error hook — status-code filtering', () => { - it.each([404, 301])('skips reporting when a Nuxt error carries a %dxx status code', async statusCode => { - const { isNuxtError } = await import('nuxt/app'); - const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); - vi.mocked(isNuxtError).mockReturnValue(true); - getClient.mockReturnValue(makeClient()); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - const errorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:error')?.[1] as (e: unknown) => void; - errorCb({ statusCode }); - - expect(reportSpy).not.toHaveBeenCalled(); - }); - - it('reports a Nuxt error when the status code is 5xx', async () => { - const { isNuxtError } = await import('nuxt/app'); - const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); - vi.mocked(isNuxtError).mockReturnValue(true); - getClient.mockReturnValue(makeClient()); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - const errorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:error')?.[1] as (e: unknown) => void; - const err = { statusCode: 503, message: 'bad gateway' }; - errorCb(err); - - expect(reportSpy).toHaveBeenCalledWith({ error: err }); - }); - - // Non-Nuxt error carrying a 4xx statusCode: exercises the `isNuxtError` - // guard directly (without the guard this would be skipped as 4xx). - it('reports a non-Nuxt error even when it carries a 4xx statusCode', async () => { - const { isNuxtError } = await import('nuxt/app'); - const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); - vi.mocked(isNuxtError).mockReturnValue(false); - getClient.mockReturnValue(makeClient()); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - const errorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:error')?.[1] as (e: unknown) => void; - const err = Object.assign(new Error('boom'), { statusCode: 404 }); - errorCb(err); - - expect(reportSpy).toHaveBeenCalledWith({ error: err }); - }); - }); - - describe('vue:error hook', () => { - it('forwards the error, component instance and info to reportNuxtError', async () => { - const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); - getClient.mockReturnValue(makeClient()); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - const vueErrorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'vue:error')?.[1] as ( - e: unknown, - instance: unknown, - info: string, - ) => void; - const err = new Error('vue boom'); - const instance = { $props: { foo: 1 } }; - vueErrorCb(err, instance, 'render'); - - expect(reportSpy).toHaveBeenCalledWith({ error: err, instance, info: 'render' }); - }); - - // Mirror the status-code filtering from app:error so both hooks are - // covered symmetrically (matches upstream #25341's `it.each` across - // both hook names). - it.each([404, 301])('skips reporting when a Nuxt error carries a %dxx status code', async statusCode => { - const { isNuxtError } = await import('nuxt/app'); - const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); - vi.mocked(isNuxtError).mockReturnValue(true); - getClient.mockReturnValue(makeClient()); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - const vueErrorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'vue:error')?.[1] as ( - e: unknown, - instance: unknown, - info: string, - ) => void; - vueErrorCb({ statusCode }, undefined, 'setup'); - - expect(reportSpy).not.toHaveBeenCalled(); - }); - - it('reports a Nuxt error when the status code is 5xx', async () => { - const { isNuxtError } = await import('nuxt/app'); - const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); - vi.mocked(isNuxtError).mockReturnValue(true); - getClient.mockReturnValue(makeClient()); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - const vueErrorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'vue:error')?.[1] as ( - e: unknown, - instance: unknown, - info: string, - ) => void; - const err = { statusCode: 503, message: 'bad gateway' }; - vueErrorCb(err, undefined, 'setup'); - - expect(reportSpy).toHaveBeenCalledWith({ error: err, instance: undefined, info: 'setup' }); - }); - - // Non-Nuxt error carrying a 4xx statusCode: exercises the `isNuxtError` - // guard directly (without the guard this would be skipped as 4xx). - it('reports a non-Nuxt error even when it carries a 4xx statusCode', async () => { - const { isNuxtError } = await import('nuxt/app'); - const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); - vi.mocked(isNuxtError).mockReturnValue(false); - getClient.mockReturnValue(makeClient()); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - const vueErrorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'vue:error')?.[1] as ( - e: unknown, - instance: unknown, - info: string, - ) => void; - const err = Object.assign(new Error('boom'), { statusCode: 404 }); - vueErrorCb(err, undefined, 'setup'); - - expect(reportSpy).toHaveBeenCalledWith({ error: err, instance: undefined, info: 'setup' }); - }); - }); -}); +import type { Client } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import clientPlugin from '../../../src/runtime/plugins/sentry.client'; +import * as utils from '../../../src/runtime/utils'; + +const getClient = vi.hoisted(() => vi.fn<() => Client | undefined>()); +const browserTracingIntegration = vi.hoisted(() => vi.fn()); +const vueIntegration = vi.hoisted(() => vi.fn()); +const createVueRouteProvider = vi.hoisted(() => vi.fn()); +const getRouteProvider = vi.hoisted(() => vi.fn()); +const setRouteProvider = vi.hoisted(() => vi.fn()); + +const nuxtVueOptions = vi.hoisted(() => ({ + trackComponents: ['NuxtApp'], + attachProps: false, +})); +const globalObj = vi.hoisted(() => ({ + _sentryNuxtVueIntegrationOptions: nuxtVueOptions, +})); + +vi.mock(import('@sentry/core'), async importOriginal => { + const mod = await importOriginal(); + return { ...mod, getClient, GLOBAL_OBJ: globalObj as never }; +}); + +vi.mock(import('@sentry/vue'), async importOriginal => { + const mod = await importOriginal(); + return { + ...mod, + browserTracingIntegration, + vueIntegration, + _INTERNAL_createVueRouteProvider: createVueRouteProvider, + getRouteProvider, + setRouteProvider, + }; +}); + +vi.mock(import('nuxt/app'), () => ({ + defineNuxtPlugin: vi.fn(plugin => plugin), + isNuxtError: vi.fn(() => false), +})); + +type PluginSetup = (nuxtApp: { + $router?: unknown; + hook: ReturnType; +}) => Promise; + +type NuxtPluginShape = { setup: PluginSetup }; + +function makeClient(overrides: Partial = {}): Client { + return { + addIntegration: vi.fn(), + getOptions: vi.fn(() => ({ attachProps: true })), + ...overrides, + } as unknown as Client; +} + +function makeNuxtApp(router?: unknown): { $router?: unknown; hook: ReturnType } { + return router === undefined ? { hook: vi.fn() } : { $router: router, hook: vi.fn() }; +} + +describe('Sentry Nuxt client plugin', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('route provider setup', () => { + it('installs a Vue route provider when a Nuxt router is present and none is set', async () => { + const router = { beforeEach: vi.fn(), onError: vi.fn() }; + const client = makeClient(); + const provider = { resolveRoute: vi.fn(), resolveCurrentRoute: vi.fn() }; + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + createVueRouteProvider.mockReturnValue(provider); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router)); + + expect(createVueRouteProvider).toHaveBeenCalledWith(expect.any(Function)); + expect(setRouteProvider).toHaveBeenCalledWith(provider, client); + }); + + it('does not override a user-supplied route provider', async () => { + const client = makeClient(); + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue({ resolveRoute: () => '/u', resolveCurrentRoute: () => '/u' }); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({})); + + expect(createVueRouteProvider).not.toHaveBeenCalled(); + expect(setRouteProvider).not.toHaveBeenCalled(); + }); + + it('skips route provider setup without an initialized client', async () => { + getClient.mockReturnValue(undefined); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({})); + + expect(createVueRouteProvider).not.toHaveBeenCalled(); + expect(setRouteProvider).not.toHaveBeenCalled(); + }); + + it('skips route provider setup without a $router on the Nuxt app', async () => { + const client = makeClient(); + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp()); + + expect(createVueRouteProvider).not.toHaveBeenCalled(); + expect(setRouteProvider).not.toHaveBeenCalled(); + }); + }); + + describe('browser tracing integration', () => { + it('adds browserTracingIntegration with the Nuxt router and path-based labels', async () => { + const router = { beforeEach: vi.fn(), onError: vi.fn() }; + const integration = { name: 'BrowserTracing' }; + const client = makeClient(); + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + browserTracingIntegration.mockReturnValue(integration); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router)); + + expect(browserTracingIntegration).toHaveBeenCalledWith({ router, routeLabel: 'path' }); + expect(client.addIntegration).toHaveBeenCalledWith(integration); + }); + + it('does not add browserTracingIntegration without an initialized client', async () => { + getClient.mockReturnValue(undefined); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({ beforeEach: vi.fn() })); + + expect(browserTracingIntegration).not.toHaveBeenCalled(); + }); + + it('does not add browserTracingIntegration when the Nuxt app has no router', async () => { + const client = makeClient(); + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp()); + + expect(browserTracingIntegration).not.toHaveBeenCalled(); + }); + }); + + describe('app:created hook — Vue integration', () => { + it('adds the Nuxt-specific Vue integration with attachErrorHandler disabled and merges GLOBAL_OBJ-provided options', async () => { + const client = makeClient(); + const integration = { name: 'NuxtVueIntegration' }; + const vueApp = { Symbol: 'app' }; + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + vueIntegration.mockReturnValue(integration); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const appCreatedCall = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:created'); + expect(appCreatedCall).toBeTruthy(); + const appCreatedCb = appCreatedCall![1] as (app: unknown) => void; + appCreatedCb(vueApp); + + // Nuxt-specific: _sentryNuxtVueIntegrationOptions from GLOBAL_OBJ must + // flow into vueIntegration alongside the fixed Nuxt options. + expect(vueIntegration).toHaveBeenCalledWith( + expect.objectContaining({ + app: vueApp, + attachErrorHandler: false, + trackComponents: nuxtVueOptions.trackComponents, + attachProps: nuxtVueOptions.attachProps, + }), + ); + expect(client.addIntegration).toHaveBeenCalledWith(integration); + }); + + it('does not add the Vue integration when the client is gone by the time the hook fires', async () => { + const client = makeClient(); + getClient.mockReturnValueOnce(client); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + + // Second getClient call (inside the hook) returns undefined — simulates teardown. + getClient.mockReturnValueOnce(undefined); + const appCreatedCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:created')?.[1] as (app: unknown) => void; + appCreatedCb({}); + + expect(vueIntegration).not.toHaveBeenCalled(); + }); + }); + + describe.each(['app:error', 'vue:error'] as const)('%s hook — status-code filtering', hookName => { + const fireHook = ( + nuxtApp: { hook: ReturnType }, + error: unknown, + ) => { + const cb = nuxtApp.hook.mock.calls.find(c => c[0] === hookName)?.[1] as ( + e: unknown, + instance?: unknown, + info?: string, + ) => void; + // vue:error carries (error, instance, info); app:error only (error). The + // plugin passes the extra args through for vue:error, so include them + // uniformly here and rely on expect.objectContaining({ error }) below. + cb(error, undefined, 'setup'); + }; + + it.each([404, 301])('skips reporting when a Nuxt error carries a %d status code', async statusCode => { + const { isNuxtError } = await import('nuxt/app'); + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + vi.mocked(isNuxtError).mockReturnValue(true); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + fireHook(nuxtApp, { statusCode }); + + expect(reportSpy).not.toHaveBeenCalled(); + }); + + it('reports a Nuxt error when the status code is 5xx', async () => { + const { isNuxtError } = await import('nuxt/app'); + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + vi.mocked(isNuxtError).mockReturnValue(true); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const err = { statusCode: 503, message: 'bad gateway' }; + fireHook(nuxtApp, err); + + expect(reportSpy).toHaveBeenCalledWith(expect.objectContaining({ error: err })); + }); + + // Non-Nuxt error carrying a 4xx statusCode exercises the `isNuxtError` + // guard directly — without the guard this would be skipped as 4xx. + it('reports a non-Nuxt error even when it carries a 4xx statusCode', async () => { + const { isNuxtError } = await import('nuxt/app'); + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + vi.mocked(isNuxtError).mockReturnValue(false); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const err = Object.assign(new Error('boom'), { statusCode: 404 }); + fireHook(nuxtApp, err); + + expect(reportSpy).toHaveBeenCalledWith(expect.objectContaining({ error: err })); + }); + }); + + describe('vue:error hook — component instance passthrough', () => { + it('forwards the error, component instance and info to reportNuxtError', async () => { + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const vueErrorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'vue:error')?.[1] as ( + e: unknown, + instance: unknown, + info: string, + ) => void; + const err = new Error('vue boom'); + const instance = { $props: { foo: 1 } }; + vueErrorCb(err, instance, 'render'); + + expect(reportSpy).toHaveBeenCalledWith({ error: err, instance, info: 'render' }); + }); + }); +}); From bc8a3ff5e3b4f95e7413fbdfd55472c12884a946 Mon Sep 17 00:00:00 2001 From: Abd Alrahaman Date: Tue, 27 Oct 2026 18:46:02 +0300 Subject: [PATCH 4/4] test(nuxt): normalize line endings + cover both isNuxtError status fields Two review items from s1gr1d: 1. The client.test.ts file I pushed had CRLF line endings from a Windows checkout with core.autocrlf=true. Converted to LF and staged with core.autocrlf=false so the file stays consistent with the rest of packages/nuxt. 2. isNuxtError on a Nuxt error can reach the plugin's skip guard via either error.status or error.statusCode (see sentry.client.ts:45). The previous it.each only covered the statusCode branch. Expanded to four cases: {status: 404} / {status: 301} / {statusCode: 404} / {statusCode: 301} using %o formatting so the test name reflects the shape under test. Local run (packages/nuxt): 22/22 passed, sentry.client.ts coverage unchanged at 95.83%. Signed-off-by: Abd Alrahaman --- .../nuxt/test/runtime/plugins/client.test.ts | 561 +++++++++--------- 1 file changed, 284 insertions(+), 277 deletions(-) diff --git a/packages/nuxt/test/runtime/plugins/client.test.ts b/packages/nuxt/test/runtime/plugins/client.test.ts index b2ac07e79b16..f87bc1a7636a 100644 --- a/packages/nuxt/test/runtime/plugins/client.test.ts +++ b/packages/nuxt/test/runtime/plugins/client.test.ts @@ -1,277 +1,284 @@ -import type { Client } from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import clientPlugin from '../../../src/runtime/plugins/sentry.client'; -import * as utils from '../../../src/runtime/utils'; - -const getClient = vi.hoisted(() => vi.fn<() => Client | undefined>()); -const browserTracingIntegration = vi.hoisted(() => vi.fn()); -const vueIntegration = vi.hoisted(() => vi.fn()); -const createVueRouteProvider = vi.hoisted(() => vi.fn()); -const getRouteProvider = vi.hoisted(() => vi.fn()); -const setRouteProvider = vi.hoisted(() => vi.fn()); - -const nuxtVueOptions = vi.hoisted(() => ({ - trackComponents: ['NuxtApp'], - attachProps: false, -})); -const globalObj = vi.hoisted(() => ({ - _sentryNuxtVueIntegrationOptions: nuxtVueOptions, -})); - -vi.mock(import('@sentry/core'), async importOriginal => { - const mod = await importOriginal(); - return { ...mod, getClient, GLOBAL_OBJ: globalObj as never }; -}); - -vi.mock(import('@sentry/vue'), async importOriginal => { - const mod = await importOriginal(); - return { - ...mod, - browserTracingIntegration, - vueIntegration, - _INTERNAL_createVueRouteProvider: createVueRouteProvider, - getRouteProvider, - setRouteProvider, - }; -}); - -vi.mock(import('nuxt/app'), () => ({ - defineNuxtPlugin: vi.fn(plugin => plugin), - isNuxtError: vi.fn(() => false), -})); - -type PluginSetup = (nuxtApp: { - $router?: unknown; - hook: ReturnType; -}) => Promise; - -type NuxtPluginShape = { setup: PluginSetup }; - -function makeClient(overrides: Partial = {}): Client { - return { - addIntegration: vi.fn(), - getOptions: vi.fn(() => ({ attachProps: true })), - ...overrides, - } as unknown as Client; -} - -function makeNuxtApp(router?: unknown): { $router?: unknown; hook: ReturnType } { - return router === undefined ? { hook: vi.fn() } : { $router: router, hook: vi.fn() }; -} - -describe('Sentry Nuxt client plugin', () => { - afterEach(() => { - vi.resetAllMocks(); - }); - - describe('route provider setup', () => { - it('installs a Vue route provider when a Nuxt router is present and none is set', async () => { - const router = { beforeEach: vi.fn(), onError: vi.fn() }; - const client = makeClient(); - const provider = { resolveRoute: vi.fn(), resolveCurrentRoute: vi.fn() }; - getClient.mockReturnValue(client); - getRouteProvider.mockReturnValue(undefined); - createVueRouteProvider.mockReturnValue(provider); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router)); - - expect(createVueRouteProvider).toHaveBeenCalledWith(expect.any(Function)); - expect(setRouteProvider).toHaveBeenCalledWith(provider, client); - }); - - it('does not override a user-supplied route provider', async () => { - const client = makeClient(); - getClient.mockReturnValue(client); - getRouteProvider.mockReturnValue({ resolveRoute: () => '/u', resolveCurrentRoute: () => '/u' }); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({})); - - expect(createVueRouteProvider).not.toHaveBeenCalled(); - expect(setRouteProvider).not.toHaveBeenCalled(); - }); - - it('skips route provider setup without an initialized client', async () => { - getClient.mockReturnValue(undefined); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({})); - - expect(createVueRouteProvider).not.toHaveBeenCalled(); - expect(setRouteProvider).not.toHaveBeenCalled(); - }); - - it('skips route provider setup without a $router on the Nuxt app', async () => { - const client = makeClient(); - getClient.mockReturnValue(client); - getRouteProvider.mockReturnValue(undefined); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp()); - - expect(createVueRouteProvider).not.toHaveBeenCalled(); - expect(setRouteProvider).not.toHaveBeenCalled(); - }); - }); - - describe('browser tracing integration', () => { - it('adds browserTracingIntegration with the Nuxt router and path-based labels', async () => { - const router = { beforeEach: vi.fn(), onError: vi.fn() }; - const integration = { name: 'BrowserTracing' }; - const client = makeClient(); - getClient.mockReturnValue(client); - getRouteProvider.mockReturnValue(undefined); - browserTracingIntegration.mockReturnValue(integration); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router)); - - expect(browserTracingIntegration).toHaveBeenCalledWith({ router, routeLabel: 'path' }); - expect(client.addIntegration).toHaveBeenCalledWith(integration); - }); - - it('does not add browserTracingIntegration without an initialized client', async () => { - getClient.mockReturnValue(undefined); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({ beforeEach: vi.fn() })); - - expect(browserTracingIntegration).not.toHaveBeenCalled(); - }); - - it('does not add browserTracingIntegration when the Nuxt app has no router', async () => { - const client = makeClient(); - getClient.mockReturnValue(client); - getRouteProvider.mockReturnValue(undefined); - - await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp()); - - expect(browserTracingIntegration).not.toHaveBeenCalled(); - }); - }); - - describe('app:created hook — Vue integration', () => { - it('adds the Nuxt-specific Vue integration with attachErrorHandler disabled and merges GLOBAL_OBJ-provided options', async () => { - const client = makeClient(); - const integration = { name: 'NuxtVueIntegration' }; - const vueApp = { Symbol: 'app' }; - getClient.mockReturnValue(client); - getRouteProvider.mockReturnValue(undefined); - vueIntegration.mockReturnValue(integration); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - const appCreatedCall = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:created'); - expect(appCreatedCall).toBeTruthy(); - const appCreatedCb = appCreatedCall![1] as (app: unknown) => void; - appCreatedCb(vueApp); - - // Nuxt-specific: _sentryNuxtVueIntegrationOptions from GLOBAL_OBJ must - // flow into vueIntegration alongside the fixed Nuxt options. - expect(vueIntegration).toHaveBeenCalledWith( - expect.objectContaining({ - app: vueApp, - attachErrorHandler: false, - trackComponents: nuxtVueOptions.trackComponents, - attachProps: nuxtVueOptions.attachProps, - }), - ); - expect(client.addIntegration).toHaveBeenCalledWith(integration); - }); - - it('does not add the Vue integration when the client is gone by the time the hook fires', async () => { - const client = makeClient(); - getClient.mockReturnValueOnce(client); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - - // Second getClient call (inside the hook) returns undefined — simulates teardown. - getClient.mockReturnValueOnce(undefined); - const appCreatedCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:created')?.[1] as (app: unknown) => void; - appCreatedCb({}); - - expect(vueIntegration).not.toHaveBeenCalled(); - }); - }); - - describe.each(['app:error', 'vue:error'] as const)('%s hook — status-code filtering', hookName => { - const fireHook = ( - nuxtApp: { hook: ReturnType }, - error: unknown, - ) => { - const cb = nuxtApp.hook.mock.calls.find(c => c[0] === hookName)?.[1] as ( - e: unknown, - instance?: unknown, - info?: string, - ) => void; - // vue:error carries (error, instance, info); app:error only (error). The - // plugin passes the extra args through for vue:error, so include them - // uniformly here and rely on expect.objectContaining({ error }) below. - cb(error, undefined, 'setup'); - }; - - it.each([404, 301])('skips reporting when a Nuxt error carries a %d status code', async statusCode => { - const { isNuxtError } = await import('nuxt/app'); - const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); - vi.mocked(isNuxtError).mockReturnValue(true); - getClient.mockReturnValue(makeClient()); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - fireHook(nuxtApp, { statusCode }); - - expect(reportSpy).not.toHaveBeenCalled(); - }); - - it('reports a Nuxt error when the status code is 5xx', async () => { - const { isNuxtError } = await import('nuxt/app'); - const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); - vi.mocked(isNuxtError).mockReturnValue(true); - getClient.mockReturnValue(makeClient()); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - const err = { statusCode: 503, message: 'bad gateway' }; - fireHook(nuxtApp, err); - - expect(reportSpy).toHaveBeenCalledWith(expect.objectContaining({ error: err })); - }); - - // Non-Nuxt error carrying a 4xx statusCode exercises the `isNuxtError` - // guard directly — without the guard this would be skipped as 4xx. - it('reports a non-Nuxt error even when it carries a 4xx statusCode', async () => { - const { isNuxtError } = await import('nuxt/app'); - const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); - vi.mocked(isNuxtError).mockReturnValue(false); - getClient.mockReturnValue(makeClient()); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - const err = Object.assign(new Error('boom'), { statusCode: 404 }); - fireHook(nuxtApp, err); - - expect(reportSpy).toHaveBeenCalledWith(expect.objectContaining({ error: err })); - }); - }); - - describe('vue:error hook — component instance passthrough', () => { - it('forwards the error, component instance and info to reportNuxtError', async () => { - const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); - getClient.mockReturnValue(makeClient()); - getRouteProvider.mockReturnValue(undefined); - - const nuxtApp = makeNuxtApp(); - await (clientPlugin as NuxtPluginShape).setup(nuxtApp); - const vueErrorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'vue:error')?.[1] as ( - e: unknown, - instance: unknown, - info: string, - ) => void; - const err = new Error('vue boom'); - const instance = { $props: { foo: 1 } }; - vueErrorCb(err, instance, 'render'); - - expect(reportSpy).toHaveBeenCalledWith({ error: err, instance, info: 'render' }); - }); - }); -}); +import type { Client } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import clientPlugin from '../../../src/runtime/plugins/sentry.client'; +import * as utils from '../../../src/runtime/utils'; + +const getClient = vi.hoisted(() => vi.fn<() => Client | undefined>()); +const browserTracingIntegration = vi.hoisted(() => vi.fn()); +const vueIntegration = vi.hoisted(() => vi.fn()); +const createVueRouteProvider = vi.hoisted(() => vi.fn()); +const getRouteProvider = vi.hoisted(() => vi.fn()); +const setRouteProvider = vi.hoisted(() => vi.fn()); + +const nuxtVueOptions = vi.hoisted(() => ({ + trackComponents: ['NuxtApp'], + attachProps: false, +})); +const globalObj = vi.hoisted(() => ({ + _sentryNuxtVueIntegrationOptions: nuxtVueOptions, +})); + +vi.mock(import('@sentry/core'), async importOriginal => { + const mod = await importOriginal(); + return { ...mod, getClient, GLOBAL_OBJ: globalObj as never }; +}); + +vi.mock(import('@sentry/vue'), async importOriginal => { + const mod = await importOriginal(); + return { + ...mod, + browserTracingIntegration, + vueIntegration, + _INTERNAL_createVueRouteProvider: createVueRouteProvider, + getRouteProvider, + setRouteProvider, + }; +}); + +vi.mock(import('nuxt/app'), () => ({ + defineNuxtPlugin: vi.fn(plugin => plugin), + isNuxtError: vi.fn(() => false), +})); + +type PluginSetup = (nuxtApp: { + $router?: unknown; + hook: ReturnType; +}) => Promise; + +type NuxtPluginShape = { setup: PluginSetup }; + +function makeClient(overrides: Partial = {}): Client { + return { + addIntegration: vi.fn(), + getOptions: vi.fn(() => ({ attachProps: true })), + ...overrides, + } as unknown as Client; +} + +function makeNuxtApp(router?: unknown): { $router?: unknown; hook: ReturnType } { + return router === undefined ? { hook: vi.fn() } : { $router: router, hook: vi.fn() }; +} + +describe('Sentry Nuxt client plugin', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('route provider setup', () => { + it('installs a Vue route provider when a Nuxt router is present and none is set', async () => { + const router = { beforeEach: vi.fn(), onError: vi.fn() }; + const client = makeClient(); + const provider = { resolveRoute: vi.fn(), resolveCurrentRoute: vi.fn() }; + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + createVueRouteProvider.mockReturnValue(provider); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router)); + + expect(createVueRouteProvider).toHaveBeenCalledWith(expect.any(Function)); + expect(setRouteProvider).toHaveBeenCalledWith(provider, client); + }); + + it('does not override a user-supplied route provider', async () => { + const client = makeClient(); + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue({ resolveRoute: () => '/u', resolveCurrentRoute: () => '/u' }); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({})); + + expect(createVueRouteProvider).not.toHaveBeenCalled(); + expect(setRouteProvider).not.toHaveBeenCalled(); + }); + + it('skips route provider setup without an initialized client', async () => { + getClient.mockReturnValue(undefined); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({})); + + expect(createVueRouteProvider).not.toHaveBeenCalled(); + expect(setRouteProvider).not.toHaveBeenCalled(); + }); + + it('skips route provider setup without a $router on the Nuxt app', async () => { + const client = makeClient(); + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp()); + + expect(createVueRouteProvider).not.toHaveBeenCalled(); + expect(setRouteProvider).not.toHaveBeenCalled(); + }); + }); + + describe('browser tracing integration', () => { + it('adds browserTracingIntegration with the Nuxt router and path-based labels', async () => { + const router = { beforeEach: vi.fn(), onError: vi.fn() }; + const integration = { name: 'BrowserTracing' }; + const client = makeClient(); + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + browserTracingIntegration.mockReturnValue(integration); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp(router)); + + expect(browserTracingIntegration).toHaveBeenCalledWith({ router, routeLabel: 'path' }); + expect(client.addIntegration).toHaveBeenCalledWith(integration); + }); + + it('does not add browserTracingIntegration without an initialized client', async () => { + getClient.mockReturnValue(undefined); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp({ beforeEach: vi.fn() })); + + expect(browserTracingIntegration).not.toHaveBeenCalled(); + }); + + it('does not add browserTracingIntegration when the Nuxt app has no router', async () => { + const client = makeClient(); + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + + await (clientPlugin as NuxtPluginShape).setup(makeNuxtApp()); + + expect(browserTracingIntegration).not.toHaveBeenCalled(); + }); + }); + + describe('app:created hook — Vue integration', () => { + it('adds the Nuxt-specific Vue integration with attachErrorHandler disabled and merges GLOBAL_OBJ-provided options', async () => { + const client = makeClient(); + const integration = { name: 'NuxtVueIntegration' }; + const vueApp = { Symbol: 'app' }; + getClient.mockReturnValue(client); + getRouteProvider.mockReturnValue(undefined); + vueIntegration.mockReturnValue(integration); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const appCreatedCall = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:created'); + expect(appCreatedCall).toBeTruthy(); + const appCreatedCb = appCreatedCall![1] as (app: unknown) => void; + appCreatedCb(vueApp); + + // Nuxt-specific: _sentryNuxtVueIntegrationOptions from GLOBAL_OBJ must + // flow into vueIntegration alongside the fixed Nuxt options. + expect(vueIntegration).toHaveBeenCalledWith( + expect.objectContaining({ + app: vueApp, + attachErrorHandler: false, + trackComponents: nuxtVueOptions.trackComponents, + attachProps: nuxtVueOptions.attachProps, + }), + ); + expect(client.addIntegration).toHaveBeenCalledWith(integration); + }); + + it('does not add the Vue integration when the client is gone by the time the hook fires', async () => { + const client = makeClient(); + getClient.mockReturnValueOnce(client); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + + // Second getClient call (inside the hook) returns undefined — simulates teardown. + getClient.mockReturnValueOnce(undefined); + const appCreatedCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'app:created')?.[1] as (app: unknown) => void; + appCreatedCb({}); + + expect(vueIntegration).not.toHaveBeenCalled(); + }); + }); + + describe.each(['app:error', 'vue:error'] as const)('%s hook — status-code filtering', hookName => { + const fireHook = ( + nuxtApp: { hook: ReturnType }, + error: unknown, + ) => { + const cb = nuxtApp.hook.mock.calls.find(c => c[0] === hookName)?.[1] as ( + e: unknown, + instance?: unknown, + info?: string, + ) => void; + // vue:error carries (error, instance, info); app:error only (error). The + // plugin passes the extra args through for vue:error, so include them + // uniformly here and rely on expect.objectContaining({ error }) below. + cb(error, undefined, 'setup'); + }; + + // The plugin's skip guard reads `error.status || error.statusCode`, so both + // field names in the 3xx/4xx range must be covered. + it.each([ + { status: 404 }, + { status: 301 }, + { statusCode: 404 }, + { statusCode: 301 }, + ])('skips reporting when a Nuxt error carries %o', async errorShape => { + const { isNuxtError } = await import('nuxt/app'); + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + vi.mocked(isNuxtError).mockReturnValue(true); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + fireHook(nuxtApp, errorShape); + + expect(reportSpy).not.toHaveBeenCalled(); + }); + + it('reports a Nuxt error when the status code is 5xx', async () => { + const { isNuxtError } = await import('nuxt/app'); + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + vi.mocked(isNuxtError).mockReturnValue(true); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const err = { statusCode: 503, message: 'bad gateway' }; + fireHook(nuxtApp, err); + + expect(reportSpy).toHaveBeenCalledWith(expect.objectContaining({ error: err })); + }); + + // Non-Nuxt error carrying a 4xx statusCode exercises the `isNuxtError` + // guard directly — without the guard this would be skipped as 4xx. + it('reports a non-Nuxt error even when it carries a 4xx statusCode', async () => { + const { isNuxtError } = await import('nuxt/app'); + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + vi.mocked(isNuxtError).mockReturnValue(false); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const err = Object.assign(new Error('boom'), { statusCode: 404 }); + fireHook(nuxtApp, err); + + expect(reportSpy).toHaveBeenCalledWith(expect.objectContaining({ error: err })); + }); + }); + + describe('vue:error hook — component instance passthrough', () => { + it('forwards the error, component instance and info to reportNuxtError', async () => { + const reportSpy = vi.spyOn(utils, 'reportNuxtError').mockImplementation(() => {}); + getClient.mockReturnValue(makeClient()); + getRouteProvider.mockReturnValue(undefined); + + const nuxtApp = makeNuxtApp(); + await (clientPlugin as NuxtPluginShape).setup(nuxtApp); + const vueErrorCb = nuxtApp.hook.mock.calls.find(c => c[0] === 'vue:error')?.[1] as ( + e: unknown, + instance: unknown, + info: string, + ) => void; + const err = new Error('vue boom'); + const instance = { $props: { foo: 1 } }; + vueErrorCb(err, instance, 'render'); + + expect(reportSpy).toHaveBeenCalledWith({ error: err, instance, info: 'render' }); + }); + }); +});