codeceptjs 3.7.5-beta.9 → 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 +0 -23
- package/lib/helper/Playwright.js +3 -0
- package/lib/helper/Puppeteer.js +5 -1
- package/lib/helper/WebDriver.js +6 -3
- package/lib/helper/network/actions.js +4 -2
- package/lib/listener/enhancedGlobalRetry.js +110 -0
- package/lib/mocha/test.js +0 -1
- package/lib/plugin/enhancedRetryFailedStep.js +99 -0
- package/lib/recorder.js +19 -3
- package/lib/retryCoordinator.js +207 -0
- package/lib/utils.js +54 -4
- package/package.json +24 -21
- package/typings/promiseBasedTypes.d.ts +49 -12
- package/typings/types.d.ts +60 -12
- package/lib/command/run-failed-tests.js +0 -218
- package/lib/plugin/failedTestsTracker.js +0 -374
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()
|
package/lib/helper/Playwright.js
CHANGED
|
@@ -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())
|
package/lib/helper/Puppeteer.js
CHANGED
|
@@ -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
|
|
package/lib/helper/WebDriver.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
633
|
-
this.browser.
|
|
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.
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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 =
|
|
480
|
-
|
|
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
|