webdriverio/packages/wdio-utils/src/utils.ts
2025-09-07 23:35:57 +00:00

439 lines
16 KiB
TypeScript

import fs from 'node:fs/promises'
import url from 'node:url'
import path from 'node:path'
import type { Options, Services } from '@wdio/types'
import { SUPPORTED_BROWSERNAMES, DEFAULT_PROTOCOL, DEFAULT_HOSTNAME, DEFAULT_PATH } from './constants.js'
const SCREENSHOT_REPLACEMENT = '"<Screenshot[base64]>"'
const SCRIPT_PLACEHOLDER = '"<Script[base64]>"'
const REGEX_SCRIPT_NAME = /return \((async )?function (\w+)/
export const SLASH = '/'
export const REG_EXP_WINDOWS_ABS_PATH = /^[A-Za-z]:\\/
function assertPath(path?: unknown) {
if (typeof path !== 'string') {
throw new TypeError('Path must be a string. Received ' + JSON.stringify(path))
}
}
export function isAbsolute(p: string) {
assertPath(p)
return p.length > 0 && (p.charCodeAt(0) === SLASH.codePointAt(0) || REG_EXP_WINDOWS_ABS_PATH.test(p))
}
/**
* overwrite native element commands with user defined
* @param {object} propertiesObject propertiesObject
*/
export function overwriteElementCommands(propertiesObject: { '__elementOverrides__'?: { value: Record<string, unknown> }, [key: string]: unknown }) {
const elementOverrides = propertiesObject.__elementOverrides__
? propertiesObject.__elementOverrides__.value
: {}
for (const [commandName, userDefinedCommand] of Object.entries(elementOverrides)) {
if (typeof userDefinedCommand !== 'function') {
throw new Error('overwriteCommand: commands be overwritten only with functions, command: ' + commandName)
}
if (!propertiesObject[commandName]) {
throw new Error('overwriteCommand: no command to be overwritten: ' + commandName)
}
const propertiesObjectCommand = (propertiesObject[commandName] as { value: unknown }).value
if (typeof propertiesObjectCommand !== 'function') {
throw new Error('overwriteCommand: only functions can be overwritten, command: ' + commandName)
}
const origCommand = propertiesObjectCommand
delete propertiesObject[commandName]
const newCommand = function (this: WebdriverIO.Browser, ...args: unknown[]) {
const element = this
return userDefinedCommand.apply(element, [
function origCommandFunction (this: WebdriverIO.Browser, ..._args: unknown[]) {
const context = this || element // respect explicite context binding, use element as default
// eslint-disable-next-line prefer-rest-params
return origCommand.apply(context, arguments)
},
...args
])
}
propertiesObject[commandName] = {
value: newCommand,
configurable: true
}
}
delete propertiesObject.__elementOverrides__
propertiesObject.__elementOverrides__ = { value: {} }
}
/**
* get command call structure
* (for logging purposes)
*/
export function commandCallStructure (commandName: string, args: unknown[], unfurl = false) {
const callArgs = args.map((arg) => {
if (
typeof arg === 'string' &&
/**
* The regex pattern matches:
* - Regular functions: `function()` or `function foo()`
* - Async functions: `async function()` or `async function foo()`
* - IIFEs: `!function()`
* - Returned functions: `return function` or `return (function`
* - Returned async functions: `return async function` or `return (async function`
* - Arrow functions: `() =>` or `param =>` or `(param1, param2) =>`
*/
/^\s*(?:(?:async\s+)?function(?:\s+\w+)?\s*\(|!function\(|return\s+\(?(?:async\s+)?function|\([^)]*\)\s*=>|\w+\s*=>)/.test(arg.trim())
) {
arg = '<fn>'
} else if (
typeof arg === 'string' &&
/**
* the isBase64 method returns for xPath values like
* "/html/body/a" a true value which is why we should
* include a command check in here.
*/
!commandName.startsWith('findElement') &&
/**
* the isBase64 method returns for the argument value like
* "9A562133B0552E0ECB7628F2E8A09E86" a true value which is
* why we should include a command check in here.
*/
!commandName.startsWith('switch') &&
isBase64(arg)
) {
arg = SCREENSHOT_REPLACEMENT
} else if (typeof arg === 'string') {
arg = `"${arg}"`
} else if (typeof arg === 'function') {
arg = '<fn>'
} else if (arg === null) {
arg = 'null'
} else if (typeof arg === 'object') {
arg = unfurl ? JSON.stringify(arg) : '<object>'
} else if (typeof arg === 'undefined') {
arg = typeof arg
}
return arg
}).join(', ')
return `${commandName}(${callArgs})`
}
/**
* transforms WebDriver result for log stream to avoid unnecessary long
* result strings e.g. if it contains a screenshot
* @param {object} result WebDriver response body
*/
export function transformCommandLogResult (result: unknown) {
if (typeof result === 'undefined') {
return '<empty result>'
} else if (typeof result !== 'object' || !result) {
return result
} else if ('file' in result && typeof result.file === 'string' && isBase64(result.file)) {
return SCREENSHOT_REPLACEMENT
} else if ('script' in result && typeof result.script === 'string' && isBase64(result.script)) {
return SCRIPT_PLACEHOLDER
} else if ('script' in result && typeof result.script === 'string' && result.script.match(REGEX_SCRIPT_NAME)) {
const newScript = result.script.match(REGEX_SCRIPT_NAME)![2]
return { ...result, script: `${newScript}(...) [${Buffer.byteLength(result.script, 'utf-8')} bytes]` }
} else if ('script' in result && typeof result.script === 'string' && result.script.startsWith('!function(')) {
return { ...result, script: `<minified function> [${Buffer.byteLength(result.script, 'utf-8')} bytes]` }
}
return result
}
/**
* checks if command argument is valid according to specification
*
* @param {*} arg command argument
* @param {Object} expectedType parameter type (e.g. `number`, `string[]` or `(number|string)`)
* @return {Boolean} true if argument is valid
*/
export function isValidParameter (arg: unknown, expectedType: string) {
let shouldBeArray = false
if (expectedType.slice(-2) === '[]') {
expectedType = expectedType.slice(0, -2)
shouldBeArray = true
}
/**
* check type of each individual array element
*/
if (shouldBeArray) {
if (!Array.isArray(arg)) {
return false
}
} else {
/**
* transform to array to have a unified check
*/
arg = [arg]
}
if (Array.isArray(arg)) {
for (const argEntity of arg) {
const argEntityType = getArgumentType(argEntity)
if (!argEntityType.match(expectedType)) {
return false
}
}
}
return true
}
/**
* get type of command argument
*/
export function getArgumentType (arg: unknown) {
return arg === null ? 'null' : typeof arg
}
/**
* Utility to import modules with user friendly error message
* @param moduleName The name of the module to import
* @param namedImport The name of the import to return
* @returns The imported module
*/
export async function userImport<T> (moduleName: string, namedImport = 'default'): Promise<T> {
try {
const mod = await import(/* @vite-ignore */moduleName)
if (namedImport in mod) {
return mod[namedImport]
}
} catch {
throw new Error(`Couldn't import "${moduleName}"! Do you have it installed? If not run "npm install ${moduleName}"!`)
}
throw new Error(`Couldn't find "${namedImport}" in module "${moduleName}"`)
}
/**
* Allows to safely require a package, it only throws if the package was found
* but failed to load due to syntax errors
* @param {string} name of package
* @return {object} package content
*/
export async function safeImport (name: string): Promise<Services.ServicePlugin | null> {
let importPath = name
try {
/**
* Initially we will search for the package by using the standard package
* resolution starting from the path given by 'import.meta.url' (which
* returns the path to this file). The default mechanism will then search
* upwards through the hierarchy in the file system in node_modules directories
* until it finds the package or reaches the root of the file system.
*
* In the case where a user has installed the @wdio/cli package globally,
* then clearly the search will be performed in the global area and not
* in the project specific area. Consequently, if the package we are
* looking for is installed within the project it will not be found and
* then we also need to search in the project, we do that by defining
* 'localNodeModules' and searching from that also.
*
* Note that import-meta-resolve will resolve to CJS no ESM export is found.
* Only in Node.js environments
*/
if (!globalThis.window) {
// For Yarn Berry compatibility, try direct import first
try {
const pkg = await import(/* @vite-ignore */name)
/**
* CJS packages build with TS imported through an ESM context can end up being this:
*
* [Module: null prototype] {
* __esModule: true,
* default: {
* launcher: [class SmokeServiceLauncher],
* default: [class SmokeService]
* },
* launcher: [class SmokeServiceLauncher]
* }
*
* In order to not have the testrunner ignore importing a service we should double check if
* a nested default is given and return that.
*/
if (pkg.default && pkg.default.default) {
return pkg.default
}
return pkg
} catch {
// Fallback to import-meta-resolve approach
}
const { resolve } = await import('import-meta-resolve')
try {
importPath = await resolve(name, import.meta.url)
} catch {
const localNodeModules = path.join(process.cwd(), 'node_modules')
try {
importPath = await resolve(name, url.pathToFileURL(localNodeModules).toString())
} catch {
return null
}
}
}
} catch {
return null
}
try {
const pkg = await import(/* @vite-ignore */importPath)
/**
* CJS packages build with TS imported through an ESM context can end up being this:
*
* [Module: null prototype] {
* __esModule: true,
* default: {
* launcher: [class SmokeServiceLauncher],
* default: [class SmokeService]
* },
* launcher: [class SmokeServiceLauncher]
* }
*
* In order to not have the testrunner ignore importing a service we should double check if
* a nested default is given and return that.
*/
if (pkg.default && pkg.default.default) {
return pkg.default
}
return pkg
} catch (e: unknown) {
throw new Error(`Couldn't initialize "${name}".\n${(e as Error).stack}`)
}
}
/**
* is function async
* @param {Function} fn function to check
* @return {Boolean} true provided function is async
*/
export function isFunctionAsync (fn: Function) {
return (fn.constructor && fn.constructor.name === 'AsyncFunction') || fn.name === 'async'
}
/**
* filter out arguments passed to specFn & hookFn, don't allow callbacks
* as there is no need for user to call e.g. `done()`
*/
export function filterSpecArgs (args: unknown[]) {
return args.filter((arg) => typeof arg !== 'function')
}
/**
* checks if provided string is Base64
* @param {string} str string to check
* @return {boolean} `true` if the provided string is Base64
*/
export function isBase64(str: string) {
if (typeof str !== 'string') {
throw new Error('Expected string but received invalid type.')
}
const len = str.length
const notBase64 = /[^A-Z0-9+/=]/i
if (!len || len % 4 !== 0 || notBase64.test(str)) {
return false
}
const firstPaddingChar = str.indexOf('=')
return (
firstPaddingChar === -1 ||
firstPaddingChar === len - 1 ||
(firstPaddingChar === len - 2 && str[len - 1] === '=')
)
}
/**
* sleep
* @param {number=0} ms number in ms to sleep
*/
export const sleep = (ms = 0) => new Promise((r) => setTimeout(r, ms))
/**
* Checks if the provided WebdriverIO capabilities object is related to Appium.
*
* @param {WebdriverIO.Capabilities} caps - The capabilities object to check.
* @returns {boolean} Returns true if the provided capabilities are related to Appium, false otherwise.
*/
export function isAppiumCapability(caps: WebdriverIO.Capabilities): boolean {
return Boolean(
caps &&
(
// @ts-expect-error outdated jsonwp cap
caps.automationName ||
caps['appium:automationName'] ||
('appium:options' in caps && caps['appium:options']?.automationName) ||
// @ts-expect-error outdated jsonwp cap
caps.deviceName ||
caps['appium:deviceName'] ||
('appium:options' in caps && caps['appium:options']?.deviceName) ||
('lt:options' in caps && caps['lt:options']?.deviceName) ||
// @ts-expect-error outdated jsonwp cap
caps.appiumVersion ||
caps['appium:appiumVersion'] ||
('appium:options' in caps && caps['appium:options']?.appiumVersion) ||
('lt:options' in caps && caps['lt:options']?.appiumVersion)
)
)
}
/**
* helper method to determine if we need to setup a browser driver
* which is:
* - whenever the user has set connection options that differ
* from the default, or a port is set
* - whenever the user defines `user` and `key` which later will
* update the connection options
*/
export function definesRemoteDriver(options: Pick<Options.WebDriver, 'user' | 'key' | 'protocol' | 'hostname' | 'port' | 'path'>) {
return Boolean(
(options.protocol && options.protocol !== DEFAULT_PROTOCOL) ||
(options.hostname && options.hostname !== DEFAULT_HOSTNAME) ||
Boolean(options.port) ||
(options.path && options.path !== DEFAULT_PATH) ||
Boolean(options.user && options.key)
)
}
export function isChrome (browserName?: string) {
return Boolean(browserName && SUPPORTED_BROWSERNAMES.chrome.includes(browserName.toLowerCase()))
}
export function isSafari (browserName?: string) {
return Boolean(browserName && SUPPORTED_BROWSERNAMES.safari.includes(browserName.toLowerCase()))
}
export function isFirefox (browserName?: string) {
return Boolean(browserName && SUPPORTED_BROWSERNAMES.firefox.includes(browserName.toLowerCase()))
}
export function isEdge (browserName?: string) {
return Boolean(browserName && SUPPORTED_BROWSERNAMES.edge.includes(browserName.toLowerCase()))
}
/**
* traverse up the scope chain until browser element was reached
*/
export function getBrowserObject (elem: WebdriverIO.Element | WebdriverIO.Browser | WebdriverIO.ElementArray): WebdriverIO.Browser {
const elemObject = elem as WebdriverIO.Element
return (elemObject as WebdriverIO.Element).parent ? getBrowserObject(elemObject.parent) : elem as WebdriverIO.Browser
}
/**
* Enables logging to a file in a specified directory.
* @param {string} outputDir Directory containing the log file
*/
export async function enableFileLogging (outputDir?: string): Promise<void> {
if (!outputDir) {
return
}
await fs.mkdir(path.join(outputDir), { recursive: true })
process.env.WDIO_LOG_PATH = path.join(outputDir, 'wdio.log')
}