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
package/lib/helper/Puppeteer.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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 ${
|
|
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.
|
|
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.
|
|
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') {
|
package/lib/helper/WebDriver.js
CHANGED
|
@@ -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(
|
|
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 ${
|
|
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
|
+
}
|