Skip to content

fix(cli): fall back to BROWSERSTACK_BINARY_URL when update_cli fails (SDK-6948) - #224

Open
AakashHotchandani wants to merge 4 commits into
mainfrom
SDK-6948-binary-url-fallback
Open

AakashHotchandani wants to merge 4 commits into
mainfrom
SDK-6948-binary-url-fallback

Conversation

@AakashHotchandani

@AakashHotchandani AakashHotchandani commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

update_cli runs during bootstrap, before the CLI 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 with a 401. On this (v9) line, requestToUpdateCLI used _fetch and resolved the 401 body ({"message":"Unauthorized"}) without throwing. That gave two outcomes:

  • Empty CLI dir: Resolved binary path: -> start() throws Failed to get CLI binary path (src/cli/index.ts) -> bootstrap() catches it -> the run continues in the direct (non-CLI) flow against hub-cloud.browserstack.com.
  • Cached binary present: the stale binary is reused without any message.

(The spawn('') / ERR_INVALID_ARG_VALUE crash and "got throws" belong to the v8 line, PR #183.)

Fix

Wrap the update_cli call in try/catch. 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). This turns BROWSERSTACK_BINARY_URL from a download-source override (only read after a successful update_cli) into a real bootstrap fallback, so a worker can pull a binary from a URL even when the staging api-<env> / update_cli tier is unreachable.

Follow-up commit f3694d8 (wdio v9 specifics). On v9, requestToUpdateCLI uses fetch and returns response.json(). It does not throw on a 401: it resolves {"message":"Unauthorized"}, so the catch above never ran. With an empty CLI dir the bootstrap still failed with Failed to get CLI binary path. With an existing binary, the stale one was reused silently. The follow-up does three things:

  • requestToUpdateCLI now rejects on !response.ok, with response.statusCode and the body attached.
  • checkAndUpdateCli also falls back when a reply has neither url nor updated_cli_version. A successful prod reply always carries both; checked against prod update_cli for cli_version 0, 1.58.5 and 99.0.0, and all three return updated_cli_version + url.
  • Without BROWSERSTACK_BINARY_URL, a non-2xx reply still keeps the existing binary, exactly as before. Network errors still propagate.

Fallback scope (cross-SDK decision): with BROWSERSTACK_BINARY_URL set, any update_cli failure (non-2xx, network error) falls back to that URL, deliberately not gated to 401/403. The variable is an opt-in internal "use this binary" override; on the success path it already replaces the update_cli URL unconditionally. This matches node-agent#2655 and python#1282. Follow-up 5c67d07 (python #1282 parity): a 2xx reply with neither url nor updated_cli_version keeps the cached binary and downloads from the URL only when none is cached. If the URL download itself fails and a cached binary exists, the cached one is used.

Opt-in / zero default impact: with no BROWSERSTACK_BINARY_URL, a non-2xx reply still keeps the existing binary (or '' when there is none), and network errors still propagate, the same as before. Contract change: CLIUtils.requestToUpdateCLI now rejects on a non-2xx reply (with response.statusCode) instead of resolving the error body.

Tests

  • 2 unit tests from the first commit: the fallback fires when requestToUpdateCLI rejects with the URL set, and the error is re-thrown without it.
  • 4 new unit tests in the follow-up:
    • requestToUpdateCLI rejects with statusCode: 401 on a 401 JSON reply.
    • A 401 JSON reply with the URL set triggers the fallback download.
    • A 401 with the URL unset keeps the existing binary and downloads nothing (unchanged behaviour).
    • A 2xx body with neither url nor updated_cli_version triggers the fallback.
    • The 3 new fallback/rejection tests fail on the first commit's source and pass with the follow-up.
  • Review follow-up 4eb68d2: ok is checked before parsing (non-JSON 5xx keeps its status); the span closes in a finally; the tests clear HTTP(S)_PROXY and assert the fetch mock was hit; new cases cover a 401 with no cached binary, a rejected fallback download, an empty BROWSERSTACK_BINARY_URL, and a non-JSON 502.
  • Follow-up 5c67d07: keeps the cached binary when a 2xx body has neither url nor updated_cli_version, falls back ... and no binary is cached, and uses the cached binary when the fallback download fails (the first and last fail on 4eb68d2 source).
  • Full suite: 57 files / 1267 tests pass. tests/cli/cliUtils.test.ts (47) also passes with a dead HTTPS_PROXY set, so those tests are hermetic. Lint and tsc --noEmit are clean.
  • Correction to the earlier note: the 3 requestToUpdateCLI tests that "fail on main" only fail locally. A local npx wrapper injects HTTPS_PROXY, which makes _fetch route through undici's ProxyAgent and bypass the global.fetch mock. With the proxy variables unset they pass, on main and here.

