Skip to content

feat(cloud-webdriver): BrowserStack device-feature capabilities, and fix cloud orientation - #1544

Open
leovarmak wants to merge 5 commits into
callstack:mainfrom
leovarmak:feat/browserstack-device-capabilities-and-orientation
Open

feat(cloud-webdriver): BrowserStack device-feature capabilities, and fix cloud orientation#1544
leovarmak wants to merge 5 commits into
callstack:mainfrom
leovarmak:feat/browserstack-device-capabilities-and-orientation

Conversation

@leovarmak

Copy link
Copy Markdown

Summary

Two related fixes to the hosted BrowserStack path:

  1. Device-feature capabilities are now configurable. Hosted sessions could only be given a device, OS version, app, and project/build/session labels. The eight device-feature capabilities had no route through the CLI, the remote-config profile, or the typed client, so a session always ran with provider defaults.
  2. orientation no longer fails on every cloud provider. The cloud WebDriver interactor sent mobile: rotate, which is not a driver command — UiAutomator2's own error enumerates its extensions and rotate is absent — so agent-device orientation was a hard failure against any hosted session.

What changed

Device-feature capabilities

Adds deviceOrientation, geoLocation, timezone, language, locale, networkProfile, customNetwork, and resignApp, each with a CLI flag and a short alias:

agent-device connect browserstack \
  --platform android --device "Google Pixel 8" \
  --provider-os-version 14.0 --provider-app bs://app-id \
  --provider-device-orientation portrait \
  --provider-geo-location US \
  --provider-timezone New_York

They are driven by a single spec table in cloud-webdriver/browserstack-device-features.ts, so adding a capability is a spec row rather than a branch in the capability builder and another in the flag reader. A structural test asserts every field owns exactly one row, so a field cannot be parsed off the CLI, ride the profile, and then be silently dropped before the wire.

Capabilities are emitted inside bstack:options, which is where BrowserStack expects vendor capabilities on the W3C wire. Its YAML config lists them unnested and its SDK relocates them; agent-device talks to the hub directly, so it nests them itself.

Three combinations are rejected up front with a typed error and a recovery hint, instead of being accepted by the hub and then ignored on a remote device:

  • an orientation outside portrait|landscape
  • --provider-no-resign-app on a non-iOS session
  • a named network profile combined with a custom network shape

Orientation transport

setOrientation now uses the WebDriver endpoints, ordered by backend:

  • POST /rotation takes exact four-way degrees, so it leads on Android — agent-device's rotation vocabulary is four-way and this is the only endpoint that can express upside-down and left-vs-right.
  • POST /orientation is two-way. XCUITest rejects /rotation, so iOS leads with this and pays the cost of collapsing both landscape rotations onto one value.

Each order keeps the other as a fallback, since only BrowserStack's UiAutomator2 is verified against a live device; a provider whose driver disagrees degrades rather than hard-failing.

The four-way mapping moved to a single table in contracts/device-rotation.ts, and the existing adb path (resolveAndroidUserRotation) now reads from it, so the local and cloud rotation mappings cannot drift apart.

Incidental fix

bstack:options is now merged per key rather than assigned. Previously a caller supplying its own bstack:options replaced the whole object, silently dropping projectName, buildName, and sessionName. Latent before this change; load-bearing now that device features write into the same object.

Testing

pnpm check passes: 533 files, 4689 tests.

New coverage:

  • browserstack-device-features.test.ts — capability projection, the field/spec structural check, and each of the three rejected combinations
  • webdriver-orientation.test.ts — per-backend transport order, the four-way degree mapping, fallback on driver rejection, and the exhausted-both-endpoints error
  • a wire-level assertion in cloud-webdriver-provider-adapters.test.ts that device features land nested in bstack:options and not at the top level, and that a caller's own vendor options merge rather than clobber
  • parser coverage for the eight flags and their aliases

Verified live against BrowserStack (Google Pixel 8, Android 14). The capabilities appear in the session's recorded create-session payload, and the rotate is confirmed in the provider's Appium log:

POST /wd/hub/session/.../rotation  →  matched to command name 'setRotation'
     body: {"x":0,"y":0,"z":0}
     Got response with status 200: {"value":"ROTATION_0"}

