import cssValue from 'css-value'
import rgb2hex from 'rgb2hex'
import GraphemeSplitter from 'grapheme-splitter'
import logger from '@wdio/logger'
import isPlainObject from 'is-plain-obj'
import { type remote, ELEMENT_KEY } from 'webdriver'
import { UNICODE_CHARACTERS, asyncIterators, getBrowserObject } from '@wdio/utils'
import type { ElementReference } from '@wdio/protocols'
import * as browserCommands from '../commands/browser.js'
import * as elementCommands from '../commands/element.js'
import elementContains from '../scripts/elementContains.js'
import querySelectorAllDeep from './thirdParty/querySelectorShadowDom.js'
import { SCRIPT_PREFIX, SCRIPT_SUFFIX } from '../commands/constant.js'
import { DEEP_SELECTOR, Key } from '../constants.js'
import { findStrategy } from './findStrategy.js'
import { getShadowRootManager, type ShadowRootManager } from '../session/shadowRoot.js'
import { getContextManager } from '../session/context.js'
import type { ElementFunction, Selector, ParsedCSSValue, CustomLocatorReturnValue } from '../types.js'
import type { CustomStrategyReference, ExtendedElementReference } from '../types.js'
const log = logger('webdriverio')
const INVALID_SELECTOR_ERROR = 'selector needs to be typeof `string` or `function`'
const IGNORED_COMMAND_FILE_EXPORTS = ['SESSION_MOCKS', 'CDP_SESSIONS']
declare global {
interface Window { __wdio_element: Record<string, HTMLElement> }
}
const scopes = {
browser: browserCommands,
element: elementCommands
}
const applyScopePrototype = (
prototype: Record<string, PropertyDescriptor>,
scope: 'browser' | 'element') => {
Object.entries(scopes[scope])
.filter(([exportName]) => !IGNORED_COMMAND_FILE_EXPORTS.includes(exportName))
.forEach(([commandName, command]) => {
prototype[commandName] = { value: command }
})
}
* enhances objects with element commands
*/
export const getPrototype = (scope: 'browser' | 'element') => {
const prototype: Record<string, PropertyDescriptor> = {
* used to store the puppeteer instance in the browser scope
*/
puppeteer: { value: null, writable: true }
}
if (scope === 'browser') {
* Returns a boolean if the current context is the Mobile native context
*/
prototype.isNativeContext = {
get: function (this: WebdriverIO.Browser) {
const context = getContextManager(this)
return context.isNativeContext
}
}
* Returns the current mobile context which could be `NATIVE_APP` or `WEBVIEW_***`
*/
prototype.mobileContext = {
get: function (this: WebdriverIO.Browser) {
const context = getContextManager(this)
return context.mobileContext
}
}
}
* register action commands
*/
applyScopePrototype(prototype, scope)
prototype.strategies = { value: new Map() }
return prototype
}
* get element id from WebDriver response
* @param {?Object|undefined} res body object from response or null
* @return {?string} element id or null if element couldn't be found
*/
export const getElementFromResponse = (res?: ElementReference) => {
* a function selector can return null
*/
if (!res) {
return null
}
* deprecated JSONWireProtocol response
*/
if ((res as unknown as { ELEMENT: string }).ELEMENT) {
return (res as unknown as { ELEMENT: string }).ELEMENT
}
* W3C WebDriver response
*/
if (res[ELEMENT_KEY]) {
return res[ELEMENT_KEY]
}
return null
}
function sanitizeCSS (value?: string) {
if (!value) {
return value
}
return value.trim().replace(/'/g, '').replace(/"/g, '').toLowerCase()
}
* parse css values to a better format
* @param {string} cssPropertyValue result of WebDriver call
* @param {string} cssProperty name of css property to parse
* @return {object} parsed css property
*/
export function parseCSS (cssPropertyValue: string, cssProperty?: string) {
const parsedValue: ParsedCSSValue = {
property: cssProperty,
value: cssPropertyValue.toLowerCase().trim(),
parsed: {}
}
if (parsedValue.value?.indexOf('rgb') === 0) {
* remove whitespace in rgb values
*/
parsedValue.value = parsedValue.value.replace(/\s/g, '')
* parse color values
*/
const color = parsedValue.value
parsedValue.parsed = rgb2hex(parsedValue.value)
parsedValue.parsed.type = 'color'
const colorType = /[rgba]+/g.exec(color) || []
parsedValue.parsed[colorType[0] as 'rgb' | 'rgba'] = color
} else if (parsedValue.property === 'font-family') {
const font = cssValue(cssPropertyValue)
const string = parsedValue.value
const value = cssPropertyValue.split(/,/).map(sanitizeCSS)
parsedValue.value = sanitizeCSS(font[0].value as string || font[0].string)
parsedValue.parsed = { value, type: 'font', string }
} else {
* parse other css properties
*/
try {
const value = cssValue(cssPropertyValue)
if (value.length === 1) {
parsedValue.parsed = value[0]
}
if (parsedValue.parsed.type && parsedValue.parsed.type === 'number' && parsedValue.parsed.unit === '') {
parsedValue.value = parsedValue.parsed.value
}
} catch {
}
}
return parsedValue
}
* check for unicode character or split string into literals
* @param {string} value text
* @return {Array} set of characters or unicode symbols
*/
export function checkUnicode (value: string) {
* "Ctrl" key is specially handled based on OS in action class
*/
if (value === Key.Ctrl) {
return [value]
}
* when sending emoji characters like 😄 or a value that is not a special character defined
* by the WebDriver protocol
*/
if (!Object.prototype.hasOwnProperty.call(UNICODE_CHARACTERS, value)) {
return new GraphemeSplitter().splitGraphemes(value)
}
return [UNICODE_CHARACTERS[value as keyof typeof UNICODE_CHARACTERS]]
}
function fetchElementByJSFunction (
selector: ElementFunction,
scope: WebdriverIO.Browser | WebdriverIO.Element,
referenceId?: string
): Promise<ElementReference | ElementReference[]> {
if (!('elementId' in scope)) {
return scope.execute(selector as any, referenceId)
}
* use a regular function because IE does not understand arrow functions
*/
const script = (function (elem: HTMLElement, id: string) {
return (selector as unknown as Function).call(elem, id)
}).toString().replace('selector', `(${selector.toString()})`)
const args: (WebdriverIO.Element | string)[] = [scope as WebdriverIO.Element]
if (referenceId) {
args.push(referenceId)
}
return getBrowserObject(scope).executeScript(`return (${script}).apply(null, arguments)`, args)
}
export function isElement (o: Selector){
return (
typeof HTMLElement === 'object'
? o instanceof HTMLElement
: o && typeof o === 'object' && o !== null && (o as HTMLElement).nodeType === 1 && typeof (o as HTMLElement).nodeName==='string'
)
}
export function isStaleElementError (err: Error) {
return (
err.message.includes('stale element reference') ||
err.message.includes('is no longer attached to the DOM') ||
err.message.toLowerCase().includes('stale element found') ||
err.message.includes('stale element not found in the current frame') ||
err.message.includes('belongs to different document')
)
}
* handle promise result (resolved or rejected promises)
* @param handle browsing context
* @param shadowRootManager instance of ShadowRootManager
* @param shadowRootId shadow root id that was inspected
* @returns a function to handle the result of a shadow root inspection
*/
export function elementPromiseHandler <T extends object>(handle: string, shadowRootManager: ShadowRootManager, shadowRootId?: string) {
return (el: T | Error) => {
const errorString = 'error' in el && typeof el.error === 'string'
? el.error
: 'message' in el && typeof el.message === 'string'
? el.message
: undefined
if (errorString) {
* clear up shadow root if it's not attached to the DOM anymore
*/
if (shadowRootId && errorString.includes('detached shadow root')) {
shadowRootManager.deleteShadowRoot(shadowRootId, handle)
}
return
}
return el as T
}
}
export function transformClassicToBidiSelector (using: string, value: string): remote.BrowsingContextCssLocator | remote.BrowsingContextXPathLocator | remote.BrowsingContextInnerTextLocator {
if (using === 'css selector' || using === 'tag name') {
return { type: 'css', value }
}
if (using === 'xpath') {
return { type: 'xpath', value }
}
if (using === 'link text') {
return { type: 'innerText', value }
}
if (using === 'partial link text') {
return { type: 'innerText', value, matchType: 'partial' }
}
throw new Error(`Can't transform classic selector ${using} to Bidi selector`)
}
* Parallel look up of a selector within multiple shadow roots
* @param this WebdriverIO Browser or Element instance
* @param selector selector to look up
* @param isMulti set to true if you call from `$$` command
* @returns a list of shadow root ids with their corresponding matches or undefined if not found
*/
export async function findDeepElement(
this: WebdriverIO.Browser | WebdriverIO.Element,
selector: Selector
): Promise<ElementReference | undefined> {
const browser = getBrowserObject(this)
const shadowRootManager = getShadowRootManager(browser)
const contextManager = getContextManager(browser)
const context = await contextManager.getCurrentContext()
const shadowRoots = shadowRootManager.getShadowElementsByContextId(
context,
(this as WebdriverIO.Element).elementId
)
const { using, value } = findStrategy(selector as string, this.isW3C, this.isMobile)
const locator = transformClassicToBidiSelector(using, value)
* look up selector within document and all shadow roots
*/
const startNodes = shadowRoots.length > 0
? shadowRoots.map((shadowRootNodeId) => ({ sharedId: shadowRootNodeId }))
: (this as WebdriverIO.Element).elementId
? [{ sharedId: (this as WebdriverIO.Element).elementId }]
: undefined
const deepElementResult = await browser.browsingContextLocateNodes({ locator, context, startNodes }).then(async (result) => {
let nodes: ExtendedElementReference[] = result.nodes.filter((node) => Boolean(node.sharedId)).map((node) => ({
[ELEMENT_KEY]: node.sharedId as string,
locator
}))
nodes = returnUniqueNodes(nodes)
if (!(this as WebdriverIO.Element).elementId) {
return nodes[0]
}
* determine if node is within tree of current element
*/
const scopedNodes = await Promise.all(nodes.map(async (node) => {
const isIn = await browser.execute(
elementContains,
{ [ELEMENT_KEY]: (this as WebdriverIO.Element).elementId } as unknown as HTMLElement,
node as unknown as HTMLElement
)
return [isIn, node]
})).then((elems) => elems.filter(([isIn]) => isIn).map(([, elem]) => elem)) as ExtendedElementReference[]
return scopedNodes[0]
}, (err) => {
log.warn(`Failed to execute browser.browsingContextLocateNodes({ ... }) due to ${err}, falling back to regular WebDriver Classic command`)
return this && 'elementId' in this && this.elementId
? this.findElementFromElement(this.elementId, using, value)
: browser.findElement(using, value)
})
return deepElementResult
}
* Parallel look up of a selector within multiple shadow roots
* @param this WebdriverIO Browser or Element instance
* @param selector selector to look up
* @param isMulti set to true if you call from `$$` command
* @returns a list of shadow root ids with their corresponding matches or undefined if not found
*/
export async function findDeepElements(
this: WebdriverIO.Browser | WebdriverIO.Element,
selector: Selector
): Promise<ElementReference[]> {
const browser = getBrowserObject(this)
const shadowRootManager = getShadowRootManager(browser)
const contextManager = getContextManager(browser)
const context = await contextManager.getCurrentContext()
const shadowRoots = shadowRootManager.getShadowElementsByContextId(
context,
(this as WebdriverIO.Element).elementId
)
const { using, value } = findStrategy(selector as string, this.isW3C, this.isMobile)
const locator = transformClassicToBidiSelector(using, value)
* look up selector within document and all shadow roots
*/
const startNodes = shadowRoots.length > 0
? shadowRoots.map((shadowRootNodeId) => ({ sharedId: shadowRootNodeId }))
: (this as WebdriverIO.Element).elementId
? [{ sharedId: (this as WebdriverIO.Element).elementId }]
: undefined
const deepElementResult = await browser.browsingContextLocateNodes({ locator, context, startNodes }).then(async (result) => {
let nodes: ExtendedElementReference[] = result.nodes.filter((node) => Boolean(node.sharedId))
.map((node) => ({
[ELEMENT_KEY]: node.sharedId as string,
locator
}))
nodes = returnUniqueNodes(nodes)
if (!(this as WebdriverIO.Element).elementId) {
return nodes
}
* determine if node is within tree of current element
*/
const scopedNodes = await Promise.all(nodes.map(async (node) => {
const isIn = await browser.execute(
elementContains,
{ [ELEMENT_KEY]: (this as WebdriverIO.Element).elementId } as unknown as HTMLElement,
node as unknown as HTMLElement
)
return [isIn, node]
})).then((elems) => elems.filter(([isIn]) => isIn).map(([, elem]) => elem))
return scopedNodes
}, (err) => {
log.warn(`Failed to execute browser.browsingContextLocateNodes({ ... }) due to ${err}, falling back to regular WebDriver Classic command`)
return this && 'elementId' in this && this.elementId
? this.findElementsFromElement(this.elementId, using, value)
: browser.findElements(using, value)
})
return deepElementResult as ElementReference[]
}
* Temporary patch for https://github.com/mozilla/geckodriver/issues/2223
*/
function returnUniqueNodes(nodes: ExtendedElementReference[]): ExtendedElementReference[] {
const ids = new Set()
return nodes.filter((node) => !ids.has(node[ELEMENT_KEY]) && ids.add(node[ELEMENT_KEY]))
}
* logic to find an element
* Note: the order of if statements matters
*/
export async function findElement(
this: WebdriverIO.Browser | WebdriverIO.Element,
selector: Selector
) {
const browserObject = getBrowserObject(this)
const shadowRootManager = getShadowRootManager(browserObject)
* do a deep lookup if
* - we are using Bidi
* - have a string selector
* - that is not a deep selector
* - and we are not in an iframe (because it is currently not supported to locate nodes in an iframe via Bidi)
*/
if (this.isBidi && typeof selector === 'string' && !selector.startsWith(DEEP_SELECTOR) && !shadowRootManager.isWithinFrame()) {
const notFoundError = new Error(`Couldn't find element with selector "${selector}"`)
const elem = await findDeepElement.call(this, selector)
return getElementFromResponse(elem) ? elem : notFoundError
}
* check if shadow DOM integration is used
*/
if (typeof selector === 'string' && selector.startsWith(DEEP_SELECTOR)) {
const notFoundError = new Error(`shadow selector "${selector.slice(DEEP_SELECTOR.length)}" did not return an HTMLElement`)
let elem: ElementReference | ElementReference[] = await browserObject.execute(
querySelectorAllDeep,
false,
selector.slice(DEEP_SELECTOR.length),
((this as WebdriverIO.Element).elementId ? this : undefined) as unknown as Element | Document
)
elem = Array.isArray(elem) ? elem[0] : elem
return getElementFromResponse(elem) ? elem : notFoundError
}
* fetch element using custom strategy function
*/
if (selector && typeof selector === 'object' && typeof (selector as CustomStrategyReference).strategy === 'function') {
const { strategy, strategyName, strategyArguments } = selector as CustomStrategyReference
const notFoundError = new Error(`Custom Strategy "${strategyName}" did not return an HTMLElement`)
let elem = await browserObject.execute(strategy, ...strategyArguments)
elem = Array.isArray(elem) ? elem[0] : elem
return getElementFromResponse(elem) ? elem : notFoundError
}
* fetch element using regular protocol command
*/
if (typeof selector === 'string' || isPlainObject(selector)) {
const { using, value } = findStrategy(selector as string, this.isW3C, this.isMobile)
return (this as WebdriverIO.Element).elementId
// casting to any necessary given weak type support of protocol commands
? this.findElementFromElement((this as WebdriverIO.Element).elementId, using, value) as unknown as ElementReference
: this.findElement(using, value) as unknown as ElementReference
}
* fetch element with JS function
*/
if (typeof selector === 'function') {
const notFoundError = new Error(`Function selector "${selector.toString()}" did not return an HTMLElement`)
let elem = await fetchElementByJSFunction(selector, this)
elem = Array.isArray(elem) ? elem[0] : elem
return getElementFromResponse(elem) ? elem : notFoundError
}
* handle DOM element transformation
* Note: this runs in the browser
*/
if (isElement(selector)) {
if (!window.__wdio_element) {
window.__wdio_element = {}
}
const notFoundError = new Error('DOM Node couldn\'t be found anymore')
const uid = Math.random().toString().slice(2)
window.__wdio_element[uid] = selector as HTMLElement
selector = ((id: string) => window.__wdio_element[id]) as unknown as ElementFunction
let elem = await fetchElementByJSFunction(selector, this, uid).catch((err) => {
* WebDriver throws a stale element reference error if the element is not found
* and therefor can't be serialized
*/
if (isStaleElementError(err)) {
return undefined
}
throw err
})
elem = Array.isArray(elem) ? elem[0] : elem
return getElementFromResponse(elem) ? elem : notFoundError
}
throw new Error(`${INVALID_SELECTOR_ERROR}, but found: \`${typeof selector}\``)
}
* logic to find a elements
*/
export async function findElements(
this: WebdriverIO.Browser | WebdriverIO.Element,
selector: Selector
) {
const browserObject = getBrowserObject(this)
* check if shadow DOM integration is used
*/
if (typeof selector === 'string' && selector.startsWith(DEEP_SELECTOR)) {
const elems: ElementReference | ElementReference[] = await browserObject.execute(
querySelectorAllDeep,
true,
selector.slice(DEEP_SELECTOR.length),
((this as WebdriverIO.Element).elementId ? this : undefined) as unknown as Element | Document
)
const elemArray = Array.isArray(elems) ? elems : [elems]
return elemArray.filter((elem) => elem && getElementFromResponse(elem))
}
* fetch elements using custom strategy function
*/
if (isPlainObject(selector) && typeof (selector as CustomStrategyReference).strategy === 'function') {
const { strategy, strategyArguments } = selector as CustomStrategyReference
const elems = await browserObject.execute(strategy, ...strategyArguments)
const elemArray = Array.isArray(elems) ? elems as ElementReference[] : [elems]
return elemArray.filter((elem) => elem && getElementFromResponse(elem))
}
* fetch element using regular protocol command
*/
if (typeof selector === 'string' || isPlainObject(selector)) {
const { using, value } = findStrategy(selector as string, this.isW3C, this.isMobile)
return (this as WebdriverIO.Element).elementId
// casting to any necessary given weak type support of protocol commands
? this.findElementsFromElement((this as WebdriverIO.Element).elementId, using, value) as unknown as ElementReference[]
: this.findElements(using, value) as unknown as ElementReference[]
}
* fetch element with JS function
*/
if (typeof selector === 'function') {
const elems = await fetchElementByJSFunction(selector, this)
const elemArray = Array.isArray(elems) ? elems as ElementReference[] : [elems]
return elemArray.filter((elem) => elem && getElementFromResponse(elem))
}
throw new Error(`${INVALID_SELECTOR_ERROR}, but found: \`${typeof selector}\``)
}
* Strip element object and return w3c and jsonwp compatible keys
*/
export function verifyArgsAndStripIfElement(args: unknown) {
function verify (arg: unknown) {
if (arg && typeof arg === 'object' && arg.constructor.name === 'Element') {
const elem = arg as WebdriverIO.Element
if (!elem.elementId) {
throw new Error(`The element with selector "${elem.selector}" you are trying to pass into the execute method wasn't found`)
}
return {
[ELEMENT_KEY]: elem.elementId,
ELEMENT: elem.elementId
}
}
return arg
}
return !Array.isArray(args) ? verify(args) : args.map(verify)
}
* getElementRect
*/
export async function getElementRect(scope: WebdriverIO.Element) {
const rect = await scope.getElementRect(scope.elementId)
const defaults = { x: 0, y: 0, width: 0, height: 0 }
* getElementRect workaround for Safari 12.0.3
* if one of [x, y, height, width] is undefined get rect with javascript
*/
if (Object.keys(defaults).some((key: keyof typeof defaults) => rect[key] === undefined)) {
const rectJs = await getBrowserObject(scope).execute(function (this: Window, el: HTMLElement) {
if (!el || !el.getBoundingClientRect) {
return
}
const { left, top, width, height } = el.getBoundingClientRect()
return {
x: left + this.scrollX,
y: top + this.scrollY,
width,
height
}
}, scope as unknown as HTMLElement)
Object.keys(defaults).forEach((key: keyof typeof defaults) => {
if (typeof rect[key] !== 'undefined') {
return
}
if (rectJs && typeof rectJs[key] === 'number') {
rect[key] = Math.floor(rectJs[key])
} else {
log.error('getElementRect', { rect, rectJs, key })
throw new Error('Failed to receive element rects via execute command')
}
})
}
return rect
}
* check if urls are valid and fix them if necessary
* @param {string} url url to navigate to
* @param {Boolean} [retryCheck=false] true if an url was already check and still failed with fix applied
* @return {string} fixed url
*/
export function validateUrl (url: string, origError?: Error): string {
try {
const urlObject = new URL(url)
return urlObject.href
} catch {
* if even adding http:// doesn't help, fail with original error
*/
if (origError) {
throw origError
}
return validateUrl(`http://${url}`, new Error(`Invalid URL: ${url}`))
}
}
export async function hasElementId (element: WebdriverIO.Element) {
* This is only necessary as isDisplayed is on the exclusion list for the middleware
*/
if (!element.elementId) {
const command = element.isReactElement
? element.parent.react$.bind(element.parent)
: element.isShadowElement
? element.parent.shadow$.bind(element.parent)
: element.parent.$.bind(element.parent)
element.elementId = (await command(element.selector as string).getElement()).elementId
}
* if element was still not found it also is not displayed
*/
if (!element.elementId) {
return false
}
return true
}
export function addLocatorStrategyHandler(scope: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser) {
return (name: string, func: (selector: string, root?: HTMLElement) => CustomLocatorReturnValue) => {
if (scope.strategies.get(name)) {
throw new Error(`Strategy ${name} already exists`)
}
scope.strategies.set(name, func)
}
}
type Entries<T> = {
[K in keyof T]: [K, T[K]];
}[keyof T][]
* Enhance elements array with data required to refetch it
* @param {object[]} elements elements
* @param {object} parent element or browser
* @param {string|Function} selector string or function, or strategy name for `custom$$`
* @param {string} foundWith name of the command elements were found with, ex `$$`, `react$$`, etc
* @param {Array} props additional properties required to fetch elements again
* @returns {object[]} elements
*/
export const enhanceElementsArray = (
elements: WebdriverIO.Element[],
parent: WebdriverIO.Browser | WebdriverIO.Element,
selector: Selector | ElementReference[] | WebdriverIO.Element[],
foundWith = '$$',
props: unknown[] = []
) => {
* as we enhance the element array in this method we need to cast its
* type as well
*/
const elementArray = elements as unknown as WebdriverIO.ElementArray
* if we have an element collection, e.g. `const elems = $$([elemA, elemB])`
* we can't assign a common selector to the element array
*/
if (!Array.isArray(selector)) {
elementArray.selector = selector
}
* if all elements have the same selector we actually can assign a selector
*/
const elems = selector as WebdriverIO.Element[]
if (Array.isArray(selector) && elems.length && elems.every((elem) => elem.selector && elem.selector === elems[0].selector)) {
elementArray.selector = elems[0].selector
}
* replace Array prototype methods with custom ones that support
* async iterators
*/
for (const [name, fn] of Object.entries(asyncIterators) as Entries<typeof asyncIterators>) {
* ToDo(Christian): typing fails here for unknown reason
*/
elementArray[name] = fn.bind(null, elementArray as unknown)
}
elementArray.parent = parent
elementArray.foundWith = foundWith
elementArray.props = props
elementArray.getElements = async () => elementArray
return elementArray
}
* is protocol stub
* @param {string} automationProtocol
*/
export const isStub = (automationProtocol?: string) => automationProtocol === './protocol-stub.js'
* compare if an object (`base`) contains the same values as another object (`match`)
* @param {object} base object to compare to
* @param {object} match object that needs to match thebase
*/
export const containsHeaderObject = (
base: Record<string, string>,
match: Record<string, string>
) => {
for (const [key, value] of Object.entries(match)) {
if (typeof base[key] === 'undefined' || base[key] !== value) {
return false
}
}
return true
}
export function createFunctionDeclarationFromString (userScript: Function | string) {
if (typeof userScript === 'string') {
return `(${SCRIPT_PREFIX}function () {\n${userScript.toString()}\n}${SCRIPT_SUFFIX}).apply(this, arguments);`
}
return new Function(`return (${SCRIPT_PREFIX}${userScript.toString()}${SCRIPT_SUFFIX}).apply(this, arguments);`).toString()
}