import type { ARIARoleDefinitionKey, ARIARoleRelationConcept, ARIARoleRelationConceptAttribute } from 'aria-query' import { roleElements } from 'aria-query' import { environment } from '../environment.js' import { DEEP_SELECTOR, ARIA_SELECTOR } from '../constants.js' const DEFAULT_STRATEGY = 'css selector' const DIRECT_SELECTOR_REGEXP = /^(id|css selector|xpath|link text|partial link text|name|tag name|class name|-android uiautomator|-android datamatcher|-android viewmatcher|-android viewtag|-ios uiautomation|-ios predicate string|-ios class chain|accessibility id):(.+)/ const XPATH_SELECTORS_START = [ '/', '(', '../', './', '*/' ] const NAME_MOBILE_SELECTORS_START = [ 'uia', 'xcuielementtype', 'android.widget', 'cyi', 'android.view' ] const XPATH_SELECTOR_REGEXP = [ // HTML tag /^([a-z0-9|-]*)/, // optional . or # + class or id /(?:(\.|#)(-?[_a-zA-Z]+[_a-zA-Z0-9-]*))?/, // optional [attribute-name="attribute-selector"] /(?:\[(-?[_a-zA-Z]+[_a-zA-Z0-9-]*)(?:=(?:"|')([a-zA-Z0-9\-_. ]+)(?:"|'))?\])?/, // optional case insensitive /(\.)?/, // *=query or =query /(\*)?=(.+)$/, ] const IMAGEPATH_MOBILE_SELECTORS_ENDSWITH = [ '.jpg', '.jpeg', '.gif', '.png', '.bmp', '.svg' ] type SelectorStrategy = string | { name: string, args: string } const defineStrategy = function (selector: SelectorStrategy) { // Condition with checking isPlainObject(selector) should be first because // in case of "selector" argument is a plain object then .match() will cause // an error like "selector.match is not a function" // Use '-android datamatcher' or '-android viewmatcher' strategy if selector is a plain object (Android only) if (typeof selector === 'object') { if (JSON.stringify(selector).indexOf('test.espresso.matcher.ViewMatchers') < 0) { return '-android datamatcher' } return '-android viewmatcher' } const stringSelector = selector as string // Check if user has specified locator strategy directly if (DIRECT_SELECTOR_REGEXP.test(stringSelector)) { return 'directly' } // Use appium image strategy if selector ends with certain text(.jpg,.gif..) if (IMAGEPATH_MOBILE_SELECTORS_ENDSWITH.some(path => { const selector = stringSelector.toLowerCase() return selector.endsWith(path) && selector !== path })) { return '-image' } // Use xPath strategy if selector starts with // if (XPATH_SELECTORS_START.some(option => stringSelector.startsWith(option))) { return 'xpath' } // Use link text strategy if selector starts with = if (stringSelector.startsWith('=')) { return 'link text' } // Use partial link text strategy if selector starts with *= if (stringSelector.startsWith('*=')) { return 'partial link text' } // Use id strategy if the selector starts with id= if (stringSelector.startsWith('id=')) { return 'id' } // use shadow dom selector if (stringSelector.startsWith(DEEP_SELECTOR)) { return 'shadow' } // use aria selector if (stringSelector.startsWith(ARIA_SELECTOR)) { return 'aria' } // Recursive element search using the UiAutomator library (Android only) if (stringSelector.startsWith('android=')) { return '-android uiautomator' } // Recursive element search using the UIAutomation library (iOS-only) if (stringSelector.startsWith('ios=')) { return '-ios uiautomation' } // Recursive element search using accessibility id if (stringSelector.startsWith('~')) { return 'accessibility id' } // Class name mobile selector // for iOS = UIA... // for Android = android.widget if (NAME_MOBILE_SELECTORS_START.some(option => stringSelector.toLowerCase().startsWith(option))) { return 'class name' } // Use tag name strategy if selector contains a tag // e.g. "
" or "
" if (stringSelector.search(/<[0-9a-zA-Z-]+( \/)*>/g) >= 0) { return 'tag name' } // Use name strategy if selector queries elements with name attributes for JSONWP // or if isMobile is used even when w3c is used // e.g. "[name='myName']" or '[name="myName"]' if (stringSelector.search(/^\[name=(?:"(.[^"]*)"|'(.[^']*)')]$/) >= 0) { return 'name' } // Allow to move up to the parent or select current element if (selector === '..' || selector === '.') { return 'xpath' } // Any element with given class, id, or attribute and content // e.g. h1.header=Welcome or [data-name=table-row]=Item or #content*=Intro if (stringSelector.match(new RegExp(XPATH_SELECTOR_REGEXP.map(rx => rx.source).join('')))) { return 'xpath extended' } if (/^\[role=[A-Za-z]+]$/.test(stringSelector)){ return 'role' } } export const findStrategy = function (selector: SelectorStrategy, isW3C?: boolean, isMobile?: boolean) { const stringSelector = selector as string let using: string = DEFAULT_STRATEGY let value = selector as string switch (defineStrategy(selector)) { // user has specified locator strategy directly case 'directly': { const match = stringSelector.match(DIRECT_SELECTOR_REGEXP) if (!match) { throw new Error('InvalidSelectorStrategy') // ToDo: move error to wdio-error package } using = match[1] value = match[2] break } case 'xpath': { using = 'xpath' break } case 'id': { using = 'id' value = stringSelector.slice(3) break } case 'link text': { using = 'link text' value = stringSelector.slice(1) break } case 'partial link text': { using = 'partial link text' value = stringSelector.slice(2) break } case 'shadow': using = 'shadow' value = stringSelector.slice(DEEP_SELECTOR.length) break case 'aria': { const label = stringSelector.slice(ARIA_SELECTOR.length) const conditions = [ // aria label is recevied by other element with aria-labelledBy // https://www.w3.org/TR/accname-1.1/#step2B `.//*[@aria-labelledby=(//*[normalize-space(text()) = "${label}"]/@id)]`, // aria label is recevied by other element with aria-labelledBy // https://www.w3.org/TR/accname-1.1/#step2B `.//*[@aria-describedby=(//*[normalize-space(text()) = "${label}"]/@id)]`, // element has direct aria label // https://www.w3.org/TR/accname-1.1/#step2C `.//*[@aria-label = "${label}"]`, // input and textarea with a label // https://www.w3.org/TR/accname-1.1/#step2D `.//input[@id = (//label[normalize-space() = "${label}"]/@for)]`, `.//textarea[@id = (//label[normalize-space() = "${label}"]/@for)]`, // input and textarea with a label as parent // https://www.w3.org/TR/accname-1.1/#step2D `.//input[ancestor::label[normalize-space(text()) = "${label}"]]`, `.//textarea[ancestor::label[normalize-space(text()) = "${label}"]]`, // aria label is received by a placeholder // https://www.w3.org/TR/accname-1.1/#step2D `.//input[@placeholder="${label}"]`, `.//textarea[@placeholder="${label}"]`, // aria label is received by a aria-placeholder // https://www.w3.org/TR/accname-1.1/#step2D `.//input[@aria-placeholder="${label}"]`, `.//textarea[@aria-placeholder="${label}"]`, // aria label is received by a title // https://www.w3.org/TR/accname-1.1/#step2D `.//*[not(self::label)][@title="${label}"]`, // images with an alt tag // https://www.w3.org/TR/accname-1.1/#step2D `.//img[@alt="${label}"]`, // aria label is received from element text content // https://www.w3.org/TR/accname-1.1/#step2G `.//*[not(self::label)][normalize-space(text()) = "${label}"]` ] using = 'xpath' value = conditions.join(' | ') break } case '-android uiautomator': { using = '-android uiautomator' value = stringSelector.slice(8) break } case '-android datamatcher': { using = '-android datamatcher' value = JSON.stringify(value) break } case '-android viewmatcher': { using = '-android viewmatcher' value = JSON.stringify(value) break } case '-ios uiautomation': { using = '-ios uiautomation' value = stringSelector.slice(4) break } case 'accessibility id': { using = 'accessibility id' value = stringSelector.slice(1) break } case 'class name': { using = 'class name' break } case 'tag name': { using = 'tag name' value = stringSelector.replace(/<|>|\/|\s/g, '') break } case 'name': { if (isMobile || !isW3C) { const match = stringSelector.match(/^\[name=(?:"(.[^"]*)"|'(.[^']*)')]$/) if (!match) { throw new Error(`InvalidSelectorMatch. Strategy 'name' has failed to match '${stringSelector}'`) } using = 'name' value = match[1] || match[2] } break } case 'xpath extended': { using = 'xpath' const match = stringSelector.match(new RegExp(XPATH_SELECTOR_REGEXP.map(rx => rx.source).join(''))) if (!match) { throw new Error(`InvalidSelectorMatch: Strategy 'xpath extended' has failed to match '${stringSelector}'`) } const PREFIX_NAME: Record = { '.': 'class', '#': 'id' } const conditions: Array = [] const [ tag, prefix, name, attrName, attrValue, insensitive, partial, query ] = match.slice(1) if (prefix) { if (prefix === '.') { // trick to match a class name exactly conditions.push(`contains(concat(" ",@${PREFIX_NAME[prefix]}," "), " ${name} ")`) } else { conditions.push(`contains(@${PREFIX_NAME[prefix]}, "${name}")`) } } if (attrName) { conditions.push( attrValue ? `contains(@${attrName}, "${attrValue}")` : `@${attrName}` ) } const partialNot = ` and not(${`.//${tag || '*'}${conditions.length ? `[${conditions.join(' and ')}]` : ''}`})` if (insensitive) { conditions.push( partial ? `contains(translate(., "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), "${query.toLowerCase()}")${partialNot}` : `normalize-space(translate(text(), "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")) = "${query.toLowerCase()}"`) } else { conditions.push(partial ? `contains(., "${query}")${partialNot}` : `normalize-space(text()) = "${query}"`) } const getValue = () => `.//${tag || '*'}[${conditions.join(' and ')}]` value = getValue() if (!partial) { conditions.pop() conditions.push( `not(${value})`, `normalize-space() = "${insensitive? query.toLowerCase() : query}"` ) value = value + ' | ' + getValue() } break } case '-image': { using = '-image' value = environment.value.readFileSync(stringSelector, { encoding: 'base64' }) break } case 'role': { const match = stringSelector.match(/^\[role=(.+)\]/) if (!match) { throw new Error(`InvalidSelectorMatch. Strategy 'role' has failed to match '${stringSelector}'`) } using = 'css selector' value = createRoleBaseXpathSelector(match[1] as ARIARoleDefinitionKey) break } } return { using, value } } const createRoleBaseXpathSelector = (role: ARIARoleDefinitionKey) => { const locatorArr: string[] = [] roleElements.get(role)?.forEach((value: ARIARoleRelationConcept) => { let locator: string let tagAttribute: string | undefined, tagAttributevalue: string | number | undefined const tagname: string = value.name if (value.attributes instanceof Array) { value.attributes.forEach((val: ARIARoleRelationConceptAttribute) => { tagAttribute = val.name tagAttributevalue = val.value }) } if (!tagAttribute) { locator = tagname } else if (!tagAttributevalue){ locator = `${tagname}[${tagAttribute}]` } else { locator = `${tagname}[${tagAttribute}="${tagAttributevalue}"]` } locatorArr.push(locator) }) let xpathLocator: string = `[role="${role}"]` locatorArr.forEach((loc) => { xpathLocator += ',' + loc }) return xpathLocator }