🏡 index : quine-global/webdriverio.git

import util from 'node:util'

import type { Capabilities, Frameworks, Options } from '@wdio/types'

import type { BrowserstackConfig, BrowserstackOptions } from './types.js'

import type { ITestCaseHookParameter } from './cucumber-types.js'

import Listener from './testOps/listener.js'

import {
    getA11yResultsSummary,
    getAppA11yResultsSummary,
    getA11yResults,
    performA11yScan,
    getUniqueIdentifier,
    getUniqueIdentifierForCucumber,
    isAccessibilityAutomationSession,
    isAppAccessibilityAutomationSession,
    isBrowserstackSession,
    o11yClassErrorHandler,
    shouldScanTestForAccessibility,
    validateCapsWithA11y,
    shouldAddServiceVersion,
    validateCapsWithNonBstackA11y,
    isTrue,
    validateCapsWithAppA11y,
    getAppA11yResults,
    executeAccessibilityScript,
    isFalse
} from './util.js'
import accessibilityScripts from './scripts/accessibility-scripts.js'
import PerformanceTester from './instrumentation/performance/performance-tester.js'
import * as PERFORMANCE_SDK_EVENTS from './instrumentation/performance/constants.js'

import { BStackLogger } from './bstackLogger.js'

class _AccessibilityHandler {
    private _platformA11yMeta: { [key: string]: unknown; }
    private _caps: Capabilities.ResolvedTestrunnerCapabilities
    private _suiteFile?: string
    private _accessibility?: boolean
    private _turboscale?: boolean
    private _options: BrowserstackConfig & BrowserstackOptions
    private _config: Options.Testrunner
    private _accessibilityOptions?: { [key: string]: unknown; }
    private _autoScanning: boolean = true
    private _testIdentifier: string | null = null
    private _testMetadata: { [key: string]: unknown; } = {}
    private static _a11yScanSessionMap: { [key: string]: unknown; } = {}
    private _sessionId: string | null = null
    private listener = Listener.getInstance()

    constructor (
        private _browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser,
        _capabilities: Capabilities.ResolvedTestrunnerCapabilities,
        _options : BrowserstackConfig & BrowserstackOptions,
        private isAppAutomate: boolean,
        _config : Options.Testrunner,
        private _framework?: string,
        _accessibilityAutomation?: boolean | string,
        _turboscale?: boolean | string,
        _accessibilityOpts?: { [key: string]: unknown; }
    ) {
        const caps = (this._browser as WebdriverIO.Browser).capabilities as WebdriverIO.Capabilities

        this._platformA11yMeta = {
            browser_name: caps.browserName,
            // @ts-expect-error invalid caps property
            browser_version: caps?.browserVersion || (caps as WebdriverIO.Capabilities)?.version || 'latest',
            platform_name: caps?.platformName,
            platform_version: this._getCapabilityValue(caps, 'appium:platformVersion', 'platformVersion'),
            os_name: this._getCapabilityValue(_capabilities, 'os', 'os'),
            os_version: this._getCapabilityValue(_capabilities, 'osVersion', 'os_version')
        }

        this._caps = _capabilities
        this._accessibility = isTrue(_accessibilityAutomation)
        this._accessibilityOptions = _accessibilityOpts
        this._autoScanning = !isFalse(this._accessibilityOptions?.autoScanning)
        this._options = _options
        this._config= _config
        this._turboscale = isTrue(_turboscale)
    }

    setSuiteFile(filename: string) {
        this._suiteFile = filename
    }

    _getCapabilityValue(caps: Capabilities.ResolvedTestrunnerCapabilities, capType: string, legacyCapType: string) {
        if (caps) {
            if (capType === 'accessibility') {
                if ((caps as WebdriverIO.Capabilities)['bstack:options'] && (isTrue((caps as WebdriverIO.Capabilities)['bstack:options']?.accessibility))) {
                    return (caps as WebdriverIO.Capabilities)['bstack:options']?.accessibility
                } else if (isTrue((caps as WebdriverIO.Capabilities)['browserstack.accessibility'])) {
                    return (caps as WebdriverIO.Capabilities)['browserstack.accessibility']
                }
            } else if (capType === 'deviceName') {
                if ((caps as WebdriverIO.Capabilities)['bstack:options'] && (caps as WebdriverIO.Capabilities)['bstack:options']?.deviceName) {
                    return (caps as WebdriverIO.Capabilities)['bstack:options']?.deviceName
                } else if ((caps as WebdriverIO.Capabilities)['bstack:options'] && (caps as WebdriverIO.Capabilities)['bstack:options']?.device) {
                    return (caps as WebdriverIO.Capabilities)['bstack:options']?.device
                } else if ((caps as WebdriverIO.Capabilities)['appium:deviceName']) {
                    return (caps as WebdriverIO.Capabilities)['appium:deviceName']
                }
            } else if (capType === 'goog:chromeOptions' && (caps as WebdriverIO.Capabilities)['goog:chromeOptions']) {
                return (caps as WebdriverIO.Capabilities)['goog:chromeOptions']
            } else {
                const bstackOptions = (caps as WebdriverIO.Capabilities)['bstack:options']
                if ( bstackOptions && bstackOptions?.[capType as keyof Capabilities.BrowserStackCapabilities]) {
                    return bstackOptions?.[capType as keyof Capabilities.BrowserStackCapabilities]
                } else if ((caps as WebdriverIO.Capabilities)[legacyCapType as keyof WebdriverIO.Capabilities]) {
                    return (caps as WebdriverIO.Capabilities)[legacyCapType as keyof WebdriverIO.Capabilities]
                }
            }
        }
    }