Validation

wdio v9, k8s staging, after the follow-up commit:

  • Setup: minimal wdio v9 + mocha sample, local npm pack of this branch, BROWSERSTACK_STAGING_ENV=k8s, an empty BROWSERSTACK_FILES_DIR, BROWSERSTACK_BINARY_URL=https://127.0.0.1:8766/binary.zip (the browserstack-binary#1889 build, served locally).
  • Log chain:
INFO  Current CLI Path Found:
WARN  update_cli request failed (401); falling back to BROWSERSTACK_BINARY_URL
DEBUG Downloading SDK binary from: https://127.0.0.1:8766/binary.zip
DEBUG Resolved binary path: <files-dir>/cli
DEBUG spawning command='<files-dir>/cli/binary-macos-arm64,sdk'     # sha256 8760e196... = the #1889 build
CLI   [PRODUCTION] Making POST request to https://sdk-k8s.bsstag.com
CLI   startBuild: response=juztqjibjym0lbhmpkmsokmwhfbmzoh4cz7zlkjw
INFO  StartBinSession successful                                     # apis.automate.api = https://apik8s.bsstag.com
  • Session 54833a27e4a834686d8af1e64143b426c8a0b2cc ran on hub-k8s.bsstag.com and the spec passed.
  • Before the follow-up, the same setup gave response {"message":"Unauthorized"} -> Resolved binary path: -> Failed to get CLI binary path -> direct flow to hub-cloud.browserstack.com -> Invalid username or password.
  • Session status marking/TRA linking is provided by fix: recognize bsstag hosts and localize session REST host on named staging envs (SDK-6948) #275; validated there (session c2572b70, passed).

