Skip to main content

Hosted Device Farms

Tier 4 — runs mobile tests on real devices in BrowserStack App Automate or Sauce Labs Real Device Cloud. Best for iOS coverage without owning Macs, real-device behavior (sensors, biometrics, OEM-specific bugs), and burst capacity.

The platform handles vendor-specific quirks (auth, APK upload, capability blocks) automatically. Same YAML, same Job History.

What the platform does for you

  1. Auto-upload the APK — agent fetches your stored APK from MinIO and uploads it to the vendor at job time. No manual curl to /app-automate/upload.
  2. Credentials from the vault — vendor keys live once in the environment credential vault, referenced by tag.
  3. Vendor-specific capability blocksbstack:options for BrowserStack, sauce:options for Sauce Labs are built automatically with the right structure.
  4. Region-aware URLs — Sauce Labs hub URL is templated by region (us-west-1, eu-central-1, apac-southeast-1).

Quick start: BrowserStack

1. Store credentials in the vault

In your environment's auth config, add a credential set:

{
"credentials": [{
"tag": "prod-browserstack",
"username": "your-bs-username",
"password": "your-bs-access-key",
"isDefault": false
}]
}

The password field carries the access key (semantically the secret). Encrypted at rest with AES-GCM.

You can have multiple credential sets — e.g. dev-browserstack and prod-browserstack. Set one as the default with defaultCredentialTag on the environment if you want apps to use it implicitly.

2. Configure the mobile app

In Configuration → Mobile Apps, edit your app:

FieldValue
Cloud providerBROWSERSTACK
Cloud config (JSON)See below
{
"credentialTag": "prod-browserstack",
"deviceName": "Google Pixel 7",
"osVersion": "13.0",
"projectName": "MyApp Tests"
}

That's it. No appUrl, no userName, no accessKey in the cloud config — the platform resolves the tag from the vault at job time.

3. Make sure your APK is uploaded

Through Mobile Apps → Upload Binary (the existing flow). The platform stores it in MinIO.

4. Run a test

Any existing mobile test against this app. The agent will:

  1. Download the APK from MinIO
  2. Upload it to https://api-cloud.browserstack.com/app-automate/upload
  3. Connect to https://hub-cloud.browserstack.com/wd/hub with bs://<uploaded-hash> as the app capability
  4. Run the test on the requested device

Watch the agent log:

DF.1.c — using resolved cloud_config from vault (provider=BROWSERSTACK)
DF.1.b — no appUrl in cloud_config; auto-uploading APK to BrowserStack
DF.1.b — BrowserStack upload OK, app_url=bs:**...***
Creating BrowserStack driver: hub=https://hub-cloud.browserstack.com/wd/hub, device=Google Pixel 7

The test then runs on the device and reports back to Job History as usual.

5. (Optional) Pre-uploaded apps for faster startup

Per-job upload adds ~5 seconds. For high-frequency CI, upload once via the BrowserStack REST API and put the returned bs://... URL in cloud_config.appUrl:

{
"credentialTag": "prod-browserstack",
"appUrl": "bs://abc123def456",
"deviceName": "Google Pixel 7"
}

When appUrl is set, the agent skips the auto-upload step. Update the URL when the APK version changes.

Quick start: Sauce Labs

Same shape as BrowserStack. Differences:

AspectBrowserStackSauce Labs
Credential mappingusername + accessKeyusername + accessKey (same)
Upload endpoint/app-automate/upload/v1/storage/upload
App reference shapebs://abc123...storage:filename=app.apk
Hub URLhub-cloud.browserstack.comondemand.{region}.saucelabs.com
Options blockbstack:optionssauce:options
Regionsone globalus-west-1 (default), eu-central-1, apac-southeast-1

Cloud config example

{
"credentialTag": "prod-saucelabs",
"region": "eu-central-1",
"deviceName": "Google Pixel 7",
"platformVersion": "13.0"
}

That's all. The agent will:

  • Upload APK to https://api.eu-central-1.saucelabs.com/v1/storage/upload
  • Connect to https://ondemand.eu-central-1.saucelabs.com/wd/hub
  • Run the test on the requested device

Credential pattern: default vs choose

The platform follows the same vault precedence pattern as API scenarios:

