diff --git a/tools/rollup-plugin/src/index.ts b/tools/rollup-plugin/src/index.ts index b0cb7815..6ac0af6d 100644 --- a/tools/rollup-plugin/src/index.ts +++ b/tools/rollup-plugin/src/index.ts @@ -22,6 +22,8 @@ export function BacktracePlugin(options?: BacktracePluginOptions): Plugin { afterWrite: (asset) => debug(`[${asset.asset.name}] wrote source and sourcemap to file`), assetFinished: (asset) => info(`[${asset.asset.name}] asset processed successfully`), assetError: (asset) => this.warn(`[${asset.asset.name}] ${asset.error}`), + assetSkipped: (asset) => debug(`[${asset.asset.name}] skipped: ${asset.error}`), + processingSummary: (message) => info(message), beforeUpload: (paths) => info(`uploading ${paths.length} sourcemaps...`), afterUpload: (result) => info(`sourcemaps uploaded to Backtrace: ${result.rxid}`), diff --git a/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts b/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts index 70aae2a0..cb7e2ec8 100644 --- a/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts +++ b/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts @@ -7,7 +7,7 @@ import { inspect, mapAsync, pass } from '../helpers/common'; import { flow, pipe } from '../helpers/flow'; import { Asset, AssetWithContent } from '../models/Asset'; import { ProcessAssetError, ProcessAssetResult } from '../models/ProcessAssetResult'; -import { R, Result } from '../models/Result'; +import { R, Result, ResultOk } from '../models/Result'; import { createArchive, finalizeArchive } from './archiveSourceMaps'; import { loadSourceMap, stripSourcesContent } from './loadSourceMaps'; import { processAsset } from './processAsset'; @@ -22,6 +22,15 @@ interface BacktracePluginUploadOptions { readonly includeSources: boolean; } +/** + * Determines what happens when an individual asset fails to process. + * + * - `'exit'` — abort the entire process and upload (default). + * - `'skip'` — silently skip the failed asset; successfully processed assets are still uploaded. + * - `'warn'` — log the failure via the `assetError` callback and continue uploading the rest. + */ +export type AssetErrorBehavior = 'exit' | 'skip' | 'warn'; + export interface BacktracePluginOptions { /** * Upload URL for uploading sourcemap files. @@ -35,6 +44,17 @@ export interface BacktracePluginOptions { * Additional upload options. */ readonly uploadOptions?: SymbolUploaderOptions & BacktracePluginUploadOptions; + + /** + * What to do when an individual asset fails to process (e.g. a missing sourcemap file). + * + * - `'warn'` — report the failure via the `assetError` callback and continue uploading the rest. (default) + * - `'skip'` — silently skip the failed asset. Successfully processed assets are still uploaded. + * - `'exit'` — abort the entire process and upload. No sourcemaps will be uploaded. + * + * @default 'warn' + */ + readonly assetErrorBehavior?: AssetErrorBehavior; } interface ProcessResult { @@ -55,6 +75,8 @@ export interface ProcessAndUploadAssetsCommandOptions { beforeUpload?(assets: AssetWithContent[]): unknown; afterUpload?(result: UploadResult): unknown; assetError?(error: ProcessAssetError): unknown; + assetSkipped?(error: ProcessAssetError): unknown; + processingSummary?(message: string): unknown; uploadError?(error: string): unknown; } @@ -84,28 +106,73 @@ export function processAndUploadAssetsCommand( R.map(writeAsset), R.map(options?.afterWrite ? inspect(options.afterWrite) : pass), R.map(options?.assetFinished ? inspect(options.assetFinished) : pass), - R.mapErr(options?.assetError ? inspect(options.assetError) : pass), ), ), ); - const assetsResult = R.flatMap(assetResults); - if (assetsResult.isErr()) { - const result: ProcessResult = { assetResults }; - options?.afterAll && options.afterAll(result); - return result; + const assetErrorBehavior = pluginOptions.assetErrorBehavior ?? 'warn'; + const failedAssets = assetResults.filter((r) => r.isErr()); + const successfulAssets = assetResults.filter((r): r is ResultOk => r.isOk()); + + if (failedAssets.length > 0) { + const summary = + `${failedAssets.length} of ${assets.length} asset(s) failed to process, ` + + `${successfulAssets.length} succeeded`; + + switch (assetErrorBehavior) { + case 'exit': + // Report each failure via assetError, then throw to fail the build. + for (const failed of failedAssets) { + if (failed.isErr()) { + options?.assetError && options.assetError(failed.data); + } + } + options?.afterAll && options.afterAll({ assetResults }); + throw new Error( + `Backtrace: ${summary}. ` + + `Upload aborted. Set assetErrorBehavior to 'skip' or 'warn' to upload the remaining assets.`, + ); + + case 'warn': + // Report each failure via assetError and continue uploading the rest. + for (const failed of failedAssets) { + if (failed.isErr()) { + options?.assetError && options.assetError(failed.data); + } + } + options?.processingSummary && + options.processingSummary( + `${summary}. Continuing with ${successfulAssets.length} successfully processed asset(s).`, + ); + break; + + case 'skip': + // Silently skip — report via assetSkipped (not assetError). + for (const failed of failedAssets) { + if (failed.isErr()) { + options?.assetSkipped && options.assetSkipped(failed.data); + } + } + options?.processingSummary && + options.processingSummary( + `${summary}. Skipped failed asset(s), continuing with ${successfulAssets.length} successfully processed asset(s).`, + ); + break; + } } - if (!uploadCommand) { + if (!uploadCommand || successfulAssets.length === 0) { const result: ProcessResult = { assetResults }; options?.afterAll && options.afterAll(result); return result; } - const sourceMapAssets = assetsResult.data.map((r) => ({ - name: path.basename(r.result.sourceMapPath), - path: r.result.sourceMapPath, - })); + const sourceMapAssets = successfulAssets + .map((r) => r.data) + .map((r) => ({ + name: path.basename(r.result.sourceMapPath), + path: r.result.sourceMapPath, + })); const includeSources = pluginOptions?.uploadOptions?.includeSources; diff --git a/tools/sourcemap-tools/tests/commands/processAndUploadAssetsCommand.spec.ts b/tools/sourcemap-tools/tests/commands/processAndUploadAssetsCommand.spec.ts new file mode 100644 index 00000000..b413612d --- /dev/null +++ b/tools/sourcemap-tools/tests/commands/processAndUploadAssetsCommand.spec.ts @@ -0,0 +1,270 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + processAndUploadAssetsCommand, + BacktracePluginOptions, + ProcessAndUploadAssetsCommandOptions, +} from '../../src/commands/processAndUploadAssetsCommand'; +import { Asset } from '../../src/models/Asset'; + +describe('processAndUploadAssetsCommand', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bt-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function createSourceFile(name: string, content?: string): string { + const source = content ?? `function ${name.replace(/[^a-zA-Z]/g, '')}(){console.log("hello")}`; + const filePath = path.join(tmpDir, name); + fs.writeFileSync(filePath, source); + return filePath; + } + + function createSourceMapFile(sourceName: string, sourceContent?: string): string { + const source = sourceContent ?? `function ${sourceName.replace(/[^a-zA-Z]/g, '')}(){console.log("hello")}`; + const sourceMap = { + version: 3, + file: sourceName, + sources: [sourceName], + names: [], + mappings: 'AAAA', + sourcesContent: [source], + }; + const mapPath = path.join(tmpDir, `${sourceName}.map`); + fs.writeFileSync(mapPath, JSON.stringify(sourceMap)); + + // Add sourceMappingURL to source file + const sourcePath = path.join(tmpDir, sourceName); + if (fs.existsSync(sourcePath)) { + const existing = fs.readFileSync(sourcePath, 'utf-8'); + fs.writeFileSync(sourcePath, existing + `\n//# sourceMappingURL=${sourceName}.map`); + } + + return mapPath; + } + + function createAsset(name: string): Asset { + return { name, path: path.join(tmpDir, name) }; + } + + describe('assetErrorBehavior', () => { + it('should throw and fail the build when behavior is exit and an asset fails', async () => { + createSourceFile('good.js'); + createSourceMapFile('good.js'); + createSourceFile('bad.js'); // No sourcemap for this one + + const assetError = jest.fn(); + const afterUpload = jest.fn(); + const afterAll = jest.fn(); + + const options: BacktracePluginOptions = { + uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', + assetErrorBehavior: 'exit', + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + afterUpload, + afterAll, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + + await expect(command([createAsset('good.js'), createAsset('bad.js')])).rejects.toThrow( + /1 of 2 asset\(s\) failed to process, 1 succeeded.*Upload aborted/, + ); + + // assetError callback should have been called for the bad asset + expect(assetError).toHaveBeenCalled(); + // Upload should NOT have happened + expect(afterUpload).not.toHaveBeenCalled(); + // afterAll should have been called before throwing + expect(afterAll).toHaveBeenCalled(); + }); + + it('should default to warn behavior (no throw, upload rest) when assetErrorBehavior is not set', async () => { + createSourceFile('good.js'); + createSourceMapFile('good.js'); + createSourceFile('bad.js'); // No sourcemap + + const assetError = jest.fn(); + const assetSkipped = jest.fn(); + const assetFinished = jest.fn(); + + const options: BacktracePluginOptions = { + uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', + // assetErrorBehavior not set — defaults to 'warn' + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + assetSkipped, + assetFinished, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + const result = await command([createAsset('good.js'), createAsset('bad.js')]); + + // Default is warn: assetError called, not assetSkipped + expect(assetError).toHaveBeenCalledTimes(1); + expect(assetSkipped).not.toHaveBeenCalled(); + expect(assetFinished).toHaveBeenCalledTimes(1); + // Upload should still happen + expect(result.uploadResult).toBeDefined(); + }); + + it('should use assetSkipped (not assetError) and upload successes when behavior is skip', async () => { + createSourceFile('good.js'); + createSourceMapFile('good.js'); + createSourceFile('bad.js'); // No sourcemap for this one + + const assetError = jest.fn(); + const assetSkipped = jest.fn(); + const assetFinished = jest.fn(); + + const options: BacktracePluginOptions = { + uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', + assetErrorBehavior: 'skip', + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + assetSkipped, + assetFinished, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + const result = await command([createAsset('good.js'), createAsset('bad.js')]); + + // skip should NOT call assetError — it's silent + expect(assetError).not.toHaveBeenCalled(); + // skip should call assetSkipped instead + expect(assetSkipped).toHaveBeenCalledTimes(1); + // The good asset should have been processed successfully + expect(assetFinished).toHaveBeenCalledTimes(1); + // Upload should have been attempted (uploadResult should be defined) + expect(result.uploadResult).toBeDefined(); + }); + + it('should use assetError (not assetSkipped) and upload successes when behavior is warn', async () => { + createSourceFile('good.js'); + createSourceMapFile('good.js'); + createSourceFile('bad.js'); // No sourcemap + + const assetError = jest.fn(); + const assetSkipped = jest.fn(); + const assetFinished = jest.fn(); + + const options: BacktracePluginOptions = { + uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', + assetErrorBehavior: 'warn', + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + assetSkipped, + assetFinished, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + const result = await command([createAsset('good.js'), createAsset('bad.js')]); + + // warn should call assetError (visible warning) + expect(assetError).toHaveBeenCalledTimes(1); + // warn should NOT call assetSkipped + expect(assetSkipped).not.toHaveBeenCalled(); + expect(assetFinished).toHaveBeenCalledTimes(1); + expect(result.uploadResult).toBeDefined(); + }); + + it('should not attempt upload when all assets fail with behavior skip', async () => { + createSourceFile('bad1.js'); // No sourcemap + createSourceFile('bad2.js'); // No sourcemap + + const assetError = jest.fn(); + const assetSkipped = jest.fn(); + const afterUpload = jest.fn(); + + const options: BacktracePluginOptions = { + uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', + assetErrorBehavior: 'skip', + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + assetSkipped, + afterUpload, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + const result = await command([createAsset('bad1.js'), createAsset('bad2.js')]); + + // skip mode: assetSkipped called, not assetError + expect(assetError).not.toHaveBeenCalled(); + expect(assetSkipped).toHaveBeenCalledTimes(2); + expect(afterUpload).not.toHaveBeenCalled(); + expect(result.uploadResult).toBeUndefined(); + }); + + it('should process all assets successfully when none fail', async () => { + createSourceFile('a.js'); + createSourceMapFile('a.js'); + createSourceFile('b.js'); + createSourceMapFile('b.js'); + + const assetError = jest.fn(); + const assetFinished = jest.fn(); + + const options: BacktracePluginOptions = { + uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', + assetErrorBehavior: 'skip', + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + assetFinished, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + const result = await command([createAsset('a.js'), createAsset('b.js')]); + + expect(assetError).not.toHaveBeenCalled(); + expect(assetFinished).toHaveBeenCalledTimes(2); + expect(result.uploadResult).toBeDefined(); + }); + + it('should still process without upload when uploadUrl is not set', async () => { + createSourceFile('good.js'); + createSourceMapFile('good.js'); + createSourceFile('bad.js'); + + const assetError = jest.fn(); + const assetSkipped = jest.fn(); + const assetFinished = jest.fn(); + + const options: BacktracePluginOptions = { + assetErrorBehavior: 'skip', + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + assetSkipped, + assetFinished, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + const result = await command([createAsset('good.js'), createAsset('bad.js')]); + + expect(assetError).not.toHaveBeenCalled(); + expect(assetSkipped).toHaveBeenCalledTimes(1); + expect(assetFinished).toHaveBeenCalledTimes(1); + expect(result.uploadResult).toBeUndefined(); + }); + }); +}); diff --git a/tools/webpack-plugin/src/BacktracePlugin.ts b/tools/webpack-plugin/src/BacktracePlugin.ts index 412584d5..72da2c42 100644 --- a/tools/webpack-plugin/src/BacktracePlugin.ts +++ b/tools/webpack-plugin/src/BacktracePlugin.ts @@ -28,6 +28,8 @@ export class BacktracePlugin implements WebpackPluginInstance { afterWrite: (asset) => logger.log(`[${asset.asset.name}] wrote source and sourcemap to file`), assetFinished: (asset) => logger.info(`[${asset.asset.name}] asset processed successfully`), assetError: (asset) => logger.error(`[${asset.asset.name}] ${asset.error}`), + assetSkipped: (asset) => logger.debug(`[${asset.asset.name}] skipped: ${asset.error}`), + processingSummary: (message) => logger.info(message), beforeUpload: (paths) => logger.log(`uploading ${paths.length} sourcemaps...`), afterUpload: (result) => logger.info(`sourcemaps uploaded to Backtrace: ${result.rxid}`),