Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pr-312.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@wdio/browserstack-service": minor
---

- Accessibility Automation now scans Chrome on Android and Safari on iPhone sessions.
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ import {
isTrue,
validateCapsWithAppA11y,
getAppA11yResults,
getMobileA11yScriptArgs,
logRejectedMobileSave,
setMobileA11yScriptTimeout,
isFalse,
setBrowserstackAnnotation
} from './util.js'
Expand Down Expand Up @@ -173,6 +176,7 @@ class _AccessibilityHandler {
const chromeOptions = this._getCapabilityValue(this._caps, 'goog:chromeOptions', '')

this._accessibility = validateCapsWithA11y(deviceName, this._platformA11yMeta, chromeOptions)
await setMobileA11yScriptTimeout(this._browser as WebdriverIO.Browser)
}
if (isAppAccessibilityAutomationSession(this._accessibility, this.isAppAutomate)) {
this._accessibility = validateCapsWithAppA11y(this._platformA11yMeta)
Expand Down Expand Up @@ -430,8 +434,9 @@ class _AccessibilityHandler {
return
}
await PerformanceTester.measureWrapper(PERFORMANCE_SDK_EVENTS.A11Y_EVENTS.SAVE_RESULTS, async () => {
const results: unknown = await (browser as WebdriverIO.Browser).executeAsync(accessibilityScripts.saveTestResults as string, dataForExtension)
const results: unknown = await (browser as WebdriverIO.Browser).executeAsync(accessibilityScripts.saveTestResults as string, { ...dataForExtension, ...getMobileA11yScriptArgs() })
BStackLogger.debug(util.format(results as string))
logRejectedMobileSave(results)
})()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { TestFrameworkState } from '../states/testFrameworkState.js'
import { AutomationFrameworkState } from '../states/automationFrameworkState.js'
import { HookState } from '../states/hookState.js'
import accessibilityScripts from '../../scripts/accessibility-scripts.js'
import { _getParamsForAppAccessibility, formatString, getAppA11yResults, getAppA11yResultsSummary, shouldScanTestForAccessibility, validateCapsWithA11y, validateCapsWithAppA11y, validateCapsWithNonBstackA11y, setBrowserstackAnnotation } from '../../util.js'
import { _getParamsForAppAccessibility, formatString, getAppA11yResults, getAppA11yResultsSummary, shouldScanTestForAccessibility, validateCapsWithA11y, validateCapsWithAppA11y, validateCapsWithNonBstackA11y, setBrowserstackAnnotation, getMobileA11yScriptArgs, getMobileResultsArg, logRejectedMobileSave, setMobileA11yScriptTimeout } from '../../util.js'
import { AutomationFrameworkConstants } from '../frameworks/constants/automationFrameworkConstants.js'
import util from 'node:util'
import type { Accessibility } from '../../grpc/index.js'
Expand Down Expand Up @@ -90,6 +90,7 @@ export default class AccessibilityModule extends BaseModule {
const device = this.getCapability(inputCaps, 'deviceName')
const chromeOptions = this.getCapability(inputCaps, 'goog:chromeOptions')
this.accessibility = validateCapsWithA11y(device, platformA11yMeta, chromeOptions)
await setMobileA11yScriptTimeout(browser)
}

//patching getA11yResultsSummary
Expand Down Expand Up @@ -410,7 +411,7 @@ export default class AccessibilityModule extends BaseModule {
}
const results = await (browser as WebdriverIO.Browser).executeAsync(
this.scriptInstance.performScan as string,
{ 'method': commandName || '' }
{ 'method': commandName || '', ...getMobileA11yScriptArgs() }
)
return (results as { [key: string]: any; } | undefined)
} catch (err: any) {
Expand Down Expand Up @@ -439,8 +440,9 @@ export default class AccessibilityModule extends BaseModule {
}

await PerformanceTester.measureWrapper(PERFORMANCE_SDK_EVENTS.A11Y_EVENTS.SAVE_RESULTS, async () => {
const results: unknown = await (browser as WebdriverIO.Browser).executeAsync(accessibilityScripts.saveTestResults as string, dataForExtension)
const results: unknown = await (browser as WebdriverIO.Browser).executeAsync(accessibilityScripts.saveTestResults as string, { ...dataForExtension, ...getMobileA11yScriptArgs() })
this.logger.debug(`save results : ${util.format(results as string)}`)
logRejectedMobileSave(results)
})()
} catch (error) {
this.logger.error(`Error while sending test stop event: ${error}`)
Expand All @@ -458,7 +460,7 @@ export default class AccessibilityModule extends BaseModule {
}
this.logger.debug('Performing scan before getting results')
await this.performScanCli(browser)
const results: Array<{ [key: string]: any; }> = await (browser as WebdriverIO.Browser).executeAsync(this.scriptInstance.getResults as string)
const results: Array<{ [key: string]: any; }> = await this.executeResultsScript(browser, this.scriptInstance.getResults as string)
return results
} catch (error: any) {
this.logger.error('No accessibility results were found.')
Expand All @@ -480,7 +482,7 @@ export default class AccessibilityModule extends BaseModule {
}
this.logger.debug('Performing scan before getting results summary')
await this.performScanCli(browser)
const summaryResults: { [key: string]: any; } = await (browser as WebdriverIO.Browser).executeAsync(this.scriptInstance.getResultsSummary as string)
const summaryResults: { [key: string]: any; } = await this.executeResultsScript(browser, this.scriptInstance.getResultsSummary as string)
return summaryResults
} catch {
this.logger.error('No accessibility summary was found.')
Expand All @@ -490,6 +492,12 @@ export default class AccessibilityModule extends BaseModule {
)()
}

