codeceptjs 4.1.0 → 4.2.0-beta.1

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.
Files changed (41) hide show
  1. package/docs/alternative-browsers.md +153 -0
  2. package/docs/basics.md +9 -1
  3. package/docs/configuration.md +2 -0
  4. package/docs/helpers/CDPBrowser.md +2138 -0
  5. package/docs/helpers/Kitesurf.md +118 -0
  6. package/docs/helpers/Obscura.md +210 -0
  7. package/docs/migration-4.md +3 -1
  8. package/docs/parallel.md +10 -0
  9. package/docs/plugins/screencast.md +18 -13
  10. package/docs/plugins.md +1 -1
  11. package/lib/command/info.js +11 -3
  12. package/lib/command/workers/runTests.js +14 -20
  13. package/lib/container.js +6 -0
  14. package/lib/data/context.js +4 -0
  15. package/lib/element/WebElement.js +5 -0
  16. package/lib/helper/Appium.js +14 -2
  17. package/lib/helper/CDPBrowser.js +3004 -0
  18. package/lib/helper/Kitesurf.js +139 -0
  19. package/lib/helper/Obscura.js +344 -0
  20. package/lib/helper/Playwright.js +30 -3
  21. package/lib/helper/Puppeteer.js +43 -16
  22. package/lib/helper/WebDriver.js +43 -8
  23. package/lib/helper/clientscripts/cdpBrowserClient.js +486 -0
  24. package/lib/helper/clientscripts/xpathPolyfill.js +31 -0
  25. package/lib/helper/extras/CDPConnection.js +92 -0
  26. package/lib/helper/extras/CDPElementHandle.js +27 -0
  27. package/lib/helper/extras/apngAssembler.js +156 -0
  28. package/lib/html.js +9 -2
  29. package/lib/listener/retryEnhancer.js +2 -1
  30. package/lib/listener/steps.js +8 -0
  31. package/lib/mocha/hooks.js +10 -0
  32. package/lib/parser.js +14 -2
  33. package/lib/plugin/junitReporter.js +17 -1
  34. package/lib/plugin/screencast.js +116 -24
  35. package/lib/step/base.js +15 -3
  36. package/lib/utils/loaderCheck.js +6 -0
  37. package/lib/utils.js +1 -1
  38. package/lib/workers.js +17 -0
  39. package/package.json +4 -1
  40. package/typings/promiseBasedTypes.d.ts +1833 -0
  41. package/typings/types.d.ts +1840 -0
