diff --git a/packages/nuxt/test/runtime/plugins/client.test.ts b/packages/nuxt/test/runtime/plugins/client.test.ts index 14b75383d1cb..a2aede723c95 100644 --- a/packages/nuxt/test/runtime/plugins/client.test.ts +++ b/packages/nuxt/test/runtime/plugins/client.test.ts @@ -1,58 +1,321 @@ -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(); + }); + + 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(); + }); }); - it.each(['app:error', 'vue:error'])('does not report 3xx/4xx Nuxt errors from %s', async hookName => { - const hook = await setupHook(hookName); + 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); - hook(nuxtError(404)); - hook(nuxtError(301)); + 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); + }); - expect(reportNuxtError).not.toHaveBeenCalled(); + 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(); + }); }); - it.each(['app:error', 'vue:error'])('reports 5xx Nuxt errors from %s', async hookName => { - const hook = await setupHook(hookName); + 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); - hook(nuxtError(503)); + 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(reportNuxtError).toHaveBeenCalledTimes(1); + 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 }); + }); }); - 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('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); - 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 = Object.assign(new Error('boom'), { statusCode: 404 }); + vueErrorCb(err, undefined, 'setup'); - expect(reportNuxtError).toHaveBeenCalledWith({ error, instance: undefined, info: 'setup function' }); + expect(reportSpy).toHaveBeenCalledWith({ error: err, instance: undefined, info: 'setup' }); + }); }); });