codeceptjs 4.0.1-beta.22 → 4.0.1-beta.25
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/lib/helper/Playwright.js +54 -51
- package/lib/helper/Puppeteer.js +4 -0
- package/lib/helper/WebDriver.js +12 -1
- package/lib/helper/errors/MultipleElementsFound.js +135 -0
- package/lib/locator.js +43 -1
- package/package.json +1 -1
package/lib/helper/Playwright.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from '../utils.js'
|
|
28
28
|
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
|
|
29
29
|
import ElementNotFound from './errors/ElementNotFound.js'
|
|
30
|
+
import MultipleElementsFound from './errors/MultipleElementsFound.js'
|
|
30
31
|
import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
|
|
31
32
|
import Popup from './extras/Popup.js'
|
|
32
33
|
import Console from './extras/Console.js'
|
|
@@ -107,6 +108,9 @@ const pathSeparator = path.sep
|
|
|
107
108
|
* those cookies are used instead and the configured `storageState` is ignored (no merge).
|
|
108
109
|
* May include session cookies, auth tokens, localStorage and (if captured with
|
|
109
110
|
* `grabStorageState({ indexedDB: true })`) IndexedDB data; treat as sensitive and do not commit.
|
|
111
|
+
* @prop {boolean} [strict=false] - throw error when multiple elements match a single-element locator.
|
|
112
|
+
* When enabled, methods like `click`, `fillField`, `selectOption`, etc. will throw a
|
|
113
|
+
* `MultipleElementsFound` error if more than one element matches the locator.
|
|
110
114
|
*/
|
|
111
115
|
const config = {}
|
|
112
116
|
|
|
@@ -417,6 +421,7 @@ class Playwright extends Helper {
|
|
|
417
421
|
highlightElement: false,
|
|
418
422
|
storageState: undefined,
|
|
419
423
|
onResponse: null,
|
|
424
|
+
strict: false, // Throw error when multiple elements match single-element locator
|
|
420
425
|
}
|
|
421
426
|
|
|
422
427
|
process.env.testIdAttribute = 'data-testid'
|
|
@@ -1912,7 +1917,12 @@ class Playwright extends Helper {
|
|
|
1912
1917
|
*/
|
|
1913
1918
|
async _locateElement(locator) {
|
|
1914
1919
|
const context = await this._getContext()
|
|
1915
|
-
|
|
1920
|
+
const elements = await findElements.call(this, context, locator)
|
|
1921
|
+
if (elements.length === 0) {
|
|
1922
|
+
throw new ElementNotFound(locator, 'Element', 'was not found')
|
|
1923
|
+
}
|
|
1924
|
+
if (this.options.strict) assertOnlyOneElement(elements, locator)
|
|
1925
|
+
return elements[0]
|
|
1916
1926
|
}
|
|
1917
1927
|
|
|
1918
1928
|
/**
|
|
@@ -1927,6 +1937,7 @@ class Playwright extends Helper {
|
|
|
1927
1937
|
const context = providedContext || (await this._getContext())
|
|
1928
1938
|
const els = await findCheckable.call(this, locator, context)
|
|
1929
1939
|
assertElementExists(els[0], locator, 'Checkbox or radio')
|
|
1940
|
+
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
1930
1941
|
return els[0]
|
|
1931
1942
|
}
|
|
1932
1943
|
|
|
@@ -2399,6 +2410,7 @@ class Playwright extends Helper {
|
|
|
2399
2410
|
async fillField(field, value) {
|
|
2400
2411
|
const els = await findFields.call(this, field)
|
|
2401
2412
|
assertElementExists(els, field, 'Field')
|
|
2413
|
+
if (this.options.strict) assertOnlyOneElement(els, field)
|
|
2402
2414
|
const el = els[0]
|
|
2403
2415
|
|
|
2404
2416
|
await el.clear()
|
|
@@ -2431,6 +2443,7 @@ class Playwright extends Helper {
|
|
|
2431
2443
|
async clearField(locator, options = {}) {
|
|
2432
2444
|
const els = await findFields.call(this, locator)
|
|
2433
2445
|
assertElementExists(els, locator, 'Field to clear')
|
|
2446
|
+
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
2434
2447
|
|
|
2435
2448
|
const el = els[0]
|
|
2436
2449
|
|
|
@@ -2447,6 +2460,7 @@ class Playwright extends Helper {
|
|
|
2447
2460
|
async appendField(field, value) {
|
|
2448
2461
|
const els = await findFields.call(this, field)
|
|
2449
2462
|
assertElementExists(els, field, 'Field')
|
|
2463
|
+
if (this.options.strict) assertOnlyOneElement(els, field)
|
|
2450
2464
|
await highlightActiveElement.call(this, els[0])
|
|
2451
2465
|
await els[0].press('End')
|
|
2452
2466
|
await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
|
|
@@ -2492,7 +2506,6 @@ class Playwright extends Helper {
|
|
|
2492
2506
|
const context = await this.context
|
|
2493
2507
|
const matchedLocator = new Locator(select)
|
|
2494
2508
|
|
|
2495
|
-
// Strict locator
|
|
2496
2509
|
if (!matchedLocator.isFuzzy()) {
|
|
2497
2510
|
this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
|
|
2498
2511
|
const els = await this._locate(matchedLocator)
|
|
@@ -2500,16 +2513,15 @@ class Playwright extends Helper {
|
|
|
2500
2513
|
return proceedSelect.call(this, context, els[0], option)
|
|
2501
2514
|
}
|
|
2502
2515
|
|
|
2503
|
-
// Fuzzy: try combobox
|
|
2504
2516
|
this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
|
|
2505
|
-
|
|
2506
|
-
|
|
2517
|
+
const literal = xpathLocator.literal(matchedLocator.value)
|
|
2518
|
+
|
|
2519
|
+
let els = await this._locate({ xpath: Locator.select.narrow(literal) })
|
|
2520
|
+
if (els.length) return proceedSelect.call(this, context, els[0], option)
|
|
2507
2521
|
|
|
2508
|
-
|
|
2509
|
-
els
|
|
2510
|
-
if (els?.length) return proceedSelect.call(this, context, els[0], option)
|
|
2522
|
+
els = await this._locate({ xpath: Locator.select.wide(literal) })
|
|
2523
|
+
if (els.length) return proceedSelect.call(this, context, els[0], option)
|
|
2511
2524
|
|
|
2512
|
-
// Fuzzy: try native select
|
|
2513
2525
|
els = await findFields.call(this, select)
|
|
2514
2526
|
assertElementExists(els, select, 'Selectable element')
|
|
2515
2527
|
return proceedSelect.call(this, context, els[0], option)
|
|
@@ -2559,6 +2571,10 @@ class Playwright extends Helper {
|
|
|
2559
2571
|
*
|
|
2560
2572
|
*/
|
|
2561
2573
|
async see(text, context = null) {
|
|
2574
|
+
// If only one argument passed and it's an object without custom toString(), treat as locator
|
|
2575
|
+
if (!context && text && typeof text === 'object' && !Array.isArray(text) && text.toString === Object.prototype.toString) {
|
|
2576
|
+
return this.seeElement(text)
|
|
2577
|
+
}
|
|
2562
2578
|
return proceedSee.call(this, 'assert', text, context)
|
|
2563
2579
|
}
|
|
2564
2580
|
|
|
@@ -4442,20 +4458,6 @@ async function findCustomElements(matcher, locator) {
|
|
|
4442
4458
|
return locators
|
|
4443
4459
|
}
|
|
4444
4460
|
|
|
4445
|
-
async function findElement(matcher, locator) {
|
|
4446
|
-
const matchedLocator = Locator.from(locator)
|
|
4447
|
-
const roleElements = await findByRole(matcher, matchedLocator)
|
|
4448
|
-
if (roleElements && roleElements.length > 0) return roleElements[0]
|
|
4449
|
-
|
|
4450
|
-
const isReactLocator = matchedLocator.type === 'react'
|
|
4451
|
-
const isVueLocator = matchedLocator.type === 'vue'
|
|
4452
|
-
|
|
4453
|
-
if (isReactLocator) return findReact(matcher, matchedLocator)
|
|
4454
|
-
if (isVueLocator) return findVue(matcher, matchedLocator)
|
|
4455
|
-
|
|
4456
|
-
return matcher.locator(buildLocatorString(matchedLocator)).first()
|
|
4457
|
-
}
|
|
4458
|
-
|
|
4459
4461
|
async function getVisibleElements(elements) {
|
|
4460
4462
|
const visibleElements = []
|
|
4461
4463
|
for (const element of elements) {
|
|
@@ -4505,47 +4507,42 @@ async function proceedClick(locator, context = null, options = {}) {
|
|
|
4505
4507
|
}
|
|
4506
4508
|
|
|
4507
4509
|
async function findClickable(matcher, locator) {
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
+
if (locator.react) return findReact(matcher, locator)
|
|
4511
|
+
if (locator.vue) return findVue(matcher, locator)
|
|
4510
4512
|
|
|
4511
|
-
|
|
4512
|
-
if (
|
|
4513
|
-
|
|
4513
|
+
locator = new Locator(locator)
|
|
4514
|
+
if (!locator.isFuzzy()) {
|
|
4515
|
+
const els = await findElements.call(this, matcher, locator)
|
|
4516
|
+
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4517
|
+
return els
|
|
4514
4518
|
}
|
|
4515
4519
|
|
|
4516
|
-
if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator)
|
|
4517
|
-
|
|
4518
4520
|
let els
|
|
4519
|
-
const literal = xpathLocator.literal(
|
|
4520
|
-
|
|
4521
|
-
try {
|
|
4522
|
-
els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
|
|
4523
|
-
if (els.length) return els
|
|
4524
|
-
} catch (err) {
|
|
4525
|
-
// getByRole not supported or failed
|
|
4526
|
-
}
|
|
4527
|
-
|
|
4528
|
-
try {
|
|
4529
|
-
els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
|
|
4530
|
-
if (els.length) return els
|
|
4531
|
-
} catch (err) {
|
|
4532
|
-
// getByRole not supported or failed
|
|
4533
|
-
}
|
|
4521
|
+
const literal = xpathLocator.literal(locator.value)
|
|
4534
4522
|
|
|
4535
4523
|
els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
|
|
4536
|
-
if (els.length)
|
|
4524
|
+
if (els.length) {
|
|
4525
|
+
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4526
|
+
return els
|
|
4527
|
+
}
|
|
4537
4528
|
|
|
4538
4529
|
els = await findElements.call(this, matcher, Locator.clickable.wide(literal))
|
|
4539
|
-
if (els.length)
|
|
4530
|
+
if (els.length) {
|
|
4531
|
+
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4532
|
+
return els
|
|
4533
|
+
}
|
|
4540
4534
|
|
|
4541
4535
|
try {
|
|
4542
4536
|
els = await findElements.call(this, matcher, Locator.clickable.self(literal))
|
|
4543
|
-
if (els.length)
|
|
4537
|
+
if (els.length) {
|
|
4538
|
+
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4539
|
+
return els
|
|
4540
|
+
}
|
|
4544
4541
|
} catch (err) {
|
|
4545
4542
|
// Do nothing
|
|
4546
4543
|
}
|
|
4547
4544
|
|
|
4548
|
-
return findElements.call(this, matcher,
|
|
4545
|
+
return findElements.call(this, matcher, locator.value) // by css or xpath
|
|
4549
4546
|
}
|
|
4550
4547
|
|
|
4551
4548
|
async function proceedSee(assertType, text, context, strict = false) {
|
|
@@ -4648,7 +4645,7 @@ async function proceedSelect(context, el, option) {
|
|
|
4648
4645
|
const role = await el.getAttribute('role')
|
|
4649
4646
|
const options = Array.isArray(option) ? option : [option]
|
|
4650
4647
|
|
|
4651
|
-
if (role === 'combobox') {
|
|
4648
|
+
if (role === 'combobox' || role === 'button') {
|
|
4652
4649
|
this.debugSection('SelectOption', 'Expanding combobox')
|
|
4653
4650
|
await highlightActiveElement.call(this, el)
|
|
4654
4651
|
const [ariaOwns, ariaControls] = await Promise.all([el.getAttribute('aria-owns'), el.getAttribute('aria-controls')])
|
|
@@ -4798,6 +4795,12 @@ function assertElementExists(res, locator, prefix, suffix) {
|
|
|
4798
4795
|
}
|
|
4799
4796
|
}
|
|
4800
4797
|
|
|
4798
|
+
function assertOnlyOneElement(elements, locator) {
|
|
4799
|
+
if (elements.length > 1) {
|
|
4800
|
+
throw new MultipleElementsFound(locator, elements)
|
|
4801
|
+
}
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4801
4804
|
function $XPath(element, selector) {
|
|
4802
4805
|
const found = document.evaluate(selector, element || document.body, null, 5, null)
|
|
4803
4806
|
const res = []
|
|
@@ -5034,7 +5037,7 @@ async function saveTraceForContext(context, name) {
|
|
|
5034
5037
|
}
|
|
5035
5038
|
|
|
5036
5039
|
async function highlightActiveElement(element) {
|
|
5037
|
-
if (
|
|
5040
|
+
if (this.options.highlightElement || store.onPause || store.debugMode) {
|
|
5038
5041
|
await element.evaluate(el => {
|
|
5039
5042
|
const prevStyle = el.style.boxShadow
|
|
5040
5043
|
el.style.boxShadow = '0px 0px 4px 3px rgba(147, 51, 234, 0.8)' // Bright purple that works on both dark/light modes
|
package/lib/helper/Puppeteer.js
CHANGED
|
@@ -1690,6 +1690,10 @@ class Puppeteer extends Helper {
|
|
|
1690
1690
|
* {{ react }}
|
|
1691
1691
|
*/
|
|
1692
1692
|
async see(text, context = null) {
|
|
1693
|
+
// If only one argument passed and it's an object without custom toString(), treat as locator
|
|
1694
|
+
if (!context && text && typeof text === 'object' && !Array.isArray(text) && text.toString === Object.prototype.toString) {
|
|
1695
|
+
return this.seeElement(text)
|
|
1696
|
+
}
|
|
1693
1697
|
return proceedSee.call(this, 'assert', text, context)
|
|
1694
1698
|
}
|
|
1695
1699
|
|
package/lib/helper/WebDriver.js
CHANGED
|
@@ -1563,6 +1563,10 @@ class WebDriver extends Helper {
|
|
|
1563
1563
|
* {{ react }}
|
|
1564
1564
|
*/
|
|
1565
1565
|
async see(text, context = null) {
|
|
1566
|
+
// If only one argument passed and it's an object without custom toString(), treat as locator
|
|
1567
|
+
if (!context && text && typeof text === 'object' && !Array.isArray(text) && text.toString === Object.prototype.toString) {
|
|
1568
|
+
return this.seeElement(text)
|
|
1569
|
+
}
|
|
1566
1570
|
return proceedSee.call(this, 'assert', text, context)
|
|
1567
1571
|
}
|
|
1568
1572
|
|
|
@@ -2399,6 +2403,10 @@ class WebDriver extends Helper {
|
|
|
2399
2403
|
})
|
|
2400
2404
|
}
|
|
2401
2405
|
|
|
2406
|
+
async _waitForAction() {
|
|
2407
|
+
return this.wait(0.1)
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2402
2410
|
/**
|
|
2403
2411
|
* {{> waitForEnabled }}
|
|
2404
2412
|
*/
|
|
@@ -3030,7 +3038,10 @@ async function proceedSelect(el, option) {
|
|
|
3030
3038
|
const listboxId = ariaOwns || ariaControls
|
|
3031
3039
|
let listboxEls = listboxId ? await this.browser.$$(`#${listboxId}`) : []
|
|
3032
3040
|
if (!listboxEls.length) {
|
|
3033
|
-
listboxEls = await this.browser
|
|
3041
|
+
listboxEls = await this.browser.findElementsFromElement(elementId, 'xpath', 'following-sibling::*[@role="listbox"]')
|
|
3042
|
+
}
|
|
3043
|
+
if (!listboxEls.length) {
|
|
3044
|
+
listboxEls = await this.browser.findElementsFromElement(elementId, 'xpath', 'ancestor::*[@role="listbox"]')
|
|
3034
3045
|
}
|
|
3035
3046
|
if (!listboxEls.length) throw new Error('Cannot find listbox for combobox')
|
|
3036
3047
|
const listbox = listboxEls[0]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import Locator from '../../locator.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error thrown when strict mode is enabled and multiple elements are found
|
|
5
|
+
* for a single-element locator operation (click, fillField, etc.)
|
|
6
|
+
*/
|
|
7
|
+
class MultipleElementsFound extends Error {
|
|
8
|
+
/**
|
|
9
|
+
* @param {Locator|string|object} locator - The locator used
|
|
10
|
+
* @param {Array<HTMLElement>} elements - Array of Playwright element handles found
|
|
11
|
+
*/
|
|
12
|
+
constructor(locator, elements) {
|
|
13
|
+
super(`Multiple elements (${elements.length}) found for "${locator}". Call fetchDetails() for full information.`)
|
|
14
|
+
this.name = 'MultipleElementsFound'
|
|
15
|
+
this.locator = locator
|
|
16
|
+
this.elements = elements
|
|
17
|
+
this.count = elements.length
|
|
18
|
+
this._detailsFetched = false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fetch detailed information about the found elements asynchronously
|
|
23
|
+
* This updates the error message with XPath and element previews
|
|
24
|
+
*/
|
|
25
|
+
async fetchDetails() {
|
|
26
|
+
if (this._detailsFetched) return
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
if (typeof this.locator === 'object' && !(this.locator instanceof Locator)) {
|
|
30
|
+
this.locator = JSON.stringify(this.locator)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const locatorObj = new Locator(this.locator)
|
|
34
|
+
const elementList = await this._generateElementList(this.elements, this.count)
|
|
35
|
+
|
|
36
|
+
this.message = `Multiple elements (${this.count}) found for "${locatorObj.toString()}" in strict mode.\n` +
|
|
37
|
+
elementList +
|
|
38
|
+
`\nUse a more specific locator or use grabWebElements() to handle multiple elements.`
|
|
39
|
+
} catch (err) {
|
|
40
|
+
this.message = `Multiple elements (${this.count}) found. Failed to fetch details: ${err.message}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this._detailsFetched = true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate a formatted list of found elements with their XPath and preview
|
|
48
|
+
* @param {Array<HTMLElement>} elements
|
|
49
|
+
* @param {number} count
|
|
50
|
+
* @returns {Promise<string>}
|
|
51
|
+
*/
|
|
52
|
+
async _generateElementList(elements, count) {
|
|
53
|
+
const items = []
|
|
54
|
+
const maxToShow = Math.min(count, 10)
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < maxToShow; i++) {
|
|
57
|
+
const el = elements[i]
|
|
58
|
+
try {
|
|
59
|
+
const info = await this._getElementInfo(el)
|
|
60
|
+
items.push(` ${i + 1}. ${info.xpath} (${info.preview})`)
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// Element might be detached or inaccessible
|
|
63
|
+
items.push(` ${i + 1}. [Unable to get element info: ${err.message}]`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (count > 10) {
|
|
68
|
+
items.push(` ... and ${count - 10} more`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return items.join('\n')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get XPath and preview for an element by running JavaScript in browser context
|
|
76
|
+
* @param {HTMLElement} element
|
|
77
|
+
* @returns {Promise<{xpath: string, preview: string}>}
|
|
78
|
+
*/
|
|
79
|
+
async _getElementInfo(element) {
|
|
80
|
+
return element.evaluate((el) => {
|
|
81
|
+
// Generate a unique XPath for this element
|
|
82
|
+
const getUniqueXPath = (element) => {
|
|
83
|
+
if (element.id) {
|
|
84
|
+
return `//*[@id="${element.id}"]`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parts = []
|
|
88
|
+
let current = element
|
|
89
|
+
|
|
90
|
+
while (current && current.nodeType === Node.ELEMENT_NODE) {
|
|
91
|
+
let index = 0
|
|
92
|
+
let sibling = current.previousSibling
|
|
93
|
+
|
|
94
|
+
while (sibling) {
|
|
95
|
+
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === current.tagName) {
|
|
96
|
+
index++
|
|
97
|
+
}
|
|
98
|
+
sibling = sibling.previousSibling
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tagName = current.tagName.toLowerCase()
|
|
102
|
+
const pathIndex = index > 0 ? `[${index + 1}]` : ''
|
|
103
|
+
parts.unshift(`${tagName}${pathIndex}`)
|
|
104
|
+
|
|
105
|
+
current = current.parentElement
|
|
106
|
+
|
|
107
|
+
// Stop at body to keep XPath reasonable
|
|
108
|
+
if (current && current.tagName === 'BODY') {
|
|
109
|
+
parts.unshift('body')
|
|
110
|
+
break
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return '/' + parts.join('/')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Get a preview of the element (tag, classes, id)
|
|
118
|
+
const getPreview = (element) => {
|
|
119
|
+
const tag = element.tagName.toLowerCase()
|
|
120
|
+
const id = element.id ? `#${element.id}` : ''
|
|
121
|
+
const classes = element.className
|
|
122
|
+
? '.' + element.className.split(' ').filter(c => c).join('.')
|
|
123
|
+
: ''
|
|
124
|
+
return `${tag}${id}${classes || ''}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
xpath: getUniqueXPath(el),
|
|
129
|
+
preview: getPreview(el),
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default MultipleElementsFound
|
package/lib/locator.js
CHANGED
|
@@ -6,6 +6,9 @@ const require = createRequire(import.meta.url)
|
|
|
6
6
|
let cssToXPath
|
|
7
7
|
|
|
8
8
|
const locatorTypes = ['css', 'by', 'xpath', 'id', 'name', 'fuzzy', 'frame', 'shadow', 'role']
|
|
9
|
+
|
|
10
|
+
// Roles that can be used as shorthand locators: { button: 'text' } -> { role: 'button', text: 'text' }
|
|
11
|
+
const shorthandRoles = ['button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox', 'heading', 'listitem', 'menuitem', 'tab', 'option', 'searchbox', 'alert', 'dialog']
|
|
9
12
|
/** @class */
|
|
10
13
|
class Locator {
|
|
11
14
|
/**
|
|
@@ -114,6 +117,16 @@ class Locator {
|
|
|
114
117
|
this.locator = locator
|
|
115
118
|
const keys = Object.keys(locator)
|
|
116
119
|
const [type] = keys
|
|
120
|
+
|
|
121
|
+
// Transform shorthand role locators: { button: 'text' } -> { role: 'button', text: 'text' }
|
|
122
|
+
if (keys.length === 1 && shorthandRoles.includes(type)) {
|
|
123
|
+
this.locator = { role: type, text: locator[type] }
|
|
124
|
+
this.type = 'role'
|
|
125
|
+
this.value = this.locator
|
|
126
|
+
Locator.filters.forEach(f => f(this.locator, this))
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
117
130
|
this.type = type
|
|
118
131
|
this.value = keys.length > 1 ? locator : locator[type]
|
|
119
132
|
Locator.filters.forEach(f => f(locator, this))
|
|
@@ -500,7 +513,7 @@ Locator.clickable = {
|
|
|
500
513
|
`.//*[@aria-label = ${literal}]`,
|
|
501
514
|
`.//*[@title = ${literal}]`,
|
|
502
515
|
`.//*[@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`,
|
|
503
|
-
`.//*[@role='link'
|
|
516
|
+
`.//*[@role='button' or @role='link' or @role='tab' or @role='menuitem' or @role='menuitemcheckbox' or @role='menuitemradio' or @role='option' or @role='radio' or @role='checkbox' or @role='switch' or @role='treeitem' or @role='gridcell' or @role='columnheader' or @role='rowheader' or @role='scrollbar' or @role='slider' or @role='spinbutton'][normalize-space(.)=${literal}]`,
|
|
504
517
|
]),
|
|
505
518
|
|
|
506
519
|
/**
|
|
@@ -570,6 +583,35 @@ Locator.checkable = {
|
|
|
570
583
|
}
|
|
571
584
|
|
|
572
585
|
Locator.select = {
|
|
586
|
+
/**
|
|
587
|
+
* Narrow strategy for finding select elements.
|
|
588
|
+
* Tries exact role match first (combobox/listbox by name), then select by name.
|
|
589
|
+
* @param {string} literal
|
|
590
|
+
* @returns {string}
|
|
591
|
+
*/
|
|
592
|
+
narrow: literal =>
|
|
593
|
+
xpathLocator.combine([
|
|
594
|
+
`.//*[@role = 'combobox'][normalize-space(@name) = ${literal}]`,
|
|
595
|
+
`.//*[@role = 'listbox'][normalize-space(@name) = ${literal}]`,
|
|
596
|
+
`.//select[@name = ${literal}]`,
|
|
597
|
+
]),
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Wide strategy for finding select elements.
|
|
601
|
+
* Includes select, button, combobox, listbox with various accessibility attributes.
|
|
602
|
+
* @param {string} literal
|
|
603
|
+
* @returns {string}
|
|
604
|
+
*/
|
|
605
|
+
wide: literal =>
|
|
606
|
+
xpathLocator.combine([
|
|
607
|
+
`.//select[((./@name = ${literal}) or ./@id = //label[@for][normalize-space(string(.)) = ${literal}]/@for or ./@placeholder = ${literal})]`,
|
|
608
|
+
`.//*[@role='button' or @role='combobox' or @role='listbox'][normalize-space(string(.)) = ${literal}]`,
|
|
609
|
+
`.//*[@role='button' or @role='combobox' or @role='listbox'][@aria-label = ${literal}]`,
|
|
610
|
+
`.//*[@role='button' or @role='combobox' or @role='listbox'][@title = ${literal}]`,
|
|
611
|
+
`.//*[@role='button' or @role='combobox' or @role='listbox'][@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`,
|
|
612
|
+
`.//label[normalize-space(string(.)) = ${literal}]//*[self::button or @role='button' or @role='combobox' or @role='listbox']`,
|
|
613
|
+
]),
|
|
614
|
+
|
|
573
615
|
/**
|
|
574
616
|
* @param {string} opt
|
|
575
617
|
* @returns {string}
|