dd-trace
Version:
Datadog APM tracing client for JavaScript
507 lines (452 loc) • 17 kB
JavaScript
'use strict'
const CiPlugin = require('../../dd-trace/src/plugins/ci_plugin')
const { storage } = require('../../datadog-core')
const { getEnvironmentVariable } = require('../../dd-trace/src/config-helper')
const {
TEST_STATUS,
JEST_TEST_RUNNER,
finishAllTraceSpans,
getTestSuiteCommonTags,
addIntelligentTestRunnerSpanTags,
TEST_PARAMETERS,
TEST_COMMAND,
TEST_FRAMEWORK_VERSION,
TEST_SOURCE_START,
TEST_ITR_UNSKIPPABLE,
TEST_ITR_FORCED_RUN,
TEST_CODE_OWNERS,
ITR_CORRELATION_ID,
TEST_SOURCE_FILE,
TEST_IS_NEW,
TEST_IS_RETRY,
TEST_EARLY_FLAKE_ENABLED,
TEST_EARLY_FLAKE_ABORT_REASON,
JEST_DISPLAY_NAME,
TEST_IS_RUM_ACTIVE,
TEST_BROWSER_DRIVER,
getFormattedError,
TEST_RETRY_REASON,
TEST_MANAGEMENT_ENABLED,
TEST_MANAGEMENT_IS_QUARANTINED,
TEST_MANAGEMENT_IS_DISABLED,
TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX,
TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED,
TEST_HAS_FAILED_ALL_RETRIES,
TEST_RETRY_REASON_TYPES,
TEST_IS_MODIFIED
} = require('../../dd-trace/src/plugins/util/test')
const { COMPONENT } = require('../../dd-trace/src/constants')
const id = require('../../dd-trace/src/id')
const {
TELEMETRY_EVENT_CREATED,
TELEMETRY_EVENT_FINISHED,
TELEMETRY_CODE_COVERAGE_STARTED,
TELEMETRY_CODE_COVERAGE_FINISHED,
TELEMETRY_ITR_FORCED_TO_RUN,
TELEMETRY_CODE_COVERAGE_EMPTY,
TELEMETRY_ITR_UNSKIPPABLE,
TELEMETRY_CODE_COVERAGE_NUM_FILES,
TELEMETRY_TEST_SESSION
} = require('../../dd-trace/src/ci-visibility/telemetry')
const log = require('../../dd-trace/src/log')
const isJestWorker = !!getEnvironmentVariable('JEST_WORKER_ID')
// https://github.com/facebook/jest/blob/d6ad15b0f88a05816c2fe034dd6900d28315d570/packages/jest-worker/src/types.ts#L38
const CHILD_MESSAGE_END = 2
function withTimeout (promise, timeoutMs) {
return new Promise(resolve => {
// Set a timeout to resolve after 1s
setTimeout(resolve, timeoutMs)
// Also resolve if the original promise resolves
promise.then(resolve)
})
}
class JestPlugin extends CiPlugin {
static id = 'jest'
// The lists are the same for every test suite, so we can cache them
getUnskippableSuites (unskippableSuitesList) {
if (!this.unskippableSuites) {
this.unskippableSuites = JSON.parse(unskippableSuitesList)
}
return this.unskippableSuites
}
getForcedToRunSuites (forcedToRunSuitesList) {
if (!this.forcedToRunSuites) {
this.forcedToRunSuites = JSON.parse(forcedToRunSuitesList)
}
return this.forcedToRunSuites
}
constructor (...args) {
super(...args)
if (isJestWorker) {
// Used to handle the end of a jest worker to be able to flush
const handler = ([message]) => {
if (message === CHILD_MESSAGE_END) {
// testSuiteSpan is not defined for older versions of jest, where jest-jasmine2 is still used
if (this.testSuiteSpan) {
this.testSuiteSpan.finish()
finishAllTraceSpans(this.testSuiteSpan)
}
this.tracer._exporter.flush()
process.removeListener('message', handler)
}
}
process.on('message', handler)
}
this.testSuiteSpanPerTestSuiteAbsolutePath = new Map()
this.addSub('ci:jest:session:finish', ({
status,
isSuitesSkipped,
isSuitesSkippingEnabled,
isCodeCoverageEnabled,
testCodeCoverageLinesTotal,
numSkippedSuites,
hasUnskippableSuites,
hasForcedToRunSuites,
error,
isEarlyFlakeDetectionEnabled,
isEarlyFlakeDetectionFaulty,
isTestManagementTestsEnabled,
onDone
}) => {
this.testSessionSpan.setTag(TEST_STATUS, status)
this.testModuleSpan.setTag(TEST_STATUS, status)
if (error) {
this.testSessionSpan.setTag('error', error)
this.testModuleSpan.setTag('error', error)
}
addIntelligentTestRunnerSpanTags(
this.testSessionSpan,
this.testModuleSpan,
{
isSuitesSkipped,
isSuitesSkippingEnabled,
isCodeCoverageEnabled,
testCodeCoverageLinesTotal,
skippingType: 'suite',
skippingCount: numSkippedSuites,
hasUnskippableSuites,
hasForcedToRunSuites
}
)
if (isEarlyFlakeDetectionEnabled) {
this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ENABLED, 'true')
}
if (isEarlyFlakeDetectionFaulty) {
this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ABORT_REASON, 'faulty')
}
if (isTestManagementTestsEnabled) {
this.testSessionSpan.setTag(TEST_MANAGEMENT_ENABLED, 'true')
}
this.testModuleSpan.finish()
this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'module')
this.testSessionSpan.finish()
this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'session')
finishAllTraceSpans(this.testSessionSpan)
this.telemetry.count(TELEMETRY_TEST_SESSION, {
provider: this.ciProviderName,
autoInjected: !!getEnvironmentVariable('DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER')
})
this.tracer._exporter.flush(() => {
if (onDone) {
onDone()
}
})
})
// Test suites can be run in a different process from jest's main one.
// This subscriber changes the configuration objects from jest to inject the trace id
// of the test session to the processes that run the test suites, and other data.
this.addSub('ci:jest:session:configuration', configs => {
configs.forEach(config => {
config._ddTestSessionId = this.testSessionSpan.context().toTraceId()
config._ddTestModuleId = this.testModuleSpan.context().toSpanId()
config._ddTestCommand = this.testSessionSpan.context()._tags[TEST_COMMAND]
config._ddItrCorrelationId = this.itrCorrelationId
config._ddIsEarlyFlakeDetectionEnabled = !!this.libraryConfig?.isEarlyFlakeDetectionEnabled
config._ddEarlyFlakeDetectionNumRetries = this.libraryConfig?.earlyFlakeDetectionNumRetries ?? 0
config._ddRepositoryRoot = this.repositoryRoot
config._ddIsFlakyTestRetriesEnabled = this.libraryConfig?.isFlakyTestRetriesEnabled ?? false
config._ddIsTestManagementTestsEnabled = this.libraryConfig?.isTestManagementEnabled ?? false
config._ddTestManagementAttemptToFixRetries = this.libraryConfig?.testManagementAttemptToFixRetries ?? 0
config._ddFlakyTestRetriesCount = this.libraryConfig?.flakyTestRetriesCount
config._ddIsDiEnabled = this.libraryConfig?.isDiEnabled ?? false
config._ddIsKnownTestsEnabled = this.libraryConfig?.isKnownTestsEnabled ?? false
config._ddIsImpactedTestsEnabled = this.libraryConfig?.isImpactedTestsEnabled ?? false
})
})
this.addSub('ci:jest:test-suite:start', ({
testSuite,
testSourceFile,
testEnvironmentOptions,
frameworkVersion,
displayName,
testSuiteAbsolutePath
}) => {
const {
_ddTestSessionId: testSessionId,
_ddTestCommand: testCommand,
_ddTestModuleId: testModuleId,
_ddItrCorrelationId: itrCorrelationId,
_ddForcedToRun,
_ddUnskippable,
_ddTestCodeCoverageEnabled
} = testEnvironmentOptions
const testSessionSpanContext = this.tracer.extract('text_map', {
'x-datadog-trace-id': testSessionId,
'x-datadog-parent-id': testModuleId
})
const testSuiteMetadata = getTestSuiteCommonTags(testCommand, frameworkVersion, testSuite, 'jest')
if (_ddUnskippable) {
const unskippableSuites = this.getUnskippableSuites(_ddUnskippable)
if (unskippableSuites[testSuite]) {
this.telemetry.count(TELEMETRY_ITR_UNSKIPPABLE, { testLevel: 'suite' })
testSuiteMetadata[TEST_ITR_UNSKIPPABLE] = 'true'
}
if (_ddForcedToRun) {
const forcedToRunSuites = this.getForcedToRunSuites(_ddForcedToRun)
if (forcedToRunSuites[testSuite]) {
this.telemetry.count(TELEMETRY_ITR_FORCED_TO_RUN, { testLevel: 'suite' })
testSuiteMetadata[TEST_ITR_FORCED_RUN] = 'true'
}
}
}
if (itrCorrelationId) {
testSuiteMetadata[ITR_CORRELATION_ID] = itrCorrelationId
}
if (displayName) {
testSuiteMetadata[JEST_DISPLAY_NAME] = displayName
}
if (testSourceFile) {
testSuiteMetadata[TEST_SOURCE_FILE] = testSourceFile
// Test suite is the whole test file, so we can use the first line as the start
testSuiteMetadata[TEST_SOURCE_START] = 1
}
const codeOwners = this.getCodeOwners(testSuiteMetadata)
if (codeOwners) {
testSuiteMetadata[TEST_CODE_OWNERS] = codeOwners
}
this.testSuiteSpan = this.tracer.startSpan('jest.test_suite', {
childOf: testSessionSpanContext,
tags: {
[COMPONENT]: this.constructor.id,
...this.testEnvironmentMetadata,
...testSuiteMetadata
},
integrationName: this.constructor.id
})
this.telemetry.ciVisEvent(TELEMETRY_EVENT_CREATED, 'suite')
if (_ddTestCodeCoverageEnabled) {
this.telemetry.ciVisEvent(TELEMETRY_CODE_COVERAGE_STARTED, 'suite', { library: 'istanbul' })
}
this.testSuiteSpanPerTestSuiteAbsolutePath.set(testSuiteAbsolutePath, this.testSuiteSpan)
})
this.addSub('ci:jest:worker-report:coverage', data => {
const formattedCoverages = JSON.parse(data).map(coverage => ({
sessionId: id(coverage.sessionId),
suiteId: id(coverage.suiteId),
files: coverage.files
}))
formattedCoverages.forEach(formattedCoverage => {
this.tracer._exporter.exportCoverage(formattedCoverage)
})
})
this.addSub('ci:jest:test-suite:finish', ({ status, errorMessage, error, testSuiteAbsolutePath }) => {
const testSuiteSpan = this.testSuiteSpanPerTestSuiteAbsolutePath.get(testSuiteAbsolutePath)
if (!testSuiteSpan) {
log.warn('"ci:jest:test-suite:finish": no span found for test suite absolute path %s', testSuiteAbsolutePath)
return
}
testSuiteSpan.setTag(TEST_STATUS, status)
if (error) {
testSuiteSpan.setTag('error', error)
testSuiteSpan.setTag(TEST_STATUS, 'fail')
} else if (errorMessage) {
testSuiteSpan.setTag('error', new Error(errorMessage))
testSuiteSpan.setTag(TEST_STATUS, 'fail')
}
// We need to give the potential error in 'ci:jest:test-suite:error' time to be published
process.nextTick(() => {
testSuiteSpan.finish()
this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'suite')
// Suites potentially run in a different process than the session,
// so calling finishAllTraceSpans on the session span is not enough
finishAllTraceSpans(testSuiteSpan)
// Flushing within jest workers is cheap, as it's just interprocess communication
// We do not want to flush after every suite if jest is running tests serially,
// as every flush is an HTTP request.
if (isJestWorker) {
this.tracer._exporter.flush()
}
this.removeAllDiProbes()
this.testSuiteSpanPerTestSuiteAbsolutePath.delete(testSuiteAbsolutePath)
})
})
this.addSub('ci:jest:test-suite:error', ({ error, errorMessage, testSuiteAbsolutePath }) => {
const runningTestSuiteSpan = this.testSuiteSpanPerTestSuiteAbsolutePath.get(testSuiteAbsolutePath)
if (!runningTestSuiteSpan) {
log.warn('"ci:jest:test-suite:error": no span found for test suite absolute path %s', testSuiteAbsolutePath)
return
}
if (error) {
runningTestSuiteSpan.setTag('error', error)
} else if (errorMessage) {
runningTestSuiteSpan.setTag('error', new Error(errorMessage))
}
})
/**
* This can't use `this.libraryConfig` like `ci:mocha:test-suite:code-coverage`
* because this subscription happens in a different process from the one
* fetching the ITR config.
*/
this.addSub('ci:jest:test-suite:code-coverage', ({ coverageFiles, testSuite, mockedFiles }) => {
if (!coverageFiles.length) {
this.telemetry.count(TELEMETRY_CODE_COVERAGE_EMPTY)
}
const files = [...coverageFiles, ...mockedFiles, testSuite]
const { _traceId, _spanId } = this.testSuiteSpan.context()
const formattedCoverage = {
sessionId: _traceId,
suiteId: _spanId,
files
}
this.tracer._exporter.exportCoverage(formattedCoverage)
this.telemetry.ciVisEvent(TELEMETRY_CODE_COVERAGE_FINISHED, 'suite', { library: 'istanbul' })
this.telemetry.distribution(TELEMETRY_CODE_COVERAGE_NUM_FILES, {}, files.length)
})
this.addBind('ci:jest:test:start', (ctx) => {
const store = storage('legacy').getStore()
const span = this.startTestSpan(ctx)
ctx.parentStore = store
ctx.currentStore = { ...store, span }
this.activeTestSpan = span
return ctx.currentStore
})
this.addBind('ci:jest:test:fn', (ctx) => {
return ctx.currentStore
})
this.addSub('ci:jest:test:finish', ({
span,
status,
testStartLine,
attemptToFixPassed,
failedAllTests,
attemptToFixFailed,
isAtrRetry
}) => {
span.setTag(TEST_STATUS, status)
if (testStartLine) {
span.setTag(TEST_SOURCE_START, testStartLine)
}
if (attemptToFixPassed) {
span.setTag(TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, 'true')
} else if (attemptToFixFailed) {
span.setTag(TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, 'false')
}
if (failedAllTests) {
span.setTag(TEST_HAS_FAILED_ALL_RETRIES, 'true')
}
if (isAtrRetry) {
span.setTag(TEST_IS_RETRY, 'true')
span.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.atr)
}
const spanTags = span.context()._tags
this.telemetry.ciVisEvent(
TELEMETRY_EVENT_FINISHED,
'test',
{
hasCodeOwners: !!spanTags[TEST_CODE_OWNERS],
isNew: spanTags[TEST_IS_NEW] === 'true',
isRum: spanTags[TEST_IS_RUM_ACTIVE] === 'true',
browserDriver: spanTags[TEST_BROWSER_DRIVER]
}
)
span.finish()
finishAllTraceSpans(span)
this.activeTestSpan = null
})
this.addSub('ci:jest:test:err', ({ span, error, shouldSetProbe, promises }) => {
if (error && span) {
span.setTag(TEST_STATUS, 'fail')
span.setTag('error', getFormattedError(error, this.repositoryRoot))
if (shouldSetProbe) {
const probeInformation = this.addDiProbe(error)
if (probeInformation) {
const { setProbePromise } = probeInformation
promises.isProbeReady = withTimeout(setProbePromise, 2000)
}
}
}
})
this.addSub('ci:jest:test:skip', ({
test,
isDisabled
}) => {
const span = this.startTestSpan(test)
span.setTag(TEST_STATUS, 'skip')
if (isDisabled) {
span.setTag(TEST_MANAGEMENT_IS_DISABLED, 'true')
}
span.finish()
})
}
startTestSpan (test) {
const {
suite,
name,
displayName,
testParameters,
frameworkVersion,
testStartLine,
testSourceFile,
isNew,
isEfdRetry,
isAttemptToFix,
isAttemptToFixRetry,
isJestRetry,
isDisabled,
isQuarantined,
isModified,
testSuiteAbsolutePath
} = test
const extraTags = {
[JEST_TEST_RUNNER]: 'jest-circus',
[TEST_PARAMETERS]: testParameters,
[TEST_FRAMEWORK_VERSION]: frameworkVersion
}
if (testStartLine) {
extraTags[TEST_SOURCE_START] = testStartLine
}
// If for whatever we don't have the source file, we'll fall back to the suite name
extraTags[TEST_SOURCE_FILE] = testSourceFile || suite
if (displayName) {
extraTags[JEST_DISPLAY_NAME] = displayName
}
if (isAttemptToFix) {
extraTags[TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX] = 'true'
}
if (isAttemptToFixRetry) {
extraTags[TEST_IS_RETRY] = 'true'
extraTags[TEST_RETRY_REASON] = TEST_RETRY_REASON_TYPES.atf
} else if (isEfdRetry) {
extraTags[TEST_IS_RETRY] = 'true'
extraTags[TEST_RETRY_REASON] = TEST_RETRY_REASON_TYPES.efd
} else if (isJestRetry) {
extraTags[TEST_IS_RETRY] = 'true'
extraTags[TEST_RETRY_REASON] = TEST_RETRY_REASON_TYPES.ext
}
if (isDisabled) {
extraTags[TEST_MANAGEMENT_IS_DISABLED] = 'true'
}
if (isQuarantined) {
extraTags[TEST_MANAGEMENT_IS_QUARANTINED] = 'true'
}
if (isModified) {
extraTags[TEST_IS_MODIFIED] = 'true'
}
if (isNew) {
extraTags[TEST_IS_NEW] = 'true'
}
const testSuiteSpan = this.testSuiteSpanPerTestSuiteAbsolutePath.get(testSuiteAbsolutePath) || this.testSuiteSpan
return super.startTestSpan(name, suite, testSuiteSpan, extraTags)
}
}
module.exports = JestPlugin