diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/basic-cjs.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/basic-cjs.test.ts index e91fb5ed133b..f07edbcb8c58 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/basic-cjs.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/basic-cjs.test.ts @@ -35,6 +35,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/esbuild/basic.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/basic.test.ts index e91fb5ed133b..f07edbcb8c58 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/basic.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/basic.test.ts @@ -35,6 +35,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/esbuild/release-disabled.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/release-disabled.test.ts index 64fdb039b963..7aac92da1976 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/release-disabled.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/release-disabled.test.ts @@ -34,6 +34,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { ", "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/esbuild/sourcemaps-assets-auto.config.js b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-assets-auto.config.js new file mode 100644 index 000000000000..588483bf6eea --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-assets-auto.config.js @@ -0,0 +1,14 @@ +import * as esbuild from "esbuild"; +import { sentryEsbuildPlugin } from "@sentry/bundler-plugins/esbuild"; +import { sentryConfig } from "../configs/sourcemaps-assets-auto.config.js"; + +await esbuild.build({ + entryPoints: ["./src/entry1.js", "./src/entry2.js"], + bundle: true, + outdir: "./out/sourcemaps-assets-auto", + minify: false, + format: "esm", + splitting: true, + chunkNames: "[name]", + plugins: [sentryEsbuildPlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-assets-auto.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-assets-auto.test.ts new file mode 100644 index 000000000000..4751e8acc361 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/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(` + [ + "chunk.js", + "entry1.js", + "entry2.js", + "sentry-cli-mock.json", + "sentry-uploaded-sources.json", + ] + `); + expect(JSON.parse(files["sentry-uploaded-sources.json"]!)).toMatchInlineSnapshot(` + [ + [ + "sentry-debug-id-stub:_sentry-debug-id-injection-stub?sentry-module-id=00000000-0000-0000-0000-000000000000", + "../../src/entry1.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/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-ignore-auto.config.js b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-ignore-auto.config.js new file mode 100644 index 000000000000..8737a7c03287 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-ignore-auto.config.js @@ -0,0 +1,14 @@ +import * as esbuild from "esbuild"; +import { sentryEsbuildPlugin } from "@sentry/bundler-plugins/esbuild"; +import { sentryConfig } from "../configs/sourcemaps-ignore-auto.config.js"; + +await esbuild.build({ + entryPoints: ["./src/entry1.js", "./src/entry2.js"], + bundle: true, + outdir: "./out/sourcemaps-ignore-auto", + minify: false, + format: "esm", + splitting: true, + chunkNames: "[name]", + plugins: [sentryEsbuildPlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-ignore-auto.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-ignore-auto.test.ts new file mode 100644 index 000000000000..4751e8acc361 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-ignore-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(` + [ + "chunk.js", + "entry1.js", + "entry2.js", + "sentry-cli-mock.json", + "sentry-uploaded-sources.json", + ] + `); + expect(JSON.parse(files["sentry-uploaded-sources.json"]!)).toMatchInlineSnapshot(` + [ + [ + "sentry-debug-id-stub:_sentry-debug-id-injection-stub?sentry-module-id=00000000-0000-0000-0000-000000000000", + "../../src/entry1.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/src/core/build-plugin-manager.ts b/packages/bundler-plugins/src/core/build-plugin-manager.ts index 2e43f8adca09..bb178bcc268c 100644 --- a/packages/bundler-plugins/src/core/build-plugin-manager.ts +++ b/packages/bundler-plugins/src/core/build-plugin-manager.ts @@ -41,6 +41,8 @@ export function _resetDeployedReleasesForTesting(): void { * before the bundler writes them to disk. The upload reads from this folder, so the bundler can * drop the source map assets from its own output. */ +export type UploadResult = { uploaded: boolean; uploadedSourceMapPaths: string[] }; + export type UploadStaging = { readonly dir: string; /** Number of artifacts written so far. */ @@ -127,11 +129,14 @@ export type SentryBuildPluginManager = { /** * Uploads sourcemaps using the "Debug ID" method. This function takes a list of build artifact paths that will be uploaded + * + * `uploadedSourceMapPaths` lists the source maps read from disk that were uploaded. It is empty + * when nothing was uploaded, in direct mode (`prepareArtifacts: false`) and for staged artifacts. */ uploadSourcemaps( buildArtifactPaths: string[], opts?: { prepareArtifacts?: boolean; staging?: UploadStaging }, - ): Promise<{ uploaded: boolean }>; + ): Promise; /** * Creates a staging folder that `uploadSourcemaps` uploads together with the build artifacts @@ -232,7 +237,7 @@ export function createSentryBuildPluginManager( createRelease: async () => { /* noop */ }, - uploadSourcemaps: async () => ({ uploaded: false }), + uploadSourcemaps: async () => ({ uploaded: false, uploadedSourceMapPaths: [] }), createUploadStaging: async () => ({ dir: '', count: 0, @@ -655,16 +660,17 @@ export function createSentryBuildPluginManager( async uploadSourcemaps( buildArtifactPaths: string[], opts?: { prepareArtifacts?: boolean; staging?: UploadStaging }, - ): Promise<{ uploaded: boolean }> { + ): Promise { const staging = opts?.staging; if (!canUploadSourceMaps(options, logger, isDevMode)) { await staging?.discard(); - return { uploaded: false }; + return { uploaded: false, uploadedSourceMapPaths: [] }; } const assets = options.sourcemaps?.assets; let uploaded = false; + const preparedSourceMapPaths: string[] = []; await startSpan( // This is `forceTransaction`ed because this span is used in dashboards in the form of indexed transactions. @@ -763,7 +769,7 @@ export function createSentryBuildPluginManager( // Preparing the bundles can be a lot of work and doing it all at once has the potential of nuking the heap so // instead we do it with a maximum of 16 concurrent workers const preparationTasks = debugIdChunkFilePaths.map((chunkFilePath, chunkIndex) => async () => { - await prepareBundleForDebugIdUpload( + const prepared = await prepareBundleForDebugIdUpload( chunkFilePath, tmpUploadFolder, stagedCount + chunkIndex, @@ -771,6 +777,9 @@ export function createSentryBuildPluginManager( options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook, options.sourcemaps?.resolveSourceMap, ); + if (prepared?.sourceMapPath !== undefined) { + preparedSourceMapPaths.push(prepared.sourceMapPath); + } }); const workers: Promise[] = []; const worker = async (): Promise => { @@ -859,7 +868,7 @@ export function createSentryBuildPluginManager( }, ); - return { uploaded }; + return { uploaded, uploadedSourceMapPaths: uploaded ? preparedSourceMapPaths.sort() : [] }; }, async createUploadStaging(): Promise { diff --git a/packages/bundler-plugins/src/core/debug-id-upload.ts b/packages/bundler-plugins/src/core/debug-id-upload.ts index cf402b5f476f..9a2efe97c305 100644 --- a/packages/bundler-plugins/src/core/debug-id-upload.ts +++ b/packages/bundler-plugins/src/core/debug-id-upload.ts @@ -20,6 +20,10 @@ export function createDebugIdUploadFunction({ sentryBuildPluginManager }: DebugI }; } +/** The source map of a prepared bundle. `sourceMapPath` is `undefined` for an inline map. */ +export type PreparedBundle = { sourceMapPath: string | undefined }; + +/** Resolves to `undefined` when the bundle was skipped. */ export async function prepareBundleForDebugIdUpload( bundleFilePath: string, uploadFolder: string, @@ -27,7 +31,7 @@ export async function prepareBundleForDebugIdUpload( logger: Logger, rewriteSourcesHook: RewriteSourcesHook, resolveSourceMapHook: ResolveSourceMapHook | undefined, -): Promise { +): Promise { let bundleContent: string; try { bundleContent = await fs.promises.readFile(bundleFilePath, 'utf8'); @@ -81,6 +85,7 @@ export async function prepareBundleForDebugIdUpload( } await writePreparedArtifact(uploadFolder, `${result.artifact.debugId}-${chunkIndex}`, result.artifact); + return { sourceMapPath }; } export type PreparedUploadArtifact = { @@ -184,6 +189,11 @@ function hasSpecCompliantDebugId(bundleSource: string): boolean { return SPEC_LAST_DEBUG_ID_REGEX.test(bundleSource); } +/** Whether the bundle carries a debug ID, injected by the plugin or as a `//# debugId=` comment. */ +export function hasDebugId(bundleSource: string): boolean { + return determineDebugIdFromBundleSource(bundleSource) !== undefined || hasSpecCompliantDebugId(bundleSource); +} + function addDebugIdToBundleSource(bundleSource: string, debugId: string): string { if (hasSpecCompliantDebugId(bundleSource)) { return bundleSource.replace(SPEC_LAST_DEBUG_ID_REGEX, `//# debugId=${debugId}`); diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index f19fe4c5642a..957c7a5f5ebb 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -110,8 +110,8 @@ export { generateModuleMetadataInjectorCode, } from './utils'; export { createSentryBuildPluginManager } from './build-plugin-manager'; -export type { SentryBuildPluginManager, UploadStaging } from './build-plugin-manager'; -export { createDebugIdUploadFunction, addDebugIdToEmittedArtifacts, stampDebugId } from './debug-id-upload'; +export type { SentryBuildPluginManager, UploadResult, UploadStaging } from './build-plugin-manager'; +export { createDebugIdUploadFunction, addDebugIdToEmittedArtifacts, hasDebugId, stampDebugId } from './debug-id-upload'; export { decideSourceMapHandling } from './sourcemap-settings'; export type { SourceMapDecision, SourceMapKind } from './sourcemap-settings'; export type { UploadFilter } from './upload-filter'; diff --git a/packages/bundler-plugins/src/esbuild/index.ts b/packages/bundler-plugins/src/esbuild/index.ts index fc17904624be..68d3c2b6a49b 100644 --- a/packages/bundler-plugins/src/esbuild/index.ts +++ b/packages/bundler-plugins/src/esbuild/index.ts @@ -1,4 +1,4 @@ -import type { Options } from '../core'; +import type { Options, SourceMapDecision, SourceMapKind } from '../core'; import { createSentryBuildPluginManager, generateReleaseInjectorCode, @@ -9,6 +9,7 @@ import { addDebugIdToEmittedArtifacts, isJsFile, } from '../core'; +import * as fs from 'node:fs'; import * as path from 'node:path'; import { createRequire } from 'node:module'; import { randomUUID } from 'node:crypto'; @@ -48,6 +49,8 @@ interface EsbuildOnEndArgs { }; } +type EsbuildSourceMapSetting = boolean | 'inline' | 'external' | 'linked' | 'both' | undefined; + interface EsbuildInitialOptions { bundle?: boolean; inject?: string[]; @@ -55,6 +58,24 @@ interface EsbuildInitialOptions { define?: Record; write?: boolean; absWorkingDir?: string; + sourcemap?: EsbuildSourceMapSetting; +} + +/** Maps esbuild's `sourcemap` setting onto the plugin's source map kinds. */ +function sourceMapKindFromEsbuildSetting(setting: EsbuildSourceMapSetting): SourceMapKind { + switch (setting) { + case undefined: + return 'unset'; + case false: + return 'off'; + case 'external': + return 'hidden'; + case 'inline': + case 'both': + return 'inline'; + default: + return 'visible'; + } } interface EsbuildPluginBuild { @@ -280,6 +301,16 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any { }); } + // With `write: false` nothing reaches the disk, so nothing can be uploaded or deleted. + const sourceMapKind = sourceMapKindFromEsbuildSetting(initialOptions.sourcemap); + const sourceMapDecision: SourceMapDecision = + initialOptions.write === false + ? { enableHidden: false, deleteAfterUpload: false } + : sentryBuildPluginManager.sourceMaps.decide(sourceMapKind); + if (sourceMapDecision.enableHidden) { + initialOptions.sourcemap = 'external'; + } + // Create release and optionally upload const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); @@ -290,27 +321,44 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any { await sentryBuildPluginManager.createRelease(); if (sourcemapsEnabled) { - const buildArtifacts = result.metafile ? Object.keys(result.metafile.outputs) : []; + // The metafile lists outputs relative to `absWorkingDir`, not to the process. + const outputDir = initialOptions.absWorkingDir ?? process.cwd(); + const buildArtifacts = result.metafile + ? Object.keys(result.metafile.outputs).map(output => path.resolve(outputDir, output)) + : []; if (options.sourcemaps?.disable !== 'disable-upload') { - await upload(buildArtifacts); + // The upload globs the paths, and glob reads backslashes as escapes. + const { uploaded, uploadedSourceMapPaths } = await upload( + buildArtifacts.map(artifact => artifact.split(path.sep).join('/')), + ); + + // esbuild writes before `onEnd` runs, so the maps are deleted from disk + // afterwards instead of being dropped from the output like in other bundlers. + // A map the plugin turned on never stays in the output. A map the user + // enabled is only deleted once it was uploaded. + if (sourceMapDecision.deleteAfterUpload) { + const sourceMaps = + sourceMapKind === 'unset' + ? buildArtifacts.filter(artifact => artifact.endsWith('.map')) + : uploadedSourceMapPaths; + + if (uploaded) { + await Promise.all(sourceMaps.map(sourceMap => fs.promises.rm(sourceMap, { force: true }))); + } else if (sourceMaps.length > 0) { + logger.error('The source maps were not uploaded, so they stay in the build output.'); + } + } } else if (initialOptions.write === false) { logger.debug('Build output is not written to disk. Skipping debug ID injection into build artifacts.'); } else { // The upload routine (which stamps debug IDs into temp copies of the artifacts) is // skipped with `disable-upload`. esbuild has no hook to modify outputs before they are // written, so the emitted artifacts get stamped on disk instead. - const outputDir = initialOptions.absWorkingDir ?? process.cwd(); await Promise.all( buildArtifacts .filter(isJsFile) - .map(bundle => - addDebugIdToEmittedArtifacts( - path.resolve(outputDir, bundle), - logger, - options.sourcemaps?.resolveSourceMap, - ), - ), + .map(bundle => addDebugIdToEmittedArtifacts(bundle, logger, options.sourcemaps?.resolveSourceMap)), ); } } diff --git a/packages/bundler-plugins/test/core/build-plugin-manager.test.ts b/packages/bundler-plugins/test/core/build-plugin-manager.test.ts index e5105d55f4f8..15eadd075962 100644 --- a/packages/bundler-plugins/test/core/build-plugin-manager.test.ts +++ b/packages/bundler-plugins/test/core/build-plugin-manager.test.ts @@ -393,7 +393,7 @@ describe('createSentryBuildPluginManager', () => { vi.spyOn(fs.promises, 'stat').mockResolvedValue({ size: 10 } as fs.Stats); vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined as never); - mockPrepareBundleForDebugIdUpload.mockResolvedValue(undefined); + mockPrepareBundleForDebugIdUpload.mockResolvedValue({ sourceMapPath: '/app/dist/a.js.map' }); const manager = createSentryBuildPluginManager( { @@ -406,10 +406,11 @@ describe('createSentryBuildPluginManager', () => { { buildTool: 'webpack', loggerPrefix: '[sentry-webpack-plugin]' }, ); - await manager.uploadSourcemaps(['/unused']); + const result = await manager.uploadSourcemaps(['/unused']); // Should call prepare for each JS chunk discovered by glob expect(mockPrepareBundleForDebugIdUpload).toHaveBeenCalled(); + expect(result).toEqual({ uploaded: true, uploadedSourceMapPaths: ['/app/dist/a.js.map'] }); // Should upload from the temp folder, where debug IDs are already injected expect(mockSourcemapUpload).toHaveBeenCalledWith({ directory: '/tmp/sentry-upload-xyz', diff --git a/packages/bundler-plugins/test/core/upload-staging.test.ts b/packages/bundler-plugins/test/core/upload-staging.test.ts index 29437eff7c0e..de45e71fa16e 100644 --- a/packages/bundler-plugins/test/core/upload-staging.test.ts +++ b/packages/bundler-plugins/test/core/upload-staging.test.ts @@ -241,7 +241,7 @@ describe('createUploadStaging', () => { const result = await manager.uploadSourcemaps([], { staging }); - expect(result).toEqual({ uploaded: true }); + expect(result).toEqual({ uploaded: true, uploadedSourceMapPaths: [] }); expect(mockSourcemapUpload).toHaveBeenCalledWith(expect.objectContaining({ directory: staging.dir })); expect(fs.existsSync(staging.dir)).toBe(false); }); @@ -273,7 +273,7 @@ describe('createUploadStaging', () => { const result = await manager.uploadSourcemaps([path.join(tmpDir, '**/*.js')], { staging }); - expect(result).toEqual({ uploaded: true }); + expect(result).toEqual({ uploaded: true, uploadedSourceMapPaths: [] }); expect(uploadedFiles).toEqual([`${DEBUG_ID}-0.js`, `${DEBUG_ID}-0.js.map`]); expect(uploadedMaps).not.toContain('stale.ts'); }); @@ -332,7 +332,7 @@ describe('createUploadStaging', () => { const result = await manager.uploadSourcemaps([], { staging }); - expect(result).toEqual({ uploaded: false }); + expect(result).toEqual({ uploaded: false, uploadedSourceMapPaths: [] }); expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/matching sources/)); expect(mockSourcemapUpload).not.toHaveBeenCalled(); expect(fs.existsSync(staging.dir)).toBe(false); @@ -353,7 +353,7 @@ describe('createUploadStaging', () => { const result = await manager.uploadSourcemaps([], { staging }); - expect(result).toEqual({ uploaded: false }); + expect(result).toEqual({ uploaded: false, uploadedSourceMapPaths: [] }); expect(mockSourcemapUpload).not.toHaveBeenCalled(); expect(fs.existsSync(staging.dir)).toBe(false); }); @@ -373,7 +373,7 @@ describe('createUploadStaging', () => { try { const result = await manager.uploadSourcemaps([], { staging }); - expect(result).toEqual({ uploaded: false }); + expect(result).toEqual({ uploaded: false, uploadedSourceMapPaths: [] }); expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(`sentry sourcemap upload ${staging.dir}`)); expect(fs.existsSync(staging.dir)).toBe(true); } finally { @@ -393,7 +393,7 @@ describe('createUploadStaging', () => { const result = await manager.uploadSourcemaps(['.next/server'], { staging, prepareArtifacts: false }); - expect(result).toEqual({ uploaded: true }); + expect(result).toEqual({ uploaded: true, uploadedSourceMapPaths: [] }); expect(mockSourcemapUpload).toHaveBeenCalledTimes(2); expect(mockSourcemapUpload).toHaveBeenCalledWith(expect.objectContaining({ directory: '.next/server' })); expect(mockSourcemapUpload).toHaveBeenCalledWith(expect.objectContaining({ directory: staging.dir })); @@ -417,7 +417,7 @@ describe('createUploadStaging', () => { const result = await manager.uploadSourcemaps([], { prepareArtifacts: false }); - expect(result).toEqual({ uploaded: false }); + expect(result).toEqual({ uploaded: false, uploadedSourceMapPaths: [] }); expect(mockSourcemapUpload).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Didn't find any matching sources")); }); @@ -476,6 +476,6 @@ describe('sourceMaps', () => { const staging = await manager.createUploadStaging(); expect(await staging.stage({ code: '', mapSource: undefined, mapDir: '', chunkPath: 'a.js' })).toBe('skipped'); - expect(await manager.uploadSourcemaps([], { staging })).toEqual({ uploaded: false }); + expect(await manager.uploadSourcemaps([], { staging })).toEqual({ uploaded: false, uploadedSourceMapPaths: [] }); }); }); diff --git a/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts new file mode 100644 index 000000000000..277f7f0ee196 --- /dev/null +++ b/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts @@ -0,0 +1,235 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as esbuild from 'esbuild'; +import type { BuildOptions } from 'esbuild'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { sentryEsbuildPlugin } from '../../src/esbuild'; +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 DEBUG_ID_MARKER = /sentry-dbid-([0-9a-f-]{36})/; + +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 }; + +describe('sourcemaps.deleteSourcemapsAfterUpload (esbuild)', () => { + let tmpDir: 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 }); + }); + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-esbuild-delete-maps-')); + entry = path.join(tmpDir, 'entry.js'); + outDir = path.join(tmpDir, 'dist'); + fs.writeFileSync(entry, 'export const answer = 42;\nconsole.log(answer);\n'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function build(pluginOptions: Options = {}, overrides: BuildOptions = {}) { + return esbuild.build({ + entryPoints: [entry], + bundle: true, + outdir: outDir, + absWorkingDir: tmpDir, + plugins: [sentryEsbuildPlugin({ ...credentials, ...pluginOptions })], + ...overrides, + }); + } + + function expectUploadedPair(upload: Upload, bundle: string): void { + const debugId = bundle.match(DEBUG_ID_MARKER)?.[1]; + expect(debugId).toBeDefined(); + expect(Object.keys(upload.files).sort()).toEqual([`${debugId}-0.js`, `${debugId}-0.js.map`]); + expect(JSON.parse(upload.files[`${debugId}-0.js.map`]!)).toMatchObject({ debug_id: debugId }); + expect(fs.existsSync(upload.directory)).toBe(false); + } + + it('enables external source maps, uploads them and deletes them when the setting is unset', async () => { + await build(); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + const bundle = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + expect(bundle).not.toContain('sourceMappingURL'); + + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, bundle); + }); + + it('switches linked source maps to external and deletes them when the option is true', async () => { + await build({ sourcemaps: { deleteSourcemapsAfterUpload: true } }, { sourcemap: true }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + expect(fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')).not.toContain('sourceMappingURL'); + expect(uploads).toHaveLength(1); + }); + + it('keeps external source maps the user enabled', async () => { + await build({}, { sourcemap: 'external' }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')); + }); + + it('keeps linked source maps when the option is unset', async () => { + await build({}, { sourcemap: true }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')).toContain('//# sourceMappingURL=entry.js.map'); + }); + + it('enables external source maps but keeps them when the option is false', async () => { + await build({ sourcemaps: { deleteSourcemapsAfterUpload: false } }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')).not.toContain('sourceMappingURL'); + expect(uploads).toHaveLength(1); + }); + + function writeSplitBuild(): BuildOptions { + fs.writeFileSync(path.join(tmpDir, 'shared.js'), 'export const shared = 1;\n'); + fs.writeFileSync(path.join(tmpDir, 'other.js'), "import { shared } from './shared.js';\nconsole.log(shared);\n"); + fs.writeFileSync(path.join(tmpDir, 'style.css'), 'body { color: red; }\n'); + fs.writeFileSync(entry, "import { shared } from './shared.js';\nimport './style.css';\nconsole.log(shared);\n"); + return { entryPoints: [entry, path.join(tmpDir, 'other.js')], splitting: true, format: 'esm' }; + } + + it('deletes every map it turned on, also those of chunks that were not uploaded', async () => { + await build({}, writeSplitBuild()); + + const files = fs.readdirSync(outDir); + expect(files.filter(file => file.endsWith('.map'))).toEqual([]); + expect(files.filter(file => /^chunk-.*\.js$/.test(file))).toHaveLength(1); + expect(files).toContain('entry.css'); + expect(uploads).toHaveLength(1); + expect(Object.keys(uploads[0]!.files).filter(file => file.endsWith('.js'))).toHaveLength(2); + }); + + it('deletes only the maps it uploaded when the user enabled them', async () => { + await build({ sourcemaps: { deleteSourcemapsAfterUpload: true } }, { ...writeSplitBuild(), sourcemap: 'external' }); + + const files = fs.readdirSync(outDir); + expect(files).not.toContain('entry.js.map'); + expect(files).not.toContain('other.js.map'); + expect(files).toContain('entry.css.map'); + expect(files.filter(file => /^chunk-.*\.js\.map$/.test(file))).toHaveLength(1); + expect(uploads).toHaveLength(1); + expect(Object.keys(uploads[0]!.files).filter(file => file.endsWith('.js'))).toHaveLength(2); + }); + + it('does not upload chunks matched by `sourcemaps.ignore` and deletes the maps it turned on for them', async () => { + const vendor = path.join(tmpDir, 'vendor.js'); + fs.writeFileSync(vendor, 'console.log("vendor");\n'); + await build({ sourcemaps: { ignore: ['**/vendor.js'] } }, { entryPoints: [entry, vendor] }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'vendor.js']); + expect(uploads).toHaveLength(1); + const uploadedChunks = Object.entries(uploads[0]!.files).filter(([name]) => name.endsWith('.js')); + expect(uploadedChunks).toHaveLength(1); + expect(uploadedChunks[0]![1]).toContain('answer'); + }); + + it('keeps the maps the user enabled for chunks matched by `sourcemaps.ignore`', async () => { + const vendor = path.join(tmpDir, 'vendor.js'); + fs.writeFileSync(vendor, 'console.log("vendor");\n'); + await build( + { sourcemaps: { ignore: ['**/vendor.js'], deleteSourcemapsAfterUpload: true } }, + { entryPoints: [entry, vendor], sourcemap: 'external' }, + ); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'vendor.js', 'vendor.js.map']); + expect(uploads).toHaveLength(1); + }); + + it('warns when source maps are turned off', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await build({ silent: false }, { sourcemap: false }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Source map generation is turned off')); + }); + + it('warns when source maps are inlined and keeps them', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await build({ silent: false, sourcemaps: { deleteSourcemapsAfterUpload: true } }, { sourcemap: 'inline' }); + + expect(fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')).toContain('sourceMappingURL=data:'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Source maps are inlined')); + }); + + it('does not enable source maps when no upload can run', async () => { + await build({ authToken: undefined }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + expect(uploads).toHaveLength(0); + }); + + it('leaves the source map setting alone and does not log when the build does not write', async () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); + const result = await build({ silent: false }, { write: false }); + + expect(result.outputFiles?.map(file => path.basename(file.path))).toEqual(['entry.js']); + expect(uploads).toHaveLength(0); + expect(infoSpy).not.toHaveBeenCalledWith(expect.stringContaining('Enabling hidden source maps')); + }); + + it('keeps the source maps the user enabled when the build does not write', async () => { + const rmSpy = vi.spyOn(fs.promises, 'rm'); + const result = await build( + { sourcemaps: { deleteSourcemapsAfterUpload: true } }, + { write: false, sourcemap: true }, + ); + + expect(result.outputFiles?.map(file => path.basename(file.path)).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(rmSpy).not.toHaveBeenCalledWith(expect.stringContaining('entry.js.map'), expect.anything()); + }); + + it('keeps the source maps when the upload fails', async () => { + mockSourcemapUpload.mockRejectedValue(new Error('upload failed')); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + await build({ silent: false }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('were not uploaded')); + }); + + it('leaves "disable-upload" builds untouched', async () => { + await build({ sourcemaps: { disable: 'disable-upload' } }, { sourcemap: true }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(uploads).toHaveLength(0); + }); +});