dd-trace
Version:
Datadog APM tracing client for JavaScript
999 lines (898 loc) • 34.1 kB
JavaScript
'use strict'
const {
TEST_STATUS,
TEST_IS_RUM_ACTIVE,
TEST_CODE_OWNERS,
getTestEnvironmentMetadata,
CI_APP_ORIGIN,
getTestParentSpan,
getCodeOwnersFileEntries,
getCodeOwnersForFilename,
getTestCommonTags,
getTestSessionCommonTags,
getTestModuleCommonTags,
getTestSuiteCommonTags,
TEST_SUITE_ID,
TEST_MODULE_ID,
TEST_SESSION_ID,
TEST_COMMAND,
TEST_MODULE,
TEST_SOURCE_START,
finishAllTraceSpans,
getCoveredFilenamesFromCoverage,
getTestSuitePath,
addIntelligentTestRunnerSpanTags,
TEST_SKIPPED_BY_ITR,
TEST_ITR_UNSKIPPABLE,
TEST_ITR_FORCED_RUN,
ITR_CORRELATION_ID,
TEST_SOURCE_FILE,
TEST_IS_NEW,
TEST_IS_RETRY,
TEST_EARLY_FLAKE_ENABLED,
getTestSessionName,
TEST_SESSION_NAME,
TEST_LEVEL_EVENT_TYPES,
TEST_RETRY_REASON,
DD_TEST_IS_USER_PROVIDED_SERVICE,
TEST_MANAGEMENT_IS_QUARANTINED,
TEST_MANAGEMENT_ENABLED,
TEST_MANAGEMENT_IS_DISABLED,
TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX,
TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED,
TEST_HAS_FAILED_ALL_RETRIES,
getLibraryCapabilitiesTags,
TEST_RETRY_REASON_TYPES,
getPullRequestDiff,
getModifiedFilesFromDiff,
TEST_IS_MODIFIED,
getPullRequestBaseBranch
} = require('../../dd-trace/src/plugins/util/test')
const { isMarkedAsUnskippable } = require('../../datadog-plugin-jest/src/util')
const { ORIGIN_KEY, COMPONENT } = require('../../dd-trace/src/constants')
const { getEnvironmentVariable } = require('../../dd-trace/src/config-helper')
const { appClosing: appClosingTelemetry } = require('../../dd-trace/src/telemetry')
const log = require('../../dd-trace/src/log')
const {
TELEMETRY_EVENT_CREATED,
TELEMETRY_EVENT_FINISHED,
TELEMETRY_ITR_FORCED_TO_RUN,
TELEMETRY_CODE_COVERAGE_EMPTY,
TELEMETRY_ITR_UNSKIPPABLE,
TELEMETRY_CODE_COVERAGE_NUM_FILES,
incrementCountMetric,
distributionMetric,
TELEMETRY_ITR_SKIPPED,
TELEMETRY_TEST_SESSION
} = require('../../dd-trace/src/ci-visibility/telemetry')
const {
GIT_REPOSITORY_URL,
GIT_COMMIT_SHA,
GIT_BRANCH,
CI_PROVIDER_NAME,
CI_WORKSPACE_PATH,
GIT_COMMIT_MESSAGE,
GIT_TAG,
GIT_PULL_REQUEST_BASE_BRANCH_SHA,
GIT_COMMIT_HEAD_SHA,
GIT_PULL_REQUEST_BASE_BRANCH,
GIT_COMMIT_HEAD_MESSAGE
} = require('../../dd-trace/src/plugins/util/tags')
const {
OS_VERSION,
OS_PLATFORM,
OS_ARCHITECTURE,
RUNTIME_NAME,
RUNTIME_VERSION
} = require('../../dd-trace/src/plugins/util/env')
const { DD_MAJOR } = require('../../../version')
const TEST_FRAMEWORK_NAME = 'cypress'
const CYPRESS_STATUS_TO_TEST_STATUS = {
passed: 'pass',
failed: 'fail',
pending: 'skip',
skipped: 'skip'
}
function getSessionStatus (summary) {
if (summary.totalFailed !== undefined && summary.totalFailed > 0) {
return 'fail'
}
if (summary.totalSkipped !== undefined && summary.totalSkipped === summary.totalTests) {
return 'skip'
}
return 'pass'
}
function getCypressVersion (details) {
if (details?.cypressVersion) {
return details.cypressVersion
}
if (details?.config?.version) {
return details.config.version
}
return ''
}
function getRootDir (details) {
if (details?.config) {
return details.config.projectRoot || details.config.repoRoot || process.cwd()
}
return process.cwd()
}
function getCypressCommand (details) {
if (!details) {
return TEST_FRAMEWORK_NAME
}
return `${TEST_FRAMEWORK_NAME} ${details.specPattern || ''}`
}
function getIsTestIsolationEnabled (cypressConfig) {
if (!cypressConfig) {
// If we can't read testIsolation config parameter, we default to allowing retries
return true
}
return cypressConfig.testIsolation === undefined ? true : cypressConfig.testIsolation
}
function getLibraryConfiguration (tracer, testConfiguration) {
return new Promise(resolve => {
if (!tracer._tracer._exporter?.getLibraryConfiguration) {
return resolve({ err: new Error('Test Optimization was not initialized correctly') })
}
tracer._tracer._exporter.getLibraryConfiguration(testConfiguration, (err, libraryConfig) => {
resolve({ err, libraryConfig })
})
})
}
function getSkippableTests (tracer, testConfiguration) {
return new Promise(resolve => {
if (!tracer._tracer._exporter?.getSkippableSuites) {
return resolve({ err: new Error('Test Optimization was not initialized correctly') })
}
tracer._tracer._exporter.getSkippableSuites(testConfiguration, (err, skippableTests, correlationId) => {
resolve({
err,
skippableTests,
correlationId
})
})
})
}
function getKnownTests (tracer, testConfiguration) {
return new Promise(resolve => {
if (!tracer._tracer._exporter?.getKnownTests) {
return resolve({ err: new Error('Test Optimization was not initialized correctly') })
}
tracer._tracer._exporter.getKnownTests(testConfiguration, (err, knownTests) => {
resolve({
err,
knownTests
})
})
})
}
function getTestManagementTests (tracer, testConfiguration) {
return new Promise(resolve => {
if (!tracer._tracer._exporter?.getTestManagementTests) {
return resolve({ err: new Error('Test Optimization was not initialized correctly') })
}
tracer._tracer._exporter.getTestManagementTests(testConfiguration, (err, testManagementTests) => {
resolve({
err,
testManagementTests
})
})
})
}
function getModifiedFiles (testEnvironmentMetadata) {
const {
[GIT_PULL_REQUEST_BASE_BRANCH]: pullRequestBaseBranch,
[GIT_PULL_REQUEST_BASE_BRANCH_SHA]: pullRequestBaseBranchSha,
[GIT_COMMIT_HEAD_SHA]: commitHeadSha
} = testEnvironmentMetadata
const baseBranchSha = pullRequestBaseBranchSha || getPullRequestBaseBranch(pullRequestBaseBranch)
if (baseBranchSha) {
const diff = getPullRequestDiff(baseBranchSha, commitHeadSha)
const modifiedFiles = getModifiedFilesFromDiff(diff)
if (modifiedFiles) {
return modifiedFiles
}
}
throw new Error('Modified tests could not be retrieved')
}
function getSuiteStatus (suiteStats) {
if (!suiteStats) {
return 'skip'
}
if (suiteStats.failures !== undefined && suiteStats.failures > 0) {
return 'fail'
}
if (suiteStats.tests !== undefined &&
(suiteStats.tests === suiteStats.pending || suiteStats.tests === suiteStats.skipped)) {
return 'skip'
}
return 'pass'
}
class CypressPlugin {
_isInit = false
testEnvironmentMetadata = getTestEnvironmentMetadata(TEST_FRAMEWORK_NAME)
finishedTestsByFile = {}
testStatuses = {}
isTestsSkipped = false
isSuitesSkippingEnabled = false
isCodeCoverageEnabled = false
isFlakyTestRetriesEnabled = false
isEarlyFlakeDetectionEnabled = false
isKnownTestsEnabled = false
earlyFlakeDetectionNumRetries = 0
testsToSkip = []
skippedTests = []
hasForcedToRunSuites = false
hasUnskippableSuites = false
unskippableSuites = []
knownTests = []
isTestManagementTestsEnabled = false
testManagementAttemptToFixRetries = 0
isImpactedTestsEnabled = false
modifiedFiles = []
constructor () {
const {
[GIT_REPOSITORY_URL]: repositoryUrl,
[GIT_COMMIT_SHA]: sha,
[OS_VERSION]: osVersion,
[OS_PLATFORM]: osPlatform,
[OS_ARCHITECTURE]: osArchitecture,
[RUNTIME_NAME]: runtimeName,
[RUNTIME_VERSION]: runtimeVersion,
[GIT_BRANCH]: branch,
[CI_PROVIDER_NAME]: ciProviderName,
[CI_WORKSPACE_PATH]: repositoryRoot,
[GIT_COMMIT_MESSAGE]: commitMessage,
[GIT_TAG]: tag,
[GIT_PULL_REQUEST_BASE_BRANCH_SHA]: pullRequestBaseSha,
[GIT_COMMIT_HEAD_SHA]: commitHeadSha,
[GIT_COMMIT_HEAD_MESSAGE]: commitHeadMessage
} = this.testEnvironmentMetadata
this.repositoryRoot = repositoryRoot || process.cwd()
this.ciProviderName = ciProviderName
this.codeOwnersEntries = getCodeOwnersFileEntries(repositoryRoot)
this.testConfiguration = {
repositoryUrl,
sha,
osVersion,
osPlatform,
osArchitecture,
runtimeName,
runtimeVersion,
branch,
testLevel: 'test',
commitMessage,
tag,
pullRequestBaseSha,
commitHeadSha,
commitHeadMessage
}
}
// Init function returns a promise that resolves with the Cypress configuration
// Depending on the received configuration, the Cypress configuration can be modified:
// for example, to enable retries for failed tests.
init (tracer, cypressConfig) {
this._isInit = true
this.tracer = tracer
this.cypressConfig = cypressConfig
this.isTestIsolationEnabled = getIsTestIsolationEnabled(cypressConfig)
if (!this.isTestIsolationEnabled) {
log.warn('Test isolation is disabled, retries will not be enabled')
}
// we have to do it here because the tracer is not initialized in the constructor
this.testEnvironmentMetadata[DD_TEST_IS_USER_PROVIDED_SERVICE] =
tracer._tracer._config.isServiceUserProvided ? 'true' : 'false'
this.libraryConfigurationPromise = getLibraryConfiguration(this.tracer, this.testConfiguration)
.then((libraryConfigurationResponse) => {
if (libraryConfigurationResponse.err) {
log.error('Cypress plugin library config response error', libraryConfigurationResponse.err)
} else {
const {
libraryConfig: {
isSuitesSkippingEnabled,
isCodeCoverageEnabled,
isEarlyFlakeDetectionEnabled,
earlyFlakeDetectionNumRetries,
isFlakyTestRetriesEnabled,
flakyTestRetriesCount,
isKnownTestsEnabled,
isTestManagementEnabled,
testManagementAttemptToFixRetries,
isImpactedTestsEnabled
}
} = libraryConfigurationResponse
this.isSuitesSkippingEnabled = isSuitesSkippingEnabled
this.isCodeCoverageEnabled = isCodeCoverageEnabled
this.isEarlyFlakeDetectionEnabled = isEarlyFlakeDetectionEnabled
this.earlyFlakeDetectionNumRetries = earlyFlakeDetectionNumRetries
this.isKnownTestsEnabled = isKnownTestsEnabled
if (isFlakyTestRetriesEnabled && this.isTestIsolationEnabled) {
this.isFlakyTestRetriesEnabled = true
this.cypressConfig.retries.runMode = flakyTestRetriesCount
}
this.isTestManagementTestsEnabled = isTestManagementEnabled
this.testManagementAttemptToFixRetries = testManagementAttemptToFixRetries
this.isImpactedTestsEnabled = isImpactedTestsEnabled
}
return this.cypressConfig
})
return this.libraryConfigurationPromise
}
getIsTestModified (testSuiteAbsolutePath) {
const relativeTestSuitePath = getTestSuitePath(testSuiteAbsolutePath, this.repositoryRoot)
if (!this.modifiedFiles) {
return false
}
const lines = this.modifiedFiles[relativeTestSuitePath]
if (!lines) {
return false
}
return lines.length > 0
}
getTestSuiteProperties (testSuite) {
return this.testManagementTests?.cypress?.suites?.[testSuite]?.tests || {}
}
getTestProperties (testSuite, testName) {
const { attempt_to_fix: isAttemptToFix, disabled: isDisabled, quarantined: isQuarantined } =
this.getTestSuiteProperties(testSuite)?.[testName]?.properties || {}
return { isAttemptToFix, isDisabled, isQuarantined }
}
getTestSuiteSpan ({ testSuite, testSuiteAbsolutePath }) {
const testSuiteSpanMetadata =
getTestSuiteCommonTags(this.command, this.frameworkVersion, testSuite, TEST_FRAMEWORK_NAME)
this.ciVisEvent(TELEMETRY_EVENT_CREATED, 'suite')
if (testSuiteAbsolutePath) {
const testSourceFile = getTestSuitePath(testSuiteAbsolutePath, this.repositoryRoot)
testSuiteSpanMetadata[TEST_SOURCE_FILE] = testSourceFile
testSuiteSpanMetadata[TEST_SOURCE_START] = 1
const codeOwners = this.getTestCodeOwners({ testSuite, testSourceFile })
if (codeOwners) {
testSuiteSpanMetadata[TEST_CODE_OWNERS] = codeOwners
}
}
return this.tracer.startSpan(`${TEST_FRAMEWORK_NAME}.test_suite`, {
childOf: this.testModuleSpan,
tags: {
[COMPONENT]: TEST_FRAMEWORK_NAME,
...this.testEnvironmentMetadata,
...testSuiteSpanMetadata
},
integrationName: TEST_FRAMEWORK_NAME
})
}
getTestSpan ({ testName, testSuite, isUnskippable, isForcedToRun, testSourceFile, isDisabled, isQuarantined }) {
const testSuiteTags = {
[TEST_COMMAND]: this.command,
[TEST_MODULE]: TEST_FRAMEWORK_NAME
}
if (this.testSuiteSpan) {
testSuiteTags[TEST_SUITE_ID] = this.testSuiteSpan.context().toSpanId()
}
if (this.testSessionSpan && this.testModuleSpan) {
testSuiteTags[TEST_SESSION_ID] = this.testSessionSpan.context().toTraceId()
testSuiteTags[TEST_MODULE_ID] = this.testModuleSpan.context().toSpanId()
// If testSuiteSpan couldn't be created, we'll use the testModuleSpan as the parent
if (!this.testSuiteSpan) {
testSuiteTags[TEST_SUITE_ID] = this.testModuleSpan.context().toSpanId()
}
}
const childOf = getTestParentSpan(this.tracer)
const {
resource,
...testSpanMetadata
} = getTestCommonTags(testName, testSuite, this.cypressConfig.version, TEST_FRAMEWORK_NAME)
if (testSourceFile) {
testSpanMetadata[TEST_SOURCE_FILE] = testSourceFile
}
const codeOwners = this.getTestCodeOwners({ testSuite, testSourceFile })
if (codeOwners) {
testSpanMetadata[TEST_CODE_OWNERS] = codeOwners
}
if (isUnskippable) {
this.hasUnskippableSuites = true
incrementCountMetric(TELEMETRY_ITR_UNSKIPPABLE, { testLevel: 'suite' })
testSpanMetadata[TEST_ITR_UNSKIPPABLE] = 'true'
}
if (isForcedToRun) {
this.hasForcedToRunSuites = true
incrementCountMetric(TELEMETRY_ITR_FORCED_TO_RUN, { testLevel: 'suite' })
testSpanMetadata[TEST_ITR_FORCED_RUN] = 'true'
}
if (isDisabled) {
testSpanMetadata[TEST_MANAGEMENT_IS_DISABLED] = 'true'
}
if (isQuarantined) {
testSpanMetadata[TEST_MANAGEMENT_IS_QUARANTINED] = 'true'
}
this.ciVisEvent(TELEMETRY_EVENT_CREATED, 'test', { hasCodeOwners: !!codeOwners })
return this.tracer.startSpan(`${TEST_FRAMEWORK_NAME}.test`, {
childOf,
tags: {
[COMPONENT]: TEST_FRAMEWORK_NAME,
[ORIGIN_KEY]: CI_APP_ORIGIN,
...testSpanMetadata,
...this.testEnvironmentMetadata,
...testSuiteTags
},
integrationName: TEST_FRAMEWORK_NAME
})
}
ciVisEvent (name, testLevel, tags = {}) {
incrementCountMetric(name, {
testLevel,
testFramework: 'cypress',
isUnsupportedCIProvider: !this.ciProviderName,
...tags
})
}
async beforeRun (details) {
// We need to make sure that the plugin is initialized before running the tests
// This is for the case where the user has not returned the promise from the init function
await this.libraryConfigurationPromise
this.command = getCypressCommand(details)
this.frameworkVersion = getCypressVersion(details)
this.rootDir = getRootDir(details)
if (this.isKnownTestsEnabled) {
const knownTestsResponse = await getKnownTests(
this.tracer,
this.testConfiguration
)
if (knownTestsResponse.err) {
log.error('Cypress known tests response error', knownTestsResponse.err)
this.isEarlyFlakeDetectionEnabled = false
this.isKnownTestsEnabled = false
} else {
if (knownTestsResponse.knownTests[TEST_FRAMEWORK_NAME]) {
this.knownTestsByTestSuite = knownTestsResponse.knownTests[TEST_FRAMEWORK_NAME]
} else {
this.isEarlyFlakeDetectionEnabled = false
this.isKnownTestsEnabled = false
}
}
}
if (this.isSuitesSkippingEnabled) {
const skippableTestsResponse = await getSkippableTests(
this.tracer,
this.testConfiguration
)
if (skippableTestsResponse.err) {
log.error('Cypress skippable tests response error', skippableTestsResponse.err)
} else {
const { skippableTests, correlationId } = skippableTestsResponse
this.testsToSkip = skippableTests || []
this.itrCorrelationId = correlationId
incrementCountMetric(TELEMETRY_ITR_SKIPPED, { testLevel: 'test' }, this.testsToSkip.length)
}
}
if (this.isTestManagementTestsEnabled) {
const testManagementTestsResponse = await getTestManagementTests(
this.tracer,
this.testConfiguration
)
if (testManagementTestsResponse.err) {
log.error('Cypress test management tests response error', testManagementTestsResponse.err)
this.isTestManagementTestsEnabled = false
} else {
this.testManagementTests = testManagementTestsResponse.testManagementTests
}
}
if (this.isImpactedTestsEnabled) {
try {
this.modifiedFiles = getModifiedFiles(this.testEnvironmentMetadata)
} catch (error) {
log.error(error)
this.isImpactedTestsEnabled = false
}
}
// `details.specs` are test files
details.specs?.forEach(({ absolute, relative }) => {
const isUnskippableSuite = isMarkedAsUnskippable({ path: absolute })
if (isUnskippableSuite) {
this.unskippableSuites.push(relative)
}
})
const childOf = getTestParentSpan(this.tracer)
const testSessionSpanMetadata =
getTestSessionCommonTags(this.command, this.frameworkVersion, TEST_FRAMEWORK_NAME)
const testModuleSpanMetadata =
getTestModuleCommonTags(this.command, this.frameworkVersion, TEST_FRAMEWORK_NAME)
if (this.isEarlyFlakeDetectionEnabled) {
testSessionSpanMetadata[TEST_EARLY_FLAKE_ENABLED] = 'true'
}
const trimmedCommand = DD_MAJOR < 6 ? this.command : 'cypress run'
const testSessionName = getTestSessionName(
this.tracer._tracer._config,
trimmedCommand,
this.testEnvironmentMetadata
)
if (this.tracer._tracer._exporter?.addMetadataTags) {
const metadataTags = {}
for (const testLevel of TEST_LEVEL_EVENT_TYPES) {
metadataTags[testLevel] = {
[TEST_SESSION_NAME]: testSessionName
}
}
const libraryCapabilitiesTags = getLibraryCapabilitiesTags(this.constructor.id, false, this.frameworkVersion)
metadataTags.test = {
...metadataTags.test,
...libraryCapabilitiesTags
}
this.tracer._tracer._exporter.addMetadataTags(metadataTags)
}
this.testSessionSpan = this.tracer.startSpan(`${TEST_FRAMEWORK_NAME}.test_session`, {
childOf,
tags: {
[COMPONENT]: TEST_FRAMEWORK_NAME,
...this.testEnvironmentMetadata,
...testSessionSpanMetadata
},
integrationName: TEST_FRAMEWORK_NAME
})
this.ciVisEvent(TELEMETRY_EVENT_CREATED, 'session')
this.testModuleSpan = this.tracer.startSpan(`${TEST_FRAMEWORK_NAME}.test_module`, {
childOf: this.testSessionSpan,
tags: {
[COMPONENT]: TEST_FRAMEWORK_NAME,
...this.testEnvironmentMetadata,
...testModuleSpanMetadata
},
integrationName: TEST_FRAMEWORK_NAME
})
this.ciVisEvent(TELEMETRY_EVENT_CREATED, 'module')
return details
}
afterRun (suiteStats) {
if (!this._isInit) {
log.warn('Attemping to call afterRun without initializating the plugin first')
return
}
if (this.testSessionSpan && this.testModuleSpan) {
const testStatus = getSessionStatus(suiteStats)
this.testModuleSpan.setTag(TEST_STATUS, testStatus)
this.testSessionSpan.setTag(TEST_STATUS, testStatus)
addIntelligentTestRunnerSpanTags(
this.testSessionSpan,
this.testModuleSpan,
{
isSuitesSkipped: this.isTestsSkipped,
isSuitesSkippingEnabled: this.isSuitesSkippingEnabled,
isCodeCoverageEnabled: this.isCodeCoverageEnabled,
skippingType: 'test',
skippingCount: this.skippedTests.length,
hasForcedToRunSuites: this.hasForcedToRunSuites,
hasUnskippableSuites: this.hasUnskippableSuites
}
)
if (this.isTestManagementTestsEnabled) {
this.testSessionSpan.setTag(TEST_MANAGEMENT_ENABLED, 'true')
}
this.testModuleSpan.finish()
this.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'module')
this.testSessionSpan.finish()
this.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'session')
incrementCountMetric(TELEMETRY_TEST_SESSION, {
provider: this.ciProviderName,
autoInjected: !!getEnvironmentVariable('DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER')
})
finishAllTraceSpans(this.testSessionSpan)
}
return new Promise(resolve => {
const exporter = this.tracer._tracer._exporter
if (!exporter) {
return resolve(null)
}
if (exporter.flush) {
exporter.flush(() => {
appClosingTelemetry()
resolve(null)
})
} else if (exporter._writer) {
exporter._writer.flush(() => {
appClosingTelemetry()
resolve(null)
})
}
})
}
afterSpec (spec, results) {
const { tests, stats } = results || {}
const cypressTests = tests || []
const finishedTests = this.finishedTestsByFile[spec.relative] || []
if (!this.testSuiteSpan) {
// dd:testSuiteStart hasn't been triggered for whatever reason
// We will create the test suite span on the spot if that's the case
log.warn('There was an error creating the test suite event.')
this.testSuiteSpan = this.getTestSuiteSpan({
testSuite: spec.relative,
testSuiteAbsolutePath: spec.absolute
})
}
// Get tests that didn't go through `dd:afterEach`
// and create a skipped test span for each of them
cypressTests.filter(({ title }) => {
const cypressTestName = title.join(' ')
const isTestFinished = finishedTests.find(({ testName }) => cypressTestName === testName)
return !isTestFinished
}).forEach(({ title }) => {
const cypressTestName = title.join(' ')
const isSkippedByItr = this.testsToSkip.find(test =>
cypressTestName === test.name && spec.relative === test.suite
)
const testSourceFile = spec.absolute && this.repositoryRoot
? getTestSuitePath(spec.absolute, this.repositoryRoot)
: spec.relative
const skippedTestSpan = this.getTestSpan({ testName: cypressTestName, testSuite: spec.relative, testSourceFile })
skippedTestSpan.setTag(TEST_STATUS, 'skip')
if (isSkippedByItr) {
skippedTestSpan.setTag(TEST_SKIPPED_BY_ITR, 'true')
}
if (this.itrCorrelationId) {
skippedTestSpan.setTag(ITR_CORRELATION_ID, this.itrCorrelationId)
}
const { isDisabled, isQuarantined } = this.getTestProperties(spec.relative, cypressTestName)
if (isDisabled) {
skippedTestSpan.setTag(TEST_MANAGEMENT_IS_DISABLED, 'true')
} else if (isQuarantined) {
skippedTestSpan.setTag(TEST_MANAGEMENT_IS_QUARANTINED, 'true')
}
skippedTestSpan.finish()
})
// Make sure that reported test statuses are the same as Cypress reports.
// This is not always the case, such as when an `after` hook fails:
// Cypress will report the last run test as failed, but we don't know that yet at `dd:afterEach`
let latestError
const finishedTestsByTestName = finishedTests.reduce((acc, finishedTest) => {
if (!acc[finishedTest.testName]) {
acc[finishedTest.testName] = []
}
acc[finishedTest.testName].push(finishedTest)
return acc
}, {})
Object.entries(finishedTestsByTestName).forEach(([testName, finishedTestAttempts]) => {
finishedTestAttempts.forEach((finishedTest, attemptIndex) => {
// TODO: there could be multiple if there have been retries!
// potentially we need to match the test status!
const cypressTest = cypressTests.find(test => test.title.join(' ') === testName)
if (!cypressTest) {
return
}
// finishedTests can include multiple tests with the same name if they have been retried
// by early flake detection. Cypress is unaware of this so .attempts does not necessarily have
// the same length as `finishedTestAttempts`
let cypressTestStatus = CYPRESS_STATUS_TO_TEST_STATUS[cypressTest.state]
if (cypressTest.attempts && cypressTest.attempts[attemptIndex]) {
cypressTestStatus = CYPRESS_STATUS_TO_TEST_STATUS[cypressTest.attempts[attemptIndex].state]
const isAtrRetry = attemptIndex > 0 &&
this.isFlakyTestRetriesEnabled &&
!finishedTest.isAttemptToFix &&
!finishedTest.isEfdRetry
if (attemptIndex > 0) {
finishedTest.testSpan.setTag(TEST_IS_RETRY, 'true')
if (finishedTest.isEfdRetry) {
finishedTest.testSpan.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.efd)
} else if (isAtrRetry) {
finishedTest.testSpan.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.atr)
} else {
finishedTest.testSpan.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.ext)
}
}
}
if (cypressTest.displayError) {
latestError = new Error(cypressTest.displayError)
}
// Update test status
if (cypressTestStatus !== finishedTest.testStatus) {
finishedTest.testSpan.setTag(TEST_STATUS, cypressTestStatus)
finishedTest.testSpan.setTag('error', latestError)
}
if (this.itrCorrelationId) {
finishedTest.testSpan.setTag(ITR_CORRELATION_ID, this.itrCorrelationId)
}
const testSourceFile = spec.absolute && this.repositoryRoot
? getTestSuitePath(spec.absolute, this.repositoryRoot)
: spec.relative
if (testSourceFile) {
finishedTest.testSpan.setTag(TEST_SOURCE_FILE, testSourceFile)
}
const codeOwners = this.getTestCodeOwners({ testSuite: spec.relative, testSourceFile })
if (codeOwners) {
finishedTest.testSpan.setTag(TEST_CODE_OWNERS, codeOwners)
}
finishedTest.testSpan.finish(finishedTest.finishTime)
})
})
if (this.testSuiteSpan) {
const status = getSuiteStatus(stats)
this.testSuiteSpan.setTag(TEST_STATUS, status)
if (latestError) {
this.testSuiteSpan.setTag('error', latestError)
}
this.testSuiteSpan.finish()
this.testSuiteSpan = null
this.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'suite')
}
}
getTasks () {
return {
'dd:testSuiteStart': ({ testSuite, testSuiteAbsolutePath }) => {
const suitePayload = {
isEarlyFlakeDetectionEnabled: this.isEarlyFlakeDetectionEnabled,
knownTestsForSuite: this.knownTestsByTestSuite?.[testSuite] || [],
earlyFlakeDetectionNumRetries: this.earlyFlakeDetectionNumRetries,
isKnownTestsEnabled: this.isKnownTestsEnabled,
isTestManagementEnabled: this.isTestManagementTestsEnabled,
testManagementAttemptToFixRetries: this.testManagementAttemptToFixRetries,
testManagementTests: this.getTestSuiteProperties(testSuite),
isImpactedTestsEnabled: this.isImpactedTestsEnabled,
isModifiedTest: this.getIsTestModified(testSuiteAbsolutePath),
repositoryRoot: this.repositoryRoot,
isTestIsolationEnabled: this.isTestIsolationEnabled
}
if (this.testSuiteSpan) {
return suitePayload
}
this.testSuiteSpan = this.getTestSuiteSpan({ testSuite, testSuiteAbsolutePath })
return suitePayload
},
'dd:beforeEach': (test) => {
const { testName, testSuite } = test
const shouldSkip = this.testsToSkip.some(test => {
return testName === test.name && testSuite === test.suite
})
const isUnskippable = this.unskippableSuites.includes(testSuite)
const isForcedToRun = shouldSkip && isUnskippable
const { isAttemptToFix, isDisabled, isQuarantined } = this.getTestProperties(testSuite, testName)
// skip test
if (shouldSkip && !isUnskippable) {
this.skippedTests.push(test)
this.isTestsSkipped = true
return { shouldSkip: true }
}
// TODO: I haven't found a way to trick cypress into ignoring a test
// The way we'll implement quarantine in cypress is by skipping the test altogether
if (!isAttemptToFix && (isDisabled || isQuarantined)) {
return { shouldSkip: true }
}
if (!this.activeTestSpan) {
this.activeTestSpan = this.getTestSpan({
testName,
testSuite,
isUnskippable,
isForcedToRun,
isDisabled,
isQuarantined
})
}
return this.activeTestSpan ? { traceId: this.activeTestSpan.context().toTraceId() } : {}
},
'dd:afterEach': ({ test, coverage }) => {
if (!this.activeTestSpan) {
log.warn('There is no active test span in dd:afterEach handler')
return null
}
const {
state,
error,
isRUMActive,
testSourceLine,
testSuite,
testSuiteAbsolutePath,
testName,
isNew,
isEfdRetry,
isAttemptToFix,
isModified
} = test
if (coverage && this.isCodeCoverageEnabled && this.tracer._tracer._exporter?.exportCoverage) {
const coverageFiles = getCoveredFilenamesFromCoverage(coverage)
const relativeCoverageFiles = [...coverageFiles, testSuiteAbsolutePath].map(
file => getTestSuitePath(file, this.repositoryRoot || this.rootDir)
)
if (!relativeCoverageFiles.length) {
incrementCountMetric(TELEMETRY_CODE_COVERAGE_EMPTY)
}
distributionMetric(TELEMETRY_CODE_COVERAGE_NUM_FILES, {}, relativeCoverageFiles.length)
const { _traceId, _spanId } = this.testSuiteSpan.context()
const formattedCoverage = {
sessionId: _traceId,
suiteId: _spanId,
testId: this.activeTestSpan.context()._spanId,
files: relativeCoverageFiles
}
this.tracer._tracer._exporter.exportCoverage(formattedCoverage)
}
const testStatus = CYPRESS_STATUS_TO_TEST_STATUS[state]
this.activeTestSpan.setTag(TEST_STATUS, testStatus)
// Save the test status to know if it has passed all retries
if (this.testStatuses[testName]) {
this.testStatuses[testName].push(testStatus)
} else {
this.testStatuses[testName] = [testStatus]
}
const testStatuses = this.testStatuses[testName]
if (error) {
this.activeTestSpan.setTag('error', error)
}
if (isRUMActive) {
this.activeTestSpan.setTag(TEST_IS_RUM_ACTIVE, 'true')
}
if (testSourceLine) {
this.activeTestSpan.setTag(TEST_SOURCE_START, testSourceLine)
}
if (isNew) {
this.activeTestSpan.setTag(TEST_IS_NEW, 'true')
if (isEfdRetry) {
this.activeTestSpan.setTag(TEST_IS_RETRY, 'true')
this.activeTestSpan.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.efd)
}
}
if (isModified) {
this.activeTestSpan.setTag(TEST_IS_MODIFIED, 'true')
if (isEfdRetry) {
this.activeTestSpan.setTag(TEST_IS_RETRY, 'true')
this.activeTestSpan.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.efd)
}
}
if (isAttemptToFix) {
this.activeTestSpan.setTag(TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX, 'true')
if (testStatuses.length > 1) {
this.activeTestSpan.setTag(TEST_IS_RETRY, 'true')
this.activeTestSpan.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.atf)
}
const isLastAttempt = testStatuses.length === this.testManagementAttemptToFixRetries + 1
if (isLastAttempt) {
if (testStatuses.includes('fail')) {
this.activeTestSpan.setTag(TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, 'false')
}
if (testStatuses.every(status => status === 'fail')) {
this.activeTestSpan.setTag(TEST_HAS_FAILED_ALL_RETRIES, 'true')
} else if (testStatuses.every(status => status === 'pass')) {
this.activeTestSpan.setTag(TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, 'true')
}
}
}
const finishedTest = {
testName,
testStatus,
finishTime: this.activeTestSpan._getTime(), // we store the finish time here
testSpan: this.activeTestSpan,
isEfdRetry,
isAttemptToFix
}
if (this.finishedTestsByFile[testSuite]) {
this.finishedTestsByFile[testSuite].push(finishedTest)
} else {
this.finishedTestsByFile[testSuite] = [finishedTest]
}
// test spans are finished at after:spec
this.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'test', {
hasCodeOwners: !!this.activeTestSpan.context()._tags[TEST_CODE_OWNERS],
isNew,
isRum: isRUMActive,
browserDriver: 'cypress'
})
this.activeTestSpan = null
return null
},
'dd:addTags': (tags) => {
if (this.activeTestSpan) {
this.activeTestSpan.addTags(tags)
}
return null
},
'dd:log': (message) => {
// eslint-disable-next-line no-console
console.log(`[datadog] ${message}`)
return null
}
}
}
getTestCodeOwners ({ testSuite, testSourceFile }) {
if (testSourceFile) {
return getCodeOwnersForFilename(testSourceFile, this.codeOwnersEntries)
}
return getCodeOwnersForFilename(testSuite, this.codeOwnersEntries)
}
}
module.exports = new CypressPlugin()