Earlier validation on the v8 line (identical code path, PR #183): a prod-mode run with BROWSERSTACK_STAGING_ENV=devapplca + BROWSERSTACK_BINARY_URL -> update_cli 401 -> fallback -> binary downloaded -> spawned -> build-start reached.

Follow-up (separate): properly localize update_cli via BROWSERSTACK_STAGING_ENV (port of ruby-sdk SDK-6583) + bring up the staging api-<env> tier. This fallback is the production-safe bridge until then.

🤖 Generated with Claude Code

…(SDK-6948)

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 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Central YAML (base), Organization UI (inherited), Workspace UI (inherited)
  • Review profile: ASSERTIVE
  • Plan: Enterprise
  • Run ID: 334e033c-6615-4882-86b6-960c0f1263fe

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

…Y_URL fallback (SDK-6948)

requestToUpdateCLI resolved a 401 as a plain JSON body ({"message":"Unauthorized"}),
so the BROWSERSTACK_BINARY_URL fallback added earlier in this PR never ran: with an
empty CLI dir the bootstrap failed with "Failed to get CLI binary path", and with an
existing binary the stale one was reused silently.

- requestToUpdateCLI now rejects on !response.ok, carrying the status code and body.
- checkAndUpdateCli also falls back when a reply has neither url nor
  updated_cli_version (prod always returns both on success).
- Without BROWSERSTACK_BINARY_URL a non-2xx reply still keeps the existing binary,
  exactly as before; network errors still propagate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@AakashHotchandani AakashHotchandani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated SDK PR Review

Verdict: ⚠️ Fix 2 issues — (1) The changeset describes the v8 spawn('') crash and claims the error "propagates exactly as before". On this line, start() already guards the empty path, and a non-2xx reply keeps the existing binary. Rewrite the CHANGELOG note to match v9. (2) The new 401/2xx tests mock global.fetch, but _fetch bypasses it under HTTP(S)_PROXY. Clear the proxy variables in beforeEach and assert that the mock was called, so the 2xx-error-body test cannot pass through the 401 branch.

Summary: 0 critical · 2 warnings · 4 suggestions across 3 files reviewed.

Intent: Make BROWSERSTACK_BINARY_URL a real bootstrap fallback when update_cli fails. update_cli always targets prod api.browserstack.com, so it rejects staging credentials with a 401. checkAndUpdateCli now wraps the call in a try/catch. With the URL set, it downloads the binary from that URL on any update_cli failure, or on a 2xx reply with neither url nor updated_cli_version. Without the URL, a non-2xx reply keeps the existing binary and other errors re-throw. requestToUpdateCLI now rejects on a non-2xx reply, because v9's fetch does not throw on 401.

The code change is sound. The no-URL path preserves behaviour, the fallback reuses the hardened download path, and graceful degradation holds. On this line, the ERR_INVALID_ARG_VALUE crash in the description was already prevented by the empty-path guard in start(). Both warnings are about accuracy: the CHANGELOG text, and tests that can pass for the wrong reason under a proxy environment.

Cross-SDK parity (#23)

Aspect wdio #224 node #2655 python #1282
(a) Fallback gating Ungated when URL set (network, any non-2xx, error-shaped 2xx) Ungated Ungated, and replaces a working cached binary
(a) No URL, HTTP-status error Keeps existing binary ('' if none) — —
(a) No URL, network/parse error Re-throws -> setupCliPath null -> non-CLI flow — —
(b) Empty BROWSERSTACK_BINARY_URL Treated as unset (|| null) Treated as unset Treated as set
(c) Busy-binary reuse Kept: isBinaryBusy runs before update_cli, so the fallback never bypasses it Fallback skips it Kept
(d) Vacuous tests Hermetic only without a proxy env; under HTTP(S)_PROXY the 2xx test passes via the 401 branch — 3 tests could never fail
(e) Span close At most once; zero times on download rejection (no try/finally) try/finally, exactly once —

Rovo enrichment unavailable — review based on local SDK docs only.

See inline comments below for full Problem and Suggested Fix detail on each finding.

Generated by Automated SDK PR review.


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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Warning — [DOCS] Changeset describes the v8 symptom, not this package's behaviour

Problem

This changeset becomes the public CHANGELOG entry, and two of its statements are wrong for this package line:

  1. "…left the CLI binary path empty and crashed the launcher with spawn('') … ERR_INVALID_ARG_VALUE". On this line, BrowserstackCLI.start() already guards the empty path before spawning (src/cli/index.ts:258-260):
    const SDK_CLI_BIN_PATH = await this.getCliBinPath()
    if (!SDK_CLI_BIN_PATH) {
        throw new Error('Failed to get CLI binary path. CLI setup failed.')
    }
    bootstrap() catches that error (index.ts:100-105), and the run continues in the direct (non-CLI) flow. The PR body's own v9 "before" log confirms this: Resolved binary path: -> Failed to get CLI binary path -> direct flow to hub-cloud. The ERR_INVALID_ARG_VALUE crash and the "got throws" description belong to the v8 line (PR fix(util): recognize *.bsstag.com hosts as BrowserStack (v8, SDK-6948) #183). On this line requestToUpdateCLI uses _fetch, which never threw on a 401.
  2. "Opt-in only — with the variable unset the original error propagates exactly as before." For a non-2xx update_cli reply, nothing propagated before and nothing propagates now. The existing binary path is returned (cliUtils.ts:283-288), or '' when there is none. Only network or parse errors propagate.

The note also omits a real contract change: CLIUtils.requestToUpdateCLI (a public static) now rejects on a non-2xx reply where it used to resolve the JSON body.

Suggested Fix

Rewrite the body to match v9:

fix(cli): fall back to `BROWSERSTACK_BINARY_URL` when `update_cli` fails (SDK-6948)

`update_cli` runs during bootstrap against production `api.browserstack.com`, so on an internal staging run (`BROWSERSTACK_STAGING_ENV`) it answers `401`. The SDK then found no CLI binary and silently fell back to the non-CLI flow, or reused a stale cached binary. When `update_cli` fails (non-2xx, network error, or a reply with neither `url` nor `updated_cli_version`) and `BROWSERSTACK_BINARY_URL` is set, the binary is now downloaded from that URL. `requestToUpdateCLI` now rejects on a non-2xx reply. With `BROWSERSTACK_BINARY_URL` unset, behaviour is unchanged: a non-2xx reply keeps the existing binary, and network errors propagate.

Confidence: 🟢 Objectively verifiable: src/cli/index.ts:258-260 at head already throws on an empty path before spawn, and requestToUpdateCLI uses _fetch, not got.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eb68d2: rewrote the changeset in v9 terms. It now says a 401 led to the non-CLI flow (empty dir) or a silent stale binary, records the requestToUpdateCLI contract change (rejects on non-2xx), and says that with BROWSERSTACK_BINARY_URL unset a non-2xx keeps the existing binary and network errors propagate. I corrected the PR description Problem section the same way. The spawn('') crash is now attributed to v8 / #183.

expect(download).not.toHaveBeenCalled()
})

