From 6f443bcbbe212644bcb48404305152b16be9141c Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 22 Oct 2026 02:18:43 +0100 Subject: [PATCH] feat(bundler-plugins): Enable external source maps and delete them after upload in the esbuild plugin esbuild writes its output before `onEnd` runs and has no hook to drop an output, so the maps are deleted from disk after a successful upload instead. When the upload fails they stay, with an error log. --- .../fixtures/esbuild/basic-cjs.test.ts | 1 + .../fixtures/esbuild/basic.test.ts | 1 + .../fixtures/esbuild/release-disabled.test.ts | 1 + .../esbuild/sourcemaps-assets-auto.config.js | 14 ++ .../esbuild/sourcemaps-assets-auto.test.ts | 35 ++++ .../esbuild/sourcemaps-ignore-auto.config.js | 14 ++ .../esbuild/sourcemaps-ignore-auto.test.ts | 35 ++++ .../src/core/debug-id-upload.ts | 5 + packages/bundler-plugins/src/core/index.ts | 2 +- packages/bundler-plugins/src/esbuild/index.ts | 83 +++++++- .../delete-sourcemaps-after-upload.test.ts | 197 ++++++++++++++++++ 11 files changed, 376 insertions(+), 12 deletions(-) create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-assets-auto.config.js create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-assets-auto.test.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-ignore-auto.config.js create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-ignore-auto.test.ts create mode 100644 packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts 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..fcfe76c003d8 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/sourcemaps-assets-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(` + [ + "chunk.js", + "chunk.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(` + [ + [ + "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..fcfe76c003d8 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/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(` + [ + "chunk.js", + "chunk.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(` + [ + [ + "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/debug-id-upload.ts b/packages/bundler-plugins/src/core/debug-id-upload.ts index cf402b5f476f..b89c9a0906e8 100644 --- a/packages/bundler-plugins/src/core/debug-id-upload.ts +++ b/packages/bundler-plugins/src/core/debug-id-upload.ts @@ -184,6 +184,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..849e4fa9d357 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -111,7 +111,7 @@ export { } 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 { 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..a85bbbf80b66 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, SourceMapKind, UploadFilter } from '../core'; import { createSentryBuildPluginManager, generateReleaseInjectorCode, @@ -7,8 +7,10 @@ import { createDebugIdUploadFunction, CodeInjection, addDebugIdToEmittedArtifacts, + hasDebugId, 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 +50,8 @@ interface EsbuildOnEndArgs { }; } +type EsbuildSourceMapSetting = boolean | 'inline' | 'external' | 'linked' | 'both' | undefined; + interface EsbuildInitialOptions { bundle?: boolean; inject?: string[]; @@ -55,6 +59,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 +302,13 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any { }); } + const sourceMapDecision = sentryBuildPluginManager.sourceMaps.decide( + sourceMapKindFromEsbuildSetting(initialOptions.sourcemap), + ); + if (sourceMapDecision.enableHidden) { + initialOptions.sourcemap = 'external'; + } + // Create release and optionally upload const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); @@ -290,27 +319,38 @@ 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 } = 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. + if (sourceMapDecision.deleteAfterUpload && initialOptions.write !== false) { + const { shouldUpload } = sentryBuildPluginManager.sourceMaps; + const sourceMaps = await getUploadedSourceMaps(buildArtifacts, shouldUpload); + + 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)), ); } } @@ -326,3 +366,24 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any { // eslint-disable-next-line @typescript-eslint/no-explicit-any export default sentryEsbuildPlugin; export type { Options as SentryEsbuildPluginOptions } from '../core'; + +/** + * The maps of the chunks the upload took: those that pass `sourcemaps.assets` and `ignore` and + * carry a debug ID. Shared chunks and CSS get none, so their maps stay in the build output. + */ +async function getUploadedSourceMaps(buildArtifacts: string[], shouldUpload: UploadFilter): Promise { + const sourceMaps = new Set(buildArtifacts.filter(artifact => artifact.endsWith('.map'))); + const uploaded: string[] = []; + + for (const artifact of buildArtifacts) { + if (!isJsFile(artifact) || !sourceMaps.has(`${artifact}.map`) || !shouldUpload(artifact)) { + continue; + } + const code = await fs.promises.readFile(artifact, 'utf8').catch(() => ''); + if (hasDebugId(code)) { + uploaded.push(`${artifact}.map`); + } + } + + return uploaded; +} 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..45792237868e --- /dev/null +++ b/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts @@ -0,0 +1,197 @@ +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); + }); + + it('keeps the maps of chunks that were not uploaded', async () => { + 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"); + + await build({}, { entryPoints: [entry, path.join(tmpDir, 'other.js')], splitting: true, format: 'esm' }); + + 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 leaves their maps in the output', 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', 'vendor.js.map']); + 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('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('does not delete anything when the build does not write', async () => { + const rmSpy = vi.spyOn(fs.promises, 'rm'); + const result = await build({}, { write: false }); + + 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); + }); +});