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,486 @@
1
+ export default function installCodeceptClient(xpathNeedsPolyfill) {
2
+ if (window.__codecept) return
3
+ const strategies = {
4
+ css: (value, root) => Array.from((root || document).querySelectorAll(value)),
5
+ xpath: (value, root) => {
6
+ const out = []
7
+ const res = document.evaluate(value, root || document.body || document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
8
+ for (let i = 0; i < res.snapshotLength; i++) out.push(res.snapshotItem(i))
9
+ return out
10
+ },
11
+ role: (value, root) => {
12
+ let matches = Array.from((root || document).querySelectorAll('*')).filter(el => resolveRole(el) === value.role)
13
+ if (value.text) {
14
+ const matchFn = value.exact ? t => t === value.text : t => t.indexOf(value.text) !== -1
15
+ matches = matches.filter(el => roleTextCandidates(el).some(matchFn))
16
+ }
17
+ return matches
18
+ },
19
+ shadow: (path, root) => {
20
+ let scope = root || document
21
+ for (let i = 0; i < path.length; i++) {
22
+ if (i === path.length - 1) return Array.from(scope.querySelectorAll(path[i]))
23
+ const host = scope.querySelector(path[i])
24
+ if (!host || !host.shadowRoot) return []
25
+ scope = host.shadowRoot
26
+ }
27
+ return []
28
+ },
29
+ }
30
+ const find = (candidates, root) => {
31
+ for (const c of candidates) {
32
+ let els = []
33
+ try {
34
+ els = strategies[c.type](c.value, root)
35
+ } catch (e) {
36
+ els = []
37
+ }
38
+ if (els.length) return els
39
+ }
40
+ return []
41
+ }
42
+ const SKIP_TEXT_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEMPLATE: 1 }
43
+ const BLOCK_TEXT_TAGS = { DIV: 1, P: 1, BR: 1, LI: 1, TR: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1, SECTION: 1, ARTICLE: 1, UL: 1, OL: 1, TABLE: 1, FORM: 1 }
44
+ const visibleText = root => {
45
+ const out = []
46
+ const walk = node => {
47
+ if (node.nodeType === 3) {
48
+ out.push(node.nodeValue)
49
+ return
50
+ }
51
+ if (node.nodeType !== 1 || SKIP_TEXT_TAGS[node.tagName]) return
52
+ const style = getComputedStyle(node)
53
+ if (style.display === 'none' || style.visibility === 'hidden') return
54
+ for (const child of node.childNodes) walk(child)
55
+ if (BLOCK_TEXT_TAGS[node.tagName]) out.push('\n')
56
+ }
57
+ // Walk the root's children directly, rather than calling walk(root) itself: a block-level
58
+ // root (e.g. an <h1> passed as the target element for grabTextFrom) must not get its own
59
+ // trailing block-separator newline appended, since that's only meant to separate it from a
60
+ // following sibling, which doesn't exist from the root's own point of view. The root's own
61
+ // visibility is still honored first, matching walk()'s check for every other node.
62
+ const start = root || document.body
63
+ if (start.nodeType === 1) {
64
+ const rootStyle = getComputedStyle(start)
65
+ if (rootStyle.display === 'none' || rootStyle.visibility === 'hidden') return ''
66
+ }
67
+ for (const child of start.childNodes) walk(child)
68
+ return out.join('')
69
+ }
70
+ const containsText = (needle, opts) => {
71
+ opts = opts || {}
72
+ const norm = s => s.replace(/\s+/g, ' ')
73
+ const raw = document.body ? (opts.useWalker ? visibleText(document.body) : document.body.innerText) : ''
74
+ let hay = norm(raw || '')
75
+ let needleNorm = norm(needle)
76
+ const hayCmp = opts.ignoreCase ? hay.toLowerCase() : hay
77
+ const needleCmp = opts.ignoreCase ? needleNorm.toLowerCase() : needleNorm
78
+ const idx = hayCmp.indexOf(needleCmp)
79
+ if (idx === -1) return { found: false, snippet: null }
80
+ const from = Math.max(0, idx - 60)
81
+ return { found: true, snippet: hay.slice(from, idx + needleNorm.length + 60) }
82
+ }
83
+ const IMPLICIT_ROLES = {
84
+ BUTTON: () => 'button',
85
+ A: el => (el.hasAttribute('href') ? 'link' : null),
86
+ AREA: el => (el.hasAttribute('href') ? 'link' : null),
87
+ INPUT: el => {
88
+ const type = (el.getAttribute('type') || 'text').toLowerCase()
89
+ switch (type) {
90
+ case 'checkbox':
91
+ return 'checkbox'
92
+ case 'radio':
93
+ return 'radio'
94
+ case 'button':
95
+ case 'submit':
96
+ case 'reset':
97
+ case 'image':
98
+ return 'button'
99
+ case 'search':
100
+ return 'searchbox'
101
+ case 'number':
102
+ return 'spinbutton'
103
+ case 'range':
104
+ return 'slider'
105
+ case 'email':
106
+ case 'tel':
107
+ case 'text':
108
+ case 'url':
109
+ case '':
110
+ return 'textbox'
111
+ default:
112
+ return null
113
+ }
114
+ },
115
+ SELECT: el => (el.multiple || el.hasAttribute('multiple') || (el.size && el.size > 1) ? 'listbox' : 'combobox'),
116
+ TEXTAREA: () => 'textbox',
117
+ H1: () => 'heading',
118
+ H2: () => 'heading',
119
+ H3: () => 'heading',
120
+ H4: () => 'heading',
121
+ H5: () => 'heading',
122
+ H6: () => 'heading',
123
+ IMG: el => (el.hasAttribute('alt') ? 'img' : null),
124
+ NAV: () => 'navigation',
125
+ UL: () => 'list',
126
+ OL: () => 'list',
127
+ LI: () => 'listitem',
128
+ TABLE: () => 'table',
129
+ FORM: () => 'form',
130
+ OPTION: () => 'option',
131
+ }
132
+ const resolveRole = el => {
133
+ const explicit = el.getAttribute && el.getAttribute('role')
134
+ if (explicit) return explicit.trim().split(/\s+/)[0]
135
+ const fn = IMPLICIT_ROLES[el.tagName]
136
+ return fn ? fn(el) : null
137
+ }
138
+ const roleTextCandidates = el => {
139
+ const out = []
140
+ const labelledBy = el.getAttribute && el.getAttribute('aria-labelledby')
141
+ if (labelledBy) {
142
+ labelledBy.split(/\s+/).forEach(id => {
143
+ const ref = document.getElementById(id)
144
+ if (ref) out.push(visibleText(ref))
145
+ })
146
+ }
147
+ const ariaLabel = el.getAttribute && el.getAttribute('aria-label')
148
+ if (ariaLabel) out.push(ariaLabel)
149
+ if (el.id) {
150
+ document.querySelectorAll('label[for]').forEach(l => {
151
+ if (l.getAttribute('for') === el.id) out.push(visibleText(l))
152
+ })
153
+ }
154
+ const wrappingLabel = el.closest && el.closest('label')
155
+ if (wrappingLabel) out.push(visibleText(wrappingLabel))
156
+ if ('value' in el && el.value) out.push(String(el.value))
157
+ const placeholder = el.getAttribute && el.getAttribute('placeholder')
158
+ if (placeholder) out.push(placeholder)
159
+ out.push(visibleText(el))
160
+ const alt = el.getAttribute && el.getAttribute('alt')
161
+ if (alt) out.push(alt)
162
+ const title = el.getAttribute && el.getAttribute('title')
163
+ if (title) out.push(title)
164
+ return out.map(t => (t || '').trim()).filter(Boolean)
165
+ }
166
+ const fire = (el, type) => el.dispatchEvent(new Event(type, { bubbles: true }))
167
+ const isEditable = el => el.isContentEditable === true || el.getAttribute('contenteditable') === 'true'
168
+ const getVal = el => (isEditable(el) ? el.textContent : el.value)
169
+ const selectAllContents = el => {
170
+ if (typeof el.setSelectionRange === 'function' && 'value' in el) {
171
+ try {
172
+ el.setSelectionRange(0, el.value.length)
173
+ return
174
+ } catch (e) {
175
+ // some input types (e.g. number/email) don't support setSelectionRange — fall through
176
+ }
177
+ }
178
+ const sel = window.getSelection && window.getSelection()
179
+ if (!sel) return
180
+ const range = document.createRange()
181
+ range.selectNodeContents(el)
182
+ sel.removeAllRanges()
183
+ sel.addRange(range)
184
+ }
185
+ const dispatchInputEvent = (el, type, data) => {
186
+ const Ctor = typeof InputEvent === 'function' ? InputEvent : Event
187
+ el.dispatchEvent(new Ctor(type, { inputType: 'insertText', data, bubbles: true, cancelable: true }))
188
+ }
189
+ // Fills a contenteditable or textarea/input host with input-event fidelity, since a blunt
190
+ // `.textContent =`/`.value =` assignment bypasses the events rich text editors (ProseMirror,
191
+ // Quill, CKEditor, ...) and widgets that use a hidden textarea as their live input-capture
192
+ // surface (Monaco, ...) actually listen to. Tries the browser's real editing command first
193
+ // (fires a genuine, trusted `input` event through the native editing pipeline on engines that
194
+ // implement it), verifying it actually landed rather than trusting its return value; if that's
195
+ // unavailable or didn't land, falls back to a synthetic `beforeinput` + direct DOM mutation +
196
+ // `input` sequence, relying on MutationObserver-based reconciliation for editors that adopt
197
+ // external DOM changes.
198
+ const fillEditable = (el, text) => {
199
+ const editable = isEditable(el)
200
+ el.focus()
201
+ selectAllContents(el)
202
+ let landed = false
203
+ try {
204
+ if (document.execCommand('insertText', false, text)) {
205
+ // A real editing command can normalize whitespace/newlines (e.g. `\n` becoming a `<div>`
206
+ // line break), so strict string equality is the wrong bar — this only needs to confirm
207
+ // *some* real insertion happened, not byte-for-byte equality.
208
+ const current = editable ? el.textContent : el.value
209
+ landed = text.length === 0 ? current === '' : current.length > 0
210
+ }
211
+ } catch (e) {
212
+ landed = false
213
+ }
214
+ if (!landed) {
215
+ selectAllContents(el)
216
+ dispatchInputEvent(el, 'beforeinput', text)
217
+ if (editable) el.textContent = text
218
+ else el.value = text
219
+ dispatchInputEvent(el, 'input', text)
220
+ }
221
+ fire(el, 'change')
222
+ }
223
+ const setVal = (el, v) => {
224
+ if (isEditable(el)) el.textContent = v
225
+ else el.value = v
226
+ }
227
+ // A `fillField` locator often resolves to a wrapper element (e.g. `<div id="editor">`) rather
228
+ // than the actual editable surface a rich text editor library mounts inside it (a nested
229
+ // contenteditable div, or a hidden `<textarea>` it keeps in sync) — generic, shape-based, no
230
+ // editor names: prefer the container itself if it already qualifies, else the first editable or
231
+ // textarea descendant, else fall back to the container so plain fields are unaffected. `nested`
232
+ // marks a descendant found this way (as opposed to a locator that matched a real form field
233
+ // directly), since only that case needs the extra select-all/execCommand fidelity in `fill` —
234
+ // every direct, ordinary field locator elsewhere keeps the plain, unaffected value-set path.
235
+ const findFillTarget = container => {
236
+ if (isEditable(container) || container.tagName === 'TEXTAREA' || container.tagName === 'INPUT' || container.tagName === 'SELECT') {
237
+ return { el: container, editable: isEditable(container), nested: false }
238
+ }
239
+ const descendants = container.querySelectorAll ? container.querySelectorAll('*') : []
240
+ for (const child of descendants) {
241
+ if (isEditable(child)) return { el: child, editable: true, nested: true }
242
+ }
243
+ for (const child of descendants) {
244
+ if (child.tagName === 'TEXTAREA') return { el: child, editable: false, nested: true }
245
+ }
246
+ return { el: container, editable: false, nested: false }
247
+ }
248
+ const isAriaCheckable = el => el.getAttribute && el.getAttribute('aria-checked') != null
249
+ const isChecked = el => (isAriaCheckable(el) ? el.getAttribute('aria-checked') === 'true' : el.checked === true)
250
+ const focusLabelledControl = label => {
251
+ let control = null
252
+ if (label.htmlFor) control = document.getElementById(label.htmlFor)
253
+ if (!control) control = label.querySelector('input, select, textarea, button, [contenteditable="true"]')
254
+ if (control && !control.disabled && control.focus) control.focus()
255
+ }
256
+ const setChecked = (el, value) => {
257
+ if (isAriaCheckable(el)) {
258
+ if (isChecked(el) !== value) el.click()
259
+ if (isChecked(el) !== value) el.setAttribute('aria-checked', String(value))
260
+ return isChecked(el) === value
261
+ }
262
+ if (el.checked !== value) el.click()
263
+ if (el.checked !== value) {
264
+ el.checked = value
265
+ fire(el, 'change')
266
+ }
267
+ return el.checked === value
268
+ }
269
+ const actions = {
270
+ count: els => els.length,
271
+ texts: (els, p) => els.map(el => (p && p.visible ? visibleText(el) : el.innerText !== undefined ? String(el.innerText) : String(el.textContent))),
272
+ values: els => els.map(el => String(getVal(el))),
273
+ attrs: (els, p) => els.map(el => el.getAttribute(p.name)),
274
+ html: els => els.map(el => el.outerHTML),
275
+ innerHtml: els => els.map(el => el.innerHTML),
276
+ outerHTML: els => (els[0] ? els[0].outerHTML : null),
277
+ absoluteXPath: els => {
278
+ const el = els[0]
279
+ if (!el) return null
280
+ const parts = []
281
+ let current = el
282
+ while (current && current.nodeType === 1) {
283
+ let index = 0
284
+ let sibling = current.previousSibling
285
+ while (sibling) {
286
+ if (sibling.nodeType === 1 && sibling.tagName === current.tagName) index++
287
+ sibling = sibling.previousSibling
288
+ }
289
+ const tagName = current.tagName.toLowerCase()
290
+ parts.unshift(index > 0 ? `${tagName}[${index + 1}]` : tagName)
291
+ current = current.parentElement
292
+ }
293
+ return `//${parts.join('/')}`
294
+ },
295
+ attrsMap: (els, p) => els.map(el => {
296
+ const out = {}
297
+ for (const attr of p.attrs) out[attr] = el[attr] || el.getAttribute(attr)
298
+ return out
299
+ }),
300
+ cssProps: (els, p) => els.map(el => {
301
+ const cs = getComputedStyle(el)
302
+ const o = {}
303
+ for (const k of p.props) o[k] = cs[k]
304
+ return o
305
+ }),
306
+ dblclick: els => {
307
+ els[0].dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
308
+ return true
309
+ },
310
+ rightclick: els => {
311
+ els[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 }))
312
+ return true
313
+ },
314
+ focus: els => {
315
+ els[0].focus()
316
+ return true
317
+ },
318
+ blur: els => {
319
+ els[0].blur()
320
+ return true
321
+ },
322
+ rect: els => {
323
+ if (els[0].scrollIntoView) els[0].scrollIntoView({ block: 'center', inline: 'center' })
324
+ const r = els[0].getBoundingClientRect()
325
+ return { x: r.x, y: r.y, width: r.width, height: r.height }
326
+ },
327
+ click: els => {
328
+ const el = els[0]
329
+ el.click()
330
+ if (el.tagName === 'LABEL') focusLabelledControl(el)
331
+ return true
332
+ },
333
+ fill: (els, p) => {
334
+ const target = findFillTarget(els[0])
335
+ const el = target.el
336
+ if (target.editable || (target.nested && el.tagName === 'TEXTAREA')) {
337
+ fillEditable(el, p.value)
338
+ return true
339
+ }
340
+ if (el.focus) el.focus()
341
+ setVal(el, p.value)
342
+ fire(el, 'input')
343
+ fire(el, 'change')
344
+ return true
345
+ },
346
+ append: (els, p) => {
347
+ const el = els[0]
348
+ setVal(el, String(getVal(el) || '') + p.value)
349
+ fire(el, 'input')
350
+ fire(el, 'change')
351
+ return true
352
+ },
353
+ clear: els => {
354
+ const el = els[0]
355
+ setVal(el, '')
356
+ fire(el, 'input')
357
+ fire(el, 'change')
358
+ return true
359
+ },
360
+ check: els => setChecked(els[0], true),
361
+ uncheck: els => setChecked(els[0], false),
362
+ select: (els, p) => {
363
+ const el = els[0]
364
+ const values = Array.isArray(p.value) ? p.value : [p.value]
365
+ const matches = (val, text) => values.includes(val) || values.includes(text)
366
+
367
+ if (el.tagName === 'SELECT') {
368
+ const opts = Array.from(el.options || [])
369
+ let found = false
370
+ if (el.multiple || el.hasAttribute('multiple')) {
371
+ opts.forEach(o => {
372
+ const match = matches(o.value, o.textContent.trim())
373
+ o.selected = match
374
+ if (match) found = true
375
+ })
376
+ } else {
377
+ const opt = opts.find(o => matches(o.value, o.textContent.trim()))
378
+ if (opt) {
379
+ el.value = opt.value
380
+ found = true
381
+ }
382
+ }
383
+ if (!found) return false
384
+ opts.forEach(o => (o.selected ? o.setAttribute('selected', 'selected') : o.removeAttribute('selected')))
385
+ fire(el, 'input')
386
+ fire(el, 'change')
387
+ return true
388
+ }
389
+
390
+ // ARIA combobox/listbox widgets: click the trigger (if any) to reveal the
391
+ // listbox, then click each matching [role="option"].
392
+ let container = el
393
+ if (el.getAttribute && el.getAttribute('role') === 'combobox') {
394
+ el.click()
395
+ container = (el.parentElement && el.parentElement.querySelector('[role="listbox"]')) || el.parentElement
396
+ }
397
+ if (!container) return false
398
+ const options = Array.from(container.querySelectorAll('[role="option"]'))
399
+ let found = false
400
+ values.forEach(v => {
401
+ const opt = options.find(o => (o.dataset && o.dataset.value === v) || o.textContent.trim() === v)
402
+ if (opt) {
403
+ opt.click()
404
+ found = true
405
+ }
406
+ })
407
+ return found
408
+ },
409
+ checked: els => isChecked(els[0]),
410
+ mark: (els, p) => {
411
+ const el = els[0]
412
+ if (!el) return null
413
+ el.setAttribute(p.attr, '1')
414
+ return { isFileInput: el.tagName === 'INPUT' && (el.getAttribute('type') || '').toLowerCase() === 'file' }
415
+ },
416
+ unmark: (els, p) => {
417
+ if (els[0]) els[0].removeAttribute(p.attr)
418
+ return true
419
+ },
420
+ dropFile: (els, p) => {
421
+ const el = els[0]
422
+ if (!el) return false
423
+ const binaryStr = atob(p.base64Content)
424
+ const bytes = new Uint8Array(binaryStr.length)
425
+ for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
426
+ const fileObj = new File([bytes], p.fileName, { type: p.mimeType })
427
+ // Duck-typed dataTransfer on a plain Event, rather than real DataTransfer/DragEvent
428
+ // instances: dropzone handlers only ever read e.dataTransfer.files, and this avoids
429
+ // depending on DragEvent/DataTransfer constructors some engines don't implement.
430
+ const files = [fileObj]
431
+ const dataTransfer = { files, items: { add() {} }, types: ['Files'], getData: () => '', setData: () => {} }
432
+ const dispatch = type => {
433
+ const ev = new Event(type, { bubbles: true, cancelable: true })
434
+ Object.defineProperty(ev, 'dataTransfer', { value: dataTransfer })
435
+ el.dispatchEvent(ev)
436
+ }
437
+ dispatch('dragenter')
438
+ dispatch('dragover')
439
+ dispatch('drop')
440
+ return true
441
+ },
442
+ visibleCount: els => els.filter(el => {
443
+ const r = el.getBoundingClientRect()
444
+ const style = getComputedStyle(el)
445
+ return r.width > 0 && r.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
446
+ }).length,
447
+ }
448
+ const candidatesNeedXPath = (candidates, within) => {
449
+ const hasXPath = arr => Array.isArray(arr) && arr.some(c => c && c.type === 'xpath')
450
+ if (hasXPath(candidates)) return true
451
+ if (!within) return false
452
+ const layers = Array.isArray(within[0]) ? within : [within]
453
+ return layers.some(hasXPath)
454
+ }
455
+ window.__codecept = {
456
+ run(candidates, action, payload, within, selection) {
457
+ if (window.__codecept.xpathNeedsPolyfill && !window.__codeceptXPathPolyfill && candidatesNeedXPath(candidates, within)) {
458
+ return '__NO_XPATH__'
459
+ }
460
+ let root
461
+ if (within) {
462
+ const layers = Array.isArray(within[0]) ? within : [within]
463
+ for (const layer of layers) {
464
+ const scopeEls = find(layer, root)
465
+ if (!scopeEls.length) return { found: 0, withinMissing: true }
466
+ root = scopeEls[0]
467
+ }
468
+ }
469
+ let els = candidates === null ? [root] : find(candidates, root)
470
+ if (selection && els.length > 1) {
471
+ if (selection.index != null) {
472
+ const idx = selection.index > 0 ? selection.index - 1 : els.length + selection.index
473
+ if (idx < 0 || idx >= els.length) return { found: els.length, outOfBounds: true, requestedIndex: selection.index }
474
+ els = [els[idx]]
475
+ } else if (selection.strict) {
476
+ return { found: els.length, strictViolation: true }
477
+ }
478
+ }
479
+ if (!els.length && action !== 'count' && action !== 'visibleCount') return { found: 0 }
480
+ return { found: els.length, result: actions[action](els, payload || {}) }
481
+ },
482
+ visibleText,
483
+ containsText,
484
+ xpathNeedsPolyfill: !!xpathNeedsPolyfill,
485
+ }
486
+ }
@@ -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
+ }
@@ -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