UNPKG

webdriver

Version:

A Node.js bindings implementation for the W3C WebDriver and Mobile JSONWire Protocol

250 lines (249 loc) 11.1 kB
import path from 'node:path'; import { EventEmitter } from 'node:events'; import { createRequire } from 'node:module'; import { WebDriverProtocol } from '@wdio/protocols'; import logger from '@wdio/logger'; import { transformCommandLogResult } from '@wdio/utils'; import { URLFactory } from './factory.js'; import { isSuccessfulResponse, getErrorFromResponseBody, getTimeoutError } from '../utils.js'; const require = createRequire(import.meta.url); const pkg = require('../../package.json'); const RETRY_METHODS = [ 'GET', 'POST', 'PUT', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE' ]; export class RequestLibError extends Error { statusCode; body; code; } export const COMMANDS_WITHOUT_RETRY = [ findCommandPathByName('performActions'), ]; const MAX_RETRY_TIMEOUT = 100; // 100ms const DEFAULT_HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Connection': 'keep-alive', 'Accept': 'application/json', 'User-Agent': 'webdriver/' + pkg.version }; const log = logger('webdriver'); export default class WebDriverRequest extends EventEmitter { body; method; endpoint; isHubCommand; requiresSessionId; defaultAgents; defaultOptions = { followRedirect: true, responseType: 'json', throwHttpErrors: false }; constructor(method, endpoint, body, isHubCommand = false) { super(); this.body = body; this.method = method; this.endpoint = endpoint; this.isHubCommand = isHubCommand; this.requiresSessionId = Boolean(this.endpoint.match(/:sessionId/)); } async makeRequest(options, sessionId) { let fullRequestOptions = Object.assign({ method: this.method }, this.defaultOptions, await this._createOptions(options, sessionId)); if (typeof options.transformRequest === 'function') { fullRequestOptions = options.transformRequest(fullRequestOptions); } this.emit('request', fullRequestOptions); return this._request(fullRequestOptions, options.transformResponse, options.connectionRetryCount, 0); } async _createOptions(options, sessionId, isBrowser = false) { const agent = isBrowser ? undefined : (options.agent || this.defaultAgents); const searchParams = isBrowser ? undefined : (typeof options.queryParams === 'object' ? options.queryParams : {}); const requestOptions = { https: {}, agent, headers: { ...DEFAULT_HEADERS, ...(typeof options.headers === 'object' ? options.headers : {}) }, searchParams, retry: { limit: options.connectionRetryCount, /** * this enables request retries for all commands except for the * ones defined in `COMMANDS_WITHOUT_RETRY` since they have their * own retry mechanism. Including a request based retry mechanism * here also ensures we retry if e.g. a connection to the server * can't be established at all. */ ...(COMMANDS_WITHOUT_RETRY.includes(this.endpoint) ? {} : { methods: RETRY_METHODS, calculateDelay: ({ computedValue }) => Math.min(MAX_RETRY_TIMEOUT, computedValue / 10) }), }, timeout: { response: options.connectionRetryTimeout } }; /** * only apply body property if existing */ if (this.body && (Object.keys(this.body).length || this.method === 'POST')) { const contentLength = Buffer.byteLength(JSON.stringify(this.body), 'utf8'); requestOptions.json = this.body; requestOptions.headers['Content-Length'] = `${contentLength}`; } /** * if we don't have a session id we set it here, unless we call commands that don't require session ids, for * example /sessions. The call to /sessions is not connected to a session itself and it therefore doesn't * require it */ let endpoint = this.endpoint; if (this.requiresSessionId) { if (!sessionId) { throw new Error('A sessionId is required for this command'); } endpoint = endpoint.replace(':sessionId', sessionId); } requestOptions.url = await URLFactory.getInstance(`${options.protocol}://` + `${options.hostname}:${options.port}` + (this.isHubCommand ? this.endpoint : path.join(options.path || '', endpoint))); /** * send authentication credentials only when creating new session */ if (this.endpoint === '/session' && options.user && options.key) { requestOptions.username = options.user; requestOptions.password = options.key; } /** * if the environment variable "STRICT_SSL" is defined as "false", it doesn't require SSL certificates to be valid. * Or the requestOptions has strictSSL for an environment which cannot get the environment variable correctly like on an Electron app. */ requestOptions.https.rejectUnauthorized = !(options.strictSSL === false || process.env.STRICT_SSL === 'false' || process.env.strict_ssl === 'false'); return requestOptions; } async _libRequest(url, options) { throw new Error('This function must be implemented'); } _libPerformanceNow() { throw new Error('This function must be implemented'); } async _request(fullRequestOptions, transformResponse, totalRetryCount = 0, retryCount = 0) { log.info(`[${fullRequestOptions.method}] ${fullRequestOptions.url.href}`); if (fullRequestOptions.json && Object.keys(fullRequestOptions.json).length) { log.info('DATA', transformCommandLogResult(fullRequestOptions.json)); } const { url, ...requestLibOptions } = fullRequestOptions; const startTime = this._libPerformanceNow(); let response = await this._libRequest(url, requestLibOptions) .catch((err) => err); const durationMillisecond = this._libPerformanceNow() - startTime; /** * handle retries for requests * @param {Error} error error object that causes the retry * @param {string} msg message that is being shown as warning to user */ const retry = (error, msg) => { /** * stop retrying if totalRetryCount was exceeded or there is no reason to * retry, e.g. if sessionId is invalid */ if (retryCount >= totalRetryCount || error.message.includes('invalid session id')) { log.error(`Request failed with status ${response.statusCode} due to ${error}`); this.emit('response', { error }); this.emit('performance', { request: fullRequestOptions, durationMillisecond, success: false, error, retryCount }); throw error; } ++retryCount; this.emit('retry', { error, retryCount }); this.emit('performance', { request: fullRequestOptions, durationMillisecond, success: false, error, retryCount }); log.warn(msg); log.info(`Retrying ${retryCount}/${totalRetryCount}`); return this._request(fullRequestOptions, transformResponse, totalRetryCount, retryCount); }; /** * handle request errors */ if (response instanceof Error) { /** * handle timeouts */ if (response.code === 'ETIMEDOUT') { const error = getTimeoutError(response, fullRequestOptions); return retry(error, 'Request timed out! Consider increasing the "connectionRetryTimeout" option.'); } /** * throw if request error is unknown */ this.emit('performance', { request: fullRequestOptions, durationMillisecond, success: false, error: response, retryCount }); throw response; } if (typeof transformResponse === 'function') { response = transformResponse(response, fullRequestOptions); } const error = getErrorFromResponseBody(response.body, fullRequestOptions.json); /** * retry connection refused errors */ if (error.message === 'java.net.ConnectException: Connection refused: connect') { return retry(error, 'Connection to Selenium Standalone server was refused.'); } /** * hub commands don't follow standard response formats * and can have empty bodies */ if (this.isHubCommand) { /** * if body contains HTML the command was called on a node * directly without using a hub, therefore throw */ if (typeof response.body === 'string' && response.body.startsWith('<!DOCTYPE html>')) { this.emit('performance', { request: fullRequestOptions, durationMillisecond, success: false, error, retryCount }); return Promise.reject(new Error('Command can only be called to a Selenium Hub')); } return { value: response.body || null }; } /** * Resolve only if successful response */ if (isSuccessfulResponse(response.statusCode, response.body)) { this.emit('response', { result: response.body }); this.emit('performance', { request: fullRequestOptions, durationMillisecond, success: true, retryCount }); return response.body; } /** * stop retrying as this will never be successful. * we will handle this at the elementErrorHandler */ if (error.name === 'stale element reference') { log.warn('Request encountered a stale element - terminating request'); this.emit('response', { error }); this.emit('performance', { request: fullRequestOptions, durationMillisecond, success: false, error, retryCount }); throw error; } /** * Move out of bounds errors can be excluded from the request retry mechanism as * it likely does not changes anything and the error is handled within the command. */ if (error.name === 'move target out of bounds') { throw error; } return retry(error, `Request failed with status ${response.statusCode} due to ${error.message}`); } } function findCommandPathByName(commandName) { const command = Object.entries(WebDriverProtocol).find(([, command]) => Object.values(command).find((cmd) => cmd.command === commandName)); if (!command) { throw new Error(`Couldn't find command "${commandName}"`); } return command[0]; }