From 07892ee9eab632f1f7804681b508a037274bc8eb Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Mon, 22 Jun 2026 17:14:12 +0200 Subject: [PATCH 1/7] feat(sourcemaps): add --debug-id flag for web sourcemap uploads Adds support for identifying sourcemaps via debug IDs (extracted from the bundle's _ddDebugIds snippet) as an alternative to service/version/ minified-path-prefix. Refactors asMultipartPayload and getMetadataPayload to accept a SourcemapUploadOptions object, and adds test fixtures and unit tests for debug ID extraction. Co-Authored-By: Claude Sonnet 4.6 --- .../bundle-with-debug-id/common.min.js | 2 + .../bundle-with-debug-id/common.min.js.map | 1 + .../sourcemaps/__tests__/upload.test.ts | 98 ++++++++++++++++++- .../src/commands/sourcemaps/interfaces.ts | 63 +++++++++--- .../base/src/commands/sourcemaps/renderer.ts | 37 +++---- .../base/src/commands/sourcemaps/upload.ts | 80 +++++++++------ 6 files changed, 216 insertions(+), 65 deletions(-) create mode 100644 packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js create mode 100644 packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js.map diff --git a/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js b/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js new file mode 100644 index 0000000000..b00e0006bb --- /dev/null +++ b/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js @@ -0,0 +1,2 @@ +(function(c,n){try{if(typeof window==='undefined')return;var w=window,m=w[n]=w[n]||{},s=new Error().stack;s&&(m[s]=c)}catch(e){}})({"ddDebugId":"2f1d7f52-4e1b-4f7c-8c0d-2f4a5f6d8e91"},"DD_SOURCE_CODE_CONTEXT"); +//# sourceMappingURL=common.min.js.map diff --git a/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js.map b/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js.map new file mode 100644 index 0000000000..b9b3af106a --- /dev/null +++ b/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["common.js"],"mappings":""} diff --git a/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts b/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts index 3aed879baa..31bb150490 100644 --- a/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts +++ b/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts @@ -1,7 +1,10 @@ +import type {CommandContext} from '@datadog/datadog-ci-base' +import type {MultipartStringValue} from '@datadog/datadog-ci-base/helpers/upload' + import chalk from 'chalk' import upath from 'upath' -import {createCommand, makeRunCLI} from '@datadog/datadog-ci-base/helpers/__tests__/testing-tools' +import {createCommand, createMockContext, makeRunCLI} from '@datadog/datadog-ci-base/helpers/__tests__/testing-tools' import {Sourcemap} from '../interfaces' import {SourcemapsUploadCommand} from '../upload' @@ -9,6 +12,99 @@ import {SourcemapsUploadCommand} from '../upload' // Always posix, even on Windows. const CWD = upath.normalize(process.cwd()) +describe('Sourcemap.asMultipartPayload', () => { + describe('extractDebugId', () => { + test('includes debug_id in event metadata when bundle contains _ddDebugIds snippet', () => { + const sourcemap = new Sourcemap( + 'src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js', + 'https://static.example.com/common.min.js', + 'src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js.map', + 'common.min.js', + 'https://static.example.com' + ) + const context = createMockContext() as CommandContext + + const payload = sourcemap.asMultipartPayload({ + cliVersion: '1.0.0', + debugId: true, + context, + }) + const event = payload.content.get('event') as MultipartStringValue + const eventValue = JSON.parse(event['value']) as {debug_id: string} + + expect(eventValue['debug_id']).toBe('2f1d7f52-4e1b-4f7c-8c0d-2f4a5f6d8e91') + }) + + test('omits debug_id and writes to stderr when bundle has no snippet', () => { + const sourcemap = new Sourcemap( + 'src/commands/sourcemaps/__tests__/fixtures/basic/common.min.js', + 'https://static.example.com/common.min.js', + 'src/commands/sourcemaps/__tests__/fixtures/basic/common.min.js.map', + 'common.min.js', + 'https://static.example.com' + ) + const context = createMockContext() as CommandContext + + const payload = sourcemap.asMultipartPayload({ + cliVersion: '1.0.0', + debugId: true, + context, + }) + const event = payload.content.get('event') as MultipartStringValue + const eventValue = JSON.parse(event['value']) as Record + + expect(eventValue['debug_id']).toBeUndefined() + expect(context.stderr.toString()).toContain('Debug ID not found') + }) + + test('omits debug_id and writes to stderr when bundle file cannot be read', () => { + const sourcemap = new Sourcemap( + 'nonexistent/path/bundle.js', + 'https://static.example.com/bundle.js', + 'src/commands/sourcemaps/__tests__/fixtures/basic/common.min.js.map', + 'bundle.js', + 'https://static.example.com' + ) + const context = createMockContext() as CommandContext + + const payload = sourcemap.asMultipartPayload({ + cliVersion: '1.0.0', + debugId: true, + context, + }) + const event = payload.content.get('event') as MultipartStringValue + const eventValue = JSON.parse(event['value']) as Record + + expect(eventValue['debug_id']).toBeUndefined() + expect(context.stderr.toString()).toContain('Cannot extract Debug ID') + }) + + test('omits debug_id without error when --debug-id flag is not set', () => { + const sourcemap = new Sourcemap( + 'src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js', + 'https://static.example.com/common.min.js', + 'src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js.map', + 'common.min.js', + 'https://static.example.com' + ) + const context = createMockContext() as CommandContext + + const payload = sourcemap.asMultipartPayload({ + cliVersion: '1.0.0', + service: 'my-service', + version: '1.2.3', + debugId: false, + context, + }) + const event = payload.content.get('event') as MultipartStringValue + const eventValue = JSON.parse(event['value']) as Record + + expect(eventValue['debug_id']).toBeUndefined() + expect(context.stderr.toString()).toBe('') + }) + }) +}) + describe('upload', () => { describe('getMinifiedURL', () => { test('should return correct URL', () => { diff --git a/packages/base/src/commands/sourcemaps/interfaces.ts b/packages/base/src/commands/sourcemaps/interfaces.ts index 2acc3e2a8a..ffcdb8acdd 100644 --- a/packages/base/src/commands/sourcemaps/interfaces.ts +++ b/packages/base/src/commands/sourcemaps/interfaces.ts @@ -1,3 +1,6 @@ +import fs from 'fs' + +import type {CommandContext} from '@datadog/datadog-ci-base' import type {MultipartPayload, MultipartValue} from '@datadog/datadog-ci-base/helpers/upload' export class Sourcemap { @@ -26,14 +29,9 @@ export class Sourcemap { this.gitData = gitData } - public asMultipartPayload( - cliVersion: string, - service: string, - version: string, - projectPath: string - ): MultipartPayload { + public asMultipartPayload(options: SourcemapUploadOptions): MultipartPayload { const content = new Map([ - ['event', this.getMetadataPayload(cliVersion, service, version, projectPath)], + ['event', this.getMetadataPayload(options)], ['source_map', {type: 'file', path: this.sourcemapPath, options: {filename: 'source_map'}}], ['minified_file', {type: 'file', path: this.minifiedFilePath, options: {filename: 'minified_file'}}], ]) @@ -53,20 +51,44 @@ export class Sourcemap { } } - private getMetadataPayload( - cliVersion: string, - service: string, - version: string, - projectPath: string - ): MultipartValue { + private extractDebugId(context: CommandContext): string | undefined { + try { + const source = fs.readFileSync(this.minifiedFilePath, 'utf-8') + const match = source.match(/"ddDebugId":"([^"]+)"/) + if (match) { + return match[1] + } + context.stderr.write(`Debug ID not found in ${this.minifiedFilePath}\n`) + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err) + context.stderr.write(`Cannot extract Debug ID from ${this.minifiedFilePath}: ${errorMsg}\n`) + } + + return undefined + } + + private getMetadataPayload({ + cliVersion, + service, + version, + projectPath, + debugId, + context, + }: SourcemapUploadOptions): MultipartValue { const metadata: {[k: string]: any} = { cli_version: cliVersion, - minified_url: this.minifiedUrl, project_path: projectPath, - service, type: 'js_sourcemap', - version, } + + if (debugId) { + metadata.debug_id = this.extractDebugId(context) + } else { + metadata.service = service + metadata.version = version + metadata.minified_url = this.minifiedUrl + } + if (this.gitData !== undefined) { metadata.git_repository_url = this.gitData.gitRepositoryURL metadata.git_commit_sha = this.gitData.gitCommitSha @@ -83,6 +105,15 @@ export class Sourcemap { } } +export interface SourcemapUploadOptions { + cliVersion: string + context: CommandContext + debugId: boolean + projectPath?: string + service?: string + version?: string +} + export interface GitData { gitCommitSha: string gitRepositoryPayload?: string diff --git a/packages/base/src/commands/sourcemaps/renderer.ts b/packages/base/src/commands/sourcemaps/renderer.ts index 517a6d3e50..e8393bd850 100644 --- a/packages/base/src/commands/sourcemaps/renderer.ts +++ b/packages/base/src/commands/sourcemaps/renderer.ts @@ -108,10 +108,11 @@ export const renderSuccessfulCommand = (statuses: UploadStatus[], duration: numb export const renderCommandInfo = ( basePath: string, - minifiedPathPrefix: string, + minifiedPathPrefix: string | undefined, projectPath: string | undefined, - releaseVersion: string, - service: string, + releaseVersion: string | undefined, + service: string | undefined, + debugId: boolean, poolLimit: number, dryRun: boolean ) => { @@ -119,22 +120,22 @@ export const renderCommandInfo = ( if (dryRun) { fullStr += chalk.yellow(`${ICONS.WARNING} DRY-RUN MODE ENABLED. WILL NOT UPLOAD SOURCEMAPS\n`) } - const startStr = chalk.green(`Starting upload with concurrency ${poolLimit}. \n`) - fullStr += startStr - const basePathStr = chalk.green(`Will look for sourcemaps in ${basePath}\n`) - fullStr += basePathStr - const minifiedPathPrefixStr = chalk.green( - `Will match JS files for errors on files starting with ${minifiedPathPrefix}\n` + fullStr += chalk.green(`Starting upload with concurrency ${poolLimit}. \n`) + fullStr += chalk.green(`Will look for sourcemaps in ${basePath}\n`) + + if (minifiedPathPrefix) { + fullStr += chalk.green(`Will match JS files for errors on files starting with ${minifiedPathPrefix}\n`) + } + + const metaParts = [] + if (!debugId) { + metaParts.push(`${chalk.green('Version:')} ${chalk.cyan(releaseVersion)}`) + metaParts.push(`${chalk.green('Service:')} ${chalk.cyan(service)}`) + } + metaParts.push( + `${chalk.green('Project path:')} ${projectPath !== undefined ? chalk.cyan(projectPath) : chalk.dim('')}` ) - fullStr += minifiedPathPrefixStr - - const serviceVersionProjectPathStr = - [ - `${chalk.green('Version:')} ${chalk.cyan(releaseVersion)}`, - `${chalk.green('Service:')} ${chalk.cyan(service)}`, - `${chalk.green('Project path:')} ${projectPath !== undefined ? chalk.cyan(projectPath) : chalk.dim('')}`, - ].join(' · ') + '\n\n' - fullStr += serviceVersionProjectPathStr + fullStr += metaParts.join(' · ') + '\n\n' return fullStr } diff --git a/packages/base/src/commands/sourcemaps/upload.ts b/packages/base/src/commands/sourcemaps/upload.ts index 976faca7b6..839e2a17f5 100644 --- a/packages/base/src/commands/sourcemaps/upload.ts +++ b/packages/base/src/commands/sourcemaps/upload.ts @@ -81,6 +81,7 @@ export class SourcemapsUploadCommand extends BaseCommand { private releaseVersion = Option.String('--release-version') private repositoryURL = Option.String('--repository-url') private commitSha = Option.String('--commit-sha') + private debugId = Option.Boolean('--debug-id', false) private service = Option.String('--service') private cliVersion = cliVersion @@ -100,28 +101,9 @@ export class SourcemapsUploadCommand extends BaseCommand { public async execute() { enableFips(this.fips || this.config.fips, this.fipsIgnoreError || this.config.fipsIgnoreError) - if (!this.releaseVersion) { - this.context.stderr.write('Missing release version\n') - - return 1 - } - - if (!this.service) { - this.context.stderr.write('Missing service\n') - - return 1 - } - - if (!this.minifiedPathPrefix) { - this.context.stderr.write('Missing minified path prefix\n') - - return 1 - } - - if (!this.isMinifiedPathPrefixValid()) { - this.context.stdout.write(renderInvalidPrefix) - - return 1 + const validationError = this.validateOptions() + if (validationError) { + return validationError } // Normalizing the basePath to resolve .. and . @@ -133,13 +115,19 @@ export class SourcemapsUploadCommand extends BaseCommand { this.projectPath, this.releaseVersion, this.service, + this.debugId, this.maxConcurrency, this.dryRun ) ) + const loggerTags = [`cli_version:${this.cliVersion}`] + if (!this.debugId) { + loggerTags.push(`version:${this.releaseVersion}`) + loggerTags.push(`service:${this.service}`) + } const metricsLogger = getMetricsLogger({ datadogSite: getDatadogSiteFromEnv(), - defaultTags: [`version:${this.releaseVersion}`, `service:${this.service}`, `cli_version:${this.cliVersion}`], + defaultTags: loggerTags, prefix: 'datadog.ci.sourcemaps.', }) const apiKeyValidator = newApiKeyValidator({ @@ -280,7 +268,7 @@ export class SourcemapsUploadCommand extends BaseCommand { private getMinifiedURLAndRelativePath(minifiedFilePath: string): [string, string] { const relativePath = upath.relative(this.basePath, minifiedFilePath) - return [buildPath(this.minifiedPathPrefix!, relativePath), relativePath] + return [buildPath(this.minifiedPathPrefix ?? '', relativePath), relativePath] } private getPayloadsToUpload = async (useGit: boolean): Promise => { @@ -380,6 +368,36 @@ export class SourcemapsUploadCommand extends BaseCommand { }) } + private validateOptions(): 1 | undefined { + if (this.debugId) { + return undefined + } + + if (!this.releaseVersion) { + this.context.stderr.write('Missing release version\n') + + return 1 + } + + if (!this.service) { + this.context.stderr.write('Missing service\n') + + return 1 + } + + if (!this.minifiedPathPrefix) { + this.context.stderr.write('Missing minified path prefix\n') + + return 1 + } + + if (!this.isMinifiedPathPrefixValid()) { + this.context.stdout.write(renderInvalidPrefix) + + return 1 + } + } + private isMinifiedPathPrefixValid(): boolean { let host try { @@ -421,12 +439,14 @@ export class SourcemapsUploadCommand extends BaseCommand { return UploadStatus.Skipped } - const payload = sourcemap.asMultipartPayload( - this.cliVersion, - this.service!, - this.releaseVersion!, - this.projectPath ?? '' - ) + const payload = sourcemap.asMultipartPayload({ + cliVersion: this.cliVersion, + service: this.service, + version: this.releaseVersion, + projectPath: this.projectPath, + debugId: this.debugId, + context: this.context, + }) if (this.dryRun) { this.context.stdout.write(`[DRYRUN] ${renderUpload(sourcemap)}`) From 8f7bda1e6d4425c4679a4879b3d8d775201545cf Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Tue, 23 Jun 2026 15:46:12 +0200 Subject: [PATCH 2/7] feat(sourcemaps): extract debug ID into own module and print it in upload output - Move extractDebugId into debugId.ts with a module-level DD_DEBUG_ID_REGEX - Support single-quoted keys and values in the regex - Print extracted debug ID in dry-run and live upload output via renderUpload - Add debugId.test.ts covering multiple snippet formats and error cases - Add execute-level dry-run test checking debug ID appears in output Co-Authored-By: Claude Sonnet 4.6 --- .../sourcemaps/__tests__/debugId.test.ts | 58 ++++++ .../sourcemaps/__tests__/upload.test.ts | 165 ++++++++---------- .../base/src/commands/sourcemaps/debugId.ts | 21 +++ .../src/commands/sourcemaps/interfaces.ts | 33 +--- .../base/src/commands/sourcemaps/renderer.ts | 7 +- .../base/src/commands/sourcemaps/upload.ts | 27 +-- 6 files changed, 172 insertions(+), 139 deletions(-) create mode 100644 packages/base/src/commands/sourcemaps/__tests__/debugId.test.ts create mode 100644 packages/base/src/commands/sourcemaps/debugId.ts diff --git a/packages/base/src/commands/sourcemaps/__tests__/debugId.test.ts b/packages/base/src/commands/sourcemaps/__tests__/debugId.test.ts new file mode 100644 index 0000000000..ddf1129637 --- /dev/null +++ b/packages/base/src/commands/sourcemaps/__tests__/debugId.test.ts @@ -0,0 +1,58 @@ +import fs from 'fs' + +import type {CommandContext} from '@datadog/datadog-ci-base' + +import {createMockContext} from '@datadog/datadog-ci-base/helpers/__tests__/testing-tools' + +import {extractDebugId} from '../debugId' + +const DEBUG_ID = '2f1d7f52-4e1b-4f7c-8c0d-2f4a5f6d8e91' + +const mockFileContent = (content: string) => { + jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(content) +} + +describe('extractDebugId', () => { + let context: CommandContext + + beforeEach(() => { + context = createMockContext() as CommandContext + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('snippet formats', () => { + test.each([ + [`{"ddDebugId":"${DEBUG_ID}"}`], + [`{"ddDebugId": "${DEBUG_ID}"}`], + [`{"ddDebugId" : "${DEBUG_ID}"}`], + [`{"ddDebugId" : "${DEBUG_ID}"}`], + [`{"ddDebugId"\t:\t"${DEBUG_ID}"}`], + [`{'ddDebugId': '${DEBUG_ID}'}`], + [`var x=1;({"ddDebugId":"${DEBUG_ID}"});var y=2;`], + [`var x=1;\n{"ddDebugId": "${DEBUG_ID}"}\nvar y=2;`], + ])('%s', (content: string) => { + mockFileContent(content) + expect(extractDebugId('bundle.js', context)).toBe(DEBUG_ID) + expect(context.stderr.toString()).toBe('') + }) + }) + + describe('missing or unreadable', () => { + test('returns undefined and writes to stderr when snippet is absent', () => { + mockFileContent('var x = 1; console.log("hello");') + expect(extractDebugId('bundle.js', context)).toBeUndefined() + expect(context.stderr.toString()).toContain('Debug ID not found') + }) + + test('returns undefined and writes to stderr when file cannot be read', () => { + jest.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { + throw new Error('ENOENT: no such file or directory') + }) + expect(extractDebugId('nonexistent.js', context)).toBeUndefined() + expect(context.stderr.toString()).toContain('Cannot extract Debug ID') + }) + }) +}) diff --git a/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts b/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts index 31bb150490..c36ecab4a8 100644 --- a/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts +++ b/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts @@ -1,10 +1,7 @@ -import type {CommandContext} from '@datadog/datadog-ci-base' -import type {MultipartStringValue} from '@datadog/datadog-ci-base/helpers/upload' - import chalk from 'chalk' import upath from 'upath' -import {createCommand, createMockContext, makeRunCLI} from '@datadog/datadog-ci-base/helpers/__tests__/testing-tools' +import {createCommand, makeRunCLI} from '@datadog/datadog-ci-base/helpers/__tests__/testing-tools' import {Sourcemap} from '../interfaces' import {SourcemapsUploadCommand} from '../upload' @@ -12,99 +9,6 @@ import {SourcemapsUploadCommand} from '../upload' // Always posix, even on Windows. const CWD = upath.normalize(process.cwd()) -describe('Sourcemap.asMultipartPayload', () => { - describe('extractDebugId', () => { - test('includes debug_id in event metadata when bundle contains _ddDebugIds snippet', () => { - const sourcemap = new Sourcemap( - 'src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js', - 'https://static.example.com/common.min.js', - 'src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js.map', - 'common.min.js', - 'https://static.example.com' - ) - const context = createMockContext() as CommandContext - - const payload = sourcemap.asMultipartPayload({ - cliVersion: '1.0.0', - debugId: true, - context, - }) - const event = payload.content.get('event') as MultipartStringValue - const eventValue = JSON.parse(event['value']) as {debug_id: string} - - expect(eventValue['debug_id']).toBe('2f1d7f52-4e1b-4f7c-8c0d-2f4a5f6d8e91') - }) - - test('omits debug_id and writes to stderr when bundle has no snippet', () => { - const sourcemap = new Sourcemap( - 'src/commands/sourcemaps/__tests__/fixtures/basic/common.min.js', - 'https://static.example.com/common.min.js', - 'src/commands/sourcemaps/__tests__/fixtures/basic/common.min.js.map', - 'common.min.js', - 'https://static.example.com' - ) - const context = createMockContext() as CommandContext - - const payload = sourcemap.asMultipartPayload({ - cliVersion: '1.0.0', - debugId: true, - context, - }) - const event = payload.content.get('event') as MultipartStringValue - const eventValue = JSON.parse(event['value']) as Record - - expect(eventValue['debug_id']).toBeUndefined() - expect(context.stderr.toString()).toContain('Debug ID not found') - }) - - test('omits debug_id and writes to stderr when bundle file cannot be read', () => { - const sourcemap = new Sourcemap( - 'nonexistent/path/bundle.js', - 'https://static.example.com/bundle.js', - 'src/commands/sourcemaps/__tests__/fixtures/basic/common.min.js.map', - 'bundle.js', - 'https://static.example.com' - ) - const context = createMockContext() as CommandContext - - const payload = sourcemap.asMultipartPayload({ - cliVersion: '1.0.0', - debugId: true, - context, - }) - const event = payload.content.get('event') as MultipartStringValue - const eventValue = JSON.parse(event['value']) as Record - - expect(eventValue['debug_id']).toBeUndefined() - expect(context.stderr.toString()).toContain('Cannot extract Debug ID') - }) - - test('omits debug_id without error when --debug-id flag is not set', () => { - const sourcemap = new Sourcemap( - 'src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js', - 'https://static.example.com/common.min.js', - 'src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js.map', - 'common.min.js', - 'https://static.example.com' - ) - const context = createMockContext() as CommandContext - - const payload = sourcemap.asMultipartPayload({ - cliVersion: '1.0.0', - service: 'my-service', - version: '1.2.3', - debugId: false, - context, - }) - const event = payload.content.get('event') as MultipartStringValue - const eventValue = JSON.parse(event['value']) as Record - - expect(eventValue['debug_id']).toBeUndefined() - expect(context.stderr.toString()).toBe('') - }) - }) -}) - describe('upload', () => { describe('getMinifiedURL', () => { test('should return correct URL', () => { @@ -209,6 +113,54 @@ describe('upload', () => { }) }) + describe('validateOptions', () => { + test('returns true when --debug-id is set (skips all other checks)', () => { + const command = createCommand(SourcemapsUploadCommand) + command['debugId'] = true + expect(command['validateOptions']()).toBe(true) + expect(command.context.stderr.toString()).toBe('') + }) + + test('returns false when release version is missing', () => { + const command = createCommand(SourcemapsUploadCommand) + expect(command['validateOptions']()).toBe(false) + expect(command.context.stderr.toString()).toContain('Missing release version') + }) + + test('returns false when service is missing', () => { + const command = createCommand(SourcemapsUploadCommand) + command['releaseVersion'] = '1.0.0' + expect(command['validateOptions']()).toBe(false) + expect(command.context.stderr.toString()).toContain('Missing service') + }) + + test('returns false when minified path prefix is missing', () => { + const command = createCommand(SourcemapsUploadCommand) + command['releaseVersion'] = '1.0.0' + command['service'] = 'my-service' + expect(command['validateOptions']()).toBe(false) + expect(command.context.stderr.toString()).toContain('Missing minified path prefix') + }) + + test('returns false when minified path prefix is invalid', () => { + const command = createCommand(SourcemapsUploadCommand) + command['releaseVersion'] = '1.0.0' + command['service'] = 'my-service' + command['minifiedPathPrefix'] = 'not-a-valid-prefix' + expect(command['validateOptions']()).toBe(false) + expect(command.context.stdout.toString()).toContain('prefix') + }) + + test('returns true when all required options are valid', () => { + const command = createCommand(SourcemapsUploadCommand) + command['releaseVersion'] = '1.0.0' + command['service'] = 'my-service' + command['minifiedPathPrefix'] = 'https://static.example.com/js' + expect(command['validateOptions']()).toBe(true) + expect(command.context.stderr.toString()).toBe('') + }) + }) + describe('getApiHelper', () => { test('should throw an error if API key is undefined', async () => { process.env = {} @@ -330,6 +282,25 @@ describe('execute', () => { '--dry-run', ]) + const runCLIWithDebugId = makeRunCLI(SourcemapsUploadCommand, [ + 'sourcemaps', + 'upload', + '--debug-id', + '--minified-path-prefix', + 'https://static.com/js', + '--dry-run', + ]) + + test('debug id', async () => { + const {context, code} = await runCLIWithDebugId([ + './src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id', + ]) + expect(code).toBe(0) + expect(context.stdout.toString()).toContain( + '[DRYRUN] Uploading sourcemap src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js.map for JS file available at https://static.com/js/common.min.js (debug ID: 2f1d7f52-4e1b-4f7c-8c0d-2f4a5f6d8e91)' + ) + }) + test('relative path with double dots', async () => { const {context, code} = await runCLI(['./src/commands/sourcemaps/__tests__/doesnotexist/../fixtures/basic']) const output = context.stdout.toString().split('\n') diff --git a/packages/base/src/commands/sourcemaps/debugId.ts b/packages/base/src/commands/sourcemaps/debugId.ts new file mode 100644 index 0000000000..0ac323545d --- /dev/null +++ b/packages/base/src/commands/sourcemaps/debugId.ts @@ -0,0 +1,21 @@ +import fs from 'fs' + +import type {CommandContext} from '@datadog/datadog-ci-base' + +const DD_DEBUG_ID_REGEX = /["']ddDebugId["']\s*:\s*["']([^"']+)["']/ + +export const extractDebugId = (filePath: string, context: CommandContext): string | undefined => { + try { + const source = fs.readFileSync(filePath, 'utf-8') + const match = source.match(DD_DEBUG_ID_REGEX) + if (match) { + return match[1] + } + context.stderr.write(`Debug ID not found in ${filePath}\n`) + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err) + context.stderr.write(`Cannot extract Debug ID from ${filePath}: ${errorMsg}\n`) + } + + return undefined +} diff --git a/packages/base/src/commands/sourcemaps/interfaces.ts b/packages/base/src/commands/sourcemaps/interfaces.ts index ffcdb8acdd..bb0c736d9a 100644 --- a/packages/base/src/commands/sourcemaps/interfaces.ts +++ b/packages/base/src/commands/sourcemaps/interfaces.ts @@ -1,5 +1,3 @@ -import fs from 'fs' - import type {CommandContext} from '@datadog/datadog-ci-base' import type {MultipartPayload, MultipartValue} from '@datadog/datadog-ci-base/helpers/upload' @@ -51,42 +49,21 @@ export class Sourcemap { } } - private extractDebugId(context: CommandContext): string | undefined { - try { - const source = fs.readFileSync(this.minifiedFilePath, 'utf-8') - const match = source.match(/"ddDebugId":"([^"]+)"/) - if (match) { - return match[1] - } - context.stderr.write(`Debug ID not found in ${this.minifiedFilePath}\n`) - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err) - context.stderr.write(`Cannot extract Debug ID from ${this.minifiedFilePath}: ${errorMsg}\n`) - } - - return undefined - } - private getMetadataPayload({ cliVersion, service, version, projectPath, debugId, - context, }: SourcemapUploadOptions): MultipartValue { const metadata: {[k: string]: any} = { cli_version: cliVersion, project_path: projectPath, type: 'js_sourcemap', - } - - if (debugId) { - metadata.debug_id = this.extractDebugId(context) - } else { - metadata.service = service - metadata.version = version - metadata.minified_url = this.minifiedUrl + service, + version, + minified_url: this.minifiedUrl, + debug_id: debugId, } if (this.gitData !== undefined) { @@ -108,7 +85,7 @@ export class Sourcemap { export interface SourcemapUploadOptions { cliVersion: string context: CommandContext - debugId: boolean + debugId?: string projectPath?: string service?: string version?: string diff --git a/packages/base/src/commands/sourcemaps/renderer.ts b/packages/base/src/commands/sourcemaps/renderer.ts index e8393bd850..4ca8f6263a 100644 --- a/packages/base/src/commands/sourcemaps/renderer.ts +++ b/packages/base/src/commands/sourcemaps/renderer.ts @@ -140,5 +140,8 @@ export const renderCommandInfo = ( return fullStr } -export const renderUpload = (sourcemap: Sourcemap): string => - `Uploading sourcemap ${sourcemap.sourcemapPath} for JS file available at ${sourcemap.minifiedUrl}\n` +export const renderUpload = (sourcemap: Sourcemap, debugId?: string): string => { + const debugIdSuffix = debugId ? ` (debug ID: ${debugId})` : '' + + return `Uploading sourcemap ${sourcemap.sourcemapPath} for JS file available at ${sourcemap.minifiedUrl}${debugIdSuffix}\n` +} diff --git a/packages/base/src/commands/sourcemaps/upload.ts b/packages/base/src/commands/sourcemaps/upload.ts index 839e2a17f5..5047b508ae 100644 --- a/packages/base/src/commands/sourcemaps/upload.ts +++ b/packages/base/src/commands/sourcemaps/upload.ts @@ -32,6 +32,7 @@ import {getRequestBuilder, buildPath} from '@datadog/datadog-ci-base/helpers/uti import * as validation from '@datadog/datadog-ci-base/helpers/validation' import {cliVersion} from '@datadog/datadog-ci-base/version' +import {extractDebugId} from './debugId' import {Sourcemap} from './interfaces' import { renderCommandInfo, @@ -101,9 +102,8 @@ export class SourcemapsUploadCommand extends BaseCommand { public async execute() { enableFips(this.fips || this.config.fips, this.fipsIgnoreError || this.config.fipsIgnoreError) - const validationError = this.validateOptions() - if (validationError) { - return validationError + if (!this.validateOptions()) { + return 1 } // Normalizing the basePath to resolve .. and . @@ -368,34 +368,36 @@ export class SourcemapsUploadCommand extends BaseCommand { }) } - private validateOptions(): 1 | undefined { + private validateOptions(): boolean { if (this.debugId) { - return undefined + return true } if (!this.releaseVersion) { this.context.stderr.write('Missing release version\n') - return 1 + return false } if (!this.service) { this.context.stderr.write('Missing service\n') - return 1 + return false } if (!this.minifiedPathPrefix) { this.context.stderr.write('Missing minified path prefix\n') - return 1 + return false } if (!this.isMinifiedPathPrefixValid()) { this.context.stdout.write(renderInvalidPrefix) - return 1 + return false } + + return true } private isMinifiedPathPrefixValid(): boolean { @@ -439,16 +441,17 @@ export class SourcemapsUploadCommand extends BaseCommand { return UploadStatus.Skipped } + const debugId = this.debugId ? extractDebugId(sourcemap.minifiedFilePath, this.context) : undefined const payload = sourcemap.asMultipartPayload({ cliVersion: this.cliVersion, service: this.service, version: this.releaseVersion, projectPath: this.projectPath, - debugId: this.debugId, + debugId, context: this.context, }) if (this.dryRun) { - this.context.stdout.write(`[DRYRUN] ${renderUpload(sourcemap)}`) + this.context.stdout.write(`[DRYRUN] ${renderUpload(sourcemap, debugId)}`) return UploadStatus.Success } @@ -467,7 +470,7 @@ export class SourcemapsUploadCommand extends BaseCommand { if (this.quiet) { return } - this.context.stdout.write(renderUpload(sourcemap)) + this.context.stdout.write(renderUpload(sourcemap, debugId)) }, retries: 5, useGzip: true, From 91c2f8ffa0981ba3e8467b355ab20b0943f4b482 Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Fri, 3 Jul 2026 13:46:47 +0200 Subject: [PATCH 3/7] refactor(sourcemaps): consolidate debug ID handling into debugId module - addDebugIdToPayloads sets each payload's debugId and gates the abort - fold missing-debug-ID skip into validatePayload as an InvalidPayload - move debug-ID messages into renderer; keep extractDebugId pure - add debugId.test.ts coverage and partial-debug-id fixture Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sourcemaps/__tests__/debugId.test.ts | 70 +++++++++++++------ .../bundle-with-partial-debug-id/a.min.js | 2 + .../bundle-with-partial-debug-id/a.min.js.map | 1 + .../bundle-with-partial-debug-id/b.min.js | 2 + .../bundle-with-partial-debug-id/b.min.js.map | 1 + .../sourcemaps/__tests__/upload.test.ts | 19 +++++ .../base/src/commands/sourcemaps/debugId.ts | 31 +++++--- .../src/commands/sourcemaps/interfaces.ts | 12 +--- .../base/src/commands/sourcemaps/renderer.ts | 6 +- .../base/src/commands/sourcemaps/upload.ts | 18 +++-- .../src/commands/sourcemaps/validation.ts | 10 ++- 11 files changed, 122 insertions(+), 50 deletions(-) create mode 100644 packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/a.min.js create mode 100644 packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/a.min.js.map create mode 100644 packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/b.min.js create mode 100644 packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/b.min.js.map diff --git a/packages/base/src/commands/sourcemaps/__tests__/debugId.test.ts b/packages/base/src/commands/sourcemaps/__tests__/debugId.test.ts index ddf1129637..5feeab4931 100644 --- a/packages/base/src/commands/sourcemaps/__tests__/debugId.test.ts +++ b/packages/base/src/commands/sourcemaps/__tests__/debugId.test.ts @@ -1,24 +1,26 @@ import fs from 'fs' -import type {CommandContext} from '@datadog/datadog-ci-base' - -import {createMockContext} from '@datadog/datadog-ci-base/helpers/__tests__/testing-tools' - -import {extractDebugId} from '../debugId' +import {addDebugIdToPayloads, extractDebugId} from '../debugId' +import {Sourcemap} from '../interfaces' const DEBUG_ID = '2f1d7f52-4e1b-4f7c-8c0d-2f4a5f6d8e91' -const mockFileContent = (content: string) => { - jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(content) -} +const makeSourcemap = (minifiedFilePath: string) => + new Sourcemap(minifiedFilePath, `https://static.com/${minifiedFilePath}`, `${minifiedFilePath}.map`, minifiedFilePath) -describe('extractDebugId', () => { - let context: CommandContext +// Mocks fs.readFileSync to return the given content keyed by minified file path. +const mockFilesByPath = (contentByPath: Record) => { + jest.spyOn(fs, 'readFileSync').mockImplementation((path: unknown) => { + const content = contentByPath[path as string] + if (content === undefined) { + throw new Error(`ENOENT: ${String(path)}`) + } - beforeEach(() => { - context = createMockContext() as CommandContext + return content }) +} +describe('extractDebugId', () => { afterEach(() => { jest.restoreAllMocks() }) @@ -34,25 +36,49 @@ describe('extractDebugId', () => { [`var x=1;({"ddDebugId":"${DEBUG_ID}"});var y=2;`], [`var x=1;\n{"ddDebugId": "${DEBUG_ID}"}\nvar y=2;`], ])('%s', (content: string) => { - mockFileContent(content) - expect(extractDebugId('bundle.js', context)).toBe(DEBUG_ID) - expect(context.stderr.toString()).toBe('') + jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(content) + expect(extractDebugId('bundle.js')).toBe(DEBUG_ID) }) }) describe('missing or unreadable', () => { - test('returns undefined and writes to stderr when snippet is absent', () => { - mockFileContent('var x = 1; console.log("hello");') - expect(extractDebugId('bundle.js', context)).toBeUndefined() - expect(context.stderr.toString()).toContain('Debug ID not found') + test('returns undefined when snippet is absent', () => { + jest.spyOn(fs, 'readFileSync').mockReturnValueOnce('var x = 1; console.log("hello");') + expect(extractDebugId('bundle.js')).toBeUndefined() }) - test('returns undefined and writes to stderr when file cannot be read', () => { + test('returns undefined when file cannot be read', () => { jest.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { throw new Error('ENOENT: no such file or directory') }) - expect(extractDebugId('nonexistent.js', context)).toBeUndefined() - expect(context.stderr.toString()).toContain('Cannot extract Debug ID') + expect(extractDebugId('nonexistent.js')).toBeUndefined() + }) + }) +}) + +describe('addDebugIdToPayloads', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + test('stores each debug ID on its payload and returns true when any is found', () => { + mockFilesByPath({ + 'a.min.js': `{"ddDebugId":"${DEBUG_ID}"}`, + 'b.min.js': 'var x = 1;', }) + const withId = makeSourcemap('a.min.js') + const withoutId = makeSourcemap('b.min.js') + + expect(addDebugIdToPayloads([withId, withoutId])).toBe(true) + expect(withId.debugId).toBe(DEBUG_ID) + expect(withoutId.debugId).toBeUndefined() + }) + + test('returns false when no payload has a debug ID', () => { + mockFilesByPath({'a.min.js': 'var x = 1;', 'b.min.js': 'var y = 2;'}) + const payloads = [makeSourcemap('a.min.js'), makeSourcemap('b.min.js')] + + expect(addDebugIdToPayloads(payloads)).toBe(false) + expect(payloads.every((p) => p.debugId === undefined)).toBe(true) }) }) diff --git a/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/a.min.js b/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/a.min.js new file mode 100644 index 0000000000..cae08f585a --- /dev/null +++ b/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/a.min.js @@ -0,0 +1,2 @@ +(function(c,n){try{if(typeof window==='undefined')return;var w=window,m=w[n]=w[n]||{},s=new Error().stack;s&&(m[s]=c)}catch(e){}})({"ddDebugId":"2f1d7f52-4e1b-4f7c-8c0d-2f4a5f6d8e91"},"DD_SOURCE_CODE_CONTEXT"); +//# sourceMappingURL=a.min.js.map diff --git a/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/a.min.js.map b/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/a.min.js.map new file mode 100644 index 0000000000..7757d38f5c --- /dev/null +++ b/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/a.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["a.js"],"mappings":""} diff --git a/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/b.min.js b/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/b.min.js new file mode 100644 index 0000000000..0b8667bb15 --- /dev/null +++ b/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/b.min.js @@ -0,0 +1,2 @@ +var x = 1; console.log("hello"); +//# sourceMappingURL=b.min.js.map diff --git a/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/b.min.js.map b/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/b.min.js.map new file mode 100644 index 0000000000..073a2c7a0d --- /dev/null +++ b/packages/base/src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id/b.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["b.js"],"mappings":""} diff --git a/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts b/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts index c36ecab4a8..e2c3b856ba 100644 --- a/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts +++ b/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts @@ -301,6 +301,25 @@ describe('execute', () => { ) }) + test('debug id missing in all files aborts with exit 1', async () => { + const {context, code} = await runCLIWithDebugId(['./src/commands/sourcemaps/__tests__/fixtures/basic']) + expect(code).toBe(1) + expect(context.stderr.toString()).toContain('No debug ID found in any minified file') + expect(context.stdout.toString()).not.toContain('[DRYRUN] Uploading sourcemap') + }) + + test('debug id missing in some files skips only those files', async () => { + const {context, code} = await runCLIWithDebugId([ + './src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id', + ]) + expect(code).toBe(0) + const stdout = context.stdout.toString() + expect(stdout).toContain('[DRYRUN] Uploading sourcemap') + expect(stdout).toContain('a.min.js.map') + expect(stdout).toContain('because no debug ID was found') + expect(stdout).toContain('b.min.js.map') + }) + test('relative path with double dots', async () => { const {context, code} = await runCLI(['./src/commands/sourcemaps/__tests__/doesnotexist/../fixtures/basic']) const output = context.stdout.toString().split('\n') diff --git a/packages/base/src/commands/sourcemaps/debugId.ts b/packages/base/src/commands/sourcemaps/debugId.ts index 0ac323545d..6c35619e39 100644 --- a/packages/base/src/commands/sourcemaps/debugId.ts +++ b/packages/base/src/commands/sourcemaps/debugId.ts @@ -1,21 +1,32 @@ import fs from 'fs' -import type {CommandContext} from '@datadog/datadog-ci-base' +import type {Sourcemap} from './interfaces' const DD_DEBUG_ID_REGEX = /["']ddDebugId["']\s*:\s*["']([^"']+)["']/ -export const extractDebugId = (filePath: string, context: CommandContext): string | undefined => { +export const extractDebugId = (filePath: string): string | undefined => { try { const source = fs.readFileSync(filePath, 'utf-8') - const match = source.match(DD_DEBUG_ID_REGEX) - if (match) { - return match[1] + + return source.match(DD_DEBUG_ID_REGEX)?.[1] + } catch { + // Unreadable file: treated as having no debug ID. + return undefined + } +} + +/** + * Adds the debug ID extracted from each payload's minified file onto the + * payload. Returns true if at least one payload has a debug ID. + */ +export const addDebugIdToPayloads = (payloads: Sourcemap[]): boolean => { + let hasAnyDebugId = false + for (const payload of payloads) { + payload.debugId = extractDebugId(payload.minifiedFilePath) + if (payload.debugId !== undefined) { + hasAnyDebugId = true } - context.stderr.write(`Debug ID not found in ${filePath}\n`) - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err) - context.stderr.write(`Cannot extract Debug ID from ${filePath}: ${errorMsg}\n`) } - return undefined + return hasAnyDebugId } diff --git a/packages/base/src/commands/sourcemaps/interfaces.ts b/packages/base/src/commands/sourcemaps/interfaces.ts index bb0c736d9a..3dd3df1496 100644 --- a/packages/base/src/commands/sourcemaps/interfaces.ts +++ b/packages/base/src/commands/sourcemaps/interfaces.ts @@ -2,6 +2,7 @@ import type {CommandContext} from '@datadog/datadog-ci-base' import type {MultipartPayload, MultipartValue} from '@datadog/datadog-ci-base/helpers/upload' export class Sourcemap { + public debugId?: string public gitData?: GitData public minifiedFilePath: string public minifiedPathPrefix?: string @@ -49,13 +50,7 @@ export class Sourcemap { } } - private getMetadataPayload({ - cliVersion, - service, - version, - projectPath, - debugId, - }: SourcemapUploadOptions): MultipartValue { + private getMetadataPayload({cliVersion, service, version, projectPath}: SourcemapUploadOptions): MultipartValue { const metadata: {[k: string]: any} = { cli_version: cliVersion, project_path: projectPath, @@ -63,7 +58,7 @@ export class Sourcemap { service, version, minified_url: this.minifiedUrl, - debug_id: debugId, + debug_id: this.debugId, } if (this.gitData !== undefined) { @@ -85,7 +80,6 @@ export class Sourcemap { export interface SourcemapUploadOptions { cliVersion: string context: CommandContext - debugId?: string projectPath?: string service?: string version?: string diff --git a/packages/base/src/commands/sourcemaps/renderer.ts b/packages/base/src/commands/sourcemaps/renderer.ts index 4ca8f6263a..e442f9d84a 100644 --- a/packages/base/src/commands/sourcemaps/renderer.ts +++ b/packages/base/src/commands/sourcemaps/renderer.ts @@ -38,6 +38,8 @@ export const renderFailedUpload = (sourcemap: Sourcemap, errorMessage: string) = return chalk.red(`${ICONS.FAILED} Failed upload sourcemap for ${sourcemapPathBold}: ${errorMessage}\n`) } +export const renderNoDebugIdFound = () => 'No debug ID found in any minified file. Aborting upload.\n' + export const renderRetriedUpload = (payload: Sourcemap, errorMessage: string, attempt: number) => { const sourcemapPathBold = `[${chalk.bold.dim(payload.sourcemapPath)}]` @@ -140,8 +142,8 @@ export const renderCommandInfo = ( return fullStr } -export const renderUpload = (sourcemap: Sourcemap, debugId?: string): string => { - const debugIdSuffix = debugId ? ` (debug ID: ${debugId})` : '' +export const renderUpload = (sourcemap: Sourcemap): string => { + const debugIdSuffix = sourcemap.debugId ? ` (debug ID: ${sourcemap.debugId})` : '' return `Uploading sourcemap ${sourcemap.sourcemapPath} for JS file available at ${sourcemap.minifiedUrl}${debugIdSuffix}\n` } diff --git a/packages/base/src/commands/sourcemaps/upload.ts b/packages/base/src/commands/sourcemaps/upload.ts index 5047b508ae..509d7daa98 100644 --- a/packages/base/src/commands/sourcemaps/upload.ts +++ b/packages/base/src/commands/sourcemaps/upload.ts @@ -32,7 +32,7 @@ import {getRequestBuilder, buildPath} from '@datadog/datadog-ci-base/helpers/uti import * as validation from '@datadog/datadog-ci-base/helpers/validation' import {cliVersion} from '@datadog/datadog-ci-base/version' -import {extractDebugId} from './debugId' +import {addDebugIdToPayloads} from './debugId' import {Sourcemap} from './interfaces' import { renderCommandInfo, @@ -42,6 +42,7 @@ import { renderGitDataNotAttachedWarning, renderGitWarning, renderInvalidPrefix, + renderNoDebugIdFound, renderRetriedUpload, renderSourcesNotFoundWarning, renderSuccessfulCommand, @@ -138,6 +139,13 @@ export class SourcemapsUploadCommand extends BaseCommand { const useGit = this.disableGit === undefined || !this.disableGit const initialTime = Date.now() const payloads = await this.getPayloadsToUpload(useGit) + + if (this.debugId && !addDebugIdToPayloads(payloads)) { + this.context.stderr.write(renderNoDebugIdFound()) + + return 1 + } + const requestBuilder = this.getRequestBuilder() const uploadMultipart = this.upload(requestBuilder, metricsLogger, apiKeyValidator) try { @@ -423,7 +431,7 @@ export class SourcemapsUploadCommand extends BaseCommand { ): (sourcemap: Sourcemap) => Promise { return async (sourcemap: Sourcemap) => { try { - validatePayload(sourcemap, this.context.stdout) + validatePayload(sourcemap, this.context.stdout, this.debugId) } catch (error) { if (error instanceof InvalidPayload) { this.context.stdout.write(renderFailedUpload(sourcemap, error.message)) @@ -441,17 +449,15 @@ export class SourcemapsUploadCommand extends BaseCommand { return UploadStatus.Skipped } - const debugId = this.debugId ? extractDebugId(sourcemap.minifiedFilePath, this.context) : undefined const payload = sourcemap.asMultipartPayload({ cliVersion: this.cliVersion, service: this.service, version: this.releaseVersion, projectPath: this.projectPath, - debugId, context: this.context, }) if (this.dryRun) { - this.context.stdout.write(`[DRYRUN] ${renderUpload(sourcemap, debugId)}`) + this.context.stdout.write(`[DRYRUN] ${renderUpload(sourcemap)}`) return UploadStatus.Success } @@ -470,7 +476,7 @@ export class SourcemapsUploadCommand extends BaseCommand { if (this.quiet) { return } - this.context.stdout.write(renderUpload(sourcemap, debugId)) + this.context.stdout.write(renderUpload(sourcemap)) }, retries: 5, useGzip: true, diff --git a/packages/base/src/commands/sourcemaps/validation.ts b/packages/base/src/commands/sourcemaps/validation.ts index 8bce5b334c..eb424bc0fd 100644 --- a/packages/base/src/commands/sourcemaps/validation.ts +++ b/packages/base/src/commands/sourcemaps/validation.ts @@ -15,7 +15,7 @@ export class InvalidPayload extends Error { } } -export const validatePayload = (sourcemap: Sourcemap, stdout: Writable) => { +export const validatePayload = (sourcemap: Sourcemap, stdout: Writable, debugIdRequired = false) => { // Check existence of sourcemap file const sourcemapCheck = checkFile(sourcemap.sourcemapPath) if (!sourcemapCheck.exists) { @@ -40,6 +40,14 @@ export const validatePayload = (sourcemap: Sourcemap, stdout: Writable) => { ) } + // Check for a debug ID when uploading in debug-ID mode. + if (debugIdRequired && sourcemap.debugId === undefined) { + throw new InvalidPayload( + 'missing_debug_id', + `Skipping sourcemap ${sourcemap.sourcemapPath} because no debug ID was found in ${sourcemap.minifiedFilePath}` + ) + } + // Check for --minified-path-prefix flag misuages. if (sourcemap.minifiedPathPrefix) { const repeated = extractRepeatedPath(sourcemap.minifiedPathPrefix, sourcemap.relativePath) From 55716f29a32669e43c43967d275bf50973643845 Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Tue, 7 Jul 2026 10:56:57 +0200 Subject: [PATCH 4/7] strict regex --- packages/base/src/commands/sourcemaps/debugId.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/base/src/commands/sourcemaps/debugId.ts b/packages/base/src/commands/sourcemaps/debugId.ts index 6c35619e39..26a1ee83b4 100644 --- a/packages/base/src/commands/sourcemaps/debugId.ts +++ b/packages/base/src/commands/sourcemaps/debugId.ts @@ -2,7 +2,8 @@ import fs from 'fs' import type {Sourcemap} from './interfaces' -const DD_DEBUG_ID_REGEX = /["']ddDebugId["']\s*:\s*["']([^"']+)["']/ +const DD_DEBUG_ID_REGEX = + /["']ddDebugId["']\s*:\s*["']([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})["']/ export const extractDebugId = (filePath: string): string | undefined => { try { From d5d3088628fd95f36d45b388c94544d35375bc87 Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Tue, 7 Jul 2026 18:10:31 +0200 Subject: [PATCH 5/7] Strict mode check --- .../sourcemaps/__tests__/upload.test.ts | 11 ++++++++++ .../base/src/commands/sourcemaps/upload.ts | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts b/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts index e2c3b856ba..be20d3d1ab 100644 --- a/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts +++ b/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts @@ -121,6 +121,17 @@ describe('upload', () => { expect(command.context.stderr.toString()).toBe('') }) + test('returns false when --debug-id is combined with service/version/path options', () => { + const command = createCommand(SourcemapsUploadCommand) + command['debugId'] = true + command['service'] = 'my-service' + command['releaseVersion'] = '1.0.0' + command['projectPath'] = 'src' + expect(command['validateOptions']()).toBe(false) + const stderr = command.context.stderr.toString() + expect(stderr).toContain('--service, --release-version, --project-path cannot be used with --debug-id') + }) + test('returns false when release version is missing', () => { const command = createCommand(SourcemapsUploadCommand) expect(command['validateOptions']()).toBe(false) diff --git a/packages/base/src/commands/sourcemaps/upload.ts b/packages/base/src/commands/sourcemaps/upload.ts index 509d7daa98..da26bcc6d2 100644 --- a/packages/base/src/commands/sourcemaps/upload.ts +++ b/packages/base/src/commands/sourcemaps/upload.ts @@ -378,6 +378,28 @@ export class SourcemapsUploadCommand extends BaseCommand { private validateOptions(): boolean { if (this.debugId) { + const conflicting: string[] = [] + + if (this.service !== undefined) { + conflicting.push('--service') + } + + if (this.releaseVersion !== undefined) { + conflicting.push('--release-version') + } + + if (this.projectPath !== undefined) { + conflicting.push('--project-path') + } + + if (conflicting.length > 0) { + this.context.stderr.write( + `${conflicting.join(', ')} cannot be used with --debug-id. Sourcemaps are matched by debug ID in this mode.\n` + ) + + return false + } + return true } From cadcb9290a2e3e6f8899926c7277e2b8f8b6ec5b Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Thu, 16 Jul 2026 11:47:16 +0200 Subject: [PATCH 6/7] fix(sourcemaps): allow --project-path with --debug-id, forbid --minified-path-prefix instead Copilot review flagged --project-path as wrongly rejected in debug-id mode; it's still used and sent in the event payload. --minified-path-prefix is the option that actually conflicts, per the PR description. --- .../sourcemaps/__tests__/upload.test.ts | 25 ++++++++++--------- .../base/src/commands/sourcemaps/upload.ts | 4 +-- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts b/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts index be20d3d1ab..7dc6ddffc3 100644 --- a/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts +++ b/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts @@ -121,15 +121,23 @@ describe('upload', () => { expect(command.context.stderr.toString()).toBe('') }) - test('returns false when --debug-id is combined with service/version/path options', () => { + test('returns false when --debug-id is combined with service/version/minified-path-prefix options', () => { const command = createCommand(SourcemapsUploadCommand) command['debugId'] = true command['service'] = 'my-service' command['releaseVersion'] = '1.0.0' - command['projectPath'] = 'src' + command['minifiedPathPrefix'] = 'https://static.example.com/js' expect(command['validateOptions']()).toBe(false) const stderr = command.context.stderr.toString() - expect(stderr).toContain('--service, --release-version, --project-path cannot be used with --debug-id') + expect(stderr).toContain('--service, --release-version, --minified-path-prefix cannot be used with --debug-id') + }) + + test('returns true when --debug-id is combined with --project-path', () => { + const command = createCommand(SourcemapsUploadCommand) + command['debugId'] = true + command['projectPath'] = 'src' + expect(command['validateOptions']()).toBe(true) + expect(command.context.stderr.toString()).toBe('') }) test('returns false when release version is missing', () => { @@ -293,14 +301,7 @@ describe('execute', () => { '--dry-run', ]) - const runCLIWithDebugId = makeRunCLI(SourcemapsUploadCommand, [ - 'sourcemaps', - 'upload', - '--debug-id', - '--minified-path-prefix', - 'https://static.com/js', - '--dry-run', - ]) + const runCLIWithDebugId = makeRunCLI(SourcemapsUploadCommand, ['sourcemaps', 'upload', '--debug-id', '--dry-run']) test('debug id', async () => { const {context, code} = await runCLIWithDebugId([ @@ -308,7 +309,7 @@ describe('execute', () => { ]) expect(code).toBe(0) expect(context.stdout.toString()).toContain( - '[DRYRUN] Uploading sourcemap src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js.map for JS file available at https://static.com/js/common.min.js (debug ID: 2f1d7f52-4e1b-4f7c-8c0d-2f4a5f6d8e91)' + '[DRYRUN] Uploading sourcemap src/commands/sourcemaps/__tests__/fixtures/bundle-with-debug-id/common.min.js.map for JS file available at common.min.js (debug ID: 2f1d7f52-4e1b-4f7c-8c0d-2f4a5f6d8e91)' ) }) diff --git a/packages/base/src/commands/sourcemaps/upload.ts b/packages/base/src/commands/sourcemaps/upload.ts index da26bcc6d2..f11c8a1fae 100644 --- a/packages/base/src/commands/sourcemaps/upload.ts +++ b/packages/base/src/commands/sourcemaps/upload.ts @@ -388,8 +388,8 @@ export class SourcemapsUploadCommand extends BaseCommand { conflicting.push('--release-version') } - if (this.projectPath !== undefined) { - conflicting.push('--project-path') + if (this.minifiedPathPrefix !== undefined) { + conflicting.push('--minified-path-prefix') } if (conflicting.length > 0) { From d0191ae51d9d6c3a669b58dd1b85cdc9f3bad1aa Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Thu, 16 Jul 2026 11:52:10 +0200 Subject: [PATCH 7/7] fix(sourcemaps): don't report missing debug ID when no sourcemaps were found addDebugIdToPayloads([]) returns false, so --debug-id mode wrongly exited 1 with a misleading "No debug ID found" error when zero sourcemaps matched, unlike non-debug-id mode which exits 0 in that case. --- .../base/src/commands/sourcemaps/__tests__/upload.test.ts | 6 ++++++ packages/base/src/commands/sourcemaps/upload.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts b/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts index 7dc6ddffc3..28d48a315b 100644 --- a/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts +++ b/packages/base/src/commands/sourcemaps/__tests__/upload.test.ts @@ -320,6 +320,12 @@ describe('execute', () => { expect(context.stdout.toString()).not.toContain('[DRYRUN] Uploading sourcemap') }) + test('debug id with no sourcemaps found succeeds with exit 0', async () => { + const {context, code} = await runCLIWithDebugId(['./src/commands/sourcemaps/__tests__/fixtures/doesnotexist']) + expect(code).toBe(0) + expect(context.stderr.toString()).not.toContain('No debug ID found') + }) + test('debug id missing in some files skips only those files', async () => { const {context, code} = await runCLIWithDebugId([ './src/commands/sourcemaps/__tests__/fixtures/bundle-with-partial-debug-id', diff --git a/packages/base/src/commands/sourcemaps/upload.ts b/packages/base/src/commands/sourcemaps/upload.ts index f11c8a1fae..943ac3b080 100644 --- a/packages/base/src/commands/sourcemaps/upload.ts +++ b/packages/base/src/commands/sourcemaps/upload.ts @@ -140,7 +140,7 @@ export class SourcemapsUploadCommand extends BaseCommand { const initialTime = Date.now() const payloads = await this.getPayloadsToUpload(useGit) - if (this.debugId && !addDebugIdToPayloads(payloads)) { + if (this.debugId && payloads.length > 0 && !addDebugIdToPayloads(payloads)) { this.context.stderr.write(renderNoDebugIdFound()) return 1