@@ -27,6 +27,7 @@ import {
2727} from '../utils.js'
2828import { isColorProperty , convertColorToRGBA } from '../colorUtils.js'
2929import ElementNotFound from './errors/ElementNotFound.js'
30+ import MultipleElementsFound from './errors/MultipleElementsFound.js'
3031import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
3132import Popup from './extras/Popup.js'
3233import 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+
41054137async 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 = {}) {
41844216async 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+
43174414async 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+
44324535function $XPath ( element , selector ) {
44334536 const found = document . evaluate ( selector , element || document . body , null , 5 , null )
44344537 const res = [ ]
0 commit comments