import type { EventEmitter } from 'node:events'
import { deepmergeCustom } from 'deepmerge-ts'
import logger, { SENSITIVE_DATA_REPLACER } from '@wdio/logger'
import type { CommandEndpoint, Protocol } from '@wdio/protocols'
import {
WebDriverProtocol, MJsonWProtocol, AppiumProtocol, ChromiumProtocol,
SauceLabsProtocol, SeleniumProtocol, GeckoProtocol, WebDriverBidiProtocol
} from '@wdio/protocols'
import { CAPABILITY_KEYS } from '@wdio/protocols'
import type { Options } from '@wdio/types'
import command from './command.js'
import { environment } from './environment.js'
import { BidiHandler } from './bidi/handler.js'
import type { Event } from './bidi/localTypes.js'
import type { Client, JSONWPCommandError, SessionFlags, RemoteConfig, CommandRuntimeOptions } from './types.js'
const log = logger('webdriver')
const deepmerge = deepmergeCustom({ mergeArrays: false })
const BROWSER_DRIVER_ERRORS = [
'unknown command: wd/hub/session',
'HTTP method not allowed',
"'POST /wd/hub/session' was not found.",
'Command not found'
]
interface SessionInitializationResponse {
value: {
sessionId?: string,
capabilities?: WebdriverIO.Capabilities
},
sessionId: string
}
* start browser session with WebDriver protocol
*/
export async function startWebDriverSession (params: RemoteConfig): Promise<{ sessionId: string, capabilities: WebdriverIO.Capabilities }> {
* the user could have passed in either w3c style or jsonwp style caps
* and we want to pass both styles to the server, which means we need
* to check what style the user sent in so we know how to construct the
* object for the other style
*/
const capabilities = params.capabilities && 'alwaysMatch' in params.capabilities
/**
* in case W3C compliant capabilities are provided
*/
? params.capabilities
* otherwise assume they passed in jsonwp-style caps (flat object)
*/
: { alwaysMatch: params.capabilities, firstMatch: [{}] }
* automatically opt-into WebDriver Bidi (@ref https://w3c.github.io/webdriver-bidi/)
*/
if (
* except, if user does not want to opt-in
*/
!capabilities.alwaysMatch['wdio:enforceWebDriverClassic'] &&
* or user requests a Safari session which does not support Bidi
*/
typeof capabilities.alwaysMatch.browserName === 'string' &&
capabilities.alwaysMatch.browserName.toLowerCase() !== 'safari'
) {
* opt-into WebDriver Bidi
*/
capabilities.alwaysMatch.webSocketUrl = true
* allow WebdriverIO to handle alerts
*/
capabilities.alwaysMatch.unhandledPromptBehavior = 'ignore'
}
validateCapabilities(capabilities.alwaysMatch)
const sessionRequest = new environment.value.Request(
'POST',
'/session',
{ capabilities }
)
let response: SessionInitializationResponse
try {
response = await sessionRequest.makeRequest(params) as SessionInitializationResponse
} catch (err) {
log.error(err)
const message = getSessionError(err as Error, params)
throw new Error(message)
}
const sessionId = response.value.sessionId || response.sessionId
* save actual received session details
*/
params.capabilities = (response.value.capabilities || response.value) as WebdriverIO.Capabilities
return { sessionId, capabilities: params.capabilities }
}
* Validates the given WebdriverIO capabilities.
*
* @param {WebdriverIO.Capabilities} capabilities - The capabilities to validate.
* @throws {Error} If the capabilities contain incognito mode.
*/
export function validateCapabilities (capabilities: WebdriverIO.Capabilities) {
const chromeArgs = capabilities['goog:chromeOptions']?.args || []
if (chromeArgs.includes('incognito') || chromeArgs.includes('--incognito')) {
throw new Error(
'Please remove "incognito" from `"goog:chromeOptions".args` as it is not supported running Chrome with WebDriver. ' +
'WebDriver sessions are always incognito mode and do not persist across browser sessions.'
)
}
* validate capabilities to check if there are no obvious mix between
* JSONWireProtocol and WebDriver protocol, e.g.
*/
if (capabilities) {
const extensionCaps = Object.keys(capabilities).filter((cap) => cap.includes(':'))
const invalidWebDriverCaps = Object.keys(capabilities)
.filter((cap) => !CAPABILITY_KEYS.includes(cap) && !cap.includes(':'))
* if there are vendor extensions, e.g. sauce:options or appium:app
* used (only WebDriver compatible) and caps that aren't defined
* in the WebDriver spec
*/
if (extensionCaps.length && invalidWebDriverCaps.length) {
throw new Error(
`Invalid or unsupported WebDriver capabilities found ("${invalidWebDriverCaps.join('", "')}"). ` +
'Ensure to only use valid W3C WebDriver capabilities (see https://w3c.github.io/webdriver/#capabilities).' +
'If you run your tests on a remote vendor, like Sauce Labs or BrowserStack, make sure that you put them ' +
'into vendor specific capabilities, e.g. "sauce:options" or "bstack:options". Please reach out ' +
'to your vendor support team if you have further questions.'
)
}
}
}
* check if WebDriver requests was successful
* @param {number} statusCode status code of request
* @param {Object} body body payload of response
* @return {Boolean} true if request was successful
*/
export function isSuccessfulResponse (statusCode?: number, body?: unknown) {
* response contains a body
*/
if (!body || typeof body !== 'object' || !('value' in body) || typeof body.value === 'undefined') {
log.debug('request failed due to missing body')
return false
}
* ignore failing element request to enable lazy loading capability
*/
if (
'status' in body && body.status === 7 && body.value && typeof body.value === 'object' &&
'message' in body.value && body.value.message && typeof body.value.message === 'string' &&
(
body.value.message.toLowerCase().startsWith('no such element') ||
body.value.message === 'An element could not be located on the page using the given search parameters.' ||
body.value.message.toLowerCase().startsWith('unable to find element')
)
) {
return true
}
* if it has a status property, it should be 0
* (just here to stay backwards compatible to the jsonwire protocol)
*/
if ('status' in body && body.status && body.status !== 0) {
log.debug(`request failed due to status ${body.status}`)
return false
}
const hasErrorResponse = body.value && (
(typeof body.value === 'object' && 'error' in body.value && body.value.error) ||
(typeof body.value === 'object' && 'stackTrace' in body.value && body.value.stackTrace) ||
(typeof body.value === 'object' && 'stacktrace' in body.value && body.value.stacktrace)
)
* check status code
*/
if (statusCode === 200 && !hasErrorResponse) {
return true
}
* if an element was not found we don't flag it as failed request because
* we lazy load it
*/
if (statusCode === 404 && typeof body.value === 'object' && body.value && 'error' in body.value && body.value.error === 'no such element') {
return true
}
* that has no error property (Appium only)
*/
if (hasErrorResponse) {
const errMsg = typeof body.value === 'object' && body.value && 'error' in body.value ? body.value.error : body.value
log.debug('request failed due to response error:', errMsg)
return false
}
return true
}
* creates the base prototype for the webdriver monad
*/
export function getPrototype ({ isW3C, isChromium, isFirefox, isMobile, isSauce, isSeleniumStandalone }: Partial<SessionFlags>) {
const prototype: Record<string, PropertyDescriptor> = {}
const ProtocolCommands = deepmerge<any>(
* if mobile apply JSONWire and WebDriver protocol because
* some legacy JSONWire commands are still used in Appium
* (e.g. set/get geolocation)
*/
isMobile
? deepmerge<any>(AppiumProtocol as Protocol, WebDriverProtocol as Protocol) as Protocol
: WebDriverProtocol,
* enable Bidi protocol for W3C sessions
*/
isW3C ? WebDriverBidiProtocol : {},
* only apply mobile protocol if session is actually for mobile
*/
isMobile ? deepmerge<any>(MJsonWProtocol, AppiumProtocol) : {},
* only apply special Chromium commands if session is using Chrome or Edge
*/
isChromium ? ChromiumProtocol : {},
* only apply special Firefox commands if session is using Firefox
*/
isFirefox ? GeckoProtocol : {},
* only Sauce Labs specific vendor commands
*/
isSauce ? SauceLabsProtocol : {},
* only apply special commands when running tests using
* Selenium Grid or Selenium Standalone server
*/
isSeleniumStandalone ? SeleniumProtocol : {},
{} as Protocol
) as Protocol
for (const [endpoint, methods] of Object.entries(ProtocolCommands)) {
for (const [method, commandData] of Object.entries(methods)) {
prototype[commandData.command] = { value: command(method, endpoint, commandData, isSeleniumStandalone) }
}
}
return prototype
}
* return all supported flags and return them in a format so we can attach them
* to the instance protocol
* @param {Object} options driver instance or option object containing these flags
* @return {Object} prototype object
*/
export function getEnvironmentVars({ isW3C, isMobile, isIOS, isAndroid, isFirefox, isSauce, isSeleniumStandalone, isChromium, isWindowsApp, isMacApp }: Partial<SessionFlags>): PropertyDescriptorMap {
return {
isW3C: { value: isW3C },
isMobile: { value: isMobile },
isIOS: { value: isIOS },
isAndroid: { value: isAndroid },
isFirefox: { value: isFirefox },
isSauce: { value: isSauce },
isSeleniumStandalone: { value: isSeleniumStandalone },
isBidi: {
* Return the value of this flag dynamically based on whether the
* BidiHandler was able to connect to the `webSocketUrl` url provided
* by the session response.
*/
get: function (this: Client & { _bidiHandler?: BidiHandler }) {
return Boolean(this._bidiHandler?.isConnected)
}
},
isChromium: { value: isChromium },
isWindowsApp: { value: isWindowsApp },
isMacApp: { value: isMacApp }
}
}
* Decorate the client's options object with host updates based on the presence of
* directConnect capabilities in the new session response. Note that this
* mutates the object.
* @param {Client} params post-new-session client
*/
export function setupDirectConnect(client: Client) {
const capabilities = client.capabilities
const directConnectProtocol = capabilities['appium:directConnectProtocol']
const directConnectHost = capabilities['appium:directConnectHost']
const directConnectPath = capabilities['appium:directConnectPath']
const directConnectPort = capabilities['appium:directConnectPort']
if (directConnectProtocol && directConnectHost && directConnectPort &&
(directConnectPath || directConnectPath === '')) {
log.info('Found direct connect information in new session response. ' +
`Will connect to server at ${directConnectProtocol}://` +
`${directConnectHost}:${directConnectPort}${directConnectPath}`)
client.options.protocol = directConnectProtocol
client.options.hostname = directConnectHost
client.options.port = directConnectPort
client.options.path = directConnectPath
}
}
* get human-readable message from response error
* @param {Error} err response error
* @param params
*/
export const getSessionError = (err: JSONWPCommandError, params: Partial<Options.WebDriver> = {}) => {
if (err.code === 'ECONNREFUSED') {
return `Unable to connect to "${params.protocol}://${params.hostname}:${params.port}${params.path}", make sure browser driver is running on that address.` +
'\nIt seems like the service failed to start or is rejecting any connections.'
}
if (err.message === 'unhandled request') {
return 'The browser driver couldn\'t start the session. Make sure you have set the "path" correctly!'
}
if (!err.message) {
return 'See wdio.* logs for more information.'
}
if (err.message.includes('Whoops! The URL specified routes to this help page.')) {
return "It seems you are running a Selenium Standalone server and point to a wrong path. Please set `path: '/wd/hub'` in your wdio.conf.js!"
}
if (BROWSER_DRIVER_ERRORS.some(m => err && err.message && err.message.includes(m))) {
return "Make sure to set `path: '/'` in your wdio.conf.js!"
}
if (err.message.includes('Bad Request - Invalid Hostname') && err.message.includes('HTTP Error 400')) {
return "Run edge driver on 127.0.0.1 instead of localhost, ex: --host=127.0.0.1, or set `hostname: 'localhost'` in your wdio.conf.js"
}
const w3cCapMessage = '\nMake sure to add vendor prefix like "goog:", "appium:", "moz:", etc to non W3C capabilities.' +
'\nSee more https://www.w3.org/TR/webdriver/#capabilities'
if (err.message.includes('Illegal key values seen in w3c capabilities')) {
return err.message + w3cCapMessage
}
if (err.message === 'Response has empty body') {
return 'Make sure to connect to valid hostname:port or the port is not in use.' +
'\nIf you use a grid server ' + w3cCapMessage
}
if (err.message.includes('failed serving request POST /wd/hub/session: Unauthorized') && (params.hostname === 'saucelabs.com' || params.hostname?.endsWith('.saucelabs.com'))) {
return 'Session request was not authorized because you either did provide a wrong access key or tried to run ' +
'in a region that has not been enabled for your user. If have registered a free trial account it is connected ' +
'to a specific region. Ensure this region is set in your configuration (https://webdriver.io/docs/options.html#region).'
}
return err.message
}
* Enhance the monad with WebDriver Bidi primitives if a connection can be established successfully
* @param socketUrl url to bidi interface
* @param strictSSL
* @param userHeaders
* @returns prototype with interface for bidi primitives
*/
export function initiateBidi (
socketUrl: string,
strictSSL: boolean = true,
userHeaders?: Record<string, string>
): PropertyDescriptorMap {
* don't connect and stale unit tests when the websocket url is set to a dummy value
*/
const isUnitTesting = environment.value.variables.WDIO_UNIT_TESTS
if (isUnitTesting) {
log.info('Skip connecting to WebDriver Bidi interface due to unit tests')
return {
_bidiHandler: {
value: {
isConnected: true,
waitForConnected: () => Promise.resolve(),
socket: { on: () => {}, off: () => {} }
}
}
}
}
socketUrl = socketUrl.replace('localhost', '127.0.0.1')
const bidiReqOpts: { rejectUnauthorized?: boolean, headers?: Record<string, string> } = strictSSL ? {} : { rejectUnauthorized: false }
if (userHeaders) {
bidiReqOpts.headers = userHeaders
}
const handler = new BidiHandler(socketUrl, bidiReqOpts)
handler.connect().then((isConnected) => isConnected && log.info(`Connected to WebDriver Bidi interface at ${socketUrl}`))
return {
_bidiHandler: { value: handler },
...Object.values(WebDriverBidiProtocol).map((def) => def.socket).reduce((acc, cur) => {
acc[cur.command] = {
value: function (this: Client, ...args: unknown[]) {
const bidiFn = handler[cur.command] as Function | undefined
* attach the client to the handler to emit events
*/
handler.attachClient(this)
this.emit(cur.command, args)
return bidiFn?.apply(handler, args)
}
}
return acc
}, {} as PropertyDescriptorMap)
}
}
export function parseBidiMessage (this: EventEmitter, data: ArrayBuffer) {
try {
const payload: Event = JSON.parse(data.toString())
if (payload.type !== 'event') {
return
}
this.emit(payload.method as string, payload.params)
} catch (err) {
log.error(`Failed parse WebDriver Bidi message: ${(err as Error).message}`)
}
}
* Masks the `text` parameter in a WebDriver command if masking is enabled in the options.
*
* - If `options.mask` is not set or the command does not have a `text` parameter, returns the original body and args.
* - If masking is enabled and a `text` parameter is present and non-empty, replaces its value with the mask in both the body and args.
*
* @param {CommandEndpoint} commandInfo - The command endpoint metadata, including parameters and variables.
* @param {CommandRuntimeOptions} options - Runtime options for the command, including the `mask` flag.
* @param {Record<string, unknown>} body - The request body object to potentially mask.
* @param {unknown[]} args - The arguments array to potentially mask.
* @returns {{
* maskedBody: Record<string, unknown>,
* maskedArgs: unknown[],
* isMasked: boolean
* }} An object containing the (possibly) masked body and args, and a flag indicating if masking was applied.
*/
export function mask(commandInfo: CommandEndpoint, options: CommandRuntimeOptions, body: Record<string, unknown>, args: unknown[]) {
const unmaskedResult = { maskedBody: body, maskedArgs: args, isMasked: false }
if (!options.mask) {
return unmaskedResult
}
const textValueParamIndex = commandInfo.parameters.findIndex((param) => param.name === 'text')
if (textValueParamIndex === -1 ) {
return unmaskedResult
}
const textValueIndexInArgs = (commandInfo.variables?.length ?? 0) + textValueParamIndex
const text = args[textValueIndexInArgs]
if (typeof text !== 'string' || !text) {
return unmaskedResult
}
const maskedBody = {
...body,
text: SENSITIVE_DATA_REPLACER
} satisfies Record<string, unknown> as Record<string, unknown>
const textValueArgsIndex = textValueParamIndex + (commandInfo.variables?.length ?? 0)
const maskedArgs = args.slice(0, textValueArgsIndex).concat(SENSITIVE_DATA_REPLACER).concat(args.slice(textValueArgsIndex + 1))
return {
maskedBody,
maskedArgs,
isMasked: true,
}
}