it('falls back when a 2xx body has neither url nor updated_cli_version', async () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Warning — [TESTING] New 401/2xx tests bypass their global.fetch mock whenever a proxy env var is set

Problem

The new checkAndUpdateCli update_cli 401 (SDK-6948) block mocks global.fetch, but requestToUpdateCLI calls _fetch from src/fetchWrapper.ts:

const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY
if (proxyUrl) { ... return undiciFetch(request.url, { ..., dispatcher: new ProxyAgent(proxyUrl) }) }
return fetch(input, init)

When either proxy variable is set (the PR body says a local npx wrapper injects HTTPS_PROXY), these tests send a real GET api.browserstack.com/.../update_cli with testuser:testkey. Prod answers that with a real 401, so:

  • falls back when a 2xx body has neither url nor updated_cli_version still passes, but through the 401 catch branch. The new 2xx error-body branch (cliUtils.ts:294-297) is never executed, so the test is vacuous in that environment (the same failure class as the python tests that could not fail).
  • The two 401 tests pass only because prod happens to return 401. If the proxy cannot reach the host, the "keeps existing binary" test fails, because the error has no statusCode and is re-thrown.

None of the tests asserts that the mock was hit, so nothing detects the bypass.

Suggested Fix

Make the block hermetic and prove the mock was reached:

const savedProxy = { http: process.env.HTTP_PROXY, https: process.env.HTTPS_PROXY }
beforeEach(() => {
    delete process.env.HTTP_PROXY
    delete process.env.HTTPS_PROXY
    // ...existing mocks
})
afterEach(() => {
    if (savedProxy.http !== undefined) { process.env.HTTP_PROXY = savedProxy.http }
    if (savedProxy.https !== undefined) { process.env.HTTPS_PROXY = savedProxy.https }
    // ...existing restore
})

// in each test
expect(global.fetch).toHaveBeenCalledTimes(1)

Alternatively, vi.mock('../../src/fetchWrapper.js') and stub _fetch directly. Apply the same treatment to the requestToUpdateCLI describe block (the rejects with the status code on a non-2xx reply test at line 680).

Confidence: 🟢 Objectively verifiable: _fetch (src/fetchWrapper.ts:19-33) routes through undici ProxyAgent when HTTP_PROXY/HTTPS_PROXY is set, which skips global.fetch. The PR body confirms this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eb68d2: both the update_cli 401 block and the requestToUpdateCLI block now delete HTTP_PROXY/HTTPS_PROXY in beforeEach (restored in afterEach), and each new test asserts expect(global.fetch).toHaveBeenCalledTimes(1). Verified hermetic: tests/cli/cliUtils.test.ts passes 47/47 both with the proxy vars unset and with HTTPS_PROXY=http://127.0.0.1:9 (dead proxy).

// only: with no BROWSERSTACK_BINARY_URL behaviour is exactly as before.
const statusCode = (err as { response?: { statusCode?: number } })?.response?.statusCode
if (!isNullOrEmpty(browserStackBinaryUrl)) {
return fallbackToBinaryUrl(statusCode ?? (err as Error)?.message)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion — [CORRECTNESS] Fallback is ungated: any update_cli failure swaps the binary when BROWSERSTACK_BINARY_URL is set

Problem

With BROWSERSTACK_BINARY_URL set, the fallback fires on every failure class: network error or timeout, prod 5xx, 429, 400, any 401/403, and any 2xx body missing both url and updated_cli_version. The fallback calls downloadLatestBinary(url, cliDir) without an expectedVersion. A custom URL carries no -x.y.z.zip version, so targetVersion is null, isBinaryAtVersion() returns false, and a working cached binary is re-downloaded and replaced on every transient prod blip. Node #2655 and Python #1282 behave the same way, so this matches them, but it is broader than the staging-401 problem the PR targets. Because the variable is opt-in and internal, the blast radius is limited.

Consider gating the fallback to the case it exists for, for example statusCode === 401 || statusCode === 403 || process.env.BROWSERSTACK_STAGING_ENV. Alternatively, on 5xx, 429 or network errors, prefer existingCliPath when one exists and only download when it is empty. If the broad behaviour is intended, record the decision in the PR body so the three SDKs stay aligned on purpose.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. After a cross-SDK review we are keeping the broad behaviour on purpose, with no gating to 401/403. BROWSERSTACK_BINARY_URL is an opt-in internal "use this binary" override, and on the success path it already replaces the update_cli URL unconditionally (response.url = browserStackBinaryUrl). Falling back to it on any update_cli failure is therefore consistent, and it matches node-agent#2655 and python#1282. I recorded the decision in the PR body. Your re-download concern is addressed in 5c67d07, aligned with python: a 2xx reply with neither url nor updated_cli_version now keeps the cached binary and only downloads when none is cached. If the URL download itself fails, a cached binary is used instead of failing the CLI setup. Tests: keeps the cached binary when a 2xx body has neither url nor updated_cli_version and uses the cached binary when the fallback download fails.

)
const jsonResponse = await response.json()
logger.debug(`response ${JSON.stringify(jsonResponse)}`)
if (!response.ok) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion — [CORRECTNESS] response.json() runs before the !response.ok check; non-JSON error bodies lose the status code

Problem

requestToUpdateCLI parses the body (cliUtils.ts:472) before checking response.ok (:474). An HTML or empty error body (an LB 502/503/504) makes json() throw a SyntaxError with no response.statusCode. With BROWSERSTACK_BINARY_URL unset, that error is re-thrown, setupCliPath returns null, and the run drops to the non-CLI flow. A JSON-bodied 5xx instead keeps the existing binary. The same HTTP status therefore takes two different paths depending on body format. This is pre-existing for the no-URL case, but the new statusCode branch is meant to cover it.

Check ok first and parse defensively:

const body = await response.json().catch(() => null)
if (!response.ok) {
    throw Object.assign(new Error(`update_cli request failed with status ${response.status}`),
        { response: { statusCode: response.status, body } })
}

Also consider reusing the existing ResponseError from src/fetchWrapper.ts instead of a new ad-hoc { response: { statusCode } } shape. Note that ResponseError.response is a Response, so it exposes .status, not .statusCode.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly fixed in 4eb68d2: requestToUpdateCLI now checks response.ok first and parses the error body defensively (response.json().catch(() => null)), so an HTML or empty 502/503/504 rejects with response.statusCode instead of a bare SyntaxError. The new test is keeps the status code when a non-2xx reply has a non-JSON body. I kept the { response: { statusCode, body } } shape rather than reusing ResponseError. ResponseError.response is a Response (it exposes .status), and its message consumes the body via res.text(). The fallback catch in checkAndUpdateCli and the first-commit tests read response.statusCode, so switching would change both for no behavioural gain.

browserStackBinaryUrl as string,
cliDir,
)
PerformanceTester.end(PerformanceEvents.SDK_CLI_CHECK_UPDATE)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion — [OBSERVABILITY] SDK_CLI_CHECK_UPDATE span is never closed when the fallback download rejects

