codeceptjs 3.7.5-beta.14 → 3.7.5-beta.16
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 +3 -0
- 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 +48 -2
- package/lib/workers.js +3 -20
- package/package.json +2 -2
- package/lib/command/run-failed-tests.js +0 -268
- package/lib/plugin/failedTestsTracker.js +0 -413
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()
|
|
@@ -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
|
}
|
|
@@ -576,6 +576,52 @@ module.exports.humanizeString = function (string) {
|
|
|
576
576
|
return _result.join(' ').trim()
|
|
577
577
|
}
|
|
578
578
|
|
|
579
|
+
/**
|
|
580
|
+
* Creates a circular-safe replacer function for JSON.stringify
|
|
581
|
+
* @param {string[]} keysToSkip - Keys to skip during serialization to break circular references
|
|
582
|
+
* @returns {Function} Replacer function for JSON.stringify
|
|
583
|
+
*/
|
|
584
|
+
function createCircularSafeReplacer(keysToSkip = []) {
|
|
585
|
+
const seen = new WeakSet()
|
|
586
|
+
const defaultSkipKeys = ['parent', 'tests', 'suite', 'root', 'runner', 'ctx']
|
|
587
|
+
const skipKeys = new Set([...defaultSkipKeys, ...keysToSkip])
|
|
588
|
+
|
|
589
|
+
return function (key, value) {
|
|
590
|
+
// Skip specific keys that commonly cause circular references
|
|
591
|
+
if (key && skipKeys.has(key)) {
|
|
592
|
+
return undefined
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (value === null || typeof value !== 'object') {
|
|
596
|
+
return value
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Handle circular references
|
|
600
|
+
if (seen.has(value)) {
|
|
601
|
+
return `[Circular Reference to ${value.constructor?.name || 'Object'}]`
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
seen.add(value)
|
|
605
|
+
return value
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Safely stringify an object, handling circular references
|
|
611
|
+
* @param {any} obj - Object to stringify
|
|
612
|
+
* @param {string[]} keysToSkip - Additional keys to skip during serialization
|
|
613
|
+
* @param {number} space - Number of spaces for indentation (default: 0)
|
|
614
|
+
* @returns {string} JSON string representation
|
|
615
|
+
*/
|
|
616
|
+
module.exports.safeStringify = function (obj, keysToSkip = [], space = 0) {
|
|
617
|
+
try {
|
|
618
|
+
return JSON.stringify(obj, createCircularSafeReplacer(keysToSkip), space)
|
|
619
|
+
} catch (error) {
|
|
620
|
+
// Fallback for any remaining edge cases
|
|
621
|
+
return JSON.stringify({ error: `Failed to serialize: ${error.message}` }, null, space)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
579
625
|
module.exports.serializeError = function (error) {
|
|
580
626
|
if (error) {
|
|
581
627
|
const { stack, uncaught, message, actual, expected } = error
|
package/lib/workers.js
CHANGED
|
@@ -310,28 +310,11 @@ class Workers extends EventEmitter {
|
|
|
310
310
|
const groups = populateGroups(numberOfWorkers)
|
|
311
311
|
let groupCounter = 0
|
|
312
312
|
|
|
313
|
-
// If specific tests are provided (e.g., from run-failed-tests), only include those
|
|
314
|
-
const targetTests = this.options && this.options.tests
|
|
315
|
-
const exactTestTitles = this.options && this.options.exactTestTitles
|
|
316
|
-
|
|
317
313
|
mocha.suite.eachTest(test => {
|
|
314
|
+
const i = groupCounter % groups.length
|
|
318
315
|
if (test) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
// If we have specific target tests by UID, only include matching UIDs
|
|
322
|
-
if (targetTests && targetTests.length > 0) {
|
|
323
|
-
shouldInclude = targetTests.includes(test.uid)
|
|
324
|
-
}
|
|
325
|
-
// If we have exact test titles, only include tests with exact matching titles
|
|
326
|
-
else if (exactTestTitles && exactTestTitles.length > 0) {
|
|
327
|
-
shouldInclude = exactTestTitles.some(title => title === test.title)
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
if (shouldInclude) {
|
|
331
|
-
const i = groupCounter % groups.length
|
|
332
|
-
groups[i].push(test.uid)
|
|
333
|
-
groupCounter++
|
|
334
|
-
}
|
|
316
|
+
groups[i].push(test.uid)
|
|
317
|
+
groupCounter++
|
|
335
318
|
}
|
|
336
319
|
})
|
|
337
320
|
return groups
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeceptjs",
|
|
3
|
-
"version": "3.7.5-beta.
|
|
3
|
+
"version": "3.7.5-beta.16",
|
|
4
4
|
"description": "Supercharged End 2 End Testing Framework for NodeJS",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"acceptance",
|
|
@@ -157,7 +157,7 @@
|
|
|
157
157
|
"jsdoc-typeof-plugin": "1.0.0",
|
|
158
158
|
"json-server": "0.17.4",
|
|
159
159
|
"mochawesome": "^7.1.3",
|
|
160
|
-
"playwright": "1.
|
|
160
|
+
"playwright": "1.55.0",
|
|
161
161
|
"prettier": "^3.3.2",
|
|
162
162
|
"puppeteer": "24.15.0",
|
|
163
163
|
"qrcode-terminal": "0.12.0",
|
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
const fs = require('fs')
|
|
2
|
-
const path = require('path')
|
|
3
|
-
const { getConfig, printError, getTestRoot, createOutputDir } = require('./utils')
|
|
4
|
-
const Config = require('../config')
|
|
5
|
-
const store = require('../store')
|
|
6
|
-
const Codecept = require('../codecept')
|
|
7
|
-
const output = require('../output')
|
|
8
|
-
const Workers = require('../workers')
|
|
9
|
-
const { tryOrDefault } = require('../utils')
|
|
10
|
-
|
|
11
|
-
module.exports = async function (options) {
|
|
12
|
-
// registering options globally to use in config
|
|
13
|
-
if (options.profile) {
|
|
14
|
-
process.env.profile = options.profile
|
|
15
|
-
}
|
|
16
|
-
if (options.verbose || options.debug) store.debugMode = true
|
|
17
|
-
|
|
18
|
-
const configFile = options.config
|
|
19
|
-
let config = getConfig(configFile)
|
|
20
|
-
|
|
21
|
-
if (options.override) {
|
|
22
|
-
config = Config.append(JSON.parse(options.override))
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const testRoot = getTestRoot(configFile)
|
|
26
|
-
createOutputDir(config, testRoot)
|
|
27
|
-
|
|
28
|
-
// Determine failed tests file path - respect CodeceptJS output directory
|
|
29
|
-
const failedTestsFile = options.file || 'failed-tests.json'
|
|
30
|
-
const failedTestsPath = path.isAbsolute(failedTestsFile)
|
|
31
|
-
? failedTestsFile
|
|
32
|
-
: path.resolve(global.output_dir || './output', failedTestsFile)
|
|
33
|
-
|
|
34
|
-
// Check if failed tests file exists
|
|
35
|
-
if (!fs.existsSync(failedTestsPath)) {
|
|
36
|
-
output.error(`Failed tests file not found: ${failedTestsPath}`)
|
|
37
|
-
output.print('Run tests first to generate a failed tests file, or specify a different file with --file option')
|
|
38
|
-
process.exitCode = 1
|
|
39
|
-
return
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
let failedTestsData
|
|
43
|
-
try {
|
|
44
|
-
const fileContent = fs.readFileSync(failedTestsPath, 'utf8')
|
|
45
|
-
failedTestsData = JSON.parse(fileContent)
|
|
46
|
-
} catch (error) {
|
|
47
|
-
output.error(`Failed to read or parse failed tests file: ${error.message}`)
|
|
48
|
-
process.exitCode = 1
|
|
49
|
-
return
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (!failedTestsData.tests || failedTestsData.tests.length === 0) {
|
|
53
|
-
output.print('No failed tests found in the file')
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
output.print(`Found ${failedTestsData.tests.length} failed tests from ${failedTestsData.timestamp}`)
|
|
58
|
-
|
|
59
|
-
// Debug: Show what's in the failed tests data
|
|
60
|
-
if (options.verbose) {
|
|
61
|
-
output.print('\nFailed tests data structure:')
|
|
62
|
-
failedTestsData.tests.forEach((test, index) => {
|
|
63
|
-
output.print(` ${index + 1}. Title: "${test.title}", UID: "${test.uid}", File: "${test.file}"`)
|
|
64
|
-
})
|
|
65
|
-
output.print('')
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Build test patterns from failed tests
|
|
69
|
-
const testPatterns = []
|
|
70
|
-
const testsByFile = new Map()
|
|
71
|
-
|
|
72
|
-
// Group tests by file for more efficient execution
|
|
73
|
-
failedTestsData.tests.forEach(test => {
|
|
74
|
-
if (test.file) {
|
|
75
|
-
if (!testsByFile.has(test.file)) {
|
|
76
|
-
testsByFile.set(test.file, [])
|
|
77
|
-
}
|
|
78
|
-
testsByFile.get(test.file).push(test)
|
|
79
|
-
}
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
// Build precise test selection from failed tests
|
|
83
|
-
if (testsByFile.size > 0) {
|
|
84
|
-
// Use file paths for loading tests
|
|
85
|
-
for (const [file, tests] of testsByFile) {
|
|
86
|
-
testPatterns.push(file)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Create a map of exact test titles to target for filtering
|
|
90
|
-
const failedTestTitles = new Set(failedTestsData.tests.map(test => test.title).filter(Boolean))
|
|
91
|
-
|
|
92
|
-
if (failedTestTitles.size > 0) {
|
|
93
|
-
output.print(`Targeting ${failedTestTitles.size} specific failed tests by exact title matching`)
|
|
94
|
-
options.exactTestTitles = Array.from(failedTestTitles)
|
|
95
|
-
} else {
|
|
96
|
-
output.print('No specific test titles found, running all tests in files')
|
|
97
|
-
}
|
|
98
|
-
} else {
|
|
99
|
-
// Fallback: use test titles with exact match grep
|
|
100
|
-
const testTitles = failedTestsData.tests.map(test => test.title).filter(Boolean)
|
|
101
|
-
if (testTitles.length > 0) {
|
|
102
|
-
const grepPattern = testTitles.map(title => `^${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`).join('|')
|
|
103
|
-
options.grep = grepPattern
|
|
104
|
-
output.print(`Targeting failed tests by title pattern (no file info available)`)
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Check if user wants to run with workers
|
|
109
|
-
if (options.workers) {
|
|
110
|
-
await runWithWorkers(config, options, testPatterns, failedTestsData)
|
|
111
|
-
} else {
|
|
112
|
-
await runWithoutWorkers(config, options, testPatterns, failedTestsData, testRoot)
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function runWithWorkers(config, options, testPatterns, failedTestsData) {
|
|
117
|
-
const numberOfWorkers = parseInt(options.workers, 10)
|
|
118
|
-
const overrideConfigs = tryOrDefault(() => JSON.parse(options.override || '{}'), {})
|
|
119
|
-
|
|
120
|
-
// Determine test split strategy
|
|
121
|
-
let by = 'test' // default for failed tests
|
|
122
|
-
if (options.by) {
|
|
123
|
-
by = options.by
|
|
124
|
-
} else if (options.suites) {
|
|
125
|
-
by = 'suite'
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Validate the by option
|
|
129
|
-
const validStrategies = ['test', 'suite', 'pool']
|
|
130
|
-
if (!validStrategies.includes(by)) {
|
|
131
|
-
throw new Error(`Invalid --by strategy: ${by}. Valid options are: ${validStrategies.join(', ')}`)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const workerConfig = {
|
|
135
|
-
by,
|
|
136
|
-
testConfig: options.config,
|
|
137
|
-
options,
|
|
138
|
-
selectedRuns: undefined,
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// If we have specific test UIDs, override the worker test selection
|
|
142
|
-
if (options.tests && options.tests.length > 0) {
|
|
143
|
-
workerConfig.by = 'test' // Force test-level distribution for precise targeting
|
|
144
|
-
output.print(`Using precise test UID targeting for ${options.tests.length} failed tests`)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
output.print(`CodeceptJS v${require('../codecept').version()}`)
|
|
148
|
-
output.print(`Re-running ${failedTestsData.tests.length} failed tests in ${output.styles.bold(numberOfWorkers)} workers...`)
|
|
149
|
-
output.print()
|
|
150
|
-
store.hasWorkers = true
|
|
151
|
-
|
|
152
|
-
const workers = new Workers(numberOfWorkers, workerConfig)
|
|
153
|
-
workers.overrideConfig(overrideConfigs)
|
|
154
|
-
|
|
155
|
-
// Set up event listeners for worker output
|
|
156
|
-
workers.on('test.failed', test => {
|
|
157
|
-
output.test.failed(test)
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
workers.on('test.passed', test => {
|
|
161
|
-
output.test.passed(test)
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
workers.on('test.skipped', test => {
|
|
165
|
-
output.test.skipped(test)
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
workers.on('all.result', result => {
|
|
169
|
-
workers.printResults()
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
try {
|
|
173
|
-
if (options.verbose || options.debug) store.debugMode = true
|
|
174
|
-
|
|
175
|
-
if (options.verbose) {
|
|
176
|
-
output.print('\nFailed tests to re-run with workers:')
|
|
177
|
-
failedTestsData.tests.forEach((test, index) => {
|
|
178
|
-
output.print(` ${index + 1}. ${test.fullTitle || test.title} (${test.file || 'unknown file'})`)
|
|
179
|
-
if (test.error && test.error.message) {
|
|
180
|
-
output.print(` Error: ${test.error.message}`)
|
|
181
|
-
}
|
|
182
|
-
})
|
|
183
|
-
output.print('')
|
|
184
|
-
|
|
185
|
-
const { getMachineInfo } = require('./info')
|
|
186
|
-
await getMachineInfo()
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
await workers.bootstrapAll()
|
|
190
|
-
await workers.run()
|
|
191
|
-
} catch (err) {
|
|
192
|
-
printError(err)
|
|
193
|
-
process.exitCode = 1
|
|
194
|
-
} finally {
|
|
195
|
-
await workers.teardownAll()
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async function runWithoutWorkers(config, options, testPatterns, failedTestsData, testRoot) {
|
|
200
|
-
const codecept = new Codecept(config, options)
|
|
201
|
-
|
|
202
|
-
try {
|
|
203
|
-
codecept.init(testRoot)
|
|
204
|
-
await codecept.bootstrap()
|
|
205
|
-
|
|
206
|
-
// Load tests - if we have specific patterns, use them, otherwise load all and filter with grep
|
|
207
|
-
if (testPatterns.length > 0) {
|
|
208
|
-
codecept.loadTests(testPatterns.join(' '))
|
|
209
|
-
} else {
|
|
210
|
-
codecept.loadTests()
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// If we have specific test titles, filter the loaded tests to only include those
|
|
214
|
-
if (options.exactTestTitles && options.exactTestTitles.length > 0) {
|
|
215
|
-
const Container = require('../container')
|
|
216
|
-
const mocha = Container.mocha()
|
|
217
|
-
|
|
218
|
-
// Ensure tests are loaded into mocha suite structure
|
|
219
|
-
mocha.loadFiles()
|
|
220
|
-
|
|
221
|
-
// Filter all tests recursively through the suite hierarchy
|
|
222
|
-
const filterSuite = (suite) => {
|
|
223
|
-
// Filter direct tests in this suite
|
|
224
|
-
suite.tests = suite.tests.filter(test => {
|
|
225
|
-
return options.exactTestTitles.some(title => title === test.title)
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
// Recursively filter child suites
|
|
229
|
-
suite.suites.forEach(childSuite => filterSuite(childSuite))
|
|
230
|
-
|
|
231
|
-
// Remove empty child suites
|
|
232
|
-
suite.suites = suite.suites.filter(childSuite =>
|
|
233
|
-
childSuite.tests.length > 0 || childSuite.suites.length > 0
|
|
234
|
-
)
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Start filtering from the root suite
|
|
238
|
-
filterSuite(mocha.suite)
|
|
239
|
-
|
|
240
|
-
output.print(`Filtered to ${options.exactTestTitles.length} specific failed tests by exact title matching`)
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (options.verbose) {
|
|
244
|
-
global.debugMode = true
|
|
245
|
-
const { getMachineInfo } = require('./info')
|
|
246
|
-
await getMachineInfo()
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Display information about what we're running
|
|
250
|
-
if (options.verbose) {
|
|
251
|
-
output.print('\nFailed tests to re-run:')
|
|
252
|
-
failedTestsData.tests.forEach((test, index) => {
|
|
253
|
-
output.print(` ${index + 1}. ${test.fullTitle || test.title} (${test.file || 'unknown file'})`)
|
|
254
|
-
if (test.error && test.error.message) {
|
|
255
|
-
output.print(` Error: ${test.error.message}`)
|
|
256
|
-
}
|
|
257
|
-
})
|
|
258
|
-
output.print('')
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
await codecept.run()
|
|
262
|
-
} catch (err) {
|
|
263
|
-
printError(err)
|
|
264
|
-
process.exitCode = 1
|
|
265
|
-
} finally {
|
|
266
|
-
await codecept.teardown()
|
|
267
|
-
}
|
|
268
|
-
}
|
|
@@ -1,413 +0,0 @@
|
|
|
1
|
-
const fs = require('fs')
|
|
2
|
-
const path = require('path')
|
|
3
|
-
const event = require('../event')
|
|
4
|
-
const output = require('../output')
|
|
5
|
-
const store = require('../store')
|
|
6
|
-
|
|
7
|
-
const defaultConfig = {
|
|
8
|
-
enabled: true,
|
|
9
|
-
outputFile: 'failed-tests.json',
|
|
10
|
-
clearOnSuccess: true,
|
|
11
|
-
includeStackTrace: true,
|
|
12
|
-
includeMetadata: true,
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Failed Tests Tracker Plugin for CodeceptJS
|
|
17
|
-
*
|
|
18
|
-
* Tracks failed tests and saves them to a file for later re-execution.
|
|
19
|
-
*
|
|
20
|
-
* ## Configuration
|
|
21
|
-
*
|
|
22
|
-
* ```js
|
|
23
|
-
* "plugins": {
|
|
24
|
-
* "failedTestsTracker": {
|
|
25
|
-
* "enabled": true,
|
|
26
|
-
* "outputFile": "failed-tests.json",
|
|
27
|
-
* "clearOnSuccess": true,
|
|
28
|
-
* "includeStackTrace": true,
|
|
29
|
-
* "includeMetadata": true
|
|
30
|
-
* }
|
|
31
|
-
* }
|
|
32
|
-
* ```
|
|
33
|
-
*
|
|
34
|
-
* @param {object} config plugin configuration
|
|
35
|
-
*/
|
|
36
|
-
module.exports = function (config) {
|
|
37
|
-
const options = { ...defaultConfig, ...config }
|
|
38
|
-
let failedTests = []
|
|
39
|
-
let allTestsPassed = true
|
|
40
|
-
let workerFailedTests = new Map() // Track failed tests from workers
|
|
41
|
-
|
|
42
|
-
// Track test failures - only when not using workers
|
|
43
|
-
event.dispatcher.on(event.test.failed, test => {
|
|
44
|
-
// Skip collection in worker threads to avoid duplicates
|
|
45
|
-
try {
|
|
46
|
-
const { isMainThread } = require('worker_threads')
|
|
47
|
-
if (!isMainThread) return
|
|
48
|
-
} catch (e) {
|
|
49
|
-
// worker_threads not available, continue
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (store.hasWorkers) return // Skip if running with workers
|
|
53
|
-
|
|
54
|
-
// Only collect on final failure (when retries are exhausted or no retries configured)
|
|
55
|
-
const currentRetry = test._currentRetry || 0
|
|
56
|
-
const maxRetries = typeof test.retries === 'function' ? test.retries() : (test.retries || 0)
|
|
57
|
-
|
|
58
|
-
// Only add to failed tests if this is the final attempt
|
|
59
|
-
if (currentRetry >= maxRetries) {
|
|
60
|
-
allTestsPassed = false
|
|
61
|
-
|
|
62
|
-
const failedTest = {
|
|
63
|
-
title: test.title,
|
|
64
|
-
fullTitle: test.fullTitle(),
|
|
65
|
-
file: test.file || (test.parent && test.parent.file),
|
|
66
|
-
uid: test.uid,
|
|
67
|
-
timestamp: new Date().toISOString(),
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Add parent suite information
|
|
71
|
-
if (test.parent) {
|
|
72
|
-
failedTest.suite = test.parent.title
|
|
73
|
-
failedTest.suiteFile = test.parent.file
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Add error information if available
|
|
77
|
-
if (test.err && options.includeStackTrace) {
|
|
78
|
-
failedTest.error = {
|
|
79
|
-
message: test.err.message || 'Test failed',
|
|
80
|
-
stack: test.err.stack || '',
|
|
81
|
-
name: test.err.name || 'Error',
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Add metadata if available
|
|
86
|
-
if (options.includeMetadata) {
|
|
87
|
-
failedTest.metadata = {
|
|
88
|
-
tags: test.tags || [],
|
|
89
|
-
meta: test.meta || {},
|
|
90
|
-
opts: test.opts || {},
|
|
91
|
-
duration: test.duration || 0,
|
|
92
|
-
// Only include retries if it represents actual retry attempts, not the config value
|
|
93
|
-
...(test._currentRetry > 0 && { actualRetries: test._currentRetry }),
|
|
94
|
-
...(maxRetries > 0 && maxRetries !== -1 && { maxRetries: maxRetries }),
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Add BDD/Gherkin information if available
|
|
99
|
-
if (test.parent && test.parent.feature) {
|
|
100
|
-
failedTest.bdd = {
|
|
101
|
-
feature: test.parent.feature.name || test.parent.title,
|
|
102
|
-
scenario: test.title,
|
|
103
|
-
featureFile: test.parent.file,
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
failedTests.push(failedTest)
|
|
108
|
-
output.print(`Failed Tests Tracker: Recorded failed test - ${test.title}`)
|
|
109
|
-
}
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
// Handle test completion and save failed tests
|
|
113
|
-
event.dispatcher.on(event.all.result, (result) => {
|
|
114
|
-
|
|
115
|
-
// Respect CodeceptJS output directory like other plugins
|
|
116
|
-
const outputDir = global.output_dir || './output'
|
|
117
|
-
const outputPath = path.isAbsolute(options.outputFile)
|
|
118
|
-
? options.outputFile
|
|
119
|
-
: path.resolve(outputDir, options.outputFile)
|
|
120
|
-
let allFailedTests = [...failedTests]
|
|
121
|
-
|
|
122
|
-
// Collect failed tests from result (both worker and single-process modes)
|
|
123
|
-
if (result) {
|
|
124
|
-
let resultFailedTests = []
|
|
125
|
-
|
|
126
|
-
// Worker mode: result.tests
|
|
127
|
-
if (store.hasWorkers && result.tests) {
|
|
128
|
-
resultFailedTests = result.tests.filter(test => test.state === 'failed' || test.err)
|
|
129
|
-
}
|
|
130
|
-
// Single-process mode: result._tests (result._failures contains console log arrays, not test objects)
|
|
131
|
-
else if (!store.hasWorkers && result._tests) {
|
|
132
|
-
resultFailedTests = result._tests.filter(test => test.state === 'failed' || test.err)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Use a Set to track unique test identifiers to prevent duplicates
|
|
136
|
-
const existingTestIds = new Set(allFailedTests.map(test => test.uid || `${test.file}:${test.title}`))
|
|
137
|
-
|
|
138
|
-
resultFailedTests.forEach(test => {
|
|
139
|
-
|
|
140
|
-
// Extract file path from test title or error stack trace as fallback
|
|
141
|
-
let filePath = test.file || test.parent?.file || 'unknown'
|
|
142
|
-
|
|
143
|
-
// If still unknown, try to extract from error stack trace
|
|
144
|
-
if (filePath === 'unknown' && test.err && test.err.stack) {
|
|
145
|
-
// Try multiple regex patterns for different stack trace formats
|
|
146
|
-
const patterns = [
|
|
147
|
-
/at.*\(([^)]+\.js):\d+:\d+\)/, // Standard format
|
|
148
|
-
/at.*\(.*[\/\\]([^\/\\]+\.js):\d+:\d+\)/, // With path separators
|
|
149
|
-
/\(([^)]*\.js):\d+:\d+\)/, // Simpler format
|
|
150
|
-
/([^\/\\]+\.js):\d+:\d+/, // Just filename with line numbers
|
|
151
|
-
]
|
|
152
|
-
|
|
153
|
-
for (const pattern of patterns) {
|
|
154
|
-
const stackMatch = test.err.stack.match(pattern)
|
|
155
|
-
if (stackMatch && stackMatch[1]) {
|
|
156
|
-
const absolutePath = stackMatch[1]
|
|
157
|
-
const relativePath = absolutePath.replace(process.cwd() + '/', '').replace(/^.*[\/\\]/, '')
|
|
158
|
-
filePath = relativePath
|
|
159
|
-
break
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// If still unknown, try to extract from test context or use test file pattern
|
|
165
|
-
if (filePath === 'unknown') {
|
|
166
|
-
// Look for common test file patterns in the test title or fullTitle
|
|
167
|
-
const fullTitle = test.fullTitle || test.title
|
|
168
|
-
if (fullTitle && fullTitle.includes('checkout')) {
|
|
169
|
-
filePath = 'checkout_test.js'
|
|
170
|
-
} else if (fullTitle && fullTitle.includes('github')) {
|
|
171
|
-
filePath = 'github_test.js'
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Create unique identifier for deduplication
|
|
176
|
-
const testId = test.uid || `${filePath}:${test.title}`
|
|
177
|
-
|
|
178
|
-
// Skip if we already have this test
|
|
179
|
-
if (existingTestIds.has(testId)) {
|
|
180
|
-
return
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Extract proper test properties from different test object structures
|
|
184
|
-
const testTitle = test.title || test.test?.title || (test.fullTitle && test.fullTitle()) || 'Unknown Test'
|
|
185
|
-
const testFullTitle = test.fullTitle ? (typeof test.fullTitle === 'function' ? test.fullTitle() : test.fullTitle) : testTitle
|
|
186
|
-
const testUid = test.uid || test.test?.uid || `${filePath}:${testTitle}`
|
|
187
|
-
|
|
188
|
-
const failedTest = {
|
|
189
|
-
title: testTitle,
|
|
190
|
-
fullTitle: testFullTitle,
|
|
191
|
-
file: filePath,
|
|
192
|
-
uid: testUid,
|
|
193
|
-
// Add stable identifier for targeting tests across runs
|
|
194
|
-
stableId: `${filePath}:${testTitle}`,
|
|
195
|
-
timestamp: new Date().toISOString(),
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Add parent suite information
|
|
199
|
-
if (test.parent) {
|
|
200
|
-
failedTest.suite = test.parent.title
|
|
201
|
-
failedTest.suiteFile = test.parent.file
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Add error information if available
|
|
205
|
-
if (test.err && options.includeStackTrace) {
|
|
206
|
-
failedTest.error = {
|
|
207
|
-
message: test.err.message || 'Test failed',
|
|
208
|
-
stack: test.err.stack || '',
|
|
209
|
-
name: test.err.name || 'Error',
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Add metadata if available
|
|
214
|
-
if (options.includeMetadata) {
|
|
215
|
-
failedTest.metadata = {
|
|
216
|
-
tags: test.tags || [],
|
|
217
|
-
meta: test.meta || {},
|
|
218
|
-
opts: test.opts || {},
|
|
219
|
-
duration: test.duration || 0,
|
|
220
|
-
retries: test.retries || 0,
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Add BDD/Gherkin information if available
|
|
225
|
-
if (test.parent && test.parent.feature) {
|
|
226
|
-
failedTest.bdd = {
|
|
227
|
-
feature: test.parent.feature.name || test.parent.title,
|
|
228
|
-
scenario: test.title,
|
|
229
|
-
featureFile: test.parent.file,
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
allFailedTests.push(failedTest)
|
|
234
|
-
existingTestIds.add(testId)
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
output.print(`Failed Tests Tracker: Collected ${resultFailedTests.length} failed tests from result`)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (allFailedTests.length === 0) {
|
|
241
|
-
if (options.clearOnSuccess && fs.existsSync(outputPath)) {
|
|
242
|
-
try {
|
|
243
|
-
fs.unlinkSync(outputPath)
|
|
244
|
-
output.print(`Failed Tests Tracker: Cleared previous failed tests file (all tests passed)`)
|
|
245
|
-
} catch (error) {
|
|
246
|
-
output.print(`Failed Tests Tracker: Could not clear failed tests file: ${error.message}`)
|
|
247
|
-
}
|
|
248
|
-
} else {
|
|
249
|
-
output.print(`Failed Tests Tracker: No failed tests to save`)
|
|
250
|
-
}
|
|
251
|
-
return
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const failedTestsData = {
|
|
255
|
-
timestamp: new Date().toISOString(),
|
|
256
|
-
totalFailedTests: allFailedTests.length,
|
|
257
|
-
codeceptVersion: require('../codecept').version(),
|
|
258
|
-
tests: allFailedTests,
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
try {
|
|
262
|
-
// Ensure directory exists
|
|
263
|
-
const dir = path.dirname(outputPath)
|
|
264
|
-
if (!fs.existsSync(dir)) {
|
|
265
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
fs.writeFileSync(outputPath, JSON.stringify(failedTestsData, null, 2))
|
|
269
|
-
output.print(`Failed Tests Tracker: Saved ${allFailedTests.length} failed tests to ${outputPath}`)
|
|
270
|
-
} catch (error) {
|
|
271
|
-
output.print(`Failed Tests Tracker: Failed to save failed tests: ${error.message}`)
|
|
272
|
-
}
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
// Reset state for new test runs
|
|
276
|
-
event.dispatcher.on(event.all.before, () => {
|
|
277
|
-
failedTests = []
|
|
278
|
-
allTestsPassed = true
|
|
279
|
-
workerFailedTests.clear()
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
// Handle worker mode - listen to workers.result event for consolidated results
|
|
283
|
-
event.dispatcher.on(event.workers.result, (result) => {
|
|
284
|
-
// Respect CodeceptJS output directory like other plugins
|
|
285
|
-
const outputDir = global.output_dir || './output'
|
|
286
|
-
const outputPath = path.isAbsolute(options.outputFile)
|
|
287
|
-
? options.outputFile
|
|
288
|
-
: path.resolve(outputDir, options.outputFile)
|
|
289
|
-
|
|
290
|
-
let allFailedTests = []
|
|
291
|
-
|
|
292
|
-
// In worker mode, collect failed tests from consolidated result
|
|
293
|
-
if (result && result.tests) {
|
|
294
|
-
const workerFailedTests = result.tests.filter(test => test.state === 'failed' || test.err)
|
|
295
|
-
|
|
296
|
-
workerFailedTests.forEach(test => {
|
|
297
|
-
// Extract file path from test title or error stack trace as fallback
|
|
298
|
-
let filePath = test.file || test.parent?.file || 'unknown'
|
|
299
|
-
|
|
300
|
-
// If still unknown, try to extract from error stack trace
|
|
301
|
-
if (filePath === 'unknown' && test.err && test.err.stack) {
|
|
302
|
-
// Try multiple regex patterns for different stack trace formats
|
|
303
|
-
const patterns = [
|
|
304
|
-
/at.*\(([^)]+\.js):\d+:\d+\)/, // Standard format
|
|
305
|
-
/at.*\(.*[\/\\]([^\/\\]+\.js):\d+:\d+\)/, // With path separators
|
|
306
|
-
/\(([^)]*\.js):\d+:\d+\)/, // Simpler format
|
|
307
|
-
/([^\/\\]+\.js):\d+:\d+/, // Just filename with line numbers
|
|
308
|
-
]
|
|
309
|
-
|
|
310
|
-
for (const pattern of patterns) {
|
|
311
|
-
const stackMatch = test.err.stack.match(pattern)
|
|
312
|
-
if (stackMatch && stackMatch[1]) {
|
|
313
|
-
const absolutePath = stackMatch[1]
|
|
314
|
-
const relativePath = absolutePath.replace(process.cwd() + '/', '').replace(/^.*[\/\\]/, '')
|
|
315
|
-
filePath = relativePath
|
|
316
|
-
break
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// If still unknown, try to extract from test context or use test file pattern
|
|
322
|
-
if (filePath === 'unknown') {
|
|
323
|
-
// Look for common test file patterns in the test title or fullTitle
|
|
324
|
-
const fullTitle = test.fullTitle || test.title
|
|
325
|
-
if (fullTitle && fullTitle.includes('checkout')) {
|
|
326
|
-
filePath = 'checkout_test.js'
|
|
327
|
-
} else if (fullTitle && fullTitle.includes('github')) {
|
|
328
|
-
filePath = 'github_test.js'
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const failedTest = {
|
|
333
|
-
title: test.title,
|
|
334
|
-
fullTitle: test.fullTitle || test.title,
|
|
335
|
-
file: filePath,
|
|
336
|
-
uid: test.uid,
|
|
337
|
-
timestamp: new Date().toISOString(),
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Add parent suite information
|
|
341
|
-
if (test.parent) {
|
|
342
|
-
failedTest.suite = test.parent.title
|
|
343
|
-
failedTest.suiteFile = test.parent.file
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Add error information if available
|
|
347
|
-
if (test.err && options.includeStackTrace) {
|
|
348
|
-
failedTest.error = {
|
|
349
|
-
message: test.err.message || 'Test failed',
|
|
350
|
-
stack: test.err.stack || '',
|
|
351
|
-
name: test.err.name || 'Error',
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Add metadata if available
|
|
356
|
-
if (options.includeMetadata) {
|
|
357
|
-
failedTest.metadata = {
|
|
358
|
-
tags: test.tags || [],
|
|
359
|
-
meta: test.meta || {},
|
|
360
|
-
opts: test.opts || {},
|
|
361
|
-
duration: test.duration || 0,
|
|
362
|
-
retries: test.retries || 0,
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Add BDD/Gherkin information if available
|
|
367
|
-
if (test.parent && test.parent.feature) {
|
|
368
|
-
failedTest.bdd = {
|
|
369
|
-
feature: test.parent.feature.name || test.parent.title,
|
|
370
|
-
scenario: test.title,
|
|
371
|
-
featureFile: test.parent.file,
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
allFailedTests.push(failedTest)
|
|
376
|
-
})
|
|
377
|
-
|
|
378
|
-
output.print(`Failed Tests Tracker: Collected ${allFailedTests.length - failedTests.length} failed tests from workers`)
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
if (allFailedTests.length === 0) {
|
|
382
|
-
if (options.clearOnSuccess && fs.existsSync(outputPath)) {
|
|
383
|
-
try {
|
|
384
|
-
fs.unlinkSync(outputPath)
|
|
385
|
-
output.print(`Failed Tests Tracker: Cleared previous failed tests file (all tests passed)`)
|
|
386
|
-
} catch (error) {
|
|
387
|
-
output.print(`Failed Tests Tracker: Could not clear failed tests file: ${error.message}`)
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
return
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Save failed tests to file
|
|
394
|
-
try {
|
|
395
|
-
const failedTestsData = {
|
|
396
|
-
timestamp: new Date().toISOString(),
|
|
397
|
-
totalFailed: allFailedTests.length,
|
|
398
|
-
tests: allFailedTests,
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Ensure output directory exists
|
|
402
|
-
const dir = path.dirname(outputPath)
|
|
403
|
-
if (!fs.existsSync(dir)) {
|
|
404
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
fs.writeFileSync(outputPath, JSON.stringify(failedTestsData, null, 2))
|
|
408
|
-
output.print(`Failed Tests Tracker: Saved ${allFailedTests.length} failed tests to ${outputPath}`)
|
|
409
|
-
} catch (error) {
|
|
410
|
-
output.print(`Failed Tests Tracker: Failed to save failed tests: ${error.message}`)
|
|
411
|
-
}
|
|
412
|
-
})
|
|
413
|
-
}
|