diff --git a/.changeset/pr-119.md b/.changeset/pr-119.md new file mode 100644 index 0000000..256a866 --- /dev/null +++ b/.changeset/pr-119.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": patch +--- + +- Fixed `browserstack_executor` commands issued through `browser.execute()` or `browser.executeAsync()` being ignored in WebDriver BiDi sessions. diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index b9e2bbe..80288ad 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -9,7 +9,8 @@ import { patchConsoleLogs, isTrue, getUniqueIdentifier, - getHookType + getHookType, + isBrowserstackExecutorScript } from './util.js' import type { BrowserstackConfig, BrowserstackOptions, MultiRemoteAction } from './types.js' import type { Pickle, Feature, ITestCaseHookParameter, CucumberHook } from './cucumber-types.js' @@ -242,6 +243,23 @@ export default class BrowserstackService implements Services.ServiceInstance { PerformanceTester.scenarioThatRan = this._scenariosThatRan if (this._browser) { + const patchBidiExecutorRouting = (resolveBrowser: () => WebdriverIO.Browser, label?: string) => { + try { + this._routeBidiExecutorToHttp(resolveBrowser()) + } catch (err) { + BStackLogger.warn(`Failed to patch execute/executeAsync for BiDi browserstack_executor routing${label ? ` on ${label}` : ''}; 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 { const sessionId = this._browser.sessionId @@ -888,6 +906,26 @@ export default class BrowserstackService implements Services.ServiceInstance { }) } + _routeBidiExecutorToHttp (browser: WebdriverIO.Browser) { + if (!browser.isBidi || !isBrowserstackSession(browser)) { + return + } + + browser.overwriteCommand('execute', async (originalExecute, script, ...args) => { + if (isBrowserstackExecutorScript(script)) { + return browser.executeScript(script, args) + } + return originalExecute(script, ...args) + }) + + browser.overwriteCommand('executeAsync', async (originalExecuteAsync, script, ...args) => { + if (isBrowserstackExecutorScript(script)) { + return browser.executeAsyncScript(script, args) + } + return originalExecuteAsync(script, ...args) + }) + } + _multiRemoteAction (action: MultiRemoteAction) { if (!this._browser) { return Promise.resolve() diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 0ba614e..8c381e9 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -2291,6 +2291,15 @@ export function getMochaTestHierarchy(test: Frameworks.Test) { return value.reverse() } +/** + * True only for the hub-interpreted `browserstack_executor: {…}` magic string. + * Anchored to the start (leading whitespace tolerated) and case-sensitive, matching + * how the hub reads the payload — a plain script merely mentioning the token must not + * be rerouted off its normal transport. + */ +export const isBrowserstackExecutorScript = (script: unknown): script is string => + typeof script === 'string' && script.trimStart().startsWith('browserstack_executor:') + export const performO11ySync = async (browser: WebdriverIO.Browser) => { if (isBrowserstackSession(browser)) { await browser.execute(`browserstack_executor: ${JSON.stringify({ diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index 95a2e32..3644930 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -113,6 +113,8 @@ beforeEach(() => { browser = { execute: vi.fn(), executeScript: vi.fn(), + executeAsyncScript: vi.fn(), + overwriteCommand: vi.fn(), on: vi.fn(), sessionId: sessionId, config: {}, @@ -626,6 +628,135 @@ 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 route executor scripts with leading whitespace and leave look-alike scripts on BiDi', async () => { + (browser as any).isBidi = true + const service = new BrowserstackService({} as any, [{}] as any, { user: 'foo', key: 'bar', capabilities: {} }) + service['_routeBidiExecutorToHttp'](browser) + + const overwrite = vi.mocked(browser.overwriteCommand).mock.calls[0][1] as Function + const originalExecute = vi.fn() + + const padded = '\n browserstack_executor: {"action":"annotate"}' + await overwrite(originalExecute, padded) + expect(browser.executeScript).toHaveBeenCalledWith(padded, []) + expect(originalExecute).not.toHaveBeenCalled() + + const lookAlike = 'return document.title.includes("browserstack_executor:")' + await overwrite(originalExecute, lookAlike) + expect(originalExecute).toHaveBeenCalledWith(lookAlike) + }) + + 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 executeAsync command to route browserstack_executor via executeAsyncScript', async () => { + (browser as any).isBidi = true + const service = new BrowserstackService({} as any, [{}] as any, { user: 'foo', key: 'bar', capabilities: {} }) + service['_routeBidiExecutorToHttp'](browser) + + expect(browser.overwriteCommand).toHaveBeenCalledWith('executeAsync', expect.any(Function)) + + const overwrite = vi.mocked(browser.overwriteCommand).mock.calls + .find(([command]) => command === 'executeAsync')?.[1] as Function + const originalExecuteAsync = vi.fn() + + await overwrite(originalExecuteAsync, 'browserstack_executor: {"action":"annotate"}') + expect(browser.executeAsyncScript).toHaveBeenCalledWith('browserstack_executor: {"action":"annotate"}', []) + expect(originalExecuteAsync).not.toHaveBeenCalled() + + const extraArg = { key: 'value' } + await overwrite(originalExecuteAsync, 'arguments[0](1)', extraArg) + expect(originalExecuteAsync).toHaveBeenCalledWith('arguments[0](1)', extraArg) + }) + + 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: {} }) + // describe('_update') leaves a file-wide getCloudProvider spy returning 'browserstack', + // so stub the session check itself for this one call rather than restoring it. + vi.spyOn(utils, 'isBrowserstackSession').mockReturnValueOnce(false) + service['_routeBidiExecutorToHttp'](browser) + + expect(browser.overwriteCommand).not.toHaveBeenCalled() + }) + + it('should overwrite execute on each instance for multiremote', async () => { + const browserA = { executeScript: vi.fn(), executeAsyncScript: vi.fn(), overwriteCommand: vi.fn(), sessionId: 'sessionA', isBidi: true } + const browserB = { executeScript: vi.fn(), executeAsyncScript: 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) + }) + + it('should keep patching remaining multiremote instances when one instance fails to resolve', async () => { + const browserA = { executeScript: vi.fn(), executeAsyncScript: vi.fn(), overwriteCommand: vi.fn(), sessionId: 'sessionA', isBidi: true } + const browserB = { executeScript: vi.fn(), executeAsyncScript: 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', () => {