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.
Files changed (49) hide show
  1. package/docs/advanced.md +1 -1
  2. package/docs/alternative-browsers.md +153 -0
  3. package/docs/basics.md +9 -1
  4. package/docs/configuration.md +2 -0
  5. package/docs/helpers/CDPBrowser.md +2138 -0
  6. package/docs/helpers/Kitesurf.md +118 -0
  7. package/docs/helpers/Obscura.md +210 -0
  8. package/docs/helpers/Playwright.md +5 -2
  9. package/docs/migration-4.md +3 -1
  10. package/docs/parallel.md +10 -0
  11. package/docs/plugins/screencast.md +18 -13
  12. package/docs/plugins.md +1 -1
  13. package/lib/command/info.js +11 -3
  14. package/lib/command/workers/runTests.js +14 -20
  15. package/lib/container.js +6 -0
  16. package/lib/data/context.js +7 -5
  17. package/lib/element/WebElement.js +5 -0
  18. package/lib/helper/Appium.js +14 -2
  19. package/lib/helper/CDPBrowser.js +3004 -0
  20. package/lib/helper/Kitesurf.js +139 -0
  21. package/lib/helper/Obscura.js +344 -0
  22. package/lib/helper/Playwright.js +41 -10
  23. package/lib/helper/Puppeteer.js +30 -7
  24. package/lib/helper/WebDriver.js +27 -6
  25. package/lib/helper/clientscripts/cdpBrowserClient.js +486 -0
  26. package/lib/helper/clientscripts/xpathPolyfill.js +31 -0
  27. package/lib/helper/errors/MultipleElementsFound.js +60 -3
  28. package/lib/helper/extras/CDPConnection.js +92 -0
  29. package/lib/helper/extras/CDPElementHandle.js +27 -0
  30. package/lib/helper/extras/PlaywrightLocator.js +2 -2
  31. package/lib/helper/extras/apngAssembler.js +156 -0
  32. package/lib/html.js +9 -2
  33. package/lib/listener/retryEnhancer.js +2 -1
  34. package/lib/listener/steps.js +8 -0
  35. package/lib/locator.js +7 -2
  36. package/lib/mocha/asyncWrapper.js +7 -1
  37. package/lib/mocha/hooks.js +10 -0
  38. package/lib/parser.js +14 -2
  39. package/lib/plugin/junitReporter.js +17 -1
  40. package/lib/plugin/screencast.js +116 -24
  41. package/lib/step/base.js +15 -3
  42. package/lib/step/config.js +1 -0
  43. package/lib/store.js +6 -0
  44. package/lib/utils/loaderCheck.js +6 -0
  45. package/lib/utils.js +1 -1
  46. package/lib/workers.js +17 -0
  47. package/package.json +5 -2
  48. package/typings/promiseBasedTypes.d.ts +1835 -0
  49. package/typings/types.d.ts +1848 -0
@@ -560,7 +560,7 @@ class Puppeteer extends Helper {
560
560
  page.setDefaultNavigationTimeout(this.options.getPageTimeout)
561
561
  this.context = await this.page.$('body')
562
562
  if (this.options.browser === 'chrome') {
563
- await page.bringToFront()
563
+ await page.bringToFront().catch(err => this.debugSection('Page', `bringToFront not supported: ${err.message}`))
564
564
  }
565
565
  }
566
566
 
