From e6dc8d2dba1902ea3f25ad15da6f2e327dcb16ff Mon Sep 17 00:00:00 2001 From: Simon Markowski Date: Fri, 31 Jul 2026 20:41:03 -0400 Subject: [PATCH 1/3] 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', () => { From b9bb4eb3bbc7626d6967fd348fb89e0d2d64c1b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:45:35 +0000 Subject: [PATCH 2/3] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-119.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-119.md diff --git a/.changeset/pr-119.md b/.changeset/pr-119.md new file mode 100644 index 0000000..12b2e1e --- /dev/null +++ b/.changeset/pr-119.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": patch +--- + +- Fixed BrowserStack executor commands (session name, status, annotations) being ignored in WebDriver BiDi sessions. From 1deec858560e769c86fa76a37d9e2c78887abfe5 Mon Sep 17 00:00:00 2001 From: xxshubhamxx Date: Wed, 5 Aug 2026 00:15:00 +0530 Subject: [PATCH 3/3] fix(browserstack-service): isolate BiDi executor patch failures per instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The try/catch wrapped the whole multiremote forEach, so a getInstance failure on one instance aborted the loop and left every later instance unpatched — a half-patched session indistinguishable in the logs from a fully-failed one. Wrap each instance's resolve-and-patch individually and name the failing instance in the warning. Co-Authored-By: Claude Opus 5 --- packages/browserstack-service/src/service.ts | 24 +++++++++++-------- .../tests/service.test.ts | 22 +++++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index 350cbc1..9daea81 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -242,17 +242,21 @@ 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) + const patchBidiExecutorRouting = (resolveBrowser: () => WebdriverIO.Browser, label?: string) => { + try { + this._routeBidiExecutorToHttp(resolveBrowser()) + } catch (err) { + BStackLogger.warn(`Failed to patch execute for BiDi browserstack_executor routing${label ? ` on ${label}` : ''}; executor commands may not work in BiDi sessions: ${err}`) } - } catch (err) { - BStackLogger.warn(`Failed to patch execute for BiDi browserstack_executor routing; executor commands may not work in BiDi sessions: ${err}`) + } + + if (this._browser.isMultiremote) { + const multiRemoteBrowser = this._browser as unknown as WebdriverIO.MultiRemoteBrowser + Object.keys(this._caps).forEach((browserName) => { + patchBidiExecutorRouting(() => multiRemoteBrowser.getInstance(browserName), browserName) + }) + } else { + patchBidiExecutorRouting(() => this._browser as WebdriverIO.Browser) } try { diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index e45703e..7a28f01 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -685,6 +685,28 @@ describe('before', () => { await overwriteA(originalExecuteA, 'return arguments[0]', extraArg) expect(originalExecuteA).toHaveBeenCalledWith('return arguments[0]', extraArg) }) + + it('should keep patching remaining multiremote instances when one instance fails to resolve', 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() + .mockImplementationOnce(() => { + throw new Error('no such instance') + }) + .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).not.toHaveBeenCalled() + expect(browserB.overwriteCommand).toHaveBeenCalledWith('execute', expect.any(Function)) + }) }) describe('beforeHook', () => {