Skip to content

Host workloads

Claimed by an executor process on a Mac, with "executor": "host", and driving a device from outside over adb or simctl. Installing an APK or tapping through a UI test is not something an app can do to itself.

The executor runs wherever the devices are physically attached. See Deploy for where it lives and what it needs on PATH.

enrol

Tell every attached device which collector to talk to, without anybody typing an address.

{ "workload": "enrol", "executor": "host",
  "params": { "url": "http://fleet-host.local:8788" } }

The enrolment screen has always said the hard part is typing an address on a touch keyboard without a typo. A QR code answers that for a phone, because a phone has a camera. It answers nothing for a television, which has neither a camera nor a keyboard worth using, and a Roku remote has no text entry beyond an on-screen grid.

There are two ways out: the device finds the brain, or the brain reaches the device. Discovery is the first. This is the second, and it is the one that still works when multicast is blocked — which is guest wifi, most offices, and every Docker bridge network.

Each driver knows its own mechanism:

Driver How
adb am start -S --es base_url <url> — the Android runner has read this since the shelf was built
simctl simctl launch with SIMCTL_CHILD_FLEET_URL
devicectl devicectl device process launch --payload-url fleetrunner://join?url=…
roku POST /launch/dev?fleet_url=<url> over ECP

It verifies, because all four fail silently

This is the whole reason the workload is more than a shell script. Every one of those mechanisms reports success for a device that did not enrol: am start exits 0 and prints Error type 3 when the package is not installed; a simctl launch succeeds against an app that ignores its environment; an ECP launch returns 200 for a channel that then cannot reach the address it was handed.

So a device's result row is "it registered with that collector within the window", or the reason it did not — and the reason names which half failed, the launch or the registration after it. Ninety seconds by default, params.wait_s to change it.

Registration is detected by watching for a device id that was not in the registry before. Not by name: the runner chooses its own id and nothing outside the device can predict it — a Roku's is GetChannelClientId(), a per-publisher value ECP does not expose. The cost is that an unrelated device registering during the window would be credited to this enrolment, which on a shelf being deliberately enrolled is a fair trade for a check that works at all.

A loopback address is refused

Omitting params.url uses the collector this executor claims from, which is usually right. If that is 127.0.0.1, the job fails immediately rather than enrolling a shelf onto an address no other device can reach — a failure that otherwise looks like a network problem for days. Pass the LAN or tailnet address explicitly.

Status: the launch path has never been run against a device. No Android device was attached and no simulator was booted on the machine this was written on. What is tested is every path that decides not to enrol: the loopback refusal, no targets, a driver with no mechanism, and one device's failure not stopping the others.

The Apple half is closer than that suggests: the runner reads FLEET_URL from its launch environment and handles fleetrunner://join, and both the iOS and tvOS apps were built and their Info.plist confirmed to register the scheme. What has not happened is a device being launched with either.

install

One artifact onto every attached device — adb install on Android, simctl/devicectl on iOS.

{ "schema": 1, "job_id": "install-903", "workload": "install", "executor": "host",
  "app": { "name": "your-app-android", "build": "903", "sha256": "latest" } }

"sha256": "latest" resolves to the newest build published for that app name. That is the mechanism that stops a nightly testing a build older than the code it guards — see Publish on merge.

With nothing attached, the job is claimed and fails cleanly with no android targets attached, which is the correct answer rather than a hang.

upgrade-test

Does the version users already have survive becoming this one? Almost no app project automates this, and it is the failure that actually loses people. A clean install passes every suite in the fleet; the path nobody runs is a real user with two years of data taking an update and the migration dropping a table.

{ "schema": 1, "job_id": "upg-1", "workload": "upgrade-test", "executor": "host",
  "app": { "name": "app-android", "build": "latest", "sha256": "latest" },
  "params": { "from_sha256": "<the build users have>",
              "seed_flow": "seed-account", "verify_flow": "account-survived",
              "app_id": "com.example.app" } }

Five steps: install the old build, seed it with a flow, install the new build over it, launch, verify. Step three is the one that has to be right — adb install -r and simctl both upgrade in place and keep the sandbox, and an uninstall between the two would make this an install test with extra steps that passes forever.

The stage is on every row and is the point of the row. A failure at install-old or seed is the old build's problem and says nothing about the upgrade; at upgrade it is packaging, a signature mismatch or a downgraded versionCode; at launch it is a migration that crashes on start; at verify it upgraded, it launched, and the data is wrong. "upgrade-test failed" without the stage sends somebody to read the wrong logs.

from_build must already be resolved to a hash. The executor deliberately does not resolve build names — the collector owns publish ordering, and a workload guessing which artifact "1.4.0" meant could pick a different one than the dashboard shows.

size-report

The cheapest workload here. No device, no install, no toolchain: it fetches an artifact a build job published and reads its zip central directory. So it can run on every push forever, and the value is the trend — a graph of download size per build answers "when did this get big" months later, which nobody can answer retrospectively without having measured all along.

{ "schema": 1, "job_id": "size-1", "workload": "size-report", "executor": "host",
  "app": { "name": "app-android", "build": "latest", "sha256": "latest" } }

Three numbers, because one misleads:

Metric What it is
artifact_bytes the archive: what CI publishes and the store holds
download_bytes the sum of compressed entries: roughly what a user waits for
installed_bytes everything unpacked: roughly what it occupies on the device

They differ by a lot — native libraries compress well and resources do not — and quoting one when somebody meant another is the usual way a size report misleads.

Native libraries group per ABI rather than as one lib/. lib/ being huge tells you nothing you did not know; lib/arm64-v8a being 18 MB of it tells you what to split.

ui-test

Maestro flows or an XCUITest bundle, per device, with the JUnit report parsed back into results and uploaded as an artifact.

