Skip to content

Commit b2e34ec

Browse files
DavertMikDavertMikclaude
authored
Feat/json locators esm (#5424)
* 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> * added shadow dom support * fixed unit tests * fix: use >> instead of >>> for Playwright shadow DOM chaining * fix: update test expectations for Frame error message * feat: add combobox/listbox support to Puppeteer and WebDriver selectOption * fix: resolve aria-labelledby for combobox/listbox in WebDriver selectOption --------- Co-authored-by: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5f7e6d9 commit b2e34ec

File tree

12 files changed

+1054
-73
lines changed

12 files changed

+1054
-73
lines changed

lib/helper/Playwright.js

Lines changed: 123 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
/**
@@ -4078,6 +4096,12 @@ function buildLocatorString(locator) {
40784096
if (locator.isXPath()) {
40794097
return `xpath=${locator.value}`
40804098
}
4099+
if (locator.isShadow()) {
4100+
// Convert shadow locator to CSS with >> chaining operator
4101+
// Playwright pierces shadow DOM by default, >> chains selectors
4102+
// { shadow: ['my-app', 'my-form', 'button'] } => 'my-app >> my-form >> button'
4103+
return locator.value.join(' >> ')
4104+
}
40814105
return locator.simplify()
40824106
}
40834107

@@ -4102,6 +4126,14 @@ async function handleRoleLocator(context, locator) {
41024126
return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
41034127
}
41044128

4129+
async function findByRole(context, locator) {
4130+
if (!locator || !locator.role) return null
4131+
const options = {}
4132+
if (locator.name) options.name = locator.name
4133+
if (locator.exact !== undefined) options.exact = locator.exact
4134+
return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
4135+
}
4136+
41054137
async function findElements(matcher, locator) {
41064138
// Check if locator is a Locator object with react/vue type, or a raw object with react/vue property
41074139
const isReactLocator = locator.type === 'react' || (locator.locator && locator.locator.react) || locator.react
@@ -4184,34 +4216,53 @@ async function proceedClick(locator, context = null, options = {}) {
41844216
async function findClickable(matcher, locator) {
41854217
const matchedLocator = new Locator(locator)
41864218

4187-
if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator)
4219+
if (!matchedLocator.isFuzzy()) {
4220+
const els = await findElements.call(this, matcher, matchedLocator)
4221+
if (this.options.strict) assertOnlyOneElement(els, locator)
4222+
return els
4223+
}
41884224

41894225
let els
41904226
const literal = xpathLocator.literal(matchedLocator.value)
41914227

41924228
try {
41934229
els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
4194-
if (els.length) return els
4230+
if (els.length) {
4231+
if (this.options.strict) assertOnlyOneElement(els, locator)
4232+
return els
4233+
}
41954234
} catch (err) {
41964235
// getByRole not supported or failed
41974236
}
41984237

41994238
try {
42004239
els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
4201-
if (els.length) return els
4240+
if (els.length) {
4241+
if (this.options.strict) assertOnlyOneElement(els, locator)
4242+
return els
4243+
}
42024244
} catch (err) {
42034245
// getByRole not supported or failed
42044246
}
42054247

42064248
els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
4207-
if (els.length) return els
4249+
if (els.length) {
4250+
if (this.options.strict) assertOnlyOneElement(els, locator)
4251+
return els
4252+
}
42084253

42094254
els = await findElements.call(this, matcher, Locator.clickable.wide(literal))
4210-
if (els.length) return els
4255+
if (els.length) {
4256+
if (this.options.strict) assertOnlyOneElement(els, locator)
4257+
return els
4258+
}
42114259

42124260
try {
42134261
els = await findElements.call(this, matcher, Locator.clickable.self(literal))
4214-
if (els.length) return els
4262+
if (els.length) {
4263+
if (this.options.strict) assertOnlyOneElement(els, locator)
4264+
return els
4265+
}
42154266
} catch (err) {
42164267
// Do nothing
42174268
}
@@ -4314,6 +4365,52 @@ async function findFields(locator) {
43144365
return this._locate({ css: locator })
43154366
}
43164367

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

4529+
function assertOnlyOneElement(elements, locator) {
4530+
if (elements.length > 1) {
4531+
throw new MultipleElementsFound(locator, elements)
4532+
}
4533+
}
4534+
44324535
function $XPath(element, selector) {
44334536
const found = document.evaluate(selector, element || document.body, null, 5, null)
44344537
const res = []

0 commit comments

Comments
 (0)