codeceptjs 4.0.0-rc.20 → 4.0.0-rc.22
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/mcp-server.js +7 -5
- package/docs/advanced.md +1 -1
- package/docs/agents.md +32 -10
- package/docs/architecture.md +219 -0
- package/docs/configuration.md +82 -127
- package/docs/continuous-integration.md +113 -151
- package/docs/custom-helpers.md +1 -1
- package/docs/environment-variables.md +131 -0
- package/docs/hooks.md +76 -277
- package/docs/installation.md +95 -40
- package/docs/parallel.md +98 -496
- package/docs/plugins.md +43 -0
- package/docs/reports.md +102 -401
- package/docs/retry.md +44 -37
- package/docs/typescript.md +54 -269
- package/lib/codecept.js +1 -1
- package/lib/command/run-workers.js +0 -14
- package/lib/command/run.js +2 -16
- package/lib/command/utils.js +14 -0
- package/lib/command/workers/runTests.js +1 -5
- package/lib/heal.js +2 -0
- package/lib/helper/Playwright.js +15 -4
- package/lib/plugin/aiTrace.js +16 -13
- package/lib/plugin/analyze.js +5 -4
- package/lib/plugin/heal.js +3 -2
- package/lib/plugin/junitReporter.js +303 -0
- package/lib/plugin/pageInfo.js +5 -7
- package/lib/plugin/retryFailedStep.js +4 -3
- package/lib/plugin/screencast.js +6 -4
- package/lib/workers.js +11 -3
- package/package.json +1 -1
- package/docs/internal-api.md +0 -265
package/lib/helper/Playwright.js
CHANGED
|
@@ -755,6 +755,11 @@ class Playwright extends Helper {
|
|
|
755
755
|
}
|
|
756
756
|
|
|
757
757
|
async _afterSuite() {
|
|
758
|
+
// Reset leftover test-level cleanup state (e.g. screenshot failures)
|
|
759
|
+
// so only errors from this suite teardown are evaluated below.
|
|
760
|
+
this.hasCleanupError = false
|
|
761
|
+
this.testFailures = []
|
|
762
|
+
|
|
758
763
|
// Stop browser after suite completes
|
|
759
764
|
// For restart strategies: stop after each suite
|
|
760
765
|
// For session mode (restart:false): stop after the last suite
|
|
@@ -2678,7 +2683,7 @@ class Playwright extends Helper {
|
|
|
2678
2683
|
if (arg && typeof arg.evaluate === 'function' && typeof arg.locator === 'function') {
|
|
2679
2684
|
return arg.evaluate(fn)
|
|
2680
2685
|
}
|
|
2681
|
-
if (this.context && this.context.
|
|
2686
|
+
if (this.context && typeof this.context.url !== 'function' && typeof this.context.innerText !== 'function') {
|
|
2682
2687
|
return this.context.locator(':root').evaluate(fn, arg)
|
|
2683
2688
|
}
|
|
2684
2689
|
return this.page.evaluate.apply(this.page, [fn, arg])
|
|
@@ -3415,7 +3420,7 @@ class Playwright extends Helper {
|
|
|
3415
3420
|
}
|
|
3416
3421
|
|
|
3417
3422
|
async _getContext() {
|
|
3418
|
-
if (
|
|
3423
|
+
if (this.context) {
|
|
3419
3424
|
return this.context
|
|
3420
3425
|
}
|
|
3421
3426
|
if (this.frame) {
|
|
@@ -4354,7 +4359,9 @@ async function proceedSee(assertType, text, context, strict = false) {
|
|
|
4354
4359
|
if (!context) {
|
|
4355
4360
|
const el = await this.context
|
|
4356
4361
|
|
|
4357
|
-
allText = el.
|
|
4362
|
+
allText = typeof el.url !== 'function' && typeof el.innerText === 'function'
|
|
4363
|
+
? [await el.innerText()]
|
|
4364
|
+
: [await el.locator('body').innerText()]
|
|
4358
4365
|
|
|
4359
4366
|
description = 'web application'
|
|
4360
4367
|
} else {
|
|
@@ -4637,12 +4644,16 @@ async function targetCreatedHandler(page) {
|
|
|
4637
4644
|
.catch(() => null)
|
|
4638
4645
|
.then(async () => {
|
|
4639
4646
|
if (this.context && this.context._type === 'Frame') {
|
|
4640
|
-
// we are inside iframe
|
|
4647
|
+
// we are inside iframe via Frame object — refresh handle
|
|
4641
4648
|
const frameEl = await this.context.frameElement()
|
|
4642
4649
|
this.context = await frameEl.contentFrame()
|
|
4643
4650
|
this.contextLocator = null
|
|
4644
4651
|
return
|
|
4645
4652
|
}
|
|
4653
|
+
if (this.context && this.context.constructor && this.context.constructor.name === 'FrameLocator') {
|
|
4654
|
+
// we are inside iframe via FrameLocator — keep it across load events
|
|
4655
|
+
return
|
|
4656
|
+
}
|
|
4646
4657
|
// if context element was in iframe - keep it
|
|
4647
4658
|
// if (await this.context.ownerFrame()) return;
|
|
4648
4659
|
this.context = page
|
package/lib/plugin/aiTrace.js
CHANGED
|
@@ -92,6 +92,7 @@ export default function (config = {}) {
|
|
|
92
92
|
let testStartTime
|
|
93
93
|
let currentUrl = null
|
|
94
94
|
let testFailed = false
|
|
95
|
+
let pendingArtifactCapture = null
|
|
95
96
|
let firstFailedStepSaved = false
|
|
96
97
|
|
|
97
98
|
const reportDir = config.output ? path.resolve(store.codeceptDir, config.output) : defaultConfig.output
|
|
@@ -129,6 +130,7 @@ export default function (config = {}) {
|
|
|
129
130
|
currentUrl = null
|
|
130
131
|
testFailed = false
|
|
131
132
|
firstFailedStepSaved = false
|
|
133
|
+
pendingArtifactCapture = null
|
|
132
134
|
})
|
|
133
135
|
|
|
134
136
|
event.dispatcher.on(event.step.after, step => {
|
|
@@ -162,13 +164,12 @@ export default function (config = {}) {
|
|
|
162
164
|
return
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
|
|
167
|
+
recorder.add(`aiTrace step persistence: ${step.toString()}`, () => persistStep(step).catch(err => {
|
|
166
168
|
output.debug(`aiTrace: Error saving step: ${err.message}`)
|
|
167
|
-
})
|
|
168
|
-
recorder.add(`wait aiTrace step persistence: ${step.toString()}`, () => stepPersistPromise, true)
|
|
169
|
+
}), true)
|
|
169
170
|
})
|
|
170
171
|
|
|
171
|
-
event.dispatcher.on(event.step.failed,
|
|
172
|
+
event.dispatcher.on(event.step.failed, step => {
|
|
172
173
|
if (!currentTest) return
|
|
173
174
|
if (step.status === 'queued' && testFailed) {
|
|
174
175
|
output.debug(`aiTrace: Skipping queued failed step "${step.toString()}" - testFailed: ${testFailed}`)
|
|
@@ -188,11 +189,9 @@ export default function (config = {}) {
|
|
|
188
189
|
}
|
|
189
190
|
existingStep.status = 'failed'
|
|
190
191
|
|
|
191
|
-
|
|
192
|
-
await captureArtifactsForStep(step, existingStep, existingStep.prefix)
|
|
193
|
-
} catch (err) {
|
|
192
|
+
pendingArtifactCapture = captureArtifactsForStep(step, existingStep, existingStep.prefix).catch(err => {
|
|
194
193
|
output.debug(`aiTrace: Error updating failed step: ${err.message}`)
|
|
195
|
-
}
|
|
194
|
+
})
|
|
196
195
|
} else {
|
|
197
196
|
if (stepNum === -1) return
|
|
198
197
|
if (isStepIgnored(step)) return
|
|
@@ -218,11 +217,9 @@ export default function (config = {}) {
|
|
|
218
217
|
steps.push(stepData)
|
|
219
218
|
firstFailedStepSaved = true
|
|
220
219
|
|
|
221
|
-
|
|
222
|
-
await captureArtifactsForStep(step, stepData, stepPrefix)
|
|
223
|
-
} catch (err) {
|
|
220
|
+
pendingArtifactCapture = captureArtifactsForStep(step, stepData, stepPrefix).catch(err => {
|
|
224
221
|
output.debug(`aiTrace: Error capturing failed step artifacts: ${err.message}`)
|
|
225
|
-
}
|
|
222
|
+
})
|
|
226
223
|
}
|
|
227
224
|
})
|
|
228
225
|
|
|
@@ -238,7 +235,13 @@ export default function (config = {}) {
|
|
|
238
235
|
if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') {
|
|
239
236
|
return
|
|
240
237
|
}
|
|
241
|
-
persist
|
|
238
|
+
recorder.add('aiTrace:persist failed', async () => {
|
|
239
|
+
if (pendingArtifactCapture) {
|
|
240
|
+
await pendingArtifactCapture
|
|
241
|
+
pendingArtifactCapture = null
|
|
242
|
+
}
|
|
243
|
+
persist(test, 'failed')
|
|
244
|
+
}, true)
|
|
242
245
|
})
|
|
243
246
|
|
|
244
247
|
async function persistStep(step) {
|
package/lib/plugin/analyze.js
CHANGED
|
@@ -12,6 +12,7 @@ const ai = aiModule.default || aiModule
|
|
|
12
12
|
import colors from 'chalk'
|
|
13
13
|
import ora from 'ora'
|
|
14
14
|
import event from '../event.js'
|
|
15
|
+
import recorder from '../recorder.js'
|
|
15
16
|
|
|
16
17
|
import output from '../output.js'
|
|
17
18
|
|
|
@@ -227,14 +228,14 @@ export default function (config = {}) {
|
|
|
227
228
|
console.log('Enabled AI analysis')
|
|
228
229
|
})
|
|
229
230
|
|
|
230
|
-
event.dispatcher.on(event.all.result,
|
|
231
|
+
event.dispatcher.on(event.all.result, result => {
|
|
231
232
|
if (!isMainThread) return // run only on main thread
|
|
232
233
|
if (!ai.isEnabled) {
|
|
233
234
|
console.log('AI is disabled, no analysis will be performed. Run tests with --ai flag to enable it.')
|
|
234
235
|
return
|
|
235
236
|
}
|
|
236
237
|
|
|
237
|
-
printReport(result)
|
|
238
|
+
recorder.add('analyze:print-ai-report', () => printReport(result), true)
|
|
238
239
|
})
|
|
239
240
|
|
|
240
241
|
event.dispatcher.on(event.workers.result, async result => {
|
|
@@ -248,7 +249,7 @@ export default function (config = {}) {
|
|
|
248
249
|
return
|
|
249
250
|
}
|
|
250
251
|
|
|
251
|
-
printReport(result)
|
|
252
|
+
await printReport(result)
|
|
252
253
|
})
|
|
253
254
|
|
|
254
255
|
async function printReport(result) {
|
|
@@ -294,7 +295,7 @@ export default function (config = {}) {
|
|
|
294
295
|
console.error('Error analyzing failed tests', err)
|
|
295
296
|
}
|
|
296
297
|
|
|
297
|
-
if (!Object.keys(
|
|
298
|
+
if (!Object.keys(Container.plugins()).includes('pageInfo')) {
|
|
298
299
|
console.log('To improve analysis, enable pageInfo plugin to get more context for failed tests.')
|
|
299
300
|
}
|
|
300
301
|
}
|
package/lib/plugin/heal.js
CHANGED
|
@@ -80,6 +80,7 @@ export default function (config = {}) {
|
|
|
80
80
|
event.dispatcher.on(event.test.before, test => {
|
|
81
81
|
currentTest = test
|
|
82
82
|
healedSteps = 0
|
|
83
|
+
healTries = 0
|
|
83
84
|
caughtError = null
|
|
84
85
|
})
|
|
85
86
|
|
|
@@ -94,7 +95,9 @@ export default function (config = {}) {
|
|
|
94
95
|
if (trigger.on === 'file' && !matchStepFile(step, trigger.path, trigger.line)) return
|
|
95
96
|
|
|
96
97
|
recorder.catchWithoutStop(async err => {
|
|
98
|
+
if (healTries >= config.healLimit) throw err
|
|
97
99
|
isHealing = true
|
|
100
|
+
healTries++
|
|
98
101
|
if (caughtError === err) throw err // avoid double handling
|
|
99
102
|
caughtError = err
|
|
100
103
|
|
|
@@ -121,8 +124,6 @@ export default function (config = {}) {
|
|
|
121
124
|
|
|
122
125
|
await heal.healStep(step, err, { test })
|
|
123
126
|
|
|
124
|
-
healTries++
|
|
125
|
-
|
|
126
127
|
recorder.add('close healing session', () => {
|
|
127
128
|
recorder.reset()
|
|
128
129
|
recorder.session.restore('heal')
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import { mkdirp } from 'mkdirp'
|
|
5
|
+
import { DOMImplementation, XMLSerializer } from '@xmldom/xmldom'
|
|
6
|
+
|
|
7
|
+
import event from '../event.js'
|
|
8
|
+
import store from '../store.js'
|
|
9
|
+
import output from '../output.js'
|
|
10
|
+
|
|
11
|
+
const defaultConfig = {
|
|
12
|
+
outputName: 'report.xml',
|
|
13
|
+
output: null,
|
|
14
|
+
testGroupName: 'CodeceptJS',
|
|
15
|
+
attachSteps: true,
|
|
16
|
+
attachMeta: true,
|
|
17
|
+
stepsInFailure: true,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const INVALID_XML_CHARS = new RegExp('[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\uFFFE\\uFFFF]', 'g')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
*
|
|
24
|
+
* Generates a JUnit-compatible XML report after a test run.
|
|
25
|
+
*
|
|
26
|
+
* Unlike Mocha's `mocha-junit-reporter`, this plugin understands CodeceptJS steps and substeps.
|
|
27
|
+
* For every `<testcase>` it includes:
|
|
28
|
+
*
|
|
29
|
+
* * `<properties>` — the test's meta information: every `meta` key from `Scenario('...', { meta })`, plus its `tags` and `retries`
|
|
30
|
+
* * `<system-out>` — an indented step/substep log (substeps are nested under their meta step); only failed steps are marked
|
|
31
|
+
* * `<failure>` — for failed tests: the error message, type, stack trace and (optionally) the step trace
|
|
32
|
+
*
|
|
33
|
+
* The produced file is consumable by Jenkins, GitLab CI, CircleCI, GitHub Actions test reporters, etc.
|
|
34
|
+
*
|
|
35
|
+
* #### Configuration
|
|
36
|
+
*
|
|
37
|
+
* ```js
|
|
38
|
+
* "plugins": {
|
|
39
|
+
* "junitReporter": {
|
|
40
|
+
* "enabled": true
|
|
41
|
+
* }
|
|
42
|
+
* }
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* Possible config options:
|
|
46
|
+
*
|
|
47
|
+
* * `outputName`: file name for the report. Default: `report.xml`.
|
|
48
|
+
* * `output`: directory where the report is stored, relative to the project root. Default: the `output` directory.
|
|
49
|
+
* * `testGroupName`: value of the `name` attribute on the root `<testsuites>` element. Default: `CodeceptJS`.
|
|
50
|
+
* * `attachMeta`: add the test's meta information (`meta` keys, `tags`, `retries`) as `<properties>`. Default: true.
|
|
51
|
+
* * `attachSteps`: add the step/substep log as `<system-out>`. Default: true.
|
|
52
|
+
* * `stepsInFailure`: append the step trace to the `<failure>` body. Default: true.
|
|
53
|
+
*
|
|
54
|
+
* CLI examples:
|
|
55
|
+
*
|
|
56
|
+
* ```
|
|
57
|
+
* npx codeceptjs run -p junitReporter
|
|
58
|
+
* npx codeceptjs run -p junitReporter:outputName=junit.xml
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* > ℹ When running with `run-workers`, steps are serialized between processes and substep nesting is flattened.
|
|
62
|
+
*
|
|
63
|
+
* @param {*} config
|
|
64
|
+
*/
|
|
65
|
+
export default function (config = {}) {
|
|
66
|
+
config = Object.assign({}, defaultConfig, config)
|
|
67
|
+
|
|
68
|
+
let written = false
|
|
69
|
+
|
|
70
|
+
const writeReport = result => {
|
|
71
|
+
if (written) return
|
|
72
|
+
if (!result || !Array.isArray(result.tests)) return
|
|
73
|
+
written = true
|
|
74
|
+
|
|
75
|
+
const dir = config.output ? path.resolve(store.codeceptDir || process.cwd(), config.output) : store.outputDir || process.cwd()
|
|
76
|
+
mkdirp.sync(dir)
|
|
77
|
+
const file = path.join(dir, config.outputName)
|
|
78
|
+
|
|
79
|
+
fs.writeFileSync(file, buildXml(result, config))
|
|
80
|
+
output.plugin('junitReporter', `JUnit report saved to ${file}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
event.dispatcher.on(event.all.result, writeReport)
|
|
84
|
+
event.dispatcher.on(event.workers.result, writeReport)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildXml(result, config) {
|
|
88
|
+
const doc = new DOMImplementation().createDocument(null, null, null)
|
|
89
|
+
const suites = groupBySuite(result.tests)
|
|
90
|
+
|
|
91
|
+
const root = doc.createElement('testsuites')
|
|
92
|
+
setAttr(root, 'name', config.testGroupName)
|
|
93
|
+
setAttr(root, 'tests', result.tests.length)
|
|
94
|
+
setAttr(root, 'failures', countState(result.tests, 'failed'))
|
|
95
|
+
setAttr(root, 'skipped', countSkipped(result.tests))
|
|
96
|
+
setAttr(root, 'errors', 0)
|
|
97
|
+
setAttr(root, 'time', toSeconds(sumDuration(result.tests)))
|
|
98
|
+
setAttr(root, 'timestamp', toIso(result.stats && result.stats.start))
|
|
99
|
+
doc.appendChild(root)
|
|
100
|
+
|
|
101
|
+
suites.forEach((tests, index) => {
|
|
102
|
+
const suite = tests[0] && tests[0].parent
|
|
103
|
+
const suiteName = (suite && suite.title) || 'Tests'
|
|
104
|
+
const suiteFile = (suite && suite.file) || (tests[0] && tests[0].file) || ''
|
|
105
|
+
|
|
106
|
+
const suiteEl = doc.createElement('testsuite')
|
|
107
|
+
setAttr(suiteEl, 'name', suiteName)
|
|
108
|
+
setAttr(suiteEl, 'id', index)
|
|
109
|
+
setAttr(suiteEl, 'tests', tests.length)
|
|
110
|
+
setAttr(suiteEl, 'failures', countState(tests, 'failed'))
|
|
111
|
+
setAttr(suiteEl, 'skipped', countSkipped(tests))
|
|
112
|
+
setAttr(suiteEl, 'errors', 0)
|
|
113
|
+
setAttr(suiteEl, 'time', toSeconds(sumDuration(tests)))
|
|
114
|
+
setAttr(suiteEl, 'timestamp', toIso(suite && suite.startedAt))
|
|
115
|
+
setAttr(suiteEl, 'hostname', os.hostname())
|
|
116
|
+
if (suiteFile) setAttr(suiteEl, 'file', suiteFile)
|
|
117
|
+
root.appendChild(suiteEl)
|
|
118
|
+
|
|
119
|
+
for (const test of tests) {
|
|
120
|
+
suiteEl.appendChild(buildTestCase(doc, test, suiteName, config))
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
return '<?xml version="1.0" encoding="UTF-8"?>\n' + new XMLSerializer().serializeToString(doc) + '\n'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildTestCase(doc, test, suiteName, config) {
|
|
128
|
+
const testEl = doc.createElement('testcase')
|
|
129
|
+
setAttr(testEl, 'name', test.title || '(no title)')
|
|
130
|
+
setAttr(testEl, 'classname', suiteName)
|
|
131
|
+
setAttr(testEl, 'time', toSeconds(test.duration || 0))
|
|
132
|
+
const file = test.file || (test.parent && test.parent.file)
|
|
133
|
+
if (file) setAttr(testEl, 'file', file)
|
|
134
|
+
|
|
135
|
+
if (config.attachMeta) {
|
|
136
|
+
const properties = metaProperties(test)
|
|
137
|
+
if (properties.length) {
|
|
138
|
+
const propertiesEl = doc.createElement('properties')
|
|
139
|
+
for (const [name, value] of properties) {
|
|
140
|
+
const prop = doc.createElement('property')
|
|
141
|
+
setAttr(prop, 'name', name)
|
|
142
|
+
setAttr(prop, 'value', value)
|
|
143
|
+
propertiesEl.appendChild(prop)
|
|
144
|
+
}
|
|
145
|
+
testEl.appendChild(propertiesEl)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const flat = flattenSteps(Array.isArray(test.steps) ? test.steps : [])
|
|
150
|
+
|
|
151
|
+
if (test.state === 'skipped' || test.state === 'pending') {
|
|
152
|
+
const skipped = doc.createElement('skipped')
|
|
153
|
+
const reason = skipReason(test)
|
|
154
|
+
if (reason) setAttr(skipped, 'message', reason)
|
|
155
|
+
testEl.appendChild(skipped)
|
|
156
|
+
} else if (test.state === 'failed') {
|
|
157
|
+
const err = test.err || {}
|
|
158
|
+
const failure = doc.createElement('failure')
|
|
159
|
+
setAttr(failure, 'message', err.message || 'Test failed')
|
|
160
|
+
setAttr(failure, 'type', err.name || 'Error')
|
|
161
|
+
let body = err.stack || err.message || 'Test failed'
|
|
162
|
+
if (config.stepsInFailure && flat.length) {
|
|
163
|
+
body += '\n\nSteps:\n' + flat.map(stepLogLine).join('\n')
|
|
164
|
+
}
|
|
165
|
+
failure.appendChild(doc.createTextNode(cleanText(body)))
|
|
166
|
+
testEl.appendChild(failure)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (config.attachSteps && flat.length) {
|
|
170
|
+
const out = doc.createElement('system-out')
|
|
171
|
+
out.appendChild(doc.createTextNode(cleanText(flat.map(stepLogLine).join('\n'))))
|
|
172
|
+
testEl.appendChild(out)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return testEl
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function metaProperties(test) {
|
|
179
|
+
const props = []
|
|
180
|
+
const meta = test.meta || {}
|
|
181
|
+
for (const key of Object.keys(meta)) {
|
|
182
|
+
if (meta[key] === undefined || meta[key] === null) continue
|
|
183
|
+
props.push([key, stringifyMeta(meta[key])])
|
|
184
|
+
}
|
|
185
|
+
if (Array.isArray(test.tags) && test.tags.length) {
|
|
186
|
+
props.push(['tags', test.tags.join(' ')])
|
|
187
|
+
}
|
|
188
|
+
if (test.retries > 0 || test.retryNum > 0) {
|
|
189
|
+
props.push(['retries', String(test.retryNum || test.retries)])
|
|
190
|
+
}
|
|
191
|
+
return props
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function stringifyMeta(value) {
|
|
195
|
+
if (typeof value === 'string') return value
|
|
196
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
197
|
+
try {
|
|
198
|
+
return JSON.stringify(value)
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return String(value)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function flattenSteps(steps) {
|
|
205
|
+
const out = []
|
|
206
|
+
let prevChain = []
|
|
207
|
+
|
|
208
|
+
for (const step of steps) {
|
|
209
|
+
const chain = metaChain(step)
|
|
210
|
+
|
|
211
|
+
let common = 0
|
|
212
|
+
while (common < chain.length && common < prevChain.length && chain[common].key === prevChain[common].key) common++
|
|
213
|
+
|
|
214
|
+
for (let d = common; d < chain.length; d++) {
|
|
215
|
+
out.push({ depth: d, step: chain[d].step })
|
|
216
|
+
}
|
|
217
|
+
out.push({ depth: chain.length, step })
|
|
218
|
+
prevChain = chain
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function metaChain(step) {
|
|
225
|
+
const chain = []
|
|
226
|
+
let meta = step && step.metaStep
|
|
227
|
+
while (meta) {
|
|
228
|
+
chain.unshift({ step: meta, key: meta })
|
|
229
|
+
meta = meta.metaStep
|
|
230
|
+
}
|
|
231
|
+
if (!chain.length && step && step.parent && step.parent.title) {
|
|
232
|
+
chain.push({ step: { title: step.parent.title, status: step.status }, key: `meta:${step.parent.title}` })
|
|
233
|
+
}
|
|
234
|
+
return chain
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function stepLogLine(entry) {
|
|
238
|
+
const indent = ' '.repeat(entry.depth)
|
|
239
|
+
const mark = entry.step && entry.step.status === 'failed' ? '[FAILED] ' : ''
|
|
240
|
+
return `${indent}${mark}${stepText(entry.step)} (${stepDuration(entry.step)}ms)`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function stepText(step) {
|
|
244
|
+
if (step && typeof step.toString === 'function' && step.toString !== Object.prototype.toString) return step.toString()
|
|
245
|
+
return (step && (step.title || step.name)) || 'step'
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function stepDuration(step) {
|
|
249
|
+
if (!step) return 0
|
|
250
|
+
if (typeof step.duration === 'number' && step.duration >= 0) return step.duration
|
|
251
|
+
if (step.startTime && step.endTime) return Math.max(0, step.endTime - step.startTime)
|
|
252
|
+
return 0
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function groupBySuite(tests) {
|
|
256
|
+
const groups = []
|
|
257
|
+
const byKey = new Map()
|
|
258
|
+
for (const test of tests) {
|
|
259
|
+
const key = test.parent || test
|
|
260
|
+
if (!byKey.has(key)) {
|
|
261
|
+
const list = []
|
|
262
|
+
byKey.set(key, list)
|
|
263
|
+
groups.push(list)
|
|
264
|
+
}
|
|
265
|
+
byKey.get(key).push(test)
|
|
266
|
+
}
|
|
267
|
+
return groups
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function skipReason(test) {
|
|
271
|
+
if (test.opts && test.opts.skipInfo && test.opts.skipInfo.message) return test.opts.skipInfo.message
|
|
272
|
+
if (test.meta && test.meta.skipReason) return test.meta.skipReason
|
|
273
|
+
return ''
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function countState(tests, state) {
|
|
277
|
+
return tests.filter(t => t.state === state).length
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function countSkipped(tests) {
|
|
281
|
+
return tests.filter(t => t.state === 'skipped' || t.state === 'pending').length
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function sumDuration(tests) {
|
|
285
|
+
return tests.reduce((sum, t) => sum + (t.duration || 0), 0)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function toSeconds(ms) {
|
|
289
|
+
return (Math.max(0, ms) / 1000).toFixed(3)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function toIso(value) {
|
|
293
|
+
const date = value ? new Date(value) : new Date()
|
|
294
|
+
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function cleanText(text) {
|
|
298
|
+
return String(text == null ? '' : text).replace(INVALID_XML_CHARS, '')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function setAttr(el, name, value) {
|
|
302
|
+
el.setAttribute(name, cleanText(value))
|
|
303
|
+
}
|
package/lib/plugin/pageInfo.js
CHANGED
|
@@ -40,10 +40,10 @@ const defaultConfig = {
|
|
|
40
40
|
export default function (config = {}) {
|
|
41
41
|
config = Object.assign(defaultConfig, config)
|
|
42
42
|
|
|
43
|
-
const helper = pickActingHelper(Container.helpers())
|
|
44
|
-
if (!helper) return
|
|
45
|
-
|
|
46
43
|
event.dispatcher.on(event.test.failed, test => {
|
|
44
|
+
const helper = pickActingHelper(Container.helpers())
|
|
45
|
+
if (!helper) return
|
|
46
|
+
|
|
47
47
|
const pageState = {}
|
|
48
48
|
|
|
49
49
|
recorder.add('pageInfo capture', async () => {
|
|
@@ -60,8 +60,6 @@ export default function (config = {}) {
|
|
|
60
60
|
if (captured.html) {
|
|
61
61
|
const htmlPath = path.join(store.outputDir, captured.html)
|
|
62
62
|
pageState.htmlSnapshot = htmlPath
|
|
63
|
-
// Scan raw HTML (pre-cleanHtml) so error classes containing digits
|
|
64
|
-
// or trash-class prefixes aren't stripped before detection.
|
|
65
63
|
const htmlForScan = captured.htmlRaw || (() => {
|
|
66
64
|
try { return fs.readFileSync(htmlPath, 'utf8') } catch { return '' }
|
|
67
65
|
})()
|
|
@@ -90,7 +88,7 @@ export default function (config = {}) {
|
|
|
90
88
|
} catch {}
|
|
91
89
|
}
|
|
92
90
|
} catch {}
|
|
93
|
-
})
|
|
91
|
+
}, true)
|
|
94
92
|
|
|
95
93
|
recorder.add('Save page info', () => {
|
|
96
94
|
test.addNote('pageInfo', pageStateToMarkdown(pageState))
|
|
@@ -99,7 +97,7 @@ export default function (config = {}) {
|
|
|
99
97
|
fs.writeFileSync(pageStateFileName, pageStateToMarkdown(pageState))
|
|
100
98
|
test.artifacts.pageInfo = pageStateFileName
|
|
101
99
|
return pageState
|
|
102
|
-
})
|
|
100
|
+
}, true)
|
|
103
101
|
})
|
|
104
102
|
}
|
|
105
103
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import debugModule from 'debug'
|
|
1
2
|
import event from '../event.js'
|
|
2
3
|
import recorder from '../recorder.js'
|
|
3
4
|
import store from '../store.js'
|
|
4
5
|
|
|
6
|
+
const debug = debugModule('codeceptjs:retryFailedStep')
|
|
7
|
+
|
|
5
8
|
const defaultConfig = {
|
|
6
9
|
retries: 3,
|
|
7
10
|
defaultIgnoredSteps: ['amOnPage', 'wait*', 'send*', 'execute*', 'run*', 'have*'],
|
|
@@ -147,9 +150,7 @@ export default function (config) {
|
|
|
147
150
|
test.opts.conditionalRetries = config.retries
|
|
148
151
|
test.opts.stepRetryPriority = stepRetryPriority
|
|
149
152
|
|
|
150
|
-
|
|
151
|
-
console.log('[retryFailedStep] applying retries =', config.retries, 'for test', test.title)
|
|
152
|
-
}
|
|
153
|
+
debug('applying retries = %d for test %s', config.retries, test.title)
|
|
153
154
|
recorder.retry(config)
|
|
154
155
|
})
|
|
155
156
|
|
package/lib/plugin/screencast.js
CHANGED
|
@@ -106,6 +106,12 @@ function wireScreencast(mode, options) {
|
|
|
106
106
|
state.startedAt = options.subtitles ? Date.now() : null
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
+
event.dispatcher.on(event.test.started, test => {
|
|
110
|
+
if (!options.video || state.startQueued) return
|
|
111
|
+
state.startQueued = true
|
|
112
|
+
recorder.add('screencast:start', async () => startScreencast(state.test, options, state), true)
|
|
113
|
+
})
|
|
114
|
+
|
|
109
115
|
event.dispatcher.on(event.step.started, step => {
|
|
110
116
|
if (state.steps) {
|
|
111
117
|
const at = Date.now()
|
|
@@ -116,10 +122,6 @@ function wireScreencast(mode, options) {
|
|
|
116
122
|
title: stepTitle(step),
|
|
117
123
|
}
|
|
118
124
|
}
|
|
119
|
-
if (!options.video || state.startQueued || !state.test) return
|
|
120
|
-
state.startQueued = true
|
|
121
|
-
const test = state.test
|
|
122
|
-
recorder.add('screencast:start', async () => startScreencast(test, options, state), true)
|
|
123
125
|
})
|
|
124
126
|
|
|
125
127
|
if (options.subtitles) {
|
package/lib/workers.js
CHANGED
|
@@ -547,10 +547,11 @@ class Workers extends EventEmitter {
|
|
|
547
547
|
if (this.isPoolMode) {
|
|
548
548
|
this.activeWorkers.set(worker, { available: true, workerIndex: null })
|
|
549
549
|
}
|
|
550
|
-
|
|
550
|
+
|
|
551
551
|
// Track last activity time to detect hanging workers
|
|
552
552
|
let lastActivity = Date.now()
|
|
553
553
|
let currentTest = null
|
|
554
|
+
let autoTerminated = false
|
|
554
555
|
const workerTimeout = process.env.CODECEPT_WORKER_TIMEOUT ? ms(process.env.CODECEPT_WORKER_TIMEOUT) : ms('5m')
|
|
555
556
|
|
|
556
557
|
const timeoutChecker = setInterval(() => {
|
|
@@ -611,6 +612,13 @@ class Workers extends EventEmitter {
|
|
|
611
612
|
})
|
|
612
613
|
}
|
|
613
614
|
|
|
615
|
+
const exitTimeout = parseInt(process.env.CODECEPT_AUTO_EXIT_TIMEOUT, 10)
|
|
616
|
+
if (exitTimeout === 0) break
|
|
617
|
+
setTimeout(() => {
|
|
618
|
+
autoTerminated = true
|
|
619
|
+
worker.terminate()
|
|
620
|
+
}, exitTimeout || 2000)
|
|
621
|
+
|
|
614
622
|
break
|
|
615
623
|
case event.suite.before:
|
|
616
624
|
{
|
|
@@ -741,8 +749,8 @@ class Workers extends EventEmitter {
|
|
|
741
749
|
worker.on('exit', (code) => {
|
|
742
750
|
clearInterval(timeoutChecker)
|
|
743
751
|
this.closedWorkers += 1
|
|
744
|
-
|
|
745
|
-
if (code !== 0) {
|
|
752
|
+
|
|
753
|
+
if (code !== 0 && !autoTerminated) {
|
|
746
754
|
console.error(`[Main] Worker exited with code ${code}`)
|
|
747
755
|
if (currentTest) {
|
|
748
756
|
console.error(`[Main] Last test running: ${currentTest}`)
|