diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore index 1dca2550f7e3..e52212a76a51 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore @@ -5,6 +5,7 @@ .nitro .cache dist +.netlify # Node dependencies node_modules diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts index 671ec4526a7a..8a9ee339b1e6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts @@ -24,10 +24,22 @@ const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; /** `nuxt generate` emits no `.output/server`. Keyed on the command so a missing one under `nuxt build` still fails. */ const isStaticBuild = process.env.NUXT_COMMAND === 'generate'; -const CLIENT_OUTPUT = path.join('.output', 'public'); +const nitroPreset = process.env.NITRO_PRESET ?? 'node-server'; + +/** If Nitro moves one of these directories, the debug ID checks below fail. */ +const OUTPUT_DIRS: Record = { + 'node-server': { client: path.join('.output', 'public'), server: path.join('.output', 'server') }, + netlify: { client: 'dist', server: path.join('.netlify', 'functions-internal', 'server') }, + 'cloudflare-pages': { client: 'dist', server: path.join('dist', '_worker.js') }, +}; + +const outputDirs = OUTPUT_DIRS[nitroPreset]; +assert.ok(outputDirs, `Expected output directories for the Nitro preset ${nitroPreset}`); + +const CLIENT_OUTPUT = outputDirs.client; /** Vite's own output directory. Nitro copies it to `CLIENT_OUTPUT`, and Sentry deletes no maps here. */ const CLIENT_BUILD_OUTPUT = path.join('.nuxt', 'dist', 'client'); -const SERVER_OUTPUT = path.join('.output', 'server'); +const SERVER_OUTPUT = outputDirs.server; /** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; @@ -35,6 +47,12 @@ const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; +/** Some presets write the server into the client output, e.g. Cloudflare Pages. */ +function isClientFile(file: string): boolean { + const relativePath = path.relative(SERVER_OUTPUT, file); + return relativePath.startsWith('..') || path.isAbsolute(relativePath); +} + function filesContaining(dir: string, needle: string): string[] { return fs .readdirSync(dir, { recursive: true, withFileTypes: true }) @@ -64,7 +82,7 @@ function debugIdsOfChunksWithoutSourceMap(dir: string): Set { } console.log( - `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + + `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, Nitro preset ${nitroPreset}, ` + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, ); @@ -218,7 +236,7 @@ console.log(''); // --- What the build leaves behind in the client output --- -const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }); +const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }).filter(isClientFile); if (keepClientSourceMaps) { assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`); @@ -228,20 +246,30 @@ if (keepClientSourceMaps) { assert.deepEqual(clientSourceMaps, [], `Expected no source maps in ${CLIENT_OUTPUT} after upload`); // The maps are gone, so a surviving reference only 404s in devtools and leaks where they were. - const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }); + const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }).filter(isClientFile); assert.deepEqual(danglingReferences, [], `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`); // Catches source maps inlined as `data:` URIs, which the reference check above skips by design. - const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER); + const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER).filter(isClientFile); assert.deepEqual(leakedSource, [], `Expected no original client source under ${CLIENT_OUTPUT}`); console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`); } -// --- What the built server answers for the client source maps --- +// --- What the build leaves behind in the server output --- -// `nuxt generate` emits no server, and a static host serves only the files that exist. +// Nuxt enables the server maps by default, so they are the user's and Sentry must keep them. if (!isStaticBuild) { + const serverSourceMaps = findSourceMapFiles({ outputDir: SERVER_OUTPUT }); + assert.ok(serverSourceMaps.length > 0, `Expected Sentry to keep the server source maps in ${SERVER_OUTPUT}`); + console.log(` ${serverSourceMaps.length} source map(s) kept in ${SERVER_OUTPUT}, as Nuxt enables them\n`); +} + +// --- What the built server answers for the client source maps --- + +// `nuxt generate` emits no server, and a static host serves only the files that exist. The other presets build a +// server for their platform, which Node cannot start. +if (!isStaticBuild && nitroPreset === 'node-server') { const clientChunkUrls = fs .readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true }) .filter(entry => entry.isFile() && entry.name.endsWith('.js')) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts index ae2e95194fa7..db827c5e9c32 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts @@ -3,11 +3,17 @@ // own warning tells users to set, which makes it the setup worth regression-testing. const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; +// With `sourcemap.client` unset, Sentry enables the client maps and finds their output directory itself, which +// differs per Nitro preset. +const nitroPreset = process.env.NITRO_PRESET; + export default defineNuxtConfig({ compatibilityDate: '2025-06-06', imports: { autoImport: false }, - sourcemap: { client: 'hidden' }, + sourcemap: { client: nitroPreset ? undefined : 'hidden' }, + + nitro: { preset: nitroPreset }, modules: ['@sentry/nuxt/module'], @@ -27,7 +33,7 @@ export default defineNuxtConfig({ release: { name: 'test-release' }, // Dropping `filesToDeleteAfterUpload` is the whole point of the "kept" variant: Sentry should // upload the maps and leave the emitted files alone. - sourcemaps: keepClientSourceMaps ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] }, + sourcemaps: keepClientSourceMaps || nitroPreset ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] }, debug: true, }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json index f417c74e5448..71a07c8e50fa 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json @@ -11,7 +11,11 @@ "test:build:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:build", "test:assert:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:assert", "test:build:static": "NUXT_COMMAND=generate pnpm test:build", - "test:assert:static": "NUXT_COMMAND=generate pnpm test:assert" + "test:assert:static": "NUXT_COMMAND=generate pnpm test:assert", + "test:build:netlify": "NITRO_PRESET=netlify pnpm test:build", + "test:assert:netlify": "NITRO_PRESET=netlify pnpm test:assert", + "test:build:cloudflare-pages": "NITRO_PRESET=cloudflare-pages pnpm test:build", + "test:assert:cloudflare-pages": "NITRO_PRESET=cloudflare-pages pnpm test:assert" }, "dependencies": { "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", @@ -38,6 +42,16 @@ "build-command": "pnpm test:build:static", "assert-command": "pnpm test:assert:static", "label": "nuxt-4-sourcemaps (static / nuxt generate)" + }, + { + "build-command": "pnpm test:build:netlify", + "assert-command": "pnpm test:assert:netlify", + "label": "nuxt-4-sourcemaps (netlify preset)" + }, + { + "build-command": "pnpm test:build:cloudflare-pages", + "assert-command": "pnpm test:assert:cloudflare-pages", + "label": "nuxt-4-sourcemaps (cloudflare-pages preset)" } ] } diff --git a/packages/nuxt/src/vite/sourceMapDeletion.ts b/packages/nuxt/src/vite/sourceMapDeletion.ts index 01b4d21daecf..7f9784e3bd37 100644 --- a/packages/nuxt/src/vite/sourceMapDeletion.ts +++ b/packages/nuxt/src/vite/sourceMapDeletion.ts @@ -2,6 +2,46 @@ import { createSentryBuildPluginManager, globFiles, type Options } from '@sentry import * as fs from 'node:fs'; import * as path from 'node:path'; +/** Nitro's output directories, which depend on the Nitro preset, e.g. `dist` for the public files on Netlify. */ +type NitroOutputDirs = { publicDir: string; serverDir: string }; + +/** The sides whose source maps only exist because Sentry enabled them, and where Nitro writes them once it is set up. */ +export type DeletionFallback = { client: boolean; server: boolean; outputDirs?: NitroOutputDirs }; + +/** + * Globs for the source maps in Nitro's output directories of the sides whose source maps only exist because Sentry + * enabled them. Empty until Nitro is set up. + */ +export function getFallbackFilesToDelete(fallback: DeletionFallback | undefined): string[] { + if (!fallback?.outputDirs) { + return []; + } + + const { client, server, outputDirs } = fallback; + const { publicDir, serverDir } = outputDirs; + return [ + ...(client ? getPublicSourceMapGlobs(publicDir, server ? undefined : serverDir) : []), + ...(server ? [`${toGlobPath(serverDir)}/**/*.map`] : []), + ]; +} + +/** Some presets write the server into the public dir, e.g. Cloudflare Pages into `dist/_worker.js`. */ +function getPublicSourceMapGlobs(publicDir: string, keptServerDir: string | undefined): string[] { + const publicGlob = toGlobPath(publicDir); + + if (keptServerDir === undefined || !isInDirectory(keptServerDir, publicDir)) { + return [`${publicGlob}/**/*.map`]; + } + + const [serverDirName] = path.relative(publicDir, keptServerDir).split(path.sep); + return [`${publicGlob}/*.map`, `${publicGlob}/!(${serverDirName})/**/*.map`]; +} + +/** `filesToDeleteAfterUpload` is matched from the working directory and needs forward slashes, also on Windows. */ +function toGlobPath(directory: string): string { + return path.relative(process.cwd(), directory).split(path.sep).join('/') || '.'; +} + export function withoutSourceMapDeletion(options: Options): Options { return { ...options, diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index 5d0f634fac59..84241321b2ab 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -5,7 +5,13 @@ import { warnOnRemovedBuildOptions } from '@sentry/core'; import type { NitroConfig } from 'nitropack'; import type { Plugin } from 'vite'; import type { SentryNuxtModuleOptions } from '../common/types'; -import { deletePublicSourceMaps, deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion'; +import { + deletePublicSourceMaps, + deleteSourceMapsAfterBuild, + type DeletionFallback, + getFallbackFilesToDelete, + withoutSourceMapDeletion, +} from './sourceMapDeletion'; import { validateSourceMapsOptionsPlugin } from './sentryVitePlugin'; /** @@ -33,7 +39,7 @@ export function setupSourceMaps( const sourceMapsEnabled = moduleOptions.sourcemaps?.disable !== true; // In case we overwrite the source map settings, we default to deleting the files - const shouldDeleteFilesFallback = { client: true, server: true }; + const shouldDeleteFilesFallback: DeletionFallback = { client: true, server: true }; nuxt.hook('modules:done', () => { if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) { @@ -99,6 +105,9 @@ export function setupSourceMaps( validateNitroSourceMapSettings(nuxt, nitroConfig, moduleOptions); + // `rollupConfig.output.sourcemap` overrides `sourcemap.server`, so the server source maps are the user's. + shouldDeleteFilesFallback.server &&= nitroConfig.rollupConfig.output?.sourcemap === undefined; + if (isDebug) { // eslint-disable-next-line no-console console.log('[Sentry] Adding Sentry Rollup plugin to the server runtime.'); @@ -115,6 +124,7 @@ export function setupSourceMaps( nuxt.hook('nitro:init', nitro => { if (sourceMapsEnabled && !nitro.options.dev && !nuxt.options?._prepare) { const publicDir = nitro.options.output.publicDir; + shouldDeleteFilesFallback.outputDirs = { publicDir, serverDir: nitro.options.output.serverDir }; const rollupConfig = (nitro.options.rollupConfig ??= { output: {} }); rollupConfig.plugins = [ @@ -151,7 +161,7 @@ function normalizePath(path: string): string { */ export function getPluginOptions( moduleOptions: SentryNuxtModuleOptions, - shouldDeleteFilesFallback?: { client: boolean; server: boolean }, + shouldDeleteFilesFallback?: DeletionFallback, ): SentryVitePluginOptions | SentryRollupPluginOptions { const sourcemapsOptions = moduleOptions.sourcemaps || {}; const filesToDeleteAfterUpload = resolveFilesToDeleteAfterUpload(moduleOptions, shouldDeleteFilesFallback); @@ -200,19 +210,15 @@ export function getPluginOptions( * (client/server) whose `sourcemap` setting resolved to `undefined`, where Sentry stepped in and * enabled `'hidden'`. Given Nuxt's default of `{ server: true, client: false }`, this isn't the * default case: it only kicks in when a side is left `undefined` (whole config or a single sub-key). + * Sentry deletes them in Nitro's output directories, which are only known once Nitro is set up. * @see https://nuxt.com/docs/4.x/api/nuxt-config#sourcemap */ function resolveFilesToDeleteAfterUpload( moduleOptions: SentryNuxtModuleOptions, - shouldDeleteFilesFallback?: { client: boolean; server: boolean }, + shouldDeleteFilesFallback?: DeletionFallback, ): string | Array | undefined { - const shouldDeleteFilesAfterUpload = shouldDeleteFilesFallback?.client || shouldDeleteFilesFallback?.server; - const fallbackFilesToDelete = [ - ...(shouldDeleteFilesFallback?.client ? ['.*/**/public/**/*.map'] : []), - ...(shouldDeleteFilesFallback?.server - ? ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'] - : []), - ]; + const fallbackFilesToDelete = getFallbackFilesToDelete(shouldDeleteFilesFallback); + const shouldDeleteFilesAfterUpload = fallbackFilesToDelete.length > 0; const filesToDeleteAfterUpload = moduleOptions.sourcemaps?.filesToDeleteAfterUpload; diff --git a/packages/nuxt/test/vite/sourceMapDeletion.test.ts b/packages/nuxt/test/vite/sourceMapDeletion.test.ts index 1599aebcdf9f..4bc4427a4569 100644 --- a/packages/nuxt/test/vite/sourceMapDeletion.test.ts +++ b/packages/nuxt/test/vite/sourceMapDeletion.test.ts @@ -1,8 +1,9 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { deletePublicSourceMaps } from '../../src/vite/sourceMapDeletion'; +import { globFiles } from '@sentry/bundler-plugins/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { deletePublicSourceMaps, getFallbackFilesToDelete } from '../../src/vite/sourceMapDeletion'; const BUILD_FILES = [ '.output/public/_nuxt/entry.DxB3kLmA.js', @@ -93,3 +94,117 @@ describe('deletePublicSourceMaps', () => { expect(remainingFiles()).toEqual(BUILD_FILES); }); }); + +/** Output directories of Nitro presets and the source maps a build writes there, relative to the Nuxt root. */ +const PRESETS = { + 'node-server': { + outputDirs: { publicDir: '.output/public', serverDir: '.output/server' }, + clientMaps: ['.output/public/_nuxt/entry.DxB3kLmA.js.map'], + serverMaps: ['.output/server/chunks/nitro/nitro.mjs.map'], + }, + netlify: { + outputDirs: { publicDir: 'dist', serverDir: '.netlify/functions-internal/server' }, + clientMaps: ['dist/_nuxt/entry.DxB3kLmA.js.map'], + serverMaps: ['.netlify/functions-internal/server/chunks/nitro/nitro.mjs.map'], + }, + vercel: { + outputDirs: { publicDir: '.vercel/output/static', serverDir: '.vercel/output/functions/__fallback.func' }, + clientMaps: ['.vercel/output/static/_nuxt/entry.DxB3kLmA.js.map'], + serverMaps: ['.vercel/output/functions/__fallback.func/chunks/nitro/nitro.mjs.map'], + }, + 'cloudflare-pages': { + outputDirs: { publicDir: 'dist', serverDir: 'dist/_worker.js' }, + clientMaps: ['dist/_nuxt/entry.DxB3kLmA.js.map', 'dist/sw.js.map'], + serverMaps: ['dist/_worker.js/chunks/nitro/nitro.mjs.map'], + }, +}; + +type Preset = keyof typeof PRESETS; + +/** Never deployed, so Sentry keeps them. */ +const UNDEPLOYED_MAPS = [ + '.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map', + 'node_modules/vue/dist/vue.runtime.esm-bundler.js.map', +]; + +describe('getFallbackFilesToDelete', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nuxt-fallback-deletion-')); + vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + function writeFiles(files: string[], nuxtRoot = projectDir): void { + for (const file of files) { + fs.mkdirSync(path.dirname(path.join(nuxtRoot, file)), { recursive: true }); + fs.writeFileSync(path.join(nuxtRoot, file), ''); + } + } + + /** Writes the build output of a preset and returns the files that the fallback globs match, relative to the root. */ + async function matchFallbackFiles( + preset: Preset, + sides: { client: boolean; server: boolean }, + nuxtRoot = projectDir, + ): Promise { + const { outputDirs, clientMaps, serverMaps } = PRESETS[preset]; + writeFiles([...clientMaps, ...serverMaps, ...UNDEPLOYED_MAPS], nuxtRoot); + + const globs = getFallbackFilesToDelete({ + ...sides, + outputDirs: { + publicDir: path.join(nuxtRoot, outputDirs.publicDir), + serverDir: path.join(nuxtRoot, outputDirs.serverDir), + }, + }); + const files = globs.length > 0 ? await globFiles(globs) : []; + return files.map(file => path.relative(nuxtRoot, file).split(path.sep).join('/')).sort(); + } + + it.each(Object.keys(PRESETS) as Preset[])( + 'matches the client and server source maps in the output of the %s preset', + async preset => { + const { clientMaps, serverMaps } = PRESETS[preset]; + + await expect(matchFallbackFiles(preset, { client: true, server: true })).resolves.toEqual( + [...clientMaps, ...serverMaps].sort(), + ); + }, + ); + + it.each(Object.keys(PRESETS) as Preset[])( + 'matches only the client source maps of the %s preset when Sentry enabled only those', + async preset => { + await expect(matchFallbackFiles(preset, { client: true, server: false })).resolves.toEqual( + [...PRESETS[preset].clientMaps].sort(), + ); + }, + ); + + it.each(Object.keys(PRESETS) as Preset[])( + 'matches only the server source maps of the %s preset when Sentry enabled only those', + async preset => { + await expect(matchFallbackFiles(preset, { client: false, server: true })).resolves.toEqual( + PRESETS[preset].serverMaps, + ); + }, + ); + + it('matches the output of a Nuxt app in a subdirectory of the working directory', async () => { + const nuxtRoot = path.join(projectDir, 'apps', 'shop'); + + await expect(matchFallbackFiles('node-server', { client: true, server: false }, nuxtRoot)).resolves.toEqual( + PRESETS['node-server'].clientMaps, + ); + }); + + it('returns no globs before Nitro reported its output directories', () => { + expect(getFallbackFilesToDelete({ client: true, server: true })).toEqual([]); + }); +}); diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index 2059462d1490..b6bc0b5109ba 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -42,6 +42,23 @@ function createMockAddVitePlugin() { type HookCallback = (...args: unknown[]) => void | Promise; +/** Nitro's output directories of the `node-server` preset, relative to the working directory. */ +const NODE_SERVER_OUTPUT = { publicDir: '.output/public', serverDir: '.output/server' }; + +function createMockNitro(options: { dev?: boolean; output?: { publicDir: string; serverDir: string } } = {}) { + const output = options.output ?? NODE_SERVER_OUTPUT; + return { + options: { + dev: options.dev ?? false, + output: { + publicDir: path.join(process.cwd(), output.publicDir), + serverDir: path.join(process.cwd(), output.serverDir), + }, + rollupConfig: { plugins: [{ name: 'sentry-rollup-plugin' }] as unknown }, + }, + }; +} + function createMockNuxt(options: { _prepare?: boolean; dev?: boolean; @@ -70,12 +87,7 @@ function createMockNuxt(options: { } describe('setupSourceMaps hooks', () => { - const defaultFilesToDeleteAfterUpload = [ - '.*/**/public/**/*.map', - '.*/**/server/**/*.map', - '.*/**/output/**/*.map', - '.*/**/function/**/*.map', - ]; + const defaultFilesToDeleteAfterUpload = ['.output/public/**/*.map', '.output/server/**/*.map']; const consoleLogSpy = vi.spyOn(console, 'log'); const consoleWarnSpy = vi.spyOn(console, 'warn'); @@ -273,6 +285,7 @@ describe('setupSourceMaps hooks', () => { setupSourceMaps({ debug: false }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:init', createMockNitro()); await mockNuxt.triggerHook('close'); expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( @@ -284,6 +297,24 @@ describe('setupSourceMaps hooks', () => { expect(mockDeleteArtifacts).toHaveBeenCalledTimes(1); }); + it('keeps the server source maps that `nitro.rollupConfig.output.sourcemap` enables', async () => { + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: undefined } }); + const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: { sourcemap: true } }, dev: false }; + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + await mockNuxt.triggerHook('nitro:init', createMockNitro()); + await mockNuxt.triggerHook('close'); + + expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ filesToDeleteAfterUpload: ['.output/public/**/*.map'] }), + }), + { buildTool: 'nuxt', loggerPrefix: '[Sentry Nuxt]' }, + ); + }); + it('uses user-provided deletion patterns after the build', async () => { const mockNuxt = createMockNuxt({ _prepare: false, @@ -397,16 +428,6 @@ describe('setupSourceMaps hooks', () => { fs.rmSync(rootDir, { recursive: true, force: true }); }); - function createMockNitro(options: { dev?: boolean } = {}) { - return { - options: { - dev: options.dev ?? false, - output: { publicDir: path.join(rootDir, '.output', 'public') }, - rollupConfig: { plugins: [{ name: 'sentry-rollup-plugin' }] as unknown }, - }, - }; - } - function findDeletionPlugin(nitro: ReturnType) { return [nitro.options.rollupConfig.plugins] .flat(2) @@ -454,6 +475,23 @@ describe('setupSourceMaps hooks', () => { expect(remainingFiles()).toEqual([PUBLIC_CHUNK, SSR_BUILD_MAP]); }); + it('deletes the public source maps of a preset that writes the public files outside of `.output`', async () => { + const netlifyPublicMap = 'dist/_nuxt/entry.DxB3kLmA.js.map'; + fs.mkdirSync(path.dirname(path.join(rootDir, netlifyPublicMap)), { recursive: true }); + fs.writeFileSync(path.join(rootDir, netlifyPublicMap), ''); + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: true } }); + const nitro = createMockNitro({ + output: { publicDir: 'dist', serverDir: '.netlify/functions-internal/server' }, + }); + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:init', nitro); + await findDeletionPlugin(nitro)?.buildStart(); + + expect(fs.existsSync(path.join(rootDir, netlifyPublicMap))).toBe(false); + }); + it.each([ { label: 'prepare mode', nuxtOptions: { _prepare: true }, nitroOptions: {} }, { label: 'dev mode', nuxtOptions: { dev: true }, nitroOptions: { dev: true } }, diff --git a/packages/nuxt/test/vite/sourceMaps.test.ts b/packages/nuxt/test/vite/sourceMaps.test.ts index 443b4b9d9a8f..79f242e4b12c 100644 --- a/packages/nuxt/test/vite/sourceMaps.test.ts +++ b/packages/nuxt/test/vite/sourceMaps.test.ts @@ -1,4 +1,5 @@ import type { Nuxt } from '@nuxt/schema'; +import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SentryNuxtModuleOptions } from '../../src/common/types'; import type { SourceMapSetting } from '../../src/vite/sourceMaps'; @@ -19,6 +20,11 @@ vi.mock('@sentry/core', async importOriginal => { }; }); +const NODE_SERVER_OUTPUT_DIRS = { + publicDir: path.join(process.cwd(), '.output', 'public'), + serverDir: path.join(process.cwd(), '.output', 'server'), +}; + describe('getPluginOptions', () => { beforeEach(() => { vi.resetModules(); @@ -213,19 +219,26 @@ describe('getPluginOptions', () => { name: 'both client and server fallback are true', clientFallback: true, serverFallback: true, - expected: ['.*/**/public/**/*.map', '.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], + expected: ['.output/public/**/*.map', '.output/server/**/*.map'], }, { name: 'only client fallback is true', clientFallback: true, serverFallback: false, - expected: ['.*/**/public/**/*.map'], + expected: ['.output/public/**/*.map'], }, { name: 'only server fallback is true', clientFallback: false, serverFallback: true, - expected: ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], + expected: ['.output/server/**/*.map'], + }, + { + name: 'both fallbacks are true, but Nitro is not set up yet', + clientFallback: true, + serverFallback: true, + outputDirs: null, + expected: undefined, }, { name: 'no fallback, but custom filesToDeleteAfterUpload is provided', @@ -245,10 +258,11 @@ describe('getPluginOptions', () => { }, ])( 'sets filesToDeleteAfterUpload correctly when $name', - ({ clientFallback, serverFallback, customOptions = {}, expected }) => { + ({ clientFallback, serverFallback, customOptions = {}, outputDirs = NODE_SERVER_OUTPUT_DIRS, expected }) => { const options = getPluginOptions(customOptions as SentryNuxtModuleOptions, { client: clientFallback, server: serverFallback, + outputDirs: outputDirs ?? undefined, }); expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expected);