@@ -0,0 +1,156 @@
1
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
2
+
3
+ const CRC_TABLE = (() => {
4
+ const table = new Uint32Array(256)
5
+ for (let n = 0; n < 256; n++) {
6
+ let c = n
7
+ for (let k = 0; k < 8; k++) {
8
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
9
+ }
10
+ table[n] = c >>> 0
11
+ }
12
+ return table
13
+ })()
14
+
15
+ function crc32(buf) {
16
+ let c = 0xffffffff
17
+ for (let i = 0; i < buf.length; i++) {
18
+ c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
19
+ }
20
+ return (c ^ 0xffffffff) >>> 0
21
+ }
22
+
23
+ /**
24
+ * True if `buf` starts with the 8-byte PNG signature.
25
+ *
26
+ * @param {object} buf a Buffer.
27
+ * @returns {boolean}
28
+ */
29
+ function isPng(buf) {
30
+ return buf.length >= 8 && buf.subarray(0, 8).equals(PNG_SIGNATURE)
31
+ }
32
+
33
+ function readChunks(buf) {
34
+ if (!isPng(buf)) throw new Error('not a PNG file (bad signature)')
35
+ const chunks = []
36
+ let offset = 8
37
+ while (offset + 8 <= buf.length) {
38
+ const length = buf.readUInt32BE(offset)
39
+ const type = buf.toString('ascii', offset + 4, offset + 8)
40
+ const dataStart = offset + 8
41
+ const dataEnd = dataStart + length
42
+ if (dataEnd + 4 > buf.length) break
43
+ const data = buf.subarray(dataStart, dataEnd)
44
+ chunks.push({ type, data })
45
+ offset = dataEnd + 4
46
+ if (type === 'IEND') break
47
+ }
48
+ return chunks
49
+ }
50
+
51
+ function makeChunk(type, data) {
52
+ const length = Buffer.alloc(4)
53
+ length.writeUInt32BE(data.length, 0)
54
+ const typeBuf = Buffer.from(type, 'ascii')
55
+ const crc = Buffer.alloc(4)
56
+ crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0)
57
+ return Buffer.concat([length, typeBuf, data, crc])
58
+ }
59
+
60
+ function delayToFraction(delayMs) {
61
+ let num = Math.max(1, Math.round(delayMs))
62
+ let den = 1000
63
+ while (num > 65535) {
64
+ num = Math.round(num / 2)
65
+ den = Math.round(den / 2) || 1
66
+ }
67
+ return { num, den }
68
+ }
69
+
70
+ /**
71
+ * Muxes a sequence of complete, same-dimension PNG frames into a single APNG (Animated PNG) file.
72
+ * This is byte-level PNG chunk container work only — no pixel encoding or decoding. The first
73
+ * frame's `IHDR` and any other ancillary chunks (e.g. `gAMA`, `pHYs`) are carried over verbatim;
74
+ * each frame's `IDAT` chunk(s) are concatenated and re-emitted as the animation's `IDAT` (frame 0)
75
+ * or `fdAT` (subsequent frames) with a shared, monotonically increasing sequence number across all
76
+ * `fcTL`/`fdAT` chunks, per the APNG spec.
77
+ *
78
+ * @param {Array<{buffer: object, delayMs: number}>} frames complete single-image PNG buffers with the
79
+ * delay (in milliseconds) to hold that frame before advancing to the next one; the last frame's own
80
+ * `delayMs` is ignored in favor of `options.lastFrameDelayMs`.
81
+ * @param {object} [options] {onDropFrame: function, numPlays: number, lastFrameDelayMs: number} — options.onDropFrame(info) is called for any frame whose
82
+ * dimensions don't match the first frame, which is then excluded from the output (APNG requires a
83
+ * single fixed size per this implementation — no scaling). options.numPlays is the animation loop
84
+ * count (0 = infinite, the default). options.lastFrameDelayMs is the hold time for the final frame
85
+ * (default 1000ms).
86
+ * @returns {object} a Buffer containing the assembled APNG file.
87
+ */
88
+ function assembleApng(frames, options = {}) {
89
+ if (!frames || !frames.length) throw new Error('assembleApng: at least one frame is required')
90
+
91
+ const parsed = frames.map(f => ({ chunks: readChunks(f.buffer), delayMs: f.delayMs }))
92
+ const ihdr = parsed[0].chunks.find(c => c.type === 'IHDR')
93
+ if (!ihdr) throw new Error('assembleApng: first frame has no IHDR chunk')
94
+ const width = ihdr.data.readUInt32BE(0)
95
+ const height = ihdr.data.readUInt32BE(4)
96
+
97
+ const usable = []
98
+ for (const pf of parsed) {
99
+ const frameIhdr = pf.chunks.find(c => c.type === 'IHDR')
100
+ const w = frameIhdr && frameIhdr.data.readUInt32BE(0)
101
+ const h = frameIhdr && frameIhdr.data.readUInt32BE(4)
102
+ if (w !== width || h !== height) {
103
+ if (typeof options.onDropFrame === 'function') {
104
+ options.onDropFrame({ width: w, height: h, expectedWidth: width, expectedHeight: height })
105
+ }
106
+ continue
107
+ }
108
+ usable.push(pf)
109
+ }
110
+ if (!usable.length) throw new Error('assembleApng: no frames with dimensions matching the first frame')
111
+
112
+ const out = [PNG_SIGNATURE, makeChunk('IHDR', ihdr.data)]
113
+
114
+ const actl = Buffer.alloc(8)
115
+ actl.writeUInt32BE(usable.length, 0)
116
+ actl.writeUInt32BE(options.numPlays || 0, 4)
117
+ out.push(makeChunk('acTL', actl))
118
+
119
+ for (const c of parsed[0].chunks) {
120
+ if (c.type === 'IHDR' || c.type === 'IDAT' || c.type === 'IEND') continue
121
+ out.push(makeChunk(c.type, c.data))
122
+ }
123
+
124
+ let seq = 0
125
+ usable.forEach((pf, i) => {
126
+ const imageData = Buffer.concat(pf.chunks.filter(c => c.type === 'IDAT').map(c => c.data))
127
+ const delayMs = i === usable.length - 1 ? (options.lastFrameDelayMs ?? 1000) : pf.delayMs
128
+ const { num, den } = delayToFraction(delayMs)
129
+
130
+ const fctl = Buffer.alloc(26)
131
+ fctl.writeUInt32BE(seq++, 0)
132
+ fctl.writeUInt32BE(width, 4)
133
+ fctl.writeUInt32BE(height, 8)
134
+ fctl.writeUInt32BE(0, 12)
135
+ fctl.writeUInt32BE(0, 16)
136
+ fctl.writeUInt16BE(num, 20)
137
+ fctl.writeUInt16BE(den, 22)
138
+ fctl.writeUInt8(0, 24)
139
+ fctl.writeUInt8(0, 25)
140
+ out.push(makeChunk('fcTL', fctl))
141
+
142
+ if (i === 0) {
143
+ out.push(makeChunk('IDAT', imageData))
144
+ } else {
145
+ const fdat = Buffer.alloc(4 + imageData.length)
146
+ fdat.writeUInt32BE(seq++, 0)
147
+ imageData.copy(fdat, 4)
148
+ out.push(makeChunk('fdAT', fdat))
149
+ }
150
+ })
151
+
152
+ out.push(makeChunk('IEND', Buffer.alloc(0)))
153
+ return Buffer.concat(out)
154
+ }
155
+
156
+ export { assembleApng, isPng, readChunks, crc32, PNG_SIGNATURE }
package/lib/html.js CHANGED
@@ -78,11 +78,12 @@ const defaultHtmlOpts = {
78
78
  textElements: ['label', 'h1', 'h2'],
79
79
  allowedAttrs: ['id', 'for', 'class', 'name', 'type', 'value', 'tabindex', 'aria-labelledby', 'aria-label', 'label', 'placeholder', 'title', 'alt', 'src', 'role'],
80
80
  allowedRoles: ['button', 'checkbox', 'search', 'textbox', 'tab'],
81
+ keepText: false,
81
82
  }
82
83
 
83
84
  function removeNonInteractiveElements(html, opts = {}) {
84
85
  opts = { ...defaultHtmlOpts, ...opts }
85
- const { interactiveElements, textElements, allowedAttrs, allowedRoles } = opts
86
+ const { interactiveElements, textElements, allowedAttrs, allowedRoles, keepText } = opts
86
87
 
87
88
  // Parse the HTML into a document tree
88
89
  const document = parse(html)
@@ -111,8 +112,14 @@ function removeNonInteractiveElements(html, opts = {}) {
111
112
  return false
112
113
  }
113
114
 
115
+ function hasVisibleText(node) {
116
+ if (node.nodeName === '#text') return !!node.value.trim()
117
+ return (node.childNodes || []).some(hasVisibleText)
118
+ }
119
+
114
120
  function hasMeaningfulText(node) {
115
121
  if (textElements.includes(node.nodeName)) return true
122
+ if (keepText && hasVisibleText(node)) return true
116
123
  return false
117
124
  }
118
125
 
@@ -294,7 +301,7 @@ function splitByChunks(text, chunkSize) {
294
301
 
295
302
  function simplifyHtmlElement(html, maxLength = 300) {
296
303
  try {
297
- html = removeNonInteractiveElements(html)
304
+ html = removeNonInteractiveElements(html, { keepText: true })
298
305
  html = html.replace(/<html>(?:<head>.*?<\/head>)?<body>(.*)<\/body><\/html>/s, '$1').trim()
299
306
  } catch (e) {
300
307
  // keep raw html if minification fails
@@ -44,7 +44,8 @@ function copyCodeceptJSProperties(originalTest, retriedTest) {
44
44
  }
45
45
 
46
46
  if (originalTest.artifacts !== undefined) {
47
- retriedTest.artifacts = originalTest.artifacts ? [...originalTest.artifacts] : []
47
+ const artifacts = originalTest.artifacts
48
+ retriedTest.artifacts = artifacts ? (Array.isArray(artifacts) ? Object.assign([], artifacts) : { ...artifacts }) : []
48
49
  }
49
50
 
50
51
  if (originalTest.steps !== undefined) {
@@ -16,6 +16,14 @@ const EXCLUDED_SESSIONS = ['tryTo', 'hopeThat']
16
16
  * Register steps inside tests
17
17
  */
18
18
  export default function () {
19
+ // Mocha's Suite has no start timestamp of its own, and junitReporter reads
20
+ // `suite.startedAt` for each `<testsuite timestamp>`. Without this the
21
+ // reporter falls back to `new Date()` at write time, stamping every suite
22
+ // with the moment the XML was serialized. (#5668)
23
+ event.dispatcher.on(event.suite.before, suite => {
24
+ suite.startedAt = +new Date()
25
+ })
26
+
19
27
  event.dispatcher.on(event.test.before, test => {
20
28
  test.startedAt = +new Date()
21
29
  })
@@ -33,12 +33,22 @@ class Hook {
33
33
  }
34
34
 
35
35
  simplify() {
36
+ // this.runnable (context.ctx.test) is the hook's own runnable; its
37
+ // .parent is the real Mocha Suite that owns this hook. Included here so
38
+ // run-workers can forward suite identity to the main process, where
39
+ // listeners (e.g. junitReporter) have no other way to recover it — the
40
+ // worker's live Suite/ctx objects aren't serializable across the thread
41
+ // boundary, only these plain fields are.
42
+ const suite = this.runnable?.parent
36
43
  return {
37
44
  hookName: this.hookName,
38
45
  title: this.title,
39
46
  // test: this.test ? serializeTest(this.test) : null,
40
47
  // suite: this.suite ? serializeSuite(this.suite) : null,
41
48
  error: this.err ? serializeError(this.err) : null,
49
+ suiteTitle: suite?.title || null,
50
+ suiteFile: suite?.file || null,
51
+ suiteTags: suite?.tags || [],
42
52
  }
43
53
  }
44
54
 
package/lib/parser.js CHANGED
@@ -4,7 +4,8 @@ function _interopDefault(ex) {
4
4
  import * as acorn from 'acorn'
5
5
  import parseFunctionModule from 'parse-function'
6
6
  const parseFunction = _interopDefault(parseFunctionModule)
7
- const parser = parseFunction({ parse: acorn.parse, ecmaVersion: 11, plugins: ['objectRestSpread'] })
7
+ const ecmaVersion = 11
8
+ const parser = parseFunction({ parse: acorn.parse, ecmaVersion, plugins: ['objectRestSpread'] })
8
9
  import output from './output.js'
9
10
 
10
11
  parser.use(destructuredArgs)
@@ -17,7 +18,7 @@ export const getParamsToString = function (fn) {
17
18
  function getParams(fn, { warnOnLegacyFormat = false } = {}) {
18
19
  if (fn.isSinonProxy) return []
19
20
  try {
20
- const reflected = parser.parse(fn)
21
+ const reflected = parser.parse(normalizeArrowFn(fn))
21
22
  if (warnOnLegacyFormat && (reflected.args.length > 1 || reflected.args[0] === 'I')) {
22
23
  output.error('Error: old CodeceptJS v2 format detected. Upgrade your project to the new format -> https://bit.ly/codecept3Up')
23
24
  }
@@ -38,6 +39,17 @@ function getParams(fn, { warnOnLegacyFormat = false } = {}) {
38
39
 
39
40
  export { getParams }
40
41
 
42
+ function normalizeArrowFn(fn) {
43
+ const code = (typeof fn === 'function' ? fn.toString() : String(fn)).trim()
44
+ if (!code.includes('=>') || code.startsWith('async')) return fn
45
+ try {
46
+ if (acorn.parseExpressionAt(code, 0, { ecmaVersion }).type !== 'ArrowFunctionExpression') return fn
47
+ } catch {
48
+ return fn
49
+ }
50
+ return `async ${code}`
51
+ }
52
+
41
53
  function destructuredArgs() {
42
54
  return (node, result) => {
43
55
  result.destructuredArgs = result.destructuredArgs || []
@@ -67,13 +67,29 @@ export default function (config = {}) {
67
67
 
68
68
  let written = false
69
69
  const hookFailures = []
70
+ // groupBySuite() (below) groups by object identity, not by value — reused
71
+ // across BeforeSuite/AfterSuite failures from the same worker-forwarded
72
+ // suite so they land in one <testsuite>, not one per failure.
73
+ const workerSuiteByKey = new Map()
70
74
 
71
75
  event.dispatcher.on(event.hook.failed, hook => {
72
76
  if (!hook || !['BeforeSuite', 'AfterSuite'].includes(hook.hookName)) return
73
77
  const err = hook.err || hook.error
74
78
  if (!err) return
75
79
  const runnable = hook.ctx && hook.ctx.test
76
- const suite = runnable && runnable.parent
80
+ // Under run-workers, hook.ctx is absent — the failure arrives as the
81
+ // plain object from Hook.simplify() in the main process instead of a
82
+ // live Hook instance. Fall back to the suiteTitle/suiteFile/suiteTags
83
+ // simplify() carries across the worker boundary so the failure still
84
+ // groups under its real suite instead of the "Tests" fallback.
85
+ let suite = runnable && runnable.parent
86
+ if (!suite && hook.suiteTitle) {
87
+ const key = `${hook.suiteTitle} ${hook.suiteFile || ''}`
88
+ if (!workerSuiteByKey.has(key)) {
89
+ workerSuiteByKey.set(key, { title: hook.suiteTitle, file: hook.suiteFile, tags: hook.suiteTags })
90
+ }
91
+ suite = workerSuiteByKey.get(key)
92
+ }
77
93
  hookFailures.push({
78
94
  title: hook.title || `${hook.hookName} hook failed`,
79
95
  state: 'failed',
@@ -20,16 +20,21 @@ const defaultConfig = {
20
20
  }
21
21
 
22
22
  /**
23
- * Records WebM video of tests using Playwright's screencast API.
23
+ * Records a video of tests. Uses Playwright's `page.screencast` API (WebM) when the active
24
+ * helper is Playwright, or raw CDP `Page.startScreencast` (APNG, assembled in-process) when the
25
+ * active helper is `CDPBrowser` or a subclass (`Obscura`, `Kitesurf`, ...). Which path is used is
26
+ * detected automatically per test run; nothing in the config changes between them.
24
27
  *
25
- * When `captions` is enabled, action annotations are burned into the video;
26
- * when `subtitles` is enabled, a standalone `.srt` is also produced. Default
27
- * `on=fail` keeps videos for failed tests only; `on=test` keeps every test's
28
- * video.
28
+ * When `captions` is enabled, action annotations are burned into the video — Playwright only,
29
+ * via `page.screencast.showActions()`/`showChapter()`; silently absent on the CDP path, since CDP
30
+ * screencast frames are raw, uncomposited page captures with no overlay mechanism. `subtitles`
31
+ * (a standalone `.srt`) works identically on both paths, since it's driven by step events, not by
32
+ * the video API. Default `on=fail` keeps videos for failed tests only; `on=test` keeps every
33
+ * test's video.
29
34
  *
30
- * Note: enabling Playwright's helper-level `video: true` together with this
31
- * plugin produces two independent recordings (`output/videos/*.webm` from the
32
- * helper, `output/screencast/*.webm` from this plugin).
35
+ * Note: enabling Playwright's helper-level `video: true` together with this plugin produces two
36
+ * independent recordings (`output/videos/*.webm` from the helper, `output/screencast/*.webm` from
37
+ * this plugin).
33
38
  *
34
39
  * #### Configuration
35
40
  *
@@ -49,11 +54,11 @@ const defaultConfig = {
49
54
  *
50
55
  * Other config options:
51
56
  *
52
- * * `captions`: burn-in action overlays via `page.screencast.showActions()`. Default: true.
57
+ * * `captions`: burn-in action overlays via `page.screencast.showActions()`. Playwright only. Default: true.
53
58
  * * `subtitles`: also write a standalone `.srt` file alongside the video. Default: false.
54
59
  * * `video`: record a video. With `video=false, subtitles=true`, only the `.srt` is produced. Default: true.
55
- * * `size`: pass-through `{ width, height }` for `screencast.start`.
56
- * * `quality`: pass-through 0–100 for `screencast.start`.
60
+ * * `size`: pass-through `{ width, height }` `screencast.start`'s `size` on Playwright, `maxWidth`/`maxHeight` on the CDP path.
61
+ * * `quality`: pass-through 0–100 for `screencast.start` (Playwright) or CDP `Page.startScreencast` (CDPBrowser family).
57
62
  *
58
63
  * CLI examples:
59
64
  *
@@ -64,7 +69,7 @@ const defaultConfig = {
64
69
  * ```
65
70
  */
66
71
  export default function (config = {}) {
67
- const helper = getBrowserHelper()
72
+ const helper = getScreencastHelper()
68
73
  if (!helper) return
69
74
 
70
75
  const cliArgs = parsePluginArgs(config._args)
@@ -86,7 +91,9 @@ function wireScreencast(mode, options) {
86
91
  const state = {
87
92
  test: null,
88
93
  webmPath: null,
94
+ apngPath: null,
89
95
  srtPath: null,
96
+ kind: null,
90
97
  steps: null,
91
98
  startedAt: null,
92
99
  failed: false,
@@ -99,7 +106,9 @@ function wireScreencast(mode, options) {
99
106
  state.test = test
100
107
  state.failed = false
101
108
  state.webmPath = null
109
+ state.apngPath = null
102
110
  state.srtPath = null
111
+ state.kind = null
103
112
  state.startQueued = false
104
113
  state.started = false
105
114
  state.steps = options.subtitles ? {} : null
@@ -141,7 +150,9 @@ function wireScreencast(mode, options) {
141
150
  recorder.add('screencast:stop', async () => finalizeScreencast({
142
151
  test: state.test,
143
152
  webmPath: state.webmPath,
153
+ apngPath: state.apngPath,
144
154
  srtPath: state.srtPath,
155
+ kind: state.kind,
145
156
  steps: state.steps,
146
157
  failed: state.failed,
147
158
  started: state.started,
@@ -152,15 +163,25 @@ function wireScreencast(mode, options) {
152
163
  }
153
164
 
154
165
  async function startScreencast(test, options, state) {
155
- const helper = getBrowserHelper()
156
- if (!helper?.page?.screencast) {
157
- if (!state.warnedNoApi) {
158
- output.plugin('screencast', 'page.screencast not available — requires Playwright >= 1.59. Skipping.')
159
- state.warnedNoApi = true
160
- }
161
- return
166
+ const helper = getScreencastHelper()
167
+
168
+ if (helper?.page?.screencast) {
169
+ state.kind = 'playwright'
170
+ return startPlaywrightScreencast(helper, test, options, state)
162
171
  }
163
172
 
173
+ if (typeof helper?.startScreencast === 'function') {
174
+ state.kind = 'cdp'
175
+ return startCdpScreencast(helper, test, options, state)
176
+ }
177
+
178
+ if (!state.warnedNoApi) {
179
+ output.plugin('screencast', 'No screencast API available on the active helper — requires Playwright >= 1.59, or a CDPBrowser-family helper (CDPBrowser/Obscura/Kitesurf). Skipping.')
180
+ state.warnedNoApi = true
181
+ }
182
+ }
183
+
184
+ async function startPlaywrightScreencast(helper, test, options, state) {
164
185
  const baseDir = path.join(store.outputDir || '_output', 'screencast')
165
186
  mkdirp.sync(baseDir)
166
187
  const baseName = testToFileName(test, { suffix: '', unique: true })
@@ -192,12 +213,42 @@ async function startScreencast(test, options, state) {
192
213
  }
193
214
  }
194
215
 
216
+ async function startCdpScreencast(helper, test, options, state) {
217
+ const baseDir = path.join(store.outputDir || '_output', 'screencast')
218
+ mkdirp.sync(baseDir)
219
+ const baseName = testToFileName(test, { suffix: '', unique: true })
220
+ state.apngPath = path.join(baseDir, `${baseName}.apng`)
221
+ state.srtPath = path.join(baseDir, `${baseName}.srt`)
222
+
223
+ const startOpts = {}
224
+ if (options.size) {
225
+ startOpts.maxWidth = options.size.width
226
+ startOpts.maxHeight = options.size.height
227
+ }
228
+ if (options.quality != null) startOpts.quality = options.quality
229
+
230
+ try {
231
+ await helper.startScreencast(startOpts)
232
+ state.started = true
233
+ } catch (err) {
234
+ output.plugin('screencast', `Failed to start: ${err.message}`)
235
+ state.apngPath = null
236
+ state.srtPath = null
237
+ state.started = false
238
+ }
239
+
240
+ // captions/chapter burn-in (showActions/showChapter) is Playwright-only — CDP screencast
241
+ // frames are raw page captures with no overlay mechanism, so this is silently absent here.
242
+ }
243
+
195
244
  async function finalizeScreencast(snapshot) {
196
- const { test, options, mode, steps } = snapshot
197
- let { webmPath, srtPath } = snapshot
245
+ const { test, options, mode, steps, kind } = snapshot
246
+ let { webmPath, apngPath, srtPath } = snapshot
198
247
 
199
- const helper = getBrowserHelper()
200
- if (snapshot.started && helper?.page?.screencast) {
248
+ const helper = getScreencastHelper()
249
+ let apngBuffer = null
250
+
251
+ if (kind === 'playwright' && snapshot.started && helper?.page?.screencast) {
201
252
  try {
202
253
  await helper.page.screencast.stop()
203
254
  } catch (err) {
@@ -205,6 +256,14 @@ async function finalizeScreencast(snapshot) {
205
256
  }
206
257
  }
207
258
 
259
+ if (kind === 'cdp' && snapshot.started && typeof helper?.stopScreencast === 'function') {
260
+ try {
261
+ apngBuffer = await helper.stopScreencast()
262
+ } catch (err) {
263
+ output.plugin('screencast', `stop failed: ${err.message}`)
264
+ }
265
+ }
266
+
208
267
  const shouldKeep = mode === 'test' || (mode === 'fail' && snapshot.failed)
209
268
 
210
269
  if (options.video && webmPath) {
@@ -218,6 +277,22 @@ async function finalizeScreencast(snapshot) {
218
277
  }
219
278
  }
220
279
 
280
+ if (options.video && apngPath) {
281
+ if (!shouldKeep || !apngBuffer) {
282
+ apngPath = null
283
+ } else {
284
+ try {
285
+ await fs.promises.writeFile(apngPath, apngBuffer)
286
+ ensureArtifactsObject(test)
287
+ test.artifacts.screencast = apngPath
288
+ attachJUnitArtifact(test, apngPath)
289
+ } catch (err) {
290
+ output.plugin('screencast', `failed to write APNG: ${err.message}`)
291
+ apngPath = null
292
+ }
293
+ }
294
+ }
295
+
221
296
  if (options.subtitles && steps) {
222
297
  if (options.video && !shouldKeep) {
223
298
  try { srtPath && fs.unlinkSync(srtPath) } catch { /* nothing to delete */ }
@@ -275,8 +350,25 @@ function buildSrt(steps) {
275
350
  return out
276
351
  }
277
352
 
353
+ // `getBrowserHelper` (from `pluginParser.js`) only recognizes `Container.STANDARD_ACTING_HELPERS`
354
+ // (`Playwright`/`WebDriver`/`Puppeteer`/`Appium`), so it never finds `CDPBrowser`/`Obscura`/
355
+ // `Kitesurf`. Rather than widen that shared list — used by several other plugins with their own,
356
+ // Playwright/WebDriver-specific assumptions — this plugin does its own duck-typed fallback lookup,
357
+ // scoped to itself: any active helper exposing a `startScreencast` function qualifies, with no
358
+ // hardcoded class names, so any future CDP-family helper picks this up automatically too.
359
+ function getScreencastHelper() {
360
+ const standard = getBrowserHelper()
361
+ if (standard) return standard
362
+ const helpers = Container.helpers()
363
+ for (const name of Object.keys(helpers)) {
364
+ if (typeof helpers[name]?.startScreencast === 'function') return helpers[name]
365
+ }
366
+ return null
367
+ }
368
+
278
369
  function ensureArtifactsObject(test) {
279
- if (!test.artifacts || Array.isArray(test.artifacts)) test.artifacts = {}
370
+ if (!test.artifacts) test.artifacts = {}
371
+ else if (Array.isArray(test.artifacts)) test.artifacts = Object.assign({}, test.artifacts)
280
372
  }
281
373
 
282
374
  function attachJUnitArtifact(test, filePath) {
package/lib/step/base.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import color from 'chalk'
2
+ import { pathToFileURL } from 'url'
2
3
  import Secret from '../secret.js'
3
4
  import { getCurrentTimeout } from '../timeout.js'
4
5
  import { ucfirst, humanizeString, serializeError } from '../utils.js'
@@ -149,8 +150,6 @@ class Step {
149
150
  const lines = this.stack.split('\n')
150
151
  if (lines[STACK_LINE]) {
151
152
  let line = lines[STACK_LINE].trim()
152
- .replace(store.codeceptDir || '', '.')
153
- .trim()
154
153
 
155
154
  // Map .temp.mjs back to original .ts files using container's tsFileMapping
156
155
  const fileMapping = store.tsFileMapping
@@ -160,10 +159,23 @@ class Step {
160
159
  line = line.replace(mjsFile, tsFile)
161
160
  break
162
161
  }
162
+
163
+ const mjsFileUrl = pathToFileURL(mjsFile).href
164
+ if (line.includes(mjsFileUrl)) {
165
+ line = line.replace(mjsFileUrl, pathToFileURL(tsFile).href)
166
+ break
167
+ }
163
168
  }
164
169
  }
165
170
 
166
- return line
171
+ const codeceptDir = store.codeceptDir || ''
172
+ if (codeceptDir) {
173
+ line = line
174
+ .replace(pathToFileURL(codeceptDir).href, '.')
175
+ .replace(codeceptDir, '.')
176
+ }
177
+
178
+ return line.trim()
167
179
  }
168
180
  return ''
169
181
  }
@@ -6,10 +6,16 @@
6
6
  * Check if a TypeScript loader is available for test files
7
7
  * Note: This checks if loaders are in the require array, not if packages are installed
8
8
  * Package installation is checked when actually requiring modules
9
+ * Always true under Bun, which transpiles TypeScript itself
9
10
  * @param {string[]} requiredModules - Array of required modules from config
10
11
  * @returns {boolean}
11
12
  */
12
13
  export function checkTypeScriptLoader(requiredModules = []) {
14
+ // Bun transpiles TypeScript natively, so no loader is needed.
15
+ // Node is not treated the same way: its native type stripping rejects enums
16
+ // and does not resolve extensionless relative imports.
17
+ if (process.versions.bun) return true
18
+
13
19
  // Check if a loader is configured in the require array
14
20
  return (
15
21
  requiredModules.includes('tsx/esm') ||
package/lib/utils.js CHANGED
@@ -108,7 +108,7 @@ export const methodsOfObject = function (obj, className) {
108
108
  export const template = function (template, data) {
109
109
  return template.replace(/{{([^{}]*)}}/g, (a, b) => {
110
110
  const r = data[b]
111
- if (r === undefined) return ''
111
+ if (r === undefined || r === null) return ''
112
112
  return r.toString()
113
113
  })
114
114
  }
package/lib/workers.js CHANGED
@@ -508,10 +508,27 @@ class Workers extends EventEmitter {
508
508
  // Create workers and set up message handlers immediately (not in recorder queue)
509
509
  // This prevents a race condition where workers start sending messages before handlers are attached
510
510
  const workerThreads = []
511
+ const staggerDelay = this.codecept.config.workerInitializationDelay !== undefined
512
+ ? this.codecept.config.workerInitializationDelay
513
+ : 200
514
+
515
+ // Maximum total stagger window (default 10 seconds). Allows capping total delay regardless of worker count.
516
+ const maxStagger = this.codecept.config.workerInitializationMaxDelay ?? 10000
517
+ // Compute effective per-worker delay to keep total stagger within maxStagger.
518
+ const effectiveDelay = staggerDelay > 0
519
+ ? Math.min(staggerDelay, Math.floor(maxStagger / Math.max(1, this.workers.length - 1)))
520
+ : 0
521
+
511
522
  for (const worker of this.workers) {
512
523
  const workerThread = createWorker(worker, this.isPoolMode)
513
524
  this._listenWorkerEvents(workerThread)
514
525
  workerThreads.push(workerThread)
526
+
527
+ // Stagger worker creation to prevent CPU spikes
528
+ // from massive V8 isolate creation and naturally stagger browser init
529
+ if (this.workers.length > 1 && effectiveDelay > 0) {
530
+ await new Promise(resolve => setTimeout(resolve, effectiveDelay))
531
+ }
515
532
  }
516
533
 
517
534
  recorder.add('workers started', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeceptjs",
3
- "version": "4.1.0",
3
+ "version": "4.2.0-beta.1",
4
4
  "type": "module",
5
5
  "description": "Supercharged End 2 End Testing Framework for NodeJS",
6
6
  "keywords": [
@@ -78,6 +78,8 @@
78
78
  "test:unit:webbapi:puppeteer": "mocha test/helper/Puppeteer_test.js --reporter @testomatio/reporter/mocha",
79
79
  "test:unit:webbapi:webDriver": "mocha test/helper/WebDriver_test.js --timeout 10000 --reporter @testomatio/reporter/mocha",
80
80
  "test:unit:webbapi:webDriver:noSeleniumServer": "mocha test/helper/WebDriver.noSeleniumServer_test.js --timeout 10000 --reporter @testomatio/reporter/mocha",
81
+ "test:unit:webbapi:cdpbrowser": "mocha test/helper/CDPBrowser_chrome_test.js --timeout 30000 --reporter @testomatio/reporter/mocha",
82
+ "test:unit:webbapi:obscura": "mocha test/helper/CDPBrowser_obscura_test.js --timeout 30000 --reporter @testomatio/reporter/mocha",
81
83
  "test:unit:expect": "mocha test/helper/Expect_test.js --reporter @testomatio/reporter/mocha",
82
84
  "test:plugin": "mocha test/plugin/plugin_test.js --reporter @testomatio/reporter/mocha",
83
85
  "def": "./runok.cjs def",
@@ -134,6 +136,7 @@
134
136
  "promise-retry": "1.1.1",
135
137
  "sprintf-js": "1.1.3",
136
138
  "uuid": "11.1.0",
139
+ "ws": "^8.21.2",
137
140
  "xpath": "0.0.34",
138
141
  "zod": "^4.1.11"
139
142
  },