diff --git a/packages/nuxt/test/runtime/plugins/client.test.ts b/packages/nuxt/test/runtime/plugins/client.test.ts new file mode 100644 index 000000000000..092a5ed4a20b --- /dev/null +++ b/packages/nuxt/test/runtime/plugins/client.test.ts @@ -0,0 +1,255 @@ +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('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' }); + }); + }); +});