Notes for reviewers

  • POST /rotation rotates the current display, not the device's persistent rotation. An activity that does not pin its own orientation — a Chrome Custom Tab hosting an OAuth page, for example — can still come up rotated and needs another orientation call once it is foreground. The docs and the capability note both say so; the session capability alone does not cover it.
  • A third transport (settings put system user_rotation over mobile: shell) was written and then removed. As a last-resort fallback it could never fire in the case it existed for — that case is /rotation succeeding but not persisting. If persistent rotation is wanted later it belongs as the preferred Android path, and providers commonly gate mobile: shell even where the driver advertises it.
  • The orientation capability stays partial: four-way intent collapses to two-way on drivers that only accept /orientation.
  • No behaviour change for Limrun, AWS Device Farm, or any local platform. The adb refactor is mapping-only and existing Android tests pass unchanged.

Adds the eight BrowserStack "device feature" session capabilities that had no
representation in agent-device: deviceOrientation, geoLocation, timezone,
language, locale, networkProfile, customNetwork, and resignApp.

These are vendor capabilities, so they are emitted inside `bstack:options`
rather than at the top level. BrowserStack's YAML config lists them unnested
and its SDK relocates them; agent-device talks to the hub directly, so it
nests them itself.

A single spec table drives both the flag reader and the capability builder, so
adding a capability is a table row rather than a branch in each. A structural
test asserts every field owns exactly one row, since a field the table forgets
would parse off the CLI, ride the profile, and then be silently dropped before
the hub ever saw it.

Rejects combinations the provider cannot act on unambiguously: an unknown
orientation is caught at the flag boundary instead of being forwarded to a hub
that accepts and then ignores it, --provider-no-resign-app is refused on
Android, and a named network profile cannot be combined with a custom network
shape.

Also fixes a latent shallow-merge bug in buildBrowserStackCapabilities: a
caller supplying its own `bstack:options` replaced the whole object and
silently dropped the project, build, and session labels. It is now merged
per key.
`setOrientation` on the cloud WebDriver path sent `mobile: rotate`, which is
not a driver command at all. UiAutomator2's own error enumerates its
extensions and `rotate` is absent from the list, so `agent-device orientation`
was a hard failure on every hosted provider.

It also forwarded agent-device's four-way rotation vocabulary verbatim
("landscape-left", "portrait-upside-down"), where the protocol accepts only
uppercase PORTRAIT/LANDSCAPE. Every other platform has a translation layer;
this path was the only one without one.

Now two transports, ordered by backend. `POST /rotation` takes exact four-way
degrees and leads on Android, since it is the only endpoint that can express
upside-down and left-versus-right. `POST /orientation` is two-way and leads on
XCUITest, which rejects `/rotation`. Each falls back to the other, because only
BrowserStack's UiAutomator2 is verified and a provider whose driver disagrees
should degrade rather than hard-fail.

Verified live against BrowserStack App Automate:
  POST /rotation {"x":0,"y":0,"z":0} -> 200 {"value":"ROTATION_0"}

The rotation-to-surface-index mapping moves to contracts/device-rotation.ts and
the existing adb path now reads from it, so the local and hosted mappings
cannot drift apart.

Note this rotates the current display, not persistent device rotation, so an
activity that does not pin its own orientation may still need rotating once it
is in the foreground.

The capability was declared "partial" without the transport existing, and no
test covered setOrientation on the cloud path; only adb and the Apple runner
were covered. Both gaps are now closed.
@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member

Reviewed ef4f1e5 despite the dirty base. Three P2 items block readiness: (1) orientation fallback catches every error; fall back only on structured unsupported-endpoint/unknown-command results and preserve timeout/auth/session/5xx failures; (2) BrowserStack-only feature flags are accepted and silently ignored by AWS/other providers, so reject them outside BrowserStack or model provider ownership; (3) add a provider-backed orientation scenario through public daemon dispatch, including fallback semantics. Rebase onto current main without resurrecting pre-extraction modules: move provider logic/tests into packages/provider-webdriver, shared contracts into packages/contracts, and resolve the five remaining textual plus eight modify/delete conflicts. Current head has no CI; rerun exact-head gates and attach iOS evidence or an exact blocker after rebase.

…ice-capabilities-and-orientation

