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
- Auto-upload the APK — agent fetches your stored APK from MinIO and uploads it to the vendor at job time. No manual
curlto/app-automate/upload. - Credentials from the vault — vendor keys live once in the environment credential vault, referenced by tag.
- Vendor-specific capability blocks —
bstack:optionsfor BrowserStack,sauce:optionsfor Sauce Labs are built automatically with the right structure. - 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:
| Field | Value |
|---|---|
| Cloud provider | BROWSERSTACK |
| 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:
- Download the APK from MinIO
- Upload it to
https://api-cloud.browserstack.com/app-automate/upload - Connect to
https://hub-cloud.browserstack.com/wd/hubwithbs://<uploaded-hash>as the app capability - 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:
| Aspect | BrowserStack | Sauce Labs |
|---|---|---|
| Credential mapping | username + accessKey | username + accessKey (same) |
| Upload endpoint | /app-automate/upload | /v1/storage/upload |
| App reference shape | bs://abc123... | storage:filename=app.apk |
| Hub URL | hub-cloud.browserstack.com | ondemand.{region}.saucelabs.com |
| Options block | bstack:options | sauce:options |
| Regions | one global | us-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 set | What happens |
|---|---|
cloud_config.credentialTag: "prod-bs" | Explicit — use that vault entry |
No credentialTag, env has a defaultCredentialTag | Default — use the env's default credential |
No credentialTag, env has a credential with isDefault: true | Default — use that one |
| Neither tag nor default exist | Agent 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 consolevideoUrl— session recordingdeviceLogsUrl,appiumLogsUrl,terminalLogsUrl— device / Appium / terminal logsnetworkLogsUrl— network logs in HAR formatbsStatus—passed/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:
| Service | Approx 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
.ipahas to be uploaded and itsbs://URL stored in the app'scloud_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
- Self-hosted (T2) — keep data in your infra, avoid vendor lock
- Credentials — full vault setup walkthrough
- YAML format — action reference