diff --git a/packages/nuxt/src/vite/sourceMapDeletion.ts b/packages/nuxt/src/vite/sourceMapDeletion.ts new file mode 100644 index 000000000000..dc23fcf7c5f5 --- /dev/null +++ b/packages/nuxt/src/vite/sourceMapDeletion.ts @@ -0,0 +1,34 @@ +import { createSentryBuildPluginManager, type Options } from '@sentry/bundler-plugins/core'; + +export function withoutSourceMapDeletion(options: Options): Options { + return { + ...options, + sourcemaps: { + ...options.sourcemaps, + filesToDeleteAfterUpload: undefined, + }, + }; +} + +export async function deleteSourceMapsAfterBuild(options: Options): Promise { + const filesToDeleteAfterUpload = await options.sourcemaps?.filesToDeleteAfterUpload; + + if (filesToDeleteAfterUpload === undefined) { + return; + } + + const deletionOptions: Options = { + ...options, + sourcemaps: { + ...options.sourcemaps, + filesToDeleteAfterUpload, + }, + }; + + const sentryBuildPluginManager = createSentryBuildPluginManager(deletionOptions, { + buildTool: 'nuxt', + loggerPrefix: '[Sentry Nuxt]', + }); + + await sentryBuildPluginManager.deleteArtifacts(); +} diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index 676e31ca2e9c..4458fbdfc3a5 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -4,6 +4,7 @@ import { sentryVitePlugin, type SentryVitePluginOptions } from '@sentry/bundler- import type { NitroConfig } from 'nitropack'; import type { Plugin } from 'vite'; import type { SentryNuxtModuleOptions } from '../common/types'; +import { deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion'; import { validateSourceMapsOptionsPlugin } from './sentryVitePlugin'; /** @@ -71,7 +72,7 @@ export function setupSourceMaps( [ validateSourceMapsOptionsPlugin({ nuxt, moduleOptions, sourceMapsEnabled }), // Vite plugin is added on the client and server side (plugin runs for both builds) - ...sentryVitePlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback)), + ...sentryVitePlugin(withoutSourceMapDeletion(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))), ], { dev: false, build: true }, // Only add source map plugin during build ); @@ -98,10 +99,16 @@ export function setupSourceMaps( // Add Sentry plugin // Runs only on server-side (Nitro) nitroConfig.rollupConfig.plugins.push( - sentryRollupPlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback)), + sentryRollupPlugin(withoutSourceMapDeletion(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))), ); } }); + + nuxt.hook('close', async () => { + if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) { + await deleteSourceMapsAfterBuild(getPluginOptions(moduleOptions, shouldDeleteFilesFallback)); + } + }); } /** diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index 630789364f20..7666c170abe1 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -47,13 +47,25 @@ function createMockNuxt(options: { } describe('setupSourceMaps hooks', () => { + const defaultFilesToDeleteAfterUpload = [ + '.*/**/public/**/*.map', + '.*/**/server/**/*.map', + '.*/**/output/**/*.map', + '.*/**/function/**/*.map', + ]; + const mockSentryVitePlugin = vi.fn(() => [{ name: 'sentry-vite-plugin' }]); const mockSentryRollupPlugin = vi.fn(() => ({ name: 'sentry-rollup-plugin' })); + const mockDeleteArtifacts = vi.fn().mockResolvedValue(undefined); + const mockCreateSentryBuildPluginManager = vi.fn(() => ({ deleteArtifacts: mockDeleteArtifacts })); const consoleLogSpy = vi.spyOn(console, 'log'); const consoleWarnSpy = vi.spyOn(console, 'warn'); beforeAll(() => { + vi.doMock('@sentry/bundler-plugins/core', () => ({ + createSentryBuildPluginManager: mockCreateSentryBuildPluginManager, + })); vi.doMock('@sentry/bundler-plugins/vite', () => ({ sentryVitePlugin: mockSentryVitePlugin, })); @@ -65,6 +77,7 @@ describe('setupSourceMaps hooks', () => { afterAll(() => { consoleLogSpy.mockRestore(); consoleWarnSpy.mockRestore(); + vi.doUnmock('@sentry/bundler-plugins/core'); vi.doUnmock('@sentry/bundler-plugins/vite'); vi.doUnmock('@sentry/bundler-plugins/rollup'); }); @@ -74,6 +87,8 @@ describe('setupSourceMaps hooks', () => { consoleWarnSpy.mockClear(); mockSentryVitePlugin.mockClear(); mockSentryRollupPlugin.mockClear(); + mockCreateSentryBuildPluginManager.mockClear(); + mockDeleteArtifacts.mockClear(); }); describe('vite plugin registration', () => { @@ -163,14 +178,7 @@ describe('setupSourceMaps hooks', () => { }); describe('shouldDeleteFilesFallback passed to getPluginOptions in Vite plugin', () => { - const defaultFilesToDeleteAfterUpload = [ - '.*/**/public/**/*.map', - '.*/**/server/**/*.map', - '.*/**/output/**/*.map', - '.*/**/function/**/*.map', - ]; - - it('sentryVitePlugin is called with fallback filesToDeleteAfterUpload when source maps are unset', async () => { + it('does not pass fallback deletion patterns to the Vite plugin', async () => { const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); const mockNuxt = createMockNuxt({ _prepare: false, @@ -181,13 +189,28 @@ describe('setupSourceMaps hooks', () => { setupSourceMaps({ debug: false }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); - expect(mockSentryVitePlugin).toHaveBeenCalledWith( - expect.objectContaining({ - sourcemaps: expect.objectContaining({ - filesToDeleteAfterUpload: defaultFilesToDeleteAfterUpload, - }), - }), - ); + expect(mockSentryVitePlugin).toHaveBeenCalledWith({ + applicationKey: undefined, + org: undefined, + project: undefined, + authToken: undefined, + telemetry: true, + url: undefined, + headers: undefined, + debug: false, + silent: false, + errorHandler: undefined, + bundleSizeOptimizations: undefined, + release: { name: undefined }, + _metaOptions: { telemetry: { metaFramework: 'nuxt' } }, + sourcemaps: { + disable: undefined, + assets: undefined, + ignore: undefined, + filesToDeleteAfterUpload: undefined, + rewriteSources: expect.any(Function), + }, + }); }); it('sentryRollupPlugin is called without filesToDeleteAfterUpload when source maps are explicitly enabled', async () => { @@ -205,10 +228,93 @@ describe('setupSourceMaps hooks', () => { const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: {} }, dev: false }; await mockNuxt.triggerHook('nitro:config', nitroConfig); - const pluginOptions = (mockSentryRollupPlugin?.mock?.calls?.[0] as unknown[])?.[0] as { - sourcemaps?: { filesToDeleteAfterUpload?: string[] }; - }; - expect(pluginOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined(); + expect(mockSentryRollupPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ filesToDeleteAfterUpload: undefined }), + }), + ); + }); + }); + + describe('close hook', () => { + it('deletes source maps after the build using fallback patterns', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ + _prepare: false, + dev: false, + sourcemap: { client: undefined, server: undefined }, + }); + const { mockAddVitePlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: false }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('close'); + + expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ filesToDeleteAfterUpload: defaultFilesToDeleteAfterUpload }), + }), + { buildTool: 'nuxt', loggerPrefix: '[Sentry Nuxt]' }, + ); + expect(mockDeleteArtifacts).toHaveBeenCalledTimes(1); + }); + + it('uses user-provided deletion patterns after the build', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ + _prepare: false, + dev: false, + sourcemap: { client: true, server: true }, + }); + const { mockAddVitePlugin } = createMockAddVitePlugin(); + const filesToDeleteAfterUpload = ['.output/**/*.map']; + + setupSourceMaps({ sourcemaps: { filesToDeleteAfterUpload } }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('close'); + + expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ filesToDeleteAfterUpload }), + }), + { buildTool: 'nuxt', loggerPrefix: '[Sentry Nuxt]' }, + ); + expect(mockDeleteArtifacts).toHaveBeenCalledTimes(1); + }); + + it('does not create a manager when deletion is not configured', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ + _prepare: false, + dev: false, + sourcemap: { client: true, server: true }, + }); + const { mockAddVitePlugin } = createMockAddVitePlugin(); + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('close'); + + expect(mockCreateSentryBuildPluginManager).not.toHaveBeenCalled(); + expect(mockDeleteArtifacts).not.toHaveBeenCalled(); + }); + + it.each([ + { label: 'prepare mode', nuxtOptions: { _prepare: true, dev: false } }, + { label: 'dev mode', nuxtOptions: { _prepare: false, dev: true } }, + ])('does not delete source maps in $label', async ({ nuxtOptions }) => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt(nuxtOptions); + const { mockAddVitePlugin } = createMockAddVitePlugin(); + + setupSourceMaps( + { sourcemaps: { filesToDeleteAfterUpload: ['.output/**/*.map'] } }, + mockNuxt as unknown as Nuxt, + mockAddVitePlugin, + ); + await mockNuxt.triggerHook('close'); + + expect(mockCreateSentryBuildPluginManager).not.toHaveBeenCalled(); }); });