UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

1,273 lines (1,135 loc) 79 kB
'use strict' // Capture real timers at module load time, before any test can install fake timers. const realSetTimeout = setTimeout const path = require('path') const shimmer = require('../../datadog-shimmer') const log = require('../../dd-trace/src/log') const { getCoveredFilenamesFromCoverage, JEST_WORKER_TRACE_PAYLOAD_CODE, JEST_WORKER_COVERAGE_PAYLOAD_CODE, JEST_WORKER_TELEMETRY_PAYLOAD_CODE, JEST_WORKER_QUARANTINE_PAYLOAD_CODE, getTestLineStart, getTestSuitePath, getTestParametersString, getIsFaultyEarlyFlakeDetection, JEST_WORKER_LOGS_PAYLOAD_CODE, getTestEndLine, isModifiedTest, DYNAMIC_NAME_RE, collectDynamicNamesFromTraces, } = require('../../dd-trace/src/plugins/util/test') const { SEED_SUFFIX_RE, getFormattedJestTestParameters, getJestTestName, getJestSuitesToRun, getEfdRetryCount, } = require('../../datadog-plugin-jest/src/util') const { addHook, channel } = require('./helpers/instrument') const testSessionStartCh = channel('ci:jest:session:start') const testSessionFinishCh = channel('ci:jest:session:finish') const codeCoverageReportCh = channel('ci:jest:coverage-report') const testSessionConfigurationCh = channel('ci:jest:session:configuration') const testSuiteStartCh = channel('ci:jest:test-suite:start') const testSuiteFinishCh = channel('ci:jest:test-suite:finish') const testSuiteErrorCh = channel('ci:jest:test-suite:error') const workerReportTraceCh = channel('ci:jest:worker-report:trace') const workerReportCoverageCh = channel('ci:jest:worker-report:coverage') const workerReportLogsCh = channel('ci:jest:worker-report:logs') const workerReportTelemetryCh = channel('ci:jest:worker-report:telemetry') const testSuiteCodeCoverageCh = channel('ci:jest:test-suite:code-coverage') const testStartCh = channel('ci:jest:test:start') const testSkippedCh = channel('ci:jest:test:skip') const testFinishCh = channel('ci:jest:test:finish') const testErrCh = channel('ci:jest:test:err') const testFnCh = channel('ci:jest:test:fn') const testSuiteHookFnCh = channel('ci:jest:test-suite:hook:fn') const skippableSuitesCh = channel('ci:jest:test-suite:skippable') const libraryConfigurationCh = channel('ci:jest:library-configuration') const knownTestsCh = channel('ci:jest:known-tests') const testManagementTestsCh = channel('ci:jest:test-management-tests') const modifiedFilesCh = channel('ci:jest:modified-files') const itrSkippedSuitesCh = channel('ci:jest:itr:skipped-suites') // Message sent by jest's main process to workers to run a test suite (=test file) // https://github.com/jestjs/jest/blob/1d682f21c7a35da4d3ab3a1436a357b980ebd0fa/packages/jest-worker/src/types.ts#L37 const CHILD_MESSAGE_CALL = 1 // Maximum time we'll wait for the tracer to flush const FLUSH_TIMEOUT = 10_000 // https://github.com/jestjs/jest/blob/41f842a46bb2691f828c3a5f27fc1d6290495b82/packages/jest-circus/src/types.ts#L9C8-L9C54 const RETRY_TIMES = Symbol.for('RETRY_TIMES') let skippableSuites = [] let knownTests = {} let isCodeCoverageEnabled = false let isCodeCoverageEnabledBecauseOfUs = false let isSuitesSkippingEnabled = false let isKeepingCoverageConfiguration = false let isUserCodeCoverageEnabled = false let isSuitesSkipped = false let numSkippedSuites = 0 let hasUnskippableSuites = false let hasForcedToRunSuites = false let isEarlyFlakeDetectionEnabled = false let earlyFlakeDetectionNumRetries = 0 let earlyFlakeDetectionSlowTestRetries = {} let earlyFlakeDetectionFaultyThreshold = 30 let isEarlyFlakeDetectionFaulty = false let hasFilteredSkippableSuites = false let isKnownTestsEnabled = false let isTestManagementTestsEnabled = false let testManagementTests = {} let testManagementAttemptToFixRetries = 0 let isImpactedTestsEnabled = false let modifiedFiles = {} const testContexts = new WeakMap() const originalTestFns = new WeakMap() const originalHookFns = new WeakMap() const retriedTestsToNumAttempts = new Map() const newTestsTestStatuses = new Map() const attemptToFixRetriedTestsStatuses = new Map() const wrappedWorkerChannels = new WeakMap() // New tests whose names contain likely dynamic data (timestamps, UUIDs, etc.) // Populated in-process for runInBand, and via worker-report:trace for parallel mode. const newTestsWithDynamicNames = new Set() const testSuiteMockedFiles = new Map() const testsToBeRetried = new Set() // Per-test: how many EFD retries were determined after the first execution. const efdDeterminedRetries = new Map() // Tests whose first run exceeded the 5-min threshold — tagged "slow". const efdSlowAbortedTests = new Set() // Tests added as EFD new-test candidates (not ATF, not impacted). const efdNewTestCandidates = new Set() // Tests that are genuinely new (not in known tests list). const newTests = new Set() const testSuiteAbsolutePathsWithFastCheck = new Set() const testSuiteJestObjects = new Map() const BREAKPOINT_HIT_GRACE_PERIOD_MS = 200 const ATR_RETRY_SUPPRESSION_FLAG = '_ddDisableAtrRetry' const atrSuppressedErrors = new Map() // Track quarantined tests whose errors were suppressed, keyed by "suite › testName" const quarantinedFailingTests = new Set() /** * Sends suppressed quarantine test names from a worker process to the main process. * Supports both child_process (process.send) and worker_threads (parentPort.postMessage). * Returns true if the data was sent (worker mode), false if in main process (runInBand). * * @param {string[]} testNames * @returns {boolean} */ function sendQuarantineInfoToMainProcess (testNames) { const payload = [JEST_WORKER_QUARANTINE_PAYLOAD_CODE, JSON.stringify(testNames)] if (process.send) { process.send(payload) return true } try { const { isMainThread, parentPort } = require('node:worker_threads') if (!isMainThread && parentPort) { parentPort.postMessage(payload) return true } } catch { // Not in a worker context } return false } // based on https://github.com/facebook/jest/blob/main/packages/jest-circus/src/formatNodeAssertErrors.ts#L41 function formatJestError (errors) { let error if (Array.isArray(errors)) { const [originalError, asyncError] = errors if (originalError === null || !originalError.stack) { error = asyncError error.message = originalError } else { error = originalError } } else { error = errors } return error } function getTestEnvironmentOptions (config) { if (config.projectConfig && config.projectConfig.testEnvironmentOptions) { // newer versions return config.projectConfig.testEnvironmentOptions } if (config.testEnvironmentOptions) { return config.testEnvironmentOptions } return {} } const MAX_IGNORED_TEST_NAMES = 10 function getTestStats (testStatuses) { return testStatuses.reduce((acc, testStatus) => { acc[testStatus]++ return acc }, { pass: 0, fail: 0 }) } /** * @param {string[]} efdNames * @param {string[]} quarantineNames * @param {number} totalCount */ /** * Renders a truncated bullet list from an array of items. * * @param {Array<{ text: string, suffix?: string }>} items * @returns {string} */ function formatList (items) { const shown = items.slice(0, MAX_IGNORED_TEST_NAMES) const more = items.length - shown.length const moreSuffix = more > 0 ? `\n ... and ${more} more` : '' return shown.map(({ text, suffix }) => ` • ${text}${suffix ? ` (${suffix})` : ''}`).join('\n') + moreSuffix } /** * Logs a single "Datadog Test Optimization" summary at session end, * combining all relevant sections (ignored failures, dynamic names). * * @param {{ efdNames: string[], quarantineNames: string[], totalCount: number } | undefined} ignoredFailures */ function logSessionSummary (ignoredFailures) { const sections = [] if (ignoredFailures) { const items = [] for (const n of ignoredFailures.efdNames) { items.push({ text: n, suffix: 'Early Flake Detection' }) } for (const n of ignoredFailures.quarantineNames) { items.push({ text: n, suffix: 'Quarantine' }) } sections.push( `${ignoredFailures.totalCount} test failure(s) were ignored. Exit code set to 0.\n\n` + formatList(items) ) } if (newTestsWithDynamicNames.size > 0) { const items = [...newTestsWithDynamicNames].map(name => ({ text: name })) sections.push( `${newTestsWithDynamicNames.size} test(s) detected as new but their names contain ` + 'dynamic data (timestamps, UUIDs, etc.).\n' + 'Tests with changing names are always treated as new on every run, ' + 'causing unnecessary Early Flake Detection retries and preventing correct new test detection.\n' + 'Consider using stable, deterministic test names.\n\n' + formatList(items) ) newTestsWithDynamicNames.clear() } if (sections.length === 0) return const line = '-'.repeat(50) // eslint-disable-next-line no-console -- Intentional user-facing session summary console.warn(`\n${line}\nDatadog Test Optimization\n${line}\n${sections.join('\n\n')}\n`) } function getWrappedEnvironment (BaseEnvironment, jestVersion) { return class DatadogEnvironment extends BaseEnvironment { constructor (config, context) { super(config, context) const rootDir = config.globalConfig ? config.globalConfig.rootDir : config.rootDir this.rootDir = rootDir this.testSuite = getTestSuitePath(context.testPath, rootDir) this.nameToParams = {} this.global._ddtrace = global._ddtrace this.hasSnapshotTests = undefined this.testSuiteAbsolutePath = context.testPath this.displayName = config.projectConfig?.displayName?.name || config.displayName this.testEnvironmentOptions = getTestEnvironmentOptions(config) const repositoryRoot = this.testEnvironmentOptions._ddRepositoryRoot // TODO: could we grab testPath from `this.getVmContext().expect.getState()` instead? // so we don't rely on context being passed (some custom test environment do not pass it) if (repositoryRoot) { this.testSourceFile = getTestSuitePath(context.testPath, repositoryRoot) this.repositoryRoot = repositoryRoot } this.isEarlyFlakeDetectionEnabled = this.testEnvironmentOptions._ddIsEarlyFlakeDetectionEnabled this.isFlakyTestRetriesEnabled = this.testEnvironmentOptions._ddIsFlakyTestRetriesEnabled this.flakyTestRetriesCount = this.testEnvironmentOptions._ddFlakyTestRetriesCount this.isDiEnabled = this.testEnvironmentOptions._ddIsDiEnabled this.isKnownTestsEnabled = this.testEnvironmentOptions._ddIsKnownTestsEnabled this.isTestManagementTestsEnabled = this.testEnvironmentOptions._ddIsTestManagementTestsEnabled this.isImpactedTestsEnabled = this.testEnvironmentOptions._ddIsImpactedTestsEnabled if (this.isKnownTestsEnabled) { earlyFlakeDetectionSlowTestRetries = this.testEnvironmentOptions._ddEarlyFlakeDetectionSlowTestRetries ?? {} try { this.knownTestsForThisSuite = this.getKnownTestsForSuite(this.testEnvironmentOptions._ddKnownTests) if (!Array.isArray(this.knownTestsForThisSuite)) { log.warn('this.knownTestsForThisSuite is not an array so new test and Early Flake detection is disabled.') this.isEarlyFlakeDetectionEnabled = false this.isKnownTestsEnabled = false } } catch { // If there has been an error parsing the tests, we'll disable Early Flake Deteciton this.isEarlyFlakeDetectionEnabled = false this.isKnownTestsEnabled = false } } if (this.isFlakyTestRetriesEnabled) { const currentNumRetries = this.global[RETRY_TIMES] if (!currentNumRetries) { this.global[RETRY_TIMES] = this.flakyTestRetriesCount } } if (this.isTestManagementTestsEnabled) { try { const hasTestManagementTests = !!testManagementTests?.jest testManagementAttemptToFixRetries = this.testEnvironmentOptions._ddTestManagementAttemptToFixRetries this.testManagementTestsForThisSuite = hasTestManagementTests ? this.getTestManagementTestsForSuite(testManagementTests?.jest?.suites?.[this.testSuite]?.tests) : this.getTestManagementTestsForSuite(this.testEnvironmentOptions._ddTestManagementTests) } catch (e) { log.error('Error parsing test management tests', e) this.isTestManagementTestsEnabled = false } } if (this.isImpactedTestsEnabled) { try { const hasImpactedTests = Object.keys(modifiedFiles).length > 0 this.modifiedFiles = hasImpactedTests ? modifiedFiles : this.testEnvironmentOptions._ddModifiedFiles } catch (e) { log.error('Error parsing impacted tests', e) this.isImpactedTestsEnabled = false } } } /** * Jest snapshot counter issue during test retries * * Problem: * - Jest tracks snapshot calls using an internal counter per test name * - Each `toMatchSnapshot()` call increments this counter * - When a test is retried, it keeps the same name but the counter continues from where it left off * * Example Issue: * Original test run creates: `exports["test can do multiple snapshots 1"] = "hello"` * Retried test expects: `exports["test can do multiple snapshots 2"] = "hello"` * * This mismatch causes snapshot tests to fail on retry because Jest is looking * for the wrong snapshot number. The solution is to reset the snapshot state. */ resetSnapshotState () { try { const expectGlobal = this.getVmContext().expect const { snapshotState: { _counters: counters } } = expectGlobal.getState() if (counters) { counters.clear() } } catch (e) { log.warn('Error resetting snapshot state', e) } } /** * Jest mock state issue during test retries * * Problem: * - Jest tracks mock function calls using internal state (call count, call arguments, etc.) * - When a test is retried, the mock state is not automatically reset * - This causes assertions like `toHaveBeenCalledTimes(1)` to fail because the call count * accumulates across retries * * The solution is to clear all mocks before each retry attempt. */ resetMockState () { try { const jestObject = testSuiteJestObjects.get(this.testSuiteAbsolutePath) if (jestObject?.clearAllMocks) { jestObject.clearAllMocks() } } catch (e) { log.warn('Error resetting mock state', e) } } // This function returns an array if the known tests are valid and null otherwise. getKnownTestsForSuite (suiteKnownTests) { // `suiteKnownTests` is `this.testEnvironmentOptions._ddKnownTests`, // which is only set if jest is configured to run in parallel. if (suiteKnownTests) { return suiteKnownTests } // Global variable `knownTests` is set only in the main process. // If jest is configured to run serially, the tests run in the same process, so `knownTests` is set. // The assumption is that if the key `jest` is defined in the dictionary, the response is valid. if (knownTests?.jest) { return knownTests.jest[this.testSuite] || [] } return null } getTestManagementTestsForSuite (testManagementTests) { if (this.testManagementTestsForThisSuite) { return this.testManagementTestsForThisSuite } if (!testManagementTests) { return { attemptToFix: [], disabled: [], quarantined: [], } } let testManagementTestsForSuite = testManagementTests // If jest is using workers, test management tests are serialized to json. // If jest runs in band, they are not. if (typeof testManagementTestsForSuite === 'string') { testManagementTestsForSuite = JSON.parse(testManagementTestsForSuite) } const result = { attemptToFix: [], disabled: [], quarantined: [], } for (const [testName, { properties }] of Object.entries(testManagementTestsForSuite)) { if (properties?.attempt_to_fix) { result.attemptToFix.push(testName) } if (properties?.disabled) { result.disabled.push(testName) } if (properties?.quarantined) { result.quarantined.push(testName) } } return result } // Generic function to handle test retries retryTest ({ jestEvent, retryCount, retryType, }) { const { testName, fn, timeout } = jestEvent for (let retryIndex = 0; retryIndex < retryCount; retryIndex++) { if (this.global.test) { this.global.test(testName, fn, timeout) } else { log.error('%s could not retry test because global.test is undefined', retryType) } } } getShouldStripSeedFromTestName () { return testSuiteAbsolutePathsWithFastCheck.has(this.testSuiteAbsolutePath) } // At the `add_test` event we don't have the test object yet, so we can't use it getTestNameFromAddTestEvent (event, state) { const describeSuffix = getJestTestName(state.currentDescribeBlock, this.getShouldStripSeedFromTestName()) return describeSuffix ? `${describeSuffix} ${event.testName}` : event.testName } async handleTestEvent (event, state) { if (super.handleTestEvent) { await super.handleTestEvent(event, state) } const setNameToParams = (name, params) => { this.nameToParams[name] = [...params] } if (event.name === 'setup' && this.global.test) { shimmer.wrap(this.global.test, 'each', each => function () { const testParameters = getFormattedJestTestParameters(arguments) const eachBind = each.apply(this, arguments) return function () { const [testName] = arguments setNameToParams(testName, testParameters) return eachBind.apply(this, arguments) } }) } if (event.name === 'test_start') { const testName = getJestTestName(event.test, this.getShouldStripSeedFromTestName()) if (testsToBeRetried.has(testName)) { // This is needed because we're retrying tests with the same name this.resetSnapshotState() this.resetMockState() } let isNewTest = false let numEfdRetry = null let numOfAttemptsToFixRetries = null const testParameters = getTestParametersString(this.nameToParams, event.test.name) let isAttemptToFix = false let isDisabled = false let isQuarantined = false if (this.isTestManagementTestsEnabled) { isAttemptToFix = this.testManagementTestsForThisSuite?.attemptToFix?.includes(testName) isDisabled = this.testManagementTestsForThisSuite?.disabled?.includes(testName) isQuarantined = this.testManagementTestsForThisSuite?.quarantined?.includes(testName) if (isAttemptToFix) { numOfAttemptsToFixRetries = retriedTestsToNumAttempts.get(testName) retriedTestsToNumAttempts.set(testName, numOfAttemptsToFixRetries + 1) } else if (isDisabled) { event.test.mode = 'skip' } } let isModified = false if (this.isImpactedTestsEnabled) { const testStartLine = getTestLineStart(event.test.asyncError, this.testSuite) const testEndLine = getTestEndLine(event.test.fn, testStartLine) isModified = isModifiedTest( this.testSourceFile, testStartLine, testEndLine, this.modifiedFiles, 'jest' ) } if (this.isKnownTestsEnabled) { isNewTest = newTests.has(testName) } const willRunEfd = this.isEarlyFlakeDetectionEnabled && (isNewTest || isModified) event.test[ATR_RETRY_SUPPRESSION_FLAG] = Boolean(isAttemptToFix || willRunEfd) if (!isAttemptToFix && willRunEfd) { numEfdRetry = retriedTestsToNumAttempts.get(testName) retriedTestsToNumAttempts.set(testName, numEfdRetry + 1) } const isJestRetry = event.test?.invocations > 1 const hasDynamicName = isNewTest && DYNAMIC_NAME_RE.test(testName) const ctx = { name: testName, suite: this.testSuite, testSourceFile: this.testSourceFile, displayName: this.displayName, testParameters, frameworkVersion: jestVersion, isNew: isNewTest, isEfdRetry: numEfdRetry > 0, isAttemptToFix, isAttemptToFixRetry: numOfAttemptsToFixRetries > 0, isJestRetry, isDisabled, isQuarantined, isModified, hasDynamicName, testSuiteAbsolutePath: this.testSuiteAbsolutePath, } testContexts.set(event.test, ctx) testStartCh.runStores(ctx, () => { let p = event.test.parent const hooks = [] while (p != null) { hooks.push(...p.hooks) p = p.parent } for (const hook of hooks) { let hookFn = hook.fn if (originalHookFns.has(hook)) { hookFn = originalHookFns.get(hook) } else { originalHookFns.set(hook, hookFn) } const newHookFn = shimmer.wrapFunction(hookFn, hookFn => function () { return testFnCh.runStores(ctx, () => hookFn.apply(this, arguments)) }) hook.fn = newHookFn } const originalFn = event.test.fn originalTestFns.set(event.test, originalFn) const newFn = shimmer.wrapFunction(event.test.fn, testFn => function () { return testFnCh.runStores(ctx, () => testFn.apply(this, arguments)) }) event.test.fn = newFn }) } if (event.name === 'hook_start' && (event.hook.type === 'beforeAll' || event.hook.type === 'afterAll')) { const ctx = { testSuiteAbsolutePath: this.testSuiteAbsolutePath } let hookFn = event.hook.fn if (originalHookFns.has(event.hook)) { hookFn = originalHookFns.get(event.hook) } else { originalHookFns.set(event.hook, hookFn) } event.hook.fn = shimmer.wrapFunction(hookFn, hookFn => function () { return testSuiteHookFnCh.runStores(ctx, () => hookFn.apply(this, arguments)) }) } if (event.name === 'add_test') { if (event.failing) { return } const testFullName = this.getTestNameFromAddTestEvent(event, state) const isSkipped = event.mode === 'todo' || event.mode === 'skip' const isAttemptToFix = this.isTestManagementTestsEnabled && this.testManagementTestsForThisSuite?.attemptToFix?.includes(testFullName) if ( isAttemptToFix && !isSkipped && !retriedTestsToNumAttempts.has(testFullName) ) { retriedTestsToNumAttempts.set(testFullName, 0) testsToBeRetried.add(testFullName) this.retryTest({ jestEvent: event, retryCount: testManagementAttemptToFixRetries, retryType: 'Test Management (Attempt to Fix)', }) } if (!isAttemptToFix && this.isImpactedTestsEnabled) { const testStartLine = getTestLineStart(event.asyncError, this.testSuite) const testEndLine = getTestEndLine(event.fn, testStartLine) const isModified = isModifiedTest( this.testSourceFile, testStartLine, testEndLine, this.modifiedFiles, 'jest' ) if (isModified && !retriedTestsToNumAttempts.has(testFullName) && this.isEarlyFlakeDetectionEnabled) { retriedTestsToNumAttempts.set(testFullName, 0) testsToBeRetried.add(testFullName) this.retryTest({ jestEvent: event, retryCount: earlyFlakeDetectionNumRetries, retryType: 'Impacted tests', }) } } if (!isAttemptToFix && this.isKnownTestsEnabled) { const isNew = !this.knownTestsForThisSuite.includes(testFullName) if (isNew && !isSkipped) { newTests.add(testFullName) } if (isNew && !isSkipped && !retriedTestsToNumAttempts.has(testFullName)) { if (DYNAMIC_NAME_RE.test(testFullName)) { // Populated directly for runInBand; for parallel workers the main process // collects these from the TEST_HAS_DYNAMIC_NAME span tag via worker-report:trace. newTestsWithDynamicNames.add(`${this.testSuite} › ${testFullName}`) } retriedTestsToNumAttempts.set(testFullName, 0) if (this.isEarlyFlakeDetectionEnabled) { testsToBeRetried.add(testFullName) efdNewTestCandidates.add(testFullName) // Cloning is deferred to test_done after the first execution, // when we know the duration and can choose the right retry count. } } } } if (event.name === 'test_done') { const originalError = event.test?.errors?.[0] let status = 'pass' if (event.test.errors && event.test.errors.length) { status = 'fail' } // restore in case it is retried event.test.fn = originalTestFns.get(event.test) // If ATR retry is being suppressed for this test (due to EFD or Attempt to Fix taking precedence) // and the test has errors for this attempt, store the errors temporarily and clear them // so Jest won't treat this attempt as failed (the real status will be reported after retries). if (event.test?.[ATR_RETRY_SUPPRESSION_FLAG] && event.test.errors?.length) { atrSuppressedErrors.set(event.test, event.test.errors) event.test.errors = [] } let attemptToFixPassed = false let attemptToFixFailed = false let failedAllTests = false let isAttemptToFix = false const testName = getJestTestName(event.test, this.getShouldStripSeedFromTestName()) if (this.isTestManagementTestsEnabled) { isAttemptToFix = this.testManagementTestsForThisSuite?.attemptToFix?.includes(testName) if (isAttemptToFix) { if (attemptToFixRetriedTestsStatuses.has(testName)) { attemptToFixRetriedTestsStatuses.get(testName).push(status) } else { attemptToFixRetriedTestsStatuses.set(testName, [status]) } const testStatuses = attemptToFixRetriedTestsStatuses.get(testName) // Check if this is the last attempt to fix. // If it is, we'll set the failedAllTests flag to true if all the tests failed // If all tests passed, we'll set the attemptToFixPassed flag to true if (testStatuses.length === testManagementAttemptToFixRetries + 1) { if (testStatuses.includes('fail')) { attemptToFixFailed = true } if (testStatuses.every(status => status === 'fail')) { failedAllTests = true } else if (testStatuses.every(status => status === 'pass')) { attemptToFixPassed = true } } } } // EFD dynamic cloning: on first execution of a new EFD candidate, // determine the retry count from the test's duration. if ( this.isEarlyFlakeDetectionEnabled && this.isKnownTestsEnabled && efdNewTestCandidates.has(testName) && event.test.invocations === 1 && !efdDeterminedRetries.has(testName) ) { const durationMs = event.test.duration ?? 0 const retryCount = getEfdRetryCount(durationMs, earlyFlakeDetectionSlowTestRetries) efdDeterminedRetries.set(testName, retryCount) if (retryCount > 0) { // Temporarily adjust jest-circus state so that retry tests are registered // into the correct describe block and bypass the "tests have started" guard. // // Problem 1 (jest-circus ≤24): currentDescribeBlock points to ROOT during // execution, and ROOT's tests loop already finished before children ran. // // Problem 2 (jest-circus ≥27): `hasStarted = true` causes `test()` to throw // "Cannot add a test after tests have started running". // // Fix: temporarily point currentDescribeBlock to the test's parent (so retries // land in the still-iterating children array) and set hasStarted = false (so the // guard is bypassed). Both are restored immediately after scheduling the retries. const originalDescribeBlock = state.currentDescribeBlock const originalHasStarted = state.hasStarted state.currentDescribeBlock = event.test.parent ?? originalDescribeBlock state.hasStarted = false this.retryTest({ jestEvent: { testName: event.test.name, fn: event.test.fn, timeout: event.test.timeout, }, retryCount, retryType: 'Early flake detection', }) state.currentDescribeBlock = originalDescribeBlock state.hasStarted = originalHasStarted } else { efdSlowAbortedTests.add(testName) } } let isEfdRetry = false // We'll store the test statuses of the retries if (this.isKnownTestsEnabled) { const isNewTest = newTests.has(testName) if (isNewTest) { if (newTestsTestStatuses.has(testName)) { newTestsTestStatuses.get(testName).push(status) isEfdRetry = true } else { newTestsTestStatuses.set(testName, [status]) } const testStatuses = newTestsTestStatuses.get(testName) // Check if this is the last EFD retry. // If it is, we'll set the failedAllTests flag to true if all the tests failed const efdRetryCount = efdDeterminedRetries.get(testName) ?? 0 if (efdRetryCount > 0 && testStatuses.length === efdRetryCount + 1 && testStatuses.every(status => status === 'fail')) { failedAllTests = true } } } // ATR: set failedAllTests when all auto test retries were exhausted and every attempt failed if (this.isFlakyTestRetriesEnabled && !isAttemptToFix && !isEfdRetry) { const maxRetries = Number(this.global[RETRY_TIMES]) || 0 if (event.test?.invocations === maxRetries + 1 && status === 'fail') { failedAllTests = true } } const promises = {} const numRetries = this.global[RETRY_TIMES] const numTestExecutions = event.test?.invocations const willBeRetriedByFailedTestReplay = numRetries > 0 && numTestExecutions - 1 < numRetries const mightHitBreakpoint = this.isDiEnabled && numTestExecutions >= 2 // For quarantined tests, suppress errors so Jest doesn't count them as failures. // This prevents --bail from stopping the test run on quarantined test failures. // The actual status ('fail') is already captured above for dd-trace reporting. // Only suppress on the final execution — not when ATR/EFD/ATF will retry the test. if (!event.test?.[ATR_RETRY_SUPPRESSION_FLAG] && !willBeRetriedByFailedTestReplay) { const quarantineCtx = testContexts.get(event.test) if (quarantineCtx?.isQuarantined && event.test.errors?.length) { quarantinedFailingTests.add(`${quarantineCtx.suite} › ${quarantineCtx.name}`) event.test.errors = [] } } const ctx = testContexts.get(event.test) if (!ctx) { log.warn('"ci:jest:test_done": no context found for test "%s"', testName) return } const finalStatus = this.getFinalStatus(testName, status, !!ctx.isNew, !!ctx.isModified, isEfdRetry, isAttemptToFix, numTestExecutions) if (status === 'fail') { const shouldSetProbe = this.isDiEnabled && willBeRetriedByFailedTestReplay && numTestExecutions === 1 testErrCh.publish({ ...ctx.currentStore, error: formatJestError(originalError), shouldSetProbe, promises, }) } // After finishing it might take a bit for the snapshot to be handled. // This means that tests retried with DI are BREAKPOINT_HIT_GRACE_PERIOD_MS slower at least. if (status === 'fail' && mightHitBreakpoint) { await new Promise(resolve => { realSetTimeout(() => { resolve() }, BREAKPOINT_HIT_GRACE_PERIOD_MS) }) } let isAtrRetry = false if (this.isFlakyTestRetriesEnabled && event.test?.invocations > 1 && !isAttemptToFix && !isEfdRetry) { isAtrRetry = true } testFinishCh.publish({ ...ctx.currentStore, status, testStartLine: getTestLineStart(event.test.asyncError, this.testSuite), attemptToFixPassed, failedAllTests, attemptToFixFailed, isAtrRetry, finalStatus, earlyFlakeAbortReason: efdSlowAbortedTests.has(testName) ? 'slow' : undefined, }) if (promises.isProbeReady) { await promises.isProbeReady } } if (event.name === 'run_finish') { for (const [test, errors] of atrSuppressedErrors) { // Do not restore errors for quarantined tests — they should stay suppressed // so Jest doesn't see the failure (prevents --bail from stopping the run). const ctx = testContexts.get(test) if (ctx?.isQuarantined) { const testName = getJestTestName(test, this.getShouldStripSeedFromTestName()) quarantinedFailingTests.add(`${ctx.suite} › ${testName}`) } else { test.errors = errors } } atrSuppressedErrors.clear() // In parallel mode, send suppressed quarantine info to the main process // so it can include them in the session summary. // In runInBand mode, keep the set — it will be consumed by the session-level code directly. if (quarantinedFailingTests.size > 0 && sendQuarantineInfoToMainProcess([...quarantinedFailingTests])) { quarantinedFailingTests.clear() } efdDeterminedRetries.clear() efdSlowAbortedTests.clear() efdNewTestCandidates.clear() newTests.clear() retriedTestsToNumAttempts.clear() attemptToFixRetriedTestsStatuses.clear() testsToBeRetried.clear() } if (event.name === 'test_skip' || event.name === 'test_todo') { const testName = getJestTestName(event.test, this.getShouldStripSeedFromTestName()) testSkippedCh.publish({ test: { name: testName, suite: this.testSuite, testSourceFile: this.testSourceFile, displayName: this.displayName, frameworkVersion: jestVersion, testStartLine: getTestLineStart(event.test.asyncError, this.testSuite), }, isDisabled: this.testManagementTestsForThisSuite?.disabled?.includes(testName), }) } } getEfdResult ({ testName, isNewTest, isModifiedTest, isEfdRetry, numberOfExecutedRetries }) { const isEfdEnabled = this.isEarlyFlakeDetectionEnabled const isEfdActive = isEfdEnabled && (isNewTest || isModifiedTest) const retryCount = efdDeterminedRetries.get(testName) ?? 0 const isSlowAbort = efdSlowAbortedTests.has(testName) const isLastEfdRetry = (isEfdRetry && numberOfExecutedRetries >= (retryCount + 1)) || isSlowAbort const isFinalEfdTestExecution = isEfdActive && isLastEfdRetry let finalStatus if (isEfdActive && isFinalEfdTestExecution) { // For EFD: The framework reports 'pass' if ANY attempt passed (flaky but not failing) const testStatuses = newTestsTestStatuses.get(testName) finalStatus = testStatuses && testStatuses.includes('pass') ? 'pass' : 'fail' } return { isEfdEnabled, isEfdActive, isFinalEfdTestExecution, finalStatus } } getAtrResult ({ status, isEfdRetry, isAttemptToFix, numberOfTestInvocations }) { const isAtrEnabled = this.isFlakyTestRetriesEnabled && !isEfdRetry && !isAttemptToFix && Number.isFinite(this.global[RETRY_TIMES]) const isLastAtrRetry = status === 'pass' || numberOfTestInvocations >= (Number(this.global[RETRY_TIMES]) + 1) const isFinalAtrTestExecution = isAtrEnabled && isLastAtrRetry // For ATR: The last execution's status is what the framework reports return { isAtrEnabled, isFinalAtrTestExecution, finalStatus: status } } getAttemptToFixResult ({ testName, isAttemptToFix, numberOfExecutedRetries }) { const isAttemptToFixEnabled = this.isTestManagementTestsEnabled && isAttemptToFix && Number.isFinite(testManagementAttemptToFixRetries) const isFinalAttemptToFixExecution = isAttemptToFixEnabled && numberOfExecutedRetries >= (testManagementAttemptToFixRetries + 1) let finalStatus if (isAttemptToFixEnabled && isFinalAttemptToFixExecution) { // For Attempt to Fix: 'pass' only if ALL attempts passed, 'fail' if ANY failed const testStatuses = attemptToFixRetriedTestsStatuses.get(testName) finalStatus = testStatuses && testStatuses.every(status => status === 'pass') ? 'pass' : 'fail' } return { isAttemptToFixEnabled, isFinalAttemptToFixExecution, finalStatus } } getFinalStatus (testName, status, isNewTest, isModifiedTest, isEfdRetry, isAttemptToFix, numberOfTestInvocations) { const numberOfExecutedRetries = retriedTestsToNumAttempts.get(testName) ?? 0 const efdResult = this.getEfdResult({ testName, isNewTest, isModifiedTest, isEfdRetry, numberOfExecutedRetries, }) const atrResult = this.getAtrResult({ status, isEfdRetry, isAttemptToFix, numberOfTestInvocations }) const attemptToFixResult = this.getAttemptToFixResult({ testName, isAttemptToFix, numberOfExecutedRetries, }) // When no retry features are active, every test execution is final const noRetryFeaturesActive = !efdResult.isEfdActive && !atrResult.isAtrEnabled && !attemptToFixResult.isAttemptToFixEnabled const isFinalTestExecution = noRetryFeaturesActive || efdResult.isFinalEfdTestExecution || atrResult.isFinalAtrTestExecution || attemptToFixResult.isFinalAttemptToFixExecution if (!isFinalTestExecution) { return } // If the test is quarantined, regardless of its actual execution result, // the final status of its last execution should be reported as 'skip'. if (this.isTestManagementTestsEnabled && this.testManagementTestsForThisSuite?.quarantined?.includes(testName)) { return 'skip' } return efdResult.finalStatus || attemptToFixResult.finalStatus || atrResult.finalStatus } teardown () { if (this._globalProxy?.propertyToValue) { for (const [key] of this._globalProxy.propertyToValue) { if (typeof key === 'string' && key.startsWith('_dd')) { this._globalProxy.propertyToValue.delete(key) } } } return super.teardown() } } } function getTestEnvironment (pkg, jestVersion) { if (pkg.default) { const wrappedTestEnvironment = getWrappedEnvironment(pkg.default, jestVersion) return new Proxy(pkg, { get (target, prop) { if (prop === 'default') { return wrappedTestEnvironment } if (prop === 'TestEnvironment') { return wrappedTestEnvironment } return target[prop] }, }) } return getWrappedEnvironment(pkg, jestVersion) } function applySuiteSkipping (originalTests, rootDir, frameworkVersion) { const jestSuitesToRun = getJestSuitesToRun(skippableSuites, originalTests, rootDir || process.cwd()) hasFilteredSkippableSuites = true log.debug('%d out of %d suites are going to run.', jestSuitesToRun.suitesToRun.length, originalTests.length) hasUnskippableSuites = jestSuitesToRun.hasUnskippableSuites hasForcedToRunSuites = jestSuitesToRun.hasForcedToRunSuites isSuitesSkipped = jestSuitesToRun.suitesToRun.length !== originalTests.length numSkippedSuites = jestSuitesToRun.skippedSuites.length itrSkippedSuitesCh.publish({ skippedSuites: jestSuitesToRun.skippedSuites, frameworkVersion }) return jestSuitesToRun.suitesToRun } addHook({ name: 'jest-environment-node', versions: ['>=24.8.0'], }, getTestEnvironment) addHook({ name: 'jest-environment-jsdom', versions: ['>=24.8.0'], }, getTestEnvironment) addHook({ name: '@happy-dom/jest-environment', versions: ['>=10.0.0'], }, getTestEnvironment) function getWrappedScheduleTests (scheduleTests, frameworkVersion) { // `scheduleTests` is an async function return function (tests) { if (!isSuitesSkippingEnabled || hasFilteredSkippableSuites) { return scheduleTests.apply(this, arguments) } const [test] = tests const rootDir = test?.context?.config?.rootDir arguments[0] = applySuiteSkipping(tests, rootDir, frameworkVersion) return scheduleTests.apply(this, arguments) } } function searchSourceWrapper (searchSourcePackage, frameworkVersion) { const SearchSource = searchSourcePackage.default ?? searchSourcePackage shimmer.wrap(SearchSource.prototype, 'getTestPaths', getTestPaths => async function () { const testPaths = await getTestPaths.apply(this, arguments) const [{ rootDir, shard }] = arguments if (isKnownTestsEnabled) { const projectSuites = testPaths.tests.map(test => getTestSuitePath(test.path, test.context.config.rootDir)) // If the `jest` key does not exist in the known tests response, we consider the Early Flake detection faulty. const isFaulty = !knownTests?.jest || getIsFaultyEarlyFlakeDetection(projectSuites, knownTests.jest, earlyFlakeDetectionFaultyThreshold) if (isFaulty) { log.error('Early flake detection is disabled because the number of new suites is too high.') isEarlyFlakeDetectionEnabled = false isKnownTestsEnabled = false const testEnvironmentOptions = testPaths.tests[0]?.context?.config?.testEnvironmentOptions // Project config is shared among all tests, so we can modify it here if (testEnvironmentOptions) { testEnvironmentOptions._ddIsEarlyFlakeDetectionEnabled = false testEnvironmentOptions._ddIsKnownTestsEnabled = false } isEarlyFlakeDetectionFaulty = true } } if (shard?.shardCount > 1 || !isSuitesSkippingEnabled || !skippableSuites.length) { // If the user is using jest sharding, we want to apply the filtering of tests in the shard process. // The reason for this is the following: // The tests for different shards are likely being run in different CI jobs so // the requests to the skippable endpoint might be done at different times and their responses might be different. // If the skippable endpoint is returning different suites and we filter the list of tests here, // the base list of tests that is used for sharding might be different, // causing the shards to potentially run the same suite. return testPaths } const { tests } = testPaths const suitesToRun = applySuiteSkipping(tests, rootDir, frameworkVersion) return { ...testPaths, tests: suitesToRun } }) return searchSourcePackage } function getCliWrapper (isNewJestVersion) { return function cliWrapper (cli, jestVersion) { if (isNewJestVersion) { cli = shimmer.wrap( cli, 'SearchSource', searchSource => searchSourceWrapper(searchSource, jestVersion), { replaceGetter: true } ) } return shimmer.wrap(cli, 'runCLI', runCLI => async function () { let onDone const configurationPromise = new Promise((resolve) => { onDone = resolve }) if (!libraryConfigurationCh.hasSubscribers) { return runCLI.apply(this, arguments) } libraryConfigurationCh.publish({ onDone, frameworkVersion: jestVersion }) try { const { err, libraryConfig } = await configurationPromise if (!err) { isCodeCoverageEnabled = libraryConfig.isCodeCoverageEnabled isSuitesSkippingEnabled = libraryConfig.isSuitesSkippingEnabled isKeepingCoverageConfiguration = libraryConfig.isKeepingCoverageConfiguration ?? isKeepingCoverageConfiguration isEarlyFlakeDetectionEnabled = libraryConfig.isEarlyFlakeDetectionEnabled earlyFlakeDetectionNumRetries = libraryConfig.earlyFlakeDetectionNumRetries earlyFlakeDetectionSlowTestRetries = libraryConfig.earlyFlakeDetectionSlowTestRetries ?? {} earlyFlakeDetectionFaultyThreshold = libraryConfig.earlyFlakeDetectionFaultyThreshold isKnownTestsEnabled = libraryConfig.isKnownTestsEnabled isTestManagementTestsEnabled = libraryConfig.isTestManagementEnabled testManagementAttemptToFixRetries = libraryConfig.testManagementAttemptToFixRetries isImpactedTestsEnabled = libraryConfig.isImpactedTestsEnabled } } catch (err) { log.error('Jest library configuration error', err) } if (isKnownTestsEnabled) { const knownTestsPromise = new Promise((resolve) => { onDone = resolve }) knownTestsCh.publish({ onDone }) try { const { err, knownTests: receivedKnownTests } = await knownTestsPromise if (err) { // We disable EFD if there has been an error in the known tests request isEarlyFlakeDetectionEnabled = false isKnownTestsEnabled = false } else { knownTests = receivedKnownTests } } catch (err) { log.error('Jest known tests error', err) } } if (isSuitesSkippingEnabled) { const skippableSuitesPromise = new Promise((resolve) => { onDone = resolve }) skippableSuitesCh.publish({ onDone }) try { const { err, skippableSuites: receivedSkippableSuites } = await skippableSuitesPromise if (!err) { skippableSuites = receivedSkippableSuites } } catch (err) { log.error('Jest test-suite skippable error', err) } } if (isTestManagementTestsEnabled) { const testManagementTestsPromise = new Promise((resolve) => { onDone = resolve }) testManagementTestsCh.publish({ onDone }) try { const { err, testManagementTests: receivedTestManagementTests } = await testManagementTestsPromise if (err) { isTestManagementTestsEnabled = false testManagementTests = {} } else { testManagementTests = receivedTestManagementTests || {} } } catch (err) { log.error('Jest test management tests error', err) isTestManagementTestsEnabled = false } } if (isImpactedTestsEnabled) { const impactedTestsPromise = new Promise((resolve) => { onDone = resolve }) modifiedFilesCh.publish({ onDone }) try { const { err, modifiedFiles: receivedModifiedFiles } = await impactedTestsPromise if (!err) { modifiedFiles = receivedModifiedFiles } } catch (err) { log.error('Jest impacted tests error', err) } } const processArgv = process.argv.slice(2).join(' ') testSessionStartCh.publish({ command: `jest ${processArgv}`, frameworkVersion: jestVersion }) const result = await runCLI.apply(this, arguments) const { results: { coverageMap, numFailedTestSuites, numFailedTests, numRuntimeErrorTestSuites = 0, numTotalTests, numTotalTestSuites, runExecError, wasInterrupted, }, } = result const hasSuiteLevelFailures = numRuntimeErrorTestSuites > 0 const hasRunLevelFailure = runExecError != null || wasInterrupted === true const mustNotFlipSuccess = hasSuiteLevelFailures || hasRunLevelFailure let testCodeCoverageLinesTotal if (isUserCodeCoverageEnabled) { try { const { pct, total } = coverageMap.getCoverageSummary().lines testCodeCoverageLinesTotal = total === 0 ? 0 : pct } catch { // ignore errors } } /** * If Early Flake Detection (EFD) is enabled the logic is as follows: * - If all attempts for a test are failing, the test has failed and we will let the test process fail. * - If just a single attempt passes, we will prevent the test process from failing. * The rationale behind is the following: you may still be able to block your CI pipeline by gating * on flakiness (the