dd-trace
Version:
Datadog APM tracing client for JavaScript
1,132 lines (1,004 loc) • 35.1 kB
JavaScript
'use strict'
const path = require('path')
const fs = require('fs')
const { URL } = require('url')
const log = require('../../log')
const { getEnvironmentVariable } = require('../../config-helper')
const satisfies = require('semifies')
const istanbul = require('istanbul-lib-coverage')
const ignore = require('ignore')
const {
getGitMetadata,
getGitInformationDiscrepancy,
getGitDiff,
getGitRemoteName,
getSourceBranch,
checkAndFetchBranch,
getLocalBranches,
getMergeBase,
getCounts
} = require('./git')
const { getUserProviderGitMetadata, validateGitRepositoryUrl, validateGitCommitSha } = require('./user-provided-git')
const { getCIMetadata } = require('./ci')
const { getRuntimeAndOSMetadata } = require('./env')
const {
GIT_BRANCH,
GIT_COMMIT_SHA,
GIT_REPOSITORY_URL,
GIT_TAG,
GIT_COMMIT_AUTHOR_EMAIL,
GIT_COMMIT_AUTHOR_NAME,
GIT_COMMIT_MESSAGE,
CI_WORKSPACE_PATH,
CI_PIPELINE_URL,
CI_JOB_NAME,
GIT_COMMIT_HEAD_SHA
} = require('./tags')
const id = require('../../id')
const {
incrementCountMetric,
TELEMETRY_GIT_COMMIT_SHA_DISCREPANCY,
TELEMETRY_GIT_SHA_MATCH
} = require('../../ci-visibility/telemetry')
const { SPAN_TYPE, RESOURCE_NAME, SAMPLING_PRIORITY } = require('../../../../../ext/tags')
const { SAMPLING_RULE_DECISION } = require('../../constants')
const { AUTO_KEEP } = require('../../../../../ext/priority')
const { version: ddTraceVersion } = require('../../../../../package.json')
// session tags
const TEST_SESSION_NAME = 'test_session.name'
const TEST_FRAMEWORK = 'test.framework'
const TEST_FRAMEWORK_VERSION = 'test.framework_version'
const TEST_TYPE = 'test.type'
const TEST_NAME = 'test.name'
const TEST_SUITE = 'test.suite'
const TEST_STATUS = 'test.status'
const TEST_PARAMETERS = 'test.parameters'
const TEST_SKIP_REASON = 'test.skip_reason'
const TEST_IS_RUM_ACTIVE = 'test.is_rum_active'
const TEST_CODE_OWNERS = 'test.codeowners'
const TEST_SOURCE_FILE = 'test.source.file'
const TEST_SOURCE_START = 'test.source.start'
const LIBRARY_VERSION = 'library_version'
const TEST_COMMAND = 'test.command'
const TEST_MODULE = 'test.module'
const TEST_SESSION_ID = 'test_session_id'
const TEST_MODULE_ID = 'test_module_id'
const TEST_SUITE_ID = 'test_suite_id'
const TEST_TOOLCHAIN = 'test.toolchain'
const TEST_SKIPPED_BY_ITR = 'test.skipped_by_itr'
// Early flake detection
const TEST_IS_NEW = 'test.is_new'
const TEST_IS_RETRY = 'test.is_retry'
const TEST_EARLY_FLAKE_ENABLED = 'test.early_flake.enabled'
const TEST_EARLY_FLAKE_ABORT_REASON = 'test.early_flake.abort_reason'
const TEST_RETRY_REASON = 'test.retry_reason'
const TEST_HAS_FAILED_ALL_RETRIES = 'test.has_failed_all_retries'
const TEST_IS_MODIFIED = 'test.is_modified'
const CI_APP_ORIGIN = 'ciapp-test'
const JEST_TEST_RUNNER = 'test.jest.test_runner'
const JEST_DISPLAY_NAME = 'test.jest.display_name'
const CUCUMBER_IS_PARALLEL = 'test.cucumber.is_parallel'
const MOCHA_IS_PARALLEL = 'test.mocha.is_parallel'
const TEST_ITR_TESTS_SKIPPED = '_dd.ci.itr.tests_skipped'
const TEST_ITR_SKIPPING_ENABLED = 'test.itr.tests_skipping.enabled'
const TEST_ITR_SKIPPING_TYPE = 'test.itr.tests_skipping.type'
const TEST_ITR_SKIPPING_COUNT = 'test.itr.tests_skipping.count'
const TEST_CODE_COVERAGE_ENABLED = 'test.code_coverage.enabled'
const TEST_ITR_UNSKIPPABLE = 'test.itr.unskippable'
const TEST_ITR_FORCED_RUN = 'test.itr.forced_run'
const ITR_CORRELATION_ID = 'itr_correlation_id'
const TEST_CODE_COVERAGE_LINES_PCT = 'test.code_coverage.lines_pct'
// selenium tags
const TEST_BROWSER_DRIVER = 'test.browser.driver'
const TEST_BROWSER_DRIVER_VERSION = 'test.browser.driver_version'
const TEST_BROWSER_NAME = 'test.browser.name'
const TEST_BROWSER_VERSION = 'test.browser.version'
// jest worker variables
const JEST_WORKER_TRACE_PAYLOAD_CODE = 60
const JEST_WORKER_COVERAGE_PAYLOAD_CODE = 61
const JEST_WORKER_LOGS_PAYLOAD_CODE = 62
// cucumber worker variables
const CUCUMBER_WORKER_TRACE_PAYLOAD_CODE = 70
// mocha worker variables
const MOCHA_WORKER_TRACE_PAYLOAD_CODE = 80
// playwright worker variables
const PLAYWRIGHT_WORKER_TRACE_PAYLOAD_CODE = 90
// Early flake detection util strings
const EFD_STRING = "Retried by Datadog's Early Flake Detection"
const EFD_TEST_NAME_REGEX = new RegExp(EFD_STRING + String.raw` \(#\d+\): `, 'g')
// Library Capabilities Tagging
const DD_CAPABILITIES_TEST_IMPACT_ANALYSIS = '_dd.library_capabilities.test_impact_analysis'
const DD_CAPABILITIES_EARLY_FLAKE_DETECTION = '_dd.library_capabilities.early_flake_detection'
const DD_CAPABILITIES_AUTO_TEST_RETRIES = '_dd.library_capabilities.auto_test_retries'
const DD_CAPABILITIES_IMPACTED_TESTS = '_dd.library_capabilities.impacted_tests'
const DD_CAPABILITIES_TEST_MANAGEMENT_QUARANTINE = '_dd.library_capabilities.test_management.quarantine'
const DD_CAPABILITIES_TEST_MANAGEMENT_DISABLE = '_dd.library_capabilities.test_management.disable'
const DD_CAPABILITIES_TEST_MANAGEMENT_ATTEMPT_TO_FIX = '_dd.library_capabilities.test_management.attempt_to_fix'
const DD_CAPABILITIES_FAILED_TEST_REPLAY = '_dd.library_capabilities.failed_test_replay'
const UNSUPPORTED_TIA_FRAMEWORKS = new Set(['playwright', 'vitest'])
const UNSUPPORTED_TIA_FRAMEWORKS_PARALLEL_MODE = new Set(['cucumber', 'mocha'])
const MINIMUM_FRAMEWORK_VERSION_FOR_EFD = {
playwright: '>=1.38.0'
}
const MINIMUM_FRAMEWORK_VERSION_FOR_IMPACTED_TESTS = {
playwright: '>=1.38.0'
}
const MINIMUM_FRAMEWORK_VERSION_FOR_QUARANTINE = {
playwright: '>=1.38.0'
}
const MINIMUM_FRAMEWORK_VERSION_FOR_DISABLE = {
playwright: '>=1.38.0'
}
const MINIMUM_FRAMEWORK_VERSION_FOR_ATTEMPT_TO_FIX = {
playwright: '>=1.38.0'
}
const MINIMUM_FRAMEWORK_VERSION_FOR_FAILED_TEST_REPLAY = {
playwright: '>=1.38.0'
}
const UNSUPPORTED_ATTEMPT_TO_FIX_FRAMEWORKS_PARALLEL_MODE = new Set(['mocha'])
const NOT_SUPPORTED_GRANULARITY_IMPACTED_TESTS_FRAMEWORKS = new Set(['mocha', 'playwright', 'vitest'])
const TEST_LEVEL_EVENT_TYPES = [
'test',
'test_suite_end',
'test_module_end',
'test_session_end'
]
const TEST_RETRY_REASON_TYPES = {
efd: 'early_flake_detection',
atr: 'auto_test_retry',
atf: 'attempt_to_fix',
ext: 'external'
}
const DD_TEST_IS_USER_PROVIDED_SERVICE = '_dd.test.is_user_provided_service'
// Dynamic instrumentation - Test optimization integration tags
const DI_ERROR_DEBUG_INFO_CAPTURED = 'error.debug_info_captured'
const DI_DEBUG_ERROR_PREFIX = '_dd.debug.error'
const DI_DEBUG_ERROR_SNAPSHOT_ID_SUFFIX = 'snapshot_id'
const DI_DEBUG_ERROR_FILE_SUFFIX = 'file'
const DI_DEBUG_ERROR_LINE_SUFFIX = 'line'
// Test Management tags
const TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX = 'test.test_management.is_attempt_to_fix'
const TEST_MANAGEMENT_IS_DISABLED = 'test.test_management.is_test_disabled'
const TEST_MANAGEMENT_IS_QUARANTINED = 'test.test_management.is_quarantined'
const TEST_MANAGEMENT_ENABLED = 'test.test_management.enabled'
const TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED = 'test.test_management.attempt_to_fix_passed'
// Test Management utils strings
const ATTEMPT_TO_FIX_STRING = "Retried by Datadog's Test Management"
const ATTEMPT_TEST_NAME_REGEX = new RegExp(ATTEMPT_TO_FIX_STRING + String.raw` \(#\d+\): `, 'g')
// Impacted tests
const POSSIBLE_BASE_BRANCHES = ['main', 'master', 'preprod', 'prod', 'dev', 'development', 'trunk']
const BASE_LIKE_BRANCH_FILTER = /^(main|master|preprod|prod|dev|development|trunk|release\/.*|hotfix\/.*)$/
module.exports = {
TEST_CODE_OWNERS,
TEST_SESSION_NAME,
TEST_FRAMEWORK,
TEST_FRAMEWORK_VERSION,
JEST_TEST_RUNNER,
JEST_DISPLAY_NAME,
CUCUMBER_IS_PARALLEL,
MOCHA_IS_PARALLEL,
TEST_TYPE,
TEST_NAME,
TEST_SUITE,
TEST_STATUS,
TEST_PARAMETERS,
TEST_SKIP_REASON,
TEST_IS_RUM_ACTIVE,
TEST_SOURCE_FILE,
CI_APP_ORIGIN,
LIBRARY_VERSION,
JEST_WORKER_TRACE_PAYLOAD_CODE,
JEST_WORKER_COVERAGE_PAYLOAD_CODE,
JEST_WORKER_LOGS_PAYLOAD_CODE,
CUCUMBER_WORKER_TRACE_PAYLOAD_CODE,
MOCHA_WORKER_TRACE_PAYLOAD_CODE,
PLAYWRIGHT_WORKER_TRACE_PAYLOAD_CODE,
TEST_SOURCE_START,
TEST_SKIPPED_BY_ITR,
TEST_IS_NEW,
TEST_IS_RETRY,
TEST_EARLY_FLAKE_ENABLED,
TEST_EARLY_FLAKE_ABORT_REASON,
TEST_RETRY_REASON,
TEST_HAS_FAILED_ALL_RETRIES,
TEST_IS_MODIFIED,
getTestEnvironmentMetadata,
getTestParametersString,
finishAllTraceSpans,
getTestParentSpan,
getTestSuitePath,
getCodeOwnersFileEntries,
getCodeOwnersForFilename,
getTestCommonTags,
getTestSessionCommonTags,
getTestModuleCommonTags,
getTestSuiteCommonTags,
TEST_COMMAND,
TEST_TOOLCHAIN,
TEST_SESSION_ID,
TEST_MODULE_ID,
TEST_SUITE_ID,
TEST_ITR_TESTS_SKIPPED,
TEST_MODULE,
TEST_ITR_SKIPPING_ENABLED,
TEST_ITR_SKIPPING_TYPE,
TEST_ITR_SKIPPING_COUNT,
TEST_CODE_COVERAGE_ENABLED,
TEST_CODE_COVERAGE_LINES_PCT,
TEST_ITR_UNSKIPPABLE,
TEST_ITR_FORCED_RUN,
ITR_CORRELATION_ID,
addIntelligentTestRunnerSpanTags,
getCoveredFilenamesFromCoverage,
resetCoverage,
mergeCoverage,
fromCoverageMapToCoverage,
getTestLineStart,
getTestEndLine,
removeInvalidMetadata,
parseAnnotations,
EFD_STRING,
EFD_TEST_NAME_REGEX,
removeEfdStringFromTestName,
removeAttemptToFixStringFromTestName,
addEfdStringToTestName,
addAttemptToFixStringToTestName,
getIsFaultyEarlyFlakeDetection,
TEST_BROWSER_DRIVER,
TEST_BROWSER_DRIVER_VERSION,
TEST_BROWSER_NAME,
TEST_BROWSER_VERSION,
getTestSessionName,
DD_CAPABILITIES_TEST_IMPACT_ANALYSIS,
DD_CAPABILITIES_EARLY_FLAKE_DETECTION,
DD_CAPABILITIES_AUTO_TEST_RETRIES,
DD_CAPABILITIES_IMPACTED_TESTS,
DD_CAPABILITIES_TEST_MANAGEMENT_QUARANTINE,
DD_CAPABILITIES_TEST_MANAGEMENT_DISABLE,
DD_CAPABILITIES_TEST_MANAGEMENT_ATTEMPT_TO_FIX,
DD_CAPABILITIES_FAILED_TEST_REPLAY,
TEST_LEVEL_EVENT_TYPES,
TEST_RETRY_REASON_TYPES,
getNumFromKnownTests,
getFileAndLineNumberFromError,
DI_ERROR_DEBUG_INFO_CAPTURED,
DI_DEBUG_ERROR_PREFIX,
DI_DEBUG_ERROR_SNAPSHOT_ID_SUFFIX,
DI_DEBUG_ERROR_FILE_SUFFIX,
DI_DEBUG_ERROR_LINE_SUFFIX,
getFormattedError,
DD_TEST_IS_USER_PROVIDED_SERVICE,
TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX,
TEST_MANAGEMENT_IS_DISABLED,
TEST_MANAGEMENT_IS_QUARANTINED,
TEST_MANAGEMENT_ENABLED,
TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED,
getLibraryCapabilitiesTags,
checkShaDiscrepancies,
getPullRequestDiff,
getPullRequestBaseBranch,
getModifiedTestsFromDiff,
isModifiedTest,
POSSIBLE_BASE_BRANCHES
}
// Returns pkg manager and its version, separated by '-', e.g. npm-8.15.0 or yarn-1.22.19
function getPkgManager () {
try {
return getEnvironmentVariable('npm_config_user_agent').split(' ')[0].replace('/', '-')
} catch {
return ''
}
}
function validateUrl (url) {
try {
const urlObject = new URL(url)
return (urlObject.protocol === 'https:' || urlObject.protocol === 'http:')
} catch {
return false
}
}
function removeInvalidMetadata (metadata) {
return Object.keys(metadata).reduce((filteredTags, tag) => {
if (tag === GIT_REPOSITORY_URL && !validateGitRepositoryUrl(metadata[GIT_REPOSITORY_URL])) {
log.error('Repository URL is not a valid repository URL: %s.', metadata[GIT_REPOSITORY_URL])
return filteredTags
}
if (tag === GIT_COMMIT_SHA && !validateGitCommitSha(metadata[GIT_COMMIT_SHA])) {
log.error('Git commit SHA must be a full-length git SHA: %s.', metadata[GIT_COMMIT_SHA])
return filteredTags
}
if (tag === CI_PIPELINE_URL && !validateUrl(metadata[CI_PIPELINE_URL])) {
return filteredTags
}
filteredTags[tag] = metadata[tag]
return filteredTags
}, {})
}
function checkShaDiscrepancies (ciMetadata, userProvidedGitMetadata) {
const {
[GIT_COMMIT_SHA]: ciCommitSHA,
[GIT_REPOSITORY_URL]: ciRepositoryUrl
} = ciMetadata
const {
[GIT_COMMIT_SHA]: userProvidedCommitSHA,
[GIT_REPOSITORY_URL]: userProvidedRepositoryUrl
} = userProvidedGitMetadata
const { gitRepositoryUrl, gitCommitSHA } = getGitInformationDiscrepancy()
const checkDiscrepancyAndSendMetrics = (
valueExpected,
valueDiscrepant,
discrepancyType,
expectedProvider,
discrepantProvider
) => {
if (valueExpected && valueDiscrepant && valueExpected !== valueDiscrepant) {
incrementCountMetric(
TELEMETRY_GIT_COMMIT_SHA_DISCREPANCY,
{
type: discrepancyType,
expected_provider: expectedProvider,
discrepant_provider: discrepantProvider
}
)
return true
}
return false
}
const checkConfigs = [
// User provided vs Git metadata
{
v1: userProvidedRepositoryUrl,
v2: gitRepositoryUrl,
type: 'repository_discrepancy',
expected: 'user_supplied',
discrepant: 'git_client'
},
{
v1: userProvidedCommitSHA,
v2: gitCommitSHA,
type: 'commit_discrepancy',
expected: 'user_supplied',
discrepant: 'git_client'
},
// User provided vs CI metadata
{
v1: userProvidedRepositoryUrl,
v2: ciRepositoryUrl,
type: 'repository_discrepancy',
expected: 'user_supplied',
discrepant: 'ci_provider'
},
{
v1: userProvidedCommitSHA,
v2: ciCommitSHA,
type: 'commit_discrepancy',
expected: 'user_supplied',
discrepant: 'ci_provider'
},
// CI metadata vs Git metadata
{
v1: ciRepositoryUrl,
v2: gitRepositoryUrl,
type: 'repository_discrepancy',
expected: 'ci_provider',
discrepant: 'git_client'
},
{
v1: ciCommitSHA,
v2: gitCommitSHA,
type: 'commit_discrepancy',
expected: 'ci_provider',
discrepant: 'git_client'
}
]
let gitCommitShaMatch = true
for (const checkConfig of checkConfigs) {
const { v1, v2, type, expected, discrepant } = checkConfig
const discrepancy = checkDiscrepancyAndSendMetrics(v1, v2, type, expected, discrepant)
if (discrepancy) {
gitCommitShaMatch = false
}
}
incrementCountMetric(
TELEMETRY_GIT_SHA_MATCH,
{ matched: gitCommitShaMatch }
)
}
function getTestEnvironmentMetadata (testFramework, config) {
// TODO: eventually these will come from the tracer (generally available)
const ciMetadata = getCIMetadata()
const {
[GIT_COMMIT_SHA]: commitSHA,
[GIT_BRANCH]: branch,
[GIT_REPOSITORY_URL]: repositoryUrl,
[GIT_TAG]: tag,
[GIT_COMMIT_AUTHOR_NAME]: authorName,
[GIT_COMMIT_AUTHOR_EMAIL]: authorEmail,
[GIT_COMMIT_MESSAGE]: commitMessage,
[CI_WORKSPACE_PATH]: ciWorkspacePath,
[GIT_COMMIT_HEAD_SHA]: headCommitSha
} = ciMetadata
const gitMetadata = getGitMetadata({
commitSHA,
branch,
repositoryUrl,
tag,
authorName,
authorEmail,
commitMessage,
ciWorkspacePath,
headCommitSha
})
const userProvidedGitMetadata = getUserProviderGitMetadata()
checkShaDiscrepancies(ciMetadata, userProvidedGitMetadata)
const runtimeAndOSMetadata = getRuntimeAndOSMetadata()
const metadata = {
[TEST_FRAMEWORK]: testFramework,
[DD_TEST_IS_USER_PROVIDED_SERVICE]: (config && config.isServiceUserProvided) ? 'true' : 'false',
...gitMetadata,
...ciMetadata,
...userProvidedGitMetadata,
...runtimeAndOSMetadata
}
if (config && config.service) {
metadata['service.name'] = config.service
}
return removeInvalidMetadata(metadata)
}
function getTestParametersString (parametersByTestName, testName) {
if (!parametersByTestName[testName]) {
return ''
}
try {
// test is invoked with each parameter set sequencially
const testParameters = parametersByTestName[testName].shift()
return JSON.stringify({ arguments: testParameters, metadata: {} })
} catch {
// We can't afford to interrupt the test if `testParameters` is not serializable to JSON,
// so we ignore the test parameters and move on
return ''
}
}
function getTestTypeFromFramework (testFramework) {
if (testFramework === 'playwright' || testFramework === 'cypress') {
return 'browser'
}
return 'test'
}
function finishAllTraceSpans (span) {
span.context()._trace.started.forEach(traceSpan => {
if (traceSpan !== span) {
traceSpan.finish()
}
})
}
function getTestParentSpan (tracer) {
return tracer.extract('text_map', {
'x-datadog-trace-id': id().toString(10),
'x-datadog-parent-id': '0000000000000000'
})
}
function getTestCommonTags (name, suite, version, testFramework) {
return {
[SPAN_TYPE]: 'test',
[TEST_TYPE]: getTestTypeFromFramework(testFramework),
[SAMPLING_RULE_DECISION]: 1,
[SAMPLING_PRIORITY]: AUTO_KEEP,
[TEST_NAME]: name,
[TEST_SUITE]: suite,
[RESOURCE_NAME]: `${suite}.${name}`,
[TEST_FRAMEWORK_VERSION]: version,
[LIBRARY_VERSION]: ddTraceVersion
}
}
/**
* We want to make sure that test suites are reported the same way for
* every OS, so we replace `path.sep` by `/`
*/
function getTestSuitePath (testSuiteAbsolutePath, sourceRoot) {
if (!testSuiteAbsolutePath) {
return sourceRoot
}
const testSuitePath = testSuiteAbsolutePath === sourceRoot
? testSuiteAbsolutePath
: path.relative(sourceRoot, testSuiteAbsolutePath)
return testSuitePath.replace(path.sep, '/')
}
const POSSIBLE_CODEOWNERS_LOCATIONS = [
'CODEOWNERS',
'.github/CODEOWNERS',
'docs/CODEOWNERS',
'.gitlab/CODEOWNERS'
]
function readCodeOwners (rootDir) {
for (const location of POSSIBLE_CODEOWNERS_LOCATIONS) {
try {
return fs.readFileSync(path.join(rootDir, location)).toString()
} catch {
// retry with next path
}
}
return ''
}
function getCodeOwnersFileEntries (rootDir) {
let codeOwnersContent
let usedRootDir = rootDir
let isTriedCwd = false
const processCwd = process.cwd()
if (!usedRootDir || usedRootDir === processCwd) {
usedRootDir = processCwd
isTriedCwd = true
}
codeOwnersContent = readCodeOwners(usedRootDir)
// If we haven't found CODEOWNERS in the provided root dir, we try with process.cwd()
if (!codeOwnersContent && !isTriedCwd) {
codeOwnersContent = readCodeOwners(processCwd)
}
if (!codeOwnersContent) {
return null
}
const entries = []
const lines = codeOwnersContent.split('\n')
for (const line of lines) {
const [content] = line.split('#')
const trimmed = content.trim()
if (trimmed === '') continue
const [pattern, ...owners] = trimmed.split(/\s+/)
entries.push({ pattern, owners })
}
// Reverse because rules defined last take precedence
return entries.reverse()
}
function getCodeOwnersForFilename (filename, entries) {
if (!entries) {
return null
}
for (const entry of entries) {
try {
const isResponsible = ignore().add(entry.pattern).ignores(filename)
if (isResponsible) {
return JSON.stringify(entry.owners)
}
} catch {
return null
}
}
return null
}
function getTestLevelCommonTags (command, testFrameworkVersion, testFramework) {
return {
[TEST_FRAMEWORK_VERSION]: testFrameworkVersion,
[LIBRARY_VERSION]: ddTraceVersion,
[TEST_COMMAND]: command,
[TEST_TYPE]: getTestTypeFromFramework(testFramework)
}
}
function getTestSessionCommonTags (command, testFrameworkVersion, testFramework) {
return {
[SPAN_TYPE]: 'test_session_end',
[RESOURCE_NAME]: `test_session.${command}`,
[TEST_MODULE]: testFramework,
[TEST_TOOLCHAIN]: getPkgManager(),
...getTestLevelCommonTags(command, testFrameworkVersion, testFramework)
}
}
function getTestModuleCommonTags (command, testFrameworkVersion, testFramework) {
return {
[SPAN_TYPE]: 'test_module_end',
[RESOURCE_NAME]: `test_module.${command}`,
[TEST_MODULE]: testFramework,
...getTestLevelCommonTags(command, testFrameworkVersion, testFramework)
}
}
function getTestSuiteCommonTags (command, testFrameworkVersion, testSuite, testFramework) {
return {
[SPAN_TYPE]: 'test_suite_end',
[RESOURCE_NAME]: `test_suite.${testSuite}`,
[TEST_MODULE]: testFramework,
[TEST_SUITE]: testSuite,
...getTestLevelCommonTags(command, testFrameworkVersion, testFramework)
}
}
function addIntelligentTestRunnerSpanTags (
testSessionSpan,
testModuleSpan,
{
isSuitesSkipped,
isSuitesSkippingEnabled,
isCodeCoverageEnabled,
testCodeCoverageLinesTotal,
skippingCount,
skippingType = 'suite',
hasUnskippableSuites,
hasForcedToRunSuites
}
) {
testSessionSpan.setTag(TEST_ITR_TESTS_SKIPPED, isSuitesSkipped ? 'true' : 'false')
testSessionSpan.setTag(TEST_ITR_SKIPPING_ENABLED, isSuitesSkippingEnabled ? 'true' : 'false')
testSessionSpan.setTag(TEST_ITR_SKIPPING_TYPE, skippingType)
testSessionSpan.setTag(TEST_ITR_SKIPPING_COUNT, skippingCount)
testSessionSpan.setTag(TEST_CODE_COVERAGE_ENABLED, isCodeCoverageEnabled ? 'true' : 'false')
testModuleSpan.setTag(TEST_ITR_TESTS_SKIPPED, isSuitesSkipped ? 'true' : 'false')
testModuleSpan.setTag(TEST_ITR_SKIPPING_ENABLED, isSuitesSkippingEnabled ? 'true' : 'false')
testModuleSpan.setTag(TEST_ITR_SKIPPING_TYPE, skippingType)
testModuleSpan.setTag(TEST_ITR_SKIPPING_COUNT, skippingCount)
testModuleSpan.setTag(TEST_CODE_COVERAGE_ENABLED, isCodeCoverageEnabled ? 'true' : 'false')
if (hasUnskippableSuites) {
testSessionSpan.setTag(TEST_ITR_UNSKIPPABLE, 'true')
testModuleSpan.setTag(TEST_ITR_UNSKIPPABLE, 'true')
}
if (hasForcedToRunSuites) {
testSessionSpan.setTag(TEST_ITR_FORCED_RUN, 'true')
testModuleSpan.setTag(TEST_ITR_FORCED_RUN, 'true')
}
// This will not be reported unless the user has manually added code coverage.
// This is always the case for Mocha and Cucumber, but not for Jest.
if (testCodeCoverageLinesTotal !== undefined) {
testSessionSpan.setTag(TEST_CODE_COVERAGE_LINES_PCT, testCodeCoverageLinesTotal)
testModuleSpan.setTag(TEST_CODE_COVERAGE_LINES_PCT, testCodeCoverageLinesTotal)
}
}
function getCoveredFilenamesFromCoverage (coverage) {
const coverageMap = istanbul.createCoverageMap(coverage)
return coverageMap
.files()
.filter(filename => {
const fileCoverage = coverageMap.fileCoverageFor(filename)
const lineCoverage = fileCoverage.getLineCoverage()
const isAnyLineExecuted = Object.entries(lineCoverage).some(([, numExecutions]) => !!numExecutions)
return isAnyLineExecuted
})
}
function resetCoverage (coverage) {
const coverageMap = istanbul.createCoverageMap(coverage)
return coverageMap
.files()
.forEach(filename => {
const fileCoverage = coverageMap.fileCoverageFor(filename)
fileCoverage.resetHits()
})
}
function mergeCoverage (coverage, targetCoverage) {
const coverageMap = istanbul.createCoverageMap(coverage)
return coverageMap
.files()
.forEach(filename => {
const fileCoverage = coverageMap.fileCoverageFor(filename)
// If the fileCoverage is not there for this filename,
// we create it to force a merge between the fileCoverages
// instead of a reference assignment (which would not work if the coverage is reset later on)
if (!targetCoverage.data[filename]) {
targetCoverage.addFileCoverage(istanbul.createFileCoverage(filename))
}
targetCoverage.addFileCoverage(fileCoverage)
const targetFileCoverage = targetCoverage.fileCoverageFor(filename)
// branches (.b) are copied by reference, so `resetHits` affects the copy, so we need to copy it manually
Object.entries(targetFileCoverage.data.b).forEach(([key, value]) => {
targetFileCoverage.data.b[key] = [...value]
})
})
}
function fromCoverageMapToCoverage (coverageMap) {
return Object.entries(coverageMap.data).reduce((acc, [filename, fileCoverage]) => {
acc[filename] = fileCoverage.data
return acc
}, {})
}
// Get the start line of a test by inspecting a given error's stack trace
function getTestLineStart (err, testSuitePath) {
if (!err.stack) {
return null
}
// From https://github.com/felixge/node-stack-trace/blob/ba06dcdb50d465cd440d84a563836e293b360427/index.js#L40
const testFileLine = err.stack.split('\n').find(line => line.includes(testSuitePath))
try {
const testFileLineMatch = testFileLine.match(/at (?:(.+?)\s+\()?(?:(.+?):(\d+)(?::(\d+))?|([^)]+))\)?/)
return Number.parseInt(testFileLineMatch[3], 10) || null
} catch {
return null
}
}
// Get the end line of a test by inspecting a given function's source code
function getTestEndLine (testFn, startLine = 0) {
const source = testFn.toString()
const lineCount = source.split('\n').length
return startLine + lineCount - 1
}
/**
* Gets an object of test tags from an Playwright annotations array.
* @param {Object[]} annotations - Annotations from a Playwright test.
* @param {string} annotations[].type - Type of annotation. A string of the shape DD_TAGS[$tag_name].
* @param {string} annotations[].description - Value of the tag.
*/
function parseAnnotations (annotations) {
return annotations.reduce((tags, annotation) => {
if (!annotation?.type) {
return tags
}
const { type, description } = annotation
if (type.startsWith('DD_TAGS')) {
const regex = /\[(.*?)\]/
const match = regex.exec(type)
let tagValue = ''
if (match) {
tagValue = match[1]
}
if (tagValue) {
tags[tagValue] = description
}
}
return tags
}, {})
}
function addEfdStringToTestName (testName, numAttempt) {
return `${EFD_STRING} (#${numAttempt}): ${testName}`
}
function addAttemptToFixStringToTestName (testName, numAttempt) {
return `${ATTEMPT_TO_FIX_STRING} (#${numAttempt}): ${testName}`
}
function removeEfdStringFromTestName (testName) {
return testName.replaceAll(EFD_TEST_NAME_REGEX, '')
}
function removeAttemptToFixStringFromTestName (testName) {
return testName.replaceAll(ATTEMPT_TEST_NAME_REGEX, '')
}
function getIsFaultyEarlyFlakeDetection (projectSuites, testsBySuiteName, faultyThresholdPercentage) {
let newSuites = 0
for (const suite of projectSuites) {
if (!testsBySuiteName[suite]) {
newSuites++
}
}
const newSuitesPercentage = (newSuites / projectSuites.length) * 100
// The faulty threshold represents a percentage, but we also want to consider
// smaller projects, where big variations in the % are more likely.
// This is why we also check the absolute number of new suites.
return (
newSuites > faultyThresholdPercentage &&
newSuitesPercentage > faultyThresholdPercentage
)
}
function getTestSessionName (config, trimmedCommand, envTags) {
if (config.ciVisibilityTestSessionName) {
return config.ciVisibilityTestSessionName
}
if (envTags[CI_JOB_NAME]) {
return `${envTags[CI_JOB_NAME]}-${trimmedCommand}`
}
return trimmedCommand
}
// Calculate the number of a tests from the known tests response, which has a shape like:
// { testModule1: { testSuite1: [test1, test2, test3] }, testModule2: { testSuite2: [test4, test5] } }
function getNumFromKnownTests (knownTests) {
if (!knownTests) {
return 0
}
let totalNumTests = 0
for (const testModule of Object.values(knownTests)) {
for (const testSuite of Object.values(testModule)) {
totalNumTests += testSuite.length
}
}
return totalNumTests
}
const DEPENDENCY_FOLDERS = [
'node_modules',
'node:',
'.pnpm',
'.yarn',
'.pnp'
]
function getFileAndLineNumberFromError (error, repositoryRoot) {
// Split the stack trace into individual lines
const stackLines = error.stack.split('\n')
// Remove potential messages on top of the stack that are not frames
const frames = stackLines.filter(line => line.includes('at ') && line.includes(repositoryRoot))
const topRelevantFrameIndex = frames.findIndex(line =>
line.includes(repositoryRoot) && !DEPENDENCY_FOLDERS.some(pattern => line.includes(pattern))
)
if (topRelevantFrameIndex === -1) {
return []
}
const topFrame = frames[topRelevantFrameIndex]
// Regular expression to match the file path, line number, and column number
const regex = /\s*at\s+(?:.*\()?(.+):(\d+):(\d+)\)?/
const match = topFrame.match(regex)
if (match) {
const filePath = match[1]
const lineNumber = Number(match[2])
return [filePath, lineNumber, topRelevantFrameIndex]
}
return []
}
function getFormattedError (error, repositoryRoot) {
const newError = new Error(error.message)
if (error.stack) {
newError.stack = error.stack.split('\n').filter(line => line.includes(repositoryRoot)).join('\n')
}
newError.name = error.name
return newError
}
function isTiaSupported (testFramework, isParallel) {
return !(UNSUPPORTED_TIA_FRAMEWORKS.has(testFramework) ||
(isParallel && UNSUPPORTED_TIA_FRAMEWORKS_PARALLEL_MODE.has(testFramework)))
}
function isEarlyFlakeDetectionSupported (testFramework, frameworkVersion) {
return testFramework === 'playwright'
? satisfies(frameworkVersion, MINIMUM_FRAMEWORK_VERSION_FOR_EFD[testFramework])
: true
}
function isImpactedTestsSupported (testFramework, frameworkVersion) {
return testFramework === 'playwright'
? satisfies(frameworkVersion, MINIMUM_FRAMEWORK_VERSION_FOR_IMPACTED_TESTS[testFramework])
: true
}
function isQuarantineSupported (testFramework, frameworkVersion) {
return testFramework === 'playwright'
? satisfies(frameworkVersion, MINIMUM_FRAMEWORK_VERSION_FOR_QUARANTINE[testFramework])
: true
}
function isDisableSupported (testFramework, frameworkVersion) {
return testFramework === 'playwright'
? satisfies(frameworkVersion, MINIMUM_FRAMEWORK_VERSION_FOR_DISABLE[testFramework])
: true
}
function isAttemptToFixSupported (testFramework, isParallel, frameworkVersion) {
if (testFramework === 'playwright') {
return satisfies(frameworkVersion, MINIMUM_FRAMEWORK_VERSION_FOR_ATTEMPT_TO_FIX[testFramework])
}
return !(isParallel && UNSUPPORTED_ATTEMPT_TO_FIX_FRAMEWORKS_PARALLEL_MODE.has(testFramework))
}
function isFailedTestReplaySupported (testFramework, frameworkVersion) {
return testFramework === 'playwright'
? satisfies(frameworkVersion, MINIMUM_FRAMEWORK_VERSION_FOR_FAILED_TEST_REPLAY[testFramework])
: true
}
function getLibraryCapabilitiesTags (testFramework, isParallel, frameworkVersion) {
return {
[DD_CAPABILITIES_TEST_IMPACT_ANALYSIS]: isTiaSupported(testFramework, isParallel)
? '1'
: undefined,
[DD_CAPABILITIES_EARLY_FLAKE_DETECTION]: isEarlyFlakeDetectionSupported(testFramework, frameworkVersion)
? '1'
: undefined,
[DD_CAPABILITIES_AUTO_TEST_RETRIES]: '1',
[DD_CAPABILITIES_IMPACTED_TESTS]: isImpactedTestsSupported(testFramework, frameworkVersion)
? '1'
: undefined,
[DD_CAPABILITIES_TEST_MANAGEMENT_QUARANTINE]: isQuarantineSupported(testFramework, frameworkVersion)
? '1'
: undefined,
[DD_CAPABILITIES_TEST_MANAGEMENT_DISABLE]: isDisableSupported(testFramework, frameworkVersion)
? '1'
: undefined,
[DD_CAPABILITIES_TEST_MANAGEMENT_ATTEMPT_TO_FIX]:
isAttemptToFixSupported(testFramework, isParallel, frameworkVersion)
? '5'
: undefined,
[DD_CAPABILITIES_FAILED_TEST_REPLAY]: isFailedTestReplaySupported(testFramework, frameworkVersion)
? '1'
: undefined
}
}
function getPullRequestBaseBranch (pullRequestBaseBranch) {
const remoteName = getGitRemoteName()
const sourceBranch = getSourceBranch()
// TODO: We will get the default branch name from the backend in the future.
const POSSIBLE_DEFAULT_BRANCHES = ['main', 'master']
const candidateBranches = []
if (pullRequestBaseBranch) {
checkAndFetchBranch(pullRequestBaseBranch, remoteName)
candidateBranches.push(pullRequestBaseBranch)
} else {
for (const branch of POSSIBLE_BASE_BRANCHES) {
checkAndFetchBranch(branch, remoteName)
}
const localBranches = getLocalBranches(remoteName)
for (const branch of localBranches) {
const shortBranchName = branch.replace(new RegExp(`^${remoteName}/`), '')
if (branch !== sourceBranch && BASE_LIKE_BRANCH_FILTER.test(shortBranchName)) {
candidateBranches.push(branch)
}
}
}
if (candidateBranches.length === 1) {
return getMergeBase(candidateBranches[0], sourceBranch)
}
const metrics = {}
for (const candidate of candidateBranches) {
// Find common ancestor
const baseSha = getMergeBase(candidate, sourceBranch)
if (!baseSha) {
continue
}
// Count commits ahead/behind
const counts = getCounts(candidate, sourceBranch)
if (!counts) {
continue
}
const behind = counts.behind
const ahead = counts.ahead
metrics[candidate] = {
behind,
ahead,
baseSha
}
}
function isDefaultBranch (branch) {
return POSSIBLE_DEFAULT_BRANCHES.some(defaultBranch =>
branch === defaultBranch || branch === `${remoteName}/${defaultBranch}`
)
}
if (Object.keys(metrics).length === 0) {
return null
}
// Find branch with smallest "ahead" value, preferring default branch on tie
let bestBranch = null
let bestScore = Infinity
for (const branch of Object.keys(metrics)) {
const score = metrics[branch].ahead
if (score < bestScore) {
bestScore = score
bestBranch = branch
} else if (score === bestScore && isDefaultBranch(branch)) {
bestScore = score
bestBranch = branch
}
}
return bestBranch ? metrics[bestBranch].baseSha : null
}
function getPullRequestDiff (baseCommit, targetCommit) {
if (!baseCommit) {
return
}
return getGitDiff(baseCommit, targetCommit)
}
function getModifiedTestsFromDiff (diff) {
if (!diff) return null
const result = {}
const filesRegex = /^diff --git a\/(?<file>.+) b\/(?<file2>.+)$/g
const linesRegex = /^@@ -\d+(,\d+)? \+(?<start>\d+)(,(?<count>\d+))? @@/g
let currentFile = null
// Go line by line
const lines = diff.split('\n')
for (const line of lines) {
// Check for new file
const fileMatch = filesRegex.exec(line)
if (fileMatch && fileMatch.groups.file) {
currentFile = fileMatch.groups.file
result[currentFile] = []
continue
}
// Check for changed lines
const lineMatch = linesRegex.exec(line)
if (lineMatch && currentFile) {
const start = Number(lineMatch.groups.start)
const count = lineMatch.groups.count ? Number(lineMatch.groups.count) : 1
for (let j = 0; j < count; j++) {
result[currentFile].push(start + j)
}
}
// Reset regexes to allow re-use
filesRegex.lastIndex = 0
linesRegex.lastIndex = 0
}
if (Object.keys(result).length === 0) {
return null
}
return result
}
function isModifiedTest (testPath, testStartLine, testEndLine, modifiedTests, testFramework) {
if (modifiedTests === undefined) {
return false
}
const lines = modifiedTests[testPath]
if (!lines) {
return false
}
// For unsupported frameworks, consider the test modified if any lines were changed
if (NOT_SUPPORTED_GRANULARITY_IMPACTED_TESTS_FRAMEWORKS.has(testFramework)) {
return lines.length > 0
}
// For supported frameworks, check if the test's line range overlaps with modified lines
return lines.some(line => line >= testStartLine && line <= testEndLine)
}