{ "workload": "ui-test", "executor": "host",
  "app": { "name": "your-app-android", "build": "903", "sha256": "latest" },
  "suite": { "kind": "maestro", "flows": "your-app/smoke.yaml" },
  "targets": { "exclusive": true },
  "lease": { "ttl_s": 1200 } }

Flows resolve relative to flows/ (FLEET_FLOWS_DIR to change). exclusive takes a device lock for the run, because two suites tapping the same phone is not a test.

iOS UI tests need a Mac with full Xcode. simctl and devicectl ship with Xcode, not the Command Line Tools, so they run on a different executor from the Android work. The iOS executor covers standing one up.

cold-start

Launch the installed build from cold, warm and hot; p50 and p95 per state, per device.

Metrics: launch_ms, launch_state.

Cold-start on iOS is not offered: simctl returns at process spawn rather than at first frame, so the number would measure the wrong thing.

app-soak

Memory, jank and crashes over hours.

Metrics: pss_mb, jank_pct, crashes, app_state.

No PSS on an iOS simulator — only host RSS is available there, which counts shared pages Android's PSS does not, so the two are not the same quantity and the workload says so rather than reporting one as the other.

a11y-audit

The accessibility tree diffed against a baseline, at the largest dynamic type.

Bold text below Android 12 is refused: the setting writes and nothing reads it, so a pass would prove nothing.

locale-shots

A screenshot flow under every locale, including RTL, bundled as a store-ready contact sheet.

Metrics: locales, shots.

The check that catches a green run that measured nothing

It fails a device when two locales produce byte-identical screenshots. An app whose locale setting never reached it is otherwise indistinguishable from a correct run: the setting reads back fine, every folder exists, and every folder is in English.

Locale is not settable on a physical iPhone, and the workload refuses rather than pretending.

web-test

Playwright suites from web-specs/ against targets.url, one result row per config project. Needs an executor started with FLEET_WEB=1.

params.browser picks the projects: one name, an array run in sequence, or "all" for everything in playwright.config.ts. The executor beacons between projects, so the lease budgets one project rather than the whole matrix.

web-shots

The capture half of visual regression. Reads web-specs/<site>/shots.json — pages with optional waitFor, mask selectors, fullPage and settle_ms, plus the profiles to capture under — screenshots every page × profile, and uploads the PNGs.

The Visual screen: two pages across two browser profiles, every cell diverged from its accepted baseline by around one to two percent after a theme colour changed

Each capture is diffed with pixelmatch on the executor, because baselines are only comparable to pixels rendered by the same host. A page over its threshold_pct (default 0.1%) fails with diff_pct and a diff-image artifact. A page with no baseline passes with a "new: no baseline" note until somebody accepts a shot.

Real phone screens

Two profile names expand to real hardware attached to the claiming executor, one profile and one baseline per device, because two phones have two screens:

Meta-name Becomes How
android-device android:<serial> per Android device Real Chrome driven via Playwright over adb
ios-sim-safari ios-sim:<name> per booted simulator Safari via simctl openurl, status bar pinned to 9:41

A meta-name that finds no hardware fails its slot rather than quietly shrinking the matrix. Pin device captures to the executor whose shelf holds the devices.

Real-iPhone Safari has no capture path; a WKWebView inside the runner app covers it instead, and the profile is named webkit rather than safari because a WKWebView has no reader mode and no content blockers, and somebody reading "safari" in a baseline matrix would believe a stronger claim than the capture supports.

web-audit

Crawls targets.url with a real browser and audits every rendered page: titles, descriptions, canonicals and their site-wide duplicates, h1s, JSON-LD validity, redirect chains, broken internal links with who links them, bounded external-link checks, sitemap-versus-crawl diff, robots.txt sanity. Then re-renders each page under a phone profile for viewport meta, content overflow, tiny text and tap targets.

A real browser rather than fetch, because these are single-page apps and fetch would bless a blank body.

Metrics: pages_crawled, issues_error, issues_warn. Error-severity findings fail the run; warnings land in the report artifact.

web-unfurl

Fetches the raw HTML the way link-preview bots do — no JavaScript — under several bot user-agents, and validates og and twitter tags plus the og:image itself.

This exists because Open Graph tags injected client-side unfurl as nothing on every platform, and a browser-based check can never see that bug. It needs the raw HTML, which is the opposite of what the rest of the web auditing does.

drain

Battery curve under a replayed GPX track.

Metrics: drain_pct_per_h, plus the per-sample curve.

Long-running, so the lease TTL defaults to 14400 s. Pairs with the smart-plug energy support to unplug a pool before a run.

soak

Whether a runner is still alive hours later — the per-check process-alive timeline per device.

archive

Pulls data the vendor will eventually delete, into the artifact store, where it is kept forever.

  • source: "gsc" — one finalized day of Search Console data. Google keeps 16 months.
  • source: "asc" — App Store Connect reviews, via an ES256 API key.
  • source: "play" — Play Console reviews.

Play returns roughly the last seven days of reviews and nothing older, so the review pulls must run daily. A lazy cadence loses data permanently.

Credentials are Keychain items on the executor host. The job spec names the account only, never the secret — POST /jobs is unauthenticated, so a spec is not a place a credential could safely live. Until the Keychain item exists the job fails with instructions rather than silently producing nothing.

digest

The weekly payoff, and the fleet eating its own cooking.

The executor gathers the week's archived reviews, dedupes by review id against the previous digest's watermark, and farms the LLM work to the shelf as ordinary batch jobs: one pass classifying every review against a fixed topic taxonomy, deterministic clustering in code between the passes, one pass summarising each cluster. Then it assembles a markdown digest with real quotes chosen in code, never generated.

Devices are matched with ram_mb >= 4000 and require_charging, and the job's model names the GGUF the shelf runs.