🏡 index : quine-global/webdriverio.git

import { type local } from 'webdriver'
import { SessionManager } from './session.js'

export function getNetworkManager(browser: WebdriverIO.Browser) {
    return SessionManager.getSessionManager(browser, NetworkManager)
}

type Context = string
const UNKNOWN_NAVIGATION_ID = 'UNKNOWN_NAVIGATION_ID'
const SUPPORTED_NAVIGATION_PROTOCOLS = ['http', 'https', 'data', 'file']

/**
 * This class is responsible for managing shadow roots and their elements.
 * It allows to do deep element lookups and pierce into shadow DOMs across
 * all components of a page.
 */
export class NetworkManager extends SessionManager {
    #browser: WebdriverIO.Browser
    #initialize: Promise<boolean>
    #requests = new Map<Context, WebdriverIO.Request>()
    #lastNetworkId?: string

    constructor(browser: WebdriverIO.Browser) {
        super(browser, NetworkManager.name)
        this.#browser = browser

        /**
         * don't run setup when Bidi is not supported or running unit tests
         */
        if (!this.isEnabled()) {
            this.#initialize = Promise.resolve(true)
            return
        }

        /**
         * listen on required bidi events
         */
        this.#initialize = this.#browser.sessionSubscribe({
            events: [
                'browsingContext.navigationStarted',
                'browsingContext.fragmentNavigated',
                'network.responseCompleted',
                'network.beforeRequestSent',
                'network.fetchError'
            ]
        }).then(() => true, () => false)
        this.#browser.on('browsingContext.navigationStarted', this.#navigationStarted.bind(this))
        this.#browser.on('browsingContext.fragmentNavigated', this.#navigationStarted.bind(this))
        this.#browser.on('network.responseCompleted', this.#responseCompleted.bind(this))
        this.#browser.on('network.beforeRequestSent', this.#beforeRequestSent.bind(this))
        this.#browser.on('network.fetchError', this.#fetchError.bind(this))
    }

    removeListeners(): void {
        super.removeListeners()
        this.#browser.off('browsingContext.navigationStarted', this.#navigationStarted.bind(this))
        this.#browser.off('browsingContext.fragmentNavigated', this.#navigationStarted.bind(this))
        this.#browser.off('network.responseCompleted', this.#responseCompleted.bind(this))
        this.#browser.off('network.beforeRequestSent', this.#beforeRequestSent.bind(this))
        this.#browser.off('network.fetchError', this.#fetchError.bind(this))
    }

    async initialize () {
        return this.#initialize
    }

    #beforeRequestSent(log: local.NetworkBeforeRequestSentParameters) {
        /**
         * for events with navigation id we skip this step as we pull information
         * from the `navigationStarted` event
         */
        if (log.navigation) {
            return
        }

        const request = this.#findRootRequest(log.navigation)
        if (!request) {
            return
        }

        const { request: id, headers, cookies, url } = log.request
        request.children?.push({
            id,
            url,
            headers: headerListToObject(headers),
            cookies: cookies.map((cookie: local.NetworkCookie) => ({
                name: cookie.name,
                value: cookie.value.type === 'string' ? cookie.value.value : atob(cookie.value.value),
                domain: cookie.domain,
                path: cookie.path,
                size: cookie.size,
                httpOnly: cookie.httpOnly,
                secure: cookie.secure,
                sameSite: cookie.sameSite,
                expiry: cookie.expiry,
            })),
            timestamp: log.timestamp
        })

    }

    #navigationStarted(log: local.BrowsingContextNavigationInfo) {
        if (
            /**
             * we need a navigation id to identify the request
             */
            !log.navigation ||
            /**
             * ignore urls that users wouldn't navigate to
             */
            !SUPPORTED_NAVIGATION_PROTOCOLS.some((protocol) => log.url.startsWith(protocol))
        ) {
            /**
             * Chrome v127 and below does not support yet navigation ids, hence we have to
             * accept that we have to accept the event without it
             */
            if (log.navigation === null && log.url === '') {
                this.#lastNetworkId = UNKNOWN_NAVIGATION_ID
                return this.#requests.set(UNKNOWN_NAVIGATION_ID, {
                    url: '',
                    headers: {},
                    timestamp: log.timestamp,
                    redirectChain: [],
                    children: []
                })
            }

            return
        }

        this.#lastNetworkId = log.navigation
        this.#requests.set(log.navigation, {
            url: log.url,
            headers: {},
            timestamp: log.timestamp,
            navigation: log.navigation,
            redirectChain: [],
            children: []
        })
    }

    #fetchError (log: local.NetworkFetchErrorParameters) {
        const response = this.#findRootRequest(log.navigation)
        if (!response) {
            return
        }

        const request = response.children?.find((child) => child.id === log.request.request)
        if (!request) {
            return
        }
        request.error = log.errorText
    }

    #findRootRequest (navigationId: string | null) {
        const response = this.#requests.get(navigationId || UNKNOWN_NAVIGATION_ID)
        if (response) {
            return response
        }

        /**
         * Chrome v127 and below does not support yet navigation ids, hence we have to
         * just pick the first request object and assume it is the root request. This
         * will break if the user operates on multiple windows at the same time.
         *
         * @see https://github.com/GoogleChromeLabs/chromium-bidi/issues/1054
         */
        const firstRequest = this.#requests.values().next().value
        return this.#lastNetworkId
            ? this.#requests.get(this.#lastNetworkId) || firstRequest
            : firstRequest
    }

    #responseCompleted (log: local.NetworkResponseCompletedParameters) {
        const response = this.#findRootRequest(log.navigation)
        if (!response) {
            return
        }

        /**
         * patch for Chrome v127 and below
         */
        if (!response.navigation && response.url === '') {
            response.url = log.request.url
            response.navigation = log.navigation as string
        }

        /**
         * update main WebdriverIO.Request object
         */
        if (log.navigation === response.navigation) {
            if (response.url !== log.response.url) {
                response.redirectChain?.push(response.url)
            }
            response.url = log.response.url
            const { headers: requestHeaders } = log.request
            const { fromCache, headers: responseHeaders, mimeType, status } = log.response
            // eslint-disable-next-line @typescript-eslint/no-unused-expressions
            response.headers = headerListToObject(requestHeaders),
            response.response = {
                fromCache,
                headers: headerListToObject(responseHeaders),
                mimeType,
                status
            }
            return
        }

        const request = response.children?.find((child) => child.id === log.request.request)
        if (!request) {
            return
        }
        request.response = {
            fromCache: log.response.fromCache,
            headers: headerListToObject(log.response.headers),
            mimeType: log.response.mimeType,
            status: log.response.status
        }
        response.children?.push(request)
    }

    getRequestResponseData(navigationId: string) {
        return this.#requests.get(navigationId)
    }

    /**
     * Returns the number of requests that are currently pending.
     * @param context browsing context id
     * @returns the number of requests that are currently pending
     */
    getPendingRequests(navigationId: string): WebdriverIO.Request[] {
        const request = this.#requests.get(navigationId)
        if (!request) {
            throw new Error(`Couldn't find request for navigation with id ${navigationId}`)
        }

        const subRequests = (request.children || [])
        /**
         * A request is pending, if:
         */
        return subRequests.filter((child) => (
            /**
             * either the request has no response yet
             */
            !child.response &&
            /**
             * and there was no request error
             */
            !child.error
        ))
    }
}

function headerListToObject(headers: local.NetworkHeader[]) {
    return headers.reduce((acc: Record<string, string>, { name, value }) => {
        acc[name] = value.value
        return acc
    }, {} as Record<string, string>)
}