# Conflicts:
#	packages/provider-webdriver/src/browserstack-device-features.test.ts
#	packages/provider-webdriver/src/browserstack-device-features.ts
#	packages/provider-webdriver/src/webdriver-orientation.test.ts
#	packages/provider-webdriver/src/webdriver-orientation.ts
#	src/cli/commands/connection-runtime.ts
#	src/cli/connection/profile-fields.ts
#	src/cloud-webdriver/provider-definitions.ts
#	src/platforms/android/input-actions.ts
#	src/remote/remote-config-schema.ts
#	test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts
…wned flags

Addresses review on callstack#1544.

The orientation fallback caught every error, so a timeout, an auth rejection, a
dead session or a provider 5xx on the first transport was swallowed and retried
against the second. When that one also failed the caller got "rejected both
endpoints" with the real cause discarded. Fallback is now keyed on structured
unsupported-endpoint signals only — HTTP 404/405, or a W3C `unknown command` /
`unknown method` code — matching the repo rule of keying on typed details rather
than message text. Everything else rethrows unchanged.

Device-feature capabilities are BrowserStack-owned, but the flags were accepted
by any cloud provider, persisted into the generated profile, and then silently
dropped at session creation. `connect aws-device-farm` now rejects them with a
typed error naming each offending flag, raised before the provider's own
required-argument checks so the caller is told what is unsupported rather than
what else is missing. Ownership is modelled on the capability spec table, so a
new capability inherits the guard without a second list to maintain.

Adds provider-backed orientation scenarios driven through public daemon dispatch
against the fake WebDriver provider: the four-way endpoint on the happy path,
the documented collapse onto the two-way endpoint when the driver does not
implement `/rotation`, and a provider 5xx that must surface without consulting
the second transport. The fake server's route handling became a table in the
process — it had grown to ten branches in one function.
@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member

Thank you so much for the contribution! How’s the experience?

@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member

Re-reviewed 52d6dd4. Package-extraction conflict resolution is clean, and the new public-daemon orientation scenarios close the route-coverage finding. Two P2s remain:

  1. The fallback classifier lets any 404/405 fall through before reading W3C value.error, so an HTTP 404 invalid session id is still masked. Structured error codes must take precedence; use bare status fallback only when no code exists.
  2. BrowserStack-only flags are rejected only by the CLI AWS profile builder. Typed-client or hand-authored AWS profiles can still reach provider preparation and be silently ignored, so enforce ownership at the provider/runtime boundary and test that route directly.

Exact-head iOS smoke is also red on a capture-stalled timeout, and BrowserStack iOS live evidence or an exact blocker is still absent. Rerun/classify CI and add iOS evidence after the code fixes; no ready-for-human label.

…ership at the runtime boundary

Addresses the second review pass on callstack#1544.

The fallback classifier returned on any 404/405 before consulting the W3C error
code, so an HTTP 404 carrying `invalid session id` was masked as a missing route
and retried against the second transport. The structured code now takes
precedence whenever the driver sent one; bare status is consulted only when no
code exists. Two cases pin it: a 404 `invalid session id` and a 405 `timeout`
must both surface rather than fall through.

Provider ownership was enforced only in the CLI profile builder, which the typed
client and hand-authored remote-config profiles bypass entirely — both reach
session preparation without passing through `connect`, so the capabilities were
accepted and then dropped. The check now lives on the capability-ownership
module and runs inside AWS Device Farm's `prepareSession`, with the CLI builder
calling the same helper instead of its own copy. Covered by a scenario that
drives the runtime boundary directly and asserts the rejection happens before
any provider session is created.
@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member

Re-reviewed at 8999144: the structured W3C error now correctly takes precedence over bare 404/405, and BrowserStack-only capability ownership is enforced at AWS session preparation as well as the CLI route. The direct runtime and public-daemon orientation regressions cover those fixes.

Remaining readiness work is evidence only: no exact-head CI checks are reported and merge state is currently unstable. Please rerun/attach the exact-head gates. Also provide BrowserStack iOS evidence for the iOS-first /orientation path (including fallback if available), or post an exact blocker with the attempted command, provider response, and recovery path. The current live proof covers Android /rotation only and cannot validate the iOS-specific transport or --provider-no-resign-app surface.

@leovarmak

leovarmak commented Aug 1, 2026

