diff --git a/packages/bundler-plugins/src/rollup/debug-id-injection.ts b/packages/bundler-plugins/src/rollup/debug-id-injection.ts new file mode 100644 index 000000000000..2b59f3216192 --- /dev/null +++ b/packages/bundler-plugins/src/rollup/debug-id-injection.ts @@ -0,0 +1,69 @@ +import { stringToUUID } from '../core'; + +export const ROLLDOWN_DEBUG_ID_PLACEHOLDER = 'SENTRY_DEBUG_ID_PLACEHOLDER_00000000'; + +type GeneratedChunk = { + type: 'chunk'; + fileName: string; + code: string; +}; + +type GeneratedAsset = { + type: 'asset'; + fileName: string; +}; + +export type GeneratedBundle = Record; + +const SENTRY_DEBUG_ID_IDENTIFIER = '_sentryDebugIdIdentifier'; +const SENTRY_DEBUG_ID_IDENTIFIER_PREFIX = 'sentry-dbid-'; + +export function hasExistingDebugID(code: string): boolean { + const chunkStartSnippet = code.slice(0, 6000); + const chunkEndSnippet = code.slice(-500); + + return chunkStartSnippet.includes(SENTRY_DEBUG_ID_IDENTIFIER) || chunkEndSnippet.includes('//# debugId='); +} + +export function getDebugIdForChunk(code: string, isRolldown: boolean): string { + return isRolldown ? ROLLDOWN_DEBUG_ID_PLACEHOLDER : stringToUUID(code); +} + +function replaceAt(code: string, start: number, search: string, replacement: string): string { + return `${code.slice(0, start)}${replacement}${code.slice(start + search.length)}`; +} + +export function finalizeRolldownDebugIds(bundle: GeneratedBundle): void { + for (const [fileName, output] of Object.entries(bundle)) { + if (output.type !== 'chunk') { + continue; + } + + const identifier = `${SENTRY_DEBUG_ID_IDENTIFIER_PREFIX}${ROLLDOWN_DEBUG_ID_PLACEHOLDER}`; + const identifierPropertyStart = output.code.indexOf(SENTRY_DEBUG_ID_IDENTIFIER); + const identifierStart = output.code.indexOf( + identifier, + identifierPropertyStart + SENTRY_DEBUG_ID_IDENTIFIER.length, + ); + if (identifierStart === -1) { + continue; + } + + const identifierPlaceholderStart = identifierStart + SENTRY_DEBUG_ID_IDENTIFIER_PREFIX.length; + const debugIdsPlaceholderStart = output.code.lastIndexOf(ROLLDOWN_DEBUG_ID_PLACEHOLDER, identifierStart - 1); + if (debugIdsPlaceholderStart === -1) { + throw new Error(`Failed to locate the Sentry debug ID placeholder for chunk \`${fileName}\`.`); + } + + // Including the final filename disambiguates otherwise identical chunks. The fixed-width replacement deliberately + // happens after Rolldown computes [hash], so the emitted filename represents the placeholder-bearing chunk. + const debugId = stringToUUID(JSON.stringify([output.fileName, output.code])); + const codeWithIdentifier = replaceAt( + output.code, + identifierPlaceholderStart, + ROLLDOWN_DEBUG_ID_PLACEHOLDER, + debugId, + ); + output.code = replaceAt(codeWithIdentifier, debugIdsPlaceholderStart, ROLLDOWN_DEBUG_ID_PLACEHOLDER, debugId); + } +} diff --git a/packages/bundler-plugins/src/rollup/debug-id-stamping.ts b/packages/bundler-plugins/src/rollup/debug-id-stamping.ts new file mode 100644 index 000000000000..c430eeda533d --- /dev/null +++ b/packages/bundler-plugins/src/rollup/debug-id-stamping.ts @@ -0,0 +1,33 @@ +import { isJsFile, stampDebugId } from '../core'; + +export type OutputBundle = Record< + string, + | { type: 'chunk'; fileName: string; code: string; sourcemapFileName?: string | null } + | { type: 'asset'; fileName: string; source: string | Uint8Array } +>; + +export function stampDebugIds(bundle: OutputBundle, updateChunkCode: boolean): void { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !isJsFile(output.fileName)) { + continue; + } + + const sourceMapAsset = bundle[output.sourcemapFileName ?? `${output.fileName}.map`]; + const sourceMapSource = + sourceMapAsset?.type === 'asset' && typeof sourceMapAsset.source === 'string' + ? sourceMapAsset.source + : undefined; + + const stamped = stampDebugId(output.code, sourceMapSource); + if (!stamped) { + continue; + } + + if (updateChunkCode) { + output.code = stamped.bundleSource; + } + if (stamped.sourceMapSource !== undefined && sourceMapAsset?.type === 'asset') { + sourceMapAsset.source = stamped.sourceMapSource; + } + } +} diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index d9bbb517daad..8d0f65b60aa5 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -6,95 +6,41 @@ import { isJsFile, shouldSkipCodeInjection, getDebugIdSnippet, - stringToUUID, createDebugIdUploadFunction, globFiles, createComponentNameAnnotateHooks, replaceBooleanFlagsInCode, CodeInjection, - stampDebugId, getCodeInjectionPosition, } from '../core'; import type { ComponentAnnotationTransformMeta } from '../core/component-annotation-oxc'; import type { SourceMap } from 'magic-string'; import MagicString from 'magic-string'; import * as path from 'node:path'; -import { createRequire } from 'node:module'; +import { + finalizeRolldownDebugIds, + getDebugIdForChunk, + hasExistingDebugID, +} from './debug-id-injection'; +import { getRollupMajorVersion } from './rollup-version'; +import { stampDebugIds, type OutputBundle } from './debug-id-stamping'; +import { getViteParseAstAsync } from './vite-annotations'; // The subset of Rollup's `TransformResult` that this plugin's `transform` // hook actually returns. Defined locally instead of imported from `rollup` // because `rollup` is an optional dependency. type TransformResult = { code: string; map?: SourceMap | string | { mappings: string } | null } | null | undefined; -// The subset of Rollup's `OutputBundle` the stamping hook reads. -type OutputBundle = Record< - string, - | { type: 'chunk'; fileName: string; code: string; sourcemapFileName?: string | null } - | { type: 'asset'; fileName: string; source: string | Uint8Array } ->; - -type ViteModule = { - parseAstAsync?: (code: string, options: { lang: 'jsx' | 'tsx' }) => Promise; +type RenderChunkPluginContext = { + meta?: { + rolldownVersion?: string; + }; }; -type ViteParseAstAsync = NonNullable; - -let viteParseAstAsyncPromise: Promise | undefined; +type GenerateBundlePluginContext = RenderChunkPluginContext; const JS_MODULE_ID_FILTER = /\.[cm]?[jt]sx?(?:[?#].*)?$/; -function hasExistingDebugID(code: string): boolean { - // Check if a debug ID has already been injected to avoid duplicate injection (e.g. by another plugin or Sentry CLI) - const chunkStartSnippet = code.slice(0, 6000); - const chunkEndSnippet = code.slice(-500); - - if (chunkStartSnippet.includes('_sentryDebugIdIdentifier') || chunkEndSnippet.includes('//# debugId=')) { - return true; // Debug ID already present, skip injection - } - - return false; -} - -function getRollupMajorVersion(): string | undefined { - try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Rollup already transpiles this for us - const req = createRequire(import.meta.url); - const rollup = req('rollup') as { VERSION?: string }; - return rollup.VERSION?.split('.')[0]; - } catch { - // do nothing, we'll just not report a version - } - - return undefined; -} - -function getViteParseAstAsync(): Promise { - if (!viteParseAstAsyncPromise) { - viteParseAstAsyncPromise = Promise.resolve() - .then(async () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Vite is an optional runtime peer for this package - const viteModule = createRequire(import.meta.url)('vite') as ViteModule; - - if (typeof viteModule.parseAstAsync !== 'function') { - return null; - } - - try { - await viteModule.parseAstAsync('const x =
;', { lang: 'tsx' }); - } catch { - return null; - } - - return viteModule.parseAstAsync; - }) - .catch(() => null); - } - - return viteParseAstAsyncPromise; -} - /** * @ignore - this is the internal plugin factory function only used for the Vite plugin! */ @@ -195,6 +141,7 @@ export function _rollupPluginInternal( } function renderChunk( + this: RenderChunkPluginContext | undefined, code: string, chunk: { fileName: string; facadeModuleId?: string | null }, _?: unknown, @@ -215,7 +162,7 @@ export function _rollupPluginInternal( const injectCode = staticInjectionCode.clone(); if (sourcemapsEnabled && !hasExistingDebugID(code)) { - const debugId = stringToUUID(code); // generate a deterministic debug ID + const debugId = getDebugIdForChunk(code, !!this?.meta?.rolldownVersion); injectCode.append(getDebugIdSnippet(debugId)); } @@ -249,28 +196,20 @@ export function _rollupPluginInternal( * comment. Rollup computes `[hash]` file names before this hook, so only plugins that hash the final * assets afterwards (e.g. subresource integrity) see the stamped content. */ - function generateBundle(_outputOptions: unknown, bundle: OutputBundle): void { - for (const output of Object.values(bundle)) { - if (output.type !== 'chunk' || !isJsFile(output.fileName)) { - continue; - } - - const sourceMapAsset = bundle[output.sourcemapFileName ?? `${output.fileName}.map`]; - const sourceMapSource = - sourceMapAsset?.type === 'asset' && typeof sourceMapAsset.source === 'string' - ? sourceMapAsset.source - : undefined; - - const stamped = stampDebugId(output.code, sourceMapSource); - if (!stamped) { - continue; - } + function generateBundle( + this: GenerateBundlePluginContext | undefined, + _outputOptions: unknown, + bundle: OutputBundle, + ): void { + if (this?.meta?.rolldownVersion) { + finalizeRolldownDebugIds(bundle); + } - output.code = stamped.bundleSource; - if (stamped.sourceMapSource !== undefined && sourceMapAsset?.type === 'asset') { - sourceMapAsset.source = stamped.sourceMapSource; - } + if (options.sourcemaps?.disable !== 'disable-upload') { + return; } + + stampDebugIds(bundle, true); } async function writeBundle( @@ -291,7 +230,9 @@ export function _rollupPluginInternal( '/**/*.mjs.map', '/**/*.cjs.map', ].map(q => `${q}?(\\?*)?(#*)`); // We want to allow query and hash strings at the end of files - const buildArtifacts = await globFiles(JS_AND_MAP_PATTERNS, { root: outputDir }); + const buildArtifacts = await globFiles(JS_AND_MAP_PATTERNS, { + root: outputDir, + }); await upload(buildArtifacts); } else if (outputOptions.file) { await upload([outputOptions.file]); @@ -314,15 +255,12 @@ export function _rollupPluginInternal( handler: transform, } : transform; - return { name, buildStart, ...(shouldTransform ? { transform: transformHook } : {}), renderChunk, - ...(options.sourcemaps?.disable === 'disable-upload' - ? { generateBundle: { order: 'pre' as const, handler: generateBundle } } - : {}), + generateBundle: { order: 'pre' as const, handler: generateBundle }, writeBundle, }; } diff --git a/packages/bundler-plugins/src/rollup/rollup-version.ts b/packages/bundler-plugins/src/rollup/rollup-version.ts new file mode 100644 index 000000000000..7e04a1468ed5 --- /dev/null +++ b/packages/bundler-plugins/src/rollup/rollup-version.ts @@ -0,0 +1,13 @@ +import { createRequire } from 'node:module'; + +export function getRollupMajorVersion(): string | undefined { + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Rollup already transpiles this for us + const req = createRequire(import.meta.url); + const rollup = req('rollup') as { VERSION?: string }; + return rollup.VERSION?.split('.')[0]; + } catch { + return undefined; + } +} diff --git a/packages/bundler-plugins/src/rollup/vite-annotations.ts b/packages/bundler-plugins/src/rollup/vite-annotations.ts new file mode 100644 index 000000000000..ab46bc7eb654 --- /dev/null +++ b/packages/bundler-plugins/src/rollup/vite-annotations.ts @@ -0,0 +1,35 @@ +import { createRequire } from 'node:module'; + +type ViteModule = { + parseAstAsync?: (code: string, options: { lang: 'jsx' | 'tsx' }) => Promise; +}; + +type ViteParseAstAsync = NonNullable; + +let viteParseAstAsyncPromise: Promise | undefined; + +export function getViteParseAstAsync(): Promise { + if (!viteParseAstAsyncPromise) { + viteParseAstAsyncPromise = Promise.resolve() + .then(async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Vite is an optional runtime peer for this package + const viteModule = createRequire(import.meta.url)('vite') as ViteModule; + + if (typeof viteModule.parseAstAsync !== 'function') { + return null; + } + + try { + await viteModule.parseAstAsync('const x =
;', { lang: 'tsx' }); + } catch { + return null; + } + + return viteModule.parseAstAsync; + }) + .catch(() => null); + } + + return viteParseAstAsyncPromise; +} diff --git a/packages/bundler-plugins/src/vite/index.ts b/packages/bundler-plugins/src/vite/index.ts index ea24f64e5d73..c8839f400af2 100644 --- a/packages/bundler-plugins/src/vite/index.ts +++ b/packages/bundler-plugins/src/vite/index.ts @@ -1,10 +1,15 @@ import type { SentryRollupPluginOptions } from '../rollup'; import { _rollupPluginInternal } from '../rollup'; +import { stampDebugIds, type OutputBundle } from '../rollup/debug-id-stamping'; import { createRequire } from 'node:module'; interface SentryVitePlugin { name: string; - enforce: 'pre'; + enforce: 'pre' | 'post'; + generateBundle?: { + order: 'pre' | 'post'; + handler: (_outputOptions: unknown, bundle: OutputBundle) => void; + }; } function getViteMajorVersion(): string | undefined { @@ -22,12 +27,28 @@ function getViteMajorVersion(): string | undefined { } export const sentryVitePlugin = (options?: SentryRollupPluginOptions): SentryVitePlugin[] => { - return [ + const plugins: SentryVitePlugin[] = [ { enforce: 'pre', ..._rollupPluginInternal(options, 'vite', getViteMajorVersion()), }, ]; + + if (!options?.disable && options?.sourcemaps?.disable === 'disable-upload') { + plugins.push({ + name: 'sentry-vite-debug-id-sourcemaps', + enforce: 'post', + generateBundle: { + order: 'post', + handler(_outputOptions, bundle) { + // Vite can rewrite source maps after the early stamping hook, so restore their debug ID fields without changing JS. + stampDebugIds(bundle, false); + }, + }, + }); + } + + return plugins; }; export type { Options as SentryVitePluginOptions } from '../core'; diff --git a/packages/bundler-plugins/test/rollup/debug-id-injection.test.ts b/packages/bundler-plugins/test/rollup/debug-id-injection.test.ts new file mode 100644 index 000000000000..5dcfd2682676 --- /dev/null +++ b/packages/bundler-plugins/test/rollup/debug-id-injection.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { + finalizeRolldownDebugIds, + getDebugIdForChunk, + hasExistingDebugID, + ROLLDOWN_DEBUG_ID_PLACEHOLDER, + type GeneratedBundle, +} from '../../src/rollup/debug-id-injection'; + +const UUID_PATTERN = /[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}/g; + +function provisionalCode(userCode = 'console.log("test");'): string { + return [ + `globalThis._sentryDebugIds[stack]="${ROLLDOWN_DEBUG_ID_PLACEHOLDER}";`, + `globalThis._sentryDebugIdIdentifier="sentry-dbid-${ROLLDOWN_DEBUG_ID_PLACEHOLDER}";`, + userCode, + ].join(''); +} + +function finalize(code: string, fileName = 'bundle.js'): string { + const bundle: GeneratedBundle = { + [fileName]: { type: 'chunk', fileName, code }, + }; + + finalizeRolldownDebugIds(bundle); + const output = bundle[fileName]; + expect(output?.type).toBe('chunk'); + + return output?.type === 'chunk' ? output.code : ''; +} + +function extractDebugId(code: string): string { + const debugIds = code.match(UUID_PATTERN); + expect(debugIds).toHaveLength(2); + expect(new Set(debugIds)).toHaveLength(1); + + return debugIds?.[0] ?? ''; +} + +describe('debug ID injection', () => { + it('uses a placeholder for Rolldown and a deterministic UUID for Rollup', () => { + expect(getDebugIdForChunk('code', true)).toBe(ROLLDOWN_DEBUG_ID_PLACEHOLDER); + expect(getDebugIdForChunk('code', false)).toMatch(UUID_PATTERN); + expect(getDebugIdForChunk('code', false)).toBe(getDebugIdForChunk('code', false)); + }); + + it('detects existing inline and comment debug IDs at chunk boundaries', () => { + expect(hasExistingDebugID('globalThis._sentryDebugIdIdentifier="sentry-dbid-existing";')).toBe(true); + expect(hasExistingDebugID('console.log("test");\n//# debugId=existing')).toBe(true); + expect(hasExistingDebugID('console.log("test");')).toBe(false); + }); + + it('generates stable IDs from finalized code and filenames', () => { + const firstBuild = finalize(provisionalCode()); + const secondBuild = finalize(provisionalCode()); + + expect(extractDebugId(firstBuild)).toBe(extractDebugId(secondBuild)); + }); + + it('changes the ID when code or the filename changes', () => { + const baseline = extractDebugId(finalize(provisionalCode('first'))); + + expect(extractDebugId(finalize(provisionalCode('second')))).not.toBe(baseline); + expect(extractDebugId(finalize(provisionalCode('first'), 'other.js'))).not.toBe(baseline); + }); + + it('does not replace placeholder-shaped user strings', () => { + const userCode = `console.log("${ROLLDOWN_DEBUG_ID_PLACEHOLDER}");`; + + expect(finalize(provisionalCode(userCode))).toContain(userCode); + }); + + it('does not replace marker-shaped strings before the injected identifier', () => { + const userCode = `// sentry-dbid-${ROLLDOWN_DEBUG_ID_PLACEHOLDER}\n`; + const finalizedCode = finalize(`${userCode}${provisionalCode()}`); + + expect(finalizedCode).toContain(userCode); + expect(extractDebugId(finalizedCode)).not.toBe(''); + }); + + it('fails when the injected placeholder is incomplete', () => { + const code = `globalThis._sentryDebugIdIdentifier="sentry-dbid-${ROLLDOWN_DEBUG_ID_PLACEHOLDER}";`; + + expect(() => finalize(code)).toThrow('Failed to locate the Sentry debug ID placeholder for chunk `bundle.js`.'); + }); + + it('ignores non-chunk assets and chunks without provisional IDs', () => { + const bundle: GeneratedBundle = { + 'asset.js': { type: 'asset', fileName: 'asset.js' }, + 'chunk.js': { type: 'chunk', fileName: 'chunk.js', code: 'console.log("test");' }, + }; + + finalizeRolldownDebugIds(bundle); + + expect(bundle).toEqual({ + 'asset.js': { type: 'asset', fileName: 'asset.js' }, + 'chunk.js': { type: 'chunk', fileName: 'chunk.js', code: 'console.log("test");' }, + }); + }); +}); diff --git a/packages/bundler-plugins/test/rollup/rolldown-determinism.test.ts b/packages/bundler-plugins/test/rollup/rolldown-determinism.test.ts new file mode 100644 index 000000000000..0586216be2f6 --- /dev/null +++ b/packages/bundler-plugins/test/rollup/rolldown-determinism.test.ts @@ -0,0 +1,137 @@ +import { rolldown } from 'rolldown'; +import { describe, expect, it } from 'vitest'; +import { sentryRollupPlugin } from '../../src/rollup'; + +const virtualModules: Record = { + 'virtual:app': `export async function loadShared() { return import('virtual:shared'); }`, + 'virtual:shared': `export const shared = 'shared';`, + 'virtual:unrelated': `console.log('unrelated');`, +}; + +type SourceMapMode = boolean | 'inline' | 'hidden'; +type OutputFormat = 'esm' | 'cjs'; + +const outputCases: [OutputFormat, SourceMapMode][] = [ + ['esm', true], + ['esm', false], + ['esm', 'inline'], + ['esm', 'hidden'], + ['cjs', true], + ['cjs', false], + ['cjs', 'inline'], + ['cjs', 'hidden'], +]; + +async function createBuild(includeUnrelatedEntry: boolean, observeBundle?: (codes: string[]) => void) { + const input: Record = includeUnrelatedEntry + ? { unrelated: 'virtual:unrelated', app: 'virtual:app' } + : { app: 'virtual:app' }; + const options = { + input, + plugins: [ + { + name: 'integrity-observer', + generateBundle(_outputOptions: unknown, bundle: Record) { + observeBundle?.(Object.values(bundle).flatMap(output => (output.type === 'chunk' ? [output.code ?? ''] : []))); + }, + }, + { + name: 'virtual-modules', + resolveId(id: string) { + return id in virtualModules ? id : null; + }, + load(id: string) { + return virtualModules[id] ?? null; + }, + }, + ...sentryRollupPlugin({ + release: { inject: false }, + sourcemaps: { disable: 'disable-upload' }, + telemetry: false, + }), + ], + }; + + return rolldown(options); +} + +async function build( + includeUnrelatedEntry: boolean, + sourcemap: SourceMapMode = true, + format: OutputFormat = 'esm', + observeBundle?: (codes: string[]) => void, +) { + const bundle = await createBuild(includeUnrelatedEntry, observeBundle); + + try { + const { output } = await bundle.generate({ + format, + sourcemap, + entryFileNames: '[name]-[hash].js', + chunkFileNames: '[name]-[hash].js', + }); + + return output.filter(outputFile => outputFile.type === 'chunk'); + } finally { + await bundle.close(); + } +} + +function expectFinalizedDebugId(code: string): void { + expect(code).not.toContain('SENTRY_DEBUG_ID_PLACEHOLDER_00000000'); + const debugIds = code.match(/[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}/g); + expect(debugIds?.length).toBeGreaterThanOrEqual(2); + expect(new Set(debugIds)).toHaveLength(1); +} + +describe('Rolldown debug ID determinism', () => { + it('finalizes and stamps chunks before later bundle observers run', async () => { + let observedCodes: string[] = []; + + const chunks = await build(false, true, 'esm', codes => { + observedCodes = codes; + }); + + expect(observedCodes).toHaveLength(chunks.length); + expect(observedCodes).toEqual(chunks.map(chunk => chunk.code)); + for (const code of observedCodes) { + expect(code).toContain('//# debugId='); + expectFinalizedDebugId(code); + } + }); + + it.each(outputCases)( + 'produces identical %s chunks in repeated builds with sourcemap=%s', + async (format, sourcemap) => { + const firstBuild = await build(false, sourcemap, format); + const secondBuild = await build(false, sourcemap, format); + + const comparableOutput = (chunks: typeof firstBuild) => + chunks.map(chunk => ({ + fileName: chunk.fileName, + code: chunk.code, + map: chunk.map?.toString(), + })); + expect(comparableOutput(secondBuild)).toEqual(comparableOutput(firstBuild)); + for (const chunk of firstBuild) { + expectFinalizedDebugId(chunk.code); + } + }, + ); + + it('keeps existing chunks stable when an unrelated entry changes placeholder allocation', async () => { + const firstBuild = await build(false); + const secondBuild = await build(true); + + for (const facadeModuleId of ['virtual:app', 'virtual:shared']) { + const firstChunk = firstBuild.find(chunk => chunk.facadeModuleId === facadeModuleId); + const secondChunk = secondBuild.find(chunk => chunk.facadeModuleId === facadeModuleId); + + expect(firstChunk).toBeDefined(); + expect(secondChunk).toBeDefined(); + expect(secondChunk?.fileName).toBe(firstChunk?.fileName); + expect(secondChunk?.code).toBe(firstChunk?.code); + expectFinalizedDebugId(firstChunk?.code ?? ''); + } + }); +}); diff --git a/packages/bundler-plugins/test/vite/public-api.test.ts b/packages/bundler-plugins/test/vite/public-api.test.ts index cade4da1c67a..7ee680dfe4a5 100644 --- a/packages/bundler-plugins/test/vite/public-api.test.ts +++ b/packages/bundler-plugins/test/vite/public-api.test.ts @@ -40,6 +40,32 @@ describe('sentryVitePlugin', () => { expect(plugins[0]).toHaveProperty('name'); }); + it('restores debug IDs on source maps after Vite rewrites them without changing chunk code', () => { + const plugins = sentryVitePlugin({ + release: { inject: false }, + sourcemaps: { disable: 'disable-upload' }, + telemetry: false, + }); + const postPlugin = plugins.find(plugin => plugin.name === 'sentry-vite-debug-id-sourcemaps'); + const debugId = 'e3ee452d-c255-448c-aa62-da0fd4c49e46'; + const code = `globalThis._sentryDebugIdIdentifier="sentry-dbid-${debugId}";\n//# debugId=${debugId}`; + const sourceMapAsset = { + type: 'asset' as const, + fileName: 'entry.js.map', + source: JSON.stringify({ version: 3, sources: ['entry.ts'], names: [], mappings: '' }), + }; + const bundle = { + 'entry.js': { type: 'chunk' as const, fileName: 'entry.js', code }, + 'entry.js.map': sourceMapAsset, + }; + + expect(postPlugin?.generateBundle).toBeDefined(); + postPlugin?.generateBundle?.handler({}, bundle); + + expect(bundle['entry.js'].code).toBe(code); + expect(JSON.parse(sourceMapAsset.source)).toMatchObject({ debug_id: debugId, debugId }); + }); + it.each([ ['when the directive has no semicolon', '"use strict"\n'], ['when another directive precedes it', '"use client";\n"use strict";\n'],