Problem

fallbackToBinaryUrl calls PerformanceTester.end(SDK_CLI_CHECK_UPDATE) only after await this.downloadLatestBinary(...) resolves. A bad or unreachable BROWSERSTACK_BINARY_URL, a zip without a binary-* entry, or a lock timeout makes the download reject, so the span is never ended. The span closes at most once on every path, but zero times on this one. The pre-existing updated_cli_version path at :311-316 has the same shape. Node #2655 wraps this in try/finally and is cleaner here.

Wrap the body of checkAndUpdateCli after PerformanceTester.start in try { ... } finally { PerformanceTester.end(...) } (or pass success=false and the error in a catch), and delete the scattered end() calls.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eb68d2: everything in checkAndUpdateCli after PerformanceTester.start(SDK_CLI_CHECK_UPDATE) now runs in a try { ... } finally { PerformanceTester.end(...) }, and the scattered end() calls are removed. The span closes exactly once on every path, including a rejected fallback download and the pre-existing updated_cli_version download. Test: propagates a rejected fallback download and still closes the span asserts end is called once.

expect(download).toHaveBeenCalledWith('https://example.com/staging-binary.zip', mockCliDir)
})

it('keeps the existing binary on a 401 when BROWSERSTACK_BINARY_URL is unset (unchanged behaviour)', async () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion — [TESTING] Branch coverage gaps: no cached binary, download rejection, empty BROWSERSTACK_BINARY_URL

Problem

The tests cover fallback-on-reject, re-throw without the URL, 401 with the URL, 401 without the URL (with a cached binary), and the 2xx error body. Three branches are still untested:

  1. Prod transient failure with no cached binary and no URL. A non-2xx reply with existingCliPath = '' returns ''. This is the prod-customer case behind the empty-path concern. Assert that checkAndUpdateCli('', …) resolves '', and that setupCliPath then returns a falsy value so start() takes the Failed to get CLI binary path exit into the non-CLI flow.
  2. Fallback download rejects. Assert that the error propagates to setupCliPath, which returns null, and that the span is closed (see the span suggestion).
  3. BROWSERSTACK_BINARY_URL=''. || null makes it count as unset, which matches node and differs from python. Pin that with a test so the three SDKs don't drift further.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eb68d2, with three new tests in the update_cli 401 block. (1) returns an empty path on a 401 with no cached binary and no BROWSERSTACK_BINARY_URL: checkAndUpdateCli('', ...) resolves ''. setupCliPath passes that through, and start() then takes the existing Failed to get CLI binary path exit; that part is unchanged code. (2) propagates a rejected fallback download and still closes the span: the rejection propagates, and the existing setupCliPath test returns null when an error occurs during setup covers the null mapping. (3) treats an empty BROWSERSTACK_BINARY_URL as unset: this pins today's || null behaviour (matches node). Whether python should align is a cross-SDK call that I have raised separately.

AakashHotchandani and others added 2 commits October 8, 2026 12:53
- requestToUpdateCLI checks response.ok before parsing, so an HTML/empty
  error page keeps its status code instead of surfacing as a SyntaxError.
- checkAndUpdateCli closes the SDK_CLI_CHECK_UPDATE span once in a finally,
  including when the fallback download rejects.
- Tests: clear HTTP(S)_PROXY so _fetch cannot bypass the global.fetch mock,
  assert the mock was hit, and cover no-cached-binary 401, rejected fallback
  download, empty BROWSERSTACK_BINARY_URL and a non-JSON 5xx.
- Changeset rewritten for the v9 behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lback download (SDK-6948)

- A 2xx update_cli reply with neither url nor updated_cli_version keeps the
  cached binary; BROWSERSTACK_BINARY_URL is only fetched when none is cached
  (python #1282 parity).
- If the BROWSERSTACK_BINARY_URL download fails and a cached binary exists,
  use it instead of failing the CLI setup.
- Tests for both, plus the no-cached-binary 2xx fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@AakashHotchandani AakashHotchandani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated SDK PR Re-Review (round 2): f3694d8 → 5c67d07

Verdict: ✅ Good to go. Both prior warnings are resolved at head. Three 💡 suggestions below are non-blocking.

Rovo enrichment unavailable. This review is based on local SDK docs only.

═══════════════════════════════════════════════════════════════

Prior findings (review 5453029954)

# Finding Ruling Evidence at 5c67d07
a ⚠️ Changeset described the v8 spawn('') crash RESOLVED .changeset/sdk-6948-binary-url-fallback.md:7 now describes the v9 behaviour: non-CLI flow or a silent stale binary. It records the requestToUpdateCLI reject-on-non-2xx contract change and the no-URL behaviour.
b ⚠️ Tests bypass the global.fetch mock under HTTP(S)_PROXY RESOLVED tests/cli/cliUtils.test.ts:505-510,524-525 and :690-696,706-707 delete and restore both proxy vars. Every fetch-driven test asserts expect(global.fetch).toHaveBeenCalledTimes(1) (:542, :553, :569, :585, :606, :762, :775). The 2xx error-body test now runs on the 2xx branch.
c 💡 Fallback is ungated DECLINED-ACCEPTABLE The decision is recorded in the PR body, and it matches node#2655 and python#1282. The re-download concern is reduced: a 2xx reply with neither url nor updated_cli_version keeps the cache (cliUtils.ts:298), and a failed URL download falls back to the cached binary (cliUtils.ts:264-272). One residual remains: with the URL set, a 401 still re-downloads on every run. That is acceptable for an opt-in internal variable.
d 💡 json() was parsed before the ok check RESOLVED (the ResponseError reuse part is DECLINED-ACCEPTABLE) cliUtils.ts:476-485 checks ok first and uses .catch(() => null). The new test is keeps the status code when a non-2xx reply has a non-JSON body. The author is right that ResponseError.response exposes .status and consumes the body, so keeping the existing shape is sound.
e 💡 Span left open when the download rejects RESOLVED cliUtils.ts:231 / :322-324 close the span in try/finally, and the scattered end() calls are removed. Test at :609 asserts one end(SDK_CLI_CHECK_UPDATE).
f 💡 Coverage gaps RESOLVED Tests at :598 (no cache, no URL → ''), :609 (download rejection propagates) and :619 (empty URL treated as unset).

═══════════════════════════════════════════════════════════════

New commits (4eb68d2, 5c67d07)

0 critical · 0 warnings · 3 suggestions

# Finding File
1 💡 [TESTING] uses the cached binary when the fallback download fails never asserts that the download ran tests/cli/cliUtils.test.ts:588
2 💡 [DOCS] New comment says a 2xx reply without url/updated_cli_version means "nothing to update". The PR body says prod always returns both. src/cli/cliUtils.ts:296
3 💡 [DOCS] Changeset says behaviour is "unchanged" without the URL, but a non-JSON non-2xx reply now keeps the binary instead of throwing .changeset/sdk-6948-binary-url-fallback.md:7

Details are in the inline comments.

═══════════════════════════════════════════════════════════════

Anti-pattern checks

  • #5 error boundaries: pass. Every failure in checkAndUpdateCli either returns the cached binary or '', or it throws into the setupCliPath catch, which returns null. start() then takes the existing Failed to get CLI binary path exit into the non-CLI flow, so the user's tests never break.
  • #8 build verification: not applicable. This package ships tsc output without obfuscation. CI Build & test passed on node 18.20, 20 and 22, and Lint passed at 5c67d07.
  • #23 parallel paths: pass. Only the CLI bootstrap calls update_cli. The worker branch (cliUtils.ts:216-226, JWT set and no binary) falls through to the same fallback. The cross-SDK behaviour matches node#2655 and python#1282: an ungated fallback, cache kept on a 2xx reply with neither field, cache used when the URL download fails, and an empty URL treated as unset (python differs on the empty URL; the author has raised that separately).
  • #26 unreachable fallbacks: pass. Each new branch is reachable and tested: json().catch on an HTML 5xx, the download-failure cache fallback, and the 2xx-neither-field branch. Finding 2 is about how that last branch is described, not whether it can be reached.

═══════════════════════════════════════════════════════════════

Full-diff regression check

The PR diff touches only checkAndUpdateCli (:227-325) and requestToUpdateCLI (:461-489), plus the changeset and tests.

  • The busy-binary short-circuits still run before update_cli, inside the new try/finally.
  • Success-path URL substitution (:308-313) is unchanged.
  • A return await inside the try makes the span end only after the download settles.
  • util is already imported (:5).

No regressions found.

═══════════════════════════════════════════════════════════════

Per-file confidence

File Status Reason
.changeset/sdk-6948-binary-url-fallback.md 🟢 All Clear Prior warning resolved. One wording suggestion.
packages/browserstack-service/src/cli/cliUtils.ts 🟢 All Clear Covered by sdk-anti-patterns #5, #23 and #26. One comment-accuracy suggestion.
packages/browserstack-service/tests/cli/cliUtils.test.ts 🟢 All Clear Prior warning resolved; tests are hermetic and assert the mock was hit. One assertion suggestion.

✅ Good to go. Suggestions 1-3 are non-blocking.

Generated by Automated SDK PR review.

expect(global.fetch).toHaveBeenCalledTimes(1)
})

