🏡 index : quine-global/webdriverio.git

import os from 'node:os'
import util, { format } from 'node:util'
import path from 'node:path'
import fs from 'node:fs'
import UsageStats, { type UsageStat } from '../testOps/usageStats.js'
import { BStackLogger } from '../bstackLogger.js'
import type BrowserStackConfig from '../config.js'
import { BSTACK_SERVICE_VERSION, FUNNEL_INSTRUMENTATION_URL } from '../constants.js'
import { getDataFromWorkers } from '../data-store.js'
import { getProductMap } from '../testHub/utils.js'
import fetchWrap from '../fetchWrapper.js'
import type { BrowserstackHealing } from '@browserstack/ai-sdk-node'
import type { FunnelData, EventProperties } from '../types.js'
import TestOpsConfig from '../testOps/testOpsConfig.js'

async function fireFunnelTestEvent(eventType: string, config: BrowserStackConfig) {
    if (!config.userName || !config.accessKey) {
        BStackLogger.debug('username/accesskey not passed')
        return
    }

    try {
        const data = buildEventData(eventType, config)
        await fireFunnelRequest(data)
        BStackLogger.debug('Funnel event success')
        config.sentFunnelData()
    } catch (error) {
        BStackLogger.debug(`Exception in sending funnel data: ${format(error)}`)
    }
}

export async function sendStart(config: BrowserStackConfig) {
    await fireFunnelTestEvent('SDKTestAttempted', config)
}

export async function sendFinish(config: BrowserStackConfig) {
    await fireFunnelTestEvent('SDKTestSuccessful', config)
}

export function saveFunnelData(eventType: string, config: BrowserStackConfig): string {
    const data = buildEventData(eventType, config)

    BStackLogger.ensureLogsFolder()
    const filePath = path.join(BStackLogger.logFolderPath, 'funnelData.json')
    fs.writeFileSync(filePath, JSON.stringify(data))
    return filePath
}

function redactCredentialsFromFunnelData(data: FunnelData) {
    if (data) {
        if (data.userName) {
            data.userName = '[REDACTED]'
        }
        if (data.accessKey) {
            data.accessKey = '[REDACTED]'
        }
    }
    return data
}

// Called from two different process
export async function fireFunnelRequest(data: FunnelData): Promise<void> {
    const { userName, accessKey } = data
    redactCredentialsFromFunnelData(data)

    BStackLogger.debug('Sending SDK event with data ' + util.inspect(data, { depth: 6 }))

    const encodedAuth = Buffer.from(`${userName}:${accessKey}`, 'utf8').toString('base64')
    const response = await fetchWrap(FUNNEL_INSTRUMENTATION_URL, {
        method: 'POST',
        headers: {
            'content-type': 'application/json',
            Authorization: `Basic ${encodedAuth}`,
        },
        body: JSON.stringify(data)
    })
    BStackLogger.debug('Funnel Event Response: ' + JSON.stringify(await response.text()))
}

function getProductList(config: BrowserStackConfig) {
    const products: string[] = []
    if (config.testObservability.enabled) {
        products.push('observability')
    }

    if (config.accessibility) {
        products.push('accessibility')
    }

    if (config.percy) {
        products.push('percy')
    }

    if (config.automate) {
        products.push('automate')
    }

    if (config.appAutomate) {
        products.push('app-automate')
    }
    return products
}

function buildEventData(eventType: string, config: BrowserStackConfig) {
    const eventProperties: EventProperties = {
        // Framework Details
        sdkRunId: config?.sdkRunID,
        testhub_uuid: TestOpsConfig.getInstance().buildHashedId,
        language_framework: getLanguageFramework(config.framework),
        referrer: getReferrer(config.framework),
        language: 'WebdriverIO',
        languageVersion: process.version,

        // Build Details
        buildName: config.buildName || 'undefined',
        buildIdentifier: String(config.buildIdentifier),

        // Host details
        os: os.type() || 'unknown',
        hostname: os.hostname() || 'unknown',

        // Product Details
        productMap: getProductMap(config),
        product: getProductList(config),

        // framework details
        framework: config.framework
    }

    if (eventType === 'SDKTestSuccessful') {
        const workerData = getDataFromWorkers()
        // @ts-expect-error
        eventProperties.productUsage = getProductUsage(workerData)
        if (process.env.BSTACK_A11Y_POLLING_TIMEOUT) {
            eventProperties.pollingTimeout = process.env.BSTACK_A11Y_POLLING_TIMEOUT as string
        }
    }

    return {
        userName: config.userName,
        accessKey: config.accessKey,
        event_type: eventType,
        detectedFramework: 'WebdriverIO-' + config.framework,
        event_properties: eventProperties
    } as unknown as FunnelData

}

