From b03ce97f2177afb0102f80237f33f04692415558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CDalwin?= <“dalwin@browserstack.com”> Date: Fri, 9 Oct 2026 21:35:05 +0530 Subject: [PATCH 1/9] [SDK-7898] Support WebdriverIO 10 in the accessibility CLI module webdriverio 10 removed browser.executeAsync, so the CLI accessibility scan, save-results, results and summary calls now go through the existing executeAccessibilityScript helper, which runs the same callback-style scripts via execute. The BiDi executor patch skips the executeAsync overwrite when the command does not exist, and the service accepts @wdio/cli ^10 as a peer. Co-Authored-By: Claude Fable 5.1 --- packages/browserstack-service/package.json | 2 +- .../src/cli/modules/accessibilityModule.ts | 15 +++--- packages/browserstack-service/src/service.ts | 4 ++ .../cli/modules/accessibilityModule.test.ts | 51 ++++++++++--------- .../tests/service.test.ts | 13 ++++- 5 files changed, 52 insertions(+), 33 deletions(-) diff --git a/packages/browserstack-service/package.json b/packages/browserstack-service/package.json index a0cbb17d..aed5b69b 100644 --- a/packages/browserstack-service/package.json +++ b/packages/browserstack-service/package.json @@ -77,7 +77,7 @@ "yauzl": "^3.4.0" }, "peerDependencies": { - "@wdio/cli": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + "@wdio/cli": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" }, "devDependencies": { "@bufbuild/buf": "^1.55.1", diff --git a/packages/browserstack-service/src/cli/modules/accessibilityModule.ts b/packages/browserstack-service/src/cli/modules/accessibilityModule.ts index edcb1b3b..629f9f0c 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, executeAccessibilityScript, formatString, getAppA11yResults, getAppA11yResultsSummary, shouldScanTestForAccessibility, validateCapsWithA11y, validateCapsWithAppA11y, isBrowserstackSession } from '../../util.js' import { AutomationFrameworkConstants } from '../frameworks/constants/automationFrameworkConstants.js' import util from 'node:util' import type { Accessibility } from '../../grpc/index.js' @@ -565,11 +565,14 @@ export default class AccessibilityModule extends BaseModule { BStackLogger.debug(util.format(results as string)) return (results as Record | undefined) } - const results = await (browser as WebdriverIO.Browser).executeAsync( + // webdriverio 10 removed executeAsync; the helper runs the callback-style + // script through execute on every supported major. + const results = await executeAccessibilityScript | undefined>( + browser, this.scriptInstance.performScan as string, { 'method': commandName || '' } ) - return (results as Record | undefined) + return results } catch (err: unknown) { this.logger.error('Accessibility Scan could not be performed : ' + err) return @@ -598,7 +601,7 @@ 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 executeAccessibilityScript(browser, accessibilityScripts.saveTestResults as string, dataForExtension) this.logger.debug(`save results : ${util.format(results as string)}`) })() } catch (error) { @@ -617,7 +620,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 = await executeAccessibilityScript>>(browser, this.scriptInstance.getResults as string) return results } catch (error: unknown) { this.logger.error('No accessibility results were found.') @@ -639,7 +642,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 = await executeAccessibilityScript>(browser, this.scriptInstance.getResultsSummary as string) return summaryResults } catch { this.logger.error('No accessibility summary was found.') diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index 8854e15c..d1b2e29b 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -1254,6 +1254,10 @@ export default class BrowserstackService implements Services.ServiceInstance { return originalExecute(script, ...args) }) + // executeAsync no longer exists on webdriverio >= 10 + if (typeof (browser as unknown as { executeAsync?: unknown }).executeAsync !== 'function') { + return + } browser.overwriteCommand('executeAsync', async (originalExecuteAsync, script, ...args) => { if (isBrowserstackExecutorScript(script)) { return browser.executeAsyncScript(script, args) diff --git a/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts b/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts index d4114756..0a5c405d 100644 --- a/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts +++ b/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts @@ -37,7 +37,8 @@ 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), + executeAccessibilityScript: vi.fn().mockResolvedValue([]) })) vi.mock('../../../src/cli/grpcClient.js', () => ({ @@ -59,6 +60,7 @@ import AutomationFramework from '../../../src/cli/frameworks/automationFramework import { AutomationFrameworkState } from '../../../src/cli/states/automationFrameworkState.js' import { HookState } from '../../../src/cli/states/hookState.js' import { TestFrameworkState } from '../../../src/cli/states/testFrameworkState.js' +import { executeAccessibilityScript } from '../../../src/util.js' describe('AccessibilityModule', () => { let accessibilityModule: AccessibilityModule @@ -69,6 +71,7 @@ describe('AccessibilityModule', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(executeAccessibilityScript).mockResolvedValue([]) mockAccessibilityConfig = { isAppAccessibility: false, @@ -79,7 +82,6 @@ describe('AccessibilityModule', () => { mockBrowser = { // a live session: the wrapper skips scanning when the driver has no sessionId sessionId: 'session-w', - executeAsync: vi.fn().mockResolvedValue([]), execute: vi.fn().mockResolvedValue({}), overwriteCommand: vi.fn() } @@ -427,7 +429,7 @@ describe('AccessibilityModule', () => { await accessibilityModule.onAfterTest() - expect(mockBrowser.executeAsync).not.toHaveBeenCalled() + expect(executeAccessibilityScript).not.toHaveBeenCalled() }) it('should return early when accessibility scan was not started', async () => { @@ -438,7 +440,7 @@ describe('AccessibilityModule', () => { await accessibilityModule.onAfterTest() - expect(mockBrowser.executeAsync).not.toHaveBeenCalled() + expect(executeAccessibilityScript).not.toHaveBeenCalled() }) }) @@ -450,7 +452,7 @@ describe('AccessibilityModule', () => { expect(result).toBeUndefined() expect(mockBrowser.execute).not.toHaveBeenCalled() - expect(mockBrowser.executeAsync).not.toHaveBeenCalled() + expect(executeAccessibilityScript).not.toHaveBeenCalled() }) it('should call execute for app accessibility', async () => { @@ -464,37 +466,36 @@ describe('AccessibilityModule', () => { expect(result).toEqual({ success: true }) }) - it('should call executeAsync for web accessibility', async () => { + it('should run the scan script for web accessibility', async () => { accessibilityModule.accessibility = true accessibilityModule.isAppAccessibility = false - mockBrowser.executeAsync.mockResolvedValue({ violations: [] }) + vi.mocked(executeAccessibilityScript).mockResolvedValue({ violations: [] }) const result = await (accessibilityModule as any).performScanCli(mockBrowser) - expect(mockBrowser.executeAsync).toHaveBeenCalled() + expect(executeAccessibilityScript).toHaveBeenCalled() expect(result).toEqual({ violations: [] }) }) it('should handle errors gracefully', async () => { accessibilityModule.accessibility = true accessibilityModule.isAppAccessibility = false - mockBrowser.executeAsync.mockRejectedValue(new Error('Scan failed')) + vi.mocked(executeAccessibilityScript).mockRejectedValue(new Error('Scan failed')) const result = await (accessibilityModule as any).performScanCli(mockBrowser) expect(result).toBeUndefined() }) - it('should pass command name to executeAsync for web accessibility', async () => { + it('should pass command name to the scan script for web accessibility', async () => { accessibilityModule.accessibility = true accessibilityModule.isAppAccessibility = false const commandName = 'click' - mockBrowser.executeAsync.mockResolvedValue({}) + vi.mocked(executeAccessibilityScript).mockResolvedValue({}) await (accessibilityModule as any).performScanCli(mockBrowser, commandName) - expect(mockBrowser.executeAsync).toHaveBeenCalledWith( - 'mock-perform-scan-script', + expect(executeAccessibilityScript).toHaveBeenCalledWith(mockBrowser, 'mock-perform-scan-script', { method: commandName } ) }) @@ -507,7 +508,7 @@ describe('AccessibilityModule', () => { const result = await accessibilityModule.getA11yResults(mockBrowser) expect(result).toBeUndefined() - expect(mockBrowser.executeAsync).not.toHaveBeenCalled() + expect(executeAccessibilityScript).not.toHaveBeenCalled() }) it('should return accessibility results when accessibility is enabled', async () => { @@ -516,18 +517,18 @@ describe('AccessibilityModule', () => { { id: 'test-1', impact: 'serious', description: 'Test violation' }, { id: 'test-2', impact: 'moderate', description: 'Another violation' } ] - mockBrowser.executeAsync.mockResolvedValue(mockResults) + vi.mocked(executeAccessibilityScript).mockResolvedValue(mockResults) const result = await accessibilityModule.getA11yResults(mockBrowser) - expect(mockBrowser.executeAsync).toHaveBeenCalledWith('mock-perform-scan-script', { method: '' }) - expect(mockBrowser.executeAsync).toHaveBeenCalledWith('mock-get-results-script') + expect(executeAccessibilityScript).toHaveBeenCalledWith(mockBrowser, 'mock-perform-scan-script', { method: '' }) + expect(executeAccessibilityScript).toHaveBeenCalledWith(mockBrowser, 'mock-get-results-script') expect(result).toEqual(mockResults) }) it('should handle errors gracefully and return empty array', async () => { accessibilityModule.accessibility = true - mockBrowser.executeAsync.mockRejectedValue(new Error('Script execution failed')) + vi.mocked(executeAccessibilityScript).mockRejectedValue(new Error('Script execution failed')) const result = await accessibilityModule.getA11yResults(mockBrowser) @@ -542,7 +543,7 @@ describe('AccessibilityModule', () => { const result = await accessibilityModule.getA11yResultsSummary(mockBrowser) expect(result).toBeUndefined() - expect(mockBrowser.executeAsync).not.toHaveBeenCalled() + expect(executeAccessibilityScript).not.toHaveBeenCalled() }) it('should return accessibility results summary when accessibility is enabled', async () => { @@ -553,18 +554,18 @@ describe('AccessibilityModule', () => { moderateViolations: 3, url: 'https://example.com' } - mockBrowser.executeAsync.mockResolvedValue(mockSummary) + vi.mocked(executeAccessibilityScript).mockResolvedValue(mockSummary) const result = await accessibilityModule.getA11yResultsSummary(mockBrowser) - expect(mockBrowser.executeAsync).toHaveBeenCalledWith('mock-perform-scan-script', { method: '' }) - expect(mockBrowser.executeAsync).toHaveBeenCalledWith('mock-get-results-summary-script') + expect(executeAccessibilityScript).toHaveBeenCalledWith(mockBrowser, 'mock-perform-scan-script', { method: '' }) + expect(executeAccessibilityScript).toHaveBeenCalledWith(mockBrowser, 'mock-get-results-summary-script') expect(result).toEqual(mockSummary) }) it('should handle errors gracefully and return empty object', async () => { accessibilityModule.accessibility = true - mockBrowser.executeAsync.mockRejectedValue(new Error('Script execution failed')) + vi.mocked(executeAccessibilityScript).mockRejectedValue(new Error('Script execution failed')) const result = await accessibilityModule.getA11yResultsSummary(mockBrowser) @@ -616,7 +617,7 @@ describe('AccessibilityModule', () => { expect(validateCapsWithA11y).not.toHaveBeenCalled() }) - it('engages the app performScan path (execute, not web executeAsync)', async () => { + it('engages the app performScan path (execute, not the web scan script)', async () => { vi.mocked(validateCapsWithAppA11y).mockReturnValue(true) vi.mocked(validateCapsWithA11y).mockReturnValue(true) vi.mocked(AutomationFramework.getState).mockImplementation(appGetState as any) @@ -626,7 +627,7 @@ describe('AccessibilityModule', () => { const result = await mockBrowser.performScan() expect(mockBrowser.execute).toHaveBeenCalled() - expect(mockBrowser.executeAsync).not.toHaveBeenCalled() + expect(executeAccessibilityScript).not.toHaveBeenCalled() expect(result).toEqual({ scanned: true }) }) diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index aa32efbc..043b0dc0 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -771,7 +771,8 @@ describe('before', () => { }) it('should overwrite executeAsync command to route browserstack_executor via executeAsyncScript', async () => { - (browser as any).isBidi = true + (browser as any).isBidi = true; + (browser as any).executeAsync = vi.fn() const service = new BrowserstackService({} as any, [{}] as any, { user: 'foo', key: 'bar', capabilities: {} }) service['_routeBidiExecutorToHttp'](browser) @@ -790,6 +791,16 @@ describe('before', () => { expect(originalExecuteAsync).toHaveBeenCalledWith('arguments[0](1)', extraArg) }) + it('should skip the executeAsync overwrite when the browser has no executeAsync (webdriverio >= 10)', async () => { + (browser as any).isBidi = true + delete (browser as any).executeAsync + const service = new BrowserstackService({} as any, [{}] as any, { user: 'foo', key: 'bar', capabilities: {} }) + service['_routeBidiExecutorToHttp'](browser) + + expect(browser.overwriteCommand).toHaveBeenCalledWith('execute', expect.any(Function)) + expect(browser.overwriteCommand).not.toHaveBeenCalledWith('executeAsync', expect.any(Function)) + }) + it('should not overwrite execute command for non-BrowserStack BiDi sessions', async () => { (browser as any).isBidi = true const service = new BrowserstackService({} as any, [{}] as any, { user: 'foo', key: 'bar', capabilities: {} }) From b27aee2782507e20a82d6da3dcb8b6713dadd7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CDalwin?= <“dalwin@browserstack.com”> Date: Fri, 9 Oct 2026 21:53:17 +0530 Subject: [PATCH 2/9] [SDK-7898] Keep per-command accessibility scans working on WebdriverIO 10 webdriverio 10 rejects the boolean third argument of overwriteCommand, so every command wrap was skipped and only the end-of-test scan ran. overwriteCommandCompat retries with the options object the new API expects, and refuses element overrides for commands v10 removed, since v10 validates those only when the first element is created. Co-Authored-By: Claude Fable 5.1 --- .../src/accessibility-handler.ts | 5 ++- .../src/cli/modules/accessibilityModule.ts | 6 +-- packages/browserstack-service/src/util.ts | 28 ++++++++++++++ .../cli/modules/accessibilityModule.test.ts | 3 +- .../browserstack-service/tests/util.test.ts | 38 +++++++++++++++++++ 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/packages/browserstack-service/src/accessibility-handler.ts b/packages/browserstack-service/src/accessibility-handler.ts index 1055bb53..586b75bd 100644 --- a/packages/browserstack-service/src/accessibility-handler.ts +++ b/packages/browserstack-service/src/accessibility-handler.ts @@ -69,7 +69,8 @@ import { executeAccessibilityScript, isFalse, getHookType, - frameworkSupportsHook + frameworkSupportsHook, + overwriteCommandCompat } from './util.js' import accessibilityScripts from './scripts/accessibility-scripts.js' import PerformanceTester from './instrumentation/performance/performance-tester.js' @@ -319,7 +320,7 @@ class _AccessibilityHandler { const orig = browser[command.name as keyof WebdriverIO.Browser] const prevImpl = orig ? orig.bind(browser) : undefined // @ts-expect-error fix type - browser.overwriteCommand(command.name, this.commandWrapper.bind(this, command, prevImpl), command.class === 'Element') + overwriteCommandCompat(browser, command.name, this.commandWrapper.bind(this, command, prevImpl), command.class === 'Element') } catch (error) { BStackLogger.debug(`Exception in overwrite command ${command.name} - ${error}`) } diff --git a/packages/browserstack-service/src/cli/modules/accessibilityModule.ts b/packages/browserstack-service/src/cli/modules/accessibilityModule.ts index 629f9f0c..d1f5c4aa 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, executeAccessibilityScript, formatString, getAppA11yResults, getAppA11yResultsSummary, shouldScanTestForAccessibility, validateCapsWithA11y, validateCapsWithAppA11y, isBrowserstackSession } from '../../util.js' +import { _getParamsForAppAccessibility, executeAccessibilityScript, formatString, overwriteCommandCompat, getAppA11yResults, getAppA11yResultsSummary, shouldScanTestForAccessibility, validateCapsWithA11y, validateCapsWithAppA11y, isBrowserstackSession } from '../../util.js' import { AutomationFrameworkConstants } from '../frameworks/constants/automationFrameworkConstants.js' import util from 'node:util' import type { Accessibility } from '../../grpc/index.js' @@ -258,8 +258,8 @@ export default class AccessibilityModule extends BaseModule { .filter((command) => command.name && command.class) .forEach((command) => { try { - browser.overwriteCommand( - // @ts-expect-error fix type + overwriteCommandCompat( + browser, command.name, this.commandWrapper.bind(this, command), command.class === 'Element' diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 5aa1e9bb..fb3769e3 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -2119,6 +2119,34 @@ async function makeGetRequest(url: string, params: Record, headers: return response } +// Element commands webdriverio 10 dropped. An element override for a missing command is only +// validated when the first element is created, which throws inside the user's test. +const WDIO_V10_REMOVED_ELEMENT_COMMANDS = new Set(['touchAction']) + +/** + * `overwriteCommand(name, fn, attachToElement)` took a boolean up to webdriverio 9 and an + * options object from 10 on; the boolean form throws there, so retry with the object. + */ +export function overwriteCommandCompat( + browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, + name: string, + fn: (...args: any[]) => unknown, + attachToElement: boolean +) { + const overwrite = (browser as any).overwriteCommand.bind(browser) as (...args: unknown[]) => unknown + try { + return overwrite(name, fn, attachToElement) + } catch (err) { + if (!String(err).includes('removed in WebdriverIO v10')) { + throw err + } + if (attachToElement && WDIO_V10_REMOVED_ELEMENT_COMMANDS.has(name)) { + throw new Error(`overwriteCommand: no command to be overwritten: ${name}`) + } + return overwrite(name, fn, { attachToElement }) + } +} + export async function executeAccessibilityScript( browser: any, fnBody: string, diff --git a/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts b/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts index 0a5c405d..352812ff 100644 --- a/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts +++ b/packages/browserstack-service/tests/cli/modules/accessibilityModule.test.ts @@ -38,7 +38,8 @@ vi.mock('../../../src/util.js', () => ({ formatString: vi.fn().mockReturnValue('formatted-script'), o11yClassErrorHandler: vi.fn().mockImplementation((cls) => cls), isBrowserstackSession: vi.fn().mockReturnValue(true), - executeAccessibilityScript: vi.fn().mockResolvedValue([]) + executeAccessibilityScript: vi.fn().mockResolvedValue([]), + overwriteCommandCompat: vi.fn((browser, name, fn, attachToElement) => browser.overwriteCommand(name, fn, attachToElement)) })) 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..7ea6d9f1 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -2528,3 +2528,41 @@ describe('getTestTags', () => { expect(utils.getTestTags(test as any)).toEqual(['@regression', '@smoke']) }) }) + +describe('overwriteCommandCompat', () => { + const fn = vi.fn() + + it('passes the boolean through on webdriverio <= 9', () => { + const browser = { overwriteCommand: vi.fn() } as any + utils.overwriteCommandCompat(browser, 'click', fn, true) + expect(browser.overwriteCommand).toHaveBeenCalledTimes(1) + expect(browser.overwriteCommand).toHaveBeenCalledWith('click', fn, true) + }) + + it('retries with an options object when webdriverio 10 rejects the boolean', () => { + const browser = { + overwriteCommand: vi.fn().mockImplementationOnce(() => { + throw new Error('Passing a boolean as the third argument to `overwriteCommand` was removed in WebdriverIO v10. Use `overwriteCommand(name, fn, { attachToElement: true })`.') + }) + } as any + utils.overwriteCommandCompat(browser, 'click', fn, true) + expect(browser.overwriteCommand).toHaveBeenCalledTimes(2) + expect(browser.overwriteCommand).toHaveBeenLastCalledWith('click', fn, { attachToElement: true }) + }) + + it('does not register a v10 element override for a command webdriverio 10 removed', () => { + const browser = { + overwriteCommand: vi.fn().mockImplementationOnce(() => { + throw new Error('Passing a boolean as the third argument to `overwriteCommand` was removed in WebdriverIO v10.') + }) + } as any + expect(() => utils.overwriteCommandCompat(browser, 'touchAction', fn, true)).toThrow('no command to be overwritten: touchAction') + expect(browser.overwriteCommand).toHaveBeenCalledTimes(1) + }) + + it('rethrows any other overwriteCommand error', () => { + const browser = { overwriteCommand: vi.fn(() => { throw new Error('no such command') }) } as any + expect(() => utils.overwriteCommandCompat(browser, 'nope', fn, false)).toThrow('no such command') + expect(browser.overwriteCommand).toHaveBeenCalledTimes(1) + }) +}) From 05ce5539c5cf38d31adce1a1601cd6a92a142057 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:23:47 +0000 Subject: [PATCH 3/9] chore(changeset): auto-generate from PR template (minor) --- .changeset/pr-278.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-278.md diff --git a/.changeset/pr-278.md b/.changeset/pr-278.md new file mode 100644 index 00000000..ab3eb5be --- /dev/null +++ b/.changeset/pr-278.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": minor +--- + +- Accessibility Automation now works on WebdriverIO 10 projects, including per-command auto-scans; `@wdio/browserstack-service` can be installed alongside `@wdio/cli@10`. From cc3d03eb24b878879f40382d466c1d10e8fe41b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CDalwin?= <“dalwin@browserstack.com”> Date: Tue, 13 Oct 2026 16:14:00 +0530 Subject: [PATCH 4/9] [SDK-7898] Detect WebdriverIO 10 in overwriteCommandCompat by the missing executeAsync too The boolean-to-options retry was keyed only on the v10 error text, so a reworded message in a later 10.x would make every per-command wrap fall through to the callers' debug-level catch and silently stop auto-scans. Treat a browser without executeAsync as v10 as well, matching the check in service.ts. Co-Authored-By: Claude Fable 5.1 --- packages/browserstack-service/src/util.ts | 5 +++- .../browserstack-service/tests/util.test.ts | 25 +++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index fb3769e3..1e2b71ac 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -2126,6 +2126,7 @@ const WDIO_V10_REMOVED_ELEMENT_COMMANDS = new Set(['touchAction']) /** * `overwriteCommand(name, fn, attachToElement)` took a boolean up to webdriverio 9 and an * options object from 10 on; the boolean form throws there, so retry with the object. + * v10 is recognised by its error text or, should that change, by the missing `executeAsync`. */ export function overwriteCommandCompat( browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, @@ -2137,7 +2138,9 @@ export function overwriteCommandCompat( try { return overwrite(name, fn, attachToElement) } catch (err) { - if (!String(err).includes('removed in WebdriverIO v10')) { + const isV10 = String(err).includes('removed in WebdriverIO v10') || + typeof (browser as unknown as { executeAsync?: unknown }).executeAsync !== 'function' + if (!isV10) { throw err } if (attachToElement && WDIO_V10_REMOVED_ELEMENT_COMMANDS.has(name)) { diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index 7ea6d9f1..3c37a688 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -2560,9 +2560,30 @@ describe('overwriteCommandCompat', () => { expect(browser.overwriteCommand).toHaveBeenCalledTimes(1) }) - it('rethrows any other overwriteCommand error', () => { - const browser = { overwriteCommand: vi.fn(() => { throw new Error('no such command') }) } as any + it('retries with an options object when executeAsync is missing even if the error text changed', () => { + const browser = { + overwriteCommand: vi.fn().mockImplementationOnce(() => { + throw new Error('Invalid third argument to overwriteCommand: expected an options object') + }) + } as any + utils.overwriteCommandCompat(browser, 'click', fn, true) + expect(browser.overwriteCommand).toHaveBeenCalledTimes(2) + expect(browser.overwriteCommand).toHaveBeenLastCalledWith('click', fn, { attachToElement: true }) + }) + + it('rethrows any other overwriteCommand error on webdriverio <= 9', () => { + const browser = { + executeAsync: vi.fn(), + overwriteCommand: vi.fn(() => { throw new Error('no such command') }) + } as any expect(() => utils.overwriteCommandCompat(browser, 'nope', fn, false)).toThrow('no such command') expect(browser.overwriteCommand).toHaveBeenCalledTimes(1) }) + + it('rethrows when the options-object retry also fails on webdriverio 10', () => { + const browser = { overwriteCommand: vi.fn(() => { throw new Error('no such command') }) } as any + expect(() => utils.overwriteCommandCompat(browser, 'nope', fn, false)).toThrow('no such command') + expect(browser.overwriteCommand).toHaveBeenCalledTimes(2) + expect(browser.overwriteCommand).toHaveBeenLastCalledWith('nope', fn, { attachToElement: false }) + }) }) From 909bda2a99a6c37ef36d523ae76156a67ea524f6 Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Tue, 10 Nov 2026 21:12:18 +0530 Subject: [PATCH 5/9] Keep the cumulative @wdio/cli peer range Co-Authored-By: Claude Opus 5.5 --- .changeset/pr-278.md | 2 +- package-lock.json | 2 +- packages/browserstack-service/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/pr-278.md b/.changeset/pr-278.md index b8e68c27..82b656ea 100644 --- a/.changeset/pr-278.md +++ b/.changeset/pr-278.md @@ -2,5 +2,5 @@ "@wdio/browserstack-service": major --- -- Supports WebdriverIO 10. This release requires WebdriverIO 10 (`@wdio/cli@^10`) and Node.js 22.19 or later. For WebdriverIO 9 projects, use the `v9` release line (`npm i -D @wdio/browserstack-service@v9`); for WebdriverIO 8, use `@wdio/browserstack-service@v8`. +- Supports WebdriverIO 10. This release requires WebdriverIO 10 and Node.js 22.19 or later. For WebdriverIO 9 projects, use the `v9` release line (`npm i -D @wdio/browserstack-service@v9`); for WebdriverIO 8, use `@wdio/browserstack-service@v8`. - Accessibility Automation, including per-command auto-scans, works on WebdriverIO 10. diff --git a/package-lock.json b/package-lock.json index 64296319..b8843b5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14217,7 +14217,7 @@ "node": ">=22.19.0" }, "peerDependencies": { - "@wdio/cli": "^10.0.0" + "@wdio/cli": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, "packages/browserstack-service/node_modules/@esbuild/aix-ppc64": { diff --git a/packages/browserstack-service/package.json b/packages/browserstack-service/package.json index 7fff8dc2..ac736573 100644 --- a/packages/browserstack-service/package.json +++ b/packages/browserstack-service/package.json @@ -76,7 +76,7 @@ "yauzl": "^3.4.0" }, "peerDependencies": { - "@wdio/cli": "^10.0.0" + "@wdio/cli": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" }, "devDependencies": { "@bufbuild/buf": "^1.55.1", From ac4b6cd0c972cc11e58562145f923e7ac6a89784 Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Tue, 10 Nov 2026 21:14:08 +0530 Subject: [PATCH 6/9] Move REMOVED_ELEMENT_COMMANDS to constants Co-Authored-By: Claude Opus 5.5 --- packages/browserstack-service/src/constants.ts | 4 ++++ packages/browserstack-service/src/util.ts | 7 ++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index 329b8b73..9fad7143 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -75,6 +75,10 @@ export const BROWSERSTACK_WDIO_CONFIG_STRATEGY = 'BROWSERSTACK_WDIO_CONFIG_STRAT /* Mirrors create-wdio's SUPPORTED_CONFIG_FILE_EXTENSION (identical in wdio v8 and v9) */ export const SUPPORTED_WDIO_CONFIG_EXTENSIONS = ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts'] export const DEFAULT_WDIO_CONFIG_BASENAME = 'wdio.conf' + +/* Element commands webdriverio 10 removed. An element override for a missing command is only +validated when the first element is created, which would throw inside the user's test. */ +export const REMOVED_ELEMENT_COMMANDS = new Set(['touchAction']) /* `wdio ` verbs that must never be mistaken for the config positional */ export const WDIO_CLI_SUBCOMMANDS = ['run', 'install', 'repl', 'config'] diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 3b607fda..58d15940 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -48,7 +48,8 @@ import { STOP_BUILD_MAX_ATTEMPTS, STOP_BUILD_ATTEMPT_TIMEOUT_MS, STOP_BUILD_TOTAL_BUDGET_MS, - STOP_BUILD_BACKOFF_BASE_MS + STOP_BUILD_BACKOFF_BASE_MS, + REMOVED_ELEMENT_COMMANDS } from './constants.js' import CrashReporter from './crash-reporter.js' import { BStackLogger } from './bstackLogger.js' @@ -2197,10 +2198,6 @@ async function makeGetRequest(url: string, params: Record, headers: return response } -// Element commands webdriverio 10 removed. An element override for a missing command is only -// validated when the first element is created, which would throw inside the user's test. -const REMOVED_ELEMENT_COMMANDS = new Set(['touchAction']) - export function overwriteScanCommand( browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, name: string, From ef228a3a2318ed1542defd62316d448d097d8c0d Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Tue, 10 Nov 2026 21:21:33 +0530 Subject: [PATCH 7/9] Allow WebdriverIO 10 installs until Percy supports it @percy/webdriverio still declares peer webdriverio <= 9. Revert once the WebdriverIO 10 regression has run. Co-Authored-By: Claude Opus 5.5 --- .npmrc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.npmrc b/.npmrc index 3a503ed1..f94b457f 100644 --- a/.npmrc +++ b/.npmrc @@ -26,4 +26,7 @@ strict-ssl=true save-exact=true audit-level=high -legacy-peer-deps=false +# Temporary: @percy/webdriverio (<= 3.3.4) declares peer webdriverio <= 9, so a +# strict install fails with ERESOLVE on webdriverio 10. Set back to false once the +# WebdriverIO 10 regression has run. +legacy-peer-deps=true From e740eaae30de4f1637b5a3c3aaab9575f3fe1ae6 Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Wed, 11 Nov 2026 15:25:14 +0530 Subject: [PATCH 8/9] Read multiremote instances through getInstance WebdriverIO 10 no longer attaches each instance to the multiremote object by name, so getCloudProvider threw and isBidiSession always returned false. Co-Authored-By: Claude Opus 5.5 --- packages/browserstack-service/src/accessibility-handler.ts | 4 ++-- packages/browserstack-service/src/util.ts | 2 +- .../tests/accessibility-handler.test.ts | 6 ++++-- packages/browserstack-service/tests/util.test.ts | 4 +--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/browserstack-service/src/accessibility-handler.ts b/packages/browserstack-service/src/accessibility-handler.ts index fa4005a7..878af84a 100644 --- a/packages/browserstack-service/src/accessibility-handler.ts +++ b/packages/browserstack-service/src/accessibility-handler.ts @@ -672,8 +672,8 @@ class _AccessibilityHandler { return true } if (Array.isArray(b?.instances)) { - const children = b as unknown as Record - return b.instances.some((name) => children[name]?.isBidi === true) + const multi = browser as WebdriverIO.MultiRemoteBrowser + return b.instances.some((name) => multi.getInstance(name)?.isBidi === true) } return false } diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 58d15940..66c28676 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -1274,7 +1274,7 @@ export function getCloudProvider(browser: WebdriverIO.Browser | WebdriverIO.Mult if (browser && 'instances' in browser) { // Loop through all instances for (const instanceName of browser.instances) { - const instance = (browser as any)[instanceName] as WebdriverIO.Browser + const instance = (browser as WebdriverIO.MultiRemoteBrowser).getInstance(instanceName) if (instance.options && instance.options.hostname && instance.options.hostname.includes('browserstack')) { return 'browserstack' } diff --git a/packages/browserstack-service/tests/accessibility-handler.test.ts b/packages/browserstack-service/tests/accessibility-handler.test.ts index 20c9941c..98ef7ee0 100644 --- a/packages/browserstack-service/tests/accessibility-handler.test.ts +++ b/packages/browserstack-service/tests/accessibility-handler.test.ts @@ -55,13 +55,15 @@ describe('shouldSkipScanForBidiWindowCommand (SDK-5047)', () => { }) it('skips for window commands when any multiremote child instance is BiDi', () => { - const multi = { instances: ['chromeA', 'chromeB'], chromeA: { isBidi: false }, chromeB: { isBidi: true } } + const children: Record = { chromeA: { isBidi: false }, chromeB: { isBidi: true } } + const multi = { instances: ['chromeA', 'chromeB'], getInstance: (name: string) => children[name] } expect(skip(multi, { name: 'getWindowHandle', class: 'Browser' })).toBe(true) expect(skip(multi, { name: 'switchToWindow', class: 'Browser' })).toBe(true) }) it('does not skip on multiremote when no child instance is BiDi', () => { - const multi = { instances: ['chromeA', 'chromeB'], chromeA: { isBidi: false }, chromeB: {} } + const children: Record = { chromeA: { isBidi: false }, chromeB: {} } + const multi = { instances: ['chromeA', 'chromeB'], getInstance: (name: string) => children[name] } expect(skip(multi, { name: 'getWindowHandle', class: 'Browser' })).toBe(false) }) }) diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index 1d4ae70c..4b0d5ab6 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -558,9 +558,7 @@ describe('getCloudProvider', () => { const browser = { isMultiRemote: true, instances: ['browserA'], - browserA: { - options: { hostname: 'hub.browserstack.com' } - } + getInstance: (name: string) => ({ browserA: { options: { hostname: 'hub.browserstack.com' } } } as any)[name] } as unknown as WebdriverIO.MultiRemoteBrowser expect(getCloudProvider(browser)).toEqual('browserstack') }) From 17ad7edc9f11b40060253d3e82968766697bcddf Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 12 Nov 2026 10:54:41 +0530 Subject: [PATCH 9/9] Name Jasmine sessions when beforeSuite carries no suite title WebdriverIO 10's Jasmine adapter no longer passes the top-level suite description to beforeSuite, so the suite title stayed unset and sessions were left unnamed. Derive it from the test's fullName in that case too. Co-Authored-By: Claude Opus 5.5 --- packages/browserstack-service/src/service.ts | 4 +- .../tests/service.test.ts | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index 96aa807f..a9335b46 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -588,10 +588,10 @@ export default class BrowserstackService implements Services.ServiceInstance { let suiteTitle = this._suiteTitle if (test.fullName) { - // For Jasmine, `suite.title` is `Jasmine__TopLevel__Suite`. + // For Jasmine, `suite.title` is `Jasmine__TopLevel__Suite` (WDIO <= 9) or absent (WDIO 10). // This tweak allows us to set the real suite name. const testSuiteName = test.fullName.slice(0, test.fullName.indexOf(test.description || '') - 1) - if (this._suiteTitle === 'Jasmine__TopLevel__Suite') { + if (!this._suiteTitle || this._suiteTitle === 'Jasmine__TopLevel__Suite') { suiteTitle = testSuiteName } else if (this._suiteTitle) { suiteTitle = getParentSuiteName(this._suiteTitle, testSuiteName) diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index f15349f6..5cea479c 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -1194,6 +1194,48 @@ describe('beforeTest', () => { } ) }) + + it('should set suite name of first test as title when the suite has no title', async () => { + const browserWithExecuteScript = { + ...browser, + executeScript: browser.execute + } as WebdriverIO.Browser + await service.before(service['_config'] as any, [], browserWithExecuteScript) + await service.beforeSuite({ file: '/specs/test.js' } as any) + await service.beforeTest({ fullName: 'foo bar baz', description: 'baz' } as any) + service.afterTest({ fullName: 'foo bar baz', description: 'baz' } as any, undefined as never, {} as any) + expect(service['_fullTitle']).toBe('foo bar') + expect(fetch).toBeCalledWith( + `${sessionBaseUrl}/${sessionId}.json`, + { + method: 'PUT', + body: JSON.stringify({ name: 'foo bar' }), + headers + } + ) + }) + + it('should set parent suite name as title when the suite has no title', async () => { + const browserWithExecuteScript = { + ...browser, + executeScript: browser.execute + } as WebdriverIO.Browser + await service.before(service['_config'] as any, [], browserWithExecuteScript) + await service.beforeSuite({ file: '/specs/test.js' } as any) + await service.beforeTest({ fullName: 'foo bar baz', description: 'baz' } as any) + await service.beforeTest({ fullName: 'foo xyz', description: 'xyz' } as any) + service.afterTest({ fullName: 'foo bar baz', description: 'baz' } as any, undefined as never, {} as any) + service.afterTest({ fullName: 'foo xyz', description: 'xyz' } as any, undefined as never, {} as any) + expect(service['_fullTitle']).toBe('foo') + expect(fetch).toBeCalledWith( + `${sessionBaseUrl}/${sessionId}.json`, + { + method: 'PUT', + body: JSON.stringify({ name: 'foo' }), + headers + } + ) + }) }) })