@@ -108,60 +108,142 @@ const elementsScript = (includeBounds: boolean) => (function () {
108108 return ( el . textContent ?. trim ( ) . replace ( / \s + / g, ' ' ) || '' ) . slice ( 0 , 100 ) ;
109109 }
110110
111+ function getRole ( el : HTMLElement ) : string | null {
112+ const explicit = el . getAttribute ( 'role' ) ;
113+ if ( explicit ) return explicit . split ( ' ' ) [ 0 ] ;
114+
115+ const tag = el . tagName . toLowerCase ( ) ;
116+ switch ( tag ) {
117+ case 'button' : return 'button' ;
118+ case 'a' : return el . hasAttribute ( 'href' ) ? 'link' : null ;
119+ case 'input' : {
120+ const type = ( el . getAttribute ( 'type' ) || 'text' ) . toLowerCase ( ) ;
121+ if ( type === 'hidden' ) return null ;
122+ if ( type === 'checkbox' || type === 'radio' ) return type ;
123+ if ( type === 'range' ) return 'slider' ;
124+ if ( type === 'search' ) return 'searchbox' ;
125+ if ( type === 'number' ) return 'spinbutton' ;
126+ if ( [ 'submit' , 'reset' , 'button' , 'image' ] . includes ( type ) ) return 'button' ;
127+ return 'textbox' ;
128+ }
129+ case 'select' : return 'combobox' ;
130+ case 'textarea' : return 'textbox' ;
131+ }
132+
133+ if ( ( el as HTMLElement & { contentEditable : string } ) . contentEditable === 'true' ) return 'textbox' ;
134+ return null ;
135+ }
136+
137+ function getSelectorAccessibleName ( el : HTMLElement ) : string {
138+ const ariaLabel = el . getAttribute ( 'aria-label' ) ;
139+ if ( ariaLabel ) return ariaLabel . trim ( ) ;
140+
141+ const labelledBy = el . getAttribute ( 'aria-labelledby' ) ;
142+ if ( labelledBy ) {
143+ const texts = labelledBy . split ( / \s + / )
144+ . map ( id => document . getElementById ( id ) ?. textContent ?. trim ( ) || '' )
145+ . filter ( Boolean ) ;
146+ if ( texts . length > 0 ) return texts . join ( ' ' ) . slice ( 0 , 100 ) ;
147+ }
148+
149+ const tag = el . tagName . toLowerCase ( ) ;
150+ if ( tag === 'img' || ( tag === 'input' && el . getAttribute ( 'type' ) === 'image' ) ) {
151+ const alt = el . getAttribute ( 'alt' ) ;
152+ if ( alt !== null ) return alt . trim ( ) ;
153+ }
154+
155+ if ( [ 'input' , 'select' , 'textarea' ] . includes ( tag ) ) {
156+ const id = el . getAttribute ( 'id' ) ;
157+ if ( id ) {
158+ const label = document . querySelector ( `label[for="${ CSS . escape ( id ) } "]` ) ;
159+ if ( label ) return label . textContent ?. trim ( ) || '' ;
160+ }
161+ const parentLabel = el . closest ( 'label' ) ;
162+ if ( parentLabel ) {
163+ const clone = parentLabel . cloneNode ( true ) as HTMLElement ;
164+ clone . querySelectorAll ( 'input,select,textarea' ) . forEach ( n => n . remove ( ) ) ;
165+ const lt = clone . textContent ?. trim ( ) ;
166+ if ( lt ) return lt ;
167+ }
168+ return '' ;
169+ }
170+
171+ return ( el . textContent ?. trim ( ) . replace ( / \s + / g, ' ' ) || '' ) . slice ( 0 , 100 ) ;
172+ }
173+
174+ function isLikelyGeneratedId ( id : string ) : boolean {
175+ return / ^ [ 0 - 9 a - f ] { 8 , } $ / i. test ( id ) ||
176+ / ^ [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 1 - 5 ] [ 0 - 9 a - f ] { 3 } - [ 8 9 a b ] [ 0 - 9 a - f ] { 3 } - [ 0 - 9 a - f ] { 12 } $ / i. test ( id ) ||
177+ / (?: ^ | [ - _ : ] ) \d { 8 , } (?: $ | [ - _ : ] ) / . test ( id ) ||
178+ / ^ (?: e m b e r | r e a c t - s e l e c t | r a d i x | h e a d l e s s u i | m u i | m a n t i n e | c h a k r a | a u t o | g e n e r a t e d ) [ - _ : ] ? \d + / i. test ( id ) ;
179+ }
180+
181+ function uniqueCssSelector ( selector : string ) : string | null {
182+ return document . querySelectorAll ( selector ) . length === 1 ? selector : null ;
183+ }
184+
111185 function getSelector ( element : HTMLElement ) : string {
112186 const tag = element . tagName . toLowerCase ( ) ;
113187
114- // 1. tag*=Text — best per WebdriverIO docs
115- const text = element . textContent ?. trim ( ) . replace ( / \s + / g, ' ' ) ;
116- if ( text && text . length > 0 && text . length <= 50 ) {
117- const sameTagElements = document . querySelectorAll ( tag ) ;
118- let matchCount = 0 ;
119- sameTagElements . forEach ( el => {
120- if ( el . textContent ?. includes ( text ) ) matchCount ++ ;
121- } ) ;
122- if ( matchCount === 1 ) return `${ tag } *=${ text } ` ;
188+ // 1. Explicit test hooks
189+ for ( const attr of [ 'data-testid' , 'data-test' , 'data-qa' ] ) {
190+ const value = element . getAttribute ( attr ) ;
191+ if ( ! value ) continue ;
192+ const selector = uniqueCssSelector ( `[${ attr } ="${ CSS . escape ( value ) } "]` ) ;
193+ if ( selector ) return selector ;
123194 }
124195
125- // 2. aria/label
126- const ariaLabel = element . getAttribute ( 'aria-label' ) ;
127- if ( ariaLabel && ariaLabel . length <= 80 ) return `aria/${ ariaLabel } ` ;
128-
129- // 3. data-testid
130- const testId = element . getAttribute ( 'data-testid' ) ;
131- if ( testId ) {
132- const sel = `[data-testid="${ CSS . escape ( testId ) } "]` ;
133- if ( document . querySelectorAll ( sel ) . length === 1 ) return sel ;
196+ // 2. Accessible name. WebdriverIO's aria/ selector is name-based, so only
197+ // emit it when the accessible-name candidate is unique among interactables.
198+ const role = getRole ( element ) ;
199+ const accessibleName = getSelectorAccessibleName ( element ) ;
200+ if ( role && accessibleName && accessibleName . length <= 80 ) {
201+ let matchCount = 0 ;
202+ document . querySelectorAll ( interactableSelectors ) . forEach ( el => {
203+ const htmlEl = el as HTMLElement ;
204+ if ( isVisible ( htmlEl ) && getRole ( htmlEl ) && getSelectorAccessibleName ( htmlEl ) === accessibleName ) matchCount ++ ;
205+ } ) ;
206+ if ( matchCount === 1 ) return `aria/${ accessibleName } ` ;
134207 }
135208
136- // 4. #id
137- if ( element . id ) return `#${ CSS . escape ( element . id ) } ` ;
209+ // 3. Stable #id
210+ if ( element . id && ! isLikelyGeneratedId ( element . id ) ) return `#${ CSS . escape ( element . id ) } ` ;
138211
139- // 5. [name] — form elements
212+ // 4. Stable input attributes
140213 const nameAttr = element . getAttribute ( 'name' ) ;
141214 if ( nameAttr ) {
142215 const sel = `${ tag } [name="${ CSS . escape ( nameAttr ) } "]` ;
143- if ( document . querySelectorAll ( sel ) . length === 1 ) return sel ;
216+ const selector = uniqueCssSelector ( sel ) ;
217+ if ( selector ) return selector ;
218+ }
219+
220+ const placeholder = element . getAttribute ( 'placeholder' ) ;
221+ if ( placeholder ) {
222+ const sel = `${ tag } [placeholder="${ CSS . escape ( placeholder ) } "]` ;
223+ const selector = uniqueCssSelector ( sel ) ;
224+ if ( selector ) return selector ;
144225 }
145226
146- // 6. tag.class — try each class individually, then first-two combination
227+ // 5. Scoped CSS fallback — try classes before structural paths
147228 if ( element . className && typeof element . className === 'string' ) {
148229 const classes = element . className . trim ( ) . split ( / \s + / ) . filter ( Boolean ) ;
149230 for ( const cls of classes ) {
150231 const sel = `${ tag } .${ CSS . escape ( cls ) } ` ;
151- if ( document . querySelectorAll ( sel ) . length === 1 ) return sel ;
232+ const selector = uniqueCssSelector ( sel ) ;
233+ if ( selector ) return selector ;
152234 }
153235 if ( classes . length >= 2 ) {
154236 const sel = `${ tag } ${ classes . slice ( 0 , 2 ) . map ( c => `.${ CSS . escape ( c ) } ` ) . join ( '' ) } ` ;
155- if ( document . querySelectorAll ( sel ) . length === 1 ) return sel ;
237+ const selector = uniqueCssSelector ( sel ) ;
238+ if ( selector ) return selector ;
156239 }
157240 }
158241
159- // 7. CSS path fallback
160242 let current : HTMLElement | null = element ;
161243 const path : string [ ] = [ ] ;
162244 while ( current && current !== document . documentElement ) {
163245 let seg = current . tagName . toLowerCase ( ) ;
164- if ( current . id ) {
246+ if ( current . id && ! isLikelyGeneratedId ( current . id ) ) {
165247 path . unshift ( `#${ CSS . escape ( current . id ) } ` ) ;
166248 break ;
167249 }
0 commit comments