Copy link
Copy Markdown
Author

Got a real hosted-iOS session, so the blocker is retired — iOS evidence below, and it happens to validate the fallback classifier on a real device rather than only in unit tests.

BrowserStack iOS evidence

Device iPhone 15, ios 26.4, App Automate. Session e505553b…, XCUITest driver.

The iOS-first ordering behaves as designed. Every orientation call went to /orientation and /rotation was never attempted — grep -c "POST /rotation" over the session's Appium log returns 0.

Portrait succeeded:

[HTTP] --> POST /orientation
[HTTP] {"orientation":"PORTRAIT"}
[debug] [WD Proxy] Matched '/orientation' to command name 'setOrientation'
[debug] [WD Proxy] Got response with status 200: {"value":null, …}
[HTTP] <-- POST /orientation 200 61 ms - 76

Landscape was refused by the driver, because the app under test pins portrait:

[HTTP] --> POST /orientation
[debug] [WD Proxy] Matched '/orientation' to command name 'setOrientation'
[WD Proxy] Got response with status 500:
  {"value":{"error":"unknown error","message":"Unable To Rotate Device"}}

Surfaced to the caller as Appium error: … Original error: Unable To Rotate Device, which is the correct outcome — a portrait-locked app genuinely cannot rotate.

This is also the fallback classifier proving itself in the wild. That 500 is exactly the shape the earlier catch-everything implementation would have swallowed: it would have retried /rotation, and on an XCUITest driver that has no such route the caller would have received "rejected both endpoints" with Unable To Rotate Device discarded. With the structured classifier the 500 is not an unsupported-endpoint signal, so no second transport is attempted and the driver's own message reaches the user. 0 rotation calls in the log is the direct evidence.

So the transport matrix is now covered on real devices both ways:

Backend Endpoint used Result
Android / UiAutomator2 POST /rotation {x:0,y:0,z:0} 200 {"value":"ROTATION_0"}
iOS / XCUITest POST /orientation {"orientation":"PORTRAIT"} 200 {"value":null}
iOS / XCUITest, portrait-locked app POST /orientation LANDSCAPE 500 Unable To Rotate Device, surfaced, no fallback

@leovarmak

Copy link
Copy Markdown
Author

Thank you so much for the contribution! How’s the experience?

@thymikee Genuinely good, and the reason this PR exists — I hit both bugs driving a real app on BrowserStack.

What worked:

  • The daemon's worktree-scoped state dir. A released agent-device on PATH and a source checkout ran side by side for days without colliding, which made "is this fixed in my build?" trivial to answer.
  • snapshot -i plus @ref for driving an unfamiliar app — finding the login control was a snapshot and a ref, not coordinate guessing.
  • Versioned CLI help really is the source of truth; help connect and help dogfood answered more than the docs site.
  • The failure that started this was self-diagnosing: Appium's Unknown mobile command "rotate" enumerates every command the driver does support, which made the fix obvious the moment the error surfaced.

Friction worth naming:

  • orientation was declared partial in the capability table with no transport behind it, so it presented as a runtime failure on a real device instead of an unsupported-command error at the boundary. A capability claiming support it does not have is worse than one honestly marked unsupported.
  • connect deferring lease allocation surprised me. It reports lease pending and creates nothing provider-side, so the BrowserStack dashboard stays empty until open — correct, but I spent a while convinced my credentials were wrong.
  • Contributing was smoother than the layer count suggests. docs/agents/cli-flags.md turning a fourteen-layer flag change into a checklist, and the gates — fallow, layering, the progress model — catching real omissions in my own diff rather than style nits, made the difference between guessing and knowing.

@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member

Re-reviewed unchanged head 8999144: the new BrowserStack iOS evidence closes the live-device blocker. XCUITest accepts portrait through the iOS-first /orientation endpoint, while the portrait-locked landscape request surfaces its 500 unchanged and the provider log records zero /rotation calls. That is the intended real-provider proof that a genuine driver failure does not fall through to the alternate endpoint.

Remaining readiness work is CI only: no checks are reported for this exact head and merge state is unstable. Please rerun/attach the exact-head gates; once green, no further review work is requested.

@leovarmak

Copy link
Copy Markdown
Author

@thymikee Can you approve CI workflow please?

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.

2 participants