🏡 index : quine-global/webdriverio.git

import { performance, PerformanceObserver } from 'node:perf_hooks'

import {
    default as SauceLabs,
    type SauceLabsOptions,
    type SauceConnectOptions,
    type SauceConnectInstance
} from 'saucelabs'
import logger from '@wdio/logger'
import type { Services, Capabilities, Options } from '@wdio/types'

import { makeCapabilityFactory } from './utils.js'
import type { SauceServiceConfig } from './types.js'
import { DEFAULT_RUNNER_NAME } from './constants.js'
import path from 'node:path'

const MAX_SC_START_TRIALS = 3

const log = logger('@wdio/sauce-service')
export default class SauceLauncher implements Services.ServiceInstance {
    private _api: SauceLabs.default
    private _sauceConnectProcess?: SauceConnectInstance

    constructor (
        private _options: SauceServiceConfig,
        _: never,
        private _config: Options.Testrunner
    ) {
        this._api = new SauceLabs.default(this._config as SauceLabsOptions)
    }

    /**
     * modify config and launch sauce connect
     */
    async onPrepare (
        _: Options.Testrunner,
        capabilities: Capabilities.TestrunnerCapabilities
    ) {
        if (!this._options.sauceConnect) {
            return
        }

        const sauceConnectTunnelName = (
            this._options.sauceConnectOpts?.tunnelName ||
            /**
             * generate random identifier if not provided
             */
            `SC-tunnel-${Math.random().toString().slice(2)}`)

        let metadata = this._options.sauceConnectOpts?.metadata || ''
        if (!metadata.includes('runner=')) {
            metadata += `runner=${DEFAULT_RUNNER_NAME}`
        }

        const sauceConnectOpts: SauceConnectOptions = {
            tunnelName: sauceConnectTunnelName,
            ...this._options.sauceConnectOpts,
            metadata: metadata,
            logger: this._options.sauceConnectOpts?.logger || ((output) => log.debug(`Sauce Connect Log: ${output}`)),
            ...(!this._options.sauceConnectOpts?.logFile && this._config.outputDir
                ? { logFile: path.join(this._config.outputDir, 'wdio-sauce-connect-tunnel.log') }
                : {}
            )
        }
        const prepareCapability = makeCapabilityFactory(sauceConnectTunnelName)
        if (Array.isArray(capabilities)) {
            for (const capability of capabilities) {
                /**
                 * Parallel Multiremote
                 */
                if (Object.values(capability).length > 0 && Object.values(capability).every(c => typeof c === 'object' && c.capabilities)) {
                    for (const browserName of Object.keys(capability)) {
                        const caps = (capability as Capabilities.RequestedMultiremoteCapabilities)[browserName].capabilities
                        prepareCapability((caps as Capabilities.W3CCapabilities).alwaysMatch || caps)
                    }
                } else {
                    prepareCapability(capability as WebdriverIO.Capabilities)
                }
            }
        } else {
            for (const browserName of Object.keys(capabilities)) {
                const caps = capabilities[browserName].capabilities
                prepareCapability((caps as Capabilities.W3CCapabilities).alwaysMatch || caps)
            }
        }

        /**
         * measure SC boot time
         */
        const obs = new PerformanceObserver((list) => {
            const entry = list.getEntries()[0]
            log.info(`Sauce Connect successfully started after ${entry.duration}ms`)
        })
        obs.observe({ entryTypes: ['measure'] })

        log.info('Starting Sauce Connect Tunnel')
        performance.mark('sauceConnectStart')
        this._sauceConnectProcess = await this.startTunnel(sauceConnectOpts)
        performance.mark('sauceConnectEnd')
        const bootimeMeasure = performance.measure('bootTime', 'sauceConnectStart', 'sauceConnectEnd')
        log.info(`Started Sauce Connect Tunnel within ${bootimeMeasure.duration}ms`)
    }

    async startTunnel (sauceConnectOpts: SauceConnectOptions, retryCount = 0): Promise<SauceConnectInstance> {
        try {
            const scProcess = await this._api.startSauceConnect(sauceConnectOpts)
            return scProcess
        } catch (err) {
            ++retryCount
            /**
             * fail starting Sauce Connect eventually
             */
            if (
                err instanceof Error &&
                /**
                 * only fail for ENOENT errors due to racing condition
                 * see: https://github.com/saucelabs/node-saucelabs/issues/86
                 */
                !err.message.includes('ENOENT') ||
                /**
                 * or if we reached the maximum rety count
                 */
                retryCount >= MAX_SC_START_TRIALS
            ) {
                throw err
            }
            log.debug(`Failed to start Sauce Connect Proxy due to ${(err as Error).stack}`)
            log.debug(`Retrying ${retryCount}/${MAX_SC_START_TRIALS}`)
            return this.startTunnel(sauceConnectOpts, retryCount)
        }
    }

    /**
     * shut down sauce connect
     */
    onComplete () {
        if (!this._sauceConnectProcess) {
            return
        }

        return this._sauceConnectProcess.close()
    }
}