From e7fe1cef4f3c381570ffb7f4c7548db419737ae6 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 22 Oct 2026 02:17:33 +0100 Subject: [PATCH] feat(bundler-plugins): Enable hidden source maps and remove them after upload in the webpack plugin When `devtool` is unset and an upload can run, the plugin sets `hidden-source-map`, copies each JS asset and its map into the upload staging folder at the end of `processAssets` and deletes the map assets, so webpack never emits them. A `devtool` with a manual `SourceMapDevToolPlugin` and development builds stay untouched. --- .../fixtures/webpack5/basic-cjs.test.ts | 1 + .../fixtures/webpack5/basic.test.ts | 1 + .../webpack5/release-disabled.test.ts | 1 + packages/bundler-plugins/src/webpack/index.ts | 131 +++++++- .../delete-sourcemaps-after-upload.test.ts | 318 ++++++++++++++++++ 5 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 packages/bundler-plugins/test/webpack/delete-sourcemaps-after-upload.test.ts diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/basic-cjs.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/basic-cjs.test.ts index 6228a54dc2af..86863c2af2f7 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/basic-cjs.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/basic-cjs.test.ts @@ -15,6 +15,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/webpack5/basic.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/basic.test.ts index 6228a54dc2af..86863c2af2f7 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/basic.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/basic.test.ts @@ -15,6 +15,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/webpack5/release-disabled.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/release-disabled.test.ts index 08320b93d43a..ac55021950dc 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/release-disabled.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/release-disabled.test.ts @@ -14,6 +14,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/packages/bundler-plugins/src/webpack/index.ts b/packages/bundler-plugins/src/webpack/index.ts index 84600ff47634..2b880866bdbe 100644 --- a/packages/bundler-plugins/src/webpack/index.ts +++ b/packages/bundler-plugins/src/webpack/index.ts @@ -1,6 +1,6 @@ /* oxlint-disable max-lines */ -import type { Options } from '../core/index'; +import type { Options, SourceMapDecision, SourceMapKind, UploadStaging } from '../core/index'; import { createSentryBuildPluginManager, generateReleaseInjectorCode, @@ -71,6 +71,7 @@ type WebpackCompiler = { options: { plugins?: unknown[]; mode?: string; + devtool?: string | false; module?: { rules?: unknown[]; }; @@ -88,12 +89,16 @@ type WebpackCompiler = { done: { tap: (name: string, callback: () => void) => void; }; + failed?: { + tap: (name: string, callback: () => void) => void; + }; }; webpack?: { DefinePlugin?: PluginClass; Compilation?: { PROCESS_ASSETS_STAGE_ADDITIONS?: number; PROCESS_ASSETS_STAGE_DEV_TOOLING?: number; + PROCESS_ASSETS_STAGE_REPORT?: number; }; sources?: { RawSource?: WebpackRawSource; @@ -116,16 +121,44 @@ type WebpackCompilation = { getAssets: () => WebpackAsset[]; getAsset: (name: string) => WebpackAsset | undefined; updateAsset: (name: string, source: WebpackSource) => void; + deleteAsset: (name: string) => void; hooks: { processAssets: { tap: ( options: { name: string; stage: number }, callback: (assets: Record) => void, ) => void; + tapPromise: (options: { name: string; stage: number }, callback: () => Promise) => void; }; }; }; +/** + * Maps webpack's `devtool` onto the plugin's source map kinds. A user who sets `devtool: false` + * and adds a `SourceMapDevToolPlugin` manages source maps themselves. + */ +function sourceMapKindFromDevtool(compiler: WebpackCompiler): SourceMapKind | undefined { + const devtool = compiler.options.devtool; + + if (devtool === undefined) { + return 'unset'; + } + if (devtool === false) { + const hasSourceMapPlugin = compiler.options.plugins?.some( + plugin => + (plugin as { constructor?: { name?: string } } | undefined)?.constructor?.name === 'SourceMapDevToolPlugin', + ); + return hasSourceMapPlugin ? undefined : 'off'; + } + if (devtool.includes('inline') || devtool.includes('eval')) { + return 'inline'; + } + if (devtool.includes('hidden')) { + return 'hidden'; + } + return 'visible'; +} + type WebpackModule = { version?: string; default?: { version?: string }; @@ -171,6 +204,51 @@ function addDebugIdsToAssets(compilation: WebpackCompilation, RawSource: Webpack } } +/** + * Copies each JS asset and its source map into the upload staging folder and drops the source map + * assets (and their compressed variants) from the compilation, so webpack never emits them. + */ +async function stageSourceMaps( + compilation: WebpackCompilation, + staging: UploadStaging, + logger: { warn: (message: string) => void }, +): Promise { + const outputPath = compilation.outputOptions.path ?? path.resolve(); + + for (const asset of compilation.getAssets()) { + if (!isJsFile(asset.name)) { + continue; + } + + const relatedSourceMap = asset.info.related?.sourceMap; + const sourceMapName = typeof relatedSourceMap === 'string' ? relatedSourceMap : `${asset.name}.map`; + const sourceMapAsset = compilation.getAsset(sourceMapName); + if (!sourceMapAsset) { + continue; + } + + const result = await staging.stage({ + code: asset.source.source().toString(), + mapSource: sourceMapAsset.source.source().toString(), + mapDir: path.join(outputPath, path.dirname(sourceMapName)), + fileName: asset.name, + }); + + if (result !== 'staged') { + logger.warn( + `Could not prepare ${asset.name} for upload, so its source map ${sourceMapName} stays in the build output.`, + ); + continue; + } + + for (const candidate of compilation.getAssets()) { + if (candidate.name === sourceMapName || candidate.name.startsWith(`${sourceMapName}.`)) { + compilation.deleteAsset(candidate.name); + } + } + } +} + function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) { const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? '[sentry-webpack-plugin]', @@ -231,6 +309,21 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) const transformReplace = Object.keys(replacementValues).length > 0; + function decideSourceMaps(compiler: WebpackCompiler): SourceMapDecision { + const kind = compiler.options.mode === 'development' ? undefined : sourceMapKindFromDevtool(compiler); + if (kind === undefined) { + return { enableHidden: false, deleteAfterUpload: false }; + } + + const decision = sentryBuildPluginManager.sourceMaps.decide(kind); + + if (decision.enableHidden) { + compiler.options.devtool = kind === 'visible' ? `hidden-${compiler.options.devtool}` : 'hidden-source-map'; + } + + return decision; + } + function addCodeInjection(compiler: WebpackCompiler): void { if (releaseInjectionCode.isEmpty() && chunkInjectionCode.isEmpty() && !sourcemapsEnabled) { return; @@ -299,6 +392,35 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) const { DefinePlugin } = compiler.webpack ?? {}; + // One plugin instance can serve several compilers (`MultiCompiler`) and + // a compiler several compilations (watch mode), so nothing is shared. + const sourceMapDecision = decideSourceMaps(compiler); + const pendingStagings = new Map(); + + if (sourceMapDecision.deleteAfterUpload) { + // After `RealContentHashPlugin` rewrote the hash placeholders, so the staged + // JS equals the emitted file and `[contenthash]` names are final. + const stage = (compiler.webpack?.Compilation?.PROCESS_ASSETS_STAGE_REPORT ?? 5000) + 1; + + compiler.hooks.thisCompilation.tap('sentry-webpack-plugin', compilation => { + compilation.hooks.processAssets.tapPromise({ name: 'sentry-webpack-plugin', stage }, async () => { + const staging = await sentryBuildPluginManager.createUploadStaging(); + pendingStagings.set(compilation, staging); + await stageSourceMaps(compilation, staging, logger); + }); + }); + + // A compilation that fails before emitting never reaches `afterEmit`. + const discardPendingStagings = (): void => { + for (const staging of pendingStagings.values()) { + void staging.discard(); + } + pendingStagings.clear(); + }; + compiler.hooks.done.tap('sentry-webpack-plugin', discardPendingStagings); + compiler.hooks.failed?.tap('sentry-webpack-plugin', discardPendingStagings); + } + // BannerPlugin prepends executable code before directive prologues. addCodeInjection(compiler); @@ -354,13 +476,18 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); + const staging = pendingStagings.get(compilation); + pendingStagings.delete(compilation); + const run = async (): Promise => { try { await sentryBuildPluginManager.createRelease(); if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') { const outputPath = compilation.outputOptions.path ?? path.resolve(); const buildArtifacts = Object.keys(compilation.assets).map(asset => path.join(outputPath, asset)); - await upload(buildArtifacts); + await upload(buildArtifacts, { staging }); + } else { + await staging?.discard(); } } finally { freeGlobalDependencyOnBuildArtifacts(); diff --git a/packages/bundler-plugins/test/webpack/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/webpack/delete-sourcemaps-after-upload.test.ts new file mode 100644 index 000000000000..1e3b65271072 --- /dev/null +++ b/packages/bundler-plugins/test/webpack/delete-sourcemaps-after-upload.test.ts @@ -0,0 +1,318 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { EntryPlugin, SourceMapDevToolPlugin, webpack } from 'webpack'; +import type { Compiler, Configuration, Stats } from 'webpack'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { sentryWebpackPlugin } from '../../src/webpack/index'; +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 }; + +function build(config: Configuration | Configuration[]): Promise { + return new Promise((resolve, reject) => { + webpack(config as Configuration, (err, stats) => { + if (err) { + return reject(err); + } + if (!stats || stats.hasErrors()) { + return reject(new Error(stats?.toString() ?? 'no stats')); + } + resolve(stats as Stats); + }); + }); +} + +function run(compiler: Compiler): Promise { + return new Promise((resolve, reject) => { + compiler.run((err, stats) => { + if (err || !stats || stats.hasErrors()) { + return reject(err ?? new Error(stats?.toString() ?? 'no stats')); + } + resolve(stats); + }); + }); +} + +function createChildCompilerPlugin(context: string): { apply(compiler: Compiler): void } { + return { + apply(compiler) { + compiler.hooks.make.tapAsync('test-child-compiler', (compilation, callback) => { + const childCompiler = compilation.createChildCompiler('test-child-compiler', { filename: 'worker.js' }, [ + new EntryPlugin(context, './worker.js', { name: 'worker' }), + ]); + childCompiler.runAsChild(error => callback(error ?? undefined)); + }); + }, + }; +} + +describe('sourcemaps.deleteSourcemapsAfterUpload (webpack)', () => { + let tmpDir: 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-webpack-delete-maps-')); + outDir = path.join(tmpDir, 'dist'); + fs.writeFileSync(path.join(tmpDir, 'entry.js'), 'export const answer = 42;\nconsole.log(answer);\n'); + fs.writeFileSync(path.join(tmpDir, 'worker.js'), 'globalThis.workerLoaded = true;\n'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function config(pluginOptions: Options = {}, overrides: Configuration = {}): Configuration { + return { + mode: 'production', + context: tmpDir, + entry: './entry.js', + output: { path: outDir, filename: 'bundle.js' }, + plugins: [sentryWebpackPlugin({ ...credentials, ...pluginOptions })], + ...overrides, + }; + } + + function expectUploadedPair( + upload: Upload, + bundle: string, + ): { debugId: string; stagedBundle: string; map: Record } { + const debugId = bundle.match(DEBUG_ID_MARKER)?.[1]; + expect(debugId).toBeDefined(); + const jsName = Object.keys(upload.files).find(name => name.startsWith(`${debugId}-`) && name.endsWith('.js')); + expect(jsName).toBeDefined(); + const stagedBundle = upload.files[jsName!]!; + expect(stagedBundle).toContain(`//# debugId=${debugId}`); + const map = JSON.parse(upload.files[`${jsName}.map`]!); + expect(map).toMatchObject({ debug_id: debugId }); + expect(fs.existsSync(upload.directory)).toBe(false); + return { debugId: debugId!, stagedBundle, map }; + } + + it('enables hidden source maps, uploads them and leaves none in the output when devtool is unset', async () => { + const stats = await build(config()); + + expect(stats.compilation.options.devtool).toBe('hidden-source-map'); + expect(fs.readdirSync(outDir)).toEqual(['bundle.js']); + const bundle = fs.readFileSync(path.join(outDir, 'bundle.js'), 'utf8'); + expect(bundle).not.toContain('sourceMappingURL'); + + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, bundle); + }); + + it('switches visible source maps to hidden and removes them when the option is true', async () => { + const stats = await build( + config({ sourcemaps: { deleteSourcemapsAfterUpload: true } }, { devtool: 'cheap-module-source-map' }), + ); + + expect(stats.compilation.options.devtool).toBe('hidden-cheap-module-source-map'); + expect(fs.readdirSync(outDir)).toEqual(['bundle.js']); + expect(fs.readFileSync(path.join(outDir, 'bundle.js'), 'utf8')).not.toContain('sourceMappingURL'); + expect(uploads).toHaveLength(1); + }); + + it('stages bundles whose debug ID webpack injects itself', async () => { + const stats = await build( + config({ sourcemaps: { deleteSourcemapsAfterUpload: true } }, { devtool: 'source-map-debugids' }), + ); + + expect(stats.compilation.options.devtool).toBe('hidden-source-map-debugids'); + expect(fs.readdirSync(outDir)).toEqual(['bundle.js']); + const bundle = fs.readFileSync(path.join(outDir, 'bundle.js'), 'utf8'); + expect(bundle).toContain('//# debugId='); + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, bundle); + }); + + it('keeps hidden source maps the user enabled and uploads them from disk', async () => { + await build(config({}, { devtool: 'hidden-source-map' })); + + expect(fs.readdirSync(outDir).sort()).toEqual(['bundle.js', 'bundle.js.map']); + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'bundle.js'), 'utf8')); + }); + + it('keeps visible source maps when the option is unset', async () => { + const stats = await build(config({}, { devtool: 'source-map' })); + + expect(stats.compilation.options.devtool).toBe('source-map'); + expect(fs.readdirSync(outDir).sort()).toEqual(['bundle.js', 'bundle.js.map']); + }); + + it('does not enable source maps when the option is false', async () => { + await build(config({ sourcemaps: { deleteSourcemapsAfterUpload: false } })); + + expect(fs.readdirSync(outDir)).toEqual(['bundle.js']); + expect(uploads).toHaveLength(0); + }); + + it('warns when source maps are turned off', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await build(config({ silent: false }, { devtool: false })); + + expect(fs.readdirSync(outDir)).toEqual(['bundle.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( + config({ silent: false, sourcemaps: { deleteSourcemapsAfterUpload: true } }, { devtool: 'inline-source-map' }), + ); + + expect(fs.readFileSync(path.join(outDir, 'bundle.js'), 'utf8')).toContain('sourceMappingURL=data:'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Source maps are inlined')); + }); + + it('leaves a manual SourceMapDevToolPlugin alone', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const stats = await build({ + ...config({ silent: false }), + devtool: false, + plugins: [ + new SourceMapDevToolPlugin({ filename: '[file].map' }), + sentryWebpackPlugin({ ...credentials, silent: false }), + ], + }); + + expect(stats.compilation.options.devtool).toBe(false); + expect(fs.readdirSync(outDir).sort()).toEqual(['bundle.js', 'bundle.js.map']); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Source map generation is turned off')); + }); + + it('does not touch development builds', async () => { + const stats = await build(config({}, { mode: 'development' })); + + expect(stats.compilation.options.devtool).toBe('eval'); + expect(fs.readdirSync(outDir)).toEqual(['bundle.js']); + }); + + it('does not enable source maps when no upload can run', async () => { + const mkdtempSpy = vi.spyOn(fs.promises, 'mkdtemp'); + const stats = await build(config({ authToken: undefined })); + + expect(stats.compilation.options.devtool).toBe(false); + expect(fs.readdirSync(outDir)).toEqual(['bundle.js']); + expect(uploads).toHaveLength(0); + expect(mkdtempSpy).not.toHaveBeenCalled(); + }); + + it('stages the final content of content-hashed bundles', async () => { + await build(config({}, { output: { path: outDir, filename: '[name].[contenthash].js' } })); + + const bundleFileName = fs.readdirSync(outDir).find(file => file.endsWith('.js'))!; + const bundle = fs.readFileSync(path.join(outDir, bundleFileName), 'utf8'); + expect(fs.readdirSync(outDir)).toEqual([bundleFileName]); + + const { debugId, stagedBundle, map } = expectUploadedPair(uploads[0]!, bundle); + expect(stagedBundle.replace(`\n//# debugId=${debugId}`, '')).toBe(bundle); + expect(map.file).toBe(bundleFileName); + }); + + it('serves every compiler of a MultiCompiler on its own', async () => { + const otherOutDir = path.join(tmpDir, 'dist-other'); + const plugin = sentryWebpackPlugin(credentials); + + await build([ + { ...config(), plugins: [plugin] }, + { ...config(), output: { path: otherOutDir, filename: 'other.js' }, plugins: [plugin] }, + ]); + + expect(fs.readdirSync(outDir)).toEqual(['bundle.js']); + expect(fs.readdirSync(otherOutDir)).toEqual(['other.js']); + expect(uploads).toHaveLength(2); + }); + + it('stages the bundles of child compilers with the parent compilation', async () => { + await build({ ...config(), plugins: [sentryWebpackPlugin(credentials), createChildCompilerPlugin(tmpDir)] }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['bundle.js', 'worker.js']); + expect(uploads).toHaveLength(1); + expect(Object.keys(uploads[0]!.files).filter(name => name.endsWith('.js'))).toHaveLength(2); + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'bundle.js'), 'utf8')); + }); + + it('leaves no staging folder behind over several compilations', async () => { + // Other test files create staging folders at the same time, so only the + // folders this compiler created are checked. + const createdFolders: string[] = []; + const mkdtemp = fs.promises.mkdtemp; + vi.spyOn(fs.promises, 'mkdtemp').mockImplementation(async (...args: Parameters) => { + const folder = await mkdtemp(...args); + createdFolders.push(folder); + return folder; + }); + + const compiler = webpack(config()); + await run(compiler); + await run(compiler); + await new Promise((resolve, reject) => compiler.close(err => (err ? reject(err) : resolve()))); + + expect(uploads).toHaveLength(2); + expect(createdFolders).toHaveLength(2); + expect(createdFolders.filter(folder => fs.existsSync(folder))).toEqual([]); + }); + + it('keeps a map it could not prepare instead of dropping it unuploaded', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const breakSourceMap = { + apply(compiler: Compiler) { + compiler.hooks.thisCompilation.tap('break-source-map', compilation => { + compilation.hooks.processAssets.tap( + { name: 'break-source-map', stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER }, + () => compilation.updateAsset('bundle.js.map', new compiler.webpack.sources.RawSource('not json')), + ); + }); + }, + }; + + await build({ + ...config({ silent: false }), + plugins: [sentryWebpackPlugin({ ...credentials, silent: false }), breakSourceMap], + }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['bundle.js', 'bundle.js.map']); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('stays in the build output')); + }); + + it('leaves "disable-upload" builds untouched', async () => { + await build(config({ sourcemaps: { disable: 'disable-upload' } }, { devtool: 'source-map' })); + + expect(fs.readdirSync(outDir).sort()).toEqual(['bundle.js', 'bundle.js.map']); + expect(uploads).toHaveLength(0); + }); +});