    async before (sessionId: string) {
        this._sessionId = sessionId
        this._accessibility = isTrue(this._getCapabilityValue(this._caps, 'accessibility', 'browserstack.accessibility'))

        //checks for running ALLY on non-bstack infra
        if (
            isAccessibilityAutomationSession(this._accessibility) &&
            (
                this._turboscale ||
                !shouldAddServiceVersion(this._config, this._options.testObservability)
            ) &&
            validateCapsWithNonBstackA11y(
                this._platformA11yMeta.browser_name as string,
                this._platformA11yMeta?.browser_version as string
            )
        ){
            this._accessibility = true
        } else {
            if (isAccessibilityAutomationSession(this._accessibility) && !this.isAppAutomate) {
                const deviceName = this._getCapabilityValue(this._caps, 'deviceName', 'device')
                const chromeOptions = this._getCapabilityValue(this._caps, 'goog:chromeOptions', '') as Capabilities.ChromeOptions

                this._accessibility = validateCapsWithA11y(deviceName as string, this._platformA11yMeta as unknown as Record<string, string>, chromeOptions)
            }
            if (isAppAccessibilityAutomationSession(this._accessibility, this.isAppAutomate)) {
                this._accessibility = validateCapsWithAppA11y(this._platformA11yMeta)
            }
        }

        (this._browser as WebdriverIO.Browser).getAccessibilityResultsSummary = async () => {
            if (isAppAccessibilityAutomationSession(this._accessibility, this.isAppAutomate)) {
                return await getAppA11yResultsSummary(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility, this._sessionId)
            }
            return await getA11yResultsSummary(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility)
        }

        (this._browser as WebdriverIO.Browser).getAccessibilityResults = async () => {
            if (isAppAccessibilityAutomationSession(this._accessibility, this.isAppAutomate)) {
                return await getAppA11yResults(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility, this._sessionId)
            }
            return await getA11yResults(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility)
        }

        (this._browser as WebdriverIO.Browser).performScan = async () => {
            const results = await performA11yScan(this.isAppAutomate, (this._browser as WebdriverIO.Browser), isBrowserstackSession(this._browser), this._accessibility)
            if (results) {
                this._testMetadata[this._testIdentifier as string] = {
                    scanTestForAccessibility : true,
                    accessibilityScanStarted : true
                }
            }
            await this._setAnnotation('Accessibility scanning was triggered manually')
            return results
        }

        (this._browser as WebdriverIO.Browser).startA11yScanning = async () => {
            AccessibilityHandler._a11yScanSessionMap[sessionId] = true
            this._testMetadata[this._testIdentifier as string] = {
                scanTestForAccessibility : true,
                accessibilityScanStarted : true
            }
            await this._setAnnotation('Accessibility scanning has started')
        }

        (this._browser as WebdriverIO.Browser).stopA11yScanning = async () => {
            AccessibilityHandler._a11yScanSessionMap[sessionId] = false
            await this._setAnnotation('Accessibility scanning has stopped')
        }

        if (!this._accessibility) {
            return
        }
        if (!('overwriteCommand' in this._browser && Array.isArray(accessibilityScripts.commandsToWrap))) {
            return
        }

        accessibilityScripts.commandsToWrap
            .filter((command) => command.name && command.class)
            .forEach((command) => {
                const browser = this._browser as WebdriverIO.Browser
                try {
                    const prevImpl = browser[command.name as keyof WebdriverIO.Browser].bind(browser)
                    // @ts-expect-error fix type
                    browser.overwriteCommand(command.name, this.commandWrapper.bind(this, command, prevImpl), command.class === 'Element')
                } catch (error) {
                    BStackLogger.debug(`Exception in overwrite command ${command.name} - ${error}`)
                }
            })
    }