function getProductUsage(workersData: { usageStats: UsageStat }[]) {
    return {
        testObservability: UsageStats.getInstance().getFormattedData(workersData)
    }
}

function getLanguageFramework(framework?: string) {
    return 'WebdriverIO_' + framework
}

function getReferrer(framework?: string) {
    const fullName = framework ? 'WebdriverIO-' + framework : 'WebdriverIO'
    return `${fullName}/${BSTACK_SERVICE_VERSION}`
}

const sendEvent = {
    tcgDown: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestTcgDownResponse', config),
    invalidTcgAuth: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestInvalidTcgAuthResponseWithUserImpact', config),
    tcgAuthFailure: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestTcgAuthFailure', config),
    tcgtInitSuccessful: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestTcgtInitSuccessful', config),
    initFailed: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestInitFailedResponse', config),
    tcgProxyFailure: (config: BrowserStackConfig) => fireFunnelTestEvent('SDKTestTcgProxyFailure', config),
}

function isProxyError(authResult: { status?: number }): boolean {
    return (authResult as BrowserstackHealing.InitErrorResponse)?.status === 502
}

function handleProxyError(config: BrowserStackConfig, isSelfHealEnabled: boolean | undefined) {
    sendEvent.tcgProxyFailure(config)
    if (isSelfHealEnabled) {
        BStackLogger.warn('Proxy Error. Disabling Healing for this session.')
    }
}

function handleUpgradeRequired(isSelfHealEnabled: boolean | undefined) {
    if (isSelfHealEnabled) {
        BStackLogger.warn('Please upgrade Browserstack Service to the latest version to use the self-healing feature.')
    }
}

function handleAuthenticationFailure(status: number, config: BrowserStackConfig, isSelfHealEnabled: boolean | undefined) {
    if (status >= 500) {
        if (isSelfHealEnabled) {
            BStackLogger.warn('Something went wrong. Disabling healing for this session. Please try again later.')
        }
        sendEvent.tcgDown(config)
    } else {
        if (isSelfHealEnabled) {
            BStackLogger.warn('Authentication Failed. Disabling Healing for this session.')
        }
        sendEvent.tcgAuthFailure(config)
    }
}

function handleAuthenticationSuccess(
    isHealingEnabledForUser: boolean,
    userId: string,
    config: BrowserStackConfig,
    isSelfHealEnabled: boolean | undefined
) {
    if (!isHealingEnabledForUser && isSelfHealEnabled) {
        BStackLogger.warn('Healing is not enabled for your group, please contact the admin')
    } else if (userId && isHealingEnabledForUser) {
        sendEvent.tcgtInitSuccessful(config)
    }
}

function handleInitializationFailure(status: number, config: BrowserStackConfig, isSelfHealEnabled: boolean | undefined) {
    if (status >= 400) {
        sendEvent.initFailed(config)
    } else if (!status && isSelfHealEnabled) {
        sendEvent.invalidTcgAuth(config)
    }

    if (isSelfHealEnabled) {
        BStackLogger.warn('Authentication Failed. Healing will be disabled for this session.')
    }
}

interface AuthResult {
    message: string
    isAuthenticated: boolean
    status: number
    userId: string
    groupId: string
    isHealingEnabled: boolean
}

export function handleHealingInstrumentation(
    authResult: AuthResult,
    config: BrowserStackConfig,
    isSelfHealEnabled: boolean | undefined,
) {
    try {
        if (isProxyError(authResult)) {
            handleProxyError(config, isSelfHealEnabled)
            return
        }

        const { message, isAuthenticated, status, userId, groupId, isHealingEnabled: isHealingEnabledForUser } = authResult

        if (message === 'Upgrade required') {
            handleUpgradeRequired(isSelfHealEnabled)
            return
        }

        if (!isAuthenticated) {
            handleAuthenticationFailure(status, config, isSelfHealEnabled)
            return
        }

        if (isAuthenticated && userId && groupId) {
            handleAuthenticationSuccess(isHealingEnabledForUser, userId, config, isSelfHealEnabled)
            return
        }

        if (status >= 400 || !status) {
            handleInitializationFailure(status, config, isSelfHealEnabled)
            return
        }

    } catch (err) {
        BStackLogger.debug('Error in handling healing instrumentation: ' + err)
    }
}