diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/basic-cjs.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/basic-cjs.test.ts index 06d3ccb3dccd..1c17c400cc29 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/basic-cjs.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/basic-cjs.test.ts @@ -19,6 +19,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { "sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"], ["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/basic.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/basic.test.ts index 06d3ccb3dccd..1c17c400cc29 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/basic.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/basic.test.ts @@ -19,6 +19,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { "sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"], ["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/release-disabled.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/release-disabled.test.ts index 36d836956df6..8a6ce51205b1 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/release-disabled.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/release-disabled.test.ts @@ -18,6 +18,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles }) => { ", "sentry-cli-mock.json": "["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/basic-cjs.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/basic-cjs.test.ts index 29e892eae928..c043197a4ac9 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/basic-cjs.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/basic-cjs.test.ts @@ -19,6 +19,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { "sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"], ["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/basic.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/basic.test.ts index 29e892eae928..c043197a4ac9 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/basic.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/basic.test.ts @@ -19,6 +19,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { "sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"], ["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/release-disabled.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/release-disabled.test.ts index a6e4dfac1179..eb67721a1582 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/release-disabled.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/release-disabled.test.ts @@ -18,6 +18,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles }) => { ", "sentry-cli-mock.json": "["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/basic-cjs.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/basic-cjs.test.ts index b648cee7c415..9b23588858ac 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/basic-cjs.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/basic-cjs.test.ts @@ -20,6 +20,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { "sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"], ["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/basic.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/basic.test.ts index b648cee7c415..9b23588858ac 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/basic.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/basic.test.ts @@ -20,6 +20,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { "sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"], ["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/release-disabled.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/release-disabled.test.ts index cd5480b81387..44729ef41b57 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/release-disabled.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/release-disabled.test.ts @@ -19,6 +19,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles }) => { ", "sentry-cli-mock.json": "["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-assets-auto.config.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-assets-auto.config.ts new file mode 100644 index 000000000000..858e86193cfa --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-assets-auto.config.ts @@ -0,0 +1,18 @@ +import { sentryVitePlugin } from "@sentry/bundler-plugins/vite"; +import { defineConfig } from "vite"; +import { sentryConfig } from "../configs/sourcemaps-assets-auto.config.js"; + +export default defineConfig({ + build: { + minify: false, + rollupOptions: { + input: ["src/entry1.js", "src/entry2.js"], + output: { + dir: "out/sourcemaps-assets-auto", + chunkFileNames: "[name].js", + entryFileNames: "[name].js", + }, + }, + }, + plugins: [sentryVitePlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-assets-auto.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-assets-auto.test.ts new file mode 100644 index 000000000000..0b087b24d6c1 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-assets-auto.test.ts @@ -0,0 +1,33 @@ +import { expect } from "vitest"; +import { test } from "./utils"; + +test(import.meta.url, ({ runBundler, readOutputFiles }) => { + runBundler({ SENTRY_TEST_RECORD_UPLOADED_SOURCES: "1" }); + const files = readOutputFiles(); + + expect(Object.keys(files).sort()).toMatchInlineSnapshot(` + [ + "common.js", + "common.js.map", + "entry1.js", + "entry2.js", + "entry2.js.map", + "sentry-cli-mock.json", + "sentry-uploaded-sources.json", + ] + `); + expect(JSON.parse(files["sentry-uploaded-sources.json"]!)).toMatchInlineSnapshot(` + [ + [ + "../../src/entry1.js", + ], + ] + `); + expect(files["sentry-cli-mock.json"]).toMatchInlineSnapshot(` + "["release","create","CURRENT_SHA","--project","fake-project"], + ["release","set-commits","CURRENT_SHA","--auto"], + ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], + " + `); +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-ignore-auto.config.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-ignore-auto.config.ts new file mode 100644 index 000000000000..a1e289918d56 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-ignore-auto.config.ts @@ -0,0 +1,18 @@ +import { sentryVitePlugin } from "@sentry/bundler-plugins/vite"; +import { defineConfig } from "vite"; +import { sentryConfig } from "../configs/sourcemaps-ignore-auto.config.js"; + +export default defineConfig({ + build: { + minify: false, + rollupOptions: { + input: ["src/entry1.js", "src/entry2.js"], + output: { + dir: "out/sourcemaps-ignore-auto", + chunkFileNames: "[name].js", + entryFileNames: "[name].js", + }, + }, + }, + plugins: [sentryVitePlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-ignore-auto.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-ignore-auto.test.ts new file mode 100644 index 000000000000..c1bfd1eb7df0 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-ignore-auto.test.ts @@ -0,0 +1,35 @@ +import { expect } from "vitest"; +import { test } from "./utils"; + +test(import.meta.url, ({ runBundler, readOutputFiles }) => { + runBundler({ SENTRY_TEST_RECORD_UPLOADED_SOURCES: "1" }); + const files = readOutputFiles(); + + expect(Object.keys(files).sort()).toMatchInlineSnapshot(` + [ + "common.js", + "entry1.js", + "entry2.js", + "entry2.js.map", + "sentry-cli-mock.json", + "sentry-uploaded-sources.json", + ] + `); + expect(JSON.parse(files["sentry-uploaded-sources.json"]!)).toMatchInlineSnapshot(` + [ + [ + "../../src/common.js", + ], + [ + "../../src/entry1.js", + ], + ] + `); + expect(files["sentry-cli-mock.json"]).toMatchInlineSnapshot(` + "["release","create","CURRENT_SHA","--project","fake-project"], + ["release","set-commits","CURRENT_SHA","--auto"], + ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], + " + `); +}); diff --git a/packages/bundler-plugins/package.json b/packages/bundler-plugins/package.json index 3bb53c945278..d4955bd8860f 100644 --- a/packages/bundler-plugins/package.json +++ b/packages/bundler-plugins/package.json @@ -145,6 +145,7 @@ "@types/node": "^20.19.0", "premove": "^4.0.0", "rolldown": "^1.0.0", + "vite": "^8.3.1", "vitest": "^4.1.11", "webpack": "5.112.2" }, diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index 7306d9b7dfab..51d1a31633e7 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -22,6 +22,7 @@ import { createRequire } from 'node:module'; import { finalizeRolldownDebugIds, getDebugIdForChunk, hasExistingDebugID } from './debug-id-injection'; import { stampDebugIds, type OutputBundle } from './debug-id-stamping'; import { createSourceMapHandling, type OutputDestinationOptions } from './source-map-handling'; +import { createViteSourceMapHooks } from '../vite/source-map-hooks'; // The subset of Rollup's `TransformResult` that this plugin's `transform` // hook actually returns. Defined locally instead of imported from `rollup` @@ -315,21 +316,41 @@ export function _createRollupPluginInternals( } : transform; + // Vite warns about `rollupOptions.output.sourcemap`, so the Vite plugin + // decides in a `config` hook instead. + const viteSourceMapHooks = + buildTool === 'vite' ? createViteSourceMapHooks(sentryBuildPluginManager, sourceMapHandling) : undefined; + const main = { name, buildStart, ...(shouldTransform ? { transform: transformHook } : {}), - // Vite warns about `rollupOptions.output.sourcemap`, so the Vite plugin - // decides in its `config` hook instead. - ...(buildTool === 'rollup' ? { outputOptions: sourceMapHandling.outputOptions } : {}), + ...(viteSourceMapHooks + ? { configResolved: viteSourceMapHooks.configResolved } + : { outputOptions: sourceMapHandling.outputOptions }), renderChunk, generateBundle: { order: 'pre' as const, handler: generateBundle }, writeBundle, }; const post = { - name: `sentry-${buildTool}-upload-staging`, - generateBundle: { order: 'post' as const, handler: sourceMapHandling.stageSourceMaps }, + name: buildTool === 'vite' ? 'sentry-vite-debug-id-sourcemaps' : 'sentry-rollup-upload-staging', + // On the post plugin, so framework plugins that set `build.sourcemap` + // themselves run first and the decision sees the merged setting. + ...(viteSourceMapHooks ? { config: viteSourceMapHooks.config } : {}), + generateBundle: { + order: 'post' as const, + async handler(outputOptions: OutputDestinationOptions, bundle: OutputBundle, isWrite?: boolean): Promise { + if (buildTool === 'vite' && options.sourcemaps?.disable === 'disable-upload') { + // Vite's module preload rewrite in `generateBundle` regenerates the source map of every + // chunk with a dynamic import, which drops the debug ID the main plugin stamped in its + // `pre` hook. Restore it without touching the chunk code, so file hashes stay valid. + stampDebugIds(bundle, false); + } + + await sourceMapHandling.stageSourceMaps(outputOptions, bundle, isWrite); + }, + }, }; return { main, post }; diff --git a/packages/bundler-plugins/src/vite/index.ts b/packages/bundler-plugins/src/vite/index.ts index d9b06efd9c25..9c8d5437fe03 100644 --- a/packages/bundler-plugins/src/vite/index.ts +++ b/packages/bundler-plugins/src/vite/index.ts @@ -1,15 +1,11 @@ import type { SentryRollupPluginOptions } from '../rollup'; -import { _rollupPluginInternal } from '../rollup'; +import { _createRollupPluginInternals } from '../rollup'; import { createRequire } from 'node:module'; -import { stampDebugIds, type OutputBundle } from '../rollup/debug-id-stamping'; interface SentryVitePlugin { name: string; enforce: 'pre' | 'post'; - generateBundle?: { - order: 'pre' | 'post'; - handler: (_outputOptions: unknown, bundle: OutputBundle) => void; - }; + [hook: string]: unknown; } function getViteMajorVersion(): string | undefined { @@ -27,27 +23,12 @@ function getViteMajorVersion(): string | undefined { } export const sentryVitePlugin = (options?: SentryRollupPluginOptions): SentryVitePlugin[] => { - 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's module preload rewrite in `generateBundle` regenerates the source map of every - // chunk with a dynamic import, which drops the debug ID the main plugin stamped in its - // `pre` hook. Restore it without touching the chunk code, so file hashes stay valid. - stampDebugIds(bundle, false); - }, - }, - }); + const { main, post } = _createRollupPluginInternals(options, 'vite', getViteMajorVersion()); + + const plugins: SentryVitePlugin[] = [{ enforce: 'pre', ...main }]; + + if (post) { + plugins.push({ enforce: 'post', ...post }); } return plugins; diff --git a/packages/bundler-plugins/src/vite/source-map-hooks.ts b/packages/bundler-plugins/src/vite/source-map-hooks.ts new file mode 100644 index 000000000000..51125f570ede --- /dev/null +++ b/packages/bundler-plugins/src/vite/source-map-hooks.ts @@ -0,0 +1,140 @@ +import * as path from 'node:path'; +import type { SentryBuildPluginManager, SourceMapDecision, SourceMapKind } from '../core'; +import type { OutputMatch, RollupSourceMapSetting, SourceMapHandling } from '../rollup/source-map-handling'; +import { getOutputMatch, sourceMapKindFromRollupSetting } from '../rollup/source-map-handling'; + +type BundlerOutput = OutputMatch & { dir?: string; sourcemap?: RollupSourceMapSetting }; +type BundlerOutputOptions = BundlerOutput | BundlerOutput[] | undefined; + +type ViteBuildOptions = { + sourcemap?: RollupSourceMapSetting; + outDir?: string; + rollupOptions?: { output?: BundlerOutputOptions }; + rolldownOptions?: { output?: BundlerOutputOptions }; +}; + +export type ViteUserConfig = { + build?: ViteBuildOptions; + environments?: Record; +}; + +export type ViteResolvedConfig = { + root: string; + build: ViteBuildOptions; + environments?: Record; +}; + +export type ViteSourceMapHooks = { + config: (userConfig: ViteUserConfig, env: { command: string }) => ViteUserConfig | null; + configResolved: (config: ViteResolvedConfig) => void; +}; + +const NO_CHANGE: SourceMapDecision = { enableHidden: false, deleteAfterUpload: false }; + +type EnvironmentOutput = { + destination: string; + match: OutputMatch; + sourcemap: RollupSourceMapSetting; + /** The output sets `sourcemap` itself, which overrides `build.sourcemap`. */ + ownSetting: boolean; +}; + +/** + * The outputs an environment writes: one per entry of `rollupOptions.output` or + * `rolldownOptions.output`. An output writes to `build.outDir` (relative to the root) unless it + * sets its own `dir`, which the bundler resolves from the working directory. An output's own + * `sourcemap` overrides `build.sourcemap`. + */ +function getOutputs(root: string, build: ViteBuildOptions): EnvironmentOutput[] { + const configured = build.rolldownOptions?.output ?? build.rollupOptions?.output; + const outputs = Array.isArray(configured) ? configured : [configured ?? {}]; + + return outputs.map(output => ({ + destination: output.dir === undefined ? path.resolve(root, build.outDir ?? 'dist') : path.resolve(output.dir), + match: getOutputMatch(output), + sourcemap: output.sourcemap ?? build.sourcemap, + ownSetting: output.sourcemap !== undefined, + })); +} + +/** + * Vite warns about `rollupOptions.output.sourcemap`, so the Vite plugin decides on `build.sourcemap` + * in a `config` hook. The hook belongs on the post plugin: framework plugins that set + * `build.sourcemap` themselves run first, and the decision sees the merged setting. The output + * directories are only known in `configResolved`, which then records the decision for each of them. + */ +export function createViteSourceMapHooks( + sentryBuildPluginManager: SentryBuildPluginManager, + sourceMapHandling: SourceMapHandling, +): ViteSourceMapHooks { + // `'*'` for the top-level setting, otherwise the name of an environment with its own. + const decisions = new Map(); + + return { + config: (userConfig, env) => { + if (env.command !== 'build') { + return null; + } + + const kind = sourceMapKindFromRollupSetting(userConfig.build?.sourcemap); + const decision = sentryBuildPluginManager.sourceMaps.decide(kind); + decisions.set('*', { kind, decision }); + + const result: ViteUserConfig = decision.enableHidden ? { build: { sourcemap: 'hidden' } } : {}; + + for (const [name, environment] of Object.entries(userConfig.environments ?? {})) { + const setting = environment?.build?.sourcemap; + if (setting === undefined) { + continue; + } + + const environmentKind = sourceMapKindFromRollupSetting(setting); + const environmentDecision = sentryBuildPluginManager.sourceMaps.decide(environmentKind); + decisions.set(name, { kind: environmentKind, decision: environmentDecision }); + + if (environmentDecision.enableHidden) { + result.environments = { ...result.environments, [name]: { build: { sourcemap: 'hidden' } } }; + } + } + + return result; + }, + + configResolved: config => { + const environments = config.environments ?? { client: { build: config.build } }; + + const { uploadPossible } = sentryBuildPluginManager.sourceMaps; + const deleteOption = sentryBuildPluginManager.normalizedOptions.sourcemaps?.deleteSourcemapsAfterUpload; + + for (const [name, environment] of Object.entries(environments)) { + const { kind, decision } = decisions.get(name) ?? decisions.get('*') ?? { kind: 'unset', decision: NO_CHANGE }; + + for (const output of getOutputs(config.root, environment.build)) { + // The plugin never changes an output's own setting, so the user enabled + // these maps: only hidden ones the user asked to delete are removed. + if (output.ownSetting) { + sourceMapHandling.setDecision(output.destination, { + match: output.match, + kind: sourceMapKindFromRollupSetting(output.sourcemap), + decision: { + enableHidden: false, + deleteAfterUpload: uploadPossible && deleteOption === true && output.sourcemap === 'hidden', + }, + }); + continue; + } + + // A plugin that ran after ours may have changed the setting again. + if (decision.deleteAfterUpload && output.sourcemap !== 'hidden') { + sentryBuildPluginManager.logger.debug( + `Source maps of the "${name}" environment are set to ${JSON.stringify(output.sourcemap)}. Not removing them after upload.`, + ); + sourceMapHandling.setDecision(output.destination, { match: output.match, kind, decision: NO_CHANGE }); + } else { + sourceMapHandling.setDecision(output.destination, { match: output.match, kind, decision }); + } + } + } + }, + }; +} diff --git a/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts new file mode 100644 index 000000000000..b8a659e837dc --- /dev/null +++ b/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts @@ -0,0 +1,327 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { build, createBuilder } from 'vite'; +import type { Plugin, ResolvedConfig } from 'vite'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { sentryVitePlugin } from '../../src/vite'; +import type { Options } from '../../src/core'; + +const { mockSourcemapUpload } = vi.hoisted(() => ({ mockSourcemapUpload: vi.fn() })); + +vi.mock('sentry', () => ({ + createSentrySDK: () => ({ + release: { create: vi.fn(), finalize: vi.fn(), 'set-commits': vi.fn(), deploy: vi.fn() }, + sourcemap: { upload: mockSourcemapUpload, inject: vi.fn() }, + run: vi.fn(), + }), +})); + +const credentials: Options = { + authToken: 't', + org: 'o', + project: 'p', + release: { name: 'test-release', create: false, finalize: false }, + telemetry: false, + silent: true, +}; + +type Upload = { directory: string; files: Record }; + +function listFiles(dir: string): string[] { + return fs + .readdirSync(dir, { recursive: true, encoding: 'utf8' }) + .filter(file => fs.statSync(path.join(dir, file)).isFile()); +} + +function readChunks(dir: string): string[] { + return listFiles(dir) + .filter(file => file.endsWith('.js')) + .map(file => fs.readFileSync(path.join(dir, file), 'utf8')); +} + +describe('sourcemaps.deleteSourcemapsAfterUpload (vite)', () => { + let root: string; + let entry: string; + let outDir: string; + let uploads: Upload[]; + + beforeEach(() => { + vi.clearAllMocks(); + uploads = []; + mockSourcemapUpload.mockImplementation(async ({ directory }: { directory: string }) => { + const files: Record = {}; + for (const file of fs.readdirSync(directory)) { + files[file] = fs.readFileSync(path.join(directory, file), 'utf8'); + } + uploads.push({ directory, files }); + }); + + root = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-vite-delete-maps-')); + entry = path.join(root, 'main.js'); + outDir = path.join(root, 'dist'); + // A dynamic import makes Vite's import analysis regenerate the source map in `generateBundle`. + fs.writeFileSync(entry, "import('./lazy.js').then(m => console.log(m.answer));\n"); + fs.writeFileSync(path.join(root, 'lazy.js'), 'export const answer = 42;\n'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + function viteConfig(pluginOptions: Options, plugins: Plugin[] = []) { + return { + configFile: false as const, + logLevel: 'silent' as const, + root, + plugins: [...sentryVitePlugin({ ...credentials, ...pluginOptions }), ...plugins], + build: { outDir, minify: false as const, rollupOptions: { input: entry } }, + }; + } + + function expectUploadedPairs(upload: Upload, chunks: string[]): void { + const uploadedNames = Object.keys(upload.files); + expect(uploadedNames.filter(name => name.endsWith('.js'))).toHaveLength(chunks.length); + + for (const chunk of chunks) { + const debugId = chunk.match(/sentry-dbid-([0-9a-f-]{36})/)?.[1]; + expect(debugId).toBeDefined(); + const mapName = uploadedNames.find(name => name.startsWith(`${debugId}-`) && name.endsWith('.map')); + expect(mapName).toBeDefined(); + expect(JSON.parse(upload.files[mapName!]!)).toMatchObject({ debug_id: debugId }); + } + + expect(fs.existsSync(upload.directory)).toBe(false); + } + + it('enables hidden source maps, uploads them and leaves none in the output when the setting is unset', async () => { + let resolvedSourcemap: unknown; + const spy: Plugin = { + name: 'spy', + configResolved(config: ResolvedConfig) { + resolvedSourcemap = config.build.sourcemap; + }, + }; + + await build(viteConfig({}, [spy])); + + expect(resolvedSourcemap).toBe('hidden'); + const files = listFiles(outDir); + expect(files.filter(file => file.endsWith('.map'))).toEqual([]); + const chunks = readChunks(outDir); + expect(chunks).toHaveLength(2); + for (const chunk of chunks) { + expect(chunk).not.toContain('sourceMappingURL'); + } + + expect(uploads).toHaveLength(1); + expectUploadedPairs(uploads[0]!, chunks); + }); + + it('switches visible source maps to hidden and removes them when the option is true', async () => { + const config = viteConfig({ sourcemaps: { deleteSourcemapsAfterUpload: true } }); + await build({ ...config, build: { ...config.build, sourcemap: true } }); + + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toEqual([]); + const chunks = readChunks(outDir); + for (const chunk of chunks) { + expect(chunk).not.toContain('sourceMappingURL'); + } + expect(uploads).toHaveLength(1); + expectUploadedPairs(uploads[0]!, chunks); + }); + + it('stages chunks whose debug ID the bundler injects itself', async () => { + const config = viteConfig({}); + await build({ + ...config, + build: { ...config.build, rollupOptions: { input: entry, output: { sourcemapDebugIds: true } } }, + }); + + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toEqual([]); + const chunks = readChunks(outDir); + for (const chunk of chunks) { + expect(chunk).toContain('//# debugId='); + } + expect(uploads).toHaveLength(1); + expectUploadedPairs(uploads[0]!, chunks); + }); + + it('removes source maps from an output directory set through rollupOptions', async () => { + const rollupOutDir = path.join(root, 'out', 'rollup'); + const config = viteConfig({}); + await build({ + ...config, + build: { ...config.build, outDir: 'dist', rollupOptions: { input: entry, output: { dir: rollupOutDir } } }, + }); + + expect(listFiles(rollupOutDir).filter(file => file.endsWith('.map'))).toEqual([]); + expect(uploads).toHaveLength(1); + expectUploadedPairs(uploads[0]!, readChunks(rollupOutDir)); + }); + + it('keeps source maps an output enables through rollupOptions and uploads them from disk', async () => { + const config = viteConfig({}); + await build({ + ...config, + build: { ...config.build, rollupOptions: { input: entry, output: { sourcemap: true } } }, + }); + + const chunks = readChunks(outDir); + expect(chunks).toHaveLength(2); + expect(chunks.every(chunk => chunk.includes('//# sourceMappingURL='))).toBe(true); + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toHaveLength(2); + expect(uploads).toHaveLength(1); + expect(Object.keys(uploads[0]!.files)).toHaveLength(4); + }); + + it('keeps hidden source maps an output enables through rollupOptions', async () => { + const config = viteConfig({}); + await build({ + ...config, + build: { ...config.build, rollupOptions: { input: entry, output: { sourcemap: 'hidden' } } }, + }); + + const chunks = readChunks(outDir); + expect(chunks).toHaveLength(2); + expect(chunks.some(chunk => chunk.includes('//# sourceMappingURL='))).toBe(false); + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toHaveLength(2); + expect(uploads).toHaveLength(1); + expect(Object.keys(uploads[0]!.files)).toHaveLength(4); + }); + + it('removes hidden source maps an output enables when the option is true', async () => { + const config = viteConfig({ sourcemaps: { deleteSourcemapsAfterUpload: true } }); + await build({ + ...config, + build: { ...config.build, rollupOptions: { input: entry, output: { sourcemap: 'hidden' } } }, + }); + + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toEqual([]); + expect(uploads).toHaveLength(1); + expectUploadedPairs(uploads[0]!, readChunks(outDir)); + }); + + it('keeps hidden source maps the user enabled and uploads them from disk', async () => { + const config = viteConfig({}); + await build({ ...config, build: { ...config.build, sourcemap: 'hidden' } }); + + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toHaveLength(2); + expect(uploads).toHaveLength(1); + expect(Object.keys(uploads[0]!.files)).toHaveLength(4); + }); + + it('does not upload chunks matched by `sourcemaps.ignore` and leaves their maps in the output', async () => { + const vendor = path.join(root, 'vendor.js'); + fs.writeFileSync(vendor, 'console.log("vendor");\n'); + const config = viteConfig({ sourcemaps: { ignore: ['**/vendor-*.js'] } }); + await build({ ...config, build: { ...config.build, rollupOptions: { input: [entry, vendor] } } }); + + const maps = listFiles(outDir).filter(file => file.endsWith('.map')); + expect(maps).toHaveLength(1); + expect(maps[0]).toMatch(/vendor-[^/]+\.js\.map$/); + expect(uploads).toHaveLength(1); + expectUploadedPairs( + uploads[0]!, + readChunks(outDir).filter(chunk => !chunk.includes('"vendor"')), + ); + }); + + it('decides for each output when two write into one directory', async () => { + const config = viteConfig({}); + await build({ + ...config, + build: { + ...config.build, + rollupOptions: { + input: entry, + output: [ + { format: 'es', entryFileNames: '[name].mjs', chunkFileNames: '[name].mjs', sourcemap: true }, + { format: 'cjs', entryFileNames: '[name].cjs', chunkFileNames: '[name].cjs' }, + ], + }, + }, + }); + + expect(listFiles(outDir).sort()).toEqual([ + 'lazy.cjs', + 'lazy.mjs', + 'lazy.mjs.map', + 'main.cjs', + 'main.mjs', + 'main.mjs.map', + ]); + expect(uploads).toHaveLength(2); + }); + + it('does not create a staging folder when the build does not write', async () => { + const mkdtempSpy = vi.spyOn(fs.promises, 'mkdtemp'); + const config = viteConfig({}); + await build({ ...config, build: { ...config.build, write: false } }); + + expect(mkdtempSpy).not.toHaveBeenCalled(); + expect(uploads).toHaveLength(0); + }); + + it('does not remove source maps when a later plugin turns them off', async () => { + const later: Plugin = { name: 'later', config: () => ({ build: { sourcemap: false } }) }; + + await build(viteConfig({}, [later])); + + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toEqual([]); + expect(uploads).toHaveLength(0); + }); + + it('uploads a client build and an SSR build of the same plugin instances on their own', async () => { + const plugins = sentryVitePlugin(credentials); + const serverOutDir = path.join(root, 'dist-server'); + + await build({ ...viteConfig({}), plugins }); + await build({ + ...viteConfig({}), + plugins, + build: { outDir: serverOutDir, minify: false, ssr: entry }, + }); + + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toEqual([]); + expect(listFiles(serverOutDir).filter(file => file.endsWith('.map'))).toEqual([]); + expect(uploads).toHaveLength(2); + }); + + it('keeps source maps in an environment that sets them itself', async () => { + const serverOutDir = path.join(root, 'dist-server'); + const builder = await createBuilder({ + ...viteConfig({}), + environments: { + ssr: { + build: { outDir: serverOutDir, minify: false, sourcemap: 'hidden', rollupOptions: { input: entry } }, + }, + }, + builder: {}, + }); + await builder.buildApp(); + + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toEqual([]); + expect(listFiles(serverOutDir).filter(file => file.endsWith('.map'))).toHaveLength(2); + expect(uploads).toHaveLength(2); + }); + + it('still restores debug IDs for "disable-upload" builds', async () => { + const config = viteConfig({ sourcemaps: { disable: 'disable-upload' } }); + await build({ ...config, build: { ...config.build, sourcemap: true } }); + + const chunks = readChunks(outDir); + const maps = listFiles(outDir).filter(file => file.endsWith('.map')); + expect(maps).toHaveLength(2); + for (const chunk of chunks) { + const debugId = chunk.match(/sentry-dbid-([0-9a-f-]{36})/)?.[1]; + expect(debugId).toBeDefined(); + const map = maps + .map(file => JSON.parse(fs.readFileSync(path.join(outDir, file), 'utf8'))) + .find(m => m.debug_id === debugId); + expect(map).toBeDefined(); + } + expect(uploads).toHaveLength(0); + }); +}); diff --git a/packages/bundler-plugins/test/vite/public-api.test.ts b/packages/bundler-plugins/test/vite/public-api.test.ts index cade4da1c67a..eba6b7ebb59e 100644 --- a/packages/bundler-plugins/test/vite/public-api.test.ts +++ b/packages/bundler-plugins/test/vite/public-api.test.ts @@ -24,7 +24,7 @@ describe('sentryVitePlugin', () => { const pluginNames = plugins.map(plugin => plugin.name); - expect(pluginNames).toEqual(expect.arrayContaining(['sentry-vite-plugin'])); + expect(pluginNames).toEqual(['sentry-vite-plugin', 'sentry-vite-debug-id-sourcemaps']); }); it('returns an array of Vite pluginswhen unplugin returns a single plugin', () => { diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts index c4c056319b55..ca39cc2fd18b 100644 --- a/packages/solidstart/test/config/withSentry.test.ts +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -129,6 +129,7 @@ describe('withSentry()', () => { 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', + 'sentry-vite-debug-id-sourcemaps', 'sentry-solidstart-update-source-map-setting', ]); }); @@ -153,6 +154,7 @@ describe('withSentry()', () => { 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', + 'sentry-vite-debug-id-sourcemaps', 'sentry-solidstart-update-source-map-setting', 'my-test-plugin', ]); @@ -181,6 +183,7 @@ describe('withSentry()', () => { 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', + 'sentry-vite-debug-id-sourcemaps', 'sentry-solidstart-update-source-map-setting', 'my-test-plugin', ]); diff --git a/packages/solidstart/test/vite/sentrySolidStart.test.ts b/packages/solidstart/test/vite/sentrySolidStart.test.ts index a2aadf600b2c..0355a719e66e 100644 --- a/packages/solidstart/test/vite/sentrySolidStart.test.ts +++ b/packages/solidstart/test/vite/sentrySolidStart.test.ts @@ -54,6 +54,7 @@ describe('sentrySolidStart()', () => { 'code-transformer', 'sentry-solidstart-files-to-delete-after-upload', 'sentry-vite-plugin', + 'sentry-vite-debug-id-sourcemaps', 'sentry-solidstart-update-source-map-setting', ]); }); diff --git a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts index db2382efdc45..3c8fcc10ce66 100644 --- a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts +++ b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts @@ -29,6 +29,7 @@ describe('sentrySolidStartVite()', () => { 'sentry-solidstart-tunnel-base-path', 'sentry-solidstart-build-instrumentation-file', 'sentry-vite-plugin', + 'sentry-vite-debug-id-sourcemaps', 'sentry-solidstart-update-source-map-setting', ]); }); diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index 7cae5e1097c4..8fb282432798 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -70,8 +70,8 @@ describe('sentrySvelteKit()', () => { // 1 kit config resolver + 1 browser-tracing variant resolver + 1 OpenTelemetry API resolver // + 1 tunnel base path plugin + 1 auto instrument plugin // + 1 orchestrion plugin + 1 build flag plugin + 1 global values injection plugin + 1 modified main plugin - // + 3 custom plugins - expect(plugins).toHaveLength(12); + // + 1 debug ID plugin + 3 custom plugins + expect(plugins).toHaveLength(13); }); it('returns the custom sentry source maps upload plugin, unmodified sourcemaps plugins and the auto-instrument plugin by default', async () => { @@ -96,6 +96,7 @@ describe('sentrySvelteKit()', () => { 'sentry-sveltekit-global-values-injection-plugin', // modified main plugin (writeBundle deferred to closeBundle): 'sentry-vite-plugin', + 'sentry-vite-debug-id-sourcemaps', 'sentry-sveltekit-update-source-map-setting-plugin', 'sentry-sveltekit-files-to-delete-after-upload-setting-plugin', // custom source maps plugin (sorcery flattening + deferred upload): @@ -124,7 +125,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the auto instrument plugin if autoInstrument is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoInstrument: false }); const pluginNames = plugins.map(plugin => plugin.name); - expect(plugins).toHaveLength(11); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + orchestrion + build flag + global values injection + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(12); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + orchestrion + build flag + global values injection + 1 modified main plugin + 1 debug ID plugin + 3 custom plugins expect(pluginNames).not.toContain('sentry-auto-instrumentation'); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8ed6712cf3a..694525471c63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1217,6 +1217,9 @@ importers: rolldown: specifier: ^1.0.0 version: 1.2.11 + vite: + specifier: ^8.3.1 + version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@8.1.1))(sass@1.72.0)(stylus@0.59.0(supports-color@8.1.1))(terser@5.51.2)(tsx@4.23.0)(yaml@2.9.0) vitest: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.11)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@21.1.2(supports-color@8.1.1))(less@4.2.0(supports-color@8.1.1))(sass@1.72.0)(stylus@0.59.0(supports-color@8.1.1))(terser@5.51.2)(tsx@4.23.0)(yaml@2.9.0)