Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions packages/base/src/commands/sourcemaps/__tests__/debugId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import fs from 'fs'

import {addDebugIdToPayloads, extractDebugId} from '../debugId'
import {Sourcemap} from '../interfaces'

const DEBUG_ID = '2f1d7f52-4e1b-4f7c-8c0d-2f4a5f6d8e91'

const makeSourcemap = (minifiedFilePath: string) =>
new Sourcemap(minifiedFilePath, `https://static.com/${minifiedFilePath}`, `${minifiedFilePath}.map`, minifiedFilePath)

// Mocks fs.readFileSync to return the given content keyed by minified file path.
const mockFilesByPath = (contentByPath: Record<string, string>) => {
jest.spyOn(fs, 'readFileSync').mockImplementation((path: unknown) => {
const content = contentByPath[path as string]
if (content === undefined) {
throw new Error(`ENOENT: ${String(path)}`)
}

return content
})
}

describe('extractDebugId', () => {
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) => {
jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(content)
expect(extractDebugId('bundle.js')).toBe(DEBUG_ID)
})
})

describe('missing or unreadable', () => {
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 when file cannot be read', () => {
jest.spyOn(fs, 'readFileSync').mockImplementationOnce(() => {
throw new Error('ENOENT: no such file or directory')
})
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)
})
})

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

86 changes: 86 additions & 0 deletions packages/base/src/commands/sourcemaps/__tests__/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,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 = {}
Expand Down Expand Up @@ -234,6 +282,44 @@ 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('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')
Expand Down
33 changes: 33 additions & 0 deletions packages/base/src/commands/sourcemaps/debugId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import fs from 'fs'

import type {Sourcemap} from './interfaces'

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 {
const source = fs.readFileSync(filePath, 'utf-8')

return source.match(DD_DEBUG_ID_REGEX)?.[1]
Comment thread
buranmert marked this conversation as resolved.
} 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
}
}

return hasAnyDebugId
}
32 changes: 17 additions & 15 deletions packages/base/src/commands/sourcemaps/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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
Expand All @@ -26,14 +28,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<string, MultipartValue>([
['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'}}],
])
Expand All @@ -53,20 +50,17 @@ export class Sourcemap {
}
}

private getMetadataPayload(
cliVersion: string,
service: string,
version: string,
projectPath: string
): MultipartValue {
private getMetadataPayload({cliVersion, service, version, projectPath}: SourcemapUploadOptions): MultipartValue {
const metadata: {[k: string]: any} = {
cli_version: cliVersion,
minified_url: this.minifiedUrl,
project_path: projectPath,
service,
type: 'js_sourcemap',
service,
version,
minified_url: this.minifiedUrl,
debug_id: this.debugId,
Comment thread
amortemousque marked this conversation as resolved.
}

if (this.gitData !== undefined) {
metadata.git_repository_url = this.gitData.gitRepositoryURL
metadata.git_commit_sha = this.gitData.gitCommitSha
Expand All @@ -83,6 +77,14 @@ export class Sourcemap {
}
}

export interface SourcemapUploadOptions {
cliVersion: string
context: CommandContext
projectPath?: string
service?: string
version?: string
}

export interface GitData {
gitCommitSha: string
gitRepositoryPayload?: string
Expand Down
46 changes: 26 additions & 20 deletions packages/base/src/commands/sourcemaps/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}]`

Expand Down Expand Up @@ -108,36 +110,40 @@ 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
) => {
let fullStr = ''
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 += minifiedPathPrefixStr
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 serviceVersionProjectPathStr =
[
`${chalk.green('Version:')} ${chalk.cyan(releaseVersion)}`,
`${chalk.green('Service:')} ${chalk.cyan(service)}`,
`${chalk.green('Project path:')} ${projectPath !== undefined ? chalk.cyan(projectPath) : chalk.dim('<empty>')}`,
].join(' · ') + '\n\n'
fullStr += serviceVersionProjectPathStr
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('<empty>')}`
)
fullStr += metaParts.join(' · ') + '\n\n'

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): string => {
const debugIdSuffix = sourcemap.debugId ? ` (debug ID: ${sourcemap.debugId})` : ''

return `Uploading sourcemap ${sourcemap.sourcemapPath} for JS file available at ${sourcemap.minifiedUrl}${debugIdSuffix}\n`
}
Loading
Loading