@@ -1714,6 +1714,10 @@ class Puppeteer extends Helper {
1714
1714
  els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
1715
1715
  if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
1716
1716
 
1717
+ // Fuzzy: try radiogroup
1718
+ els = await findByRole(comboboxSearchCtx, { role: 'radiogroup', name: matchedLocator.value })
1719
+ if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
1720
+
1717
1721
  // Fuzzy: try native select
1718
1722
  const visibleEls = await findVisibleFields.call(this, select, context)
1719
1723
  assertElementExists(visibleEls, select, 'Selectable field')
@@ -1991,7 +1995,7 @@ class Puppeteer extends Helper {
1991
1995
  const els = await this._locate(locator)
1992
1996
  const texts = []
1993
1997
  for (const el of els) {
1994
- texts.push(await (await el.getProperty('innerText')).jsonValue())
1998
+ texts.push(await el.evaluate(node => node.innerText))
1995
1999
  }
1996
2000
  return texts
1997
2001
  }
@@ -2501,7 +2505,6 @@ class Puppeteer extends Helper {
2501
2505
  */
2502
2506
  async waitInUrl(urlPart, sec = null) {
2503
2507
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
2504
- const expectedUrl = resolveUrl(urlPart, this.options.url)
2505
2508
 
2506
2509
  return this.page
2507
2510
  .waitForFunction(
@@ -2510,12 +2513,12 @@ class Puppeteer extends Helper {
2510
2513
  return currUrl.indexOf(urlPart) > -1
2511
2514
  },
2512
2515
  { timeout: waitTimeout },
2513
- expectedUrl,
2516
+ urlPart,
2514
2517
  )
2515
2518
  .catch(async e => {
2516
2519
  const currUrl = await this._getPageUrl()
2517
2520
  if (/Waiting failed:/i.test(e.message) || /failed: timeout/i.test(e.message)) {
2518
- throw new Error(`expected url to include ${expectedUrl}, but found ${currUrl}`)
2521
+ throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`)
2519
2522
  } else {
2520
2523
  throw e
2521
2524
  }
@@ -3159,14 +3162,14 @@ async function proceedSee(assertType, text, context, strict = false) {
3159
3162
  el = await this.context.$('body')
3160
3163
  }
3161
3164
 
3162
- allText = [await el.getProperty('innerText').then(p => p.jsonValue())]
3165
+ allText = [await el.evaluate(node => node.innerText)]
3163
3166
  description = 'web application'
3164
3167
  } else {
3165
3168
  const locator = new Locator(context, 'css')
3166
3169
  description = `element ${locator.toString()}`
3167
3170
  const els = await this._locate(locator)
3168
3171
  assertElementExists(els, locator.toString())
3169
- allText = await Promise.all(els.map(el => el.getProperty('innerText').then(p => p.jsonValue())))
3172
+ allText = await Promise.all(els.map(el => el.evaluate(node => node.innerText)))
3170
3173
  }
3171
3174
 
3172
3175
  if (store?.currentStep?.opts?.ignoreCase === true) {
@@ -3456,6 +3459,14 @@ async function targetCreatedHandler(page) {
3456
3459
  .catch(() => null)
3457
3460
  .then(context => (this.context = context))
3458
3461
  })
3462
+ page.on('framenavigated', frame => {
3463
+ if (frame.parentFrame()) return
3464
+ if (this.withinLocator) return
3465
+ page
3466
+ .$('body')
3467
+ .catch(() => null)
3468
+ .then(context => (this.context = context))
3469
+ })
3459
3470
  page.on('console', msg => {
3460
3471
  this.debugSection(`Browser:${ucfirst(msg.type())}`, (msg._text || '') + msg.args().join(' '))
3461
3472
  consoleLogStore.add(msg)
@@ -3656,6 +3667,18 @@ async function proceedSelect(context, el, option) {
3656
3667
  return this._waitForAction()
3657
3668
  }
3658
3669
 
3670
+ if (role === 'radiogroup') {
3671
+ if (options.length > 1) throw new Error(`selectOption: a radio group holds one value, but ${options.length} options were passed: ${options.join(', ')}`)
3672
+ const [opt] = options
3673
+ let optEls = await findByRole.call(this, el, { role: 'radio', name: opt, exact: true })
3674
+ if (!optEls?.length) optEls = await findByRole.call(this, el, { role: 'radio', name: opt })
3675
+ if (!optEls?.length) throw new ElementNotFound(opt, 'Option', 'was not found in this radio group')
3676
+ this.debugSection('SelectOption', `Clicking: "${opt}"`)
3677
+ highlightActiveElement.call(this, optEls[0], context)
3678
+ await optEls[0].click()
3679
+ return this._waitForAction()
3680
+ }
3681
+
3659
3682
  // Native <select> element
3660
3683
  const tagName = await el.evaluate(e => e.tagName)
3661
3684
  if (tagName !== 'SELECT') {
@@ -1329,6 +1329,10 @@ class WebDriver extends Helper {
1329
1329
  els = await this._locateByRole({ role: 'listbox', text: matchedLocator.value })
1330
1330
  if (els?.length) return proceedSelectOption.call(this, selectElement(els, select, this), option)
1331
1331
 
1332
+ // Fuzzy: try radiogroup
1333
+ els = await this._locateByRole({ role: 'radiogroup', text: matchedLocator.value })
1334
+ if (els?.length) return proceedSelectOption.call(this, selectElement(els, select, this), option)
1335
+
1332
1336
  // Fuzzy: try native select
1333
1337
  const res = await findFields.call(this, select, context)
1334
1338
  assertElementExists(res, select, 'Selectable field')
@@ -2521,7 +2525,6 @@ class WebDriver extends Helper {
2521
2525
  async waitInUrl(urlPart, sec = null) {
2522
2526
  const client = this.browser
2523
2527
  const aSec = sec || this.options.waitForTimeoutInSeconds
2524
- const expectedUrl = resolveUrl(urlPart, this.options.url)
2525
2528
  let currUrl = ''
2526
2529
 
2527
2530
  return client
@@ -2529,7 +2532,7 @@ class WebDriver extends Helper {
2529
2532
  function () {
2530
2533
  return this.getUrl().then(res => {
2531
2534
  currUrl = decodeUrl(res)
2532
- return currUrl.indexOf(expectedUrl) > -1
2535
+ return currUrl.indexOf(urlPart) > -1
2533
2536
  })
2534
2537
  },
2535
2538
  { timeout: aSec * 1000 },
@@ -2537,7 +2540,7 @@ class WebDriver extends Helper {
2537
2540
  .catch(e => {
2538
2541
  e = wrapError(e)
2539
2542
  if (e.message.indexOf('timeout')) {
2540
- throw new Error(`expected url to include ${expectedUrl}, but found ${currUrl}`)
2543
+ throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`)
2541
2544
  }
2542
2545
  throw e
2543
2546
  })
@@ -3249,6 +3252,9 @@ async function findCheckable(locator, locateFn) {
3249
3252
  els = await locateFn(Locator.checkable.byText(literal))
3250
3253
  if (els.length) return els
3251
3254
 
3255
+ els = await locateFn(Locator.checkable.byName(literal))
3256
+ if (els.length) return els
3257
+
3252
3258
  // Try ARIA selector for accessible name
3253
3259
  try {
3254
3260
  els = await locateFn(`aria/${locator.value}`)
@@ -3257,12 +3263,10 @@ async function findCheckable(locator, locateFn) {
3257
3263
  // ARIA selector not supported or failed
3258
3264
  }
3259
3265
 
3260
- els = await locateFn(Locator.checkable.byName(literal))
3261
- if (els.length) return els
3262
-
3263
3266
  return await locateFn(locator.value) // by css or xpath
3264
3267
  }
3265
3268
 
3269
+
3266
3270
  function withStrictLocator(locator) {
3267
3271
  locator = new Locator(locator)
3268
3272
  return locator.simplify()
@@ -3562,6 +3566,23 @@ async function proceedSelectOption(elem, option) {
3562
3566
  return
3563
3567
  }
3564
3568
 
3569
+ if (role === 'radiogroup') {
3570
+ if (options.length > 1) throw new Error(`selectOption: a radio group holds one value, but ${options.length} options were passed: ${options.join(', ')}`)
3571
+ const [opt] = options
3572
+ const radios = await this.browser.findElementsFromElement(elementId, 'xpath', `.//*[@role="radio"]`)
3573
+ const names = []
3574
+ for (const radio of radios) {
3575
+ names.push(await getElementTextAttributes.call(this, radio))
3576
+ }
3577
+ let index = names.findIndex(texts => texts.some(text => text && text.trim() === opt))
3578
+ if (index === -1) index = names.findIndex(texts => texts.some(text => text && text.includes(opt)))
3579
+ if (index === -1) throw new ElementNotFound(opt, 'Option', 'was not found in this radio group')
3580
+ this.debugSection('SelectOption', `Clicking: "${opt}"`)
3581
+ highlightActiveElement.call(this, radios[index])
3582
+ await this.browser.elementClick(getElementId(radios[index]))
3583
+ return
3584
+ }
3585
+
3565
3586
  // Native <select> element
3566
3587
  highlightActiveElement.call(this, elem)
3567
3588
 
@@ -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
+ }