From e6dc8d2dba1902ea3f25ad15da6f2e327dcb16ff Mon Sep 17 00:00:00 2001 From: Simon Markowski Date: Fri, 31 Jul 2026 20:41:03 -0400 Subject: [PATCH] fix(browserstack-service): route browserstack_executor commands via HTTP/S in BiDi sessions In BiDi sessions, browser.execute() routes over WebSocket directly to the browser, bypassing BrowserStack's HTTP hub, so browserstack_executor: commands fail silently. Overwrite the execute command in BiDi sessions to route executor-prefixed scripts through executeScript (which always uses HTTP/S), leaving all other scripts untouched. Handles single-browser and multiremote setups. Ported from webdriverio/webdriverio#15216. Co-Authored-By: RohanImmanuel Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/browserstack-service/src/service.ts | 26 ++++++++ .../tests/service.test.ts | 59 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index b9e2bbe..350cbc1 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -242,6 +242,19 @@ export default class BrowserstackService implements Services.ServiceInstance { PerformanceTester.scenarioThatRan = this._scenariosThatRan if (this._browser) { + try { + if (this._browser.isMultiremote) { + const multiRemoteBrowser = this._browser as unknown as WebdriverIO.MultiRemoteBrowser + Object.keys(this._caps).forEach((browserName) => { + this._routeBidiExecutorToHttp(multiRemoteBrowser.getInstance(browserName)) + }) + } else { + this._routeBidiExecutorToHttp(this._browser as WebdriverIO.Browser) + } + } catch (err) { + BStackLogger.warn(`Failed to patch execute for BiDi browserstack_executor routing; executor commands may not work in BiDi sessions: ${err}`) + } + try { const sessionId = this._browser.sessionId @@ -888,6 +901,19 @@ export default class BrowserstackService implements Services.ServiceInstance { }) } + _routeBidiExecutorToHttp (browser: WebdriverIO.Browser) { + if (!browser.isBidi) { + return + } + + browser.overwriteCommand('execute', async (originalExecute, script, ...args) => { + if (typeof script === 'string' && script.startsWith('browserstack_executor:')) { + return browser.executeScript(script, args) + } + return originalExecute(script, ...args) + }) + } + _multiRemoteAction (action: MultiRemoteAction) { if (!this._browser) { return Promise.resolve() diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index 95a2e32..e45703e 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -113,6 +113,7 @@ beforeEach(() => { browser = { execute: vi.fn(), executeScript: vi.fn(), + overwriteCommand: vi.fn(), on: vi.fn(), sessionId: sessionId, config: {}, @@ -626,6 +627,64 @@ describe('before', () => { expect(service['_failReasons']).toEqual([]) expect(service['_sessionBaseUrl']).toEqual('https://api.browserstack.com/automate-turboscale/v1/sessions') }) + + it('should overwrite execute command to route browserstack_executor via executeScript', async () => { + (browser as any).isBidi = true + const service = new BrowserstackService({} as any, [{}] as any, { user: 'foo', key: 'bar', capabilities: {} }) + await service.before(service['_config'] as any, [], browser) + + expect(browser.overwriteCommand).toHaveBeenCalledWith('execute', expect.any(Function)) + + const overwrite = vi.mocked(browser.overwriteCommand).mock.calls[0][1] as Function + const originalExecute = vi.fn() + + await overwrite(originalExecute, 'browserstack_executor: {"action":"annotate"}') + expect(browser.executeScript).toHaveBeenCalledWith('browserstack_executor: {"action":"annotate"}', []) + expect(originalExecute).not.toHaveBeenCalled() + + await overwrite(originalExecute, 'return document.title') + expect(originalExecute).toHaveBeenCalledWith('return document.title') + + const extraArg = { key: 'value' } + await overwrite(originalExecute, 'return arguments[0]', extraArg) + expect(originalExecute).toHaveBeenCalledWith('return arguments[0]', extraArg) + }) + + it('should not overwrite execute command for non-BiDi sessions', async () => { + (browser as any).isBidi = false + const service = new BrowserstackService({} as any, [{}] as any, { user: 'foo', key: 'bar', capabilities: {} }) + await service.before(service['_config'] as any, [], browser) + + expect(browser.overwriteCommand).not.toHaveBeenCalled() + }) + + it('should overwrite execute on each instance for multiremote', async () => { + const browserA = { executeScript: vi.fn(), overwriteCommand: vi.fn(), sessionId: 'sessionA', isBidi: true } + const browserB = { executeScript: vi.fn(), overwriteCommand: vi.fn(), sessionId: 'sessionB', isBidi: true } + const multiRemoteBrowser = { + ...browser, + isMultiremote: true, + getInstance: vi.fn().mockImplementation((name: string) => name === 'browserA' ? browserA : browserB) + } as unknown as WebdriverIO.MultiRemoteBrowser + + const service = new BrowserstackService({} as any, { browserA: {}, browserB: {} } as any, { + user: 'foo', key: 'bar' + }) + await service.before(service['_config'] as any, [], multiRemoteBrowser as any) + + expect(browserA.overwriteCommand).toHaveBeenCalledWith('execute', expect.any(Function)) + expect(browserB.overwriteCommand).toHaveBeenCalledWith('execute', expect.any(Function)) + + const overwriteA = vi.mocked(browserA.overwriteCommand).mock.calls[0][1] as Function + await overwriteA(vi.fn(), 'browserstack_executor: {"action":"annotate"}') + expect(browserA.executeScript).toHaveBeenCalledWith('browserstack_executor: {"action":"annotate"}', []) + expect(browserB.executeScript).not.toHaveBeenCalled() + + const originalExecuteA = vi.fn() + const extraArg = { key: 'value' } + await overwriteA(originalExecuteA, 'return arguments[0]', extraArg) + expect(originalExecuteA).toHaveBeenCalledWith('return arguments[0]', extraArg) + }) }) describe('beforeHook', () => {