it('uses the cached binary when the fallback download fails', async () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion: [TESTING] This test never shows the fallback download ran

This test proves the result is mockExistingPath. That is also what checkAndUpdateCli would return if a regression skipped the fallback for a 401 when a binary is cached (for example, gating the fallback on isNullOrEmpty(existingCliPath)). The test fails on 4eb68d2, because the rejection propagated there, so it is not vacuous today. It does not, however, pin the path its name describes. Unlike the other tests in this block, it also does not assert that global.fetch was hit.

Suggested Fix: capture the spy and assert that the attempt happened:

const download = vi.spyOn(CLIUtils, 'downloadLatestBinary').mockRejectedValue(new Error('download failed'))
// ...
expect(download).toHaveBeenCalledWith('https://example.com/staging-binary.zip', mockCliDir)
expect(global.fetch).toHaveBeenCalledTimes(1)

throw err
}

// A 2xx reply with neither `updated_cli_version` nor `url` means there is nothing to update:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion: [DOCS] The comment's reading of a 2xx reply with neither field contradicts the PR's own evidence

The comment says a 2xx reply with neither updated_cli_version nor url "means there is nothing to update". The PR body says the opposite. It reports that prod update_cli returns both updated_cli_version and url for cli_version 0, 1.58.5 and 99.0.0, so an up-to-date client still gets both fields. This branch is therefore a guard for an unexpected reply shape (for example {success:true} or an error-shaped 2xx), not the up-to-date reply. The behaviour itself is fine and matches python#1282: keep the cache, and download only when nothing is cached.

