diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index e715264ba0e4..9778ad6da483 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -50,12 +50,13 @@ "dependencies": { "@nuxt/kit": "^3.13.2", "@sentry/browser": "10.34.0", + "@sentry/bundler-plugin-core": "^4.7.0", "@sentry/cloudflare": "10.34.0", "@sentry/core": "10.34.0", "@sentry/node": "10.34.0", "@sentry/node-core": "10.34.0", - "@sentry/rollup-plugin": "^4.6.2", - "@sentry/vite-plugin": "^4.6.2", + "@sentry/rollup-plugin": "^4.7.0", + "@sentry/vite-plugin": "^4.7.0", "@sentry/vue": "10.34.0" }, "devDependencies": { diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 3656eac56e63..11b9e5ce2ff4 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -135,6 +135,10 @@ export default defineNuxtModule({ } nuxt.hooks.hook('nitro:init', nitro => { + if (nuxt.options?._prepare) { + return; + } + if (serverConfigFile) { addMiddlewareInstrumentation(nitro); } diff --git a/packages/nuxt/src/vite/buildEndUploadHook.ts b/packages/nuxt/src/vite/buildEndUploadHook.ts new file mode 100644 index 000000000000..21c19dfdc4cd --- /dev/null +++ b/packages/nuxt/src/vite/buildEndUploadHook.ts @@ -0,0 +1,130 @@ +import { existsSync } from 'node:fs'; +import type { Nuxt } from '@nuxt/schema'; +import type { createSentryBuildPluginManager as createSentryBuildPluginManagerType } from '@sentry/bundler-plugin-core'; +import * as path from 'path'; +import type { SentryNuxtModuleOptions } from '../common/types'; +import { getPluginOptions } from './sourceMaps'; + +/** + * A build-end hook that handles Sentry release creation and source map uploads. + * It creates a new Sentry release if configured, uploads source maps to Sentry, + * and optionally deletes the source map files after upload. + * + * This runs after both Vite (Nuxt) and Rollup (Nitro) builds complete, ensuring + * debug IDs are injected and source maps uploaded only once. + */ +// eslint-disable-next-line complexity +export async function handleBuildDoneHook( + sentryModuleOptions: SentryNuxtModuleOptions, + nuxt: Nuxt, + shouldDeleteFilesFallback?: { client: boolean; server: boolean }, +): Promise { + const debug = sentryModuleOptions.debug ?? false; + if (debug) { + // eslint-disable-next-line no-console + console.log('[Sentry] Nuxt build ended. Starting to upload build-time info to Sentry (release, source maps)...'); + } + + let createSentryBuildPluginManager: typeof createSentryBuildPluginManagerType | undefined; + try { + const bundlerPluginCore = await import('@sentry/bundler-plugin-core'); + createSentryBuildPluginManager = bundlerPluginCore.createSentryBuildPluginManager; + } catch (error) { + debug && + // eslint-disable-next-line no-console + console.warn('[Sentry] Could not load build manager package. Will not upload build-time info to Sentry.', error); + return; + } + + if (!createSentryBuildPluginManager) { + // eslint-disable-next-line no-console + debug && console.warn('[Sentry] Could not find createSentryBuildPluginManager in bundler plugin core.'); + return; + } + + const outputDir = nuxt.options.nitro?.output?.dir || path.join(nuxt.options.rootDir, '.output'); + + if (!existsSync(outputDir)) { + // eslint-disable-next-line no-console + debug && console.warn(`[Sentry] Output directory does not exist yet: ${outputDir}. Skipping source map upload.`); + return; + } + + const options = getPluginOptions(sentryModuleOptions, shouldDeleteFilesFallback, 'full'); + + // eslint-disable-next-line deprecation/deprecation + const sourceMapsUploadOptions = sentryModuleOptions.sourceMapsUploadOptions || {}; + const sourceMapsEnabled = + sentryModuleOptions.sourcemaps?.disable === true + ? false + : sentryModuleOptions.sourcemaps?.disable === false + ? true + : // eslint-disable-next-line deprecation/deprecation + (sourceMapsUploadOptions.enabled ?? true); + + if (sourceMapsEnabled) { + const existingIgnore = options.sourcemaps?.ignore || []; + const ignorePatterns = Array.isArray(existingIgnore) ? existingIgnore : [existingIgnore]; + + // node_modules source maps are ignored + const nodeModulesPatterns = ['**/node_modules/**', '**/node_modules/**/*.map']; + const hasNodeModulesIgnore = ignorePatterns.some( + pattern => typeof pattern === 'string' && pattern.includes('node_modules'), + ); + + if (!hasNodeModulesIgnore) { + ignorePatterns.push(...nodeModulesPatterns); + } + + options.sourcemaps = { + ...options.sourcemaps, + ignore: ignorePatterns.length > 0 ? ignorePatterns : undefined, + }; + + if (debug && ignorePatterns.length > 0) { + // eslint-disable-next-line no-console + console.log(`[Sentry] Excluding patterns from source map upload: ${ignorePatterns.join(', ')}`); + } + } + + try { + const sentryBuildPluginManager = createSentryBuildPluginManager(options, { + buildTool: 'nuxt', + loggerPrefix: '[Sentry Nuxt Module]', + }); + + await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); + await sentryBuildPluginManager.createRelease(); + + // eslint-disable-next-line no-console + debug && console.log('[Sentry] Successfully uploaded release information.'); + + if (!sourceMapsEnabled) { + debug && + // eslint-disable-next-line no-console + console.log('[Sentry] Source map upload is disabled. Skipping debugID injection and source map upload steps.'); + } else { + await sentryBuildPluginManager.injectDebugIds([outputDir]); + // eslint-disable-next-line no-console + debug && console.log('[Sentry] Successfully injected Debug IDs.'); + + // todo: rewriteSources seems to not be applied + await sentryBuildPluginManager.uploadSourcemaps([outputDir], { + // We don't want to prepare the artifacts because we injected Debug IDs manually before + prepareArtifacts: false, + }); + // eslint-disable-next-line no-console + debug && console.log('[Sentry] Successfully uploaded source maps.'); + + await sentryBuildPluginManager.deleteArtifacts(); + debug && + // eslint-disable-next-line no-console + console.log( + `[Sentry] Successfully deleted specified source map artifacts (${sentryModuleOptions.sourcemaps?.filesToDeleteAfterUpload ? '' : "based on Sentry's default "}\`filesToDeleteAfterUpload: [${options.sourcemaps?.filesToDeleteAfterUpload}\`]).`, + ); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error("[Sentry] Error during Sentry's build-end hook: ", error); + } +} diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index dff4f74df2f7..156f3d107919 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -3,6 +3,8 @@ import { sentryRollupPlugin, type SentryRollupPluginOptions } from '@sentry/roll import { sentryVitePlugin, type SentryVitePluginOptions } from '@sentry/vite-plugin'; import type { NitroConfig } from 'nitropack'; import type { SentryNuxtModuleOptions } from '../common/types'; +import { handleBuildDoneHook } from './buildEndUploadHook'; +import { shouldDisableSourceMapsUpload } from './utils'; /** * Whether the user enabled (true, 'hidden', 'inline') or disabled (false) source maps @@ -12,6 +14,15 @@ export type UserSourceMapSetting = 'enabled' | 'disabled' | 'unset' | undefined; /** A valid source map setting */ export type SourceMapSetting = boolean | 'hidden' | 'inline'; +/** + * Controls what functionality the bundler plugin provides. + * + * - `'release-injection-only'`: Plugin only injects release information. Source maps upload, + * debug ID injection, and file deletion are handled by the build-end hook. + * - `'full'`: Plugin handles everything including source maps upload and file deletion. + */ +export type PluginMode = 'release-injection-only' | 'full'; + /** * Setup source maps for Sentry inside the Nuxt module during build time (in Vite for Nuxt and Rollup for Nitro). */ @@ -35,7 +46,7 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu let shouldDeleteFilesFallback = { client: true, server: true }; nuxt.hook('modules:done', () => { - if (sourceMapsEnabled && !nuxt.options.dev) { + if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) { // Changing this setting will propagate: // - for client to viteConfig.build.sourceMap // - for server to viteConfig.build.sourceMap and nitro.sourceMap @@ -49,17 +60,29 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu server: previousSourceMapSettings.server === 'unset', }; - if ( - isDebug && - !moduleOptions.sourcemaps?.filesToDeleteAfterUpload && - // eslint-disable-next-line deprecation/deprecation - !sourceMapsUploadOptions.sourcemaps?.filesToDeleteAfterUpload && - (shouldDeleteFilesFallback.client || shouldDeleteFilesFallback.server) - ) { - // eslint-disable-next-line no-console - console.log( - "[Sentry] As Sentry enabled `'hidden'` source maps, source maps will be automatically deleted after uploading them to Sentry.", - ); + if (isDebug && (shouldDeleteFilesFallback.client || shouldDeleteFilesFallback.server)) { + const enabledDeleteFallbacks = + shouldDeleteFilesFallback.client && shouldDeleteFilesFallback.server + ? 'client-side and server-side' + : shouldDeleteFilesFallback.server + ? 'server-side' + : 'client-side'; + + if ( + !moduleOptions.sourcemaps?.filesToDeleteAfterUpload && + // eslint-disable-next-line deprecation/deprecation + !sourceMapsUploadOptions.sourcemaps?.filesToDeleteAfterUpload + ) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] We enabled \`'hidden'\` source maps for your ${enabledDeleteFallbacks} build. Source map files will be automatically deleted after uploading them to Sentry.`, + ); + } else { + // eslint-disable-next-line no-console + console.log( + `[Sentry] We enabled \`'hidden'\` source maps for your ${enabledDeleteFallbacks} build. Source map files will be deleted according to your \`sourcemaps.filesToDeleteAfterUpload\` configuration. To use automatic deletion instead, leave \`filesToDeleteAfterUpload\` empty.`, + ); + } } } }); @@ -86,20 +109,21 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu 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.`); + console.log(`[Sentry] Adding Sentry Vite plugin to the ${runtime} runtime for release injection.`); } } - // 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. + // Add Sentry Vite plugin for release injection only + // Source maps upload, debug ID injection, and artifact deletion are handled in the build:done hook viteConfig.plugins = viteConfig.plugins || []; - viteConfig.plugins.push(sentryVitePlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))); + viteConfig.plugins.push( + sentryVitePlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback, 'release-injection-only')), + ); } }); nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { - if (sourceMapsEnabled && !nitroConfig.dev) { + if (sourceMapsEnabled && !nitroConfig.dev && !nuxt.options?._prepare) { if (!nitroConfig.rollupConfig) { nitroConfig.rollupConfig = {}; } @@ -115,16 +139,24 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu if (isDebug) { // eslint-disable-next-line no-console - console.log('[Sentry] Adding Sentry Rollup plugin to the server runtime.'); + console.log('[Sentry] Adding Sentry Rollup plugin to the server runtime for release injection.'); } - // Add Sentry plugin - // Runs only on server-side (Nitro) + // Add Sentry Rollup plugin for release injection only + // Source maps upload, debug ID injection, and artifact deletion are handled in the build:done hook nitroConfig.rollupConfig.plugins.push( - sentryRollupPlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback)), + sentryRollupPlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback, 'release-injection-only')), ); } }); + + // This ensures debug IDs are injected and source maps uploaded only once at the end of the build + nuxt.hook('close', async () => { + // `nuxt prepare` runs during package installation -> we don't need to upload anything here + if (!nuxt.options.dev && !nuxt.options._prepare) { + await handleBuildDoneHook(moduleOptions, nuxt, shouldDeleteFilesFallback); + } + }); } /** @@ -144,11 +176,14 @@ function normalizePath(path: string): string { export function getPluginOptions( moduleOptions: SentryNuxtModuleOptions, shouldDeleteFilesFallback?: { client: boolean; server: boolean }, + pluginMode: PluginMode = 'release-injection-only', ): SentryVitePluginOptions | SentryRollupPluginOptions { // eslint-disable-next-line deprecation/deprecation const sourceMapsUploadOptions = moduleOptions.sourceMapsUploadOptions || {}; - const shouldDeleteFilesAfterUpload = shouldDeleteFilesFallback?.client || shouldDeleteFilesFallback?.server; + const shouldDeleteFilesAfterUpload = + pluginMode === 'full' && (shouldDeleteFilesFallback?.client || shouldDeleteFilesFallback?.server); + const fallbackFilesToDelete = [ ...(shouldDeleteFilesFallback?.client ? ['.*/**/public/**/*.map'] : []), ...(shouldDeleteFilesFallback?.server @@ -197,6 +232,8 @@ export function getPluginOptions( release: { // eslint-disable-next-line deprecation/deprecation name: moduleOptions.release?.name ?? sourceMapsUploadOptions.release?.name, + // todo: release is injected twice sometimes (fix in bundler plugins) + inject: moduleOptions.release?.inject, // Support all release options from BuildTimeOptionsBase ...moduleOptions.release, ...moduleOptions?.unstable_sentryBundlerPluginOptions?.release, @@ -209,7 +246,7 @@ export function getPluginOptions( ...moduleOptions?.unstable_sentryBundlerPluginOptions, sourcemaps: { - disable: moduleOptions.sourcemaps?.disable, + disable: shouldDisableSourceMapsUpload(moduleOptions, pluginMode), // The server/client files are in different places depending on the nitro preset (e.g. '.output/server' or '.netlify/functions-internal/server') // We cannot determine automatically how the build folder looks like (depends on the preset), so we have to accept that source maps are uploaded multiple times (with the vitePlugin for Nuxt and the rollupPlugin for Nitro). // If we could know where the server/client assets are located, we could do something like this (based on the Nitro preset): isNitro ? ['./.output/server/**/*'] : ['./.output/public/**/*'], @@ -217,11 +254,14 @@ export function getPluginOptions( assets: sourcemapsOptions.assets ?? deprecatedSourcemapsOptions.assets ?? undefined, // eslint-disable-next-line deprecation/deprecation ignore: sourcemapsOptions.ignore ?? deprecatedSourcemapsOptions.ignore ?? undefined, - filesToDeleteAfterUpload: filesToDeleteAfterUpload - ? filesToDeleteAfterUpload - : shouldDeleteFilesFallback?.server || shouldDeleteFilesFallback?.client - ? fallbackFilesToDelete - : undefined, + filesToDeleteAfterUpload: + pluginMode === 'release-injection-only' + ? undefined // Setting this to `undefined` to only delete files during buildEndUploadHook (not before, when vite/rollup plugins run) + : filesToDeleteAfterUpload + ? filesToDeleteAfterUpload + : shouldDeleteFilesFallback?.server || shouldDeleteFilesFallback?.client + ? fallbackFilesToDelete + : undefined, rewriteSources: (source: string) => normalizePath(source), ...moduleOptions?.unstable_sentryBundlerPluginOptions?.sourcemaps, }, diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index fc55ebf412c2..8920d9559136 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -2,6 +2,49 @@ import { consoleSandbox } from '@sentry/core'; import * as fs from 'fs'; import type { Nuxt } from 'nuxt/schema'; import * as path from 'path'; +import type { SentryNuxtModuleOptions } from '../common/types'; +import type { PluginMode } from './sourceMaps'; + +/** + * Determines whether source maps upload should be disabled in the bundler plugin. + * + * The logic follows a clear precedence: + * 1. Plugin mode check: If mode is `'release-injection-only'`, always disable upload + * (upload is handled by the build-end hook instead) + * 2. When mode is `'full'`, check user options with precedence: + * a. New option: `moduleOptions.sourcemaps.disable` (takes precedence) + * b. Deprecated option: `sourceMapsUploadOptions.enabled` (inverted, used as fallback) + * c. Default: `false` (upload enabled when mode is 'full' and no user option set) + * + * Only exported for testing. + */ +export function shouldDisableSourceMapsUpload( + moduleOptions: SentryNuxtModuleOptions, + pluginMode: PluginMode = 'release-injection-only', +): boolean { + // Step 1: If plugin mode is 'release-injection-only', always disable upload + if (pluginMode !== 'full') { + return true; + } + + // Step 2: Plugin mode is 'full' - check user options + // Note: disable can be boolean or 'disable-upload' - both truthy values mean disable upload + const disableOption = moduleOptions.sourcemaps?.disable; + if (disableOption !== undefined) { + // true or 'disable-upload' -> disable upload; false -> enable upload + return disableOption !== false; + } + + // Priority 2: Deprecated option + // eslint-disable-next-line deprecation/deprecation + const deprecatedEnabled = moduleOptions.sourceMapsUploadOptions?.enabled; + if (deprecatedEnabled !== undefined) { + return !deprecatedEnabled; + } + + // Default: upload enabled when plugin mode is 'full' + return false; +} /** * Find the default SDK init file for the given type (client or server). diff --git a/packages/nuxt/test/vite/buildEndUploadHook.test.ts b/packages/nuxt/test/vite/buildEndUploadHook.test.ts new file mode 100644 index 000000000000..3cfbfaf36a51 --- /dev/null +++ b/packages/nuxt/test/vite/buildEndUploadHook.test.ts @@ -0,0 +1,172 @@ +import type { Nuxt } from '@nuxt/schema'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SentryNuxtModuleOptions } from '../../src/common/types'; +import { handleBuildDoneHook } from '../../src/vite/buildEndUploadHook'; + +vi.mock('node:fs'); +vi.mock('../../src/vite/sourceMaps'); +vi.mock('@sentry/bundler-plugin-core'); + +describe('handleBuildDoneHook', () => { + let mockNuxt: Nuxt; + let mockSentryBuildPluginManager: any; + let mockCreateRelease: ReturnType; + let mockUploadSourcemaps: ReturnType; + let mockInjectDebugIds: ReturnType; + let mockDeleteArtifacts: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + + mockCreateRelease = vi.fn().mockResolvedValue(undefined); + mockUploadSourcemaps = vi.fn().mockResolvedValue(undefined); + mockInjectDebugIds = vi.fn().mockResolvedValue(undefined); + mockDeleteArtifacts = vi.fn().mockResolvedValue(undefined); + + mockSentryBuildPluginManager = { + createRelease: mockCreateRelease, + uploadSourcemaps: mockUploadSourcemaps, + injectDebugIds: mockInjectDebugIds, + deleteArtifacts: mockDeleteArtifacts, + telemetry: { + emitBundlerPluginExecutionSignal: vi.fn().mockResolvedValue(undefined), + }, + }; + + const { createSentryBuildPluginManager } = await import('@sentry/bundler-plugin-core'); + vi.mocked(createSentryBuildPluginManager).mockReturnValue(mockSentryBuildPluginManager); + + const { existsSync } = await import('node:fs'); + vi.mocked(existsSync).mockReturnValue(true); + + const { getPluginOptions } = await import('../../src/vite/sourceMaps'); + vi.mocked(getPluginOptions).mockReturnValue({}); + + mockNuxt = { + options: { + rootDir: '/test', + nitro: { output: { dir: '/test/.output' } }, + }, + } as any; + }); + + it('should create release even when source maps are disabled', async () => { + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: true }, + }; + + await handleBuildDoneHook(options, mockNuxt, undefined); + + expect(mockCreateRelease).toHaveBeenCalledTimes(1); + expect(mockInjectDebugIds).not.toHaveBeenCalled(); + expect(mockUploadSourcemaps).not.toHaveBeenCalled(); + }); + + it('should upload source maps when enabled', async () => { + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: false }, + }; + + await handleBuildDoneHook(options, mockNuxt, undefined); + + expect(mockCreateRelease).toHaveBeenCalledTimes(1); + expect(mockInjectDebugIds).toHaveBeenCalledWith(['/test/.output']); + expect(mockUploadSourcemaps).toHaveBeenCalledWith(['/test/.output'], { prepareArtifacts: false }); + expect(mockDeleteArtifacts).toHaveBeenCalledTimes(1); + }); + + it('should add node_modules to ignore patterns when source maps are enabled', async () => { + const { createSentryBuildPluginManager } = await import('@sentry/bundler-plugin-core'); + const { getPluginOptions } = await import('../../src/vite/sourceMaps'); + + vi.mocked(getPluginOptions).mockReturnValue({}); + + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: false }, + }; + + await handleBuildDoneHook(options, mockNuxt, undefined); + + const pluginOptions = vi.mocked(createSentryBuildPluginManager).mock.calls[0]?.[0]; + + expect(pluginOptions?.sourcemaps?.ignore).toEqual(['**/node_modules/**', '**/node_modules/**/*.map']); + }); + + it('should not add node_modules patterns when source maps are disabled', async () => { + const { createSentryBuildPluginManager } = await import('@sentry/bundler-plugin-core'); + const { getPluginOptions } = await import('../../src/vite/sourceMaps'); + + vi.mocked(getPluginOptions).mockReturnValue({}); + + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: true }, + }; + + await handleBuildDoneHook(options, mockNuxt, undefined); + + const pluginOptions = vi.mocked(createSentryBuildPluginManager).mock.calls[0]?.[0]; + + expect(pluginOptions?.sourcemaps?.ignore).toBeUndefined(); + }); + + it('should not log source map related messages when source maps are disabled', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: true }, + debug: true, + }; + + await handleBuildDoneHook(options, mockNuxt, undefined); + + const allLogs = consoleLogSpy.mock.calls.map(call => call.join(' ')); + + const alwaysShownLogsStrings = [ + '[Sentry] Nuxt build ended. Starting to upload build-time info to Sentry (release, source maps)...', + '[Sentry] Source map upload is disabled. Skipping debugID injection and source map upload steps.', + ]; + + const loggedGeneralLogs = allLogs.filter(log => alwaysShownLogsStrings.includes(log)); + + const loggedSourceMapLogs = allLogs.filter(log => { + const lowerCaseLog = log.toLowerCase(); + + if (alwaysShownLogsStrings.map(log => log.toLowerCase()).includes(lowerCaseLog)) { + return false; + } + + return lowerCaseLog.includes('source map') || lowerCaseLog.includes('sourcemap'); + }); + + expect(loggedGeneralLogs).toHaveLength(2); + expect(loggedSourceMapLogs).toHaveLength(0); + + consoleLogSpy.mockRestore(); + }); + + it('should pass shouldDeleteFilesFallback to getPluginOptions', async () => { + const { getPluginOptions } = await import('../../src/vite/sourceMaps'); + + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: false }, + }; + + const shouldDeleteFilesFallback = { client: true, server: false }; + + await handleBuildDoneHook(options, mockNuxt, shouldDeleteFilesFallback); + + expect(getPluginOptions).toHaveBeenCalledWith(options, shouldDeleteFilesFallback); + }); + + it('should pass undefined shouldDeleteFilesFallback when not provided', async () => { + const { getPluginOptions } = await import('../../src/vite/sourceMaps'); + + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: false }, + }; + + await handleBuildDoneHook(options, mockNuxt, undefined); + + expect(getPluginOptions).toHaveBeenCalledWith(options, undefined); + }); +}); diff --git a/packages/nuxt/test/vite/sourceMaps.test.ts b/packages/nuxt/test/vite/sourceMaps.test.ts index e4ae498639b0..e1b5c0e5dbf1 100644 --- a/packages/nuxt/test/vite/sourceMaps.test.ts +++ b/packages/nuxt/test/vite/sourceMaps.test.ts @@ -1,7 +1,7 @@ import type { Nuxt } from '@nuxt/schema'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SentryNuxtModuleOptions } from '../../src/common/types'; -import type { SourceMapSetting } from '../../src/vite/sourceMaps'; +import type { PluginMode, SourceMapSetting } from '../../src/vite/sourceMaps'; import { changeNuxtSourceMapSettings, getPluginOptions, @@ -85,7 +85,8 @@ describe('getPluginOptions', () => { }, debug: true, }; - const options = getPluginOptions(customOptions, { client: true, server: false }); + // Pass 'full' mode to test filesToDeleteAfterUpload handling + const options = getPluginOptions(customOptions, { client: true, server: false }, 'full'); expect(options).toEqual( expect.objectContaining({ org: 'custom-org', @@ -151,7 +152,8 @@ describe('getPluginOptions', () => { }, }; - const result = getPluginOptions(options); + // Pass 'full' mode to test filesToDeleteAfterUpload handling (only in build-end hook) + const result = getPluginOptions(options, undefined, 'full'); expect(result).toMatchObject({ org: 'new-org', @@ -318,16 +320,175 @@ describe('getPluginOptions', () => { expectedFilesToDelete: undefined, }, ])( - 'sets filesToDeleteAfterUpload correctly when $name', + 'sets filesToDeleteAfterUpload correctly when $name (with pluginMode=full)', ({ clientFallback, serverFallback, customOptions, expectedFilesToDelete }) => { - const options = getPluginOptions(customOptions as SentryNuxtModuleOptions, { - client: clientFallback, - server: serverFallback, - }); + // These tests verify filesToDeleteAfterUpload behavior when pluginMode is 'full' + const options = getPluginOptions( + customOptions as SentryNuxtModuleOptions, + { client: clientFallback, server: serverFallback }, + 'full', + ); expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expectedFilesToDelete); }, ); + + describe('filesToDeleteAfterUpload with pluginMode', () => { + it.each([ + // pluginMode='release-injection-only' - always undefined (files deleted only in buildEndUploadHook) + { + pluginMode: 'release-injection-only' as PluginMode, + clientFallback: true, + serverFallback: true, + customOptions: {}, + expected: undefined, + desc: 'release-injection-only + fallbacks set -> undefined (deferred to buildEndUploadHook)', + }, + { + pluginMode: 'release-injection-only' as PluginMode, + clientFallback: false, + serverFallback: false, + customOptions: { sourcemaps: { filesToDeleteAfterUpload: ['custom/**/*.map'] } }, + expected: undefined, + desc: 'release-injection-only + custom filesToDeleteAfterUpload -> undefined (deferred to buildEndUploadHook)', + }, + { + pluginMode: undefined as PluginMode | undefined, + clientFallback: true, + serverFallback: true, + customOptions: {}, + expected: undefined, + desc: 'default pluginMode + fallbacks set -> undefined (deferred to buildEndUploadHook)', + }, + + // pluginMode='full' - respects options + { + pluginMode: 'full' as PluginMode, + clientFallback: true, + serverFallback: true, + customOptions: {}, + expected: [ + '.*/**/public/**/*.map', + '.*/**/server/**/*.map', + '.*/**/output/**/*.map', + '.*/**/function/**/*.map', + ], + desc: 'full + both fallbacks -> uses fallback paths', + }, + { + pluginMode: 'full' as PluginMode, + clientFallback: true, + serverFallback: false, + customOptions: {}, + expected: ['.*/**/public/**/*.map'], + desc: 'full + client fallback only -> uses client fallback path', + }, + { + pluginMode: 'full' as PluginMode, + clientFallback: false, + serverFallback: true, + customOptions: {}, + expected: ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], + desc: 'full + server fallback only -> uses server fallback paths', + }, + { + pluginMode: 'full' as PluginMode, + clientFallback: false, + serverFallback: false, + customOptions: { sourcemaps: { filesToDeleteAfterUpload: ['custom/**/*.map'] } }, + expected: ['custom/**/*.map'], + desc: 'full + custom filesToDeleteAfterUpload -> uses custom paths', + }, + { + pluginMode: 'full' as PluginMode, + clientFallback: false, + serverFallback: false, + customOptions: {}, + expected: undefined, + desc: 'full + no fallbacks + no custom -> undefined', + }, + ])('$desc', ({ pluginMode, clientFallback, serverFallback, customOptions, expected }) => { + const options = getPluginOptions( + customOptions as SentryNuxtModuleOptions, + { client: clientFallback, server: serverFallback }, + pluginMode, + ); + + expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expected); + }); + }); + + describe('getPluginOptions sourcemaps.disable integration', () => { + it.each([ + // pluginMode='release-injection-only' - always disabled + { + pluginMode: 'release-injection-only' as PluginMode, + moduleDisable: true, + expected: true, + desc: 'release-injection-only + user disable=true -> disabled', + }, + { + pluginMode: 'release-injection-only' as PluginMode, + moduleDisable: false, + expected: true, + desc: 'release-injection-only + user disable=false -> disabled (pluginMode wins)', + }, + { + pluginMode: 'release-injection-only' as PluginMode, + moduleDisable: undefined, + expected: true, + desc: 'release-injection-only + no user option -> disabled', + }, + + // pluginMode='full' - respects user options + { + pluginMode: 'full' as PluginMode, + moduleDisable: true, + expected: true, + desc: 'full + user disable=true -> disabled', + }, + { + pluginMode: 'full' as PluginMode, + moduleDisable: false, + expected: false, + desc: 'full + user disable=false -> enabled', + }, + { + pluginMode: 'full' as PluginMode, + moduleDisable: undefined, + expected: false, + desc: 'full + no user option -> enabled (default)', + }, + + // Default pluginMode (undefined -> defaults to 'release-injection-only') + { + pluginMode: undefined as PluginMode | undefined, + moduleDisable: true, + expected: true, + desc: 'default pluginMode + user disable=true -> disabled', + }, + { + pluginMode: undefined as PluginMode | undefined, + moduleDisable: false, + expected: true, + desc: 'default pluginMode + user disable=false -> disabled (pluginMode wins)', + }, + { + pluginMode: undefined as PluginMode | undefined, + moduleDisable: undefined, + expected: true, + desc: 'default pluginMode + no user option -> disabled', + }, + ])('$desc', ({ moduleDisable, pluginMode, expected }) => { + const options = getPluginOptions( + { sourcemaps: moduleDisable !== undefined ? { disable: moduleDisable } : {} }, + undefined, + pluginMode, + ); + + expect(options.sourcemaps?.disable).toBe(expected); + }); + }); }); describe('validate sourcemap settings', () => { diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index 1b256987828b..43fc709140cf 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -13,6 +13,7 @@ import { SENTRY_REEXPORTED_FUNCTIONS, SENTRY_WRAPPED_ENTRY, SENTRY_WRAPPED_FUNCTIONS, + shouldDisableSourceMapsUpload, } from '../../src/vite/utils'; vi.mock('fs'); @@ -428,3 +429,76 @@ describe('addOTelCommonJSImportAlias', () => { expect(nuxtMock.options.alias).toBeUndefined(); }); }); + +describe('shouldDisableSourceMapsUpload', () => { + describe("pluginMode='release-injection-only'", () => { + it.each([ + { moduleDisable: true, desc: 'with user option disable=true' }, + { moduleDisable: false, desc: 'with user option disable=false' }, + { moduleDisable: undefined, desc: 'with no user option' }, + ])('always returns true (disabled) $desc', ({ moduleDisable }) => { + const result = shouldDisableSourceMapsUpload( + { sourcemaps: moduleDisable !== undefined ? { disable: moduleDisable } : {} }, + 'release-injection-only', + ); + expect(result).toBe(true); + }); + + it('ignores deprecated enabled option when pluginMode is release-injection-only', () => { + const result = shouldDisableSourceMapsUpload( + { sourceMapsUploadOptions: { enabled: true } }, + 'release-injection-only', + ); + expect(result).toBe(true); + }); + + it('defaults to disabled when pluginMode param is undefined', () => { + const result = shouldDisableSourceMapsUpload({}, undefined); + expect(result).toBe(true); + }); + }); + + describe("pluginMode='full'", () => { + describe('with new option (sourcemaps.disable)', () => { + it('returns true when disable=true', () => { + const result = shouldDisableSourceMapsUpload({ sourcemaps: { disable: true } }, 'full'); + expect(result).toBe(true); + }); + + it('returns false when disable=false', () => { + const result = shouldDisableSourceMapsUpload({ sourcemaps: { disable: false } }, 'full'); + expect(result).toBe(false); + }); + + it('new option takes precedence over deprecated option', () => { + // New option says enable (disable=false), deprecated says disable (enabled=false) + // New option should win + const result = shouldDisableSourceMapsUpload( + { sourcemaps: { disable: false }, sourceMapsUploadOptions: { enabled: false } }, + 'full', + ); + expect(result).toBe(false); + }); + }); + + // todo(v11): these tests can be removed when deprecated option is removed + describe('with deprecated option (sourceMapsUploadOptions.enabled)', () => { + it('returns true when enabled=false (inverted)', () => { + const result = shouldDisableSourceMapsUpload({ sourceMapsUploadOptions: { enabled: false } }, 'full'); + expect(result).toBe(true); + }); + + it('returns false when enabled=true (inverted)', () => { + const result = shouldDisableSourceMapsUpload({ sourceMapsUploadOptions: { enabled: true } }, 'full'); + expect(result).toBe(false); + }); + }); + + describe('with no user options', () => { + it('returns false (upload enabled by default)', () => { + const result = shouldDisableSourceMapsUpload({}, 'full'); + expect(result).toBe(false); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 9ad1fe308e75..b059f3020863 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7074,6 +7074,11 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.6.2.tgz#b052ded0fc12088d4a5032a4022b65551717a631" integrity sha512-6VTjLJXtIHKwxMmThtZKwi1+hdklLNzlbYH98NhbH22/Vzb/c6BlSD2b5A0NGN9vFB807rD4x4tuP+Su7BxQXQ== +"@sentry/babel-plugin-component-annotate@4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.7.0.tgz#46841deb27275b7d235f2fbce42c5156ad6c7ae6" + integrity sha512-MkyajDiO17/GaHHFgOmh05ZtOwF5hmm9KRjVgn9PXHIdpz+TFM5mkp1dABmR6Y75TyNU98Z1aOwPOgyaR5etJw== + "@sentry/bundler-plugin-core@4.6.2", "@sentry/bundler-plugin-core@^4.6.2": version "4.6.2" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.6.2.tgz#65239308aba07de9dad48bf51d6589be5d492860" @@ -7088,6 +7093,20 @@ magic-string "0.30.8" unplugin "1.0.1" +"@sentry/bundler-plugin-core@4.7.0", "@sentry/bundler-plugin-core@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.7.0.tgz#00ab83727df34bbbe170f032fa948e6f21f43185" + integrity sha512-gFdEtiup/7qYhN3vp1v2f0WL9AG9OorWLtIpfSBYbWjtzklVNg1sizvNyZ8nEiwtnb25LzvvCUbOP1SyP6IodQ== + dependencies: + "@babel/core" "^7.18.5" + "@sentry/babel-plugin-component-annotate" "4.7.0" + "@sentry/cli" "^2.57.0" + dotenv "^16.3.1" + find-up "^5.0.0" + glob "^10.5.0" + magic-string "0.30.8" + unplugin "1.0.1" + "@sentry/cli-darwin@2.58.4": version "2.58.4" resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.58.4.tgz#5e3005c1f845acac243e8dcb23bef17337924768" @@ -7148,12 +7167,12 @@ "@sentry/cli-win32-i686" "2.58.4" "@sentry/cli-win32-x64" "2.58.4" -"@sentry/rollup-plugin@^4.6.2": - version "4.6.2" - resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.6.2.tgz#e03a835e52c4613b2c856ff3cb411f5683176c78" - integrity sha512-sTgh24KfV8iJhv1zESZi6atgJEgOPpwy1W/UqOdmKPyDW5FkX9Zp9lyMF+bbJDWBqhACUJBGsIbE3MAonLX3wQ== +"@sentry/rollup-plugin@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.7.0.tgz#92f9a5ed6b27de382ece4e973d9854099f62c1af" + integrity sha512-G928V05BLAIAIky42AN6zTDIKwfTYzWQ/OivSBTY3ZFJ2Db3lkB5UFHhtRsTjT9Hy/uZnQQjs397rixn51X3Vg== dependencies: - "@sentry/bundler-plugin-core" "4.6.2" + "@sentry/bundler-plugin-core" "4.7.0" unplugin "1.0.1" "@sentry/vite-plugin@^4.6.2": @@ -7164,6 +7183,14 @@ "@sentry/bundler-plugin-core" "4.6.2" unplugin "1.0.1" +"@sentry/vite-plugin@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-4.7.0.tgz#2d819ff0cc40d6a85503e86f834e358bad2cdde5" + integrity sha512-eQXDghOQLsYwnHutJo8TCzhG4gp0KLNq3h96iqFMhsbjnNnfYeCX1lIw1pJEh/az3cDwSyPI/KGkvf8hr0dZmQ== + dependencies: + "@sentry/bundler-plugin-core" "4.7.0" + unplugin "1.0.1" + "@sentry/webpack-plugin@^4.6.2": version "4.6.2" resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-4.6.2.tgz#371c00cc5ce7654e34c123accd471f55b6ce4ed4"