webdriver
Version:
A Node.js bindings implementation for the W3C WebDriver and Mobile JSONWire Protocol
385 lines (384 loc) • 16.5 kB
JavaScript
import { deepmergeCustom } from 'deepmerge-ts';
import logger from '@wdio/logger';
import { WebDriverProtocol, MJsonWProtocol, JsonWProtocol, AppiumProtocol, ChromiumProtocol, SauceLabsProtocol, SeleniumProtocol, GeckoProtocol, WebDriverBidiProtocol } from '@wdio/protocols';
import { transformCommandLogResult } from '@wdio/utils';
import { CAPABILITY_KEYS } from '@wdio/protocols';
import RequestFactory from './request/factory.js';
import command from './command.js';
import { BidiHandler } from './bidi/handler.js';
import { REG_EXPS } from './constants.js';
const log = logger('webdriver');
const deepmerge = deepmergeCustom({ mergeArrays: false });
const BROWSER_DRIVER_ERRORS = [
'unknown command: wd/hub/session', // chromedriver
'HTTP method not allowed', // geckodriver
"'POST /wd/hub/session' was not found.", // safaridriver
'Command not found' // iedriver
];
/**
* start browser session with WebDriver protocol
*/
export async function startWebDriverSession(params) {
/**
* validate capabilities to check if there are no obvious mix between
* JSONWireProtocol and WebDriver protocol, e.g.
*/
if (params.capabilities) {
const extensionCaps = Object.keys(params.capabilities).filter((cap) => cap.includes(':'));
const invalidWebDriverCaps = Object.keys(params.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.');
}
}
/**
* 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 [w3cCaps, jsonwpCaps] = params.capabilities && params.capabilities.alwaysMatch
/**
* in case W3C compliant capabilities are provided
*/
? [params.capabilities, params.capabilities.alwaysMatch]
/**
* otherwise assume they passed in jsonwp-style caps (flat object)
*/
: [{ alwaysMatch: params.capabilities, firstMatch: [{}] }, params.capabilities];
const sessionRequest = await RequestFactory.getInstance('POST', '/session', {
capabilities: w3cCaps, // W3C compliant
desiredCapabilities: jsonwpCaps // JSONWP compliant
});
let response;
try {
response = await sessionRequest.makeRequest(params);
}
catch (err) {
log.error(err);
const message = getSessionError(err, params);
throw new Error('Failed to create session.\n' + message);
}
const sessionId = response.value.sessionId || response.sessionId;
/**
* save actual received session details
*/
params.capabilities = response.value.capabilities || response.value;
return { sessionId, capabilities: params.capabilities };
}
/**
* 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, body) {
/**
* response contains a body
*/
if (!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 (body.status === 7 && body.value && body.value.message &&
(body.value.message.toLowerCase().startsWith('no such element') ||
// Appium
body.value.message === 'An element could not be located on the page using the given search parameters.' ||
// Internet Explorer
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 (body.status && body.status !== 0) {
log.debug(`request failed due to status ${body.status}`);
return false;
}
const hasErrorResponse = body.value && (body.value.error || body.value.stackTrace || 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 && body.value && body.value.error === 'no such element') {
return true;
}
/**
* that has no error property (Appium only)
*/
if (hasErrorResponse) {
log.debug('request failed due to response error:', body.value.error);
return false;
}
return true;
}
/**
* creates the base prototype for the webdriver monad
*/
export function getPrototype({ isW3C, isChromium, isFirefox, isMobile, isSauce, isSeleniumStandalone }) {
const prototype = {};
const ProtocolCommands = deepmerge(
/**
* if mobile apply JSONWire and WebDriver protocol because
* some legacy JSONWire commands are still used in Appium
* (e.g. set/get geolocation)
*/
isMobile
? deepmerge(JsonWProtocol, WebDriverProtocol)
: isW3C ? WebDriverProtocol : JsonWProtocol,
/**
* enable Bidi protocol for W3C sessions
*/
isW3C ? WebDriverBidiProtocol : {},
/**
* only apply mobile protocol if session is actually for mobile
*/
isMobile ? deepmerge(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 : {}, {});
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;
}
/**
* helper method to determine the error from webdriver response
* @param {Object} body body object
* @return {Object} error
*/
export function getErrorFromResponseBody(body, requestOptions) {
if (!body) {
return new Error('Response has empty body');
}
if (typeof body === 'string' && body.length) {
return new Error(body);
}
if (typeof body !== 'object') {
return new Error('Unknown error');
}
return new CustomRequestError(body, requestOptions);
}
//Exporting for testability
export class CustomRequestError extends Error {
constructor(body, requestOptions) {
const errorObj = body.value || body;
let errorMessage = errorObj.message || errorObj.class || 'unknown error';
/**
* Improve Chromedriver's error message for an invalid selector
*
* Chrome:
* error: 'invalid argument'
* message: 'invalid argument: invalid locator\n (Session info: chrome=122.0.6261.94)'
* Firefox:
* error: 'invalid selector'
* message: 'Given xpath expression "//button" is invalid: NotSupportedError: Operation is not supported'
* Safari:
* error: 'timeout'
* message: ''
*/
if (typeof errorObj.message === 'string' && errorObj.message.includes('invalid locator')) {
errorMessage = (`The selector "${requestOptions.value}" used with strategy "${requestOptions.using}" is invalid!`);
}
super(errorMessage);
if (errorObj.error) {
this.name = errorObj.error;
}
else if (errorObj.message && errorObj.message.includes('stale element reference')) {
this.name = 'stale element reference';
}
else {
this.name = errorObj.name || 'WebDriver Error';
}
Error.captureStackTrace(this, CustomRequestError);
}
}
/**
* 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, isBidi, isChromium }) {
return {
isW3C: { value: isW3C },
isMobile: { value: isMobile },
isIOS: { value: isIOS },
isAndroid: { value: isAndroid },
isFirefox: { value: isFirefox },
isSauce: { value: isSauce },
isSeleniumStandalone: { value: isSeleniumStandalone },
isBidi: { value: isBidi },
isChromium: { value: isChromium },
};
}
/**
* 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) {
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
*/
export const getSessionError = (err, params = {}) => {
// browser driver / service is not started
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.';
}
// wrong path: selenium-standalone
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!";
}
// wrong path: chromedriver, geckodriver, etc
if (BROWSER_DRIVER_ERRORS.some(m => err && err.message && err.message.includes(m))) {
return "Make sure to set `path: '/'` in your wdio.conf.js!";
}
// edge driver on localhost
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';
// Illegal w3c capability passed to selenium standalone
if (err.message.includes('Illegal key values seen in w3c capabilities')) {
return err.message + w3cCapMessage;
}
// wrong host/port, port in use, illegal w3c capability passed to selenium grid
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?.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;
};
/**
* return timeout error with information about the executing command on which the test hangs
*/
export const getTimeoutError = (error, requestOptions) => {
const cmdName = getExecCmdName(requestOptions);
const cmdArgs = getExecCmdArgs(requestOptions);
const cmdInfoMsg = `when running "${cmdName}" with method "${requestOptions.method}"`;
const cmdArgsMsg = cmdArgs ? ` and args ${cmdArgs}` : '';
const timeoutErr = new Error(`${error.message} ${cmdInfoMsg}${cmdArgsMsg}`);
return Object.assign(timeoutErr, error);
};
function getExecCmdName(requestOptions) {
const { href } = requestOptions.url;
const res = href.match(REG_EXPS.commandName) || [];
return res[1] || href;
}
function getExecCmdArgs(requestOptions) {
const { json: cmdJson } = requestOptions;
if (typeof cmdJson !== 'object') {
return '';
}
const transformedRes = transformCommandLogResult(cmdJson);
if (typeof transformedRes === 'string') {
return transformedRes;
}
if (typeof cmdJson.script === 'string') {
const scriptRes = cmdJson.script.match(REG_EXPS.execFn) || [];
return `"${scriptRes[1] || cmdJson.script}"`;
}
return Object.keys(cmdJson).length ? `"${JSON.stringify(cmdJson)}"` : '';
}
/**
* Enhance the monad with WebDriver Bidi primitives if a connection can be established successfully
* @param socketUrl url to bidi interface
* @returns prototype with interface for bidi primitives
*/
export function initiateBidi(socketUrl, strictSSL = true) {
socketUrl = socketUrl.replace('localhost', '127.0.0.1');
const bidiReqOpts = strictSSL ? {} : { rejectUnauthorized: false };
const handler = new BidiHandler(socketUrl, bidiReqOpts);
handler.connect().then(() => 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: handler[cur.command]?.bind(handler)
};
return acc;
}, {})
};
}
export function parseBidiMessage(data) {
try {
// keep backwards compatibility
// ToDo(Christian): remove in v9
this.emit('message', data);
const payload = JSON.parse(data.toString());
if (payload.type !== 'event') {
return;
}
this.emit(payload.method, payload.params);
}
catch (err) {
log.error(`Failed parse WebDriver Bidi message: ${err.message}`);
}
}