codeceptjs 3.7.5-beta.8 → 3.7.5

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.
package/bin/codecept.js CHANGED
@@ -296,29 +296,6 @@ program
296
296
 
297
297
  .action(require('../lib/command/run-rerun'))
298
298
 
299
- program
300
- .command('run-failed-tests')
301
- .description('Re-run tests that failed in the previous test run')
302
- .option(commandFlags.config.flag, commandFlags.config.description)
303
- .option(commandFlags.profile.flag, commandFlags.profile.description)
304
- .option(commandFlags.verbose.flag, commandFlags.verbose.description)
305
- .option(commandFlags.debug.flag, commandFlags.debug.description)
306
- .option(commandFlags.steps.flag, commandFlags.steps.description)
307
- .option('-o, --override [value]', 'override current config options')
308
- .option('-f, --file [path]', 'path to failed tests file (default: ./failed-tests.json)')
309
- .option('-g, --grep <pattern>', 'only run failed tests matching <pattern>')
310
- .option('-p, --plugins <k=v,k2=v2,...>', 'enable plugins, comma-separated')
311
- .option('--features', 'run only *.feature files and skip tests')
312
- .option('--tests', 'run only JS test files and skip features')
313
- .option('--colors', 'force enabling of colors')
314
- .option('--no-colors', 'force disabling of colors')
315
- .option('-R, --reporter <name>', 'specify the reporter to use')
316
- .option('-O, --reporter-options <k=v,k2=v2,...>', 'reporter-specific options')
317
- .option('--workers <number>', 'run failed tests in parallel using specified number of workers')
318
- .option('--suites', 'parallel execution of suites not single tests (when using --workers)')
319
- .option('--by <strategy>', 'test distribution strategy when using --workers: "test", "suite", or "pool"')
320
- .action(errorHandler(require('../lib/command/run-failed-tests')))
321
-
322
299
  program.on('command:*', cmd => {
323
300
  console.log(`\nUnknown command ${cmd}\n`)
324
301
  program.outputHelp()
@@ -642,6 +642,9 @@ class Playwright extends Helper {
642
642
  async _after() {
643
643
  if (!this.isRunning) return
644
644
 
645
+ // Clear popup state to prevent leakage between tests
646
+ popupStore.clear()
647
+
645
648
  if (this.isElectron) {
646
649
  this.browser.close()
647
650
  this.electronSessions.forEach(session => session.close())
@@ -330,6 +330,9 @@ class Puppeteer extends Helper {
330
330
  async _after() {
331
331
  if (!this.isRunning) return
332
332
 
333
+ // Clear popup state to prevent leakage between tests
334
+ popupStore.clear()
335
+
333
336
  // close other sessions
334
337
  const contexts = this.browser.browserContexts()
335
338
  const defaultCtx = contexts.shift()
@@ -2588,7 +2591,8 @@ class Puppeteer extends Helper {
2588
2591
  *
2589
2592
  * {{> stopRecordingTraffic }}
2590
2593
  */
2591
- stopRecordingTraffic() {
2594
+ async stopRecordingTraffic() {
2595
+ await this.page.setRequestInterception(false)
2592
2596
  stopRecordingTraffic.call(this)
2593
2597
  }
2594
2598
 
@@ -491,7 +491,7 @@ class WebDriver extends Helper {
491
491
  }
492
492
  config.capabilities.browserName = config.browser || config.capabilities.browserName
493
493
 
494
- // WebDriver Bidi Protocol. Default: false
494
+ // WebDriver Bidi Protocol. Default: true
495
495
  config.capabilities.webSocketUrl = config.bidiProtocol ?? config.capabilities.webSocketUrl ?? true
496
496
 
497
497
  config.capabilities.browserVersion = config.browserVersion || config.capabilities.browserVersion
@@ -629,8 +629,11 @@ class WebDriver extends Helper {
629
629
 
630
630
  this.browser.on('dialog', () => {})
631
631
 
632
- await this.browser.sessionSubscribe({ events: ['log.entryAdded'] })
633
- this.browser.on('log.entryAdded', logEvents)
632
+ // Check for Bidi, because "sessionSubscribe" is an exclusive Bidi protocol feature. Otherwise, error will be thrown.
633
+ if (this.browser.capabilities && this.browser.capabilities.webSocketUrl) {
634
+ await this.browser.sessionSubscribe({ events: ['log.entryAdded'] })
635
+ this.browser.on('log.entryAdded', logEvents)
636
+ }
634
637
 
635
638
  return this.browser
636
639
  }
@@ -30,7 +30,7 @@ async function seeTraffic({
30
30
  throw new Error('Missing required key "url" in object given to "I.seeTraffic".');
31
31
  }
32
32
 
33
- if (!this.recording || !this.recordedAtLeastOnce) {
33
+ if (!this.recordedAtLeastOnce) {
34
34
  throw new Error('Failure in test automation. You use "I.seeTraffic", but "I.startRecordingTraffic" was never called before.');
35
35
  }
36
36
 
@@ -66,7 +66,7 @@ async function seeTraffic({
66
66
  }
67
67
 
68
68
  async function grabRecordedNetworkTraffics() {
69
- if (!this.recording || !this.recordedAtLeastOnce) {
69
+ if (!this.recordedAtLeastOnce) {
70
70
  throw new Error('Failure in test automation. You use "I.grabRecordedNetworkTraffics", but "I.startRecordingTraffic" was never called before.');
71
71
  }
72
72
 
@@ -107,6 +107,8 @@ async function grabRecordedNetworkTraffics() {
107
107
  function stopRecordingTraffic() {
108
108
  // @ts-ignore
109
109
  this.page.removeAllListeners('request');
110
+ // @ts-ignore
111
+ this.page.removeAllListeners('requestfinished');
110
112
  this.recording = false;
111
113
  }
112
114
 
@@ -0,0 +1,110 @@
1
+ const event = require('../event')
2
+ const output = require('../output')
3
+ const Config = require('../config')
4
+ const { isNotSet } = require('../utils')
5
+
6
+ const hooks = ['Before', 'After', 'BeforeSuite', 'AfterSuite']
7
+
8
+ /**
9
+ * Priority levels for retry mechanisms (higher number = higher priority)
10
+ * This ensures consistent behavior when multiple retry mechanisms are active
11
+ */
12
+ const RETRY_PRIORITIES = {
13
+ MANUAL_STEP: 100, // I.retry() or step.retry() - highest priority
14
+ STEP_PLUGIN: 50, // retryFailedStep plugin
15
+ SCENARIO_CONFIG: 30, // Global scenario retry config
16
+ FEATURE_CONFIG: 20, // Global feature retry config
17
+ HOOK_CONFIG: 10, // Hook retry config - lowest priority
18
+ }
19
+
20
+ /**
21
+ * Enhanced global retry mechanism that coordinates with other retry types
22
+ */
23
+ module.exports = function () {
24
+ event.dispatcher.on(event.suite.before, suite => {
25
+ let retryConfig = Config.get('retry')
26
+ if (!retryConfig) return
27
+
28
+ if (Number.isInteger(+retryConfig)) {
29
+ // is number - apply as feature-level retry
30
+ const retryNum = +retryConfig
31
+ output.log(`[Global Retry] Feature retries: ${retryNum}`)
32
+
33
+ // Only set if not already set by higher priority mechanism
34
+ if (isNotSet(suite.retries())) {
35
+ suite.retries(retryNum)
36
+ suite.opts.retryPriority = RETRY_PRIORITIES.FEATURE_CONFIG
37
+ }
38
+ return
39
+ }
40
+
41
+ if (!Array.isArray(retryConfig)) {
42
+ retryConfig = [retryConfig]
43
+ }
44
+
45
+ for (const config of retryConfig) {
46
+ if (config.grep) {
47
+ if (!suite.title.includes(config.grep)) continue
48
+ }
49
+
50
+ // Handle hook retries with priority awareness
51
+ hooks
52
+ .filter(hook => !!config[hook])
53
+ .forEach(hook => {
54
+ const retryKey = `retry${hook}`
55
+ if (isNotSet(suite.opts[retryKey])) {
56
+ suite.opts[retryKey] = config[hook]
57
+ suite.opts[`${retryKey}Priority`] = RETRY_PRIORITIES.HOOK_CONFIG
58
+ }
59
+ })
60
+
61
+ // Handle feature-level retries
62
+ if (config.Feature) {
63
+ if (isNotSet(suite.retries()) || (suite.opts.retryPriority || 0) <= RETRY_PRIORITIES.FEATURE_CONFIG) {
64
+ suite.retries(config.Feature)
65
+ suite.opts.retryPriority = RETRY_PRIORITIES.FEATURE_CONFIG
66
+ output.log(`[Global Retry] Feature retries: ${config.Feature}`)
67
+ }
68
+ }
69
+ }
70
+ })
71
+
72
+ event.dispatcher.on(event.test.before, test => {
73
+ let retryConfig = Config.get('retry')
74
+ if (!retryConfig) return
75
+
76
+ if (Number.isInteger(+retryConfig)) {
77
+ // Only set if not already set by higher priority mechanism
78
+ if (test.retries() === -1) {
79
+ test.retries(retryConfig)
80
+ test.opts.retryPriority = RETRY_PRIORITIES.SCENARIO_CONFIG
81
+ output.log(`[Global Retry] Scenario retries: ${retryConfig}`)
82
+ }
83
+ return
84
+ }
85
+
86
+ if (!Array.isArray(retryConfig)) {
87
+ retryConfig = [retryConfig]
88
+ }
89
+
90
+ retryConfig = retryConfig.filter(config => !!config.Scenario)
91
+
92
+ for (const config of retryConfig) {
93
+ if (config.grep) {
94
+ if (!test.fullTitle().includes(config.grep)) continue
95
+ }
96
+
97
+ if (config.Scenario) {
98
+ // Respect priority system
99
+ if (test.retries() === -1 || (test.opts.retryPriority || 0) <= RETRY_PRIORITIES.SCENARIO_CONFIG) {
100
+ test.retries(config.Scenario)
101
+ test.opts.retryPriority = RETRY_PRIORITIES.SCENARIO_CONFIG
102
+ output.log(`[Global Retry] Scenario retries: ${config.Scenario}`)
103
+ }
104
+ }
105
+ }
106
+ })
107
+ }
108
+
109
+ // Export priority constants for use by other retry mechanisms
110
+ module.exports.RETRY_PRIORITIES = RETRY_PRIORITIES
package/lib/mocha/test.js CHANGED
@@ -121,7 +121,6 @@ function serializeTest(test, error = null) {
121
121
  }
122
122
 
123
123
  return {
124
- file: test.file ? relativeDir(test.file) : undefined,
125
124
  opts: test.opts || {},
126
125
  tags: test.tags || [],
127
126
  uid: test.uid,
@@ -0,0 +1,99 @@
1
+ const event = require('../event')
2
+ const recorder = require('../recorder')
3
+ const store = require('../store')
4
+ const output = require('../output')
5
+ const { RETRY_PRIORITIES } = require('../retryCoordinator')
6
+
7
+ const defaultConfig = {
8
+ retries: 3,
9
+ defaultIgnoredSteps: ['amOnPage', 'wait*', 'send*', 'execute*', 'run*', 'have*'],
10
+ factor: 1.5,
11
+ ignoredSteps: [],
12
+ }
13
+
14
+ /**
15
+ * Enhanced retryFailedStep plugin that coordinates with other retry mechanisms
16
+ *
17
+ * This plugin provides step-level retries and coordinates with global retry settings
18
+ * to avoid conflicts and provide predictable behavior.
19
+ */
20
+ module.exports = config => {
21
+ config = Object.assign({}, defaultConfig, config)
22
+ config.ignoredSteps = config.ignoredSteps.concat(config.defaultIgnoredSteps)
23
+ const customWhen = config.when
24
+
25
+ let enableRetry = false
26
+
27
+ const when = err => {
28
+ if (!enableRetry) return false
29
+ if (store.debugMode) return false
30
+ if (!store.autoRetries) return false
31
+ if (customWhen) return customWhen(err)
32
+ return true
33
+ }
34
+ config.when = when
35
+
36
+ event.dispatcher.on(event.step.started, step => {
37
+ // if a step is ignored - return
38
+ for (const ignored of config.ignoredSteps) {
39
+ if (step.name === ignored) return
40
+ if (ignored instanceof RegExp) {
41
+ if (step.name.match(ignored)) return
42
+ } else if (ignored.indexOf('*') && step.name.startsWith(ignored.slice(0, -1))) return
43
+ }
44
+ enableRetry = true // enable retry for a step
45
+ })
46
+
47
+ event.dispatcher.on(event.step.finished, () => {
48
+ enableRetry = false
49
+ })
50
+
51
+ event.dispatcher.on(event.test.before, test => {
52
+ // pass disableRetryFailedStep is a preferred way to disable retries
53
+ // test.disableRetryFailedStep is used for backward compatibility
54
+ if (test.opts.disableRetryFailedStep || test.disableRetryFailedStep) {
55
+ store.autoRetries = false
56
+ output.log(`[Step Retry] Disabled for test: ${test.title}`)
57
+ return // disable retry when a test is not active
58
+ }
59
+
60
+ // Check if step retries should be disabled due to higher priority scenario retries
61
+ const scenarioRetries = test.retries()
62
+ const stepRetryPriority = RETRY_PRIORITIES.STEP_PLUGIN
63
+ const scenarioPriority = test.opts.retryPriority || 0
64
+
65
+ if (scenarioRetries > 0 && config.deferToScenarioRetries !== false) {
66
+ // Scenario retries are configured with higher or equal priority
67
+ // Option 1: Disable step retries (conservative approach)
68
+ store.autoRetries = false
69
+ output.log(`[Step Retry] Deferred to scenario retries (${scenarioRetries} retries)`)
70
+ return
71
+
72
+ // Option 2: Reduce step retries to avoid excessive total retries
73
+ // const reducedStepRetries = Math.max(1, Math.floor(config.retries / scenarioRetries))
74
+ // config.retries = reducedStepRetries
75
+ // output.log(`[Step Retry] Reduced to ${reducedStepRetries} retries due to scenario retries (${scenarioRetries})`)
76
+ }
77
+
78
+ // this option is used to set the retries inside _before() block of helpers
79
+ store.autoRetries = true
80
+ test.opts.conditionalRetries = config.retries
81
+ test.opts.stepRetryPriority = stepRetryPriority
82
+
83
+ recorder.retry(config)
84
+
85
+ output.log(`[Step Retry] Enabled with ${config.retries} retries for test: ${test.title}`)
86
+ })
87
+
88
+ // Add coordination info for debugging
89
+ event.dispatcher.on(event.test.finished, test => {
90
+ if (test.state === 'passed' && test.opts.conditionalRetries && store.autoRetries) {
91
+ const stepRetries = test.opts.conditionalRetries || 0
92
+ const scenarioRetries = test.retries() || 0
93
+
94
+ if (stepRetries > 0 && scenarioRetries > 0) {
95
+ output.log(`[Retry Coordination] Test used both step retries (${stepRetries}) and scenario retries (${scenarioRetries})`)
96
+ }
97
+ }
98
+ })
99
+ }
package/lib/recorder.js CHANGED
@@ -11,6 +11,7 @@ let running = false
11
11
  let errFn
12
12
  let queueId = 0
13
13
  let sessionId = null
14
+ let sessionStack = [] // Stack to support nested sessions
14
15
  let asyncErr = null
15
16
  let ignoredErrs = []
16
17
 
@@ -89,6 +90,7 @@ module.exports = {
89
90
  if (promise && running) this.catch()
90
91
  queueId++
91
92
  sessionId = null
93
+ sessionStack = [] // Clear the session stack
92
94
  asyncErr = null
93
95
  log(`${currentQueue()} Starting recording promises`)
94
96
  promise = Promise.resolve()
@@ -123,8 +125,13 @@ module.exports = {
123
125
  */
124
126
  start(name) {
125
127
  if (sessionId) {
126
- debug(`${currentQueue()}Session already started as ${sessionId}`)
127
- this.restore(sessionId)
128
+ debug(`${currentQueue()}Session already started as ${sessionId}, nesting session ${name}`)
129
+ // Push current session to stack instead of restoring it
130
+ sessionStack.push({
131
+ id: sessionId,
132
+ promise: promise,
133
+ running: this.running,
134
+ })
128
135
  }
129
136
  debug(`${currentQueue()}Starting <${name}> session`)
130
137
  tasks.push('--->')
@@ -142,9 +149,18 @@ module.exports = {
142
149
  tasks.push('<---')
143
150
  debug(`${currentQueue()}Finalize <${name}> session`)
144
151
  this.running = false
145
- sessionId = null
146
152
  this.catch(errFn)
147
153
  promise = promise.then(() => oldPromises.pop())
154
+
155
+ // Restore parent session from stack if available
156
+ if (sessionStack.length > 0) {
157
+ const parentSession = sessionStack.pop()
158
+ sessionId = parentSession.id
159
+ this.running = parentSession.running
160
+ debug(`${currentQueue()}Restored parent session <${sessionId}>`)
161
+ } else {
162
+ sessionId = null
163
+ }
148
164
  },
149
165
 
150
166
  /**
@@ -0,0 +1,207 @@
1
+ const output = require('./output')
2
+
3
+ /**
4
+ * Retry Coordinator - Central coordination for all retry mechanisms
5
+ *
6
+ * This module provides:
7
+ * 1. Priority-based retry coordination
8
+ * 2. Unified configuration validation
9
+ * 3. Consolidated retry reporting
10
+ * 4. Conflict detection and resolution
11
+ */
12
+
13
+ /**
14
+ * Priority levels for retry mechanisms (higher number = higher priority)
15
+ */
16
+ const RETRY_PRIORITIES = {
17
+ MANUAL_STEP: 100, // I.retry() or step.retry() - highest priority
18
+ STEP_PLUGIN: 50, // retryFailedStep plugin
19
+ SCENARIO_CONFIG: 30, // Global scenario retry config
20
+ FEATURE_CONFIG: 20, // Global feature retry config
21
+ HOOK_CONFIG: 10, // Hook retry config - lowest priority
22
+ }
23
+
24
+ /**
25
+ * Retry mechanism types
26
+ */
27
+ const RETRY_TYPES = {
28
+ MANUAL_STEP: 'manual-step',
29
+ STEP_PLUGIN: 'step-plugin',
30
+ SCENARIO: 'scenario',
31
+ FEATURE: 'feature',
32
+ HOOK: 'hook',
33
+ }
34
+
35
+ /**
36
+ * Global retry coordination state
37
+ */
38
+ let retryState = {
39
+ activeTest: null,
40
+ activeSuite: null,
41
+ retryHistory: [],
42
+ conflicts: [],
43
+ }
44
+
45
+ /**
46
+ * Registers a retry mechanism for coordination
47
+ * @param {string} type - Type of retry mechanism
48
+ * @param {Object} config - Retry configuration
49
+ * @param {Object} target - Target object (test, suite, etc.)
50
+ * @param {number} priority - Priority level
51
+ */
52
+ function registerRetry(type, config, target, priority = 0) {
53
+ const retryInfo = {
54
+ type,
55
+ config,
56
+ target,
57
+ priority,
58
+ timestamp: Date.now(),
59
+ }
60
+
61
+ // Detect conflicts
62
+ const existingRetries = retryState.retryHistory.filter(r => r.target === target && r.type !== type && r.priority !== priority)
63
+
64
+ if (existingRetries.length > 0) {
65
+ const conflict = {
66
+ newRetry: retryInfo,
67
+ existingRetries: existingRetries,
68
+ resolved: false,
69
+ }
70
+ retryState.conflicts.push(conflict)
71
+ handleRetryConflict(conflict)
72
+ }
73
+
74
+ retryState.retryHistory.push(retryInfo)
75
+
76
+ output.log(`[Retry Coordinator] Registered ${type} retry (priority: ${priority})`)
77
+ }
78
+
79
+ /**
80
+ * Handles conflicts between retry mechanisms
81
+ * @param {Object} conflict - Conflict information
82
+ */
83
+ function handleRetryConflict(conflict) {
84
+ const { newRetry, existingRetries } = conflict
85
+
86
+ // Find highest priority retry
87
+ const allRetries = [newRetry, ...existingRetries]
88
+ const highestPriority = Math.max(...allRetries.map(r => r.priority))
89
+ const winningRetry = allRetries.find(r => r.priority === highestPriority)
90
+
91
+ // Log the conflict resolution
92
+ output.log(`[Retry Coordinator] Conflict detected:`)
93
+ allRetries.forEach(retry => {
94
+ const status = retry === winningRetry ? 'ACTIVE' : 'DEFERRED'
95
+ output.log(` - ${retry.type} (priority: ${retry.priority}) [${status}]`)
96
+ })
97
+
98
+ conflict.resolved = true
99
+ conflict.winner = winningRetry
100
+ }
101
+
102
+ /**
103
+ * Gets the effective retry configuration for a target
104
+ * @param {Object} target - Target object (test, suite, etc.)
105
+ * @returns {Object} Effective retry configuration
106
+ */
107
+ function getEffectiveRetryConfig(target) {
108
+ const targetRetries = retryState.retryHistory.filter(r => r.target === target)
109
+
110
+ if (targetRetries.length === 0) {
111
+ return { type: 'none', retries: 0 }
112
+ }
113
+
114
+ // Find highest priority retry
115
+ const highestPriority = Math.max(...targetRetries.map(r => r.priority))
116
+ const effectiveRetry = targetRetries.find(r => r.priority === highestPriority)
117
+
118
+ return {
119
+ type: effectiveRetry.type,
120
+ retries: effectiveRetry.config.retries || effectiveRetry.config,
121
+ config: effectiveRetry.config,
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Generates a retry summary report
127
+ * @returns {Object} Retry summary
128
+ */
129
+ function generateRetrySummary() {
130
+ const summary = {
131
+ totalRetryMechanisms: retryState.retryHistory.length,
132
+ conflicts: retryState.conflicts.length,
133
+ byType: {},
134
+ recommendations: [],
135
+ }
136
+
137
+ // Count by type
138
+ retryState.retryHistory.forEach(retry => {
139
+ summary.byType[retry.type] = (summary.byType[retry.type] || 0) + 1
140
+ })
141
+
142
+ // Generate recommendations
143
+ if (summary.conflicts > 0) {
144
+ summary.recommendations.push('Consider consolidating retry configurations to avoid conflicts')
145
+ }
146
+
147
+ if (summary.byType[RETRY_TYPES.STEP_PLUGIN] && summary.byType[RETRY_TYPES.SCENARIO]) {
148
+ summary.recommendations.push('Step-level and scenario-level retries are both active - consider using only one approach')
149
+ }
150
+
151
+ return summary
152
+ }
153
+
154
+ /**
155
+ * Resets the retry coordination state (useful for testing)
156
+ */
157
+ function reset() {
158
+ retryState = {
159
+ activeTest: null,
160
+ activeSuite: null,
161
+ retryHistory: [],
162
+ conflicts: [],
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Validates retry configuration for common issues
168
+ * @param {Object} config - Configuration object
169
+ * @returns {Array} Array of validation warnings
170
+ */
171
+ function validateConfig(config) {
172
+ const warnings = []
173
+
174
+ if (!config) return warnings
175
+
176
+ // Check for potential configuration conflicts
177
+ if (config.retry && config.plugins && config.plugins.retryFailedStep) {
178
+ const globalRetries = typeof config.retry === 'number' ? config.retry : config.retry.Scenario || config.retry.Feature
179
+ const stepRetries = config.plugins.retryFailedStep.retries || 3
180
+
181
+ if (globalRetries && stepRetries) {
182
+ warnings.push(`Both global retries (${globalRetries}) and step retries (${stepRetries}) are configured - total executions could be ${globalRetries * (stepRetries + 1)}`)
183
+ }
184
+ }
185
+
186
+ // Check for excessive retry counts
187
+ if (config.retry) {
188
+ const retryValues = typeof config.retry === 'number' ? [config.retry] : Object.values(config.retry)
189
+ const maxRetries = Math.max(...retryValues.filter(v => typeof v === 'number'))
190
+
191
+ if (maxRetries > 5) {
192
+ warnings.push(`High retry count detected (${maxRetries}) - consider investigating test stability instead`)
193
+ }
194
+ }
195
+
196
+ return warnings
197
+ }
198
+
199
+ module.exports = {
200
+ RETRY_PRIORITIES,
201
+ RETRY_TYPES,
202
+ registerRetry,
203
+ getEffectiveRetryConfig,
204
+ generateRetrySummary,
205
+ validateConfig,
206
+ reset,
207
+ }
package/lib/utils.js CHANGED
@@ -203,7 +203,7 @@ module.exports.test = {
203
203
  if (fs.existsSync(dataFile)) {
204
204
  break
205
205
  }
206
-
206
+
207
207
  // Use Node.js child_process.spawnSync with platform-specific sleep commands
208
208
  // This avoids busy waiting and allows other processes to run
209
209
  try {
@@ -221,7 +221,7 @@ module.exports.test = {
221
221
  // No-op loop - much lighter than previous approaches
222
222
  }
223
223
  }
224
-
224
+
225
225
  // Exponential backoff: gradually increase polling interval to reduce resource usage
226
226
  pollInterval = Math.min(pollInterval * 1.2, maxPollInterval)
227
227
  }
@@ -476,8 +476,12 @@ module.exports.isNotSet = function (obj) {
476
476
  return false
477
477
  }
478
478
 
479
- module.exports.emptyFolder = async directoryPath => {
480
- require('child_process').execSync(`rm -rf ${directoryPath}/*`)
479
+ module.exports.emptyFolder = directoryPath => {
480
+ // Do not throw on non-existent directory, since it may be created later
481
+ if (!fs.existsSync(directoryPath)) return
482
+ for (const file of fs.readdirSync(directoryPath)) {
483
+ fs.rmSync(path.join(directoryPath, file), { recursive: true, force: true })
484
+ }
481
485
  }
482
486
 
483
487
  module.exports.printObjectProperties = obj => {
@@ -576,6 +580,52 @@ module.exports.humanizeString = function (string) {
576
580
  return _result.join(' ').trim()
577
581
  }
578
582
 
583
+ /**
584
+ * Creates a circular-safe replacer function for JSON.stringify
585
+ * @param {string[]} keysToSkip - Keys to skip during serialization to break circular references
586
+ * @returns {Function} Replacer function for JSON.stringify
587
+ */
588
+ function createCircularSafeReplacer(keysToSkip = []) {
589
+ const seen = new WeakSet()
590
+ const defaultSkipKeys = ['parent', 'tests', 'suite', 'root', 'runner', 'ctx']
591
+ const skipKeys = new Set([...defaultSkipKeys, ...keysToSkip])
592
+
593
+ return function (key, value) {
594
+ // Skip specific keys that commonly cause circular references
595
+ if (key && skipKeys.has(key)) {
596
+ return undefined
597
+ }
598
+
599
+ if (value === null || typeof value !== 'object') {
600
+ return value
601
+ }
602
+
603
+ // Handle circular references
604
+ if (seen.has(value)) {
605
+ return `[Circular Reference to ${value.constructor?.name || 'Object'}]`
606
+ }
607
+
608
+ seen.add(value)
609
+ return value
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Safely stringify an object, handling circular references
615
+ * @param {any} obj - Object to stringify
616
+ * @param {string[]} keysToSkip - Additional keys to skip during serialization
617
+ * @param {number} space - Number of spaces for indentation (default: 0)
618
+ * @returns {string} JSON string representation
619
+ */
620
+ module.exports.safeStringify = function (obj, keysToSkip = [], space = 0) {
621
+ try {
622
+ return JSON.stringify(obj, createCircularSafeReplacer(keysToSkip), space)
623
+ } catch (error) {
624
+ // Fallback for any remaining edge cases
625
+ return JSON.stringify({ error: `Failed to serialize: ${error.message}` }, null, space)
626
+ }
627
+ }
628
+
579
629
  module.exports.serializeError = function (error) {
580
630
  if (error) {
581
631
  const { stack, uncaught, message, actual, expected } = error