🏡 index : quine-global/webdriverio.git

import logger from '@wdio/logger'
import { sleep } from '@wdio/utils'
import type { Options } from '@wdio/types'

import { type RequestInit as UndiciRequestInit } from 'undici'

import  { WebDriverResponseError, WebDriverRequestError } from './error.js'
import { RETRYABLE_STATUS_CODES, RETRYABLE_ERROR_CODES } from './constants.js'
import type { WebDriverResponse, RequestLibResponse, RequestOptions, RequestEventHandler } from './types.js'

import { isSuccessfulResponse } from '../utils.js'
import { DEFAULTS } from '../constants.js'
import pkg from '../../package.json' with { type: 'json' }

import './polyfill.js'

const ERRORS_TO_EXCLUDE_FROM_RETRY = [
    'detached shadow root',
    'move target out of bounds'
]

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 abstract class WebDriverRequest {
    protected abstract fetch(url: URL, opts: RequestInit): Promise<Response>

    body?: Record<string, unknown>
    method: string
    endpoint: string
    isHubCommand: boolean
    requiresSessionId: boolean
    eventHandler: RequestEventHandler
    abortSignal?: AbortSignal
    constructor (
        method: string,
        endpoint: string,
        body?: Record<string, unknown>,
        abortSignal?: AbortSignal,
        isHubCommand: boolean = false,
        eventHandler: RequestEventHandler = {}
    ) {
        this.body = body
        this.method = method
        this.endpoint = endpoint
        this.isHubCommand = isHubCommand
        this.requiresSessionId = Boolean(this.endpoint.match(/:sessionId/))
        this.eventHandler = eventHandler
        this.abortSignal = abortSignal
    }

    async makeRequest (options: RequestOptions, sessionId?: string) {
        const { url, requestOptions } = await this.createOptions(options, sessionId)
        this.eventHandler.onRequest?.(requestOptions)
        return this._request(url, requestOptions, options.transformResponse, options.connectionRetryCount, 0)
    }

    async createOptions (options: RequestOptions, sessionId?: string, isBrowser: boolean = false): Promise<{ url: URL; requestOptions: RequestInit; }> {
        const timeout = options.connectionRetryTimeout || DEFAULTS.connectionRetryTimeout.default as number
        const requestOptions: RequestInit = {
            method: this.method,
            redirect: 'follow',
            signal: AbortSignal.any([
                AbortSignal.timeout(timeout),
                ...(this.abortSignal ? [this.abortSignal] : [])
            ])
        }

        const requestHeaders: HeadersInit = new Headers({
            ...DEFAULT_HEADERS,
            ...(typeof options.headers === 'object' ? options.headers : {})
        })

        const searchParams = isBrowser ? undefined : (typeof options.queryParams === 'object' ? options.queryParams : undefined)

        /**
         * only apply body property if existing
         */
        if (this.body && (Object.keys(this.body).length || this.method === 'POST')) {
            const contentLength = new TextEncoder().encode(JSON.stringify(this.body)).length
            requestOptions.body = this.body as unknown as BodyInit
            requestHeaders.set('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)
        }

        const url = new URL(`${options.protocol}://${options.hostname}:${options.port}${this.isHubCommand ? this.endpoint : `${options.path || ''}/${endpoint}`.replace(/(\/){2,}/g, '/')}`)

        if (searchParams) {
            url.search = new URLSearchParams(searchParams).toString()
        }

        /**
         * send authentication credentials only when creating new session
         */
        if (this.endpoint === '/session' && options.user && options.key) {
            requestHeaders.set('Authorization', 'Basic ' + btoa(options.user + ':' + options.key))
        }

        requestOptions.headers = requestHeaders

        return {
            url,
            requestOptions: typeof options.transformRequest === 'function'
                ? options.transformRequest(requestOptions)
                : requestOptions
        }
    }

    protected async _libRequest (url: URL, opts: RequestInit): Promise<Options.RequestLibResponse> {
        try {

            const dispatcher = (opts as UndiciRequestInit).dispatcher

            const response = await this.fetch(url, {
                method: opts.method,
                body: JSON.stringify(opts.body),
                headers: opts.headers as Record<string, string>,
                signal: opts.signal,
                redirect: opts.redirect,
                ...(dispatcher ? { dispatcher } : {})
            })

            return {
                statusCode: response.status,
                body: await response.json() ?? {},
            } satisfies Options.RequestLibResponse
        } catch (err) {
            if (!(err instanceof Error)) {
                throw new WebDriverRequestError(
                    new Error(`Failed to fetch ${url.href}: ${(err as Error).message || err || 'Unknown error'}`),
                    url,
                    opts
                )
            }

            throw new WebDriverRequestError(err, url, opts)
        }
    }

    protected async _request (
        url: URL,
        fullRequestOptions: RequestInit,
        transformResponse?: (response: RequestLibResponse, requestOptions: RequestInit) => RequestLibResponse,
        totalRetryCount = 0,
        retryCount = 0
    ): Promise<WebDriverResponse> {
        log.info(`[${fullRequestOptions.method}] ${(url as URL).href}`)

        if (fullRequestOptions.body && Object.keys(fullRequestOptions.body).length) {
            this.eventHandler.onLogData?.(fullRequestOptions.body)
        }

        const { ...requestLibOptions } = fullRequestOptions
        const startTime = performance.now()
        let response = await this._libRequest(url!, requestLibOptions)
            .catch((err: WebDriverRequestError) => err)
        const durationMillisecond = performance.now() - 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 = async (error: Error) => {
            /**
             * 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(error.message)
                this.eventHandler.onResponse?.({ error })
                this.eventHandler.onPerformance?.({ request: fullRequestOptions, durationMillisecond, success: false, error, retryCount })
                throw error
            }

            if (retryCount > 0) {
                /*
                 * Exponential backoff with a minimum of 500ms and a maximum of 10_000ms.
                 */
                await sleep(Math.min(10000, 250 * Math.pow(2, retryCount)))
            }

            ++retryCount

            this.eventHandler.onRetry?.({ error, retryCount })
            this.eventHandler.onPerformance?.({ request: fullRequestOptions, durationMillisecond, success: false, error, retryCount })
            log.warn(error.message)
            log.info(`Retrying ${retryCount}/${totalRetryCount}`)
            return this._request(url, fullRequestOptions, transformResponse, totalRetryCount, retryCount)
        }

        /**
         * handle request errors
         */
        if (response instanceof Error) {
            const resError = response as WebDriverRequestError

            /**
             * retry failed requests, only if:
             * - the abort signal is not aborted
             * - the error code or status code is retryable
             */
            if (
                !(this.abortSignal && this.abortSignal.aborted) &&
                (
                    (resError.code && RETRYABLE_ERROR_CODES.includes(resError.code)) ||
                    (resError.statusCode && RETRYABLE_STATUS_CODES.includes(resError.statusCode))
                )
            ) {
                return retry(resError)
            }

            /**
             * throw if request error is unknown
             */
            this.eventHandler.onPerformance?.({ request: fullRequestOptions, durationMillisecond, success: false, error: response, retryCount })
            throw response
        }

        if (typeof transformResponse === 'function') {
            response = transformResponse(response, fullRequestOptions) as RequestLibResponse
        }

        /**
         * Resolve only if successful response
         */
        if (isSuccessfulResponse(response.statusCode, response.body)) {
            this.eventHandler.onResponse?.({ result: response.body })
            this.eventHandler.onPerformance?.({ request: fullRequestOptions, durationMillisecond, success: true, retryCount })
            return response.body as WebDriverResponse<unknown>
        }

        const error = new WebDriverResponseError(response, url, fullRequestOptions)

        /**
         * 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.eventHandler.onPerformance?.({ 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 }
        }

        /**
         * 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.eventHandler.onResponse?.({ error })
            this.eventHandler.onPerformance?.({ request: fullRequestOptions, durationMillisecond, success: false, error, retryCount })
            throw error
        }

        /**
         * some 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 (ERRORS_TO_EXCLUDE_FROM_RETRY.includes(error.name)) {
            throw error
        }

        return retry(error)
    }
}