    async beforeTest (suiteTitle: string | undefined, test: Frameworks.Test) {
        try {
            if (
                this._framework !== 'mocha' ||
                !this.shouldRunTestHooks(this._browser, this._accessibility)
            ) {
                /* This is to be used when test events are sent */
                Listener.setTestRunAccessibilityVar(false)
                return
            }

            // @ts-expect-error fix type
            const shouldScanTest = this._autoScanning && shouldScanTestForAccessibility(suiteTitle, test.title, this._accessibilityOptions)
            const testIdentifier = this.getIdentifier(test)
            this._testIdentifier = testIdentifier

            if (this._sessionId) {
                /* For case with multiple tests under one browser, before hook of 2nd test should change this map value */
                AccessibilityHandler._a11yScanSessionMap[this._sessionId] = shouldScanTest
            }

            /* This is to be used when test events are sent */
            Listener.setTestRunAccessibilityVar(this._accessibility && shouldScanTest)

            this._testMetadata[testIdentifier] = {
                scanTestForAccessibility : shouldScanTest,
                accessibilityScanStarted : true
            }

            // @ts-expect-error fix type
            this._testMetadata[testIdentifier].accessibilityScanStarted = shouldScanTest

            if (shouldScanTest) {
                BStackLogger.info('Automate test case execution has started.')
            }
        } catch (error) {
            BStackLogger.error(`Exception in starting accessibility automation scan for this test case ${error}`)
        }
    }

    async afterTest (suiteTitle: string | undefined, test: Frameworks.Test) {
        BStackLogger.debug('Accessibility after test hook. Before sending test stop event')
        if (
            this._framework !== 'mocha' ||
            !this.shouldRunTestHooks(this._browser, this._accessibility)
        ) {
            return
        }

        try {
            const testIdentifier = this.getIdentifier(test)
            // @ts-expect-error fix type
            const accessibilityScanStarted = this._testMetadata[testIdentifier]?.accessibilityScanStarted
            // @ts-expect-error fix type
            const shouldScanTestForAccessibility = this._testMetadata[testIdentifier]?.scanTestForAccessibility

            if (!accessibilityScanStarted) {
                return
            }

            if (shouldScanTestForAccessibility) {
                BStackLogger.info('Automate test case execution has ended. Processing for accessibility testing is underway. ')

                const dataForExtension = {
                    'thTestRunUuid': process.env.TEST_ANALYTICS_ID,
                    'thBuildUuid': process.env.BROWSERSTACK_TESTHUB_UUID,
                    'thJwtToken': process.env.BROWSERSTACK_TESTHUB_JWT
                }

                await this.sendTestStopEvent((this._browser as WebdriverIO.Browser), dataForExtension)

                BStackLogger.info('Accessibility testing for this test case has ended.')
            }
        } catch (error) {
            BStackLogger.error(`Accessibility results could not be processed for the test case ${test.title}. Error : ${error}`)
        }
    }

    /**
      * Cucumber Only
    */
    async beforeScenario (world: ITestCaseHookParameter) {
        const pickleData = world.pickle
        const gherkinDocument = world.gherkinDocument
        const featureData = gherkinDocument.feature
        const uniqueId = getUniqueIdentifierForCucumber(world)
        this._testIdentifier = uniqueId
        if (!this.shouldRunTestHooks(this._browser, this._accessibility)) {
            /* This is to be used when test events are sent */
            Listener.setTestRunAccessibilityVar(false)
            return
        }

        try {
            // @ts-expect-error fix type
            const shouldScanScenario = this._autoScanning && shouldScanTestForAccessibility(featureData?.name, pickleData.name, this._accessibilityOptions, world, true)
            this._testMetadata[uniqueId] = {
                scanTestForAccessibility : shouldScanScenario,
                accessibilityScanStarted : true
            }

            // @ts-expect-error fix type
            this._testMetadata[uniqueId].accessibilityScanStarted = shouldScanScenario
            if (this._sessionId) {
                /* For case with multiple tests under one browser, before hook of 2nd test should change this map value */
                AccessibilityHandler._a11yScanSessionMap[this._sessionId] = shouldScanScenario
            }

            /* This is to be used when test events are sent */
            Listener.setTestRunAccessibilityVar(this._accessibility && shouldScanScenario)

            if (shouldScanScenario) {
                BStackLogger.info('Automate test case execution has started.')
            }
        } catch (error) {
            BStackLogger.error(`Exception in starting accessibility automation scan for this test case ${error}`)
        }
    }