Suggested Fix: reword the comment, for example: // A 2xx reply with neither field is not a normal update_cli reply: keep a cached binary, and only fetch from BROWSERSTACK_BINARY_URL when there is none.


fix(cli): fall back to `BROWSERSTACK_BINARY_URL` when `update_cli` fails (SDK-6948)

`update_cli` runs during bootstrap against production `api.browserstack.com`, so on an internal staging run (`BROWSERSTACK_STAGING_ENV`) it answers `401`. The SDK then found no CLI binary and fell back to the non-CLI flow, or silently reused a stale cached binary. When `BROWSERSTACK_BINARY_URL` is set and `update_cli` fails (non-2xx or network error), the binary is now downloaded from that URL. If that download fails, a cached binary is used when there is one. A 2xx reply with neither `url` nor `updated_cli_version` keeps the cached binary, and only downloads from the URL when none is cached. `CLIUtils.requestToUpdateCLI` now rejects on a non-2xx reply (with `response.statusCode`) instead of resolving the error body. With `BROWSERSTACK_BINARY_URL` unset, behaviour is unchanged: a non-2xx reply keeps the existing binary, and network errors propagate.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion: [DOCS] "Behaviour is unchanged" is slightly too broad without the URL

With BROWSERSTACK_BINARY_URL unset, a non-2xx reply with a non-JSON body (for example an HTML 502/503 from the load balancer) used to surface as a SyntaxError. setupCliPath then returned null, and the run used the non-CLI flow. Now requestToUpdateCLI attaches statusCode (cliUtils.ts:476-485), so checkAndUpdateCli returns the cached binary (:288-291) and the CLI flow continues. This affects prod customers, and it is an improvement, but the changeset says the behaviour did not change.

Suggested Fix: add a short clause, for example: "…a non-2xx reply (including a non-JSON error page) keeps the existing binary, and network errors propagate."

AakashHotchandani added a commit that referenced this pull request Oct 13, 2026
- util: treat *.bsstag.com as BrowserStack only on a named staging env
  (BROWSERSTACK_STAGING_ENV bare token, trimmed; or BROWSERSTACK_ENV
  staging/stag/preprod/pre-prod), exact suffix match, case-insensitive.
  Replaces the unconditional `includes('bsstag')` match.
- cli: BROWSERSTACK_BINARY_URL fallback also covers a 2xx update_cli reply
  with no url and no cached binary; a failed fallback download uses the
  cached binary when present; the update_cli error is logged; the perf span
  closes on every path.
- service: _printSessionURL logs a failed or empty session lookup at debug
  instead of throwing (v8 uses got, which throws on 404/401).
- tests: bsstag gating, fallback download failures, 2xx-without-url,
  _printSessionURL 404 / missing automation_session.

Addresses review comments 4256353854 and 4256353859.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant