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.
- package/docs/alternative-browsers.md +153 -0
- package/docs/basics.md +9 -1
- package/docs/configuration.md +2 -0
- package/docs/helpers/CDPBrowser.md +2138 -0
- package/docs/helpers/Kitesurf.md +118 -0
- package/docs/helpers/Obscura.md +210 -0
- package/docs/migration-4.md +3 -1
- package/docs/parallel.md +10 -0
- package/docs/plugins/screencast.md +18 -13
- package/docs/plugins.md +1 -1
- package/lib/command/info.js +11 -3
- package/lib/command/workers/runTests.js +14 -20
- package/lib/container.js +6 -0
- package/lib/data/context.js +4 -0
- package/lib/element/WebElement.js +5 -0
- package/lib/helper/Appium.js +14 -2
- package/lib/helper/CDPBrowser.js +3004 -0
- package/lib/helper/Kitesurf.js +139 -0
- package/lib/helper/Obscura.js +344 -0
- package/lib/helper/Playwright.js +30 -3
- package/lib/helper/Puppeteer.js +43 -16
- package/lib/helper/WebDriver.js +43 -8
- package/lib/helper/clientscripts/cdpBrowserClient.js +486 -0
- package/lib/helper/clientscripts/xpathPolyfill.js +31 -0
- package/lib/helper/extras/CDPConnection.js +92 -0
- package/lib/helper/extras/CDPElementHandle.js +27 -0
- package/lib/helper/extras/apngAssembler.js +156 -0
- package/lib/html.js +9 -2
- package/lib/listener/retryEnhancer.js +2 -1
- package/lib/listener/steps.js +8 -0
- package/lib/mocha/hooks.js +10 -0
- package/lib/parser.js +14 -2
- package/lib/plugin/junitReporter.js +17 -1
- package/lib/plugin/screencast.js +116 -24
- package/lib/step/base.js +15 -3
- package/lib/utils/loaderCheck.js +6 -0
- package/lib/utils.js +1 -1
- package/lib/workers.js +17 -0
- package/package.json +4 -1
- package/typings/promiseBasedTypes.d.ts +1833 -0
- 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
|
-
|
|
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) {
|
package/lib/listener/steps.js
CHANGED
|
@@ -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
|
})
|
package/lib/mocha/hooks.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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',
|
package/lib/plugin/screencast.js
CHANGED
|
@@ -20,16 +20,21 @@ const defaultConfig = {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Records
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
32
|
-
*
|
|
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 }`
|
|
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 =
|
|
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 =
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 =
|
|
200
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/lib/utils/loaderCheck.js
CHANGED
|
@@ -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.
|
|
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
|
},
|