Skip to content

Commit 6adfb39

Browse files
DavertMikclaude
andcommitted
feat: add JSON string locators, selectOption combobox/listbox support, and strict mode
- Add JSON string parsing for locators (e.g., '{"css": "#button"}') - Add selectOption support for ARIA combobox/listbox roles - Add strict mode option for Playwright that throws MultipleElementsFound error - Add MultipleElementsFound error class with detailed element info - Add tests for all new features Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 061dc22 commit 6adfb39

File tree

7 files changed

+637
-20
lines changed

7 files changed

+637
-20
lines changed

lib/helper/Playwright.js

Lines changed: 117 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '../utils.js'
2828
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
2929
import ElementNotFound from './errors/ElementNotFound.js'
30+
import MultipleElementsFound from './errors/MultipleElementsFound.js'
3031
import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
3132
import Popup from './extras/Popup.js'
3233
import Console from './extras/Console.js'
@@ -392,6 +393,7 @@ class Playwright extends Helper {
392393
highlightElement: false,
393394
storageState: undefined,
394395
onResponse: null,
396+
strict: false,
395397
}
396398

397399
process.env.testIdAttribute = 'data-testid'
@@ -1753,7 +1755,12 @@ class Playwright extends Helper {
17531755
*/
17541756
async _locateElement(locator) {
17551757
const context = await this._getContext()
1756-
return findElement(context, locator)
1758+
const elements = await findElements.call(this, context, locator)
1759+
if (elements.length === 0) {
1760+
throw new ElementNotFound(locator, 'Element', 'was not found')
1761+
}
1762+
if (this.options.strict) assertOnlyOneElement(elements, locator)
1763+
return elements[0]
17571764
}
17581765

17591766
/**
@@ -1768,6 +1775,7 @@ class Playwright extends Helper {
17681775
const context = providedContext || (await this._getContext())
17691776
const els = await findCheckable.call(this, locator, context)
17701777
assertElementExists(els[0], locator, 'Checkbox or radio')
1778+
if (this.options.strict) assertOnlyOneElement(els, locator)
17711779
return els[0]
17721780
}
17731781

@@ -2240,6 +2248,7 @@ class Playwright extends Helper {
22402248
async fillField(field, value) {
22412249
const els = await findFields.call(this, field)
22422250
assertElementExists(els, field, 'Field')
2251+
if (this.options.strict) assertOnlyOneElement(els, field)
22432252
const el = els[0]
22442253

22452254
await el.clear()
@@ -2272,6 +2281,7 @@ class Playwright extends Helper {
22722281
async clearField(locator, options = {}) {
22732282
const els = await findFields.call(this, locator)
22742283
assertElementExists(els, locator, 'Field to clear')
2284+
if (this.options.strict) assertOnlyOneElement(els, locator)
22752285

22762286
const el = els[0]
22772287

@@ -2288,6 +2298,7 @@ class Playwright extends Helper {
22882298
async appendField(field, value) {
22892299
const els = await findFields.call(this, field)
22902300
assertElementExists(els, field, 'Field')
2301+
if (this.options.strict) assertOnlyOneElement(els, field)
22912302
await highlightActiveElement.call(this, els[0])
22922303
await els[0].press('End')
22932304
await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
@@ -2330,23 +2341,30 @@ class Playwright extends Helper {
23302341
* {{> selectOption }}
23312342
*/
23322343
async selectOption(select, option) {
2333-
const els = await findFields.call(this, select)
2334-
assertElementExists(els, select, 'Selectable field')
2335-
const el = els[0]
2336-
2337-
await highlightActiveElement.call(this, el)
2338-
let optionToSelect = ''
2344+
const context = await this.context
2345+
const matchedLocator = new Locator(select)
23392346

2340-
try {
2341-
optionToSelect = (await el.locator('option', { hasText: option }).textContent()).trim()
2342-
} catch (e) {
2343-
optionToSelect = option
2347+
// Strict locator
2348+
if (!matchedLocator.isFuzzy()) {
2349+
this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
2350+
const els = await this._locate(matchedLocator)
2351+
assertElementExists(els, select, 'Selectable element')
2352+
return proceedSelect.call(this, context, els[0], option)
23442353
}
23452354

2346-
if (!Array.isArray(option)) option = [optionToSelect]
2355+
// Fuzzy: try combobox
2356+
this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
2357+
let els = await findByRole(context, { role: 'combobox', name: matchedLocator.value })
2358+
if (els?.length) return proceedSelect.call(this, context, els[0], option)
23472359

2348-
await el.selectOption(option)
2349-
return this._waitForAction()
2360+
// Fuzzy: try listbox
2361+
els = await findByRole(context, { role: 'listbox', name: matchedLocator.value })
2362+
if (els?.length) return proceedSelect.call(this, context, els[0], option)
2363+
2364+
// Fuzzy: try native select
2365+
els = await findFields.call(this, select)
2366+
assertElementExists(els, select, 'Selectable element')
2367+
return proceedSelect.call(this, context, els[0], option)
23502368
}
23512369

23522370
/**
@@ -4102,6 +4120,14 @@ async function handleRoleLocator(context, locator) {
41024120
return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
41034121
}
41044122

4123+
async function findByRole(context, locator) {
4124+
if (!locator || !locator.role) return null
4125+
const options = {}
4126+
if (locator.name) options.name = locator.name
4127+
if (locator.exact !== undefined) options.exact = locator.exact
4128+
return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
4129+
}
4130+
41054131
async function findElements(matcher, locator) {
41064132
// Check if locator is a Locator object with react/vue type, or a raw object with react/vue property
41074133
const isReactLocator = locator.type === 'react' || (locator.locator && locator.locator.react) || locator.react
@@ -4184,34 +4210,53 @@ async function proceedClick(locator, context = null, options = {}) {
41844210
async function findClickable(matcher, locator) {
41854211
const matchedLocator = new Locator(locator)
41864212

4187-
if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator)
4213+
if (!matchedLocator.isFuzzy()) {
4214+
const els = await findElements.call(this, matcher, matchedLocator)
4215+
if (this.options.strict) assertOnlyOneElement(els, locator)
4216+
return els
4217+
}
41884218

41894219
let els
41904220
const literal = xpathLocator.literal(matchedLocator.value)
41914221

41924222
try {
41934223
els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
4194-
if (els.length) return els
4224+
if (els.length) {
4225+
if (this.options.strict) assertOnlyOneElement(els, locator)
4226+
return els
4227+
}
41954228
} catch (err) {
41964229
// getByRole not supported or failed
41974230
}
41984231

41994232
try {
42004233
els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
4201-
if (els.length) return els
4234+
if (els.length) {
4235+
if (this.options.strict) assertOnlyOneElement(els, locator)
4236+
return els
4237+
}
42024238
} catch (err) {
42034239
// getByRole not supported or failed
42044240
}
42054241

42064242
els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
4207-
if (els.length) return els
4243+
if (els.length) {
4244+
if (this.options.strict) assertOnlyOneElement(els, locator)
4245+
return els
4246+
}
42084247

42094248
els = await findElements.call(this, matcher, Locator.clickable.wide(literal))
4210-
if (els.length) return els
4249+
if (els.length) {
4250+
if (this.options.strict) assertOnlyOneElement(els, locator)
4251+
return els
4252+
}
42114253

42124254
try {
42134255
els = await findElements.call(this, matcher, Locator.clickable.self(literal))
4214-
if (els.length) return els
4256+
if (els.length) {
4257+
if (this.options.strict) assertOnlyOneElement(els, locator)
4258+
return els
4259+
}
42154260
} catch (err) {
42164261
// Do nothing
42174262
}
@@ -4314,6 +4359,52 @@ async function findFields(locator) {
43144359
return this._locate({ css: locator })
43154360
}
43164361

4362+
async function proceedSelect(context, el, option) {
4363+
const role = await el.getAttribute('role')
4364+
const options = Array.isArray(option) ? option : [option]
4365+
4366+
if (role === 'combobox') {
4367+
this.debugSection('SelectOption', 'Expanding combobox')
4368+
await highlightActiveElement.call(this, el)
4369+
const [ariaOwns, ariaControls] = await Promise.all([el.getAttribute('aria-owns'), el.getAttribute('aria-controls')])
4370+
await el.click()
4371+
await this._waitForAction()
4372+
4373+
const listboxId = ariaOwns || ariaControls
4374+
let listbox = listboxId ? context.locator(`#${listboxId}`).first() : null
4375+
if (!listbox || !(await listbox.count())) listbox = context.getByRole('listbox').first()
4376+
4377+
for (const opt of options) {
4378+
const optEl = listbox.getByRole('option', { name: opt }).first()
4379+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
4380+
await highlightActiveElement.call(this, optEl)
4381+
await optEl.click()
4382+
}
4383+
return this._waitForAction()
4384+
}
4385+
4386+
if (role === 'listbox') {
4387+
for (const opt of options) {
4388+
const optEl = el.getByRole('option', { name: opt }).first()
4389+
this.debugSection('SelectOption', `Clicking: "${opt}"`)
4390+
await highlightActiveElement.call(this, optEl)
4391+
await optEl.click()
4392+
}
4393+
return this._waitForAction()
4394+
}
4395+
4396+
await highlightActiveElement.call(this, el)
4397+
let optionToSelect = option
4398+
try {
4399+
optionToSelect = (await el.locator('option', { hasText: option }).textContent()).trim()
4400+
} catch (e) {
4401+
optionToSelect = option
4402+
}
4403+
if (!Array.isArray(option)) option = [optionToSelect]
4404+
await el.selectOption(option)
4405+
return this._waitForAction()
4406+
}
4407+
43174408
async function proceedSeeInField(assertType, field, value) {
43184409
const els = await findFields.call(this, field)
43194410
assertElementExists(els, field, 'Field')
@@ -4429,6 +4520,12 @@ function assertElementExists(res, locator, prefix, suffix) {
44294520
}
44304521
}
44314522

4523+
function assertOnlyOneElement(elements, locator) {
4524+
if (elements.length > 1) {
4525+
throw new MultipleElementsFound(locator, elements)
4526+
}
4527+
}
4528+
44324529
function $XPath(element, selector) {
44334530
const found = document.evaluate(selector, element || document.body, null, 5, null)
44344531
const res = []
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

0 commit comments

Comments
 (0)