    async afterScenario (world: ITestCaseHookParameter) {
        BStackLogger.debug('Accessibility after scenario hook. Before sending test stop event')
        if (!this.shouldRunTestHooks(this._browser, this._accessibility)) {
            return
        }

        const pickleData = world.pickle
        try {
            const uniqueId = getUniqueIdentifierForCucumber(world)
            // @ts-expect-error fix type
            const accessibilityScanStarted = this._testMetadata[uniqueId]?.accessibilityScanStarted
            // @ts-expect-error fix type
            const shouldScanTestForAccessibility = this._testMetadata[uniqueId]?.scanTestForAccessibility

            if (!accessibilityScanStarted) {
                return
            }

            if (shouldScanTestForAccessibility) {
                BStackLogger.info('Automate test case execution has ended. Processing for accessibility testing is underway. ')

                const dataForExtension = {
                    'thTestRunUuid': process.env.TEST_ANALYTICS_ID,
                    'thBuildUuid': process.env.BROWSERSTACK_TESTHUB_UUID,
                    'thJwtToken': process.env.BROWSERSTACK_TESTHUB_JWT
                }

                await this.sendTestStopEvent(( this._browser as WebdriverIO.Browser), dataForExtension)

                BStackLogger.info('Accessibility testing for this test case has ended.')
            }
        } catch (error) {
            BStackLogger.error(`Accessibility results could not be processed for the test case ${pickleData.name}. Error : ${error}`)
        }
    }

    /*
     * private methods
     */

    private async commandWrapper (command: { name: string, class: string }, prevImpl: Function, origFunction: Function, ...args: string[]) {
        if (
            this._sessionId && AccessibilityHandler._a11yScanSessionMap[this._sessionId] &&
                (
                    !command.name.includes('execute') ||
                    !AccessibilityHandler.shouldPatchExecuteScript(args.length ? args[0] : null)
                )
        ) {
            BStackLogger.debug(`Performing scan for ${command.class} ${command.name}`)
            await performA11yScan(this.isAppAutomate, this._browser, true, true, command.name)
        }
        return prevImpl(...args)
    }

    private async sendTestStopEvent(browser: WebdriverIO.Browser, dataForExtension: unknown) {
        BStackLogger.debug('Performing scan before saving results')
        if (AccessibilityHandler._a11yScanSessionMap[this._sessionId as string]) {
            await PerformanceTester.measureWrapper(PERFORMANCE_SDK_EVENTS.A11Y_EVENTS.PERFORM_SCAN, async () => {
                await performA11yScan(this.isAppAutomate, browser, true, true)
            }, { command: 'afterTest' })()
        }

        if (isAppAccessibilityAutomationSession(this._accessibility, this.isAppAutomate)) {
            return
        }

        await PerformanceTester.measureWrapper(PERFORMANCE_SDK_EVENTS.A11Y_EVENTS.SAVE_RESULTS, async () => {
            if (accessibilityScripts.saveTestResults) {
                const results: unknown = await executeAccessibilityScript(browser, accessibilityScripts.saveTestResults, dataForExtension)
                BStackLogger.debug(util.format(results as string))
            } else {
                BStackLogger.error('saveTestResults script is null or undefined')
            }
        })()

    }

    private getIdentifier (test: Frameworks.Test | ITestCaseHookParameter) {
        if ('pickle' in test) {
            return getUniqueIdentifierForCucumber(test)
        }
        return getUniqueIdentifier(test, this._framework)
    }

    private shouldRunTestHooks(browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, isAccessibility?: boolean | string) {
        if (!browser) {
            return false
        }
        return isAccessibilityAutomationSession(isAccessibility)
    }

    private async checkIfPageOpened(browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, testIdentifier: string, shouldScanTest?: boolean) {
        let pageOpen = false
        this._testMetadata[testIdentifier] = {
            scanTestForAccessibility : shouldScanTest,
            accessibilityScanStarted : true
        }

        try {
            const currentURL = await (browser as WebdriverIO.Browser).getUrl()
            const url = new URL(currentURL)
            pageOpen = url?.protocol === 'http:' || url?.protocol === 'https:'
        } catch {
            pageOpen = false
        }

        return pageOpen
    }

    private static shouldPatchExecuteScript(script: string | null): boolean {
        if (!script || typeof script !== 'string') {
            return true
        }

        return (
            script.toLowerCase().indexOf('browserstack_executor') !== -1 ||
            script.toLowerCase().indexOf('browserstack_accessibility_automation_script') !== -1
        )
    }

    private async _setAnnotation(message: string) {
        if (this._accessibility && isBrowserstackSession(this._browser)) {
            await (this._browser as WebdriverIO.Browser).executeScript(`browserstack_executor: ${JSON.stringify({
                action: 'annotate',
                arguments: {
                    data: message,
                    level: 'info'
                }
            })}`, [])
        }
    }
}

// https://github.com/microsoft/TypeScript/issues/6543
const AccessibilityHandler: typeof _AccessibilityHandler = o11yClassErrorHandler(_AccessibilityHandler)
type AccessibilityHandler = _AccessibilityHandler

export default AccessibilityHandler