From 7850442cf06bf76518256cddc11d7873ad02ec7d Mon Sep 17 00:00:00 2001 From: palspal-bstack Date: Fri, 23 Oct 2026 19:20:53 +0530 Subject: [PATCH 1/4] A11Y-13878: Scan mobile browsers in-page for accessibility Chrome on Android, and Safari on iPhone for the iOS cohort, can't load the accessibility extension, so their scans run in the page. Allow those sessions when the group has mobile scanning, read the mobile and iOS group flags from build start, and pass is_mobile_session and thTestRunUuid to the scan, results and save scripts in both the classic and CLI flows. Co-Authored-By: Claude Opus 5.5 --- .../src/accessibility-handler.ts | 5 +- .../src/cli/modules/accessibilityModule.ts | 17 +++-- packages/browserstack-service/src/util.ts | 62 +++++++++++++++++-- .../cli/modules/accessibilityModule.test.ts | 5 +- .../browserstack-service/tests/util.test.ts | 49 +++++++++++++-- 5 files changed, 121 insertions(+), 17 deletions(-) diff --git a/packages/browserstack-service/src/accessibility-handler.ts b/packages/browserstack-service/src/accessibility-handler.ts index 1055bb53..35913870 100644 --- a/packages/browserstack-service/src/accessibility-handler.ts +++ b/packages/browserstack-service/src/accessibility-handler.ts @@ -67,6 +67,8 @@ import { validateCapsWithAppA11y, getAppA11yResults, executeAccessibilityScript, + getMobileA11yScriptArgs, + logRejectedMobileSave, isFalse, getHookType, frameworkSupportsHook @@ -582,8 +584,9 @@ class _AccessibilityHandler { await PerformanceTester.measureWrapper(PERFORMANCE_SDK_EVENTS.A11Y_EVENTS.SAVE_RESULTS, async () => { if (accessibilityScripts.saveTestResults) { - const results: unknown = await executeAccessibilityScript(browser, accessibilityScripts.saveTestResults, dataForExtension) + const results: unknown = await executeAccessibilityScript(browser, accessibilityScripts.saveTestResults, { ...dataForExtension, ...getMobileA11yScriptArgs() }) BStackLogger.debug(util.format(results as string)) + logRejectedMobileSave(results) } else { BStackLogger.error('saveTestResults script is null or undefined') } diff --git a/packages/browserstack-service/src/cli/modules/accessibilityModule.ts b/packages/browserstack-service/src/cli/modules/accessibilityModule.ts index edcb1b3b..58b39d8b 100644 --- a/packages/browserstack-service/src/cli/modules/accessibilityModule.ts +++ b/packages/browserstack-service/src/cli/modules/accessibilityModule.ts @@ -10,7 +10,7 @@ import { AutomationFrameworkState } from '../states/automationFrameworkState.js' import { HookState } from '../states/hookState.js' import type { Command } from '../../scripts/accessibility-scripts.js' import accessibilityScripts from '../../scripts/accessibility-scripts.js' -import { _getParamsForAppAccessibility, formatString, getAppA11yResults, getAppA11yResultsSummary, shouldScanTestForAccessibility, validateCapsWithA11y, validateCapsWithAppA11y, isBrowserstackSession } from '../../util.js' +import { _getParamsForAppAccessibility, formatString, getAppA11yResults, getAppA11yResultsSummary, shouldScanTestForAccessibility, validateCapsWithA11y, validateCapsWithAppA11y, isBrowserstackSession, getMobileA11yScriptArgs, getMobileResultsArg, logRejectedMobileSave } from '../../util.js' import { AutomationFrameworkConstants } from '../frameworks/constants/automationFrameworkConstants.js' import util from 'node:util' import type { Accessibility } from '../../grpc/index.js' @@ -567,7 +567,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 Record | undefined) } catch (err: unknown) { @@ -598,8 +598,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}`) @@ -617,7 +618,7 @@ export default class AccessibilityModule extends BaseModule { } this.logger.debug('Performing scan before getting results') await this.performScanCli(browser) - const results: Array> = await (browser as WebdriverIO.Browser).executeAsync(this.scriptInstance.getResults as string) + const results: Array> = await this.executeResultsScript(browser, this.scriptInstance.getResults as string) return results } catch (error: unknown) { this.logger.error('No accessibility results were found.') @@ -639,7 +640,7 @@ export default class AccessibilityModule extends BaseModule { } this.logger.debug('Performing scan before getting results summary') await this.performScanCli(browser) - const summaryResults: Record = await (browser as WebdriverIO.Browser).executeAsync(this.scriptInstance.getResultsSummary as string) + const summaryResults: Record = await this.executeResultsScript(browser, this.scriptInstance.getResultsSummary as string) return summaryResults } catch { this.logger.error('No accessibility summary was found.') @@ -649,6 +650,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(browser: WebdriverIO.Browser, script: string): Promise { + const mobileArg = getMobileResultsArg() + return (mobileArg ? browser.executeAsync(script, mobileArg) : browser.executeAsync(script)) as Promise + } + async getDriverExecuteParams(): Promise> { const payload: Omit = { product: 'accessibility', diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 5aa1e9bb..e9c1a233 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -339,7 +339,7 @@ export const processAccessibilityResponse = (response: LaunchResponse | StartBin } if (response.accessibility.options) { - const { accessibilityToken, pollingTimeout, scannerVersion } = jsonifyAccessibilityArray(response.accessibility.options.capabilities as Array>, 'name', 'value') + const { accessibilityToken, pollingTimeout, scannerVersion, isMobileScanningEnabledForGroup, isIosSafariScanningEnabledForGroup } = jsonifyAccessibilityArray(response.accessibility.options.capabilities as Array>, 'name', 'value') const result = jsonifyAccessibilityArray(response.accessibility.options.capabilities as Array>, 'name', 'value') const scriptsJson = { 'scripts': jsonifyAccessibilityArray(response.accessibility.options.scripts as Array>, 'name', 'command'), @@ -357,6 +357,9 @@ export const processAccessibilityResponse = (response: LaunchResponse | StartBin if (pollingTimeout) { process.env.BSTACK_A11Y_POLLING_TIMEOUT = pollingTimeout as string } + // 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)) + process.env.BROWSERSTACK_A11Y_IOS_SAFARI_SCANNING_ENABLED_FOR_GROUP = String(isTrue(isIosSafariScanningEnabledForGroup)) if (scriptsJson) { // @ts-expect-error fix type AccessibilityScripts.update(scriptsJson) @@ -481,13 +484,60 @@ 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' + +export const isIosSafariScanningEnabledForGroup = () => process.env.BROWSERSTACK_A11Y_IOS_SAFARI_SCANNING_ENABLED_FOR_GROUP === 'true' + +/* Mobile browsers accessibility scans in-page: Chrome on Android, and Safari on iPhone (not iPad) for the iOS cohort. */ +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 => (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) + // eslint-disable-next-line @typescript-eslint/no-explicit-any 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') { @@ -628,7 +678,7 @@ export const performA11yScan = async (isAppAutomate: boolean, browser: Webdriver return ( results as { [key: string]: any; } | undefined ) } if (AccessibilityScripts.performScan) { - const results = await executeAccessibilityScript(browser, AccessibilityScripts.performScan, { method: commandName || '' }) + const results = await executeAccessibilityScript(browser, AccessibilityScripts.performScan, { method: commandName || '', ...getMobileA11yScriptArgs() }) return ( results as { [key: string]: unknown; } | undefined ) } BStackLogger.error('AccessibilityScripts.performScan is null') @@ -650,7 +700,7 @@ export const getA11yResults = PerformanceTester.measureWrapper(PERFORMANCE_SDK_E BStackLogger.debug('Performing scan before getting results') await performA11yScan(isAppAutomate, browser, isBrowserStackSession, isAccessibility) if (AccessibilityScripts.getResults) { - const results: Array<{ [key: string]: unknown }> = await executeAccessibilityScript(browser, AccessibilityScripts.getResults) + const results: Array<{ [key: string]: unknown }> = await executeAccessibilityScript(browser, AccessibilityScripts.getResults, getMobileResultsArg()) return results } BStackLogger.error('AccessibilityScripts.getResults is null') @@ -729,7 +779,7 @@ export const getA11yResultsSummary = PerformanceTester.measureWrapper(PERFORMANC BStackLogger.debug('Performing scan before getting results summary') await performA11yScan(isAppAutomate, browser, isBrowserStackSession, isAccessibility) if (AccessibilityScripts.getResultsSummary) { - const summaryResults: { [key: string]: unknown; } = await executeAccessibilityScript(browser, AccessibilityScripts.getResultsSummary) + const summaryResults: { [key: string]: unknown; } = await executeAccessibilityScript(browser, AccessibilityScripts.getResultsSummary, getMobileResultsArg()) return summaryResults } BStackLogger.error('AccessibilityScripts.getResultsSummary is null') diff --git a/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts b/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts index d4114756..7c44b06e 100644 --- a/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts +++ b/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts @@ -37,7 +37,10 @@ vi.mock('../../../src/util.js', () => ({ _getParamsForAppAccessibility: vi.fn().mockReturnValue('{}'), formatString: vi.fn().mockReturnValue('formatted-script'), o11yClassErrorHandler: vi.fn().mockImplementation((cls) => cls), - isBrowserstackSession: vi.fn().mockReturnValue(true) + isBrowserstackSession: vi.fn().mockReturnValue(true), + getMobileA11yScriptArgs: vi.fn().mockReturnValue({}), + getMobileResultsArg: vi.fn().mockReturnValue(undefined), + logRejectedMobileSave: vi.fn() })) vi.mock('../../../src/cli/grpcClient.js', () => ({ diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index a6e2e6bc..0e3b75b9 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -1436,10 +1436,51 @@ describe('validateCapsWithA11y', () => { logInfoMock = vi.spyOn(log, 'warn') }) - it('returns false if deviceName is defined', async () => { - expect(validateCapsWithA11y('Samsung S22')).toEqual(false) - expect(logInfoMock.mock.calls[0][0]) - .toContain('Accessibility Automation will run only on Desktop browsers.') + describe('mobile browsers', () => { + afterEach(() => { + delete process.env.BROWSERSTACK_A11Y_MOBILE_SCANNING_ENABLED_FOR_GROUP + delete process.env.BROWSERSTACK_A11Y_IOS_SAFARI_SCANNING_ENABLED_FOR_GROUP + delete process.env.TEST_ANALYTICS_ID + validateCapsWithA11y(undefined, { browser_name: 'chrome' }) + }) + + it('returns false for a device that is not Chrome on Android or Safari on iPhone', async () => { + process.env.BROWSERSTACK_A11Y_MOBILE_SCANNING_ENABLED_FOR_GROUP = 'true' + expect(validateCapsWithA11y('Samsung S22', { browser_name: 'samsung', platform_name: 'android' })).toEqual(false) + expect(logInfoMock.mock.calls[0][0]) + .toContain('Accessibility Automation will run only on Desktop browsers, Chrome on Android or Safari on iPhone.') + }) + + it('returns false for Chrome on Android when the group has no mobile scanning', async () => { + expect(validateCapsWithA11y('Google Pixel 7', { browser_name: 'chrome', platform_name: 'android' })).toEqual(false) + expect(logInfoMock.mock.calls[0][0]).toContain('Mobile Accessibility Automation is not enabled for your account') + expect(utils.getMobileA11yScriptArgs()).toEqual({}) + }) + + it('returns true for Chrome on Android and adds the mobile script args', async () => { + process.env.BROWSERSTACK_A11Y_MOBILE_SCANNING_ENABLED_FOR_GROUP = 'true' + process.env.TEST_ANALYTICS_ID = 'test-run-uuid' + // Appium-backed sessions can report the host OS as platformName + expect(validateCapsWithA11y('Google Pixel 7', { browser_name: 'chrome', platform_name: 'LINUX' })).toEqual(true) + expect(utils.getMobileA11yScriptArgs()).toEqual({ is_mobile_session: true, thTestRunUuid: 'test-run-uuid' }) + expect(utils.getMobileResultsArg()).toEqual({ is_mobile_session: true, thTestRunUuid: 'test-run-uuid' }) + }) + + it('allows Safari on iPhone only for the iOS cohort and never on iPad', async () => { + process.env.BROWSERSTACK_A11Y_MOBILE_SCANNING_ENABLED_FOR_GROUP = 'true' + expect(validateCapsWithA11y('iPhone 17', { browser_name: 'safari', platform_name: 'ios' })).toEqual(false) + process.env.BROWSERSTACK_A11Y_IOS_SAFARI_SCANNING_ENABLED_FOR_GROUP = 'true' + expect(validateCapsWithA11y('iPhone 17', { browser_name: 'safari' })).toEqual(true) + expect(validateCapsWithA11y('iPad Pro 13 2024', { browser_name: 'safari', platform_name: 'ios' })).toEqual(false) + }) + + it('clears the mobile script args for a desktop session', async () => { + process.env.BROWSERSTACK_A11Y_MOBILE_SCANNING_ENABLED_FOR_GROUP = 'true' + validateCapsWithA11y('Google Pixel 7', { browser_name: 'chrome', platform_name: 'android' }) + expect(validateCapsWithA11y(undefined, { browser_name: 'chrome' })).toEqual(true) + expect(utils.getMobileA11yScriptArgs()).toEqual({}) + expect(utils.getMobileResultsArg()).toBeUndefined() + }) }) it('returns false if browser is not chrome', async () => { From 93db8813703862f964038793fb4c2a6c5a735c3e Mon Sep 17 00:00:00 2001 From: palspal-bstack Date: Tue, 27 Oct 2026 13:10:53 +0530 Subject: [PATCH 2/4] A11Y-13878: Scan iPhone by default and set a mobile script timeout Prod build start doesn't send isIosSafariScanningEnabledForGroup yet, so a missing flag no longer turns iOS off; only an explicit false does. Appium XCUITest starts iOS sessions with a ~0 ms async script timeout, so every executeAsync scan timed out. Set a 30s script timeout once a session is identified as mobile, in both the classic and CLI flows. Co-Authored-By: Claude Opus 5.5 --- .../src/accessibility-handler.ts | 2 ++ .../src/cli/modules/accessibilityModule.ts | 3 ++- packages/browserstack-service/src/util.ts | 23 ++++++++++++++++--- .../cli/modules/accessibilityModule.test.ts | 3 ++- .../browserstack-service/tests/util.test.ts | 18 +++++++++++++-- 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/browserstack-service/src/accessibility-handler.ts b/packages/browserstack-service/src/accessibility-handler.ts index 35913870..93cb3b82 100644 --- a/packages/browserstack-service/src/accessibility-handler.ts +++ b/packages/browserstack-service/src/accessibility-handler.ts @@ -69,6 +69,7 @@ import { executeAccessibilityScript, getMobileA11yScriptArgs, logRejectedMobileSave, + setMobileA11yScriptTimeout, isFalse, getHookType, frameworkSupportsHook @@ -224,6 +225,7 @@ class _AccessibilityHandler { const chromeOptions = this._getCapabilityValue(this._caps, 'goog:chromeOptions', '') as Capabilities.ChromeOptions this._accessibility = validateCapsWithA11y(deviceName as string, this._platformA11yMeta as unknown as Record, chromeOptions) + await setMobileA11yScriptTimeout(this._browser as WebdriverIO.Browser) } if (isAppAccessibilityAutomationSession(this._accessibility, this.isAppAutomate)) { this._accessibility = validateCapsWithAppA11y(this._platformA11yMeta) diff --git a/packages/browserstack-service/src/cli/modules/accessibilityModule.ts b/packages/browserstack-service/src/cli/modules/accessibilityModule.ts index 58b39d8b..47ddcd52 100644 --- a/packages/browserstack-service/src/cli/modules/accessibilityModule.ts +++ b/packages/browserstack-service/src/cli/modules/accessibilityModule.ts @@ -10,7 +10,7 @@ import { AutomationFrameworkState } from '../states/automationFrameworkState.js' import { HookState } from '../states/hookState.js' import type { Command } from '../../scripts/accessibility-scripts.js' import accessibilityScripts from '../../scripts/accessibility-scripts.js' -import { _getParamsForAppAccessibility, formatString, getAppA11yResults, getAppA11yResultsSummary, shouldScanTestForAccessibility, validateCapsWithA11y, validateCapsWithAppA11y, isBrowserstackSession, getMobileA11yScriptArgs, getMobileResultsArg, logRejectedMobileSave } from '../../util.js' +import { _getParamsForAppAccessibility, formatString, getAppA11yResults, getAppA11yResultsSummary, shouldScanTestForAccessibility, validateCapsWithA11y, validateCapsWithAppA11y, isBrowserstackSession, 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' @@ -199,6 +199,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 diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index e9c1a233..453e2900 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -359,7 +359,9 @@ export const processAccessibilityResponse = (response: LaunchResponse | StartBin } // 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)) - process.env.BROWSERSTACK_A11Y_IOS_SAFARI_SCANNING_ENABLED_FOR_GROUP = String(isTrue(isIosSafariScanningEnabledForGroup)) + if (isIosSafariScanningEnabledForGroup !== undefined) { + process.env.BROWSERSTACK_A11Y_IOS_SAFARI_SCANNING_ENABLED_FOR_GROUP = String(isTrue(isIosSafariScanningEnabledForGroup)) + } if (scriptsJson) { // @ts-expect-error fix type AccessibilityScripts.update(scriptsJson) @@ -488,9 +490,10 @@ export const MOBILE_SCANNING_DISABLED_WARNING = 'Mobile Accessibility Automation export const isMobileScanningEnabledForGroup = () => process.env.BROWSERSTACK_A11Y_MOBILE_SCANNING_ENABLED_FOR_GROUP === 'true' -export const isIosSafariScanningEnabledForGroup = () => process.env.BROWSERSTACK_A11Y_IOS_SAFARI_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) for the iOS cohort. */ +/* 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() @@ -518,6 +521,20 @@ export const logRejectedMobileSave = (results: unknown) => { /* Desktop result scripts take no argument; mobile ones need the session flag and run uuid. */ export const getMobileResultsArg = () => (mobileA11ySession ? getMobileA11yScriptArgs() : undefined) +export const MOBILE_A11Y_SCRIPT_TIMEOUT_MS = 30000 + +/* 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: MOBILE_A11Y_SCRIPT_TIMEOUT_MS }) + } catch (error) { + BStackLogger.debug(`Could not set the script timeout for the mobile accessibility session: ${error}`) + } +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const validateCapsWithA11y = (deviceName?: any, platformMeta?: { [key: string]: any; }, chromeOptions?: any) => { /* Check if the current driver platform is eligible for Accessibility scan */ diff --git a/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts b/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts index 7c44b06e..3f3b9a6c 100644 --- a/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts +++ b/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts @@ -40,7 +40,8 @@ vi.mock('../../../src/util.js', () => ({ isBrowserstackSession: vi.fn().mockReturnValue(true), getMobileA11yScriptArgs: vi.fn().mockReturnValue({}), getMobileResultsArg: vi.fn().mockReturnValue(undefined), - logRejectedMobileSave: vi.fn() + logRejectedMobileSave: vi.fn(), + setMobileA11yScriptTimeout: vi.fn() })) vi.mock('../../../src/cli/grpcClient.js', () => ({ diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index 0e3b75b9..b23319d3 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -1466,14 +1466,28 @@ describe('validateCapsWithA11y', () => { expect(utils.getMobileResultsArg()).toEqual({ is_mobile_session: true, thTestRunUuid: 'test-run-uuid' }) }) - it('allows Safari on iPhone only for the iOS cohort and never on iPad', async () => { + it('allows Safari on iPhone unless the group is opted out, and never on iPad', async () => { process.env.BROWSERSTACK_A11Y_MOBILE_SCANNING_ENABLED_FOR_GROUP = 'true' - expect(validateCapsWithA11y('iPhone 17', { browser_name: 'safari', platform_name: 'ios' })).toEqual(false) + expect(validateCapsWithA11y('iPhone 17', { browser_name: 'safari', platform_name: 'ios' })).toEqual(true) + process.env.BROWSERSTACK_A11Y_IOS_SAFARI_SCANNING_ENABLED_FOR_GROUP = 'false' + expect(validateCapsWithA11y('iPhone 17', { browser_name: 'safari' })).toEqual(false) process.env.BROWSERSTACK_A11Y_IOS_SAFARI_SCANNING_ENABLED_FOR_GROUP = 'true' expect(validateCapsWithA11y('iPhone 17', { browser_name: 'safari' })).toEqual(true) expect(validateCapsWithA11y('iPad Pro 13 2024', { browser_name: 'safari', platform_name: 'ios' })).toEqual(false) }) + it('sets a script timeout only for a mobile session', async () => { + const browser = { setTimeout: vi.fn() } as unknown as WebdriverIO.Browser + process.env.BROWSERSTACK_A11Y_MOBILE_SCANNING_ENABLED_FOR_GROUP = 'true' + validateCapsWithA11y('iPhone 17', { browser_name: 'safari', platform_name: 'ios' }) + await utils.setMobileA11yScriptTimeout(browser) + expect(browser.setTimeout).toHaveBeenCalledWith({ script: utils.MOBILE_A11Y_SCRIPT_TIMEOUT_MS }) + vi.mocked(browser.setTimeout).mockClear() + validateCapsWithA11y(undefined, { browser_name: 'chrome' }) + await utils.setMobileA11yScriptTimeout(browser) + expect(browser.setTimeout).not.toHaveBeenCalled() + }) + it('clears the mobile script args for a desktop session', async () => { process.env.BROWSERSTACK_A11Y_MOBILE_SCANNING_ENABLED_FOR_GROUP = 'true' validateCapsWithA11y('Google Pixel 7', { browser_name: 'chrome', platform_name: 'android' }) From 2edd18361fec79456528f668813737c999e7f98b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:41:45 +0000 Subject: [PATCH 3/4] chore(changeset): auto-generate from PR template (minor) --- .changeset/pr-311.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-311.md diff --git a/.changeset/pr-311.md b/.changeset/pr-311.md new file mode 100644 index 00000000..eedccab6 --- /dev/null +++ b/.changeset/pr-311.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": minor +--- + +- Accessibility Automation now scans Chrome on Android and Safari on iPhone sessions. From 9183eb5cbee01522bc406afeb82321b3825cf73d Mon Sep 17 00:00:00 2001 From: palspal-bstack Date: Tue, 27 Oct 2026 13:34:49 +0530 Subject: [PATCH 4/4] A11Y-13878: Take the mobile script timeout from the backend Use the build-start scannerProcessingTimeout capability for the mobile script timeout, as the Node SDK does, instead of a fixed 30s. Fall back to 30s when the backend doesn't send it. Co-Authored-By: Claude Opus 5.5 --- packages/browserstack-service/src/util.ts | 10 +++++++--- packages/browserstack-service/tests/util.test.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 453e2900..c816b388 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -339,7 +339,7 @@ export const processAccessibilityResponse = (response: LaunchResponse | StartBin } if (response.accessibility.options) { - const { accessibilityToken, pollingTimeout, scannerVersion, isMobileScanningEnabledForGroup, isIosSafariScanningEnabledForGroup } = jsonifyAccessibilityArray(response.accessibility.options.capabilities as Array>, 'name', 'value') + const { accessibilityToken, pollingTimeout, scannerVersion, scannerProcessingTimeout, isMobileScanningEnabledForGroup, isIosSafariScanningEnabledForGroup } = jsonifyAccessibilityArray(response.accessibility.options.capabilities as Array>, 'name', 'value') const result = jsonifyAccessibilityArray(response.accessibility.options.capabilities as Array>, 'name', 'value') const scriptsJson = { 'scripts': jsonifyAccessibilityArray(response.accessibility.options.scripts as Array>, 'name', 'command'), @@ -357,6 +357,9 @@ export const processAccessibilityResponse = (response: LaunchResponse | StartBin if (pollingTimeout) { process.env.BSTACK_A11Y_POLLING_TIMEOUT = pollingTimeout as string } + if (scannerProcessingTimeout) { + process.env.BSTACK_A11Y_SCANNER_PROCESSING_TIMEOUT = scannerProcessingTimeout as string + } // 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) { @@ -521,7 +524,8 @@ export const logRejectedMobileSave = (results: unknown) => { /* Desktop result scripts take no argument; mobile ones need the session flag and run uuid. */ export const getMobileResultsArg = () => (mobileA11ySession ? getMobileA11yScriptArgs() : undefined) -export const MOBILE_A11Y_SCRIPT_TIMEOUT_MS = 30000 +/* 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) => { @@ -529,7 +533,7 @@ export const setMobileA11yScriptTimeout = async (browser: WebdriverIO.Browser) = return } try { - await browser.setTimeout({ script: MOBILE_A11Y_SCRIPT_TIMEOUT_MS }) + await browser.setTimeout({ script: getMobileA11yScriptTimeoutMs() }) } catch (error) { BStackLogger.debug(`Could not set the script timeout for the mobile accessibility session: ${error}`) } diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index b23319d3..1a53c9dd 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -1476,12 +1476,16 @@ describe('validateCapsWithA11y', () => { expect(validateCapsWithA11y('iPad Pro 13 2024', { browser_name: 'safari', platform_name: 'ios' })).toEqual(false) }) - it('sets a script timeout only for a mobile session', async () => { + it('sets the backend scanner timeout as the script timeout only for a mobile session', async () => { const browser = { setTimeout: vi.fn() } as unknown as WebdriverIO.Browser process.env.BROWSERSTACK_A11Y_MOBILE_SCANNING_ENABLED_FOR_GROUP = 'true' validateCapsWithA11y('iPhone 17', { browser_name: 'safari', platform_name: 'ios' }) await utils.setMobileA11yScriptTimeout(browser) - expect(browser.setTimeout).toHaveBeenCalledWith({ script: utils.MOBILE_A11Y_SCRIPT_TIMEOUT_MS }) + expect(browser.setTimeout).toHaveBeenCalledWith({ script: 30000 }) + process.env.BSTACK_A11Y_SCANNER_PROCESSING_TIMEOUT = '45' + await utils.setMobileA11yScriptTimeout(browser) + expect(browser.setTimeout).toHaveBeenLastCalledWith({ script: 45000 }) + delete process.env.BSTACK_A11Y_SCANNER_PROCESSING_TIMEOUT vi.mocked(browser.setTimeout).mockClear() validateCapsWithA11y(undefined, { browser_name: 'chrome' }) await utils.setMobileA11yScriptTimeout(browser)