What you setWhat happens
cloud_config.credentialTag: "prod-bs"Explicit — use that vault entry
No credentialTag, env has a defaultCredentialTagDefault — use the env's default credential
No credentialTag, env has a credential with isDefault: trueDefault — use that one
Neither tag nor default existAgent passes through; provider validation surfaces "missing userName"
Inline userName + accessKey set (legacy)Pass through unchanged

Most teams set one credential per environment + flag it as default. Then mobile apps just need { "deviceName": "..." } — no credential boilerplate.

Switching an existing test

Already running on T1 (local) and want to graduate to a real device? Only change cloud_provider:

Cloud provider: NONE → BROWSERSTACK
Cloud config: {} → {
+ "credentialTag": "prod-browserstack",
+ "deviceName": "Google Pixel 7"
+ }

The YAML doesn't change. The agent automatically:

  • Downloads your APK from MinIO (already there from T1 setup)
  • Uploads it to BrowserStack
  • Connects to the right hub
  • Runs the test
  • Reports results to the same Job History

Device matrix (multiple devices, in parallel)

Run one test across several devices in a single call — the platform fans out one job per device and runs them concurrently. Pass a devices list to run_mobile_test (or POST /api/mobile-tests/{id}/executions):

run_mobile_test(
mobile_test="Swag Labs Login",
environment="development",
devices=[
{"device": "Google Pixel 7", "osVersion": "13.0"},
{"device": "Samsung Galaxy S22", "osVersion": "12.0"}
]
)

Accepts {device, osVersion} objects or bare device-name strings. The per-run device overrides the app's configured device for that job only. The response returns a matrixRunId plus one jobId per device:

{
"matrixRunId": "37f7a3c2-...",
"deviceCount": 2,
"jobs": [
{"device": "Google Pixel 7", "osVersion": "13.0", "jobId": "b676d524-..."},
{"device": "Samsung Galaxy S22", "osVersion": "12.0", "jobId": "bf579599-..."}
]
}

Poll each jobId for its per-device result. Omit devices for a single-device run (device comes from the app, unchanged).

Concurrency is bounded by the agent's MAX_CONCURRENT_JOBS (default 3 for the mobile agent). N devices above that slot count queue and run as slots free up. It is also bounded by your vendor's parallel-session limit — e.g. a 1-parallel BrowserStack plan will serialize the sessions regardless of agent slots.

Debug artifacts, profiling & dashboard status

Every BrowserStack run now brings its session telemetry back with the result, under summary.browserstack on each job's result:

  • dashboardUrl — deep link to the session in the BrowserStack console
  • videoUrl — session recording
  • deviceLogsUrl, appiumLogsUrl, terminalLogsUrl — device / Appium / terminal logs
  • networkLogsUrl — network logs in HAR format
  • bsStatuspassed / failed, also written back to the BrowserStack dashboard so runs are labelled and filterable in their console (the session name is the test name)
  • appProfiling — app performance metrics: CPU, memory, battery, UI-rendering FPS, app-start time, screen-load time, app size

These are URLs, not inlined blobs — fetch the video/logs/HAR on demand. App profiling is enabled by default (bstack:options.appProfiling); to turn it off, set appProfiling: false in your app's bstackOptions.

Watching a run back

Ask the platform for a run and it fetches the links from the device farm at that moment:

curl -s $API/api/mobile-tests/runs/{jobId}/artifacts -H "Authorization: Bearer $TOKEN"
{
"jobId": "993b513d-...",
"sessionId": "0d5a359e...",
"status": "failed",
"reason": "An element could not be located on the page using the given search parameters.",
"device": "Google Pixel 7",
"osVersion": "13.0",
"videoUrl": "https://app-automate.browserstack.com/sessions/.../video?token=...",
"deviceLogsUrl": "...",
"appiumLogsUrl": "...",
"dashboardUrl": "..."
}

reason is the device farm's own account of the failure — the app crashed on launch, the device disconnected, no device was ever allocated. It knows things a test result cannot.

To jump straight to the recording, GET /api/mobile-tests/runs/{jobId}/video redirects to it.

These are fetched, not stored. The recording stays on BrowserStack under their retention; we keep the session id. That matters because the links themselves are short-lived — the video URL carries a token and the log link expires after a week — so a link you saved last month will not work, while asking again will.

A run outlives the test. Deleting a mobile test does not delete its history, and its recordings stay reachable by jobId.

Running iOS

iOS works the same way Android does — the platform reads which kind of session to create from the app's platform and dispatches accordingly.

Two things differ:

