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 e52212a76a51..c39056c011b0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore @@ -15,6 +15,7 @@ node_modules .tmp_chunks .tmp_build_stdout .tmp_build_stderr +.tmp_debug_ids_without_source_map # Logs logs 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 782b54789526..0e1f608ab7c1 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 @@ -17,6 +17,9 @@ import { /** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */ const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; +/** This variant has no auth token, so Sentry uploads nothing but still removes the client maps it enabled. */ +const noUpload = process.env.E2E_NO_UPLOAD === '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'; @@ -33,8 +36,6 @@ 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 = outputDirs.server; /** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ @@ -57,124 +58,106 @@ function filesContaining(dir: string, needle: string): string[] { .filter(file => fs.readFileSync(file, 'utf8').includes(needle)); } -/** - * Rolldown emits no source map for a chunk without mappable source, such as Vue's export helper. - * Such a chunk still gets a debug ID, but there is no map to upload for it. - */ -function debugIdsOfChunksWithoutSourceMap(dir: string): Set { - const debugIds = new Set(); - - for (const file of filesContaining(dir, 'sentry-dbid-')) { - if (file.endsWith('.map') || fs.existsSync(`${file}.map`)) { - continue; - } - - for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { - debugIds.add((match[1] as string).toLowerCase()); - } - } - - return debugIds; -} - console.log( `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, Nitro preset ${nitroPreset}, ` + - `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}${noUpload ? ', no upload' : ''}\n`, ); -const { requests, bundles, sourcemaps, uploadedDebugIds } = assertSourceMapUpload({ - outputDirs: isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT], - // The client output is checked below, because the "kept" variant expects its maps to stay and some presets - // write the server into it. - strippedDirs: [], - // Vite builds the client and Nitro's Rollup builds the server. Counting bundles would still pass - // with either plugin dropped, so each side is pinned to a marker only that side's source supplies. - // Nitro defaults to `sourcemapExcludeSources: true`; the module flips it to `false`, which is the - // only reason the server marker survives into `sourcesContent`. - markers: { 'client source (Vite plugin)': CLIENT_MARKER, 'server source (Rollup plugin)': SERVER_MARKER }, - // Nitro copies Vite's client output to `CLIENT_OUTPUT`, and Sentry deletes no maps in Vite's own directory. - debugIdsWithoutSourceMap: debugIdsOfChunksWithoutSourceMap(CLIENT_BUILD_OUTPUT), -}); -console.log(`Found ${bundles.length} artifact bundle(s)\n`); - -// --- Nuxt-specific checks on the upload --- - -// Sentry rejects a release that names no project with a 400, and the mock server accepts anything, -// so assert on the body rather than on the call alone. -const releaseCreates = requests.filter(r => r.url?.includes('/releases') && r.method === 'POST'); -assert.ok(releaseCreates.length > 0, 'Expected a POST to create the release'); -for (const request of releaseCreates) { - const body = request.jsonBody as { version?: string; projects?: string[] } | undefined; - assert.equal( - body?.version, - 'test-release', - `Expected the release create body to carry the version, got ${JSON.stringify(body)}`, - ); - assert.deepEqual( - body?.projects, - ['test-project'], - `Expected the release create body to name the project, got ${JSON.stringify(body)}`, - ); -} +if (!noUpload) { + const { requests, bundles, sourcemaps, uploadedDebugIds } = assertSourceMapUpload({ + outputDirs: isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT], + // The client output is checked below, because the "kept" variant expects its maps to stay and some presets + // write the server into it. + strippedDirs: [], + // Vite builds the client and Nitro's Rollup builds the server. Counting bundles would still pass + // with either plugin dropped, so each side is pinned to a marker only that side's source supplies. + // Nitro defaults to `sourcemapExcludeSources: true`; the module flips it to `false`, which is the + // only reason the server marker survives into `sourcesContent`. + markers: { 'client source (Vite plugin)': CLIENT_MARKER, 'server source (Rollup plugin)': SERVER_MARKER }, + // Written by a plugin in `nuxt.config.ts`, because Sentry removes the client maps it enabled from the bundle. + debugIdsWithoutSourceMap: '.tmp_debug_ids_without_source_map', + }); + console.log(`Found ${bundles.length} artifact bundle(s)\n`); + + // --- Nuxt-specific checks on the upload --- + + // Sentry rejects a release that names no project with a 400, and the mock server accepts anything, + // so assert on the body rather than on the call alone. + const releaseCreates = requests.filter(r => r.url?.includes('/releases') && r.method === 'POST'); + assert.ok(releaseCreates.length > 0, 'Expected a POST to create the release'); + for (const request of releaseCreates) { + const body = request.jsonBody as { version?: string; projects?: string[] } | undefined; + assert.equal( + body?.version, + 'test-release', + `Expected the release create body to carry the version, got ${JSON.stringify(body)}`, + ); + assert.deepEqual( + body?.projects, + ['test-project'], + `Expected the release create body to name the project, got ${JSON.stringify(body)}`, + ); + } -assert.ok( - requests.some(r => r.url?.includes('/releases/') && r.method === 'PUT'), - 'Expected a PUT to finalize the release', -); + assert.ok( + requests.some(r => r.url?.includes('/releases/') && r.method === 'PUT'), + 'Expected a PUT to finalize the release', + ); -assert.ok( - requests.some(r => r.url?.includes('/chunk-upload/') && r.method === 'GET'), - 'Expected a GET for the chunk-upload options', -); + assert.ok( + requests.some(r => r.url?.includes('/chunk-upload/') && r.method === 'GET'), + 'Expected a GET for the chunk-upload options', + ); -for (const request of getAssembleRequests(requests)) { - assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); - for (const chunk of request.assembleBody?.chunks ?? []) { - assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); + for (const request of getAssembleRequests(requests)) { + assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); + for (const chunk of request.assembleBody?.chunks ?? []) { + assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); + } } -} -// `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. -const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( - source => source.startsWith('../') || path.isAbsolute(source), -); -assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); - -const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); -assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); - -for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { - // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. - // Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519 - const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir }); - assert.deepEqual(filesWithMultipleDebugIds, [], `Expected at most one debug ID per file in ${outputDir}`); - - // A server file can borrow the debug ID of its SSR build chunk. Then the upload check above passes on - // Vite's map even if Nitro uploaded nothing. - const filesWithForeignSourcemaps = findFilesWithForeignSourcemaps({ outputDir }, sourcemaps); - assert.deepEqual( - filesWithForeignSourcemaps, - [], - `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, + // `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. + const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( + source => source.startsWith('../') || path.isAbsolute(source), ); -} + assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); -// If both builds upload a map under a borrowed debug ID, Sentry can resolve with the wrong one. -const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps); -assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map'); + const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); + assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); -if (!isStaticBuild) { - // Nitro bundles Vite's SSR output, so server frames reach `.vue` files only through the maps of that build. - const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); - const serverSources = sourcemaps - .filter(map => map.debugId && serverDebugIds.has(map.debugId)) - .flatMap(map => getSourcemapSources(map.sourcemap)); - assert.ok( - serverSources.some(source => source.endsWith('app/pages/index.vue')), - `Expected the server source maps to resolve to app/pages/index.vue, got: ${serverSources.join(', ')}`, - ); + for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { + // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. + // Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519 + const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir }); + assert.deepEqual(filesWithMultipleDebugIds, [], `Expected at most one debug ID per file in ${outputDir}`); + + // A server file can borrow the debug ID of its SSR build chunk. Then the upload check above passes on + // Vite's map even if Nitro uploaded nothing. + const filesWithForeignSourcemaps = findFilesWithForeignSourcemaps({ outputDir }, sourcemaps); + assert.deepEqual( + filesWithForeignSourcemaps, + [], + `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, + ); + } + + // If both builds upload a map under a borrowed debug ID, Sentry can resolve with the wrong one. + const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps); + assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map'); + + if (!isStaticBuild) { + // Nitro bundles Vite's SSR output, so server frames reach `.vue` files only through the maps of that build. + const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); + const serverSources = sourcemaps + .filter(map => map.debugId && serverDebugIds.has(map.debugId)) + .flatMap(map => getSourcemapSources(map.sourcemap)); + assert.ok( + serverSources.some(source => source.endsWith('app/pages/index.vue')), + `Expected the server source maps to resolve to app/pages/index.vue, got: ${serverSources.join(', ')}`, + ); + } + console.log(''); } -console.log(''); // --- What the build leaves behind in the client output --- 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 db827c5e9c32..6bf7b923116d 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 @@ -1,20 +1,49 @@ +import * as fs from 'node:fs'; + // Nuxt 4 defaults `sourcemap.client` to `false`, which the SDK respects as a deliberate opt-out, so // an app that never mentions `sourcemap`, uploads nothing client-side. `'hidden'` is what the SDK's // 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. +// Without an auth token nothing is uploaded, but Sentry still removes the client maps it enabled. +const noUpload = process.env.E2E_NO_UPLOAD === 'true'; + +// With `sourcemap.client` unset, Sentry enables the client maps and removes them from the build output itself. The +// output directories differ per Nitro preset. const nitroPreset = process.env.NITRO_PRESET; +type OutputBundle = Record; + +// The bundler emits no map for a chunk without mappable source, such as Vue's export helper, so its debug ID +// cannot be uploaded. +// Recorded before the Sentry plugin removes the maps it enabled from the bundle. +function recordChunksWithoutSourceMap() { + return { + name: 'record-chunks-without-source-map', + apply: 'build' as const, + generateBundle(_options: unknown, bundle: OutputBundle) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) { + continue; + } + for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`); + } + } + }, + }; +} + export default defineNuxtConfig({ compatibilityDate: '2025-06-06', imports: { autoImport: false }, - sourcemap: { client: nitroPreset ? undefined : 'hidden' }, + sourcemap: { client: nitroPreset || noUpload ? undefined : 'hidden' }, nitro: { preset: nitroPreset }, + vite: { plugins: [recordChunksWithoutSourceMap()] }, + modules: ['@sentry/nuxt/module'], runtimeConfig: { @@ -27,13 +56,14 @@ export default defineNuxtConfig({ sentry: { sentryUrl: 'http://localhost:3032', - authToken: 'fake-auth-token', + authToken: noUpload ? undefined : 'fake-auth-token', org: 'test-org', project: 'test-project', 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 || nitroPreset ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] }, + sourcemaps: + keepClientSourceMaps || nitroPreset || noUpload ? {} : { 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 89a72de550af..31b18c780af0 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 @@ -10,6 +10,8 @@ "test:assert": "pnpm ts-node --script-mode assert-build.ts", "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:no-upload": "E2E_NO_UPLOAD=true pnpm test:build", + "test:assert:no-upload": "E2E_NO_UPLOAD=true pnpm test:assert", "test:build:static": "NUXT_COMMAND=generate pnpm test:build", "test:assert:static": "NUXT_COMMAND=generate pnpm test:assert", "test:build:netlify": "NITRO_PRESET=netlify pnpm test:build", @@ -35,6 +37,11 @@ "assert-command": "pnpm test:assert:keep-client-sourcemaps", "label": "nuxt-4-sourcemaps (client source maps kept)" }, + { + "build-command": "pnpm test:build:no-upload", + "assert-command": "pnpm test:assert:no-upload", + "label": "nuxt-4-sourcemaps (no upload)" + }, { "build-command": "pnpm test:build:static", "assert-command": "pnpm test:assert:static", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts index 4dc1e34c400a..06aff0008baa 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts @@ -16,6 +16,9 @@ import { /** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */ const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; +/** This variant has no auth token, so Sentry uploads nothing but still removes the client maps it enabled. */ +const noUpload = process.env.E2E_NO_UPLOAD === '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'; @@ -59,121 +62,123 @@ function debugIdsOfChunksWithoutSourceMap(dir: string): Set { console.log( `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + - `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, -); - -// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk -// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the -// maps are kept. The upload is the same in all variants. -const outputDirsWithSourceMaps = [ - ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), - ...(isStaticBuild ? [] : [SERVER_OUTPUT]), -]; -const debugIdsWithoutSourceMap = new Set( - outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}${noUpload ? ', no upload' : ''}\n`, ); -const { requests, bundles, sourcemaps, uploadedDebugIds } = assertSourceMapUpload({ - outputDirs: outputDirsWithSourceMaps, - // The client output is checked below, because the "kept" variant expects its maps to stay. - strippedDirs: [], - // Vite builds the client and Nitro builds the server. Counting bundles would still pass with either - // plugin dropped, so each side is pinned to a marker only that side's source supplies. - // Nitro 3 deletes `sourcesContent` from the server maps unless `experimental.sourcemapMinify` is `false`. The module - // sets it to `false`, which is the only reason the server marker survives into `sourcesContent`. - markers: { 'client source (Vite plugin)': CLIENT_MARKER, 'server source (Rollup plugin)': SERVER_MARKER }, - debugIdsWithoutSourceMap: [...debugIdsWithoutSourceMap], -}); -console.log(`Found ${bundles.length} artifact bundle(s)\n`); - -// --- Nuxt-specific checks on the upload --- - -// Sentry rejects a release that names no project with a 400, and the mock server accepts anything, -// so assert on the body rather than on the call alone. -const releaseCreates = requests.filter(r => r.url?.includes('/releases') && r.method === 'POST'); -assert.ok(releaseCreates.length > 0, 'Expected a POST to create the release'); -for (const request of releaseCreates) { - const body = request.jsonBody as { version?: string; projects?: string[] } | undefined; - assert.equal( - body?.version, - 'test-release', - `Expected the release create body to carry the version, got ${JSON.stringify(body)}`, - ); - assert.deepEqual( - body?.projects, - ['test-project'], - `Expected the release create body to name the project, got ${JSON.stringify(body)}`, +if (!noUpload) { + // Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk + // that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the + // maps are kept. The upload is the same in all variants. + const outputDirsWithSourceMaps = [ + ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), + ...(isStaticBuild ? [] : [SERVER_OUTPUT]), + ]; + const debugIdsWithoutSourceMap = new Set( + outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), ); -} -assert.ok( - requests.some(r => r.url?.includes('/releases/') && r.method === 'PUT'), - 'Expected a PUT to finalize the release', -); + const { requests, bundles, sourcemaps, uploadedDebugIds } = assertSourceMapUpload({ + outputDirs: outputDirsWithSourceMaps, + // The client output is checked below, because the "kept" variant expects its maps to stay. + strippedDirs: [], + // Vite builds the client and Nitro builds the server. Counting bundles would still pass with either + // plugin dropped, so each side is pinned to a marker only that side's source supplies. + // Nitro 3 deletes `sourcesContent` from the server maps unless `experimental.sourcemapMinify` is `false`. The module + // sets it to `false`, which is the only reason the server marker survives into `sourcesContent`. + markers: { 'client source (Vite plugin)': CLIENT_MARKER, 'server source (Rollup plugin)': SERVER_MARKER }, + debugIdsWithoutSourceMap: [...debugIdsWithoutSourceMap], + }); + console.log(`Found ${bundles.length} artifact bundle(s)\n`); + + // --- Nuxt-specific checks on the upload --- + + // Sentry rejects a release that names no project with a 400, and the mock server accepts anything, + // so assert on the body rather than on the call alone. + const releaseCreates = requests.filter(r => r.url?.includes('/releases') && r.method === 'POST'); + assert.ok(releaseCreates.length > 0, 'Expected a POST to create the release'); + for (const request of releaseCreates) { + const body = request.jsonBody as { version?: string; projects?: string[] } | undefined; + assert.equal( + body?.version, + 'test-release', + `Expected the release create body to carry the version, got ${JSON.stringify(body)}`, + ); + assert.deepEqual( + body?.projects, + ['test-project'], + `Expected the release create body to name the project, got ${JSON.stringify(body)}`, + ); + } -assert.ok( - requests.some(r => r.url?.includes('/chunk-upload/') && r.method === 'GET'), - 'Expected a GET for the chunk-upload options', -); + assert.ok( + requests.some(r => r.url?.includes('/releases/') && r.method === 'PUT'), + 'Expected a PUT to finalize the release', + ); + + assert.ok( + requests.some(r => r.url?.includes('/chunk-upload/') && r.method === 'GET'), + 'Expected a GET for the chunk-upload options', + ); -for (const request of getAssembleRequests(requests)) { - assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); - for (const chunk of request.assembleBody?.chunks ?? []) { - assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); + for (const request of getAssembleRequests(requests)) { + assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); + for (const chunk of request.assembleBody?.chunks ?? []) { + assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); + } } -} -// `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. -const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( - source => source.startsWith('../') || path.isAbsolute(source), -); -assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); + // `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. + const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( + source => source.startsWith('../') || path.isAbsolute(source), + ); + assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); -const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); -assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); + const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); + assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); -// --- Every output directory ships at least one uploaded debug ID --- + // --- Every output directory ships at least one uploaded debug ID --- -// The shared assertions cover the directories that keep their maps. A directory whose maps are deleted -// must still ship a debug ID with an uploaded map, or a build that uploads nothing would pass. -for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { - const injectedDebugIds = findInjectedDebugIds({ outputDir }); - assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - assert.ok( - injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), - `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, - ); + // The shared assertions cover the directories that keep their maps. A directory whose maps are deleted + // must still ship a debug ID with an uploaded map, or a build that uploads nothing would pass. + for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { + const injectedDebugIds = findInjectedDebugIds({ outputDir }); + assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + assert.ok( + injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), + `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, + ); - console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); + console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); - // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. - // Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519 - const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir }); - assert.deepEqual(filesWithMultipleDebugIds, [], `Expected at most one debug ID per file in ${outputDir}`); + // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. + // Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519 + const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir }); + assert.deepEqual(filesWithMultipleDebugIds, [], `Expected at most one debug ID per file in ${outputDir}`); - // A server file can borrow the debug ID of its SSR build chunk. Then the upload check above passes on - // Vite's map even if Nitro uploaded nothing. - const filesWithForeignSourcemaps = findFilesWithForeignSourcemaps({ outputDir }, sourcemaps); - assert.deepEqual( - filesWithForeignSourcemaps, - [], - `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, - ); -} + // A server file can borrow the debug ID of its SSR build chunk. Then the upload check above passes on + // Vite's map even if Nitro uploaded nothing. + const filesWithForeignSourcemaps = findFilesWithForeignSourcemaps({ outputDir }, sourcemaps); + assert.deepEqual( + filesWithForeignSourcemaps, + [], + `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, + ); + } -// If both builds upload a map under a borrowed debug ID, Sentry can resolve with the wrong one. -const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps); -assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map'); - -// TODO: Nitro 3 bundles the SSR build's output again, but its maps stop at that output -// (`.nuxt/vite/services/ssr/...`) and do not chain to the SSR build's own maps. So server frames do not reach -// `.vue` files. Assert this when the server maps reach the original sources: -// const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); -// const serverSources = sourcemaps -// .filter(map => map.debugId && serverDebugIds.has(map.debugId)) -// .flatMap(map => getSourcemapSources(map.sourcemap)); -// assert.ok(serverSources.some(source => source.endsWith('app/pages/index.vue'))); -console.log(''); + // If both builds upload a map under a borrowed debug ID, Sentry can resolve with the wrong one. + const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps); + assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map'); + + // TODO: Nitro 3 bundles the SSR build's output again, but its maps stop at that output + // (`.nuxt/vite/services/ssr/...`) and do not chain to the SSR build's own maps. So server frames do not reach + // `.vue` files. Assert this when the server maps reach the original sources: + // const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); + // const serverSources = sourcemaps + // .filter(map => map.debugId && serverDebugIds.has(map.debugId)) + // .flatMap(map => getSourcemapSources(map.sourcemap)); + // assert.ok(serverSources.some(source => source.endsWith('app/pages/index.vue'))); + console.log(''); +} // --- What the build leaves behind in the client output --- diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts index f2f14911dde3..550dd0526694 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-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'; +// Without an auth token nothing is uploaded, but Sentry still removes the client maps it enabled. +const noUpload = process.env.E2E_NO_UPLOAD === 'true'; + +// With `sourcemap.client` unset, Sentry enables the client maps, uploads them and removes them itself. +const sdkClientSourceMaps = process.env.E2E_SDK_CLIENT_SOURCEMAPS === 'true'; + export default defineNuxtConfig({ compatibilityDate: '2025-06-06', imports: { autoImport: false }, - sourcemap: { client: 'hidden' }, + sourcemap: { client: noUpload || sdkClientSourceMaps ? undefined : 'hidden' }, modules: ['@sentry/nuxt/module'], @@ -21,13 +27,16 @@ export default defineNuxtConfig({ sentry: { sentryUrl: 'http://localhost:3032', - authToken: 'fake-auth-token', + authToken: noUpload ? undefined : 'fake-auth-token', org: 'test-org', project: 'test-project', 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 || noUpload || sdkClientSourceMaps + ? {} + : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] }, debug: true, }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json index af833ac3a1bf..f32adc7396bb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json @@ -10,6 +10,10 @@ "test:assert": "pnpm ts-node --script-mode assert-build.ts", "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:no-upload": "E2E_NO_UPLOAD=true pnpm test:build", + "test:assert:no-upload": "E2E_NO_UPLOAD=true pnpm test:assert", + "test:build:sdk-client-sourcemaps": "E2E_SDK_CLIENT_SOURCEMAPS=true pnpm test:build", + "test:assert:sdk-client-sourcemaps": "pnpm test:assert", "test:build:static": "NUXT_COMMAND=generate pnpm test:build", "test:assert:static": "NUXT_COMMAND=generate pnpm test:assert" }, @@ -34,6 +38,16 @@ "assert-command": "pnpm test:assert:keep-client-sourcemaps", "label": "nuxt-5-sourcemaps (client source maps kept)" }, + { + "build-command": "pnpm test:build:no-upload", + "assert-command": "pnpm test:assert:no-upload", + "label": "nuxt-5-sourcemaps (no upload)" + }, + { + "build-command": "pnpm test:build:sdk-client-sourcemaps", + "assert-command": "pnpm test:assert:sdk-client-sourcemaps", + "label": "nuxt-5-sourcemaps (client source maps enabled by Sentry)" + }, { "build-command": "pnpm test:build:static", "assert-command": "pnpm test:assert:static", diff --git a/packages/nuxt/src/vite/sourceMapDeletion.ts b/packages/nuxt/src/vite/sourceMapDeletion.ts index 874381d66b36..6911f80bbd6c 100644 --- a/packages/nuxt/src/vite/sourceMapDeletion.ts +++ b/packages/nuxt/src/vite/sourceMapDeletion.ts @@ -1,26 +1,30 @@ -import { createSentryBuildPluginManager, globFiles, type Options } from '@sentry/bundler-plugins/core'; +import { + createSentryBuildPluginManager, + globFiles, + type InternalOptions, + type Options, +} from '@sentry/bundler-plugins/core'; 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 }; +/** + * The sides whose source maps only exist because Sentry enabled them, and where Nitro writes them once it is set + * up. `publicDir` is only set for a builder other than Vite, where no Vite plugin removes the client maps. + */ +export type DeletionFallback = { client: boolean; server: boolean; serverDir?: string; publicDir?: string }; /** * 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. + * enabled them. Empty until Nitro is set up. With the Vite builder, the Vite plugin removes the client maps itself. */ export function getFallbackFilesToDelete(fallback: DeletionFallback | undefined): string[] { - if (!fallback?.outputDirs) { + if (fallback?.serverDir === undefined) { return []; } - const { client, server, outputDirs } = fallback; - const { publicDir, serverDir } = outputDirs; + const { client, server, serverDir, publicDir } = fallback; return [ - ...(client ? getPublicSourceMapGlobs(publicDir, server ? undefined : serverDir) : []), + ...(client && publicDir !== undefined ? getPublicSourceMapGlobs(publicDir, server ? undefined : serverDir) : []), ...(server ? [`${toGlobPath(serverDir)}/**/*.map`] : []), ]; } @@ -37,11 +41,68 @@ function getPublicSourceMapGlobs(publicDir: string, keptServerDir: string | unde return [`${publicGlob}/*.map`, `${publicGlob}/!(${serverDirName})/**/*.map`]; } +/** Only the Vite builder runs the Vite plugin that removes the client source maps. */ +export function isViteBuilder(nuxt: { options: { builder?: unknown } }): boolean { + const { builder } = nuxt.options; + return builder === undefined || builder === 'vite' || builder === '@nuxt/vite-builder'; +} + +export function logSentryEnabledSourceMaps(fallback: DeletionFallback, viteBuilder: boolean): void { + for (const side of ['client', 'server'] as const) { + if (fallback[side]) { + const userOptions = + side === 'client' && viteBuilder + ? '`sourcemaps.deleteSourcemapsAfterUpload` or `sourcemaps.filesToDeleteAfterUpload`' + : '`sourcemaps.filesToDeleteAfterUpload`'; + // eslint-disable-next-line no-console + console.log( + `[Sentry] We enabled \`'hidden'\` source maps for your ${side}-side build. Sentry deletes them after the upload, unless you set ${userOptions}.`, + ); + } + } +} + /** `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('/') || '.'; } +/** + * Options for the Vite plugin of the client build. It removes the client maps Sentry enabled, unless the + * user set `deleteSourcemapsAfterUpload` or `filesToDeleteAfterUpload`. The user's glob runs in the Nitro + * build instead, because a broad glob would also match the SSR maps Nitro still needs. + */ +export function withClientSourceMapDeletion( + options: Options, + userSourcemaps: + | Pick, 'deleteSourcemapsAfterUpload' | 'filesToDeleteAfterUpload'> + | undefined, + fallback: DeletionFallback, +): InternalOptions { + return { + ...options, + sourcemaps: { + ...options.sourcemaps, + filesToDeleteAfterUpload: undefined, + // The Vite plugin reads these in its `config` hook, after `modules:done` set the fallback. + deleteSourcemapsAfterUpload: environment => + userSourcemaps?.deleteSourcemapsAfterUpload ?? + (isClientEnvironment(environment) && fallback.client && userSourcemaps?.filesToDeleteAfterUpload === undefined + ? true + : undefined), + sdkEnabledSourceMaps: environment => isClientEnvironment(environment) && fallback.client, + }, + }; +} + +/** + * With the Vite environment API, one config holds the `client` and the server environments. Without it, the + * plugin is only part of the client config, which has no named environments. + */ +function isClientEnvironment(environment: string | undefined): boolean { + return environment === undefined || environment === 'client'; +} + 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 7d1f11401eb9..074f910a381c 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -10,6 +10,9 @@ import { deleteSourceMapsAfterBuild, type DeletionFallback, getFallbackFilesToDelete, + isViteBuilder, + logSentryEnabledSourceMaps, + withClientSourceMapDeletion, withoutSourceMapDeletion, } from './sourceMapDeletion'; import { validateSourceMapsOptionsPlugin } from './sentryVitePlugin'; @@ -57,25 +60,8 @@ export function setupSourceMaps( shouldDeleteFilesFallback.client = previousSourceMapSettings.client === 'unset'; shouldDeleteFilesFallback.server = previousSourceMapSettings.server === 'unset'; - 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 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.`, - ); - } + if (isDebug) { + logSentryEnabledSourceMaps(shouldDeleteFilesFallback, isViteBuilder(nuxt)); } } }); @@ -88,7 +74,13 @@ export function setupSourceMaps( }); addVitePlugin( - sentryVitePlugin(withoutSourceMapDeletion(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))), + sentryVitePlugin( + withClientSourceMapDeletion( + getPluginOptions(moduleOptions), + moduleOptions.sourcemaps, + shouldDeleteFilesFallback, + ), + ), // Nitro bundles the SSR output again, so debug IDs from the SSR build would point to the wrong maps. { dev: false, build: true, server: false }, ); @@ -126,7 +118,10 @@ 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 }; + shouldDeleteFilesFallback.serverDir = nitro.options.output.serverDir; + if (!isViteBuilder(nuxt)) { + shouldDeleteFilesFallback.publicDir = publicDir; + } const rollupConfig = (nitro.options.rollupConfig ??= { output: {} }); rollupConfig.plugins = [ @@ -207,12 +202,11 @@ export function getPluginOptions( } /** - * Determines which files to delete after upload. If the user set `filesToDeleteAfterUpload`, we use - * that. Otherwise we only delete the source maps Sentry generated itself — i.e. the sides - * (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. + * Determines which files to delete after the build. If the user set `filesToDeleteAfterUpload`, we use + * that. Otherwise we only delete the source maps Sentry generated itself, i.e. when the `sourcemap` setting + * of a side resolved to `undefined` and Sentry enabled `'hidden'`. This covers the server maps, and the + * client maps with a builder other than Vite. 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( @@ -234,7 +228,7 @@ function resolveFilesToDeleteAfterUpload( ); } - return filesToDeleteAfterUpload + return filesToDeleteAfterUpload !== undefined ? filesToDeleteAfterUpload : shouldDeleteFilesAfterUpload ? fallbackFilesToDelete @@ -249,7 +243,9 @@ function resolveFilesToDeleteAfterUpload( - keep this setting (don't do anything - like deletion - besides uploading) 3. users did not set source maps generation - we enable 'hidden' source maps generation - - configure `filesToDeleteAfterUpload` to delete all .map files (we emit a log about this) + - client with the Vite builder: pass `deleteSourcemapsAfterUpload: true`, so the maps never reach the build output + - client with another builder: configure `filesToDeleteAfterUpload` for the .map files in Nitro's public dir + - server: configure `filesToDeleteAfterUpload` to delete the .map files after the build (we emit a log about this) Users only have to explicitly enable client source maps. Sentry only overwrites the base Nuxt source map settings as they propagate. */ diff --git a/packages/nuxt/test/vite/sourceMapDeletion.test.ts b/packages/nuxt/test/vite/sourceMapDeletion.test.ts index 4bc4427a4569..2f02ef4a4b3f 100644 --- a/packages/nuxt/test/vite/sourceMapDeletion.test.ts +++ b/packages/nuxt/test/vite/sourceMapDeletion.test.ts @@ -151,56 +151,60 @@ describe('getFallbackFilesToDelete', () => { async function matchFallbackFiles( preset: Preset, sides: { client: boolean; server: boolean }, - nuxtRoot = projectDir, + { nuxtRoot = projectDir, viteBuilder = true }: { nuxtRoot?: string; viteBuilder?: boolean } = {}, ): 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), - }, + serverDir: path.join(nuxtRoot, outputDirs.serverDir), + publicDir: viteBuilder ? undefined : path.join(nuxtRoot, outputDirs.publicDir), }); 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 only the server source maps of the %s preset', async preset => { + await expect(matchFallbackFiles(preset, { client: true, server: true })).resolves.toEqual( + PRESETS[preset].serverMaps, + ); + }); + + // The Vite plugin removes the client maps Sentry enabled before they reach the public dir. it.each(Object.keys(PRESETS) as Preset[])( - 'matches the client and server source maps in the output of the %s preset', + 'matches nothing in the %s preset when Sentry enabled only the client source maps', async preset => { - const { clientMaps, serverMaps } = PRESETS[preset]; - - await expect(matchFallbackFiles(preset, { client: true, server: true })).resolves.toEqual( - [...clientMaps, ...serverMaps].sort(), - ); + await expect(matchFallbackFiles(preset, { client: true, server: false })).resolves.toEqual([]); }, ); + // No Vite plugin runs with webpack or rspack, so the public dir still holds the client maps. it.each(Object.keys(PRESETS) as Preset[])( - 'matches only the client source maps of the %s preset when Sentry enabled only those', + 'matches the client and server source maps of the %s preset with a builder other than Vite', async preset => { - await expect(matchFallbackFiles(preset, { client: true, server: false })).resolves.toEqual( - [...PRESETS[preset].clientMaps].sort(), + const { clientMaps, serverMaps } = PRESETS[preset]; + + await expect(matchFallbackFiles(preset, { client: true, server: true }, { viteBuilder: false })).resolves.toEqual( + [...clientMaps, ...serverMaps].sort(), ); }, ); it.each(Object.keys(PRESETS) as Preset[])( - 'matches only the server source maps of the %s preset when Sentry enabled only those', + 'matches only the client source maps of the %s preset with a builder other than Vite when Sentry enabled only those', async preset => { - await expect(matchFallbackFiles(preset, { client: false, server: true })).resolves.toEqual( - PRESETS[preset].serverMaps, - ); + await expect( + matchFallbackFiles(preset, { client: true, server: false }, { viteBuilder: false }), + ).resolves.toEqual([...PRESETS[preset].clientMaps].sort()); }, ); 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, + await expect(matchFallbackFiles('node-server', { client: false, server: true }, { nuxtRoot })).resolves.toEqual( + PRESETS['node-server'].serverMaps, ); }); diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index 682cb2cb43b0..fbd29e2b96f8 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -1,5 +1,6 @@ import type { Nuxt } from '@nuxt/schema'; import type * as BundlerPluginsCore from '@sentry/bundler-plugins/core'; +import type { SentryVitePluginInternalOptions } from '@sentry/bundler-plugins/vite'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -8,17 +9,27 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, type MockInstanc import { setupSourceMaps } from '../../src/vite/sourceMaps'; import type { SourceMapSetting } from '../../src/vite/sourceMapSettings'; -const { mockSentryVitePlugin, mockSentryRollupPlugin, mockDeleteArtifacts, mockCreateSentryBuildPluginManager } = - vi.hoisted(() => { - const deleteArtifacts = vi.fn().mockResolvedValue(undefined); +const { + capturedViteOptions, + mockSentryVitePlugin, + mockSentryRollupPlugin, + mockDeleteArtifacts, + mockCreateSentryBuildPluginManager, +} = vi.hoisted(() => { + const deleteArtifacts = vi.fn().mockResolvedValue(undefined); + const captured: { options?: SentryVitePluginInternalOptions } = {}; - return { - mockSentryVitePlugin: vi.fn(() => [{ name: 'sentry-vite-plugin' }]), - mockSentryRollupPlugin: vi.fn(() => ({ name: 'sentry-rollup-plugin' })), - mockDeleteArtifacts: deleteArtifacts, - mockCreateSentryBuildPluginManager: vi.fn(() => ({ deleteArtifacts })), - }; - }); + return { + capturedViteOptions: captured, + mockSentryVitePlugin: vi.fn((options: SentryVitePluginInternalOptions) => { + captured.options = options; + return [{ name: 'sentry-vite-plugin' }]; + }), + mockSentryRollupPlugin: vi.fn(() => ({ name: 'sentry-rollup-plugin' })), + mockDeleteArtifacts: deleteArtifacts, + mockCreateSentryBuildPluginManager: vi.fn(() => ({ deleteArtifacts })), + }; +}); vi.mock('@sentry/bundler-plugins/core', async importOriginal => ({ ...(await importOriginal()), @@ -64,6 +75,7 @@ function createMockNuxt(options: { _prepare?: boolean; dev?: boolean; sourcemap?: SourceMapSetting | { server?: SourceMapSetting; client?: SourceMapSetting }; + builder?: string; }) { const hooks: Record = {}; @@ -72,6 +84,7 @@ function createMockNuxt(options: { _prepare: options._prepare ?? false, dev: options.dev ?? false, sourcemap: options.sourcemap ?? { server: undefined, client: undefined }, + builder: options.builder, }, hook: (name: string, callback: HookCallback) => { hooks[name] = hooks[name] || []; @@ -88,7 +101,7 @@ function createMockNuxt(options: { } describe('setupSourceMaps hooks', () => { - const defaultFilesToDeleteAfterUpload = ['.output/public/**/*.map', '.output/server/**/*.map']; + const defaultFilesToDeleteAfterUpload = ['.output/server/**/*.map']; const consoleLogSpy = vi.spyOn(console, 'log'); const consoleWarnSpy = vi.spyOn(console, 'warn'); @@ -105,6 +118,7 @@ describe('setupSourceMaps hooks', () => { mockSentryRollupPlugin.mockClear(); mockCreateSentryBuildPluginManager.mockClear(); mockDeleteArtifacts.mockClear(); + capturedViteOptions.options = undefined; }); describe('removed `unstable_sentryBundlerPluginOptions`', () => { @@ -248,7 +262,8 @@ describe('setupSourceMaps hooks', () => { assets: undefined, ignore: undefined, filesToDeleteAfterUpload: undefined, - deleteSourcemapsAfterUpload: false, + deleteSourcemapsAfterUpload: expect.any(Function), + sdkEnabledSourceMaps: expect.any(Function), rewriteSources: expect.any(Function), }, }); @@ -276,6 +291,120 @@ describe('setupSourceMaps hooks', () => { }); }); + describe('client source map deletion', () => { + function getDeleteOption(environment?: string): boolean | undefined { + const value = capturedViteOptions.options?.sourcemaps?.deleteSourcemapsAfterUpload; + return typeof value === 'function' ? value(environment) : value; + } + + function getSdkEnabledSourceMaps(environment?: string): boolean | undefined { + const value = capturedViteOptions.options?.sourcemaps?.sdkEnabledSourceMaps; + return typeof value === 'function' ? value(environment) : value; + } + + // The Vite plugin reads the option in its `config` hook, which runs after `modules:done`. + it('asks the Vite plugin to delete the client source maps Sentry enabled', async () => { + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: true } }); + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + + expect(mockNuxt.options.sourcemap).toEqual({ client: 'hidden', server: true }); + expect(getDeleteOption()).toBe(true); + expect(capturedViteOptions.options?.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined(); + }); + + // The plugin then also removes the maps of chunks that the user's `assets` or `ignore` leave out. + it('tells the Vite plugin that Sentry enabled the client source maps', async () => { + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: true } }); + + setupSourceMaps( + { sourcemaps: { assets: ['.nuxt/dist/client/_nuxt/entry*'] } }, + mockNuxt as unknown as Nuxt, + vi.fn(), + ); + await mockNuxt.triggerHook('modules:done'); + + expect(capturedViteOptions.options?.sourcemaps?.assets).toEqual(['.nuxt/dist/client/_nuxt/entry*']); + expect(getSdkEnabledSourceMaps()).toBe(true); + }); + + // With the Vite environment API, the plugin also decides for the server environments. + it.each([ + { environment: undefined, sdkEnabled: true }, + { environment: 'client', sdkEnabled: true }, + { environment: 'ssr', sdkEnabled: false }, + { environment: 'nitro', sdkEnabled: false }, + ])('answers for the client only, in the $environment environment', async ({ environment, sdkEnabled }) => { + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: true } }); + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + + expect(getDeleteOption(environment)).toBe(sdkEnabled ? true : undefined); + expect(getSdkEnabledSourceMaps(environment)).toBe(sdkEnabled); + }); + + it.each([['hidden'], [true], [false]] as const)( + 'keeps the client source maps of a `sourcemap.client: %s` the user set', + async client => { + const mockNuxt = createMockNuxt({ sourcemap: { client, server: true } }); + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + + expect(getDeleteOption()).toBeUndefined(); + expect(getSdkEnabledSourceMaps()).toBe(false); + }, + ); + + it.each([ + { deleteSourcemapsAfterUpload: true, client: 'hidden' as const }, + { deleteSourcemapsAfterUpload: false, client: undefined }, + ])( + 'passes a user-set `deleteSourcemapsAfterUpload: $deleteSourcemapsAfterUpload` through', + async ({ deleteSourcemapsAfterUpload, client }) => { + const mockNuxt = createMockNuxt({ sourcemap: { client, server: true } }); + + setupSourceMaps({ sourcemaps: { deleteSourcemapsAfterUpload } }, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + + expect(getDeleteOption()).toBe(deleteSourcemapsAfterUpload); + expect(getDeleteOption('client')).toBe(deleteSourcemapsAfterUpload); + expect(getDeleteOption('ssr')).toBe(deleteSourcemapsAfterUpload); + }, + ); + + // Today's glob deleted these maps, also when the user uploads by hand. + it('deletes the client source maps Sentry enabled when only the upload is disabled', async () => { + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: true } }); + + setupSourceMaps({ sourcemaps: { disable: 'disable-upload' } }, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + + expect(capturedViteOptions.options?.sourcemaps?.disable).toBe('disable-upload'); + expect(getDeleteOption()).toBe(true); + expect(getSdkEnabledSourceMaps()).toBe(true); + }); + + // A broad glob also matches the SSR build's maps, which Nitro still needs. + it('applies only the user-set `filesToDeleteAfterUpload`, after the build', async () => { + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: true } }); + const filesToDeleteAfterUpload = ['.output/**/*.map']; + + setupSourceMaps({ sourcemaps: { filesToDeleteAfterUpload } }, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('close'); + + expect(capturedViteOptions.options?.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined(); + expect(getDeleteOption()).toBeUndefined(); + expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( + expect.objectContaining({ sourcemaps: expect.objectContaining({ filesToDeleteAfterUpload }) }), + { buildTool: 'nuxt', loggerPrefix: '[Sentry Nuxt]' }, + ); + }); + }); + describe('close hook', () => { it('deletes source maps after the build using fallback patterns', async () => { const mockNuxt = createMockNuxt({ @@ -309,12 +438,7 @@ describe('setupSourceMaps hooks', () => { 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]' }, - ); + expect(mockCreateSentryBuildPluginManager).not.toHaveBeenCalled(); }); it('uses user-provided deletion patterns after the build', async () => { @@ -463,35 +587,33 @@ describe('setupSourceMaps hooks', () => { expect(remainingFiles()).toEqual([PUBLIC_CHUNK, SSR_BUILD_MAP]); }); - it('deletes only the public source maps with the fallback patterns and keeps the SSR build maps', async () => { + // The Vite plugin never writes the client maps Sentry enabled, and Nitro still needs the SSR build maps. + it('deletes nothing with the fallback patterns', async () => { const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: undefined } }); const nitro = createMockNitro(); setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn()); await mockNuxt.triggerHook('modules:done'); await mockNuxt.triggerHook('nitro:init', nitro); - const deletionPlugin = findDeletionPlugin(nitro); - await deletionPlugin?.buildStart(); + await findDeletionPlugin(nitro)?.buildStart(); - expect(deletionPlugin).toBeDefined(); - expect(remainingFiles()).toEqual([PUBLIC_CHUNK, SSR_BUILD_MAP]); + expect(remainingFiles()).toEqual([PUBLIC_MAP, 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' }, + // No Vite plugin runs with webpack or rspack, so the client maps Sentry enabled reach the public dir. + it('deletes the client source maps Sentry enabled with a builder other than Vite', async () => { + const mockNuxt = createMockNuxt({ + sourcemap: { client: undefined, server: undefined }, + builder: '@nuxt/webpack-builder', }); + const nitro = createMockNitro(); 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); + expect(remainingFiles()).toEqual([PUBLIC_CHUNK, SSR_BUILD_MAP]); }); it.each([ @@ -513,6 +635,29 @@ describe('setupSourceMaps hooks', () => { }); describe('debug logging', () => { + it.each([ + { + builder: '@nuxt/vite-builder', + clientOptions: '`sourcemaps.deleteSourcemapsAfterUpload` or `sourcemaps.filesToDeleteAfterUpload`', + }, + { builder: '@nuxt/webpack-builder', clientOptions: '`sourcemaps.filesToDeleteAfterUpload`' }, + ])( + 'names the options that keep the source maps Sentry enabled with $builder', + async ({ builder, clientOptions }) => { + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: undefined }, builder }); + + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + + expect(consoleLogSpy).toHaveBeenCalledWith( + `[Sentry] We enabled \`'hidden'\` source maps for your client-side build. Sentry deletes them after the upload, unless you set ${clientOptions}.`, + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + "[Sentry] We enabled `'hidden'` source maps for your server-side build. Sentry deletes them after the upload, unless you set `sourcemaps.filesToDeleteAfterUpload`.", + ); + }, + ); + it('logs a [Sentry] message in production mode', async () => { const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); const { mockAddVitePlugin, getValidationPlugin } = createMockAddVitePlugin(); diff --git a/packages/nuxt/test/vite/sourceMaps.test.ts b/packages/nuxt/test/vite/sourceMaps.test.ts index 89b1c44acd10..d54a9779178b 100644 --- a/packages/nuxt/test/vite/sourceMaps.test.ts +++ b/packages/nuxt/test/vite/sourceMaps.test.ts @@ -22,10 +22,7 @@ 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'), -}; +const NODE_SERVER_DIR = path.join(process.cwd(), '.output', 'server'); describe('getPluginOptions', () => { beforeEach(() => { @@ -221,13 +218,13 @@ describe('getPluginOptions', () => { name: 'both client and server fallback are true', clientFallback: true, serverFallback: true, - expected: ['.output/public/**/*.map', '.output/server/**/*.map'], + expected: ['.output/server/**/*.map'], }, { name: 'only client fallback is true', clientFallback: true, serverFallback: false, - expected: ['.output/public/**/*.map'], + expected: undefined, }, { name: 'only server fallback is true', @@ -239,7 +236,7 @@ describe('getPluginOptions', () => { name: 'both fallbacks are true, but Nitro is not set up yet', clientFallback: true, serverFallback: true, - outputDirs: null, + serverDir: null, expected: undefined, }, { @@ -260,11 +257,11 @@ describe('getPluginOptions', () => { }, ])( 'sets filesToDeleteAfterUpload correctly when $name', - ({ clientFallback, serverFallback, customOptions = {}, outputDirs = NODE_SERVER_OUTPUT_DIRS, expected }) => { + ({ clientFallback, serverFallback, customOptions = {}, serverDir = NODE_SERVER_DIR, expected }) => { const options = getPluginOptions(customOptions as SentryNuxtModuleOptions, { client: clientFallback, server: serverFallback, - outputDirs: outputDirs ?? undefined, + serverDir: serverDir ?? undefined, }); expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expected);