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,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
|