dd-trace 6.8.0 → 6.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/ci/diagnose.js +27 -25
  2. package/ci/init.js +6 -6
  3. package/ci/runbook.md +114 -20
  4. package/ci/test-optimization-validation/approval-artifacts.js +36 -3
  5. package/ci/test-optimization-validation/approval.js +78 -22
  6. package/ci/test-optimization-validation/blocker-category.js +24 -0
  7. package/ci/test-optimization-validation/ci-discovery.js +23 -17
  8. package/ci/test-optimization-validation/ci-package-scripts.js +34 -6
  9. package/ci/test-optimization-validation/ci-remediation.js +1 -1
  10. package/ci/test-optimization-validation/cli.js +280 -52
  11. package/ci/test-optimization-validation/command-blocker.js +159 -20
  12. package/ci/test-optimization-validation/command-output-policy.js +9 -3
  13. package/ci/test-optimization-validation/command-runner.js +9 -7
  14. package/ci/test-optimization-validation/environment.js +4 -2
  15. package/ci/test-optimization-validation/executable.js +15 -13
  16. package/ci/test-optimization-validation/execution-lock.js +70 -0
  17. package/ci/test-optimization-validation/framework-adapters/cucumber-validation.json +3 -0
  18. package/ci/test-optimization-validation/framework-adapters/cucumber.js +657 -5
  19. package/ci/test-optimization-validation/framework-adapters/vitest.js +517 -0
  20. package/ci/test-optimization-validation/generated-files.js +38 -28
  21. package/ci/test-optimization-validation/generated-test-contract.js +8 -4
  22. package/ci/test-optimization-validation/generated-verifier.js +24 -17
  23. package/ci/test-optimization-validation/literal-glob.js +52 -0
  24. package/ci/test-optimization-validation/manifest-loader.js +3 -0
  25. package/ci/test-optimization-validation/manifest-scaffold.js +300 -50
  26. package/ci/test-optimization-validation/manifest-schema.js +80 -17
  27. package/ci/test-optimization-validation/offline-fixtures.js +13 -4
  28. package/ci/test-optimization-validation/package-check.js +94 -0
  29. package/ci/test-optimization-validation/plan-writer.js +183 -10
  30. package/ci/test-optimization-validation/preflight-runner.js +110 -31
  31. package/ci/test-optimization-validation/project-build-artifact.js +31 -0
  32. package/ci/test-optimization-validation/redaction.js +18 -27
  33. package/ci/test-optimization-validation/report-writer.js +182 -48
  34. package/ci/test-optimization-validation/result-semantics.js +20 -3
  35. package/ci/test-optimization-validation/runner-command.js +40 -20
  36. package/ci/test-optimization-validation/runner-contract.js +79 -43
  37. package/ci/test-optimization-validation/scenarios/basic-reporting.js +59 -5
  38. package/ci/test-optimization-validation/scenarios/ci-wiring.js +141 -35
  39. package/ci/test-optimization-validation/scenarios/helpers.js +30 -6
  40. package/ci/test-optimization-validation/static-diagnosis.js +10 -3
  41. package/ci/test-optimization-validation/test-output.js +2 -1
  42. package/ci/test-optimization-validation/validation-blocker.js +21 -0
  43. package/ci/vitest-no-worker-init-setup.mjs +30 -34
  44. package/index.d.ts +98 -1
  45. package/init.js +1 -17
  46. package/initialize.mjs +11 -0
  47. package/loader-hook.mjs +6 -4
  48. package/package.json +5 -3
  49. package/packages/datadog-core/src/utils/src/parse-tags.js +0 -1
  50. package/packages/datadog-esbuild/src/utils.js +16 -1
  51. package/packages/datadog-instrumentations/src/ai.js +61 -34
  52. package/packages/datadog-instrumentations/src/bluebird.js +6 -6
  53. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +81 -5
  54. package/packages/datadog-instrumentations/src/cucumber.js +124 -47
  55. package/packages/datadog-instrumentations/src/dns.js +21 -2
  56. package/packages/datadog-instrumentations/src/electron.js +1 -0
  57. package/packages/datadog-instrumentations/src/fastify.js +3 -1
  58. package/packages/datadog-instrumentations/src/grpc/client.js +4 -2
  59. package/packages/datadog-instrumentations/src/helpers/callback-instrumentor.js +3 -1
  60. package/packages/datadog-instrumentations/src/helpers/hooks.js +2 -0
  61. package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +33 -9
  62. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +118 -0
  63. package/packages/datadog-instrumentations/src/helpers/rewriter/loader.mjs +20 -2
  64. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.js +32 -0
  65. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +61 -0
  66. package/packages/datadog-instrumentations/src/helpers/router-helper.js +37 -6
  67. package/packages/datadog-instrumentations/src/jest/coverage-backfill.js +1 -1
  68. package/packages/datadog-instrumentations/src/jest.js +979 -233
  69. package/packages/datadog-instrumentations/src/mocha/main.js +107 -9
  70. package/packages/datadog-instrumentations/src/mocha/utils.js +64 -42
  71. package/packages/datadog-instrumentations/src/mocha/webdriverio-protocol.js +25 -0
  72. package/packages/datadog-instrumentations/src/mocha/worker.js +12 -18
  73. package/packages/datadog-instrumentations/src/mysql.js +1 -1
  74. package/packages/datadog-instrumentations/src/next.js +155 -23
  75. package/packages/datadog-instrumentations/src/openai.js +14 -0
  76. package/packages/datadog-instrumentations/src/pg.js +180 -6
  77. package/packages/datadog-instrumentations/src/playwright.js +173 -79
  78. package/packages/datadog-instrumentations/src/promise-js.js +4 -4
  79. package/packages/datadog-instrumentations/src/promise.js +3 -3
  80. package/packages/datadog-instrumentations/src/router.js +195 -19
  81. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +43 -10
  82. package/packages/datadog-instrumentations/src/vitest-main.js +360 -56
  83. package/packages/datadog-instrumentations/src/vitest-util.js +42 -7
  84. package/packages/datadog-instrumentations/src/vitest-worker.js +234 -17
  85. package/packages/datadog-instrumentations/src/webdriverio.js +194 -18
  86. package/packages/datadog-instrumentations/src/when.js +3 -3
  87. package/packages/datadog-instrumentations/src/ws.js +5 -1
  88. package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/utils.js +59 -48
  89. package/packages/datadog-plugin-child_process/src/scrub-cmd-params.js +1 -3
  90. package/packages/datadog-plugin-cucumber/src/index.js +5 -1
  91. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +50 -46
  92. package/packages/datadog-plugin-cypress/src/support.js +7 -4
  93. package/packages/datadog-plugin-electron/src/net.js +10 -4
  94. package/packages/datadog-plugin-http/src/client.js +1 -1
  95. package/packages/datadog-plugin-http2/src/client.js +1 -1
  96. package/packages/datadog-plugin-jest/src/index.js +1 -18
  97. package/packages/datadog-plugin-jest/src/util.js +2 -0
  98. package/packages/datadog-plugin-kafkajs/src/producer.js +2 -1
  99. package/packages/datadog-plugin-mocha/src/index.js +318 -1
  100. package/packages/datadog-plugin-next/src/index.js +57 -9
  101. package/packages/datadog-plugin-openai/src/tracing.js +4 -0
  102. package/packages/datadog-plugin-pg/src/index.js +65 -1
  103. package/packages/datadog-plugin-playwright/src/index.js +1 -1
  104. package/packages/datadog-plugin-vitest/src/index.js +95 -7
  105. package/packages/datadog-shimmer/src/shimmer.js +6 -2
  106. package/packages/dd-trace/src/aiguard/sdk.js +3 -0
  107. package/packages/dd-trace/src/appsec/{blocking.js → blocking/index.js} +26 -6
  108. package/packages/dd-trace/src/appsec/handlers/auth.js +62 -0
  109. package/packages/dd-trace/src/appsec/handlers/http-request.js +215 -0
  110. package/packages/dd-trace/src/appsec/handlers/http-response.js +78 -0
  111. package/packages/dd-trace/src/appsec/handlers/http-shared.js +24 -0
  112. package/packages/dd-trace/src/appsec/handlers/payments.js +104 -0
  113. package/packages/dd-trace/src/appsec/iast/analyzers/hsts-header-missing-analyzer.js +1 -1
  114. package/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js +1 -1
  115. package/packages/dd-trace/src/appsec/index.js +48 -476
  116. package/packages/dd-trace/src/appsec/sdk/track_event.js +9 -0
  117. package/packages/dd-trace/src/ci-visibility/coverage-report-discovery.js +46 -14
  118. package/packages/dd-trace/src/ci-visibility/efd-retry-policy.js +89 -0
  119. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +27 -13
  120. package/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js +17 -0
  121. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/index.js +21 -1
  122. package/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +22 -7
  123. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +9 -14
  124. package/packages/dd-trace/src/ci-visibility/requests/upload-coverage-report.js +21 -5
  125. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +1 -1
  126. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache.js +5 -1
  127. package/packages/dd-trace/src/config/generated-config-types.d.ts +2 -0
  128. package/packages/dd-trace/src/config/helper.js +2 -0
  129. package/packages/dd-trace/src/config/index.js +16 -5
  130. package/packages/dd-trace/src/config/remote_config.js +1 -1
  131. package/packages/dd-trace/src/config/supported-configurations.json +8 -0
  132. package/packages/dd-trace/src/debugger/devtools_client/status.js +1 -1
  133. package/packages/dd-trace/src/encode/coverage-ci-visibility.js +0 -1
  134. package/packages/dd-trace/src/guardrails/apply-pm2-env.js +16 -0
  135. package/packages/dd-trace/src/guardrails/log.js +1 -10
  136. package/packages/dd-trace/src/llmobs/eval-metric.js +249 -0
  137. package/packages/dd-trace/src/llmobs/experiments/index.js +33 -25
  138. package/packages/dd-trace/src/llmobs/experiments/util.js +4 -2
  139. package/packages/dd-trace/src/llmobs/noop.js +2 -0
  140. package/packages/dd-trace/src/llmobs/plugins/ai/ddTelemetry.js +1 -1
  141. package/packages/dd-trace/src/llmobs/plugins/ai/util.js +1 -1
  142. package/packages/dd-trace/src/llmobs/plugins/bedrockruntime.js +5 -5
  143. package/packages/dd-trace/src/llmobs/plugins/langchain/index.js +1 -1
  144. package/packages/dd-trace/src/llmobs/sdk.js +149 -68
  145. package/packages/dd-trace/src/llmobs/telemetry.js +18 -0
  146. package/packages/dd-trace/src/llmobs/writers/base.js +3 -0
  147. package/packages/dd-trace/src/llmobs/writers/util.js +2 -0
  148. package/packages/dd-trace/src/log/channels.js +1 -9
  149. package/packages/dd-trace/src/log/levels.js +12 -0
  150. package/packages/dd-trace/src/openfeature/writers/exposures.js +1 -1
  151. package/packages/dd-trace/src/openfeature/writers/util.js +1 -1
  152. package/packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js +1 -1
  153. package/packages/dd-trace/src/opentelemetry/metrics/meter.js +8 -4
  154. package/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js +1 -1
  155. package/packages/dd-trace/src/opentelemetry/span.js +4 -0
  156. package/packages/dd-trace/src/plugins/ci_plugin.js +21 -5
  157. package/packages/dd-trace/src/plugins/index.js +1 -0
  158. package/packages/dd-trace/src/plugins/util/ci.js +6 -0
  159. package/packages/dd-trace/src/plugins/util/git.js +6 -4
  160. package/packages/dd-trace/src/plugins/util/http-otel-semantics.js +2 -2
  161. package/packages/dd-trace/src/plugins/util/ip_extractor.js +1 -1
  162. package/packages/dd-trace/src/plugins/util/stacktrace.js +3 -0
  163. package/packages/dd-trace/src/plugins/util/test.js +319 -75
  164. package/packages/dd-trace/src/plugins/util/url.js +1 -1
  165. package/packages/dd-trace/src/plugins/util/web.js +23 -1
  166. package/packages/dd-trace/src/profiler.js +1 -1
  167. package/packages/dd-trace/src/profiling/exporters/event_serializer.js +1 -1
  168. package/packages/dd-trace/src/profiling/profilers/events.js +2 -2
  169. package/packages/dd-trace/src/proxy.js +1 -0
  170. package/packages/dd-trace/src/ritm.js +5 -0
  171. package/packages/dd-trace/src/runtime_metrics/runtime_metrics.js +4 -2
  172. package/packages/dd-trace/src/sampling_rule.js +4 -2
  173. package/packages/dd-trace/src/service-naming/schemas/v0/storage.js +1 -1
  174. package/packages/dd-trace/src/service-naming/schemas/v1/storage.js +1 -1
  175. package/packages/dd-trace/src/telemetry/logs/index.js +3 -0
  176. package/packages/dd-trace/src/telemetry/telemetry.js +1 -1
  177. package/vendor/dist/pprof-format/index.js +1 -1
  178. package/version.js +10 -8
  179. /package/packages/dd-trace/src/appsec/{blocked_templates.js → blocking/templates.js} +0 -0
