diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index f2968d70482d..55656e103738 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -3,6 +3,7 @@ import { addPluginTemplate, addServerPlugin, addTemplate, + addVitePlugin, createResolver, defineNuxtModule, } from '@nuxt/kit'; @@ -88,7 +89,7 @@ export default defineNuxtModule({ } if (clientConfigFile || serverConfigFile) { - setupSourceMaps(moduleOptions, nuxt); + setupSourceMaps(moduleOptions, nuxt, addVitePlugin); } addOTelCommonJSImportAlias(nuxt); diff --git a/packages/nuxt/src/vite/sentryVitePlugin.ts b/packages/nuxt/src/vite/sentryVitePlugin.ts new file mode 100644 index 000000000000..78c11110bf72 --- /dev/null +++ b/packages/nuxt/src/vite/sentryVitePlugin.ts @@ -0,0 +1,57 @@ +import type { Nuxt } from '@nuxt/schema'; +import { sentryVitePlugin } from '@sentry/vite-plugin'; +import type { ConfigEnv, Plugin, UserConfig } from 'vite'; +import type { SentryNuxtModuleOptions } from '../common/types'; +import { extractNuxtSourceMapSetting, getPluginOptions, validateDifferentSourceMapSettings } from './sourceMaps'; + +/** + * Creates a Vite plugin that adds the Sentry Vite plugin and validates source map settings. + */ +export function createSentryViteConfigPlugin(options: { + nuxt: Nuxt; + moduleOptions: SentryNuxtModuleOptions; + sourceMapsEnabled: boolean; + shouldDeleteFilesFallback: { client: boolean; server: boolean }; +}): Plugin { + const { nuxt, moduleOptions, sourceMapsEnabled, shouldDeleteFilesFallback } = options; + const isDebug = moduleOptions.debug; + + return { + name: 'sentry-nuxt-vite-config', + config(viteConfig: UserConfig, env: ConfigEnv) { + // Only run in production builds + if (!sourceMapsEnabled || env.mode === 'development' || nuxt.options?._prepare) { + return; + } + + // Detect runtime from Vite config + // In Nuxt, SSR builds have build.ssr: true, client builds don't + const runtime = viteConfig.build?.ssr ? 'server' : 'client'; + + const nuxtSourceMapSetting = extractNuxtSourceMapSetting(nuxt, runtime); + + // Initialize build config if needed + viteConfig.build = viteConfig.build || {}; + const viteSourceMap = viteConfig.build.sourcemap; + + // Vite source map options are the same as the Nuxt source map config options (unless overwritten) + validateDifferentSourceMapSettings({ + nuxtSettingKey: `sourcemap.${runtime}`, + nuxtSettingValue: nuxtSourceMapSetting, + otherSettingKey: 'viteConfig.build.sourcemap', + otherSettingValue: viteSourceMap, + }); + + if (isDebug) { + // eslint-disable-next-line no-console + console.log(`[Sentry] Adding Sentry Vite plugin to the ${runtime} runtime.`); + } + + // Add Sentry plugin by mutating the config + // Vite plugin is added on the client and server side (plugin runs for both builds) + // Nuxt client source map is 'false' by default. Warning about this will be shown already in an earlier step, and it's also documented that `nuxt.sourcemap.client` needs to be enabled. + viteConfig.plugins = viteConfig.plugins || []; + viteConfig.plugins.push(sentryVitePlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))); + }, + }; +} diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index 771be8d3d532..b270a34a50b5 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -1,8 +1,10 @@ import type { Nuxt } from '@nuxt/schema'; import { sentryRollupPlugin, type SentryRollupPluginOptions } from '@sentry/rollup-plugin'; -import { sentryVitePlugin, type SentryVitePluginOptions } from '@sentry/vite-plugin'; +import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; import type { NitroConfig } from 'nitropack'; +import type { Plugin } from 'vite'; import type { SentryNuxtModuleOptions } from '../common/types'; +import { createSentryViteConfigPlugin } from './sentryVitePlugin'; /** * Whether the user enabled (true, 'hidden', 'inline') or disabled (false) source maps @@ -15,7 +17,11 @@ export type SourceMapSetting = boolean | 'hidden' | 'inline'; /** * Setup source maps for Sentry inside the Nuxt module during build time (in Vite for Nuxt and Rollup for Nitro). */ -export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nuxt): void { +export function setupSourceMaps( + moduleOptions: SentryNuxtModuleOptions, + nuxt: Nuxt, + addVitePlugin: (plugin: Plugin | (() => Plugin), options?: { dev?: boolean; build?: boolean }) => void, +): void { // TODO(v11): remove deprecated options (also from SentryNuxtModuleOptions type) const isDebug = moduleOptions.debug; @@ -32,7 +38,7 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu (sourceMapsUploadOptions.enabled ?? true); // In case we overwrite the source map settings, we default to deleting the files - let shouldDeleteFilesFallback = { client: true, server: true }; + const shouldDeleteFilesFallback = { client: true, server: true }; nuxt.hook('modules:done', () => { if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) { @@ -41,13 +47,12 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu // - for server to viteConfig.build.sourceMap and nitro.sourceMap // On server, nitro.rollupConfig.output.sourcemap remains unaffected from this change. - // ONLY THIS nuxt.sourcemap.(server/client) setting is the one Sentry will eventually overwrite with 'hidden' + // ONLY THIS nuxt.sourcemap.(server/client) setting is the one Sentry will overwrite with 'hidden', if needed. const previousSourceMapSettings = changeNuxtSourceMapSettings(nuxt, moduleOptions); - shouldDeleteFilesFallback = { - client: previousSourceMapSettings.client === 'unset', - server: previousSourceMapSettings.server === 'unset', - }; + // Mutate in place so the Vite plugin (which captured this object at registration time) sees the updated values + shouldDeleteFilesFallback.client = previousSourceMapSettings.client === 'unset'; + shouldDeleteFilesFallback.server = previousSourceMapSettings.server === 'unset'; if (isDebug && (shouldDeleteFilesFallback.client || shouldDeleteFilesFallback.server)) { const enabledDeleteFallbacks = @@ -76,39 +81,16 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu } }); - nuxt.hook('vite:extendConfig', async (viteConfig, env) => { - if (sourceMapsEnabled && viteConfig.mode !== 'development' && !nuxt.options?._prepare) { - const runtime = env.isServer ? 'server' : env.isClient ? 'client' : undefined; - const nuxtSourceMapSetting = extractNuxtSourceMapSetting(nuxt, runtime); - - viteConfig.build = viteConfig.build || {}; - const viteSourceMap = viteConfig.build.sourcemap; - - // Vite source map options are the same as the Nuxt source map config options (unless overwritten) - validateDifferentSourceMapSettings({ - nuxtSettingKey: `sourcemap.${runtime}`, - nuxtSettingValue: nuxtSourceMapSetting, - otherSettingKey: 'viteConfig.build.sourcemap', - otherSettingValue: viteSourceMap, - }); - - if (isDebug) { - if (!runtime) { - // eslint-disable-next-line no-console - console.log("[Sentry] Cannot detect runtime (client/server) inside hook 'vite:extendConfig'."); - } else { - // eslint-disable-next-line no-console - console.log(`[Sentry] Adding Sentry Vite plugin to the ${runtime} runtime.`); - } - } - - // Add Sentry plugin - // Vite plugin is added on the client and server side (hook runs twice) - // Nuxt client source map is 'false' by default. Warning about this will be shown already in an earlier step, and it's also documented that `nuxt.sourcemap.client` needs to be enabled. - viteConfig.plugins = viteConfig.plugins || []; - viteConfig.plugins.push(sentryVitePlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))); - } - }); + addVitePlugin( + createSentryViteConfigPlugin({ + nuxt, + moduleOptions, + sourceMapsEnabled, + shouldDeleteFilesFallback, + }), + // Only add source map plugin during build + { dev: false, build: true }, + ); nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { if (sourceMapsEnabled && !nitroConfig.dev && !nuxt.options?._prepare) { @@ -379,7 +361,13 @@ export function validateNitroSourceMapSettings( } } -function validateDifferentSourceMapSettings({ +/** + * Validates that source map settings are consistent between Nuxt and Vite/Nitro configurations. + * Logs a warning if conflicting settings are detected. + * + * @internal Only exported for testing. + */ +export function validateDifferentSourceMapSettings({ nuxtSettingKey, nuxtSettingValue, otherSettingKey, diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index 230c92b812a7..4a881583ac93 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -1,7 +1,50 @@ import type { Nuxt } from '@nuxt/schema'; +import type { Plugin, UserConfig } from 'vite'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SourceMapSetting } from '../../src/vite/sourceMaps'; +function createMockAddVitePlugin() { + let capturedPlugin: Plugin | null = null; + + const mockAddVitePlugin = vi.fn((plugin: Plugin | (() => Plugin)) => { + capturedPlugin = typeof plugin === 'function' ? plugin() : plugin; + }); + + return { + mockAddVitePlugin, + getCapturedPlugin: () => capturedPlugin, + }; +} + +type HookCallback = (...args: unknown[]) => void | Promise; + +function createMockNuxt(options: { + _prepare?: boolean; + dev?: boolean; + sourcemap?: SourceMapSetting | { server?: SourceMapSetting; client?: SourceMapSetting }; +}) { + const hooks: Record = {}; + + return { + options: { + _prepare: options._prepare ?? false, + dev: options.dev ?? false, + sourcemap: options.sourcemap ?? { server: undefined, client: undefined }, + }, + hook: (name: string, callback: HookCallback) => { + hooks[name] = hooks[name] || []; + hooks[name].push(callback); + }, + // Helper to trigger hooks in tests + triggerHook: async (name: string, ...args: unknown[]) => { + const callbacks = hooks[name] || []; + for (const callback of callbacks) { + await callback(...args); + } + }, + }; +} + describe('setupSourceMaps hooks', () => { const mockSentryVitePlugin = vi.fn(() => ({ name: 'sentry-vite-plugin' })); const mockSentryRollupPlugin = vi.fn(() => ({ name: 'sentry-rollup-plugin' })); @@ -32,93 +75,247 @@ describe('setupSourceMaps hooks', () => { mockSentryRollupPlugin.mockClear(); }); - type HookCallback = (...args: unknown[]) => void | Promise; + describe('vite plugin registration', () => { + it('calls `addVitePlugin` when setupSourceMaps is called', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); - function createMockNuxt(options: { - _prepare?: boolean; - dev?: boolean; - sourcemap?: SourceMapSetting | { server?: SourceMapSetting; client?: SourceMapSetting }; - }) { - const hooks: Record = {}; + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); - return { - options: { - _prepare: options._prepare ?? false, - dev: options.dev ?? false, - sourcemap: options.sourcemap ?? { server: undefined, client: undefined }, - }, - hook: (name: string, callback: HookCallback) => { - hooks[name] = hooks[name] || []; - hooks[name].push(callback); + const plugin = getCapturedPlugin(); + expect(plugin).not.toBeNull(); + expect(plugin?.name).toBe('sentry-nuxt-vite-config'); + // modules:done is called afterward. Later, the plugin is actually added + }); + + it.each([ + { + label: 'prepare mode', + nuxtOptions: { _prepare: true }, + viteOptions: { mode: 'production', command: 'build' as const }, + buildConfig: { build: {}, plugins: [] }, }, - // Helper to trigger hooks in tests - triggerHook: async (name: string, ...args: unknown[]) => { - const callbacks = hooks[name] || []; - for (const callback of callbacks) { - await callback(...args); - } + { + label: 'dev mode', + nuxtOptions: { dev: true }, + viteOptions: { mode: 'development', command: 'build' as const }, + buildConfig: { build: {}, plugins: [] }, }, - }; - } + ])('does not add plugins to vite config in $label', async ({ nuxtOptions, viteOptions, buildConfig }) => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt(nuxtOptions); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + + const plugin = getCapturedPlugin(); + expect(plugin).not.toBeNull(); + + if (plugin && typeof plugin.config === 'function') { + const viteConfig: UserConfig = buildConfig; + plugin.config(viteConfig, viteOptions); + expect(viteConfig.plugins?.length).toBe(0); + } + }); + + it.each([ + { label: 'server (SSR) build', buildConfig: { build: { ssr: true }, plugins: [] } }, + { label: 'client build', buildConfig: { build: { ssr: false }, plugins: [] } }, + ])('adds sentry vite plugin to vite config for $label in production', async ({ buildConfig }) => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + + const plugin = getCapturedPlugin(); + expect(plugin).not.toBeNull(); + + if (plugin && typeof plugin.config === 'function') { + const viteConfig: UserConfig = buildConfig; + plugin.config(viteConfig, { mode: 'production', command: 'build' }); + expect(viteConfig.plugins?.length).toBeGreaterThan(0); + } + }); + }); + + describe('sentry vite plugin calls', () => { + it('calls sentryVitePlugin in production mode', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); - it('should not call any source map related functions in nuxt prepare mode', async () => { - const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); - const mockNuxt = createMockNuxt({ _prepare: true }); + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); - setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt); + const plugin = getCapturedPlugin(); + if (plugin && typeof plugin.config === 'function') { + plugin.config({ build: { ssr: false }, plugins: [] }, { mode: 'production', command: 'build' }); + } - await mockNuxt.triggerHook('modules:done'); - await mockNuxt.triggerHook( - 'vite:extendConfig', - { build: {}, plugins: [], mode: 'production' }, - { isServer: true, isClient: false }, - ); - await mockNuxt.triggerHook('nitro:config', { rollupConfig: { plugins: [] }, dev: false }); + expect(mockSentryVitePlugin).toHaveBeenCalled(); + }); - expect(mockSentryVitePlugin).not.toHaveBeenCalled(); - expect(mockSentryRollupPlugin).not.toHaveBeenCalled(); + it.each([ + { label: 'prepare mode', nuxtOptions: { _prepare: true }, viteMode: 'production' as const }, + { label: 'dev mode', nuxtOptions: { dev: true }, viteMode: 'development' as const }, + ])('does not call sentryVitePlugin in $label', async ({ nuxtOptions, viteMode }) => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt(nuxtOptions); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); - expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('[Sentry]')); + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + + const plugin = getCapturedPlugin(); + if (plugin && typeof plugin.config === 'function') { + plugin.config({ build: {}, plugins: [] }, { mode: viteMode, command: 'build' }); + } + + expect(mockSentryVitePlugin).not.toHaveBeenCalled(); + }); }); - it('should call source map related functions when not in prepare mode', async () => { - const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); - const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); + describe('shouldDeleteFilesFallback passed to getPluginOptions in Vite plugin', () => { + const defaultFilesToDeleteAfterUpload = [ + '.*/**/public/**/*.map', + '.*/**/server/**/*.map', + '.*/**/output/**/*.map', + '.*/**/function/**/*.map', + ]; + + it('uses mutated shouldDeleteFilesFallback (unset → true): plugin.config() after modules:done gets fallback filesToDeleteAfterUpload', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ + _prepare: false, + dev: false, + sourcemap: { client: undefined, server: undefined }, + }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: false }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + + const plugin = getCapturedPlugin(); + expect(plugin).not.toBeNull(); + if (plugin && typeof plugin.config === 'function') { + plugin.config({ build: { ssr: false }, plugins: [] }, { mode: 'production', command: 'build' }); + } + + expect(mockSentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ + filesToDeleteAfterUpload: defaultFilesToDeleteAfterUpload, + }), + }), + ); + }); + + it('uses mutated shouldDeleteFilesFallback (explicitly enabled → false): plugin.config() after modules:done gets no filesToDeleteAfterUpload', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ + _prepare: false, + dev: false, + sourcemap: { client: true, server: true }, + }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: false }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + + const plugin = getCapturedPlugin(); + expect(plugin).not.toBeNull(); + if (plugin && typeof plugin.config === 'function') { + plugin.config({ build: { ssr: false }, plugins: [] }, { mode: 'production', command: 'build' }); + } - setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt); + const pluginOptions = (mockSentryVitePlugin?.mock?.calls?.[0] as unknown[])?.[0] as { + sourcemaps?: { filesToDeleteAfterUpload?: string[] }; + }; + expect(pluginOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined(); + }); + }); + + describe('nitro:config hook', () => { + it('adds sentryRollupPlugin to nitro rollup config in production mode', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); + const { mockAddVitePlugin } = createMockAddVitePlugin(); - await mockNuxt.triggerHook('modules:done'); + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); - const viteConfig = { build: {}, plugins: [] as unknown[], mode: 'production' }; - await mockNuxt.triggerHook('vite:extendConfig', viteConfig, { isServer: true, isClient: false }); + const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: {} }, dev: false }; + await mockNuxt.triggerHook('nitro:config', nitroConfig); - const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: {} }, dev: false }; - await mockNuxt.triggerHook('nitro:config', nitroConfig); + expect(mockSentryRollupPlugin).toHaveBeenCalled(); + expect(nitroConfig.rollupConfig.plugins.length).toBeGreaterThan(0); + }); - expect(mockSentryVitePlugin).toHaveBeenCalled(); - expect(mockSentryRollupPlugin).toHaveBeenCalled(); + it.each([ + { + label: 'prepare mode', + nuxtOptions: { _prepare: true }, + nitroConfig: { rollupConfig: { plugins: [] }, dev: false }, + }, + { label: 'dev mode', nuxtOptions: { dev: true }, nitroConfig: { rollupConfig: { plugins: [] }, dev: true } }, + ])('does not add sentryRollupPlugin to nitro rollup config in $label', async ({ nuxtOptions, nitroConfig }) => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt(nuxtOptions); + const { mockAddVitePlugin } = createMockAddVitePlugin(); - expect(viteConfig.plugins.length).toBeGreaterThan(0); - expect(nitroConfig.rollupConfig.plugins.length).toBeGreaterThan(0); + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:config', nitroConfig); - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[Sentry]')); + expect(mockSentryRollupPlugin).not.toHaveBeenCalled(); + }); }); - it('should not call source map related functions in dev mode', async () => { - const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); - const mockNuxt = createMockNuxt({ _prepare: false, dev: true }); + describe('debug logging', () => { + it('logs a [Sentry] message in production mode', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + + const plugin = getCapturedPlugin(); + if (plugin && typeof plugin.config === 'function') { + plugin.config({ build: { ssr: false }, plugins: [] }, { mode: 'production', command: 'build' }); + } + + const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: {} }, dev: false }; + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[Sentry] Adding Sentry Vite plugin to the client runtime.'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[Sentry] Adding Sentry Rollup plugin to the server runtime.'), + ); + }); + + it('does not log a [Sentry] messages in prepare mode', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ _prepare: true }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); - setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt); + const plugin = getCapturedPlugin(); + if (plugin && typeof plugin.config === 'function') { + plugin.config({ build: {}, plugins: [] }, { mode: 'production', command: 'build' }); + } - await mockNuxt.triggerHook('modules:done'); - await mockNuxt.triggerHook( - 'vite:extendConfig', - { build: {}, plugins: [], mode: 'development' }, - { isServer: true, isClient: false }, - ); - await mockNuxt.triggerHook('nitro:config', { rollupConfig: { plugins: [] }, dev: true }); + await mockNuxt.triggerHook('nitro:config', { rollupConfig: { plugins: [] }, dev: false }); - expect(mockSentryVitePlugin).not.toHaveBeenCalled(); - expect(mockSentryRollupPlugin).not.toHaveBeenCalled(); + expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('[Sentry]')); + }); }); }); diff --git a/packages/nuxt/test/vite/sourceMaps.test.ts b/packages/nuxt/test/vite/sourceMaps.test.ts index e4ae498639b0..87e87d14b635 100644 --- a/packages/nuxt/test/vite/sourceMaps.test.ts +++ b/packages/nuxt/test/vite/sourceMaps.test.ts @@ -4,7 +4,9 @@ import type { SentryNuxtModuleOptions } from '../../src/common/types'; import type { SourceMapSetting } from '../../src/vite/sourceMaps'; import { changeNuxtSourceMapSettings, + extractNuxtSourceMapSetting, getPluginOptions, + validateDifferentSourceMapSettings, validateNitroSourceMapSettings, } from '../../src/vite/sourceMaps'; @@ -35,6 +37,7 @@ describe('getPluginOptions', () => { authToken: 'default-token', url: 'https://santry.io', telemetry: true, + debug: false, sourcemaps: expect.objectContaining({ rewriteSources: expect.any(Function), }), @@ -43,7 +46,6 @@ describe('getPluginOptions', () => { metaFramework: 'nuxt', }), }), - debug: false, }), ); }); @@ -57,6 +59,7 @@ describe('getPluginOptions', () => { expect(options).toEqual( expect.objectContaining({ telemetry: true, + debug: false, sourcemaps: expect.objectContaining({ rewriteSources: expect.any(Function), }), @@ -65,7 +68,6 @@ describe('getPluginOptions', () => { metaFramework: 'nuxt', }), }), - debug: false, }), ); }); @@ -108,6 +110,14 @@ describe('getPluginOptions', () => { ); }); + it('normalizes source paths via rewriteSources', () => { + const options = getPluginOptions({} as SentryNuxtModuleOptions, undefined); + const rewrite = options.sourcemaps?.rewriteSources as ((s: string) => string) | undefined; + expect(rewrite).toBeTypeOf('function'); + expect(rewrite!('../../../foo/bar')).toBe('./foo/bar'); + expect(rewrite!('./local')).toBe('./local'); + }); + it('prioritizes new BuildTimeOptionsBase options over deprecated ones', () => { const options: SentryNuxtModuleOptions = { // New options @@ -268,27 +278,19 @@ describe('getPluginOptions', () => { name: 'both client and server fallback are true', clientFallback: true, serverFallback: true, - customOptions: {}, - expectedFilesToDelete: [ - '.*/**/public/**/*.map', - '.*/**/server/**/*.map', - '.*/**/output/**/*.map', - '.*/**/function/**/*.map', - ], + expected: ['.*/**/public/**/*.map', '.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], }, { name: 'only client fallback is true', clientFallback: true, serverFallback: false, - customOptions: {}, - expectedFilesToDelete: ['.*/**/public/**/*.map'], + expected: ['.*/**/public/**/*.map'], }, { name: 'only server fallback is true', clientFallback: false, serverFallback: true, - customOptions: {}, - expectedFilesToDelete: ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], + expected: ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], }, { name: 'no fallback, but custom filesToDeleteAfterUpload is provided (deprecated)', @@ -299,7 +301,7 @@ describe('getPluginOptions', () => { sourcemaps: { filesToDeleteAfterUpload: ['deprecated/path/**/*.map'] }, }, }, - expectedFilesToDelete: ['deprecated/path/**/*.map'], + expected: ['deprecated/path/**/*.map'], }, { name: 'no fallback, but custom filesToDeleteAfterUpload is provided (new)', @@ -308,46 +310,95 @@ describe('getPluginOptions', () => { customOptions: { sourcemaps: { filesToDeleteAfterUpload: ['new-custom/path/**/*.map'] }, }, - expectedFilesToDelete: ['new-custom/path/**/*.map'], + expected: ['new-custom/path/**/*.map'], }, { name: 'no fallback, both source maps explicitly false and no custom filesToDeleteAfterUpload', clientFallback: false, serverFallback: false, customOptions: {}, - expectedFilesToDelete: undefined, + expected: undefined, }, ])( 'sets filesToDeleteAfterUpload correctly when $name', - ({ clientFallback, serverFallback, customOptions, expectedFilesToDelete }) => { + ({ clientFallback, serverFallback, customOptions = {}, expected }) => { const options = getPluginOptions(customOptions as SentryNuxtModuleOptions, { client: clientFallback, server: serverFallback, }); - expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expectedFilesToDelete); + expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expected); }, ); }); -describe('validate sourcemap settings', () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); +describe('validateDifferentSourceMapSettings', () => { + let consoleWarnSpy: ReturnType; beforeEach(() => { - consoleLogSpy.mockClear(); - consoleWarnSpy.mockClear(); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { - vi.clearAllMocks(); + consoleWarnSpy.mockRestore(); }); - describe('should handle nitroConfig.rollupConfig.output.sourcemap settings', () => { - afterEach(() => { - vi.clearAllMocks(); + it('does not warn when both settings match', () => { + validateDifferentSourceMapSettings({ + nuxtSettingKey: 'sourcemap.server', + nuxtSettingValue: true, + otherSettingKey: 'nitro.sourceMap', + otherSettingValue: true, + }); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('warns when settings conflict', () => { + validateDifferentSourceMapSettings({ + nuxtSettingKey: 'sourcemap.server', + nuxtSettingValue: true, + otherSettingKey: 'nitro.sourceMap', + otherSettingValue: false, }); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('sourcemap.server')); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('nitro.sourceMap')); + }); +}); +describe('extractNuxtSourceMapSetting', () => { + it.each<{ + runtime: 'client' | 'server' | undefined; + sourcemap: SourceMapSetting | { client?: SourceMapSetting; server?: SourceMapSetting }; + expected: SourceMapSetting | undefined; + }>([ + { runtime: undefined, sourcemap: true, expected: undefined }, + { runtime: 'client', sourcemap: true, expected: true }, + { runtime: 'server', sourcemap: 'hidden', expected: 'hidden' }, + { runtime: 'client', sourcemap: { client: true, server: false }, expected: true }, + { runtime: 'server', sourcemap: { client: true, server: 'hidden' }, expected: 'hidden' }, + ])('returns correct value for runtime=$runtime and sourcemap type', ({ runtime, sourcemap, expected }) => { + const nuxt = { options: { sourcemap } }; + expect(extractNuxtSourceMapSetting(nuxt as Parameters[0], runtime)).toBe( + expected, + ); + }); +}); + +describe('validate sourcemap settings', () => { + let consoleWarnSpy: ReturnType; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + describe('should handle nitroConfig.rollupConfig.output.sourcemap settings', () => { type MinimalNitroConfig = { sourceMap?: SourceMapSetting; rollupConfig?: { @@ -401,17 +452,20 @@ describe('validate sourcemap settings', () => { describe('change Nuxt source map settings', () => { let nuxt: { options: { sourcemap: { client: boolean | 'hidden'; server: boolean | 'hidden' } } }; let sentryModuleOptions: SentryNuxtModuleOptions; - - const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + let consoleLogSpy: ReturnType; beforeEach(() => { - consoleLogSpy.mockClear(); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); // @ts-expect-error - Nuxt types don't accept `undefined` but we want to test this case nuxt = { options: { sourcemap: { client: undefined } } }; sentryModuleOptions = {}; }); + afterEach(() => { + consoleLogSpy.mockRestore(); + }); + it.each([ { clientSourcemap: false, expectedSourcemap: false, expectedReturn: 'disabled' }, { clientSourcemap: 'hidden', expectedSourcemap: 'hidden', expectedReturn: 'enabled' },