Give it an .ipa, not an .apk, and it must be a build signed for device-farm distribution. A store build cannot be installed on a farm device. Upload it and put the resulting bs:// URL in the app's cloud_config.appUrl — iOS builds are not auto-uploaded for you yet.

The device defaults are different. A Pixel is not a valid iOS device, so an iOS app config needs its own deviceName and osVersion — e.g. iPhone 14 and 16.

{
"provider": "BROWSERSTACK",
"appUrl": "bs://<your uploaded ipa>",
"deviceName": "iPhone 14",
"osVersion": "16"
}

Permission prompts are handled for you: iOS auto-accepts system alerts during the session, which is the closest equivalent to Android's auto-granting at install time. Override it with appium:autoAcceptAlerts if a test needs to see the prompt.

Selectors behave slightly differently on iOS — see Matching on text.

Available on BrowserStack and local Appium. Sauce Labs and self-hosted device pods refuse an iOS run with a message naming the tier, rather than booking a device and failing on it.

Custom Appium capabilities / bstack:options

Set appiumCapabilities on the mobile app (or pass appiumCapabilities on a run to override for that run only — per-run wins) to forward any Appium cap or bstack:options to the session. This unlocks, with no extra platform code:

  • Geolocation (bstack:options.gpsLocation), language/locale, timezone
  • Network simulation — 2G/3G/offline/throttle
  • Biometric / passcode injection, camera image injection, device orientation

Example (app-level):

{
"appiumCapabilities": {
"appium:language": "fr",
"appium:locale": "FR",
"bstack:options": { "gpsLocation": "48.8566,2.3522", "networkProfile": "3g-good" }
}
}

Cost sanity check

For ~1000 mobile-test runs / month, 5 min average:

ServiceApprox monthly cost
BrowserStack ($199/parallel/mo)~$200 — fine for low volume
Sauce Labs (similar tier)~$199
T2 self-hosted on AWS c5.metal~$500 + ops

Above ~3 hours/day of test execution, T2 self-hosted becomes more cost-effective. Below that, vendors win on cold-start and zero ops.

What's NOT supported yet

  • iOS on Sauce Labs and self-hosted — iOS runs on BrowserStack and on local Appium. The other two tiers refuse an iOS run by name rather than booking a device and failing on it.
  • iOS auto-upload — an .ipa has to be uploaded and its bs:// URL stored in the app's cloud_config.appUrl. The agent does not upload iOS builds for you yet, as it does for Android.
  • AWS Device Farm — different execution model (async, not real-time WebDriver). On the roadmap. Setting it on an app today refuses the run and says so; it does not quietly fall back to a local emulator (see below).
  • URL caching — every job re-uploads the APK. Future optimization caches the bs://... URL on the mobile app after first upload.

A farm we cannot drive is refused, not quietly run somewhere else

If an app names a device farm this build has no driver for, the run fails with MOBILE_PROVIDER_UNSUPPORTED, naming the farm and listing what can actually run.

It used to fall back to a local emulator with a warning in the log. That meant choosing a real-device farm could produce a green run on a developer's laptop, with nothing in the result saying it had never gone near a device — and which hardware the test touches is the whole reason for picking a farm. A loud failure is the only honest answer.

The agent prints what it can run when it starts:

AppiumDriverFactory ready — providers: [BROWSERSTACK, NONE, SAUCELABS, SELF_HOSTED]

That list is built from the drivers actually present, so it tells you the truth for the build you are running. Adding a farm is one new driver class; it appears in that list the moment it exists.

Troubleshooting

"BrowserStack 401 Unauthorized"

Usually the vault credential is wrong — check the tag, username and password against the BrowserStack dashboard.

But check this first: if the access key the agent sent looks like ***, the credential is fine and the agent's identity is the problem. The device-farm account is only released to the test agents' own account. An agent configured to log in as an ordinary user — even an admin — receives the masked value and the farm rejects it. Agents authenticate as a dedicated service account; if yours does not, that is the fault.

"credentialTag 'X' not found in environment N's credential vault"

The error message lists available tags. Either fix the tag name in cloud_config or add the missing entry to the env vault.

"No appUrl in cloud_config and no app_path on mobile app"

Either upload an APK first via Upload Binary, or set cloud_config.appUrl to a pre-uploaded vendor URL.

Test runs but device behaves oddly

Check the bstack:options debug capability is on (it's default-true in our config). View the session video in the BrowserStack dashboard.

Next steps