@@ -1,16 +1,23 @@
1
1
  'use strict'
2
2
 
3
3
  // Capture real timers at module load time, before any test can install fake timers.
4
+ const realClearTimeout = clearTimeout
4
5
  const realSetTimeout = setTimeout
5
6
 
6
7
  const { readFileSync } = require('node:fs')
7
8
  const { builtinModules, createRequire } = require('node:module')
9
+ const { performance } = require('node:perf_hooks')
8
10
  const path = require('path')
9
11
  const satisfies = require('../../../vendor/dist/semifies')
10
12
  const { DD_MAJOR } = require('../../../version')
11
13
  const shimmer = require('../../datadog-shimmer')
12
14
  const { getEnvironmentVariable } = require('../../dd-trace/src/config/helper')
13
15
  const log = require('../../dd-trace/src/log')
16
+ const {
17
+ EMPTY_EFD_RETRY_POLICY,
18
+ getEfdRetryCountForDuration,
19
+ hasEfdRetries,
20
+ } = require('../../dd-trace/src/ci-visibility/efd-retry-policy')
14
21
  const {
15
22
  getCoveredFilesFromCoverage,
16
23
  getExecutableFilesFromCoverage,
@@ -27,10 +34,11 @@ const {
27
34
  isModifiedTest,
28
35
  DYNAMIC_NAME_RE,
29
36
  collectDynamicNamesFromTraces,
37
+ recordTestManagementExecution,
30
38
  recordAttemptToFixExecution,
31
39
  logAttemptToFixTestExecution,
32
40
  logTestOptimizationSummary,
33
- getEfdRetryCount,
41
+ TEST_IMPACT_ANALYSIS_ALL_TESTS_SKIPPED_MESSAGE,
34
42
  getTestCoverageLinesPercentage,
35
43
  applySkippedCoverageToCoverage,
36
44
  getTestOptimizationRequestResults,
@@ -115,8 +123,6 @@ let numSkippedSuites = 0
115
123
  let hasUnskippableSuites = false
116
124
  let hasForcedToRunSuites = false
117
125
  let isEarlyFlakeDetectionEnabled = false
118
- let earlyFlakeDetectionNumRetries = 0
119
- let earlyFlakeDetectionSlowTestRetries = {}
120
126
  let earlyFlakeDetectionFaultyThreshold = 30
121
127
  let isEarlyFlakeDetectionFaulty = false
122
128
  let hasFilteredSkippableSuites = false
@@ -141,8 +147,10 @@ const originalTestFns = new WeakMap()
141
147
  const originalHookFns = new WeakMap()
142
148
  const concurrentHookContextQueues = new WeakMap()
143
149
  const concurrentHookFns = new WeakMap()
150
+ const efdRetryMetadataByTest = new WeakMap()
151
+ const removedRetryTests = new WeakSet()
144
152
  const retriedTestsToNumAttempts = new Map()
145
- const newTestsTestStatuses = new Map()
153
+ const efdTestStatuses = new Map()
146
154
  const attemptToFixRetriedTestsStatuses = new Map()
147
155
  const wrappedWorkerChannels = new WeakMap()
148
156
  // New tests whose names contain likely dynamic data (timestamps, UUIDs, etc.)
@@ -153,10 +161,12 @@ const testSuiteMockedFiles = new Map()
153
161
  const testsToBeRetried = new Set()
154
162
  // Per-test: how many EFD retries were determined after the first execution.
155
163
  const efdDeterminedRetries = new Map()
164
+ // Per-test: total executions to report, including retries Jest ran before the test they belong to.
165
+ const efdExpectedExecutions = new Map()
156
166
  // Tests whose first run exceeded the 5-min threshold — tagged "slow".
157
167
  const efdSlowAbortedTests = new Set()
158
- // Tests added as EFD new-test candidates (not ATF, not impacted).
159
- const efdNewTestCandidates = new Set()
168
+ // Tests whose first execution determines the duration-based EFD retry count.
169
+ const efdCandidates = new Set()
160
170
  // Tests that are genuinely new (not in known tests list).
161
171
  const newTests = new Set()
162
172
  const testSuiteJestObjects = new Map()
@@ -173,13 +183,39 @@ const handledJestEvents = new WeakSet()
173
183
  * @typedef {object} ConcurrentTestOptions
174
184
  * @property {(...args: unknown[]) => unknown} [concurrentTest]
175
185
  * @property {unknown} [concurrentTestThisArg]
186
+ * @property {EfdRetryGate[]} [efdRetryGates]
176
187
  * @property {boolean} [isAttemptToFixRetry]
177
188
  * @property {boolean} [isEfdRetry]
189
+ * @property {number} [efdRetryIndex]
178
190
  * @property {boolean} [isModified]
179
- * @property {(...args: unknown[]) => unknown} [serialTest]
180
- * @property {unknown} [serialTestThisArg]
181
191
  * @property {(...args: unknown[]) => unknown} [sourceTestFn]
182
192
  * @property {string} [testParameters]
193
+ * @property {number} [timeout]
194
+ */
195
+
196
+ /**
197
+ * @typedef {object} JestRetryOptions
198
+ * @property {object} [concurrentTestState]
199
+ * @property {EfdRetryGate[]} [efdRetryGates]
200
+ * @property {object} jestEvent
201
+ * @property {object} state
202
+ * @property {boolean} [isModified]
203
+ * @property {number} retryCount
204
+ * @property {string} retryType
205
+ * @property {string} testFullName
206
+ */
207
+
208
+ /**
209
+ * @typedef {object} EfdRetryGate
210
+ * @property {Promise<boolean>} promise
211
+ * @property {(shouldRun: boolean) => void} resolve
212
+ */
213
+
214
+ /**
215
+ * @typedef {object} DetachedEfdRetry
216
+ * @property {object} ctx
217
+ * @property {() => void} resolve
218
+ * @property {() => void} start
183
219
  */
184
220
 
185
221
  const ATR_RETRY_SUPPRESSION_FLAG = '_ddDisableAtrRetry'
@@ -278,6 +314,10 @@ function getTestEnvironmentOptions (config) {
278
314
 
279
315
  const MAX_IGNORED_TEST_NAMES = 10
280
316
 
317
+ /**
318
+ * @typedef {Parameters<typeof logTestOptimizationSummary>[0]} TestOptimizationSummary
319
+ */
320
+
281
321
  function getTestStats (testStatuses) {
282
322
  return testStatuses.reduce((acc, testStatus) => {
283
323
  acc[testStatus]++
@@ -288,22 +328,20 @@ function getTestStats (testStatuses) {
288
328
  /**
289
329
  * Formats the ignored-failure section for the Test Optimization summary.
290
330
  *
291
- * @param {{ efdNames: string[], quarantineNames: string[], totalCount: number } | undefined} ignoredFailures
331
+ * @param {{
332
+ * efdNames: string[],
333
+ * quarantineNames: string[],
334
+ * totalCount: number,
335
+ * efdFailureCount: number
336
+ * } | undefined} ignoredFailures
292
337
  * @returns {string}
293
338
  */
294
339
  function formatIgnoredFailuresSummary (ignoredFailures) {
295
- if (!ignoredFailures) return ''
296
-
297
- const items = []
340
+ if (!ignoredFailures?.efdFailureCount) return ''
298
341
 
299
- for (const n of ignoredFailures.efdNames) {
300
- items.push({ text: n, suffix: 'Early Flake Detection' })
301
- }
302
- for (const n of ignoredFailures.quarantineNames) {
303
- items.push({ text: n, suffix: 'Quarantine' })
304
- }
342
+ const items = ignoredFailures.efdNames.map(text => ({ text, suffix: 'Early Flake Detection' }))
305
343
 
306
- if (items.length === 0 || ignoredFailures.totalCount <= 0) return ''
344
+ if (items.length === 0) return ''
307
345
 
308
346
  const shown = items.slice(0, MAX_IGNORED_TEST_NAMES)
309
347
  const more = items.length - shown.length
@@ -312,18 +350,28 @@ function formatIgnoredFailuresSummary (ignoredFailures) {
312
350
  .map(({ text, suffix }) => ` • ${text}${suffix ? ` (${suffix})` : ''}`)
313
351
  .join('\n') + moreSuffix
314
352
 
315
- return `${ignoredFailures.totalCount} test failure(s) were ignored. Exit code set to 0.\n\n${formattedItems}`
353
+ return `${ignoredFailures.efdFailureCount} test failure(s) were ignored. Exit code set to 0.\n\n${formattedItems}`
316
354
  }
317
355
 
318
356
  /**
319
357
  * Logs a single "Datadog Test Optimization" summary at session end.
320
358
  *
321
- * @param {{ efdNames: string[], quarantineNames: string[], totalCount: number } | undefined} ignoredFailures
359
+ * @param {{
360
+ * efdNames: string[],
361
+ * quarantineNames: string[],
362
+ * totalCount: number,
363
+ * efdFailureCount: number
364
+ * } | undefined} ignoredFailures
365
+ * @param {NonNullable<TestOptimizationSummary['attemptToFixExecutions']>} attemptToFixExecutions
366
+ * @param {boolean} allTestsSkipped
322
367
  */
323
- function logSessionSummary (ignoredFailures, attemptToFixExecutions) {
368
+ function logSessionSummary (ignoredFailures, attemptToFixExecutions, allTestsSkipped) {
324
369
  logTestOptimizationSummary({
325
370
  attemptToFixExecutions,
326
- extraSections: [formatIgnoredFailuresSummary(ignoredFailures)],
371
+ extraSections: [
372
+ formatIgnoredFailuresSummary(ignoredFailures),
373
+ allTestsSkipped ? TEST_IMPACT_ANALYSIS_ALL_TESTS_SKIPPED_MESSAGE : '',
374
+ ],
327
375
  newTestsWithDynamicNames,
328
376
  })
329
377
  loggedAttemptToFixTests.clear()
@@ -349,11 +397,11 @@ function getAttemptToFixExecutionsFromJestResults (result) {
349
397
 
350
398
  for (const { fullName, status } of testResults) {
351
399
  const testName = removeSeedSuffixFromTestName(fullName)
352
- const testStatus = getTestStatusFromJestResult(status)
353
- if (!testStatus) continue
354
-
355
400
  const testManagementTest = testManagementTestsForSuite[testName]?.properties
356
401
  if (!testManagementTest?.attempt_to_fix) continue
402
+ const testStatus = getTestStatusFromJestResult(status) ||
403
+ (status === 'pending' || status === 'todo' ? 'skip' : undefined)
404
+ if (!testStatus) continue
357
405
 
358
406
  recordAttemptToFixExecution(executions, {
359
407
  testSuite,
@@ -368,6 +416,51 @@ function getAttemptToFixExecutionsFromJestResults (result) {
368
416
  return executions
369
417
  }
370
418
 
419
+ /**
420
+ * Records test-management results reported by Jest after all retries have finished.
421
+ *
422
+ * @param {{
423
+ * globalConfig?: { rootDir?: string },
424
+ * results: {
425
+ * testResults: Array<{
426
+ * testResults: Array<{ fullName: string, status: string }>,
427
+ * testFilePath: string
428
+ * }>
429
+ * }
430
+ * }} result
431
+ * @param {string[]} quarantineFailureNames
432
+ */
433
+ function recordTestManagementExecutionsFromJestResults (result, quarantineFailureNames) {
434
+ const rootDir = result.globalConfig?.rootDir || process.cwd()
435
+ const failedQuarantinedTests = new Set(quarantineFailureNames)
436
+
437
+ for (const { testResults, testFilePath } of result.results.testResults) {
438
+ const testSuite = getTestSuitePath(testFilePath, rootDir)
439
+ const testManagementTestsForSuite = testManagementTests
440
+ ?.jest
441
+ ?.suites
442
+ ?.[testSuite]
443
+ ?.tests
444
+ if (!testManagementTestsForSuite) continue
445
+
446
+ for (const { fullName, status } of testResults) {
447
+ const testName = removeSeedSuffixFromTestName(fullName)
448
+ const testManagementTest = testManagementTestsForSuite[testName]?.properties
449
+ if (!testManagementTest) continue
450
+
451
+ const name = `${testSuite} › ${testName}`
452
+ recordTestManagementExecution({
453
+ testSuite,
454
+ testName,
455
+ status: failedQuarantinedTests.has(name) ? 'fail' : getTestStatusFromJestResult(status),
456
+ isAttemptToFix: testManagementTest.attempt_to_fix,
457
+ isDisabled: testManagementTest.disabled,
458
+ isQuarantined: testManagementTest.quarantined,
459
+ })
460
+ }
461
+ }
462
+ }
463
+
371
464
  function wrapConsoleErrorForJestReferenceErrors () {
372
465
  if (isConsoleErrorWrapped) return
373
466
 
@@ -465,8 +558,52 @@ function getWrappedCustomHandleTestEvent (handleTestEvent, datadogHandleTestEven
465
558
  return wrappedHandleTestEvent
466
559
  }
467
560
 
561
+ /**
562
+ * Mirrors the test selection checks Jest Circus performs after publishing `test_start`.
563
+ *
564
+ * @param {object} test
565
+ * @param {boolean} hasFocusedTests
566
+ * @param {RegExp|undefined} testNamePattern
567
+ * @returns {boolean}
568
+ */
569
+ function isJestTestSkipped (test, hasFocusedTests, testNamePattern) {
570
+ if (
571
+ test.mode === 'skip' ||
572
+ (hasFocusedTests && test.mode !== 'only') ||
573
+ (testNamePattern && !testNamePattern.test(getRawJestTestName(test)))
574
+ ) {
575
+ return true
576
+ }
577
+
578
+ let parent = test.parent
579
+ while (parent) {
580
+ if (parent.mode === 'skip') return true
581
+ parent = parent.parent
582
+ }
583
+ return false
584
+ }
585
+
468
586
  function getWrappedEnvironment (BaseEnvironment, jestVersion) {
587
+ const hasConcurrentTestsStartEvent = satisfies(jestVersion, '>=30.0.0')
588
+ const hasTestsInChildren = satisfies(jestVersion, '>=26.0.0')
589
+
590
+ /**
591
+ * @param {object} describeBlock
592
+ * @returns {object[]|undefined}
593
+ */
594
+ function getTestEntries (describeBlock) {
595
+ return hasTestsInChildren ? describeBlock?.children : describeBlock?.tests
596
+ }
597
+
469
598
  return class DatadogEnvironment extends BaseEnvironment {
599
+ #activeDetachedEfdRetries
600
+ #detachedEfdRetryConcurrency
601
+ #detachedEfdRetryQueue
602
+ #discardedEfdRetryTests
603
+ #earlyFlakeDetectionRetryPolicy
604
+ #efdRetryGatesByName
605
+ #pendingPre30ConcurrentTests
606
+
470
607
  constructor (config, context) {
471
608
  super(config, context)
472
609
  const rootDir = config.globalConfig ? config.globalConfig.rootDir : config.rootDir
@@ -504,11 +641,16 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
504
641
  this.concurrentTestContexts = new Map()
505
642
  this.concurrentTestStates = new WeakMap()
506
643
  this.concurrentTestSourceFns = new WeakMap()
644
+ this.testParametersByFunction = new WeakMap()
507
645
  this.wrappedConcurrentTestFunctions = new WeakSet()
508
646
  this.jestEachBind = undefined
647
+ this.#activeDetachedEfdRetries = 0
648
+ this.#detachedEfdRetryConcurrency = 1
649
+ this.#pendingPre30ConcurrentTests = 0
650
+ this.#earlyFlakeDetectionRetryPolicy =
651
+ this.testEnvironmentOptions._ddEarlyFlakeDetectionRetryPolicy ?? EMPTY_EFD_RETRY_POLICY
509
652
 
510
653
  if (this.isKnownTestsEnabled) {
511
- earlyFlakeDetectionSlowTestRetries = this.testEnvironmentOptions._ddEarlyFlakeDetectionSlowTestRetries ?? {}
512
654
  try {
513
655
  this.knownTestsForThisSuite = this.getKnownTestsForSuite(this.testEnvironmentOptions._ddKnownTests)
514
656
 
@@ -579,6 +721,53 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
579
721
  }
580
722
  }
581
723
 
724
+ /**
725
+ * Rebuilds serial `test.each` so every generated row function keeps its parameters.
726
+ *
727
+ * @param {Function|undefined} test
728
+ * @returns {void}
729
+ */
730
+ bindTestEach (test) {
731
+ if (typeof test?.each !== 'function') return
732
+
733
+ const bind = this.getJestEachBind()
734
+ if (typeof bind !== 'function') {
735
+ const environment = this
736
+ shimmer.wrap(test, 'each', each => function (...args) {
737
+ const testParameters = getFormattedJestTestParameters(args)
738
+ const eachBind = each.apply(this, args)
739
+ return function (...args) {
740
+ const [testName] = args
741
+ environment.setNameToParams(testName, testParameters)
742
+ return eachBind.apply(this, args)
743
+ }
744
+ })
745
+ return
746
+ }
747
+
748
+ // Jest creates each row function at runtime, so Orchestrion cannot associate parameters statically.
749
+ const environment = this
750
+ test.each = function wrappedTestEach (...eachArgs) {
751
+ const testParameters = getFormattedJestTestParameters(eachArgs)
752
+ return function (...testArgs) {
753
+ let parameterIndex = 0
754
+ const eachTest = function (testName, testFn, ...callArgs) {
755
+ const parameters = testParameters?.[parameterIndex++]
756
+ if (parameters !== undefined && typeof testFn === 'function') {
757
+ environment.appendNameToParams(testName, parameters)
758
+ environment.testParametersByFunction.set(
759
+ testFn,
760
+ getTestParametersString(environment.nameToParams, testName)
761
+ )
762
+ }
763
+ return test.call(this, testName, testFn, ...callArgs)
764
+ }
765
+ const eachBind = bind(eachTest).apply(this, eachArgs)
766
+ return eachBind.apply(this, testArgs)
767
+ }
768
+ }
769
+ }
770
+
582
771
  /**
583
772
  * Wraps Jest's concurrent test registration methods so eager concurrent bodies
584
773
  * in older Jest versions execute inside their test span context.
@@ -604,10 +793,10 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
604
793
  const concurrentTest = test?.concurrent
605
794
  if (typeof concurrentTest !== 'function') return
606
795
 
607
- this.wrapConcurrentTestFunction(test, 'concurrent', state, test, test)
608
- this.wrapConcurrentTestFunction(test.concurrent, 'only', state, test.only, test)
609
- this.wrapConcurrentTestFunction(test.concurrent, 'failing', state, test.failing, test)
610
- this.wrapConcurrentTestFunction(test.concurrent.only, 'failing', state, test.only?.failing, test.only)
796
+ this.wrapConcurrentTestFunction(test, 'concurrent', state)
797
+ this.wrapConcurrentTestFunction(test.concurrent, 'only', state)
798
+ this.wrapConcurrentTestFunction(test.concurrent, 'failing', state)
799
+ this.wrapConcurrentTestFunction(test.concurrent.only, 'failing', state)
611
800
  }
612
801
 
613
802
  /**
@@ -616,11 +805,9 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
616
805
  * @param {object} target
617
806
  * @param {string} methodName
618
807
  * @param {object} state
619
- * @param {Function|undefined} serialTest
620
- * @param {unknown} serialTestThisArg
621
808
  * @returns {void}
622
809
  */
623
- wrapConcurrentTestFunction (target, methodName, state, serialTest, serialTestThisArg) {
810
+ wrapConcurrentTestFunction (target, methodName, state) {
624
811
  let concurrentTest = target?.[methodName]
625
812
  if (typeof concurrentTest !== 'function') return
626
813
  if (this.wrappedConcurrentTestFunctions.has(concurrentTest)) return
@@ -646,9 +833,8 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
646
833
  const wrappedTestFn = environment.createConcurrentTestFn(testName, testFn, asyncError, state, {
647
834
  concurrentTest,
648
835
  concurrentTestThisArg: this,
649
- serialTest,
650
- serialTestThisArg,
651
836
  sourceTestFn: environment.concurrentTestSourceFns.get(testFn),
837
+ timeout: args[0],
652
838
  })
653
839
  return concurrentTest.call(this, testName, wrappedTestFn, ...args)
654
840
  }
@@ -711,8 +897,6 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
711
897
  concurrentTestThisArg: options?.concurrentTestThisArg,
712
898
  ctx: this.createConcurrentTestContext(testName, testFn, asyncError, state, options),
713
899
  numExecutions: 0,
714
- serialTest: options?.serialTest,
715
- serialTestThisArg: options?.serialTestThisArg,
716
900
  state,
717
901
  testFn,
718
902
  }
@@ -777,6 +961,8 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
777
961
  frameworkVersion: jestVersion,
778
962
  isNew: isNewTest,
779
963
  isEfdRetry: options?.isEfdRetry === true,
964
+ efdRetryGates: options?.efdRetryGates,
965
+ efdRetryIndex: options?.efdRetryIndex,
780
966
  isAttemptToFix,
781
967
  isAttemptToFixRetry: options?.isAttemptToFixRetry === true,
782
968
  isJestRetry: false,
@@ -785,6 +971,7 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
785
971
  isModified: options?.isModified ?? this.isTestModified(asyncError, sourceTestFn),
786
972
  hasDynamicName: isNewTest && DYNAMIC_NAME_RE.test(testFullName),
787
973
  testSuiteAbsolutePath: this.testSuiteAbsolutePath,
974
+ testTimeout: options?.timeout || state.testTimeout,
788
975
  }
789
976
  let contexts = this.concurrentTestContexts.get(testFullName)
790
977
  if (contexts) {
@@ -880,6 +1067,183 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
880
1067
  }
881
1068
  }
882
1069
 
1070
+ /**
1071
+ * @param {object} concurrentTestState
1072
+ * @param {number|undefined} startedAt
1073
+ * @returns {void}
1074
+ */
1075
+ completePre30ConcurrentTest (concurrentTestState, startedAt) {
1076
+ if (!concurrentTestState.trackEfdCompletion) return
1077
+
1078
+ concurrentTestState.trackEfdCompletion = false
1079
+ const { ctx } = concurrentTestState
1080
+ if (startedAt !== undefined) {
1081
+ ctx.efdDurationMs = performance.now() - startedAt
1082
+ this.determineEfdRetries(ctx.name, ctx.efdDurationMs)
1083
+ }
1084
+ this.#pendingPre30ConcurrentTests--
1085
+ this.drainDetachedEfdRetries()
1086
+ }
1087
+
1088
+ /**
1089
+ * @param {object} concurrentTestState
1090
+ * @param {unknown} result
1091
+ * @param {number|undefined} startedAt
1092
+ * @returns {unknown}
1093
+ */
1094
+ trackPre30ConcurrentTest (concurrentTestState, result, startedAt) {
1095
+ if (typeof result?.then !== 'function') {
1096
+ this.completePre30ConcurrentTest(concurrentTestState, startedAt)
1097
+ return result
1098
+ }
1099
+
1100
+ return Promise.resolve(result).finally(() => {
1101
+ this.completePre30ConcurrentTest(concurrentTestState, startedAt)
1102
+ })
1103
+ }
1104
+
1105
+ /**
1106
+ * @param {EfdRetryGate} retryGate
1107
+ * @param {object} ctx
1108
+ * @param {() => unknown} run
1109
+ * @returns {Promise<void>}
1110
+ */
1111
+ enqueueDetachedEfdRetry (retryGate, ctx, run) {
1112
+ /**
1113
+ * @param {() => void} resolve
1114
+ * @returns {void}
1115
+ */
1116
+ const enqueue = (resolve) => {
1117
+ /**
1118
+ * @param {boolean} shouldRun
1119
+ * @returns {void}
1120
+ */
1121
+ const onDecision = (shouldRun) => {
1122
+ if (!shouldRun) {
1123
+ ctx.isDiscardedEfdRetry = true
1124
+ resolve()
1125
+ return
1126
+ }
1127
+
1128
+ /**
1129
+ * @type {() => void}
1130
+ */
1131
+ let start
1132
+ const startPromise = new Promise(resolve => {
1133
+ start = resolve
1134
+ })
1135
+ const retry = { ctx, resolve, start }
1136
+ /**
1137
+ * @param {unknown} error
1138
+ * @returns {void}
1139
+ */
1140
+ const onRejected = (error) => {
1141
+ retry.ctx.detachedEfdRetryError = error
1142
+ this.finishDetachedEfdRetry(retry)
1143
+ }
1144
+ startPromise.then(() => this.runDetachedEfdRetryWithTimeout(ctx, run)).then(
1145
+ () => this.finishDetachedEfdRetry(retry),
1146
+ onRejected
1147
+ )
1148
+ this.#detachedEfdRetryQueue ??= []
1149
+ this.#detachedEfdRetryQueue.push(retry)
1150
+ this.drainDetachedEfdRetries()
1151
+ }
1152
+ retryGate.promise.then(onDecision)
1153
+ }
1154
+ return new Promise(enqueue)
1155
+ }
1156
+
1157
+ /**
1158
+ * Applies the timeout Jest 28/29 cannot start around a detached concurrent retry body.
1159
+ *
1160
+ * @param {object} ctx
1161
+ * @param {() => unknown} run
1162
+ * @returns {Promise<void>}
1163
+ */
1164
+ runDetachedEfdRetryWithTimeout (ctx, run) {
1165
+ return new Promise((resolve, reject) => {
1166
+ let completed = false
1167
+ const timeoutId = realSetTimeout(() => {
1168
+ if (completed) return
1169
+
1170
+ completed = true
1171
+ reject(new Error(`Exceeded timeout of ${ctx.testTimeout} ms for a test.`))
1172
+ }, ctx.testTimeout)
1173
+
1174
+ const settle = (callback, value) => {
1175
+ if (completed) return
1176
+
1177
+ completed = true
1178
+ timeoutId.unref?.()
1179
+ realClearTimeout(timeoutId)
1180
+ callback(value)
1181
+ }
1182
+
1183
+ let result
1184
+ try {
1185
+ result = run()
1186
+ } catch (error) {
1187
+ settle(reject, error)
1188
+ return
1189
+ }
1190
+ Promise.resolve(result).then(
1191
+ () => settle(resolve),
1192
+ error => settle(reject, error)
1193
+ )
1194
+ })
1195
+ }
1196
+
1197
+ /**
1198
+ * @returns {void}
1199
+ */
1200
+ drainDetachedEfdRetries () {
1201
+ const queue = this.#detachedEfdRetryQueue
1202
+ if (!queue) return
1203
+
1204
+ while (
1205
+ this.#pendingPre30ConcurrentTests === 0 &&
1206
+ this.#activeDetachedEfdRetries < this.#detachedEfdRetryConcurrency &&
1207
+ queue.length > 0
1208
+ ) {
1209
+ this.#activeDetachedEfdRetries++
1210
+ queue.shift().start()
1211
+ }
1212
+ }
1213
+
1214
+ /**
1215
+ * @param {DetachedEfdRetry} retry
1216
+ * @returns {void}
1217
+ */
1218
+ finishDetachedEfdRetry (retry) {
1219
+ this.#activeDetachedEfdRetries--
1220
+ retry.resolve()
1221
+ this.drainDetachedEfdRetries()
1222
+ }
1223
+
1224
+ /**
1225
+ * Preserves Jest's immediate error when a callback test also returns a value.
1226
+ *
1227
+ * @param {() => unknown} run
1228
+ * @param {(error?: Error) => void} done
1229
+ * @returns {void}
1230
+ */
1231
+ runCallbackEfdRetry (run, done) {
1232
+ let result
1233
+ try {
1234
+ result = run()
1235
+ } catch (error) {
1236
+ done(error)
1237
+ return
1238
+ }
1239
+ if (result !== undefined) {
1240
+ done(new Error(
1241
+ "Test functions cannot both take a 'done' callback and return something. " +
1242
+ "Either use a 'done' callback, or return a promise."
1243
+ ))
1244
+ }
1245
+ }
1246
+
883
1247
  /**
884
1248
  * Runs a concurrent test function inside its test span context.
885
1249
  *
@@ -896,24 +1260,51 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
896
1260
  if (typeof done === 'function') {
897
1261
  done()
898
1262
  }
1263
+ this.completePre30ConcurrentTest(concurrentTestState)
899
1264
  return
900
1265
  }
901
1266
 
902
1267
  const runTest = () => testFn.apply(thisArg, args)
903
- if (!ctx.isAttemptToFixRetry && !ctx.isEfdRetry) {
904
- ctx.isJestRetry = concurrentTestState.numExecutions > 0
1268
+ const runTestInContext = () => {
1269
+ if (!ctx.isAttemptToFixRetry && !ctx.isEfdRetry) {
1270
+ ctx.isJestRetry = concurrentTestState.numExecutions > 0
1271
+ }
1272
+ concurrentTestState.numExecutions++
1273
+
1274
+ if (ctx.currentStore) {
1275
+ return testFnCh.runStores(ctx, runTest)
1276
+ }
1277
+
1278
+ let result
1279
+ testStartCh.runStores(ctx, () => {
1280
+ result = testFnCh.runStores(ctx, runTest)
1281
+ })
1282
+ return result
1283
+ }
1284
+
1285
+ const retryGate = ctx.efdRetryGates?.[ctx.efdRetryIndex - 1]
1286
+ if (retryGate && !hasConcurrentTestsStartEvent) {
1287
+ ctx.detachedEfdRetryPromise = this.enqueueDetachedEfdRetry(retryGate, ctx, runTestInContext)
1288
+ return
1289
+ }
1290
+ if (retryGate && typeof args[0] === 'function') {
1291
+ this.runCallbackEfdRetry(runTestInContext, args[0])
1292
+ return
905
1293
  }
906
- concurrentTestState.numExecutions++
907
1294
 
908
- if (ctx.currentStore) {
909
- return testFnCh.runStores(ctx, runTest)
1295
+ if (!concurrentTestState.trackEfdCompletion) {
1296
+ return runTestInContext()
910
1297
  }
911
1298
 
1299
+ const startedAt = efdCandidates.has(ctx.name) ? performance.now() : undefined
912
1300
  let result
913
- testStartCh.runStores(ctx, () => {
914
- result = testFnCh.runStores(ctx, runTest)
915
- })
916
- return result
1301
+ try {
1302
+ result = runTestInContext()
1303
+ } catch (error) {
1304
+ this.completePre30ConcurrentTest(concurrentTestState, startedAt)
1305
+ throw error
1306
+ }
1307
+ return this.trackPre30ConcurrentTest(concurrentTestState, result, startedAt)
917
1308
  }
918
1309
 
919
1310
  /**
@@ -1047,30 +1438,63 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
1047
1438
  return result
1048
1439
  }
1049
1440
 
1050
- // Generic function to handle test retries
1441
+ /**
1442
+ * @param {number} retryCount
1443
+ * @returns {EfdRetryGate[]}
1444
+ */
1445
+ createEfdRetryGates (retryCount) {
1446
+ const gates = []
1447
+ for (let retryIndex = 0; retryIndex < retryCount; retryIndex++) {
1448
+ /** @type {(shouldRun: boolean) => void} */
1449
+ let resolveGate
1450
+ const promise = new Promise(resolve => {
1451
+ resolveGate = resolve
1452
+ })
1453
+ gates.push({ promise, resolve: resolveGate })
1454
+ }
1455
+ return gates
1456
+ }
1457
+
1458
+ /**
1459
+ * Registers retry tests while Jest still accepts additions to the test tree.
1460
+ *
1461
+ * @param {JestRetryOptions} options
1462
+ * @returns {number}
1463
+ */
1051
1464
  retryTest ({
1465
+ concurrentTestState: registeredConcurrentTestState,
1466
+ efdRetryGates,
1052
1467
  jestEvent,
1053
- forceSerial,
1468
+ state,
1469
+ isModified,
1054
1470
  retryCount,
1055
1471
  retryType,
1056
1472
  }) {
1057
- const { testName, fn, timeout } = jestEvent
1473
+ const { testName, fn, mode, timeout } = jestEvent
1058
1474
  const isAttemptToFixRetry = retryType === 'Test Management (Attempt to Fix)'
1059
1475
  const isEfdRetry = retryType === 'Impacted tests' || retryType === 'Early flake detection'
1060
- for (let retryIndex = 0; retryIndex < retryCount; retryIndex++) {
1061
- const concurrentTestState = this.concurrentTestStates.get(fn)
1476
+ const concurrentTestState = registeredConcurrentTestState || this.concurrentTestStates.get(fn)
1477
+ let originalTest
1478
+ if (isEfdRetry && !efdRetryGates) {
1479
+ originalTest = getTestEntries(state.currentDescribeBlock)?.at(-1)
1480
+ if (originalTest?.fn !== fn) {
1481
+ log.error('%s could not retain its original Jest test', retryType)
1482
+ return 0
1483
+ }
1484
+ }
1485
+ let registeredRetryCount = 0
1486
+ for (let retryIndex = 1; retryIndex <= retryCount; retryIndex++) {
1487
+ const testEntries = getTestEntries(state.currentDescribeBlock)
1488
+ const initialTestCount = testEntries?.length
1489
+ let retryFn
1062
1490
  if (concurrentTestState) {
1063
- const test = forceSerial
1064
- ? concurrentTestState.serialTest
1065
- : (
1066
- concurrentTestState.concurrentTest ||
1067
- getOriginalConcurrentTest(this.global.test?.concurrent)
1068
- )
1491
+ const test = concurrentTestState.concurrentTest ??
1492
+ getOriginalConcurrentTest(this.global.test?.concurrent)
1069
1493
  if (typeof test !== 'function') {
1070
1494
  log.error('%s could not retry concurrent test because test is undefined', retryType)
1071
1495
  continue
1072
1496
  }
1073
- const retryFn = this.createConcurrentTestFn(
1497
+ retryFn = this.createConcurrentTestFn(
1074
1498
  testName,
1075
1499
  concurrentTestState.testFn,
1076
1500
  undefined,
@@ -1078,65 +1502,427 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
1078
1502
  {
1079
1503
  concurrentTest: concurrentTestState.concurrentTest,
1080
1504
  concurrentTestThisArg: concurrentTestState.concurrentTestThisArg,
1505
+ efdRetryGates,
1506
+ efdRetryIndex: retryIndex,
1081
1507
  isAttemptToFixRetry,
1082
1508
  isEfdRetry,
1083
- isModified: concurrentTestState.ctx.isModified,
1084
- serialTest: concurrentTestState.serialTest,
1085
- serialTestThisArg: concurrentTestState.serialTestThisArg,
1509
+ isModified: isModified ?? concurrentTestState.ctx.isModified,
1086
1510
  testParameters: concurrentTestState.ctx.testParameters,
1511
+ timeout,
1087
1512
  }
1088
1513
  )
1089
- const thisArg = forceSerial
1090
- ? concurrentTestState.serialTestThisArg
1091
- : concurrentTestState.concurrentTestThisArg
1092
- test.call(thisArg, testName, retryFn, timeout)
1514
+ test.call(concurrentTestState.concurrentTestThisArg, testName, retryFn, timeout)
1093
1515
  } else if (this.global.test) {
1094
- this.global.test(testName, fn, timeout)
1516
+ const test = mode === 'only' ? this.global.test.only : this.global.test
1517
+ // The child-count check below proves Jest added a node, so retries can share the test
1518
+ // function instead of retaining one wrapper per scheduled execution.
1519
+ retryFn = fn
1520
+ test(testName, retryFn, timeout)
1095
1521
  } else {
1096
1522
  log.error('%s could not retry test because global.test is undefined', retryType)
1523
+ continue
1097
1524
  }
1525
+
1526
+ if (isEfdRetry) {
1527
+ const retryTest = testEntries?.length === initialTestCount + 1 ? testEntries.at(-1) : undefined
1528
+ if (retryTest?.fn !== retryFn) {
1529
+ log.error('%s could not retain its pre-registered Jest retry', retryType)
1530
+ continue
1531
+ }
1532
+ efdRetryMetadataByTest.set(retryTest, {
1533
+ isModified: Boolean(isModified),
1534
+ originalTest,
1535
+ retryIndex,
1536
+ })
1537
+ }
1538
+ registeredRetryCount++
1098
1539
  }
1540
+ return registeredRetryCount
1099
1541
  }
1100
1542
 
1101
- // At the `add_test` event we don't have the test object yet, so we can't use it
1543
+ /**
1544
+ * Registers retries for the test Jest just added, keeping a registration failure out of the
1545
+ * `add_test` dispatch, where Jest would report it as a test file error.
1546
+ *
1547
+ * @param {JestRetryOptions} retryOptions
1548
+ * @returns {boolean}
1549
+ */
1550
+ registerRetryTests (retryOptions) {
1551
+ // Jest turns later additions into "Cannot add a test after tests have started running".
1552
+ if (retryOptions.state.hasStarted) {
1553
+ log.error('%s could not register retries after Jest started', retryOptions.retryType)
1554
+ return false
1555
+ }
1556
+
1557
+ const testEntries = getTestEntries(retryOptions.state.currentDescribeBlock)
1558
+ const initialTestCount = testEntries?.length
1559
+ try {
1560
+ const registeredRetryCount = this.retryTest(retryOptions)
1561
+ if (registeredRetryCount === retryOptions.retryCount) return true
1562
+ } catch (error) {
1563
+ log.error('%s could not register retries', retryOptions.retryType, error)
1564
+ }
1565
+
1566
+ if (testEntries && initialTestCount !== undefined) {
1567
+ const retryTests = testEntries.splice(initialTestCount)
1568
+ for (const retryTest of retryTests) {
1569
+ removedRetryTests.add(retryTest)
1570
+ const retryCtx = this.concurrentTestStates.get(retryTest.fn)?.ctx
1571
+ if (retryCtx) {
1572
+ this.removeConcurrentTestContext(retryOptions.testFullName, retryCtx)
1573
+ }
1574
+ }
1575
+ const tests = retryOptions.state.currentDescribeBlock?.tests
1576
+ if (tests) {
1577
+ let writeIndex = 0
1578
+ for (const test of tests) {
1579
+ if (!removedRetryTests.has(test)) {
1580
+ tests[writeIndex++] = test
1581
+ }
1582
+ }
1583
+ tests.length = writeIndex
1584
+ }
1585
+ }
1586
+ this.#efdRetryGatesByName?.delete(retryOptions.testFullName)
1587
+ log.error('%s did not register every retry', retryOptions.retryType)
1588
+ return false
1589
+ }
1590
+
1591
+ /**
1592
+ * Drops the pre-registered EFD retries that exceed the retry count Jest may still run.
1593
+ *
1594
+ * @param {string} testName
1595
+ * @param {object} executedTest The Jest test node that selected the retry count.
1596
+ * @param {number} [retryCount] Retries to keep. Every retry is dropped when omitted.
1597
+ * @returns {number} Retries left to run.
1598
+ */
1599
+ discardEfdRetries (testName, executedTest, retryCount = 0) {
1600
+ const siblings = getTestEntries(executedTest.parent)
1601
+ const executedTestIndex = siblings ? siblings.indexOf(executedTest) : -1
1602
+ if (executedTestIndex === -1) return 0
1603
+
1604
+ const originalTest = efdRetryMetadataByTest.get(executedTest)?.originalTest ?? executedTest
1605
+
1606
+ // Circus iterates children live, so only entries after the test that just ran may move.
1607
+ // Whichever pre-registered slot runs first is the original execution; the next retryCount
1608
+ // slots are its retries, independent of Jest's randomized physical node order.
1609
+ let runnableRetryCount = 0
1610
+ let writeIndex = executedTestIndex + 1
1611
+ for (let readIndex = writeIndex; readIndex < siblings.length; readIndex++) {
1612
+ const sibling = siblings[readIndex]
1613
+ const siblingOriginalTest = efdRetryMetadataByTest.get(sibling)?.originalTest
1614
+ if (sibling === originalTest || siblingOriginalTest === originalTest) {
1615
+ if (runnableRetryCount >= retryCount) {
1616
+ removedRetryTests.add(sibling)
1617
+ const discardedCtx = this.concurrentTestStates.get(sibling.fn)?.ctx
1618
+ if (discardedCtx) {
1619
+ this.removeConcurrentTestContext(testName, discardedCtx)
1620
+ }
1621
+ continue
1622
+ }
1623
+ runnableRetryCount++
1624
+ }
1625
+ siblings[writeIndex++] = sibling
1626
+ }
1627
+ siblings.length = writeIndex
1628
+
1629
+ const tests = executedTest.parent?.tests
1630
+ if (tests) {
1631
+ writeIndex = 0
1632
+ for (const test of tests) {
1633
+ if (!removedRetryTests.has(test)) {
1634
+ tests[writeIndex++] = test
1635
+ }
1636
+ }
1637
+ tests.length = writeIndex
1638
+ }
1639
+ return runnableRetryCount
1640
+ }
1641
+
1642
+ /**
1643
+ * @param {object} describeBlock
1644
+ * @param {boolean} hasFocusedTests
1645
+ * @param {RegExp|undefined} testNamePattern
1646
+ * @returns {number}
1647
+ */
1648
+ markPre30ConcurrentTests (describeBlock, hasFocusedTests, testNamePattern) {
1649
+ if (describeBlock.mode === 'skip') return 0
1650
+
1651
+ let testCount = 0
1652
+ for (const child of describeBlock.children) {
1653
+ if (child.type === 'describeBlock') {
1654
+ testCount += this.markPre30ConcurrentTests(child, hasFocusedTests, testNamePattern)
1655
+ continue
1656
+ }
1657
+ if (
1658
+ child.type !== 'test' ||
1659
+ !child.concurrent ||
1660
+ efdRetryMetadataByTest.has(child) ||
1661
+ isJestTestSkipped(child, hasFocusedTests, testNamePattern)
1662
+ ) {
1663
+ continue
1664
+ }
1665
+
1666
+ const concurrentTestState = this.concurrentTestStates.get(child.fn)
1667
+ if (concurrentTestState) {
1668
+ concurrentTestState.trackEfdCompletion = true
1669
+ testCount++
1670
+ }
1671
+ }
1672
+ return testCount
1673
+ }
1674
+
1675
+ /**
1676
+ * @param {object} describeBlock
1677
+ * @param {object} state
1678
+ * @returns {void}
1679
+ */
1680
+ preparePre30ConcurrentTests (describeBlock, state) {
1681
+ const testNamePattern = state.testNamePattern
1682
+ ? new RegExp(state.testNamePattern.source, state.testNamePattern.flags)
1683
+ : undefined
1684
+ this.#detachedEfdRetryConcurrency = state.maxConcurrency ?? 1
1685
+ this.#detachedEfdRetryQueue = []
1686
+ this.#pendingPre30ConcurrentTests = this.markPre30ConcurrentTests(
1687
+ describeBlock,
1688
+ state.hasFocusedTests,
1689
+ testNamePattern
1690
+ )
1691
+ this.drainDetachedEfdRetries()
1692
+ }
1693
+
1694
+ /**
1695
+ * @param {object[]} tests
1696
+ * @returns {void}
1697
+ */
1698
+ orderConcurrentEfdRetries (tests) {
1699
+ if (!this.#efdRetryGatesByName) return
1700
+
1701
+ const retryTests = []
1702
+ let originalTestIndex = 0
1703
+ for (const test of tests) {
1704
+ if (efdRetryMetadataByTest.has(test)) {
1705
+ retryTests.push(test)
1706
+ } else {
1707
+ tests[originalTestIndex++] = test
1708
+ }
1709
+ }
1710
+ for (const retryTest of retryTests) {
1711
+ tests[originalTestIndex++] = retryTest
1712
+ }
1713
+ }
1714
+
1715
+ /**
1716
+ * @param {string} testName
1717
+ * @param {number} durationMs
1718
+ * @returns {void}
1719
+ */
1720
+ determineEfdRetries (testName, durationMs) {
1721
+ if (efdDeterminedRetries.has(testName)) return
1722
+
1723
+ const retryCount = getEfdRetryCountForDuration(durationMs, this.#earlyFlakeDetectionRetryPolicy)
1724
+ efdDeterminedRetries.set(testName, retryCount)
1725
+ efdExpectedExecutions.set(testName, retryCount + 1)
1726
+ const retryGates = this.#efdRetryGatesByName?.get(testName)
1727
+ if (retryGates) {
1728
+ for (let retryIndex = 0; retryIndex < retryGates.length; retryIndex++) {
1729
+ retryGates[retryIndex].resolve(retryIndex < retryCount)
1730
+ }
1731
+ }
1732
+ if (retryCount === 0) {
1733
+ efdSlowAbortedTests.add(testName)
1734
+ }
1735
+ }
1736
+
1737
+ /**
1738
+ * @returns {void}
1739
+ */
1740
+ removeDiscardedEfdRetries () {
1741
+ if (!this.#discardedEfdRetryTests) return
1742
+
1743
+ for (const retryTest of this.#discardedEfdRetryTests) {
1744
+ const siblings = getTestEntries(retryTest.parent)
1745
+ const retryTestIndex = siblings?.indexOf(retryTest) ?? -1
1746
+ if (retryTestIndex !== -1) {
1747
+ siblings.splice(retryTestIndex, 1)
1748
+ }
1749
+ }
1750
+ this.#discardedEfdRetryTests = undefined
1751
+ }
1752
+
1753
+ // The `add_test` payload has no test node, so derive its full name from the current describe block.
1102
1754
  getTestNameFromAddTestEvent (event, state) {
1103
1755
  const describeSuffix = getRawJestTestName(state.currentDescribeBlock)
1104
1756
  const testName = describeSuffix ? `${describeSuffix} ${event.testName}` : event.testName
1105
1757
  return removeSeedSuffixFromTestName(testName)
1106
1758
  }
1107
1759
 
1760
+ /**
1761
+ * Classifies a newly registered test and pre-registers every retry Jest may need.
1762
+ *
1763
+ * @param {object} event
1764
+ * @param {object} state
1765
+ * @returns {void}
1766
+ */
1767
+ handleAddTestEvent (event, state) {
1768
+ if (event.concurrent) {
1769
+ this.hasConcurrentTests = true
1770
+ }
1771
+ if (event.failing) {
1772
+ return
1773
+ }
1774
+
1775
+ const testFullName = this.getTestNameFromAddTestEvent(event, state)
1776
+ const concurrentTestContexts = this.concurrentTestContexts.get(testFullName)
1777
+ const concurrentTestState = this.concurrentTestStates.get(event.fn) ||
1778
+ concurrentTestContexts?.at(-1)?.concurrentTestState
1779
+ const registeredTest = getTestEntries(state.currentDescribeBlock)?.at(-1)
1780
+ if (concurrentTestState && registeredTest?.fn === event.fn) {
1781
+ testContexts.set(registeredTest, concurrentTestState.ctx)
1782
+ }
1783
+ if (retriedTestsToNumAttempts.has(testFullName)) {
1784
+ return
1785
+ }
1786
+ const isSkipped = event.mode === 'todo' || event.mode === 'skip'
1787
+ const isAttemptToFix = this.isTestManagementTestsEnabled &&
1788
+ this.testManagementTestsForThisSuite?.attemptToFix?.includes(testFullName)
1789
+ const isDisabled = this.isTestManagementTestsEnabled &&
1790
+ this.testManagementTestsForThisSuite?.disabled?.includes(testFullName)
1791
+ if (isAttemptToFix && !isSkipped) {
1792
+ retriedTestsToNumAttempts.set(testFullName, 0)
1793
+ testsToBeRetried.add(testFullName)
1794
+ const retriesRegistered = this.registerRetryTests({
1795
+ concurrentTestState,
1796
+ jestEvent: event,
1797
+ state,
1798
+ retryCount: testManagementAttemptToFixRetries,
1799
+ retryType: 'Test Management (Attempt to Fix)',
1800
+ testFullName,
1801
+ })
1802
+ if (!retriesRegistered) {
1803
+ retriedTestsToNumAttempts.delete(testFullName)
1804
+ testsToBeRetried.delete(testFullName)
1805
+ }
1806
+ }
1807
+
1808
+ const isModified = this.isImpactedTestsEnabled && (
1809
+ concurrentTestState?.ctx?.isModified ||
1810
+ this.isTestModified(event.asyncError, event.fn)
1811
+ )
1812
+ const isNew = this.isKnownTestsEnabled && !this.knownTestsForThisSuite.includes(testFullName)
1813
+
1814
+ if (isNew && !isSkipped && !isAttemptToFix) {
1815
+ newTests.add(testFullName)
1816
+ if (DYNAMIC_NAME_RE.test(testFullName)) {
1817
+ // Populated directly for runInBand; for parallel workers the main process
1818
+ // collects these from the TEST_HAS_DYNAMIC_NAME span tag via worker-report:trace.
1819
+ newTestsWithDynamicNames.add(`${this.testSuite} › ${testFullName}`)
1820
+ }
1821
+ }
1822
+
1823
+ if (
1824
+ isAttemptToFix ||
1825
+ isDisabled ||
1826
+ isSkipped ||
1827
+ !this.isEarlyFlakeDetectionEnabled ||
1828
+ !hasEfdRetries(this.#earlyFlakeDetectionRetryPolicy) ||
1829
+ (!isNew && !isModified)
1830
+ ) {
1831
+ return
1832
+ }
1833
+
1834
+ retriedTestsToNumAttempts.set(testFullName, 0)
1835
+ testsToBeRetried.add(testFullName)
1836
+ const schedulingRetryCount = this.#earlyFlakeDetectionRetryPolicy.schedulingRetryCount
1837
+ const efdRetryGates = concurrentTestState
1838
+ ? this.createEfdRetryGates(schedulingRetryCount)
1839
+ : undefined
1840
+ if (efdRetryGates) {
1841
+ this.#efdRetryGatesByName ??= new Map()
1842
+ this.#efdRetryGatesByName.set(testFullName, efdRetryGates)
1843
+ }
1844
+ const retriesRegistered = this.registerRetryTests({
1845
+ concurrentTestState,
1846
+ efdRetryGates,
1847
+ isModified,
1848
+ jestEvent: event,
1849
+ state,
1850
+ retryCount: schedulingRetryCount,
1851
+ retryType: isModified ? 'Impacted tests' : 'Early flake detection',
1852
+ testFullName,
1853
+ })
1854
+ if (retriesRegistered) {
1855
+ efdCandidates.add(testFullName)
1856
+ } else {
1857
+ retriedTestsToNumAttempts.delete(testFullName)
1858
+ testsToBeRetried.delete(testFullName)
1859
+ }
1860
+ }
1861
+
1108
1862
  async handleTestEvent (event, state) {
1109
1863
  if (isDatadogJestEventHandled(event)) return
1110
1864
  markDatadogJestEventHandled(event)
1111
1865
 
1866
+ if (event.name === 'add_test') {
1867
+ this.handleAddTestEvent(event, state)
1868
+ }
1869
+
1112
1870
  if (super.handleTestEvent) {
1113
1871
  await super.handleTestEvent(event, state)
1114
1872
  }
1115
1873
 
1116
1874
  if (event.name === 'setup') {
1117
1875
  this.wrapConcurrentTest(state)
1876
+ this.bindTestEach(this.global.test)
1118
1877
  }
1119
- if (event.name === 'setup' && this.global.test) {
1120
- const environment = this
1121
- shimmer.wrap(this.global.test, 'each', each => function (...args) {
1122
- const testParameters = getFormattedJestTestParameters(args)
1123
- const eachBind = each.apply(this, args)
1124
- return function (...args) {
1125
- const [testName] = args
1126
- environment.setNameToParams(testName, testParameters)
1127
- return eachBind.apply(this, args)
1128
- }
1129
- })
1878
+ if (
1879
+ event.name === 'run_describe_start' &&
1880
+ !hasConcurrentTestsStartEvent &&
1881
+ !event.describeBlock.parent &&
1882
+ this.#efdRetryGatesByName?.size > 0
1883
+ ) {
1884
+ this.preparePre30ConcurrentTests(event.describeBlock, state)
1130
1885
  }
1131
- if (event.name === 'test_start') {
1886
+ if (event.name === 'concurrent_tests_start') {
1887
+ this.orderConcurrentEfdRetries(event.tests)
1888
+ } else if (event.name === 'test_start') {
1132
1889
  const testName = getJestTestName(event.test)
1133
- const concurrentTestState = this.concurrentTestStates.get(event.test.fn)
1890
+ let isAttemptToFix = false
1891
+ let isDisabled = false
1892
+ let isQuarantined = false
1893
+ if (this.isTestManagementTestsEnabled) {
1894
+ isAttemptToFix = this.testManagementTestsForThisSuite?.attemptToFix?.includes(testName)
1895
+ isDisabled = this.testManagementTestsForThisSuite?.disabled?.includes(testName)
1896
+ isQuarantined = this.testManagementTestsForThisSuite?.quarantined?.includes(testName)
1897
+ if (!isAttemptToFix && isDisabled) {
1898
+ event.test.mode = 'skip'
1899
+ }
1900
+ }
1901
+ if (
1902
+ event.test.mode === 'todo' ||
1903
+ isJestTestSkipped(event.test, state.hasFocusedTests, state.testNamePattern)
1904
+ ) {
1905
+ return
1906
+ }
1907
+
1908
+ const registeredConcurrentTestState = testContexts.get(event.test)?.concurrentTestState
1909
+ const concurrentTestState = this.concurrentTestStates.get(event.test.fn) ||
1910
+ registeredConcurrentTestState
1134
1911
  let concurrentCtx = concurrentTestState?.ctx
1135
1912
  if (concurrentCtx) {
1136
1913
  this.removeConcurrentTestContext(testName, concurrentCtx)
1137
1914
  } else {
1138
1915
  concurrentCtx = this.getNextConcurrentTestContext(testName)
1139
1916
  }
1917
+ if (hasConcurrentTestsStartEvent && concurrentCtx?.efdRetryGates) {
1918
+ const retryGate = concurrentCtx.efdRetryGates[concurrentCtx.efdRetryIndex - 1]
1919
+ const shouldRun = await retryGate.promise
1920
+ if (!shouldRun) {
1921
+ concurrentCtx.isDiscardedEfdRetry = true
1922
+ event.test.mode = 'skip'
1923
+ return
1924
+ }
1925
+ }
1140
1926
  if (testsToBeRetried.has(testName)) {
1141
1927
  // This is needed because we're retrying tests with the same name
1142
1928
  this.resetSnapshotState()
@@ -1148,24 +1934,17 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
1148
1934
  let numOfAttemptsToFixRetries = null
1149
1935
  const testParameters = concurrentCtx?.testParameters ||
1150
1936
  concurrentTestState?.ctx?.testParameters ||
1937
+ this.testParametersByFunction.get(event.test.fn) ||
1151
1938
  getTestParametersString(this.nameToParams, event.test.name)
1152
1939
 
1153
- let isAttemptToFix = false
1154
- let isDisabled = false
1155
- let isQuarantined = false
1156
- if (this.isTestManagementTestsEnabled) {
1157
- isAttemptToFix = this.testManagementTestsForThisSuite?.attemptToFix?.includes(testName)
1158
- isDisabled = this.testManagementTestsForThisSuite?.disabled?.includes(testName)
1159
- isQuarantined = this.testManagementTestsForThisSuite?.quarantined?.includes(testName)
1160
- if (isAttemptToFix) {
1161
- numOfAttemptsToFixRetries = retriedTestsToNumAttempts.get(testName)
1162
- retriedTestsToNumAttempts.set(testName, numOfAttemptsToFixRetries + 1)
1163
- } else if (isDisabled) {
1164
- event.test.mode = 'skip'
1165
- }
1940
+ if (isAttemptToFix) {
1941
+ numOfAttemptsToFixRetries = retriedTestsToNumAttempts.get(testName)
1942
+ retriedTestsToNumAttempts.set(testName, numOfAttemptsToFixRetries + 1)
1166
1943
  }
1167
1944
 
1168
- let isModified = this.isTestModified(event.test.asyncError, event.test.fn)
1945
+ const efdRetryMetadata = efdRetryMetadataByTest.get(event.test)
1946
+ let isModified = efdRetryMetadata?.isModified ||
1947
+ this.isTestModified(event.test.asyncError, event.test.fn)
1169
1948
  if (concurrentCtx?.isModified || concurrentTestState?.ctx?.isModified) {
1170
1949
  isModified = true
1171
1950
  }
@@ -1174,7 +1953,9 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
1174
1953
  isNewTest = newTests.has(testName)
1175
1954
  }
1176
1955
 
1177
- const willRunEfd = this.isEarlyFlakeDetectionEnabled && (isNewTest || isModified)
1956
+ const willRunEfd = this.isEarlyFlakeDetectionEnabled &&
1957
+ hasEfdRetries(this.#earlyFlakeDetectionRetryPolicy) &&
1958
+ (isNewTest || isModified)
1178
1959
  event.test[ATR_RETRY_SUPPRESSION_FLAG] = Boolean(isAttemptToFix || willRunEfd)
1179
1960
 
1180
1961
  if (!isAttemptToFix && willRunEfd) {
@@ -1193,6 +1974,7 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
1193
1974
  ctx.frameworkVersion = jestVersion
1194
1975
  ctx.isNew = isNewTest
1195
1976
  ctx.isEfdRetry ||= numEfdRetry > 0
1977
+ ctx.efdRetryIndex ??= efdRetryMetadata?.retryIndex
1196
1978
  ctx.isAttemptToFix = isAttemptToFix
1197
1979
  ctx.isAttemptToFixRetry ||= numOfAttemptsToFixRetries > 0
1198
1980
  ctx.isJestRetry = isJestRetry
@@ -1301,67 +2083,23 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
1301
2083
  })
1302
2084
  }
1303
2085
 
1304
- if (event.name === 'add_test') {
1305
- if (event.concurrent) {
1306
- this.hasConcurrentTests = true
1307
- }
1308
-
1309
- if (event.failing) {
1310
- return
1311
- }
1312
-
1313
- const testFullName = this.getTestNameFromAddTestEvent(event, state)
1314
- const isSkipped = event.mode === 'todo' || event.mode === 'skip'
1315
- const isAttemptToFix = this.isTestManagementTestsEnabled &&
1316
- this.testManagementTestsForThisSuite?.attemptToFix?.includes(testFullName)
1317
- if (
1318
- isAttemptToFix &&
1319
- !isSkipped &&
1320
- !retriedTestsToNumAttempts.has(testFullName)
1321
- ) {
1322
- retriedTestsToNumAttempts.set(testFullName, 0)
1323
- testsToBeRetried.add(testFullName)
1324
- this.retryTest({
1325
- jestEvent: event,
1326
- retryCount: testManagementAttemptToFixRetries,
1327
- retryType: 'Test Management (Attempt to Fix)',
1328
- })
2086
+ if (event.name === 'test_done') {
2087
+ const retryCtx = testContexts.get(event.test)
2088
+ if (!hasConcurrentTestsStartEvent && retryCtx?.concurrentTestState) {
2089
+ this.completePre30ConcurrentTest(retryCtx.concurrentTestState)
1329
2090
  }
1330
- if (!isAttemptToFix && this.isImpactedTestsEnabled) {
1331
- const concurrentTestState = this.concurrentTestStates.get(event.fn)
1332
- const isModified = concurrentTestState?.ctx?.isModified ||
1333
- this.isTestModified(event.asyncError, event.fn)
1334
- if (isModified && !retriedTestsToNumAttempts.has(testFullName) && this.isEarlyFlakeDetectionEnabled) {
1335
- retriedTestsToNumAttempts.set(testFullName, 0)
1336
- testsToBeRetried.add(testFullName)
1337
- this.retryTest({
1338
- jestEvent: event,
1339
- retryCount: earlyFlakeDetectionNumRetries,
1340
- retryType: 'Impacted tests',
1341
- })
2091
+ if (retryCtx?.detachedEfdRetryPromise) {
2092
+ await retryCtx.detachedEfdRetryPromise
2093
+ if (retryCtx.detachedEfdRetryError !== undefined) {
2094
+ event.test.errors.push(retryCtx.detachedEfdRetryError)
1342
2095
  }
1343
2096
  }
1344
- if (!isAttemptToFix && this.isKnownTestsEnabled) {
1345
- const isNew = !this.knownTestsForThisSuite.includes(testFullName)
1346
- if (isNew && !isSkipped) {
1347
- newTests.add(testFullName)
1348
- }
1349
- if (isNew && !isSkipped && !retriedTestsToNumAttempts.has(testFullName)) {
1350
- if (DYNAMIC_NAME_RE.test(testFullName)) {
1351
- // Populated directly for runInBand; for parallel workers the main process
1352
- // collects these from the TEST_HAS_DYNAMIC_NAME span tag via worker-report:trace.
1353
- newTestsWithDynamicNames.add(`${this.testSuite} › ${testFullName}`)
1354
- }
1355
- retriedTestsToNumAttempts.set(testFullName, 0)
1356
- if (this.isEarlyFlakeDetectionEnabled) {
1357
- testsToBeRetried.add(testFullName)
1358
- efdNewTestCandidates.add(testFullName)
1359
- // Cloning is deferred to test_done after the first execution,
1360
- // when we know the duration and can choose the right retry count.
1361
- }
1362
- }
2097
+ if (retryCtx?.isDiscardedEfdRetry) {
2098
+ this.#discardedEfdRetryTests ??= new Set()
2099
+ this.#discardedEfdRetryTests.add(event.test)
2100
+ return
1363
2101
  }
1364
- } else if (event.name === 'test_done') {
2102
+
1365
2103
  const originalError = event.test?.errors?.[0]
1366
2104
  let status = 'pass'
1367
2105
  if (event.test.errors && event.test.errors.length) {
@@ -1409,71 +2147,32 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
1409
2147
  }
1410
2148
  }
1411
2149
 
1412
- // EFD dynamic cloning: on first execution of a new EFD candidate,
1413
- // determine the retry count from the test's duration.
2150
+ // The first execution selects how many pre-registered EFD retries remain runnable.
1414
2151
  if (
1415
2152
  this.isEarlyFlakeDetectionEnabled &&
1416
- this.isKnownTestsEnabled &&
1417
- efdNewTestCandidates.has(testName) &&
2153
+ efdCandidates.has(testName) &&
1418
2154
  event.test.invocations === 1 &&
1419
2155
  !efdDeterminedRetries.has(testName)
1420
2156
  ) {
1421
- const durationMs = event.test.duration ?? 0
1422
- const retryCount = getEfdRetryCount(durationMs, earlyFlakeDetectionSlowTestRetries)
1423
- efdDeterminedRetries.set(testName, retryCount)
1424
- if (retryCount > 0) {
1425
- // Temporarily adjust jest-circus state so that retry tests are registered
1426
- // into the correct describe block and bypass the "tests have started" guard.
1427
- //
1428
- // Problem 1 (jest-circus ≤24): currentDescribeBlock points to ROOT during
1429
- // execution, and ROOT's tests loop already finished before children ran.
1430
- //
1431
- // Problem 2 (jest-circus ≥27): `hasStarted = true` causes `test()` to throw
1432
- // "Cannot add a test after tests have started running".
1433
- //
1434
- // Fix: temporarily point currentDescribeBlock to the test's parent (so retries
1435
- // land in the still-iterating children array) and set hasStarted = false (so the
1436
- // guard is bypassed). Both are restored immediately after scheduling the retries.
1437
- const originalDescribeBlock = state.currentDescribeBlock
1438
- const originalHasStarted = state.hasStarted
1439
- state.currentDescribeBlock = event.test.parent ?? originalDescribeBlock
1440
- state.hasStarted = false
1441
- this.retryTest({
1442
- forceSerial: true,
1443
- jestEvent: {
1444
- testName: event.test.name,
1445
- fn: event.test.fn,
1446
- timeout: event.test.timeout,
1447
- },
1448
- retryCount,
1449
- retryType: 'Early flake detection',
1450
- })
1451
- state.currentDescribeBlock = originalDescribeBlock
1452
- state.hasStarted = originalHasStarted
1453
- } else {
1454
- efdSlowAbortedTests.add(testName)
2157
+ this.determineEfdRetries(testName, event.test.duration ?? 0)
2158
+ if (!this.#efdRetryGatesByName?.has(testName)) {
2159
+ this.discardEfdRetries(testName, event.test, efdDeterminedRetries.get(testName))
1455
2160
  }
1456
2161
  }
1457
2162
 
1458
- let isEfdRetry = false
1459
- // We'll store the test statuses of the retries
1460
- if (this.isKnownTestsEnabled) {
1461
- const isNewTest = newTests.has(testName)
1462
- if (isNewTest) {
1463
- if (newTestsTestStatuses.has(testName)) {
1464
- newTestsTestStatuses.get(testName).push(status)
1465
- isEfdRetry = true
1466
- } else {
1467
- newTestsTestStatuses.set(testName, [status])
1468
- }
1469
- const testStatuses = newTestsTestStatuses.get(testName)
1470
- // Check if this is the last EFD retry.
1471
- // If it is, we'll set the failedAllTests flag to true if all the tests failed
1472
- const efdRetryCount = efdDeterminedRetries.get(testName) ?? 0
1473
- if (efdRetryCount > 0 && testStatuses.length === efdRetryCount + 1 &&
1474
- testStatuses.every(status => status === 'fail')) {
1475
- failedAllTests = true
1476
- }
2163
+ const isEfdRetry = retryCtx?.isEfdRetry === true
2164
+ if (efdCandidates.has(testName)) {
2165
+ let testStatuses = efdTestStatuses.get(testName)
2166
+ if (testStatuses) {
2167
+ testStatuses.push(status)
2168
+ } else {
2169
+ testStatuses = [status]
2170
+ efdTestStatuses.set(testName, testStatuses)
2171
+ }
2172
+ const efdRetryCount = efdDeterminedRetries.get(testName) ?? 0
2173
+ if (efdRetryCount > 0 && testStatuses.length === efdExpectedExecutions.get(testName) &&
2174
+ testStatuses.every(status => status === 'fail')) {
2175
+ failedAllTests = true
1477
2176
  }
1478
2177
  }
1479
2178
 
@@ -1510,6 +2209,16 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
1510
2209
  log.warn('"ci:jest:test_done": no context found for test "%s"', testName)
1511
2210
  return
1512
2211
  }
2212
+ if (!isJestWorker) {
2213
+ recordTestManagementExecution({
2214
+ testSuite: ctx.suite,
2215
+ testName: ctx.name,
2216
+ status,
2217
+ isAttemptToFix: ctx.isAttemptToFix,
2218
+ isDisabled: ctx.isDisabled,
2219
+ isQuarantined: ctx.isQuarantined,
2220
+ })
2221
+ }
1513
2222
  if (ctx.concurrentTestState && !ctx.currentStore && !ctx.isDisabled) {
1514
2223
  testStartCh.runStores(ctx, () => {})
1515
2224
  }
@@ -1565,6 +2274,7 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
1565
2274
  ctx.currentStore = undefined
1566
2275
  }
1567
2276
  } else if (event.name === 'run_finish') {
2277
+ this.removeDiscardedEfdRetries()
1568
2278
  for (const [test, errors] of atrSuppressedErrors) {
1569
2279
  // Do not restore errors for non-ATF quarantined tests — they should stay suppressed
1570
2280
  // so Jest doesn't see the failure (prevents --bail from stopping the run).
@@ -1586,15 +2296,31 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
1586
2296
  }
1587
2297
 
1588
2298
  efdDeterminedRetries.clear()
2299
+ efdExpectedExecutions.clear()
1589
2300
  efdSlowAbortedTests.clear()
1590
- efdNewTestCandidates.clear()
2301
+ efdCandidates.clear()
1591
2302
  newTests.clear()
1592
2303
  retriedTestsToNumAttempts.clear()
1593
2304
  attemptToFixRetriedTestsStatuses.clear()
1594
2305
  testsToBeRetried.clear()
2306
+ this.#efdRetryGatesByName = undefined
2307
+ this.#detachedEfdRetryQueue = undefined
1595
2308
  testSuiteDatadogEnvironments.delete(this.testSuiteAbsolutePath)
1596
2309
  } else if (event.name === 'test_skip' || event.name === 'test_todo') {
1597
2310
  const testName = getJestTestName(event.test)
2311
+ const retryGates = this.#efdRetryGatesByName?.get(testName)
2312
+ if (retryGates) {
2313
+ if (efdRetryMetadataByTest.has(event.test)) {
2314
+ this.#discardedEfdRetryTests ??= new Set()
2315
+ this.#discardedEfdRetryTests.add(event.test)
2316
+ return
2317
+ }
2318
+ for (const gate of retryGates) {
2319
+ gate.resolve(false)
2320
+ }
2321
+ } else {
2322
+ this.discardEfdRetries(testName, event.test)
2323
+ }
1598
2324
  testSkippedCh.publish({
1599
2325
  test: {
1600
2326
  name: testName,
@@ -1609,18 +2335,18 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
1609
2335
  }
1610
2336
  }
1611
2337
 
1612
- getEfdResult ({ testName, isNewTest, isModifiedTest, isEfdRetry, numberOfExecutedRetries }) {
2338
+ getEfdResult ({ testName, isNewTest, isModifiedTest }) {
1613
2339
  const isEfdEnabled = this.isEarlyFlakeDetectionEnabled
1614
- const isEfdActive = isEfdEnabled && (isNewTest || isModifiedTest)
1615
- const retryCount = efdDeterminedRetries.get(testName) ?? 0
1616
- const isSlowAbort = efdSlowAbortedTests.has(testName)
1617
- const isLastEfdRetry = (isEfdRetry && numberOfExecutedRetries >= (retryCount + 1)) || isSlowAbort
1618
- const isFinalEfdTestExecution = isEfdActive && isLastEfdRetry
2340
+ const isEfdActive = isEfdEnabled &&
2341
+ hasEfdRetries(this.#earlyFlakeDetectionRetryPolicy) &&
2342
+ (isNewTest || isModifiedTest)
2343
+ const testStatuses = efdTestStatuses.get(testName)
2344
+ const isFinalEfdTestExecution = isEfdActive &&
2345
+ (efdSlowAbortedTests.has(testName) || testStatuses?.length === (efdExpectedExecutions.get(testName) ?? 0))
1619
2346
 
1620
2347
  let finalStatus
1621
2348
  if (isEfdActive && isFinalEfdTestExecution) {
1622
2349
  // For EFD: The framework reports 'pass' if ANY attempt passed (flaky but not failing)
1623
- const testStatuses = newTestsTestStatuses.get(testName)
1624
2350
  finalStatus = testStatuses && testStatuses.includes('pass') ? 'pass' : 'fail'
1625
2351
  }
1626
2352
 
@@ -1659,15 +2385,21 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
1659
2385
  return { isAttemptToFixEnabled, isFinalAttemptToFixExecution, finalStatus }
1660
2386
  }
1661
2387
 
1662
- getFinalStatus (testName, status, isNewTest, isModifiedTest, isEfdRetry, isAttemptToFix, numberOfTestInvocations) {
2388
+ getFinalStatus (
2389
+ testName,
2390
+ status,
2391
+ isNewTest,
2392
+ isModifiedTest,
2393
+ isEfdRetry,
2394
+ isAttemptToFix,
2395
+ numberOfTestInvocations
2396
+ ) {
1663
2397
  const numberOfExecutedRetries = retriedTestsToNumAttempts.get(testName) ?? 0
1664
2398
 
1665
2399
  const efdResult = this.getEfdResult({
1666
2400
  testName,
1667
2401
  isNewTest,
1668
2402
  isModifiedTest,
1669
- isEfdRetry,
1670
- numberOfExecutedRetries,
1671
2403
  })
1672
2404
  const atrResult = this.getAtrResult({ status, isEfdRetry, isAttemptToFix, numberOfTestInvocations })
1673
2405
  const attemptToFixResult = this.getAttemptToFixResult({
@@ -1866,8 +2598,6 @@ function resetLibraryConfiguration () {
1866
2598
  isItrEnabled = false
1867
2599
  isSuitesSkippingEnabled = false
1868
2600
  isEarlyFlakeDetectionEnabled = false
1869
- earlyFlakeDetectionNumRetries = 0
1870
- earlyFlakeDetectionSlowTestRetries = {}
1871
2601
  earlyFlakeDetectionFaultyThreshold = 30
1872
2602
  isEarlyFlakeDetectionFaulty = false
1873
2603
  isKnownTestsEnabled = false
@@ -1895,8 +2625,8 @@ function applySuiteSkipping (originalTests, rootDir, frameworkVersion) {
1895
2625
  hasUnskippableSuites = jestSuitesToRun.hasUnskippableSuites
1896
2626
  hasForcedToRunSuites = jestSuitesToRun.hasForcedToRunSuites
1897
2627
 
1898
- isSuitesSkipped = jestSuitesToRun.suitesToRun.length !== originalTests.length
1899
- numSkippedSuites = jestSuitesToRun.skippedSuites.length
2628
+ isSuitesSkipped ||= jestSuitesToRun.suitesToRun.length !== originalTests.length
2629
+ numSkippedSuites += jestSuitesToRun.skippedSuites.length
1900
2630
  skippedSuitesCoverage = isSuitesSkipped && isTiaCoverageBackfillEnabled() && hasSkippableSuitesCoverage()
1901
2631
  ? skippableSuitesCoverage
1902
2632
  : {}
@@ -2242,8 +2972,6 @@ function getCliWrapper (isNewJestVersion) {
2242
2972
  isItrEnabled = libraryConfig.isItrEnabled
2243
2973
  isSuitesSkippingEnabled = isItrEnabled && libraryConfig.isSuitesSkippingEnabled
2244
2974
  isEarlyFlakeDetectionEnabled = libraryConfig.isEarlyFlakeDetectionEnabled
2245
- earlyFlakeDetectionNumRetries = libraryConfig.earlyFlakeDetectionNumRetries
2246
- earlyFlakeDetectionSlowTestRetries = libraryConfig.earlyFlakeDetectionSlowTestRetries ?? {}
2247
2975
  earlyFlakeDetectionFaultyThreshold = libraryConfig.earlyFlakeDetectionFaultyThreshold
2248
2976
  isKnownTestsEnabled = libraryConfig.isKnownTestsEnabled
2249
2977
  isTestManagementTestsEnabled = libraryConfig.isTestManagementEnabled
@@ -2377,15 +3105,24 @@ function getCliWrapper (isNewJestVersion) {
2377
3105
  }
2378
3106
  }
2379
3107
 
2380
- /** @type {{ efdNames: string[], quarantineNames: string[], totalCount: number } | undefined} */
3108
+ /**
3109
+ * @type {{
3110
+ * efdNames: string[],
3111
+ * quarantineNames: string[],
3112
+ * totalCount: number,
3113
+ * efdFailureCount: number
3114
+ * } | undefined}
3115
+ */
2381
3116
  let ignoredFailuresSummary
2382
3117
  if (isEarlyFlakeDetectionEnabled) {
2383
- for (const [testName, testStatuses] of newTestsTestStatuses) {
3118
+ for (const [testName, testStatuses] of efdTestStatuses) {
2384
3119
  const { pass, fail } = getTestStats(testStatuses)
2385
3120
  if (pass > 0) { // as long as one passes, we'll consider the test passed
2386
3121
  numEfdFailedTestsToIgnore += fail
2387
- const suite = fullNameToSuite.get(testName)
2388
- efdIgnoredNames.push(suite ? `${suite} ${testName}` : testName)
3122
+ if (fail > 0) {
3123
+ const suite = fullNameToSuite.get(testName)
3124
+ efdIgnoredNames.push(suite ? `${suite} › ${testName}` : testName)
3125
+ }
2389
3126
  }
2390
3127
  }
2391
3128
  // If every test that failed was an EFD retry, we'll consider the suite passed
@@ -2399,6 +3136,7 @@ function getCliWrapper (isNewJestVersion) {
2399
3136
  efdNames: efdIgnoredNames,
2400
3137
  quarantineNames: [],
2401
3138
  totalCount: numEfdFailedTestsToIgnore,
3139
+ efdFailureCount: numEfdFailedTestsToIgnore,
2402
3140
  }
2403
3141
  }
2404
3142
  }
@@ -2471,6 +3209,7 @@ function getCliWrapper (isNewJestVersion) {
2471
3209
  efdNames: [],
2472
3210
  quarantineNames: quarantineIgnoredNames,
2473
3211
  totalCount: totalQuarantineFailures,
3212
+ efdFailureCount: 0,
2474
3213
  }
2475
3214
  }
2476
3215
  }
@@ -2496,6 +3235,7 @@ function getCliWrapper (isNewJestVersion) {
2496
3235
  efdNames: efdIgnoredNames,
2497
3236
  quarantineNames: quarantineIgnoredNames,
2498
3237
  totalCount: visibleIgnoredFailures + numSuppressedQuarantinedTests,
3238
+ efdFailureCount: numEfdFailedTestsToIgnore,
2499
3239
  }
2500
3240
  }
2501
3241
  }
@@ -2518,7 +3258,13 @@ function getCliWrapper (isNewJestVersion) {
2518
3258
  await getChannelPromise(codeCoverageReportCh, { rootDir })
2519
3259
  }
2520
3260
 
2521
- logSessionSummary(ignoredFailuresSummary, getAttemptToFixExecutionsFromJestResults(result))
3261
+ recordTestManagementExecutionsFromJestResults(result, quarantineIgnoredNames)
3262
+ const allTestsSkipped = isSuitesSkipped && numTotalTests === 0 && numTotalTestSuites === 0
3263
+ logSessionSummary(
3264
+ ignoredFailuresSummary,
3265
+ getAttemptToFixExecutionsFromJestResults(result),
3266
+ allTestsSkipped
3267
+ )
2522
3268
 
2523
3269
  resetSuiteSkippingRunState()
2524
3270
 
@@ -2861,7 +3607,7 @@ const DD_TEST_ENVIRONMENT_OPTION_KEYS = [
2861
3607
  '_ddItrCorrelationId',
2862
3608
  '_ddKnownTests',
2863
3609
  '_ddIsEarlyFlakeDetectionEnabled',
2864
- '_ddEarlyFlakeDetectionSlowTestRetries',
3610
+ '_ddEarlyFlakeDetectionRetryPolicy',
2865
3611
  '_ddRepositoryRoot',
2866
3612
  '_ddTestCodeCoverageEnabled',
2867
3613
  '_ddIsFlakyTestRetriesEnabled',