From 27288d6831140ef6d4afdbe360517977c639abe3 Mon Sep 17 00:00:00 2001 From: Aakash Hotchandani Date: Thu, 24 Sep 2026 16:52:56 +0530 Subject: [PATCH] fix(cli): fall back to BROWSERSTACK_BINARY_URL when update_cli fails (SDK-6948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_cli runs during bootstrap — before the binary spawns and before GRR localizes hosts — so it always targets the prod api host. On an internal staging run (BROWSERSTACK_STAGING_ENV) the staging creds are rejected there with 401, leaving the CLI binary path empty and crashing the launcher with spawn('') -> ERR_INVALID_ARG_VALUE ('file' cannot be empty). Wrap the update_cli call: on failure, if BROWSERSTACK_BINARY_URL is set, download the binary from that URL and use it; otherwise re-throw (behaviour unchanged when the var is unset). Adds unit tests for both paths. v9 port of the v8 fix (PR #183), validated end-to-end against devapplca (update_cli 401 -> fallback download from a served zip -> binary spawned -> build-start reached). Co-Authored-By: Claude Opus 4.8 --- .changeset/sdk-6948-binary-url-fallback.md | 7 ++++ .../browserstack-service/src/cli/cliUtils.ts | 32 +++++++++++++++-- .../tests/cli/cliUtils.test.ts | 35 +++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 .changeset/sdk-6948-binary-url-fallback.md diff --git a/.changeset/sdk-6948-binary-url-fallback.md b/.changeset/sdk-6948-binary-url-fallback.md new file mode 100644 index 00000000..0700043f --- /dev/null +++ b/.changeset/sdk-6948-binary-url-fallback.md @@ -0,0 +1,7 @@ +--- +"@wdio/browserstack-service": patch +--- + +fix(cli): fall back to `BROWSERSTACK_BINARY_URL` when `update_cli` fails (SDK-6948) + +`update_cli` runs during bootstrap — before the binary is spawned and before GRR localizes the API hosts — so it always targets production `api.browserstack.com`. On an internal staging run (`BROWSERSTACK_STAGING_ENV`) the staging credentials are rejected there (`401`), which left the CLI binary path empty and crashed the launcher with `spawn('') … ERR_INVALID_ARG_VALUE: 'file' cannot be empty`. When `update_cli` fails and `BROWSERSTACK_BINARY_URL` is set, download the binary from that URL instead so the run can proceed. Opt-in only — with the variable unset the original error propagates exactly as before. diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index 03b335f2..73d11149 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -252,14 +252,40 @@ export class CLIUtils { } queryParams.cli_version = version } - const response = await this.requestToUpdateCLI(queryParams, config) + const browserStackBinaryUrl = + process.env.BROWSERSTACK_BINARY_URL || null + + let response + try { + response = await this.requestToUpdateCLI(queryParams, config) + } catch (err) { + // update_cli runs during bootstrap — before the binary is spawned and before GRR + // localizes the API hosts — so it always targets the production api host. On an internal + // staging run (BROWSERSTACK_STAGING_ENV) the staging creds are rejected there (401). If an + // explicit binary URL was supplied, use it so the run is not blocked on this call. Opt-in + // only: with no BROWSERSTACK_BINARY_URL the error propagates exactly as before, so + // production behaviour is unchanged. + if (!isNullOrEmpty(browserStackBinaryUrl)) { + const status = (err as { response?: { statusCode?: number } })?.response?.statusCode ?? (err as Error)?.message + logger.warn( + `update_cli request failed (${status}); falling back to BROWSERSTACK_BINARY_URL`, + ) + const fallbackBinaryPath = await this.downloadLatestBinary( + browserStackBinaryUrl as string, + cliDir, + ) + PerformanceTester.end(PerformanceEvents.SDK_CLI_CHECK_UPDATE) + return fallbackBinaryPath + } + PerformanceTester.end(PerformanceEvents.SDK_CLI_CHECK_UPDATE) + throw err + } + if (nestedKeyValue(response, ['updated_cli_version'])) { logger.debug( `Need to update binary, current binary version: ${queryParams.cli_version}`, ) - const browserStackBinaryUrl = - process.env.BROWSERSTACK_BINARY_URL || null if (!isNullOrEmpty(browserStackBinaryUrl)) { logger.debug( `Using BROWSERSTACK_BINARY_URL: ${browserStackBinaryUrl}`, diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index aa82167c..f924ba2e 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -459,6 +459,41 @@ describe('CLIUtils', () => { mockConfig ) }) + + it('falls back to BROWSERSTACK_BINARY_URL when update_cli fails (SDK-6948)', async () => { + const saved = process.env.BROWSERSTACK_BINARY_URL + process.env.BROWSERSTACK_BINARY_URL = 'https://example.com/staging-binary.zip' + try { + const fallbackPath = '/mock/cli/dir/binary-fallback' + vi.spyOn(CLIUtils, 'runShellCommand').mockResolvedValue('1.0.0') + // update_cli fails (e.g. staging creds against the prod api host -> 401) + vi.spyOn(CLIUtils, 'requestToUpdateCLI').mockRejectedValue({ response: { statusCode: 401 } }) + vi.spyOn(CLIUtils, 'downloadLatestBinary').mockResolvedValue(fallbackPath) + + const result = await CLIUtils.checkAndUpdateCli(mockExistingPath, mockCliDir, mockConfig) + + expect(result).toBe(fallbackPath) + expect(CLIUtils.downloadLatestBinary).toHaveBeenCalledWith('https://example.com/staging-binary.zip', mockCliDir) + } finally { + if (saved === undefined) { delete process.env.BROWSERSTACK_BINARY_URL } else { process.env.BROWSERSTACK_BINARY_URL = saved } + } + }) + + it('re-throws when update_cli fails and no BROWSERSTACK_BINARY_URL is set (unchanged behaviour)', async () => { + const saved = process.env.BROWSERSTACK_BINARY_URL + delete process.env.BROWSERSTACK_BINARY_URL + try { + const err = new Error('Unauthorized') + vi.spyOn(CLIUtils, 'runShellCommand').mockResolvedValue('1.0.0') + vi.spyOn(CLIUtils, 'requestToUpdateCLI').mockRejectedValue(err) + const downloadSpy = vi.spyOn(CLIUtils, 'downloadLatestBinary').mockResolvedValue('/should/not/be/used') + + await expect(CLIUtils.checkAndUpdateCli(mockExistingPath, mockCliDir, mockConfig)).rejects.toBe(err) + expect(downloadSpy).not.toHaveBeenCalled() + } finally { + if (saved !== undefined) { process.env.BROWSERSTACK_BINARY_URL = saved } + } + }) }) describe('setupCliPath', () => {