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', () => {