codeceptjs 4.1.0 → 4.2.0-beta.2
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/advanced.md +1 -1
- 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/helpers/Playwright.md +5 -2
- 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 +7 -5
- 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 +41 -10
- package/lib/helper/Puppeteer.js +30 -7
- package/lib/helper/WebDriver.js +27 -6
- package/lib/helper/clientscripts/cdpBrowserClient.js +486 -0
- package/lib/helper/clientscripts/xpathPolyfill.js +31 -0
- package/lib/helper/errors/MultipleElementsFound.js +60 -3
- package/lib/helper/extras/CDPConnection.js +92 -0
- package/lib/helper/extras/CDPElementHandle.js +27 -0
- package/lib/helper/extras/PlaywrightLocator.js +2 -2
- 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/locator.js +7 -2
- package/lib/mocha/asyncWrapper.js +7 -1
- 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/step/config.js +1 -0
- package/lib/store.js +6 -0
- package/lib/utils/loaderCheck.js +6 -0
- package/lib/utils.js +1 -1
- package/lib/workers.js +17 -0
- package/package.json +5 -2
- package/typings/promiseBasedTypes.d.ts +1835 -0
- package/typings/types.d.ts +1848 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from 'fs'
|
|
2
|
+
import { createRequire } from 'module'
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url)
|
|
5
|
+
let cached
|
|
6
|
+
|
|
7
|
+
export default function xpathPolyfillSource() {
|
|
8
|
+
if (cached) return cached
|
|
9
|
+
const engine = readFileSync(require.resolve('xpath/xpath.js'), 'utf8')
|
|
10
|
+
cached = `(function(){
|
|
11
|
+
if (window.__codeceptXPathPolyfill) return
|
|
12
|
+
window.__codeceptXPathPolyfill = true
|
|
13
|
+
var module = { exports: {} }
|
|
14
|
+
var exports = module.exports
|
|
15
|
+
${engine}
|
|
16
|
+
var parse = module.exports.parse
|
|
17
|
+
document.evaluate = function(expr, ctx, resolver, type, res) {
|
|
18
|
+
var nodes = parse(expr).select({ node: ctx || document, isHtml: true })
|
|
19
|
+
var i = 0
|
|
20
|
+
return {
|
|
21
|
+
resultType: type,
|
|
22
|
+
snapshotLength: nodes.length,
|
|
23
|
+
snapshotItem: function(idx) { return idx < nodes.length ? nodes[idx] : null },
|
|
24
|
+
iterateNext: function() { return i < nodes.length ? nodes[i++] : null },
|
|
25
|
+
singleNodeValue: nodes.length ? nodes[0] : null,
|
|
26
|
+
booleanValue: nodes.length > 0,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
})()`
|
|
30
|
+
return cached
|
|
31
|
+
}
|
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
import Locator from '../../locator.js'
|
|
2
2
|
|
|
3
|
+
export function splitXPath(xpath) {
|
|
4
|
+
if (typeof xpath !== 'string' || xpath.length === 0) return []
|
|
5
|
+
const withoutRoot = xpath.startsWith('//') ? xpath.slice(1) : xpath
|
|
6
|
+
return withoutRoot.split('/').filter(Boolean)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isAncestorXPath(ancestor, descendant) {
|
|
10
|
+
if (!ancestor || !descendant || ancestor === descendant) return false
|
|
11
|
+
const ancestorSegments = splitXPath(ancestor)
|
|
12
|
+
const descendantSegments = splitXPath(descendant)
|
|
13
|
+
if (ancestorSegments.length === 0 || ancestorSegments.length >= descendantSegments.length) return false
|
|
14
|
+
return ancestorSegments.every((segment, index) => segment === descendantSegments[index])
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function computeParents(entries) {
|
|
18
|
+
const parents = new Array(entries.length).fill(-1)
|
|
19
|
+
const stack = []
|
|
20
|
+
for (let i = 0; i < entries.length; i++) {
|
|
21
|
+
const xpath = entries[i].xpath
|
|
22
|
+
if (!xpath) continue
|
|
23
|
+
while (stack.length > 0 && !isAncestorXPath(entries[stack[stack.length - 1]].xpath, xpath)) {
|
|
24
|
+
stack.pop()
|
|
25
|
+
}
|
|
26
|
+
parents[i] = stack.length > 0 ? stack[stack.length - 1] : -1
|
|
27
|
+
stack.push(i)
|
|
28
|
+
}
|
|
29
|
+
return parents
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function computeDepths(entries) {
|
|
33
|
+
const parents = computeParents(entries)
|
|
34
|
+
return parents.map((parent, i) => {
|
|
35
|
+
if (!entries[i].xpath) return 0
|
|
36
|
+
let depth = 0
|
|
37
|
+
let current = parent
|
|
38
|
+
while (current !== -1) {
|
|
39
|
+
depth++
|
|
40
|
+
current = parents[current]
|
|
41
|
+
}
|
|
42
|
+
return depth
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatTree(entries, depths, parents) {
|
|
47
|
+
return entries.map((entry, i) => {
|
|
48
|
+
const pad = ' '.repeat(depths[i] || 0)
|
|
49
|
+
if (entry.error) {
|
|
50
|
+
return `${pad} ${entry.index}. [Unable to get element info: ${entry.error}]`
|
|
51
|
+
}
|
|
52
|
+
const parentPos = parents ? parents[i] : -1
|
|
53
|
+
const nesting = parentPos !== undefined && parentPos !== -1 ? ` (inside ${entries[parentPos].index}.)` : ''
|
|
54
|
+
return `${pad} ${entry.index}.${nesting} > ${entry.xpath}\n${pad} ${entry.html}`
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
3
58
|
class MultipleElementsFound extends Error {
|
|
4
59
|
constructor(locator, webElements) {
|
|
5
60
|
const locatorStr = (typeof locator === 'object' && !(locator instanceof Locator))
|
|
@@ -17,7 +72,7 @@ class MultipleElementsFound extends Error {
|
|
|
17
72
|
if (this._detailsFetched) return
|
|
18
73
|
|
|
19
74
|
try {
|
|
20
|
-
const
|
|
75
|
+
const entries = []
|
|
21
76
|
const maxToShow = Math.min(this.count, 10)
|
|
22
77
|
|
|
23
78
|
for (let i = 0; i < maxToShow; i++) {
|
|
@@ -25,12 +80,14 @@ class MultipleElementsFound extends Error {
|
|
|
25
80
|
try {
|
|
26
81
|
const xpath = await webEl.toAbsoluteXPath()
|
|
27
82
|
const html = await webEl.toSimplifiedHTML()
|
|
28
|
-
|
|
83
|
+
entries.push({ index: i + 1, xpath, html })
|
|
29
84
|
} catch (err) {
|
|
30
|
-
|
|
85
|
+
entries.push({ index: i + 1, error: err.message })
|
|
31
86
|
}
|
|
32
87
|
}
|
|
33
88
|
|
|
89
|
+
const items = formatTree(entries, computeDepths(entries), computeParents(entries))
|
|
90
|
+
|
|
34
91
|
if (this.count > 10) {
|
|
35
92
|
items.push(` ... and ${this.count - 10} more`)
|
|
36
93
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { WebSocket } from 'ws'
|
|
2
|
+
|
|
3
|
+
class CDPConnection {
|
|
4
|
+
constructor(endpoint, options = {}) {
|
|
5
|
+
this.endpoint = endpoint
|
|
6
|
+
this.headers = options.headers || {}
|
|
7
|
+
this.timeout = options.timeout || 10000
|
|
8
|
+
this.ws = null
|
|
9
|
+
this.lastId = 0
|
|
10
|
+
this.pending = new Map()
|
|
11
|
+
this.listeners = new Map()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async connect() {
|
|
15
|
+
await new Promise((resolve, reject) => {
|
|
16
|
+
this.ws = new WebSocket(this.endpoint, { headers: this.headers })
|
|
17
|
+
this.ws.once('open', resolve)
|
|
18
|
+
this.ws.once('error', reject)
|
|
19
|
+
})
|
|
20
|
+
this.ws.on('message', raw => {
|
|
21
|
+
try {
|
|
22
|
+
this._onMessage(JSON.parse(raw.toString()))
|
|
23
|
+
} catch (err) {
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
this.ws.on('close', () => {
|
|
27
|
+
for (const { reject, timer } of this.pending.values()) {
|
|
28
|
+
clearTimeout(timer)
|
|
29
|
+
reject(new Error('CDP connection closed'))
|
|
30
|
+
}
|
|
31
|
+
this.pending.clear()
|
|
32
|
+
})
|
|
33
|
+
return this
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get isConnected() {
|
|
37
|
+
return !!this.ws && this.ws.readyState === WebSocket.OPEN
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
send(method, params = {}, sessionId = undefined) {
|
|
41
|
+
if (!this.isConnected) {
|
|
42
|
+
return Promise.reject(new Error(`CDP connection is not open (sending ${method})`))
|
|
43
|
+
}
|
|
44
|
+
const id = ++this.lastId
|
|
45
|
+
const message = { id, method, params }
|
|
46
|
+
if (sessionId) message.sessionId = sessionId
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
this.pending.delete(id)
|
|
50
|
+
reject(new Error(`CDP command ${method} timed out after ${this.timeout}ms`))
|
|
51
|
+
}, this.timeout)
|
|
52
|
+
this.pending.set(id, { resolve, reject, timer })
|
|
53
|
+
this.ws.send(JSON.stringify(message))
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
on(method, fn) {
|
|
58
|
+
if (!this.listeners.has(method)) this.listeners.set(method, [])
|
|
59
|
+
this.listeners.get(method).push(fn)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_onMessage(msg) {
|
|
63
|
+
if (msg.id && this.pending.has(msg.id)) {
|
|
64
|
+
const { resolve, reject, timer } = this.pending.get(msg.id)
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
this.pending.delete(msg.id)
|
|
67
|
+
if (msg.error) reject(new Error(msg.error.message))
|
|
68
|
+
else resolve(msg.result)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (msg.method && this.listeners.has(msg.method)) {
|
|
72
|
+
for (const fn of this.listeners.get(msg.method)) {
|
|
73
|
+
try {
|
|
74
|
+
fn(msg.params, msg.sessionId)
|
|
75
|
+
} catch (err) {
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async close() {
|
|
82
|
+
if (!this.ws) return
|
|
83
|
+
await new Promise(resolve => {
|
|
84
|
+
if (this.ws.readyState === WebSocket.CLOSED) return resolve()
|
|
85
|
+
this.ws.once('close', resolve)
|
|
86
|
+
this.ws.close()
|
|
87
|
+
})
|
|
88
|
+
this.ws = null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default CDPConnection
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight element handle for `CDPBrowser`. Unlike Puppeteer/WebDriver, `CDPBrowser` never
|
|
3
|
+
* keeps a persistent handle to a DOM node on the Node side — every action re-resolves candidates
|
|
4
|
+
* in-page via `_run`/`_runSelected`. This class stores the candidates and the 1-based index of one
|
|
5
|
+
* specific element within that candidate set, and re-resolves it on demand. It exists to give
|
|
6
|
+
* `grabWebElement(s)` and `MultipleElementsFound.fetchDetails()` something to call
|
|
7
|
+
* `toAbsoluteXPath()`/`toOuterHTML()` on, wrapped by `lib/element/WebElement.js`.
|
|
8
|
+
*/
|
|
9
|
+
class CDPElementHandle {
|
|
10
|
+
constructor(helper, candidates, index) {
|
|
11
|
+
this.helper = helper
|
|
12
|
+
this.candidates = candidates
|
|
13
|
+
this.index = index
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async outerHTML() {
|
|
17
|
+
const res = await this.helper._runSelected(this.candidates, 'outerHTML', null, { index: this.index })
|
|
18
|
+
return res.result
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async absoluteXPath() {
|
|
22
|
+
const res = await this.helper._runSelected(this.candidates, 'absoluteXPath', null, { index: this.index })
|
|
23
|
+
return res.result
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default CDPElementHandle
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
function findByPlaywrightLocator(matcher, locator) {
|
|
2
2
|
const pwLocator = locator.locator || locator
|
|
3
3
|
if (pwLocator && pwLocator.toString && pwLocator.toString().includes(process.env.testIdAttribute)) {
|
|
4
4
|
return matcher.getByTestId(pwLocator.pw.value.split('=')[1])
|
|
5
5
|
}
|
|
6
6
|
const pwValue = typeof pwLocator.pw === 'string' ? pwLocator.pw : pwLocator.pw
|
|
7
|
-
return matcher.locator(pwValue)
|
|
7
|
+
return matcher.locator(pwValue)
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export { findByPlaywrightLocator }
|
|
@@ -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/locator.js
CHANGED
|
@@ -649,6 +649,9 @@ Locator.field = {
|
|
|
649
649
|
]),
|
|
650
650
|
}
|
|
651
651
|
|
|
652
|
+
const checkable = `self::input[@type = 'checkbox' or @type = 'radio'] or @role = 'checkbox' or @role = 'radio' or @role = 'switch'`
|
|
653
|
+
const visibleCheckable = `.//*[${checkable}][not(@aria-hidden = 'true')]`
|
|
654
|
+
|
|
652
655
|
Locator.checkable = {
|
|
653
656
|
/**
|
|
654
657
|
* @param {string} literal
|
|
@@ -656,8 +659,10 @@ Locator.checkable = {
|
|
|
656
659
|
*/
|
|
657
660
|
byText: literal =>
|
|
658
661
|
xpathLocator.combine([
|
|
659
|
-
|
|
660
|
-
`.//label[contains(normalize-space(string(.)), ${literal})]
|
|
662
|
+
`${visibleCheckable}[(@id = //label[@for][contains(normalize-space(string(.)), ${literal})]/@for) or @placeholder = ${literal}]`,
|
|
663
|
+
`.//label[contains(normalize-space(string(.)), ${literal})]//*[${checkable}][not(@aria-hidden = 'true')]`,
|
|
664
|
+
`${visibleCheckable}[@aria-labelledby = //*[@id][contains(normalize-space(string(.)), ${literal})]/@id]`,
|
|
665
|
+
`${visibleCheckable}[@aria-label = ${literal}]`,
|
|
661
666
|
]),
|
|
662
667
|
|
|
663
668
|
/**
|
|
@@ -4,7 +4,7 @@ import recorder from '../recorder.js'
|
|
|
4
4
|
import assertThrown from '../assert/throws.js'
|
|
5
5
|
import { ucfirst, isAsyncFunction } from '../utils.js'
|
|
6
6
|
import { getInjectedArguments } from './inject.js'
|
|
7
|
-
import { fireHook } from './hooks.js'
|
|
7
|
+
import { fireHook, BeforeSuiteHook, AfterSuiteHook } from './hooks.js'
|
|
8
8
|
|
|
9
9
|
const injectHook = function (inject, suite) {
|
|
10
10
|
try {
|
|
@@ -232,6 +232,10 @@ export function suiteSetup(suite) {
|
|
|
232
232
|
|
|
233
233
|
// Set up error handler for suite setup
|
|
234
234
|
recorder.errHandler(err => {
|
|
235
|
+
// A helper's `_beforeSuite()` runs through this hook, not through the
|
|
236
|
+
// `injected()` wrapper, so nothing here used to emit `hook.failed` and
|
|
237
|
+
// reporters listening for it never saw the failure. (#5660)
|
|
238
|
+
event.emit(event.hook.failed, new BeforeSuiteHook(suite, err))
|
|
235
239
|
doneFn(err)
|
|
236
240
|
})
|
|
237
241
|
|
|
@@ -254,6 +258,8 @@ export function suiteTeardown(suite) {
|
|
|
254
258
|
|
|
255
259
|
// Set up error handler for suite teardown
|
|
256
260
|
recorder.errHandler(err => {
|
|
261
|
+
// Same for a helper's `_afterSuite()`. (#5660)
|
|
262
|
+
event.emit(event.hook.failed, new AfterSuiteHook(suite, err))
|
|
257
263
|
doneFn(err)
|
|
258
264
|
})
|
|
259
265
|
|
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',
|