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.
@@ -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.constructor.name === 'FrameLocator') {
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 ((this.context && this.context.constructor.name === 'FrameLocator') || this.context) {
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.constructor.name !== 'Locator' ? [await el.locator('body').innerText()] : [await el.innerText()]
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
@@ -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
- const stepPersistPromise = persistStep(step).catch(err => {
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, async step => {
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
- try {
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
- try {
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(test, 'failed')
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) {
@@ -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, async 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(container.plugins()).includes('pageInfo')) {
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
  }
@@ -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
+ }
@@ -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
- if (process.env.DEBUG_RETRY_PLUGIN) {
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
 
@@ -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}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeceptjs",
3
- "version": "4.0.0-rc.20",
3
+ "version": "4.0.0-rc.22",
4
4
  "type": "module",
5
5
  "description": "Supercharged End 2 End Testing Framework for NodeJS",
6
6
  "keywords": [