// Desktop result scripts take no argument; mobile ones need the session flag and run uuid.
private async executeResultsScript<T>(browser: WebdriverIO.Browser, script: string): Promise<T> {
const mobileArg = getMobileResultsArg()
return (mobileArg ? browser.executeAsync(script, mobileArg) : browser.executeAsync(script)) as Promise<T>
}

async getDriverExecuteParams(scriptName: string) {
const payload: Omit<FetchDriverExecuteParamsEventRequest, 'binSessionId'> = {
product: 'accessibility',
Expand Down
89 changes: 83 additions & 6 deletions packages/browserstack-service/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ export const processAccessibilityResponse = (response: LaunchResponse | StartBi
}

if (response.accessibility.options) {
const { accessibilityToken, pollingTimeout, scannerVersion } = jsonifyAccessibilityArray(response.accessibility.options.capabilities, 'name', 'value')
const { accessibilityToken, pollingTimeout, scannerVersion, scannerProcessingTimeout, isMobileScanningEnabledForGroup, isIosSafariScanningEnabledForGroup } = jsonifyAccessibilityArray(response.accessibility.options.capabilities, 'name', 'value')
const result = jsonifyAccessibilityArray(response.accessibility.options.capabilities, 'name', 'value')
const scriptsJson = {
'scripts': jsonifyAccessibilityArray(response.accessibility.options.scripts, 'name', 'command'),
Expand All @@ -358,6 +358,14 @@ export const processAccessibilityResponse = (response: LaunchResponse | StartBi
if (pollingTimeout) {
process.env.BSTACK_A11Y_POLLING_TIMEOUT = pollingTimeout
}
if (scannerProcessingTimeout) {
process.env.BSTACK_A11Y_SCANNER_PROCESSING_TIMEOUT = scannerProcessingTimeout
}
// Group entitlements for mobile web a11y; set in the launcher, so workers inherit them.
process.env.BROWSERSTACK_A11Y_MOBILE_SCANNING_ENABLED_FOR_GROUP = String(isTrue(isMobileScanningEnabledForGroup))
if (isIosSafariScanningEnabledForGroup !== undefined) {
process.env.BROWSERSTACK_A11Y_IOS_SAFARI_SCANNING_ENABLED_FOR_GROUP = String(isTrue(isIosSafariScanningEnabledForGroup))
}
if (scriptsJson) {
AccessibilityScripts.update(scriptsJson)
AccessibilityScripts.store()
Expand Down Expand Up @@ -495,12 +503,75 @@ export const validateCapsWithAppA11y = (platformMeta?: { [key: string]: any; })
return true
}

export const MOBILE_SCANNING_DISABLED_WARNING = 'Mobile Accessibility Automation is not enabled for your account. Accessibility scans will be skipped on mobile sessions. Contact BrowserStack to enable mobile accessibility scanning.'

export const isMobileScanningEnabledForGroup = () => process.env.BROWSERSTACK_A11Y_MOBILE_SCANNING_ENABLED_FOR_GROUP === 'true'

// On unless the backend opts the group out, so backends that don't send the flag still scan iOS.
export const isIosSafariScanningEnabledForGroup = () => process.env.BROWSERSTACK_A11Y_IOS_SAFARI_SCANNING_ENABLED_FOR_GROUP !== 'false'

/* Mobile browsers accessibility scans in-page: Chrome on Android, and Safari on iPhone (not iPad) unless the group is opted out. */
export const isMobileA11yPlatformPair = (platformName?: string, browserName?: string, deviceName?: string) => {
const platform = String(platformName || '').toLowerCase()
const browser = String(browserName || '').toLowerCase()
if (platform === 'android') {
return browser.includes('chrome')
}
return platform === 'ios' && /safari|iphone|webkit/.test(browser) && !/ipad/i.test(String(deviceName || '')) && isIosSafariScanningEnabledForGroup()
}

// One driver session per worker process, so the worker's mobile state is module-level.
let mobileA11ySession = false

/* Mobile scans post results from the page, so the scan, results and save scripts need the session flag and run uuid. */
export const getMobileA11yScriptArgs = (): Record<string, unknown> => (mobileA11ySession
? { 'is_mobile_session': true, 'thTestRunUuid': process.env.TEST_ANALYTICS_ID }
: {})

/* The mobile save reports failure in its result instead of throwing, so surface it. */
export const logRejectedMobileSave = (results: unknown) => {
if (mobileA11ySession && (results as { success?: unknown } | undefined)?.success === false) {
BStackLogger.error(`Accessibility results could not be saved for this mobile session: ${(results as { msg?: string }).msg}`)
}
}

/* Desktop result scripts take no argument; mobile ones need the session flag and run uuid. */
export const getMobileResultsArg = () => (mobileA11ySession ? getMobileA11yScriptArgs() : undefined)

/* The backend's scannerProcessingTimeout (seconds) from build start; 30s when it isn't sent, as in the Node SDK. */
export const getMobileA11yScriptTimeoutMs = () => (parseInt(process.env.BSTACK_A11Y_SCANNER_PROCESSING_TIMEOUT || '', 10) || 30) * 1000

/* Appium XCUITest starts iOS sessions with a ~0 ms async script timeout, so every executeAsync scan times out until one is set. */
export const setMobileA11yScriptTimeout = async (browser: WebdriverIO.Browser) => {
if (!mobileA11ySession) {
return
}
try {
await browser.setTimeout({ script: getMobileA11yScriptTimeoutMs() })
} catch (error) {
BStackLogger.debug(`Could not set the script timeout for the mobile accessibility session: ${error}`)
}
}

export const validateCapsWithA11y = (deviceName?: any, platformMeta?: { [key: string]: any; }, chromeOptions?: any) => {
/* Check if the current driver platform is eligible for Accessibility scan */
mobileA11ySession = false
try {
if (deviceName) {
BStackLogger.warn('Accessibility Automation will run only on Desktop browsers.')
return false
// Session caps can report the Appium host OS (e.g. LINUX) as platformName; the device name tells iOS apart.
const reportedPlatform = String(platformMeta?.platform_name || '').toLowerCase()
const platform = ['android', 'ios'].includes(reportedPlatform) ? reportedPlatform : (/iphone|ipad/i.test(String(deviceName)) ? 'ios' : 'android')
if (!isMobileA11yPlatformPair(platform, platformMeta?.browser_name, deviceName)) {
BStackLogger.warn('Accessibility Automation will run only on Desktop browsers, Chrome on Android or Safari on iPhone.')
return false
}
if (!isMobileScanningEnabledForGroup()) {
BStackLogger.warn(MOBILE_SCANNING_DISABLED_WARNING)
return false
}
BStackLogger.info(`Accessibility Automation is enabled for mobile browser (${platform}, ${platformMeta?.browser_name}).`)
mobileA11ySession = true
return true
}

if (platformMeta?.browser_name?.toLowerCase() !== 'chrome') {
Expand Down Expand Up @@ -614,7 +685,7 @@ export const performA11yScan = async (isAppAutomate: boolean, browser: Webdriver
BStackLogger.debug(util.format(results as string))
return ( results as { [key: string]: any; } | undefined )
}
const results: unknown = await (browser as WebdriverIO.Browser).executeAsync(AccessibilityScripts.performScan as string, { 'method': commandName || '' })
const results: unknown = await (browser as WebdriverIO.Browser).executeAsync(AccessibilityScripts.performScan as string, { 'method': commandName || '', ...getMobileA11yScriptArgs() })
BStackLogger.debug(util.format(results as string))
return ( results as { [key: string]: any; } | undefined )
} catch (err : any) {
Expand All @@ -634,7 +705,10 @@ export const getA11yResults = PerformanceTester.measureWrapper(PERFORMANCE_SDK_E
try {
BStackLogger.debug('Performing scan before getting results')
await performA11yScan(isAppAutomate, browser, isBrowserStackSession, isAccessibility)
const results: Array<{ [key: string]: any; }> = await (browser as WebdriverIO.Browser).executeAsync(AccessibilityScripts.getResults as string)
const mobileArg = getMobileResultsArg()
const results: Array<{ [key: string]: any; }> = await (mobileArg
? (browser as WebdriverIO.Browser).executeAsync(AccessibilityScripts.getResults as string, mobileArg)
: (browser as WebdriverIO.Browser).executeAsync(AccessibilityScripts.getResults as string))
return results
} catch (error: any) {
BStackLogger.error('No accessibility results were found.')
Expand Down Expand Up @@ -708,7 +782,10 @@ export const getA11yResultsSummary = PerformanceTester.measureWrapper(PERFORMANC
try {
BStackLogger.debug('Performing scan before getting results summary')
await performA11yScan(isAppAutomate, browser, isBrowserStackSession, isAccessibility)
const summaryResults: { [key: string]: any; } = await (browser as WebdriverIO.Browser).executeAsync(AccessibilityScripts.getResultsSummary as string)
const mobileArg = getMobileResultsArg()
const summaryResults: { [key: string]: any; } = await (mobileArg
? (browser as WebdriverIO.Browser).executeAsync(AccessibilityScripts.getResultsSummary as string, mobileArg)
: (browser as WebdriverIO.Browser).executeAsync(AccessibilityScripts.getResultsSummary as string))
return summaryResults
} catch (error: any) {
BStackLogger.error(`No accessibility summary was found. Error: ${error?.message ?? util.inspect(error, { depth: 2 })}`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ vi.mock('../../../src/util.js', () => ({
getAppA11yResultsSummary: vi.fn().mockResolvedValue({}),
_getParamsForAppAccessibility: vi.fn().mockReturnValue('{}'),
formatString: vi.fn().mockReturnValue('formatted-script'),
isBrowserstackSession: vi.fn().mockReturnValue(true)
isBrowserstackSession: vi.fn().mockReturnValue(true),
getMobileA11yScriptArgs: vi.fn().mockReturnValue({}),
getMobileResultsArg: vi.fn().mockReturnValue(undefined),
logRejectedMobileSave: vi.fn(),
setMobileA11yScriptTimeout: vi.fn()
}))

vi.mock('../../../src/cli/grpcClient.js', () => ({
Expand Down
Loading
Loading