diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore new file mode 100644 index 000000000000..1dca2550f7e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore @@ -0,0 +1,23 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Mock Sentry server artifacts +.tmp_mock_uploads.json +.tmp_chunks +.tmp_build_stdout +.tmp_build_stderr + +# Logs +logs +*.log + +# Misc +.DS_Store diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/app/app.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/app/app.vue new file mode 100644 index 000000000000..eb66eddd8a33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/app/app.vue @@ -0,0 +1,12 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/app/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/app/pages/index.vue new file mode 100644 index 000000000000..bc19868e4488 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/app/pages/index.vue @@ -0,0 +1,9 @@ + + + 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 new file mode 100644 index 000000000000..8ff5db65dfc7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts @@ -0,0 +1,148 @@ +import * as assert from 'assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + findInjectedDebugIds, + findSourceMapFiles, + findSourceMappingUrlComments, + getArtifactBundles, + getAssembleRequests, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemaps, + loadMockServerResults, +} from '@sentry-internal/test-utils'; + +/** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */ +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 SERVER_OUTPUT = path.join('.output', 'server'); + +/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ +const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; +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; + +function filesContaining(dir: string, needle: string): string[] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => path.join(entry.parentPath, entry.name)) + .filter(file => fs.readFileSync(file, 'utf8').includes(needle)); +} + +console.log( + `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, +); + +const requests = loadMockServerResults(); + +console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); +for (const request of requests) { + console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); +} +console.log(''); + +// --- The upload reached Sentry --- + +assert.ok( + requests.some(r => r.authorization.includes('fake-auth-token')), + 'Expected requests with the configured auth token', +); + +assert.ok( + requests.some(r => r.url?.includes('/releases/')), + 'Expected at least one request to releases endpoint', +); + +const chunkPosts = getChunkUploadPosts(requests); +assert.ok( + chunkPosts.some(r => r.bodySize > 0), + 'Expected at least one chunk upload POST with a non-empty body', +); + +const assembleRequests = getAssembleRequests(requests); +assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); +for (const request of assembleRequests) { + assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); + assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); +} + +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); +console.log(`Found ${bundles.length} artifact bundle(s)\n`); + +// --- Both bundlers uploaded --- + +const sourcemaps = getSourcemaps(bundles); +assert.ok( + sourcemaps.some(map => map.sourcemap.mappings?.length), + 'Expected at least one uploaded sourcemap with non-empty mappings', +); + +const containsMarker = (marker: string): boolean => + sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); + +// 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. +assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)'); +// Nitro defaults to `sourcemapExcludeSources: true`; the module flips it to `false`, which is the +// only reason this marker survives into `sourcesContent`. +assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)'); + +// `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 './…'`); + +// --- Debug IDs tie the shipped bundle to the uploaded map --- + +const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); +assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); + +const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); +assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); + +// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting +// the upload alone cannot show this. +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}`); + + const unuploaded = injectedDebugIds.filter(debugId => !uploadedDebugIds.has(debugId)); + assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); + + console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s), all uploaded`); +} +console.log(''); + +// --- What the build leaves behind in the client output --- + +const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }); + +if (keepClientSourceMaps) { + assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`); + console.log(` ${clientSourceMaps.length} source map(s) kept in ${CLIENT_OUTPUT}, as configured\n`); +} else { + // This directory is served to the internet, so a surviving `.map` hands out the original source. + 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 }); + 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); + 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`); +} + +console.log('All sourcemap assertions passed!'); 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 new file mode 100644 index 000000000000..ae2e95194fa7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts @@ -0,0 +1,33 @@ +// 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'; + +export default defineNuxtConfig({ + compatibilityDate: '2025-06-06', + imports: { autoImport: false }, + + sourcemap: { client: 'hidden' }, + + modules: ['@sentry/nuxt/module'], + + runtimeConfig: { + public: { + sentry: { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }, + }, + }, + + sentry: { + sentryUrl: 'http://localhost:3032', + authToken: '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'] }, + 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 new file mode 100644 index 000000000000..899079cdacfe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json @@ -0,0 +1,45 @@ +{ + "name": "nuxt-4-sourcemaps", + "description": "E2E test app asserting what the Nuxt SDK uploads to Sentry and what it leaves behind in `.output`.", + "private": true, + "type": "module", + "scripts": { + "build": "node start-mock-sentry-server.mjs & nuxt ${NUXT_COMMAND:-build} > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", + "clean": "npx nuxi cleanup", + "test:build": "pnpm install && pnpm build", + "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:static": "NUXT_COMMAND=generate pnpm test:build", + "test:assert:static": "NUXT_COMMAND=generate pnpm test:assert" + }, + "dependencies": { + "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", + "nuxt": "^4.1.2" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^22.20.0", + "ts-node": "10.9.1", + "typescript": "~5.0.0" + }, + "volta": { + "extends": "../../package.json", + "node": "22.20.0" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:keep-client-sourcemaps", + "assert-command": "pnpm test:assert:keep-client-sourcemaps", + "label": "nuxt-4-sourcemaps (client source maps kept)" + }, + { + "build-command": "pnpm test:build:static", + "assert-command": "pnpm test:assert:static", + "label": "nuxt-4-sourcemaps (static / nuxt generate)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/sentry.client.config.ts new file mode 100644 index 000000000000..795fc10a560c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/sentry.client.config.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/nuxt'; +import { useRuntimeConfig } from '#imports'; + +Sentry.init({ + dsn: useRuntimeConfig().public.sentry.dsn, + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/sentry.server.config.ts new file mode 100644 index 000000000000..38ed95a35801 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/sentry.server.config.ts @@ -0,0 +1,6 @@ +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/server/api/server-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/server/api/server-error.ts new file mode 100644 index 000000000000..c2d54d5f4e75 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/server/api/server-error.ts @@ -0,0 +1,8 @@ +import { defineEventHandler } from '#imports'; + +// SOURCEMAP_MARKER_SERVER — the server counterpart of the client marker. Nitro defaults to +// `sourcemapExcludeSources: true`, which would drop this from the uploaded map; the Sentry module +// flips it to `false`, so finding this marker in `sourcesContent` is what proves that still works. +export default defineEventHandler(() => { + throw new Error('Server error from the Nuxt source map E2E app'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..1680a6360165 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer({ org: 'test-org' }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..a2aac493d878 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["**/*.ts", "**/*.vue"], + "exclude": ["node_modules", ".output", ".nuxt"] +} diff --git a/dev-packages/test-utils/src/build-output.ts b/dev-packages/test-utils/src/build-output.ts index e3aebc97b41d..ef0e4f03d312 100644 --- a/dev-packages/test-utils/src/build-output.ts +++ b/dev-packages/test-utils/src/build-output.ts @@ -1,8 +1,8 @@ import * as fs from 'fs'; import * as path from 'path'; -interface AbsolutePathImportOptions { - /** Directory holding the emitted bundles, e.g. `/.next/server`. */ +export interface OutputScanOptions { + /** Directory holding the emitted bundles, e.g. `/.output/public`. */ outputDir: string; /** Only used to shorten the reported file paths. Defaults to `process.cwd()`. */ buildDir?: string; @@ -10,6 +10,30 @@ interface AbsolutePathImportOptions { extensions?: string[]; } +const JS_EXTENSIONS = ['.js', '.mjs', '.cjs']; + +/** `//# sourceMappingURL=…` / `/*# sourceMappingURL=… *\/`, ignoring inline data URIs. */ +const SOURCE_MAPPING_URL_PATTERN = /[#@]\s*sourceMappingURL\s*=\s*(?!data:)(\S+)/g; + +/** The `sentry-dbid-` identifier the bundler plugin injects alongside `_sentryDebugIds`. */ +const INJECTED_DEBUG_ID_PATTERN = /sentry-dbid-([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})/gi; + +function* walkFiles(outputDir: string, extensions?: string[]): Generator { + if (!fs.existsSync(outputDir)) { + throw new Error(`[build-output] Output directory does not exist: ${outputDir}`); + } + + for (const entry of fs.readdirSync(outputDir, { recursive: true, withFileTypes: true })) { + if (!entry.isFile()) { + continue; + } + if (extensions && !extensions.includes(path.extname(entry.name))) { + continue; + } + yield path.join(entry.parentPath, entry.name); + } +} + const SPECIFIER_PATTERNS = [ /\brequire\(\s*["']([^"']+)["']\s*\)/g, /\bimport\(\s*["']([^"']+)["']\s*\)/g, @@ -30,20 +54,11 @@ const SPECIFIER_PATTERNS = [ export function findAbsolutePathImports({ outputDir, buildDir = process.cwd(), - extensions = ['.js', '.mjs', '.cjs'], -}: AbsolutePathImportOptions): string[] { - if (!fs.existsSync(outputDir)) { - throw new Error(`[findAbsolutePathImports] Output directory does not exist: ${outputDir}`); - } - + extensions = JS_EXTENSIONS, +}: OutputScanOptions): string[] { const leaks: string[] = []; - for (const entry of fs.readdirSync(outputDir, { recursive: true, withFileTypes: true })) { - if (!entry.isFile() || !extensions.includes(path.extname(entry.name))) { - continue; - } - - const file = path.join(entry.parentPath, entry.name); + for (const file of walkFiles(outputDir, extensions)) { const contents = fs.readFileSync(file, 'utf8'); for (const pattern of SPECIFIER_PATTERNS) { @@ -58,3 +73,69 @@ export function findAbsolutePathImports({ return leaks; } + +/** + * Returns every `.map` file under `outputDir`, relative to `buildDir`. + * + * A source map left in a publicly served directory hands out the app's original source to anyone + * who requests it, so this is the assertion that a "delete after upload" setting actually took + * effect on the emitted build rather than only on the files the uploader happened to see. + */ +export function findSourceMapFiles({ outputDir, buildDir = process.cwd() }: OutputScanOptions): string[] { + const maps: string[] = []; + + for (const file of walkFiles(outputDir)) { + if (file.endsWith('.map')) { + maps.push(path.relative(buildDir, file)); + } + } + + return maps; +} + +/** + * Returns every `` pair where emitted output still points at an external source map. + * + * `'hidden'` source maps exist precisely so the map can be uploaded without the bundle advertising + * it. A surviving comment both 404s in devtools and leaks the map's location, so it means the + * hidden setting did not reach that bundler. Inline `data:` maps are ignored - those are a + * different (and separately detectable) failure. + */ +export function findSourceMappingUrlComments({ + outputDir, + buildDir = process.cwd(), + extensions = JS_EXTENSIONS, +}: OutputScanOptions): string[] { + const references: string[] = []; + + for (const file of walkFiles(outputDir, extensions)) { + const contents = fs.readFileSync(file, 'utf8'); + + for (const match of contents.matchAll(SOURCE_MAPPING_URL_PATTERN)) { + references.push(`${path.relative(buildDir, file)} → ${match[1] as string}`); + } + } + + return references; +} + +/** + * Returns the debug IDs the bundler plugin injected into the emitted JavaScript. + * + * These are the IDs the SDK reports at runtime, so they are the half of the debug-ID contract that + * inspecting the upload alone cannot see: an uploaded map is only usable if the shipped bundle + * claims the same ID. + */ +export function findInjectedDebugIds({ outputDir, extensions = JS_EXTENSIONS }: OutputScanOptions): string[] { + const debugIds = new Set(); + + for (const file of walkFiles(outputDir, extensions)) { + const contents = fs.readFileSync(file, 'utf8'); + + for (const match of contents.matchAll(INJECTED_DEBUG_ID_PATTERN)) { + debugIds.add((match[1] as string).toLowerCase()); + } + } + + return [...debugIds]; +} diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 94c16e744211..3ee7cb7ef2b5 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -15,7 +15,13 @@ export { getSpanOp, } from './event-proxy-server'; -export { findAbsolutePathImports } from './build-output'; +export { + findAbsolutePathImports, + findSourceMapFiles, + findSourceMappingUrlComments, + findInjectedDebugIds, +} from './build-output'; +export type { OutputScanOptions } from './build-output'; export { getPlaywrightConfig } from './playwright-config'; export { createBasicSentryServer, createTestServer } from './server'; diff --git a/dev-packages/test-utils/src/sourcemap-upload-utils.ts b/dev-packages/test-utils/src/sourcemap-upload-utils.ts index 96a621b5f44c..5c1cce500f34 100644 --- a/dev-packages/test-utils/src/sourcemap-upload-utils.ts +++ b/dev-packages/test-utils/src/sourcemap-upload-utils.ts @@ -55,6 +55,8 @@ export interface ParsedSourcemap { [key: string]: unknown; version?: number; sources?: string[]; + /** Absent when the generator drops all sources (Rollup's `sourcemapExcludeSources`), null per dropped entry. */ + sourcesContent?: (string | null)[]; mappings?: string; }