Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
333 changes: 298 additions & 35 deletions packages/nuxt/test/runtime/plugins/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
}) => Promise<void>;

type NuxtPluginShape = { setup: PluginSetup };

async function setupHook(hookName: string): Promise<Hook> {
const { default: plugin } = await import('../../../src/runtime/plugins/sentry.client');
const hooks: Record<string, Hook> = {};
const nuxtApp = { hook: (name: string, hook: Hook) => (hooks[name] = hook) };
await (plugin as unknown as { setup: (app: unknown) => Promise<void> }).setup(nuxtApp);
expect(hooks).toHaveProperty(hookName);
return hooks[hookName]!;
function makeClient(overrides: Partial<Client> = {}): 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<typeof vi.fn> } {
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 });
});
Comment thread
cursor[bot] marked this conversation as resolved.
});

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' });
});
});
});