dd-trace
Version:
Datadog APM tracing client for JavaScript
1,494 lines (1,351 loc) • 67.3 kB
JavaScript
'use strict'
/* eslint-disable eslint-rules/eslint-process-env */
const fs = require('node:fs')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const satisfies = require('../vendor/dist/semifies')
const { DD_MAJOR, VERSION } = require('../version')
const MAX_TEXT_FILE_SIZE = 512 * 1024
const MAX_SCANNED_FILES = 1500
const MAX_SCANNED_TEXT_BYTES = 32 * 1024 * 1024
const PACKAGE_SECTIONS = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
]
const SKIPPED_DIRECTORIES = new Set([
'.cache',
'.git',
'.hg',
'.next',
'.nuxt',
'.output',
'.parcel-cache',
'.serverless',
'.svn',
'.turbo',
'.yarn',
'build',
'coverage',
'dist',
'node_modules',
'out',
'target',
'tmp',
'vendor',
])
const SKIPPED_FILES = new Set([
'ci/diagnose.js',
])
const TEXT_EXTENSIONS = new Set([
'.cjs',
'.cts',
'.js',
'.json',
'.mjs',
'.mts',
'.sh',
'.ts',
'.tsx',
'.yaml',
'.yml',
])
const TEXT_FILE_NAMES = new Set([
'Dockerfile',
'Jenkinsfile',
'Makefile',
'docker-compose.yml',
'docker-compose.yaml',
'package.json',
])
const NODE_OPTIONS_RE = /\bNODE_OPTIONS\b/
const INIT_PRELOAD_TARGET =
String.raw`(?:dd-trace\/ci\/init|(?:[^\s'"]*[\/\\])?node_modules[\/\\]dd-trace[\/\\]ci[\/\\]init|\.\/ci\/init)`
const INIT_PRELOAD_RE =
new RegExp(String.raw`(?:^|[\s='"])(?:-r|--require)(?:=|\s+)['"]?${INIT_PRELOAD_TARGET}(?:\.js)?['"]?(?=$|\s|["'])`)
const REGISTER_PRELOAD_RE =
/(?:^|[\s='"])(?:--import|-r|--require)(?:=|\s+)['"]?dd-trace\/register(?:\.js)?['"]?(?=$|\s|["'])/
const WRONG_INIT_RE = /dd-trace\/(?:init|initialize\.mjs)\b|require\(['"]dd-trace['"]\)\.init\s*\(/
const DIRECT_CI_INIT_RE = /(?:require\(|import\s+)['"]dd-trace\/ci\/init(?:\.js)?['"]/
const CI_DISABLED_RE = /DD_CIVISIBILITY_ENABLED["'\s:=]+(?:false|0)\b/i
const ITR_DISABLED_RE = /DD_CIVISIBILITY_ITR_ENABLED["'\s:=]+(?:false|0)\b/i
const GIT_UPLOAD_DISABLED_RE = /DD_CIVISIBILITY_GIT_UPLOAD_ENABLED["'\s:=]+(?:false|0)\b/i
const AGENTLESS_ENABLED_RE = /DD_CIVISIBILITY_AGENTLESS_ENABLED["'\s:=]+(?:true|1)\b/i
const API_KEY_RE = /\b(?:DD_API_KEY|DATADOG_API_KEY)\b/
const SERVICE_RE = /\bDD_SERVICE\b/
const OTEL_OTLP_RE = /OTEL_TRACES_EXPORTER["'\s:=]+otlp\b/i
const WATCH_MODE_RE = /(?:^|\s)(?:watch|--watch|--watchAll)(?!(?:=false)(?:\s|$))(?:\s|=|$)/
const CYPRESS_MANUAL_PLUGIN_RE = /dd-trace\/ci\/cypress\/(?:plugin|after-run|after-spec)\b/
const CYPRESS_SUPPORT_RE = /dd-trace\/ci\/cypress\/support\b/
const CYPRESS_SUPPORT_DISABLED_RE = /supportFile\s*:\s*false|"supportFile"\s*:\s*false/
const CUCUMBER_PARALLEL_RE =
/\bcucumber(?:-js)?\b[\s\S]{0,200}\s--parallel\b|--parallel\b[\s\S]{0,200}\bcucumber(?:-js)?\b/
const JEST_FORCE_EXIT_RE = /\bforceExit\s*:\s*true\b|--forceExit\b|"forceExit"\s*:\s*true/
const JEST_JASMINE_RE = /jest-jasmine2/
const CURRENT_ENV_PROVIDER_KEYS = [
'GITHUB_ACTIONS',
'GITLAB_CI',
'CIRCLECI',
'JENKINS_URL',
'BUILDKITE',
'TRAVIS',
'TF_BUILD',
'BITBUCKET_BUILD_NUMBER',
'DRONE',
'TEAMCITY_VERSION',
]
/**
* Builds the framework support table for the tracer major version that is running this script.
*
* @param {number} ddMajor dd-trace major version
* @returns {Array<object>} supported framework definitions
*/
function getFrameworkDefinitions (ddMajor) {
return [
{
id: 'jest',
name: 'Jest',
packages: ['jest', '@jest/core'],
commandPatterns: [/\bjest\b/],
configPatterns: [/^jest\.config\./, /^config-jest\./],
supportedRange: ddMajor >= 6 ? '>=28.0.0' : '>=24.8.0',
recommendation: ddMajor >= 6
? 'Upgrade Jest to >=28.0.0, or use dd-trace v5 for older Jest versions.'
: 'Upgrade Jest to >=24.8.0.',
},
{
id: 'mocha',
name: 'Mocha',
packages: ['mocha'],
commandPatterns: [/\bmocha\b/],
configPatterns: [/^\.mocharc\./],
supportedRange: ddMajor >= 6 ? '>=8.0.0' : '>=5.2.0',
recommendation: ddMajor >= 6
? 'Upgrade Mocha to >=8.0.0, or use dd-trace v5 for older Mocha versions.'
: 'Upgrade Mocha to >=5.2.0.',
notes: [
'Impacted tests are detected at suite level for Mocha.',
],
},
{
id: 'cucumber',
name: 'Cucumber',
packages: ['@cucumber/cucumber'],
commandPatterns: [/\bcucumber-js\b/, /\bcucumber\b/],
configPatterns: [/^cucumber\./],
supportedRange: '>=7.0.0',
recommendation: 'Upgrade @cucumber/cucumber to >=7.0.0.',
},
{
id: 'cypress',
name: 'Cypress',
packages: ['cypress'],
commandPatterns: [/\bcypress\s+(?:run|open)\b/],
configPatterns: [/^cypress\.config\./, /^cypress\.json$/],
supportedRange: ddMajor >= 6 ? '>=12.0.0' : '>=6.7.0',
autoInstrumentationRange: ddMajor >= 6 ? '>=12.0.0' : '>=10.2.0',
recommendation: ddMajor >= 6
? 'Upgrade Cypress to >=12.0.0, or use dd-trace v5 for older Cypress versions.'
: 'Upgrade Cypress to >=6.7.0.',
},
{
id: 'playwright',
name: 'Playwright',
packages: ['@playwright/test'],
commandPatterns: [/\bplaywright\s+test\b/],
configPatterns: [/^playwright\.config\./],
supportedRange: ddMajor >= 6 ? '>=1.38.0' : '>=1.18.0',
recommendation: ddMajor >= 6
? 'Upgrade Playwright to >=1.38.0, or use dd-trace v5 for older Playwright versions.'
: 'Upgrade Playwright to >=1.18.0.',
notes: [
'Test Impact Analysis suite skipping is not supported for Playwright.',
'Impacted tests are detected at suite level for Playwright.',
],
},
{
id: 'vitest',
name: 'Vitest',
packages: ['vitest'],
commandPatterns: [/\bvitest\b/],
configPatterns: [/^vitest\.config\./, /^vite\.config\./],
supportedRange: '>=1.6.0',
recommendation: 'Upgrade Vitest to >=1.6.0.',
notes: [
'Test Impact Analysis suite skipping is not supported for Vitest.',
'Impacted tests are detected at suite level for Vitest.',
],
esmInitialization: true,
},
]
}
const UNSUPPORTED_FRAMEWORKS = [
{
id: 'node-test',
name: 'Node.js test runner',
packages: [],
commandPatterns: [/\bnode\s+--test\b/, /\bnode\s+--experimental-test-coverage\b/],
},
{ id: 'ava', name: 'AVA', packages: ['ava'], commandPatterns: [/\bava\b/] },
{ id: 'tap', name: 'tap', packages: ['tap'], commandPatterns: [/\btap\b/] },
{ id: 'jasmine', name: 'Jasmine', packages: ['jasmine'], commandPatterns: [/\bjasmine\b/] },
{ id: 'karma', name: 'Karma', packages: ['karma'], commandPatterns: [/\bkarma\b/] },
{ id: 'uvu', name: 'uvu', packages: ['uvu'], commandPatterns: [/\buvu\b/] },
{
id: 'testcafe',
name: 'TestCafe',
packages: ['testcafe'],
commandPatterns: [/\btestcafe\b/],
},
]
/**
* Runs all static checks for a repository.
*
* @param {object} [options] diagnosis options
* @param {string} [options.root] repository path to inspect
* @param {NodeJS.ProcessEnv} [options.env] environment to inspect
* @param {Function} [options.execFile] command runner used for git checks
* @param {string} [options.gitExecutable] trusted git executable used for git checks
* @param {number} [options.maxFiles] maximum number of text files to scan
* @param {number} [options.maxTotalBytes] maximum aggregate bytes of text files to scan
* @param {string[]} [options.excludePaths] repository paths to exclude from the text scan
* @returns {object} diagnosis report
*/
function runDiagnosis (options = {}) {
const root = path.resolve(options.root || process.cwd())
const physicalRoot = getPhysicalRoot(root)
const env = options.env || process.env
const execFile = options.execFile || execFileSync
const maxFiles = options.maxFiles || MAX_SCANNED_FILES
const maxTotalBytes = options.maxTotalBytes || MAX_SCANNED_TEXT_BYTES
const results = []
const files = collectTextFiles(root, physicalRoot, maxFiles, options.excludePaths)
const textFiles = readTextFiles(root, physicalRoot, files, maxTotalBytes)
const truncatedFileScan = files.truncated || textFiles.truncated
const manifests = readPackageManifests(root, textFiles)
const rootManifest = manifests.find(manifest => manifest.relativePath === 'package.json')
const rootPackageJsonState = getRootPackageJsonState(root, physicalRoot)
const scripts = collectScripts(manifests)
const workflowFiles = textFiles.filter(file => isWorkflowFile(file.relativePath))
const definitions = getFrameworkDefinitions(DD_MAJOR)
const supportedFrameworks = detectSupportedFrameworks(root, definitions, manifests, scripts, textFiles)
const eligibleFrameworks = getEligibleFrameworks(supportedFrameworks)
const unsupportedFrameworks = detectUnsupportedFrameworks(UNSUPPORTED_FRAMEWORKS, manifests, scripts)
const evidence = collectEvidence(textFiles, env)
checkPackageManifest(results, rootManifest, rootPackageJsonState)
checkDdTraceDependency(results, manifests, { root, truncatedFileScan, rootPackageJsonState })
checkSupportedFrameworks(results, supportedFrameworks)
checkUnsupportedFrameworks(results, unsupportedFrameworks, supportedFrameworks)
checkInitialization(results, supportedFrameworks, evidence, env)
checkFrameworkConfiguration(results, supportedFrameworks, evidence, textFiles, manifests)
checkCiConfiguration(results, workflowFiles, evidence, env)
const gitExecutable = options.gitExecutable || (options.execFile ? 'git' : findTrustedGitExecutable())
checkGit(results, root, env, execFile, gitExecutable)
checkCurrentEnvironment(results, env, evidence)
return {
root,
ddTraceVersion: VERSION,
ddTraceMajor: DD_MAJOR,
scannedFileCount: textFiles.length,
truncatedFileScan,
supportedFrameworks: supportedFrameworks.map(serializeSupportedFramework),
eligibleFrameworks: eligibleFrameworks.map(serializeEligibleFramework),
unsupportedFrameworks: unsupportedFrameworks.map(serializeUnsupportedFramework),
results,
}
}
/**
* Adds a normalized result to the list.
*
* @param {Array<object>} results mutable result list
* @param {string} status result status
* @param {string} title short title
* @param {string} message result details
* @param {object} [extra] optional fields
*/
function addResult (results, status, title, message, extra = {}) {
results.push({
status,
title,
message,
...extra,
})
}
/**
* Reads package.json files collected by the repository scan.
*
* @param {string} root repository root
* @param {Array<object>} textFiles scanned text files
* @returns {Array<object>} parsed package manifests
*/
function readPackageManifests (root, textFiles) {
const manifests = []
for (const file of textFiles) {
if (path.basename(file.relativePath) !== 'package.json') continue
const json = parseJson(file.content)
if (!json) continue
manifests.push({
path: path.join(root, file.relativePath),
relativePath: file.relativePath,
json,
})
}
return manifests
}
/**
* Checks whether the repository root has a package.json independently from the text-file scan.
*
* @param {string} root repository root
* @param {string|undefined} physicalRoot physical repository root
* @returns {{ exists: boolean }} root package.json state
*/
function getRootPackageJsonState (root, physicalRoot) {
return {
exists: Boolean(getSafeScannedFile(root, physicalRoot, 'package.json')),
}
}
/**
* Checks that a root package.json exists.
*
* @param {Array<object>} results mutable result list
* @param {object|undefined} rootManifest parsed root package manifest
* @param {object} rootPackageJsonState root package.json filesystem state
*/
function checkPackageManifest (results, rootManifest, rootPackageJsonState) {
if (rootManifest) {
addResult(results, 'ok', 'Root package.json found', 'Dependency and script checks can inspect package metadata.')
return
}
if (rootPackageJsonState.exists) {
addResult(
results,
'warning',
'Root package.json not determined',
'A root package.json file exists, but static diagnosis could not parse it as scanned text.',
{
recommendation:
'Check whether package.json is readable UTF-8 JSON and smaller than the static diagnosis file limit.',
}
)
return
}
addResult(
results,
'warning',
'No root package.json found',
'The diagnosis could not inspect root dependencies or package scripts.',
{ recommendation: 'Run this script from the JavaScript repository root, or pass --path <repository>.' }
)
}
/**
* Checks whether dd-trace is declared in repository manifests.
*
* @param {Array<object>} results mutable result list
* @param {Array<object>} manifests package manifests
* @param {object} options dependency check options
* @param {string} [options.root] repository root
* @param {boolean} [options.truncatedFileScan] whether static file discovery was truncated
* @param {object} [options.rootPackageJsonState] root package manifest state
*/
function checkDdTraceDependency (results, manifests, options = {}) {
const entries = findDependencyEntries(manifests, ['dd-trace'])
if (entries.length) {
addResult(
results,
'ok',
'dd-trace dependency found',
`Detected dd-trace in ${formatLocations(entries.map(entry => entry.relativePath))}.`
)
return
}
const installedPackageJson = options.root && path.join(options.root, 'node_modules', 'dd-trace', 'package.json')
let installed = false
try {
installed = installedPackageJson && fs.statSync(installedPackageJson).isFile()
} catch {}
if (installed) {
addResult(
results,
'ok',
'dd-trace package installed',
'Detected an installed dd-trace package even though it was not declared in the scanned package manifests.'
)
return
}
if (options.truncatedFileScan || (options.rootPackageJsonState?.exists && !hasParsedRootManifest(manifests))) {
addResult(
results,
'warning',
'dd-trace dependency not determined',
'Static diagnosis did not confirm a dd-trace dependency, but the file scan was incomplete or root package ' +
'metadata could not be parsed.',
{ recommendation: 'Inspect the package that runs tests and install dd-trace there if it is absent.' }
)
return
}
addResult(
results,
'warning',
'dd-trace dependency not found in package.json',
'The script did not find dd-trace in dependencies or devDependencies.',
{ recommendation: 'Install dd-trace in the project that runs the tests.' }
)
}
/**
* Checks whether static diagnosis parsed the repository root package manifest.
*
* @param {Array<object>} manifests parsed package manifests
* @returns {boolean} true when the root package.json was parsed
*/
function hasParsedRootManifest (manifests) {
return manifests.some(manifest => manifest.relativePath === 'package.json')
}
/**
* Checks supported framework detections and versions.
*
* @param {Array<object>} results mutable result list
* @param {Array<object>} frameworks detected supported frameworks
*/
function checkSupportedFrameworks (results, frameworks) {
if (!frameworks.length) {
addResult(
results,
'warning',
'No supported test framework detected',
'No supported Test Optimization framework was found in dependencies, scripts, or config files.',
{
recommendation:
'Use Jest, Mocha, Cucumber, Cypress, Playwright, or Vitest with a supported version.',
}
)
return
}
for (const framework of frameworks) {
if (!framework.versionDetections.length) {
addResult(
results,
'warning',
`${framework.name} detected but version is unknown`,
`${framework.name} appears in scripts or config, but no package version could be determined.`,
{
locations: framework.locations,
recommendation:
`Ensure ${framework.packages.join(' or ')} is installed and matches ${framework.supportedRange}.`,
}
)
}
for (const detection of framework.versionDetections) {
if (!detection.version) {
addResult(
results,
'warning',
`${framework.name} version could not be determined`,
`Detected ${detection.packageName}@${detection.rawVersion}, but the version is not statically comparable.`,
{
locations: [detection.relativePath],
recommendation: `Verify ${framework.name} satisfies ${framework.supportedRange}.`,
}
)
continue
}
const status = satisfies(detection.version, framework.supportedRange) ? 'ok' : 'error'
const source = detection.source === 'installed' ? 'installed package' : 'package manifest'
addResult(
results,
status,
`${framework.name} ${detection.version} ${status === 'ok' ? 'is supported' : 'is not supported'}`,
`Detected ${detection.packageName}@${detection.rawVersion} from ${source}; supported range is ` +
`${framework.supportedRange}.`,
{
locations: detection.relativePath ? [detection.relativePath] : undefined,
recommendation: status === 'error' ? framework.recommendation : undefined,
}
)
}
for (const note of framework.notes || []) {
addResult(results, 'info', `${framework.name} capability note`, note)
}
}
}
/**
* Checks unsupported framework detections.
*
* @param {Array<object>} results mutable result list
* @param {Array<object>} unsupported detected unsupported frameworks
* @param {Array<object>} supported detected supported frameworks
*/
function checkUnsupportedFrameworks (results, unsupported, supported) {
for (const framework of unsupported) {
const status = supported.length ? 'warning' : 'error'
addResult(
results,
status,
`${framework.name} is not supported by Test Optimization`,
`${framework.name} was detected in dependencies or test scripts.`,
{
locations: framework.locations,
recommendation:
'Use a supported JavaScript test framework for automatic Test Optimization instrumentation.',
}
)
}
}
/**
* Checks Test Optimization initialization.
*
* @param {Array<object>} results mutable result list
* @param {Array<object>} frameworks detected supported frameworks
* @param {object} evidence repository evidence
* @param {NodeJS.ProcessEnv} env environment
*/
function checkInitialization (results, frameworks, evidence, env) {
if (!frameworks.length) return
const hasCiInit = evidence.hasCiInit || hasCiInitInNodeOptions(env.NODE_OPTIONS)
const hasCypressOnly = frameworks.length === 1 && frameworks[0].id === 'cypress'
const hasCypressManualPlugin = evidence.cypressManualPluginLocations.length > 0
if (hasCiInit) {
addResult(
results,
'ok',
'Test Optimization initialization found',
'Found dd-trace/ci/init preloaded through NODE_OPTIONS in repository files or the current environment.',
{ locations: evidence.ciInitLocations }
)
} else if (hasCypressOnly && hasCypressManualPlugin) {
addResult(
results,
'ok',
'Cypress manual plugin initialization found',
'Found the Cypress-specific dd-trace Test Optimization plugin setup.',
{ locations: evidence.cypressManualPluginLocations }
)
} else {
addResult(
results,
'error',
'Missing Test Optimization initialization',
'No NODE_OPTIONS preload for dd-trace/ci/init was found in repository files or the current environment.',
{
recommendation:
'Run tests with NODE_OPTIONS="-r dd-trace/ci/init". For ESM test runners, also include ' +
'--import dd-trace/register.js.',
}
)
}
if (evidence.directCiInitLocations.length) {
addResult(
results,
'error',
'Test Optimization initialization is imported directly',
'The diagnosis found require("dd-trace/ci/init") or import "dd-trace/ci/init". ' +
'That does not preload the tracer early enough for Test Optimization setup.',
{
locations: evidence.directCiInitLocations,
recommendation: 'Set NODE_OPTIONS="-r dd-trace/ci/init" on the test process instead.',
}
)
}
if (evidence.wrongInitLocations.length) {
addResult(
results,
'error',
'Plain dd-trace initialization found in test setup',
'The diagnosis found dd-trace/init, dd-trace/initialize.mjs, or require("dd-trace").init(). ' +
'That does not initialize the tracer in Test Optimization mode.',
{
locations: evidence.wrongInitLocations,
recommendation: 'Use dd-trace/ci/init for test commands instead of the plain tracing initializer.',
}
)
}
if (frameworks.some(framework => framework.esmInitialization) && hasCiInit && !evidence.hasRegister &&
!hasRegisterInNodeOptions(env.NODE_OPTIONS)) {
addResult(
results,
'warning',
'ESM loader registration not found',
'Vitest and other ESM-heavy test runners often need dd-trace/register.js before dd-trace/ci/init.',
{
recommendation:
'Use NODE_OPTIONS="--import dd-trace/register.js -r dd-trace/ci/init" for ESM test runs.',
}
)
}
}
/**
* Checks framework-specific configuration pitfalls.
*
* @param {Array<object>} results mutable result list
* @param {Array<object>} frameworks detected supported frameworks
* @param {object} evidence repository evidence
* @param {Array<object>} textFiles scanned text files
* @param {Array<object>} manifests package manifests
*/
function checkFrameworkConfiguration (results, frameworks, evidence, textFiles, manifests) {
if (hasFramework(frameworks, 'vitest') && !hasFramework(frameworks, 'playwright') &&
findDependencyEntries(manifests, ['playwright']).length > 0) {
addResult(
results,
'info',
'Playwright package is not a Playwright Test runner',
'The repository uses Vitest and has the playwright package, but no @playwright/test runner was detected. ' +
'Treat Playwright as Vitest browser-provider infrastructure, not as another test framework.',
{}
)
}
if (hasFramework(frameworks, 'cypress')) {
checkCypressConfiguration(results, evidence)
}
if (hasFramework(frameworks, 'jest')) {
const jestLocations = findLocations(textFiles, JEST_FORCE_EXIT_RE)
if (jestLocations.length) {
addResult(
results,
'warning',
'Jest forceExit can drop Test Optimization data',
'Jest\'s forceExit option can terminate before dd-trace flushes all test data.',
{
locations: jestLocations,
recommendation: 'Remove --forceExit or forceExit: true from Jest configuration when possible.',
}
)
}
const jasmineLocations = findLocations(textFiles, JEST_JASMINE_RE)
if (jasmineLocations.length) {
addResult(
results,
'info',
'Jest is configured with jest-jasmine2',
'dd-trace can avoid crashing with jest-jasmine2, but jest-circus is the better-supported runner.',
{
locations: jasmineLocations,
recommendation: 'Prefer the default jest-circus runner on supported Jest versions.',
}
)
}
const tsConfigLocations = findJestTypescriptConfigLocations(textFiles)
const hasTsNode = findDependencyEntries(manifests, ['ts-node']).length > 0
if (tsConfigLocations.length && !hasTsNode) {
addResult(
results,
'warning',
'Jest TypeScript config may need ts-node',
'Jest loads TypeScript configuration files before test transforms. Without ts-node or an equivalent ' +
'precompiled config, the selected command can fail before collecting tests.',
{
locations: tsConfigLocations,
recommendation:
'Install ts-node for the diagnostic run, or use a temporary JSON/CommonJS Jest config generated ' +
'from the repository config.',
}
)
}
}
if (hasFramework(frameworks, 'cucumber')) {
const cucumber = frameworks.find(framework => framework.id === 'cucumber')
const parallelLocations = findLocations(textFiles, CUCUMBER_PARALLEL_RE)
const hasOldParallel = cucumber.versionDetections.some(detection =>
detection.version && !satisfies(detection.version, '>=11.0.0')
)
if (parallelLocations.length && hasOldParallel) {
addResult(
results,
'warning',
'Cucumber parallel mode has feature limits before version 11',
'Some Test Optimization features for Cucumber parallel mode require @cucumber/cucumber >=11.0.0.',
{
locations: parallelLocations,
recommendation: 'Upgrade @cucumber/cucumber to >=11.0.0 when using --parallel.',
}
)
}
}
}
/**
* Checks Cypress-specific setup.
*
* @param {Array<object>} results mutable result list
* @param {object} evidence repository evidence
*/
function checkCypressConfiguration (results, evidence) {
if (evidence.cypressSupportDisabledLocations.length) {
addResult(
results,
'warning',
'Cypress support file is disabled',
'Cypress browser-side hooks cannot be injected when supportFile is false.',
{
locations: evidence.cypressSupportDisabledLocations,
recommendation: 'Use a Cypress support file, or manually require dd-trace/ci/cypress/support.',
}
)
return
}
if (evidence.cypressSupportLocations.length) {
addResult(
results,
'ok',
'Cypress support hook found',
'Found dd-trace/ci/cypress/support in the repository.',
{ locations: evidence.cypressSupportLocations }
)
} else {
addResult(
results,
'info',
'Cypress support hook not explicitly configured',
'For supported Cypress versions, dd-trace can inject a temporary support wrapper when dd-trace/ci/init is used.',
{
recommendation:
'If browser-side test events are missing, add require("dd-trace/ci/cypress/support") to the ' +
'Cypress support file.',
}
)
}
}
/**
* Checks static CI workflow files.
*
* @param {Array<object>} results mutable result list
* @param {Array<object>} workflowFiles scanned CI workflow files
* @param {object} evidence repository evidence
* @param {NodeJS.ProcessEnv} env environment
*/
function checkCiConfiguration (results, workflowFiles, evidence, env) {
if (!workflowFiles.length) {
addResult(
results,
'info',
'No CI workflow files found',
'The diagnosis did not find common CI configuration files to inspect.'
)
return
}
addResult(
results,
'ok',
'CI workflow files found',
`Inspected ${workflowFiles.length} CI workflow file(s).`,
{ locations: workflowFiles.map(file => file.relativePath) }
)
if (!evidence.hasCiInit && !hasCiInitInNodeOptions(env.NODE_OPTIONS)) {
addResult(
results,
'warning',
'CI workflows do not show Test Optimization initialization',
'No CI workflow file shows NODE_OPTIONS preloading dd-trace/ci/init.',
{
recommendation:
'Set NODE_OPTIONS="-r dd-trace/ci/init" in the CI job that runs the supported JavaScript test framework.',
}
)
}
const shallowGithubLocations = []
const containerGithubLocations = []
for (const file of workflowFiles) {
if (!file.relativePath.startsWith('.github/workflows/')) continue
if (/actions\/checkout/.test(file.content) && !/fetch-depth\s*:\s*0\b/.test(file.content)) {
shallowGithubLocations.push(file.relativePath)
}
if (/\bcontainer\s*:/.test(file.content) && !/safe\.directory/.test(file.content)) {
containerGithubLocations.push(file.relativePath)
}
}
if (shallowGithubLocations.length) {
addResult(
results,
'warning',
'GitHub Actions checkout may be shallow',
'actions/checkout defaults to a shallow checkout, which can limit git metadata and impacted-test detection.',
{
locations: shallowGithubLocations,
recommendation: 'Set fetch-depth: 0 for the checkout step, or keep git unshallowing enabled.',
}
)
}
if (containerGithubLocations.length) {
addResult(
results,
'info',
'Containerized GitHub jobs may need Git safe.directory',
'Git can reject metadata commands in containerized jobs when checkout ownership differs from the container user.',
{
locations: containerGithubLocations,
recommendation: 'Run git config --global --add safe.directory "$GITHUB_WORKSPACE" when needed.',
}
)
}
if (evidence.hasAgentlessEnabled && !evidence.hasApiKey && !env.DD_API_KEY && !env.DATADOG_API_KEY) {
addResult(
results,
'warning',
'Agentless mode is enabled but no API key reference was found',
'DD_CIVISIBILITY_AGENTLESS_ENABLED requires DD_API_KEY or DATADOG_API_KEY at runtime.',
{
recommendation: 'Provide DD_API_KEY or DATADOG_API_KEY as a CI secret in the test job.',
}
)
}
if (evidence.gitStrategyNoneLocations.length) {
addResult(
results,
'warning',
'CI configuration disables git checkout',
'Git metadata extraction cannot work when the CI job does not check out the repository.',
{
locations: evidence.gitStrategyNoneLocations,
recommendation: 'Enable repository checkout for Test Optimization jobs.',
}
)
}
}
/**
* Checks local git availability and repository metadata.
*
* @param {Array<object>} results mutable result list
* @param {string} root repository root
* @param {NodeJS.ProcessEnv} env environment
* @param {Function} execFile command runner
* @param {string|undefined} gitExecutable trusted git executable
*/
function checkGit (results, root, env, execFile, gitExecutable) {
const gitEnv = getGitEnvironment(gitExecutable, env)
if (!canRunGit(execFile, root, gitExecutable, gitEnv)) {
addResult(
results,
'error',
'git executable is not available',
'Test Optimization uses git to extract repository metadata and impacted files.',
{ recommendation: 'Install git in the CI image or runner that executes tests.' }
)
return
}
addResult(results, 'ok', 'git executable found', 'The current environment can execute git.')
const insideWorktree = runGit(execFile, root, gitExecutable, gitEnv, ['rev-parse', '--is-inside-work-tree'])
if (insideWorktree !== 'true') {
addResult(
results,
'warning',
'Current path is not inside a git worktree',
'Git metadata extraction needs the checked-out repository.',
{ recommendation: 'Run the test command from inside the checked-out repository.' }
)
return
}
const head = runGit(execFile, root, gitExecutable, gitEnv, ['rev-parse', 'HEAD'])
const remote = runGit(execFile, root, gitExecutable, gitEnv, ['config', '--get', 'remote.origin.url'])
const branch = runGit(execFile, root, gitExecutable, gitEnv, ['branch', '--show-current'])
const shallow = runGit(execFile, root, gitExecutable, gitEnv, ['rev-parse', '--is-shallow-repository'])
if (head) {
addResult(results, 'ok', 'git commit SHA detected', `Current HEAD is ${head.slice(0, 12)}.`)
} else {
addResult(
results,
'warning',
'git commit SHA could not be detected',
'The diagnosis could not read git rev-parse HEAD.',
{ recommendation: 'Ensure the CI checkout includes a valid git repository.' }
)
}
if (!remote) {
addResult(
results,
'warning',
'git remote origin is not configured',
'Repository URL metadata may be missing.',
{ recommendation: 'Configure remote.origin.url or provide DD_GIT_REPOSITORY_URL.' }
)
}
if (!branch && !hasBranchMetadata(env)) {
addResult(
results,
'warning',
'git branch metadata could not be detected',
'The checkout appears detached and no CI branch metadata was found in the current environment.',
{ recommendation: 'Provide branch metadata through CI provider variables or DD_GIT_BRANCH.' }
)
}
if (shallow === 'true') {
addResult(
results,
'warning',
'Repository is shallow',
'A shallow repository can limit metadata upload and impacted-test detection.',
{ recommendation: 'Use a full checkout, or keep DD_CIVISIBILITY_GIT_UNSHALLOW_ENABLED enabled.' }
)
}
}
/**
* Checks current environment variables relevant to Test Optimization.
*
* @param {Array<object>} results mutable result list
* @param {NodeJS.ProcessEnv} env environment
* @param {object} evidence repository evidence
*/
function checkCurrentEnvironment (results, env, evidence) {
if (isFalseLike(env.DD_CIVISIBILITY_ENABLED) || evidence.hasCiVisibilityDisabled) {
addResult(
results,
'error',
'Test Optimization is explicitly disabled',
'DD_CIVISIBILITY_ENABLED is set to false or 0.',
{ recommendation: 'Remove DD_CIVISIBILITY_ENABLED=false from the test job.' }
)
}
if (isFalseLike(env.DD_CIVISIBILITY_ITR_ENABLED) || evidence.hasItrDisabled) {
addResult(
results,
'warning',
'Test Impact Analysis is disabled',
'DD_CIVISIBILITY_ITR_ENABLED is set to false or 0.',
{ recommendation: 'Remove DD_CIVISIBILITY_ITR_ENABLED=false if suite skipping should be enabled.' }
)
}
if (isFalseLike(env.DD_CIVISIBILITY_GIT_UPLOAD_ENABLED) || evidence.hasGitUploadDisabled) {
addResult(
results,
'warning',
'Git metadata upload is disabled',
'DD_CIVISIBILITY_GIT_UPLOAD_ENABLED is set to false or 0.',
{ recommendation: 'Remove DD_CIVISIBILITY_GIT_UPLOAD_ENABLED=false unless this is intentional.' }
)
}
if (isTrueLike(env.DD_CIVISIBILITY_AGENTLESS_ENABLED) && !env.DD_API_KEY && !env.DATADOG_API_KEY) {
addResult(
results,
'error',
'Agentless mode is missing an API key',
'The current environment has DD_CIVISIBILITY_AGENTLESS_ENABLED set, but no DD_API_KEY or DATADOG_API_KEY.',
{ recommendation: 'Set DD_API_KEY or DATADOG_API_KEY in the CI job.' }
)
}
if (!env.DD_SERVICE && !evidence.hasService) {
addResult(
results,
'warning',
'DD_SERVICE was not found',
'A missing service name makes Test Optimization data harder to find and group.',
{ recommendation: 'Set DD_SERVICE in the test job.' }
)
}
if ((env.OTEL_TRACES_EXPORTER || '').toLowerCase() === 'otlp' || evidence.hasOtelOtlpExporter) {
addResult(
results,
'warning',
'OTEL_TRACES_EXPORTER=otlp was found',
'An OTLP traces exporter can interfere with dd-trace test payloads in instrumented shells.',
{ recommendation: 'Unset OTEL_TRACES_EXPORTER for dd-trace Test Optimization jobs.' }
)
}
if (env.CI) {
checkCurrentCiMetadata(results, env)
} else {
addResult(
results,
'info',
'Current process is not running in CI',
'Runtime CI metadata checks were skipped because CI is not set in the current environment.'
)
}
}
/**
* Checks current CI provider metadata.
*
* @param {Array<object>} results mutable result list
* @param {NodeJS.ProcessEnv} env environment
*/
function checkCurrentCiMetadata (results, env) {
const providerDetected = CURRENT_ENV_PROVIDER_KEYS.some(key => env[key])
if (!providerDetected) {
addResult(
results,
'warning',
'Current CI provider is not recognized',
'The current environment has CI set, but no known CI provider variables were found.',
{ recommendation: 'Provide CI and git metadata with DD_GIT_* variables if the provider is custom.' }
)
}
if (!hasShaMetadata(env)) {
addResult(
results,
'warning',
'Current CI commit SHA metadata is missing',
'The current CI environment does not expose a recognized commit SHA variable.',
{ recommendation: 'Provide DD_GIT_COMMIT_SHA when the CI provider does not expose one.' }
)
}
if (!hasBranchMetadata(env)) {
addResult(
results,
'warning',
'Current CI branch metadata is missing',
'The current CI environment does not expose a recognized branch or tag variable.',
{ recommendation: 'Provide DD_GIT_BRANCH or DD_GIT_TAG when the CI provider does not expose one.' }
)
}
}
/**
* Collects package scripts from all manifests.
*
* @param {Array<object>} manifests package manifests
* @returns {Array<object>} scripts
*/
function collectScripts (manifests) {
const scripts = []
for (const manifest of manifests) {
const manifestScripts = manifest.json.scripts || {}
for (const [name, command] of Object.entries(manifestScripts)) {
if (typeof command !== 'string') continue
scripts.push({
name,
command,
relativePath: manifest.relativePath,
})
}
}
return scripts
}
/**
* Detects supported test frameworks.
*
* @param {string} root repository root
* @param {Array<object>} definitions framework definitions
* @param {Array<object>} manifests package manifests
* @param {Array<object>} scripts package scripts
* @param {Array<object>} textFiles scanned text files
* @returns {Array<object>} detected supported frameworks
*/
function detectSupportedFrameworks (root, definitions, manifests, scripts, textFiles) {
const frameworks = []
for (const definition of definitions) {
const dependencyEntries = findDependencyEntries(manifests, definition.packages)
const scriptMatches = findScriptMatches(scripts, definition.commandPatterns)
const configMatches = findConfigMatches(textFiles, definition.configPatterns)
if (!dependencyEntries.length && !scriptMatches.length && !configMatches.length) continue
frameworks.push({
...definition,
dependencyEntries,
scriptMatches,
configMatches,
locations: unique([
...dependencyEntries.map(entry => entry.relativePath),
...scriptMatches.map(script => script.relativePath),
...configMatches,
]),
versionDetections: getVersionDetections(root, definition.packages, dependencyEntries),
})
}
return frameworks
}
/**
* Gets frameworks that are eligible live-validation candidates.
*
* @param {Array<object>} frameworks detected supported frameworks
* @returns {Array<object>} eligible frameworks with eligibility details
*/
function getEligibleFrameworks (frameworks) {
const eligible = []
for (const framework of frameworks) {
const command = getEligibleCommandMatch(framework)
const version = getSupportedVersionDetection(framework, command?.relativePath)
const reasons = []
if (!version) reasons.push(`No statically supported ${framework.name} version was found.`)
if (!command) reasons.push(`No eligible ${framework.name} test command was found.`)
if (!version || !command) continue
eligible.push({
...framework,
eligibleCommand: command,
eligibleVersion: version,
eligibility: {
command: command.command,
commandLocation: command.relativePath,
version: version.version,
versionLocation: version.relativePath,
},
ineligibleReasons: reasons,
})
}
return eligible
}
/**
* Gets the first supported version detection for a framework.
*
* @param {object} framework detected framework
* @param {string} [relativePath] package manifest that owns the selected command
* @returns {object|undefined} supported version detection
*/
function getSupportedVersionDetection (framework, relativePath) {
const detections = framework.versionDetections || []
const localDetections = relativePath
? detections.filter(detection => detection.relativePath === relativePath)
: []
const candidates = localDetections.length > 0 ? localDetections : detections
for (const detection of candidates) {
if (detection.version && satisfies(detection.version, framework.supportedRange)) return detection
}
}
/**
* Gets an eligible script command for a framework.
*
* @param {object} framework detected framework
* @returns {object|undefined} eligible command match
*/
function getEligibleCommandMatch (framework) {
const scriptMatches = framework.scriptMatches || []
for (const script of scriptMatches) {
if (isIneligibleFrameworkCommand(framework.id, script.command)) continue
return script
}
if (scriptMatches.length > 0) return
if (framework.id === 'jest' || framework.id === 'mocha' || framework.id === 'vitest') {
return framework.dependencyEntries?.[0] && {
command: `direct ${framework.id} binary`,
relativePath: framework.dependencyEntries[0].relativePath,
}
}
}
/**
* Checks whether a framework command is ineligible for live validation.
*
* @param {string} frameworkId framework id
* @param {string} command package script command
* @returns {boolean} whether the command is ineligible
*/
function isIneligibleFrameworkCommand (frameworkId, command) {
if (frameworkId === 'vitest' && /\bvitest\s+bench\b/.test(command)) return true
if (WATCH_MODE_RE.test(command)) return true
return false
}
/**
* Detects unsupported test frameworks.
*
* @param {Array<object>} definitions unsupported framework definitions
* @param {Array<object>} manifests package manifests
* @param {Array<object>} scripts package scripts
* @returns {Array<object>} detected unsupported frameworks
*/
function detectUnsupportedFrameworks (definitions, manifests, scripts) {
const frameworks = []
for (const definition of definitions) {
const dependencyEntries = findDependencyEntries(manifests, definition.packages)
const scriptMatches = findScriptMatches(scripts, definition.commandPatterns)
if (!dependencyEntries.length && !scriptMatches.length) continue
frameworks.push({
...definition,
locations: unique([
...dependencyEntries.map(entry => entry.relativePath),
...scriptMatches.map(script => script.relativePath),
]),
})
}
return frameworks
}
/**
* Collects useful boolean evidence from scanned files and environment.
*
* @param {Array<object>} textFiles scanned text files
* @param {NodeJS.ProcessEnv} env environment
* @returns {object} evidence object
*/
function collectEvidence (textFiles, env) {
const ciInitLocations = findNodeOptionsPreloadLocations(textFiles, INIT_PRELOAD_RE)
const registerLocations = findNodeOptionsPreloadLocations(textFiles, REGISTER_PRELOAD_RE)
return {
ciInitLocations,
directCiInitLocations: findLocations(textFiles, DIRECT_CI_INIT_RE),
wrongInitLocations: findLocations(textFiles.filter(isTestSetupOrCiFile), WRONG_INIT_RE),
cypressManualPluginLocations: findLocations(textFiles, CYPRESS_MANUAL_PLUGIN_RE),
cypressSupportLocations: findLocations(textFiles, CYPRESS_SUPPORT_RE),
cypressSupportDisabledLocations: findLocations(textFiles, CYPRESS_SUPPORT_DISABLED_RE),
gitStrategyNoneLocations: findLocations(textFiles, /GIT_STRATEGY\s*:\s*none\b/i),
hasCiInit: ciInitLocations.length > 0,
hasRegister: registerLocations.length > 0,
hasCiVisibilityDisabled: findLocations(textFiles, CI_DISABLED_RE).length > 0,
hasItrDisabled: findLocations(textFiles, ITR_DISABLED_RE).length > 0,
hasGitUploadDisabled: findLocations(textFiles, GIT_UPLOAD_DISABLED_RE).length > 0,
hasAgentlessEnabled: findLocations(textFiles, AGENTLESS_ENABLED_RE).length > 0 ||
isTrueLike(env.DD_CIVISIBILITY_AGENTLESS_ENABLED),
hasApiKey: findLocations(textFiles, API_KEY_RE).length > 0,
hasService: findLocations(textFiles, SERVICE_RE).length > 0,
hasOtelOtlpExporter: findLocations(textFiles, OTEL_OTLP_RE).length > 0,
}
}
/**
* Finds dependency entries in package manifests.
*
* @param {Array<object>} manifests package manifests
* @param {string[]} packageNames package names
* @returns {Array<object>} matching dependency entries
*/
function findDependencyEntries (manifests, packageNames) {
const entries = []
const packageSet = new Set(packageNames)
for (const manifest of manifests) {
for (const section of PACKAGE_SECTIONS) {
const dependencies = manifest.json[section]
if (!dependencies) continue
for (const [name, range] of Object.entries(dependencies)) {
if (!packageSet.has(name)) continue
entries.push({
packageName: name,
rawVersion: String(range),
section,
relativePath: manifest.relativePath,
})
}
}
}
return entries
}
/**
* Resolves framework package versions from installed modules and manifest ranges.
*
* @param {string} root repository root
* @param {string[]} packageNames package names
* @param {Array<object>} dependencyEntries dependency entries
* @returns {Array<object>} version detections
*/
function getVersionDetections (root, packageNames, dependencyEntries) {
const detections = []
for (const entry of dependencyEntries) {
const packageRoot = path.dirname(path.resolve(root, entry.relativePath))
const installedVersion = getInstalledPackageVersion(root, entry.packageName, packageRoot)
detections.push({
packageName: entry.packageName,
rawVersion: installedVersion || entry.rawVersion,
version: installedVersion || coerceVersion(entry.rawVersion),
relativePath: entry.relativePath,
source: installedVersion ? 'installed' : 'manifest',
})
}
if (detections.length > 0) return uniqueVersionDetections(detections)
for (const packageName of packageNames) {
const version = getInstalledPackageVersion(root, packageName, root)
if (!version) continue
detections.push({
packageName,
rawVersion: version,
version,
source: 'installed',
})
}
return uniqueVersionDetections(detections)
}
/**
* Reads an installed package version from node_modules.
*
* @param {string} root repository root
* @param {string} packageName package name
* @param {string} packageRoot package directory where resolution begins
* @returns {string|undefined} installed version
*/
function getInstalledPackageVersion (root, packageName, packageRoot) {
const repositoryRoot = path.resolve(root)
let directory = path.resolve(packageRoot)
if (!isPathInside(repositoryRoot, directory)) return
while (isPathInside(repositoryRoot, directory)) {
const packageJsonPath = path.join(directory, 'node_modules', ...packageName.split('/'), 'package.json')
const json = readJsonFile(packageJsonPath)
if (typeof json?.version === 'string') return json.version
if (directory === repositoryRoot) return
const parent = path.dirname(directory)
if (parent === directory) return
directory = parent
}
}
/**
* Finds scripts matching any pattern.
*
* @param {Array<object>} scripts package scripts
* @param {RegExp[]} patterns command patterns
* @returns {Array<object>} matching scripts
*/
function findScriptMatches (scripts, patterns) {
const matches = []
for (const script of scripts) {
if (!/test|spec|e2e|integration|unit/i.test(script.name) &&
!patterns.some(pattern => pattern.test(script.command))) {
continue
}
if (patterns.some(pattern => pattern.test(script.command))) {
matches.push(script)
}
}
return matches
}
/**
* Finds scanned config files that match definition patterns.
*
* @param {Array<object>} textFiles scanned text files
* @param {RegExp[]} patterns filename patterns
* @returns {string[]} matching relative paths
*/
function findConfigMatches (textFiles, patterns) {
const matches = []
for (const file of textFiles) {
const basename = path.basename(file.relativePath)
if (patterns.some(pattern => pattern.test(basename))) {
matches.push(file.relativePath)
}
}
return matches
}
/**
* Finds Jest TypeScript config files.
*
* @param {Array<object>} textFiles scanned text files
* @returns {string[]} matching relative paths
*/
function findJestTypescriptConfigLocations (textFiles) {
return textFiles
.map(file => file.relativePath)
.filter(relativePath => /(?:^|\/)jest\.config\.(?:ts|mts|cts)$/.test(relativePath))
}
/**
* Finds files where NODE_OPTIONS appears close to a supported Node preload option.
*
* @param {Array<object>} textFiles scanned text files
* @param {RegExp} preloadPattern Node preload option pattern
* @returns {string[]} matching relative paths
*/
function findNodeOptionsPreloadLocations (textFiles, preloadPattern) {
const locations = []
for (const file of textFiles) {
const lines = file.content.split(/\r?\n/)
for (let i = 0; i < lines.length; i++) {
if (!NODE_OPTIONS_RE.test(lines[i])) continue
const window = lines.slice(i, i + 4).join('\n')
preloadPattern.lastIndex = 0
if (preloadPattern.test(window)) {
locations.push(file.relativePath)
break
}
}
}
return unique(locations)
}
/**
* Finds files whose content matches a pattern.
*
* @param {Array<object>} textFiles scanned text files
* @param {RegExp} pattern content pattern
* @returns {string[]} matching relative paths
*/
function findLocations (textFiles, pattern) {
const locations = []
for (const file of textFiles) {
pattern.lastIndex = 0
if (pattern.test(file.content)) {
locations.push(file.relativePath)
}
}
return unique(locations)
}
/**
* Collects text-like repository files.
*
* @param {string} root repository root
* @param {string|undefined} physicalRoot physical repository root
* @param {number} maxFiles maximum number of files
* @param {string[]} [excludePaths] repository paths excluded from the