ai-mobile-tester
# ai-mobile-tester
An MCP server that lets AI assistants (Claude Code, Cursor, Windsurf, …) write and run **mobile UI tests by natural language** — **Android and iOS simulators**, **native and WebView/hybrid** — with no Appium and no Chromedriver. Android drives over ADB; iOS simulators over `xcrun simctl` + WebDriverAgent, and their WKWebViews over Apple's Web Inspector protocol. You describe a test in plain English; the assistant captures the screen, authors a durable **YAML flow**, then replays it **deterministically** with **self-healing** locators (native) and an HTML report.
One flow can target both platforms: leave `platform:` out and it runs on Android by default, or on iOS when given a simulator UDID (`device_id` for `run_flow`, `--device` for the CLI), with `when: { platform }` for the steps that genuinely differ. A flow that only ever runs on iOS declares `platform: ios` instead, and then uses the only booted simulator on its own. **iOS is simulators only** — no physical-device support.
## How it works
1. **Observe** — the assistant captures a compact, token-frugal snapshot of the screen — `observe_ui` (native) or `observe_webview` (WebView) — with stable selectors and `[ref]` handles.
2. **Author** — it writes a **YAML flow** (tap / input / assert / scroll steps) from those selectors. You keep the flow file.
3. **Validate** — `validate_flow` lints the flow offline (schema, fragile selectors, undefined variables) — no device needed.
4. **Run** — `run_flow` executes it **deterministically** on the device (no AI per run), **self-heals** drifted native locators, and writes an **HTML report**.
The YAML flow is the durable artifact: author once with the assistant, then replay forever (CI, daily, …) at zero AI cost and with no drift.
## Prerequisites
- **Node.js 18+**
- **For Android:** the **Android SDK Platform Tools** — `adb` in your PATH ([download](https://developer.android.com/tools/releases/platform-tools)) — and a device with USB debugging enabled, or a running emulator
- **For iOS:** a Mac with the full **Xcode** and an iOS simulator runtime — see [ios-testing.md](docs/ios-testing.md#prerequisites). Running an iOS flow needs no Android SDK, but `list_devices` fails without `adb` (find a simulator's UDID with `xcrun simctl list devices` instead), and `init` warns that `adb` is missing.
- **For WebView / hybrid apps:** the app's WebView must opt in to remote debugging — on Android `WebView.setWebContentsDebuggingEnabled(true)` (debug builds usually have it), on iOS `WKWebView.isInspectable = true` (iOS 16.4+; Safari needs no opt-in). iOS also needs the optional `appium-remote-debugger` client — see [ios-testing.md](docs/ios-testing.md#webview-testing-on-ios)
## Install & set up
```bash
npm install -g ai-mobile-tester
npx ai-mobile-tester init
```
The wizard checks `adb`, registers the MCP server with **Claude Code** (via `claude mcp add`, writing `~/.claude.json`) and **Claude Desktop** (its `claude_desktop_config.json`, if installed), and installs the `/run-test` slash command. Restart Claude Code / Claude Desktop and the tools are available.
### Manual / advanced MCP configuration
`init` is the easy path, but you can register the server by hand for any MCP client. The server runs on stdio via the **`serve`** subcommand:
```jsonc
{
"mcpServers": {
"ai-mobile-tester": { "command": "npx", "args": ["-y", "ai-mobile-tester", "serve"] },
},
}
```
For Claude Code you can also run `claude mcp add --scope user ai-mobile-tester -- ai-mobile-tester serve`. For the most stable setup, install globally (`npm i -g ai-mobile-tester`) and point at `ai-mobile-tester serve` (or the absolute `node <prefix>/dist/index.js` that `init` writes) — the `npx` form re-resolves from a cache that npm can garbage-collect.
## Example — a native flow
```yaml
appId: com.example.app
env:
TEST_USER: "you@example.com"
TEST_PASSWORD: "" # keep the secret out of the file; pass it at run time (--env TEST_PASSWORD=…)
---
- launchApp
- tapOn: { id: login_button }
- inputText: { into: { id: email }, text: "${TEST_USER}" }
- inputText: { into: { id: password }, text: "${TEST_PASSWORD}" }
- tapOn: "Sign in"
- assertVisible: "Welcome"
```
## Example — a WebView flow
```yaml
appId: com.example.shop
env:
TEST_USER: "you@example.com"
TEST_PASSWORD: "" # keep the secret out of the file; pass it at run time (--env TEST_PASSWORD=…)
---
- launchApp
- switchContext: "WEBVIEW_com.example.shop@shop.example.com"
- assertVisible: { css: "#email" }
- inputText: { into: { css: "#email" }, text: "${TEST_USER}" }
- tapOn: { css: '[data-testid="signin"]' }
- assertVisible: { css: ".order-summary" }
- switchContext: NATIVE_APP
```
Validate offline, then run it:
```
/run-test path/to/flow.yaml
```
…or just ask the assistant to run it.
## Run in CI (no AI)
Once a flow exists, run it headlessly — no Claude, no MCP, just a device (Android through `adb`, or a booted iOS simulator):
```bash
ai-mobile-tester run flow.yaml --junit results.xml # exit 0 pass · 1 fail · 2 could-not-run
ai-mobile-tester validate flow.yaml # offline pre-check
```
Portable to any CI (GitHub Actions, MacStadium, GitLab, …). See **[docs/ci-runner.md](docs/ci-runner.md)**.
## Documentation
- **[Authoring UI tests — the full workflow](docs/authoring-tests.md)** — **start here.**
- **[YAML flow format reference](docs/yaml-flow-format.md)** — every command and selector.
- **[Waits, timeouts and `dismiss:`](docs/waits-and-timeouts.md)** — which commands poll, what `optional` and `timeoutMs` really cost, and the `dismiss:` rules.
- **[When the screen never idles](docs/screen-never-idles.md)** — `ERROR: could not get idle state`: what it means, how to confirm it, and the escape hatches.
- **[Testing WebView / hybrid apps](docs/webview-testing.md)** — drive WebViews by CSS selector, on both platforms.
- **[Testing iOS apps (simulators)](docs/ios-testing.md)** — prerequisites, WebDriverAgent, divergences from Android, and the WebView setup.
- **[Compose testability](docs/compose-testability.md)** — make Jetpack Compose elements addressable with `testTag`s.
- **[Running flows in CI](docs/ci-runner.md)** — exit codes, secrets, JUnit, a portable recipe.
## MCP tools
**Flow engine:**
| Tool | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `observe_ui` | Compact, token-frugal snapshot of the native screen (actionable elements get a `[ref]`) |
| `describe_element` | Every attribute of the element(s) a selector matches — the state flags `observe_ui` hides, plus exact bounds and the `index:` of each match |
| `observe_webview` | Lists the app's WebView pages and returns a compact DOM snapshot of the richest, by CSS selector |
| `webview_tap` | Tap a WebView element by css/text, then return the updated DOM (interactive observe→act→observe) |
| `webview_input` | Type into a WebView field by css/text, then return the updated DOM |
| `input_text` | Type into a field (optionally focus it by selector first) |
| `validate_flow` | Statically validate a YAML flow (schema + lints), no device needed |
| `run_flow` | Run a validated YAML flow deterministically; self-heals native locators; writes an HTML report |
| `check_testability` | Report Compose `testTag` coverage so elements are addressable |
**Device & app:** `list_devices`, `device_info`, `connect_device`, `launch_app`, `install_apk`, `uninstall_app`, `clear_app_data`, `force_stop`.
**Interaction:** `tap`, `tap_xy`, `tap_element`, `long_press`, `swipe`, `scroll_down`, `scroll_up`, `type_text`, `press_key`.
**Observation & assertions:** `take_screenshot`, `dump_ui`, `describe_element`, `find_element`, `get_current_activity`, `is_element_visible`, `assert_visible`, `assert_not_visible`, `assert_text`, `wait_for_element`, `wait_for_text`, `wait_for_activity`.
## Development
```bash
git clone <your-fork> ai-mobile-tester && cd ai-mobile-tester
npm install
npm run build
npm test # the test suite
npm run lint && npm run format:check
```
## What's new in 3.0.0
- **Cross-platform flows:** write one YAML flow that runs on Android _or_ iOS. Neutral `pressKey`
names (`ENTER`, `TAB`, `DELETE`, `HOME`, …) and friendly permission names (`camera`, `microphone`,
`location`, …) resolve to the right platform-native key automatically, and a `when: { platform }`
clause on `runFlow` lets a shared flow branch for a platform-specific step. See
[yaml-flow-format.md](docs/yaml-flow-format.md) and [ios-testing.md](docs/ios-testing.md).
## What's new in 3.1.0
- **WebView taps dispatch real touches:** `tapOn` in a WebView now sends a real trusted CDP touch event instead of a synthetic `click()`. Elements must be unoccluded to actuate. After a WebView tap that should launch a native screen, use the new `assertActivity` command (Android-only) to verify the Activity transition happened — a content-only `assertVisible` can pass even if the native transition failed.
## What's new in 3.2.0
- **`dismiss:` auto-dismisses async dialogs.** A config-level list of selectors was checked at every step boundary (native only, skipped inside a WebView context) and the first visible match tapped — for permission prompts, rating nags, or promo interstitials that appear unpredictably. Dismissals show up as a run-level annotation in the report header, not as numbered steps, so step indices stay diffable. (Since 3.5.0 the watcher also fires _during_ a wait, not only between steps — see below.)
- **Conditional branching:** `runFlow`'s `when:` now also accepts `{ visible: <selector> }` / `{ notVisible: <selector> }` alongside the existing `{ platform }`, plus an `else:` for the other side. Use `else`, not two adjacent `when`s — see [yaml-flow-format.md](docs/yaml-flow-format.md#when-and-else-conditional-runflow). Note: steps inside a `visible`/`notVisible`-guarded branch don't self-heal (a `{ platform }` guard still does for an inline `commands:` branch — a `file:` subflow is always strict-only).
- **`observe_ui` no longer hands back a bare empty tree** on a continuously animating screen — it retries briefly, then returns an actionable diagnostic naming the likely cause instead of silence.
## What's new in 3.3.0
- **`tap` and `input_text` wait for the screen to settle** before returning their snapshot. Previously they captured immediately after acting, so the tree you got back could still be the screen you just left, or a half-drawn one mid-transition. They now re-capture until the tree has both changed and stopped changing. When it never settles you get the newest snapshot plus a note saying which test failed — either the screen kept moving (a live animation), or it never changed at all, in which case the note says plainly that the snapshot is the screen from _before_ your action. Costs one extra UI dump per action, two when it never settles; `observe_ui` is unchanged.
## What's new in 3.4.0
- **WebView tools now work on iOS simulators.** `observe_webview`, `webview_tap`, `webview_input`, and `switchContext: "WEBVIEW_<bundle-id>@<url-match>"` drive WKWebViews over Apple's Web Inspector protocol, with the same YAML grammar as Android. Needs the optional `appium-remote-debugger` client (`npm install --no-save appium-remote-debugger`) and the target app to set `WKWebView.isInspectable = true` (iOS 16.4+; Safari needs no opt-in) — see [ios-testing.md](docs/ios-testing.md#webview-testing-on-ios) for the full setup and its honest limits (one simulator at a time, taps need exactly one on-screen WebView, the page must not be pinch-zoomed).
## What's new in 3.5.0
- **The runner no longer reports green for a run that never happened.** `uiautomator dump` prints `ERROR: could not get idle state.` **and exits 0**, and the dump file was never deleted first — so the follow-up read silently returned the _previous_ dump. A **19-minute-old tree of a different screen** was measured being served as the current UI, and selector resolution, `assertVisible`, `assertNotVisible`, `tapOn` and the self-healing locator store all consumed it. That produces **false passes as readily as false failures**. A refused dump now fails with a named cause instead of returning a tree, and `run_flow` reports it as `screen never idle` rather than as `not visible: #your_selector` — a misattribution that cost one reporter most of a session. See **[When the screen never idles](docs/screen-never-idles.md)**.
- **⚠ Runs will fail more often after upgrading, and that is the fix working — not a regression.** Two shapes of flow change colour, and both were passing on something untrue:
- A screen whose UI tree could never be read used to be tested against a stale tree from an earlier screen. Those steps passed or failed at random; they now fail honestly, with the cause named and the escape hatches listed in the error itself.
- **A flow whose `assertNotVisible` passed at 300ms because the element had not rendered _yet_ will now wait for it, find it, and fail.** That assertion was winning a race, not checking the screen. If the step means "this never appears at all", give it a short explicit budget (`assertNotVisible: { id: error_banner, timeoutMs: 1000 }`); if it was standing in for "wait for the spinner to go away", it now does what it always read like.
- **A selector that named nothing native — a `css` outside a WebView, `{ index: 0 }`, an `id`/`text` that a `${VAR}` expanded to nothing — used to match _every_ node and be answered with the first one.** `assertVisible` passed without looking, `tapOn` tapped the root node's centre, and `scrollUntilVisible` with a `css` element inside a `WEBVIEW_` context returned `passed` in 1ms having never scrolled. Every native lookup now fails with `matches any node on the native path — needs id or text`, before it takes a dump. Give the step an `id` or `text`, or move it into the WebView context where `css` belongs.
- **Waiting for something to go away is a real wait.** `assertNotVisible` polls until the element is gone instead of answering off one snapshot in ~300ms while every other lookup polled ~11s. New `waitFor: { element, state: visible | notVisible, timeoutMs? }` states a synchronisation point explicitly (and never self-heals, so the locator is taken literally). New per-step and flow-level **`timeoutMs`** — the hard-coded 10s fitted no real app. Precedence is step → flow default → 10000, and an explicit value beats `optional`'s short cap in both directions. The flow default reaches every polling wait, `assertActivity` included; `scrollUntilVisible` is the one deliberate exception, because its budget funds scroll gestures rather than one lookup. The full table, with harness-measured costs, is in **[Waits, timeouts and `dismiss:`](docs/waits-and-timeouts.md)**.
- **`dismiss:` now fires _during_ waits, not only between steps.** `tapOn`/`assertVisible` spend up to 10s polling inside a single step, so a dialog that rendered mid-poll was never dismissed and the step failed with the dialog correctly declared. The watcher now runs on every poll tick, reusing that tick's snapshot — **zero extra UI dumps** — capped at 3 dismissals per wait so a mis-aimed entry cannot become a tap storm. The rule that goes with it: never point `dismiss:` at an element an explicit step also touches.
- **Escape hatches for screens that cannot be read.** `tapXY: { x, y }` / `{ xPct, yPct }` taps a raw coordinate from YAML (percentages survive a change of device); `stopApp` and `launchApp: { forceStop: true }` give a cold start without `clearState`'s data wipe, on both platforms; and `get_current_activity` / `assertActivity` — the only assertion that never reads the UI tree — now falls back through `mResumedActivity` → `mCurrentFocus` → `mFocusedApp` → `topResumedActivity` instead of returning null on Android 16.
## What's new in 3.6.0
Three authoring diagnostics. Nothing about how a flow runs changes — this release is about what you are told while writing one.
- **A failed lookup now says what WAS on screen.** `not visible: #navigation_rebrand_explore`, after twelve seconds of silent polling, names the one thing that is not there and nothing that is — so a wrong selector, a modal covering the screen, and the wrong screen entirely all read identically. Every native miss now appends a second line listing the screen's addressable nodes:
```
not visible: #ghost
on screen (3): #email_field, #login_button, #title
```
You see it wherever the failure surfaces: inline in `run_flow`'s and the CLI's summary — 3.6.0 inlines the failing step's error for **any** failure, not only a refused dump — as well as in `report.html` and the JUnit XML. The interactive `wait_for_element` / `assert_visible` MCP tools do not carry it yet.
It reuses the snapshot the poll loop already captured, so it costs **zero extra UI dumps** — and on a screen that will not dump at all, taking another one is exactly what would fail while trying to explain the failure. Ids first, text where there is no id, both clamped to 40 characters, capped at 12 entries with identical handles collapsed (`#row_item ×30`) so a whole list screen cannot turn one failure into a token bomb (~600 characters worst case). **Read the `(N)` in the header to tell an overlay from a whole screen** — it is the true node count. A dialog or bottom sheet has few addressable nodes, fits inside the cap and is listed in full; a large `(N)` is a whole screen. Do not read the `+N more` tail that way: the collapse means a 48-node list screen shows 9 entries and no tail at all. Applies to `tapOn`, `assertVisible`, `inputText`, `waitFor: { state: visible }`, `scrollUntilVisible` and — since 3.7.0 — `capture`; **not** to `assertNotVisible`, whose failure already means the element is on screen. A WebView miss keeps its own text — with one exception, `scrollUntilVisible`, which has no WebView branch and so reports the native tree it actually searched even inside a `switchContext`.
- **New `describe_element` tool: "is this state even assertable?" in one call.** `observe_ui` renders a compact view and hides most attributes, so the only way to find out whether a control exposes its state was to drop to raw `uiautomator dump` XML. An entire test was once authored around "the heart turns red" before anyone discovered the app never reported that state at all. One call would have shown it:
```
"Favorite" — 1 match.
match index: 0
...
contentDesc: "Favorite"
checkable: false
checked: false
selected: false
...
```
(Abridged — the real block lists all 17 attributes plus bounds; `...` marks the 13 elided here.)
Nothing flips between the two states, so no assertion can be written on it — a fact worth five seconds rather than a session. `describe_element` returns **every** attribute the dump carries, plus exact bounds, and one block per match with the `index:` a flow selector would use — so an ambiguous selector shows all its candidates instead of silently picking the first. Read-only and native. It matches on `id`/`text`, and accepts the same `enabled`/`checked`/`focused`/`selected` qualifiers a flow selector does — so the `index:` it reports is the one that selector will get. See [authoring-tests.md](docs/authoring-tests.md#ask-whether-a-state-is-assertable-at-all).
- **`validate_flow` catches a password committed in `env:`.** Flow files get committed, and so do the credentials in them — this repository's own example flow carries a plaintext password, which is how the rule earned its place. `validate_flow` now warns when an `env:` default is a non-empty literal under a credential-shaped key (`password`, `passwd`, `passphrase`, `passcode`, `pwd`, `secret`, `token`, `credential`, plus `key` only in compound form like `api_key`/`apiKey`), and tells you to blank the default and pass the real value at run time. Bare `key` and bare `pass` are deliberately excluded — `KEY_CODE`, `SORT_KEY` and `BOARDING_PASS` are ordinary flow variables, and a false positive has no remedy but renaming yours. It **never** warns on a `${VAR}` reference or on the empty default [ci-runner.md](docs/ci-runner.md#secrets) tells you to commit — warning on the recommended pattern is how a validator teaches people to ignore it. A non-empty _placeholder_ is not exempt: nothing about the value is inspected, so `"changeme"` warns too. Key names only: no entropy scoring, no guessing from the value's shape, and the value is never echoed into the warning. It is a warning, not an error; by the time you read it the value is already in your history, and the follow-up is to rotate it.
## What's new in 3.7.0
- **`capture`: assert on the value that was actually on the screen.** A test that favourites _the first vehicle_ in a re-ranking list and then checks the Favorites tab could previously express nothing stronger than `assertVisible: 'View \d{4} .*'` — and on any account that already has favourites, that is **already true before the test runs**. It passed with the feature completely broken. `capture` binds a string read off the screen to a flow variable that every later step can use:
```yaml
- capture: { from: { id: toolbar_split_title }, as: VEHICLE }
- tapOn: { id: favorite_button }
- tapOn: { id: favorites_tab }
- assertVisible: { text: "View .*${VEHICLE}.*" } # the car you actually favourited
```
The full form is `{ from: <selector>, as: NAME, attr?: text | contentDesc, redact?: true }`. `from` is an ordinary selector with the same polling, `timeoutMs` budget and self-healing as `assertVisible`. `as` must match `^[A-Z][A-Z0-9_]*$`, and that is **enforced**, not advised. Omit `attr` and it reads `text`, falling back to `contentDesc` when `text` is empty; write `attr` out and it reads **only** the attribute you named — naming one and being answered with a different one is the quiet substitution this command exists to remove, so an explicit `attr: text` on an icon whose label lives in `contentDesc` fails where omitting it would have worked. Scope is flow-wide and forward-only, and it crosses `runFlow` boundaries in **both** directions: a parent's capture is visible inside a sub-flow, and a sub-flow's is still bound after it returns. See [yaml-flow-format.md](docs/yaml-flow-format.md#capture-bind-a-runtime-value-to-a-variable).
**An empty read fails the step rather than binding nothing.** `""` would turn `text: "View .*${VEHICLE}.*"` into `View .*.*` — a pattern matching any card on the screen, so the run goes green while the assertion has quietly become the tautology this feature exists to remove. A capture whose element is on screen but whose attribute is empty fails, and the report row says `(element found, value empty)` so it does not read as a missing element. `optional: true` is refused at parse time for the same reason: a skipped capture leaves the name unbound and every later step then fails on an unresolved variable — correct, but bewildering. `capture` is **native only**; inside a `switchContext: "WEBVIEW_…"` it fails instead of quietly reading the native tree behind the page, which would bind a real-looking wrong value.
- **⚠ Behaviour change: an interpolated value is now matched _literally_ in a native `id`/`text`.** `id` and `text` are regex full-match, so a `${VAR}` used to be spliced in as a **pattern**. A real UI string — `$29,499`, `2020 or newer (19,125)` — compiles to a perfectly valid regex that matches nothing, and the step fails as though the element were absent, with nothing in the message saying the value was read as a pattern. Since 3.7.0 the substituted value is escaped so it matches itself and nothing else.
The rule is by **field**, not by origin, so `${VAR}` means the same thing wherever its value came from — an `env:` default, `--env`, `process.env` or a capture:
- **escaped:** a native `id`, a native `text`.
- **never escaped:** `css` (a CSS selector, not a regex — a backslash corrupts it), a `text` selector inside a WEBVIEW context (the DOM path compares with `===`, exact equality), `inputText`'s `text:` (this is the string typed into the app), and everything that is not a regex-matched selector field (`appId`, `tapXY`, `switchContext`).
The pattern you wrote _around_ the reference is untouched, so `{ text: "View .*${VEHICLE}.*" }` keeps its own `.*` live and only the value goes in literal. **`${VAR:raw}` is the opt-out**, applied per reference, for a variable whose value really is meant as a pattern. What can change colour: a flow whose `env:` default was deliberately written as a regex and used in a native `id`/`text` — `validate_flow` now warns on exactly that combination, so those flows name themselves offline rather than on a device. It also **fixes** a latent bug in the other direction: an email address in a `text:` selector had its dots matching any character, and now matches itself.
- **`validate_flow` refuses a `${VAR}` used above the `capture` that binds it.** No execution order resolves it, so it is an error — caught offline, before a device, an app install and eleven steps have been spent. The line between error and warning is drawn on how many runs the flow is wrong on: **wrong on every path is an error, wrong on some paths is a warning, wrong on no path is silence.** So a capture inside a `runFlow when:` branch (or a `repeat` whose `times` may be `0`) still _defines_ the name and only **warns** — that branch may well run, and a fatal false positive stops a working flow dead. A `when:`/`else:` pair where both sides capture the same name is bound on every path and stays silent. Capturing the same name twice warns, naming both steps; so does a `capture` inside a WebView context. `optional:` and a malformed `as:` are hard parse errors.
**An empty `env:` default does not buy silence, and that is the subtle one.** `NAME: ""` is the shape `docs/ci-runner.md#secrets` teaches for a value injected at run time, so it is the idiom most flows carry — and treating a declared-but-blank key as "defined" would make every check above inert on exactly the flows they were written for. If the branch holding the capture never runs, `VEHICLE` stays `""`, `text: "View .*${VEHICLE}.*"` collapses to `View .*.*`, and the step passes against any card on the screen. It **warns** rather than errors, because `--env VEHICLE=…` legitimately fills a declared key and a fatal error would block that run. A flow that declares `NAME: ""` and captures nothing is untouched.
**One gap, stated rather than papered over:** a `dismiss:` entry that references a captured name is not covered by the ordering checks — they walk the step list, and `dismiss:` is config. At run time such an entry stands itself down and warns at every step boundary until the capture lands, so it is noisy rather than silent, but `validate_flow` will not tell you offline.
A "selector without an id is fragile" warning no longer fires on a `text` that interpolates a `${VAR}`. Asserting on a captured value requires a text selector by definition — the value is only knowable at run time — so the warning was unsatisfiable on the pattern this release recommends. It still fires on a plain text-only or index-only selector, where an id genuinely is the better choice.
- **Captured values are reported — and two of the three places are new machine-readable output you may already be parsing.** The capture's own row in `report.html` gains `VEHICLE = 2021 Honda Civic (read from text)`. The JUnit XML (`--junit`) gains `captured NAME=value` in that step's `<system-out>`, next to the existing `healed` note. And `run_flow`'s summary text gains a `captured: NAME=value, …` line **on a failed run only** — `report.html` is a path a model cannot open, and that string is returned on every single call, so a passing run stays quiet. `redact: true` stores `***` in the record itself, so no renderer can leak a redacted value by forgetting a flag; the real value still reaches later steps. **There is no `report.json`** — the machine-readable `run_flow` payload remains deferred work, and this release deliberately did not invent a partial one, so `--junit` is still the machine-readable artifact that exists.
## What's new in 3.7.1
- **iOS runs no longer abort when their WebDriverAgent session is replaced.** WDA holds one session at a time, and a session that has been replaced answers `404` with the error code `invalid session id` and the message `Session does not exist`. The client already had a retry for exactly that case — but it looked for the code's wording inside the message, which never contains it, so the retry never ran and the run died with `WDA GET /session/<id>/window/size failed: Session does not exist`. It shipped behind a green unit test whose fake WDA copied the code into the message field, which a real WDA never does. Recovery now reads the structured error code, and still retries only once. That is safe because WDA rejects a command sent on a dead session before running it (measured: a tap sent on a dead session left an on-screen counter untouched).
- **Tool calls made during a run share its WDA session instead of ending it.** The server builds a fresh driver for every tool call, and each one used to create a session of its own — so an `observe_ui` or `tap` issued while a backgrounded `run_flow` was still running ended that run's session. Every call and run in the server process now shares one session per WDA, and clients that hit a dead session at the same moment create one replacement between them, not one each. Measured against a simulator: 201 interleaved calls from several clients ran on a single session with no failures, where the same kind of loop with a session per client created a new session on every one of its 80 calls. A second _process_ driving the same simulator — another MCP server, Appium, a script — can still replace the session; that is recovered from, not prevented. See [ios-testing.md](docs/ios-testing.md#webdriveragent-sessions-and-concurrent-tool-calls).
- **WDA is never reinstalled because it was slow to answer.** A `/status` check that timed out used to be read as "WDA is not running" and answered with a reinstall, which ends every session and sends the app under test to the background — after which later steps silently read the home screen. But `/status` waits behind a UI dump already in progress: on a 1,500-element page the dump took 3.8s, and a `/status` sent meanwhile missed its 2-second budget against a perfectly healthy WDA. A timed-out check is now checked again, so WDA gets up to 32s in all (the 2s check, then a 30s wait); a WDA that still does not answer is an error naming the command that restarts it; and only a WDA that is actually down — a refused or reset connection, or an error answer from `/status` — is reinstalled. WDA is also confirmed once per tool call or run, instead of before every one of the hundreds of commands a long flow sends. **⚠ Behaviour change:** if WDA genuinely dies partway through a run, the run fails at the step that met the dead WDA — with `dismiss:` configured too — and nothing in that run brings WDA back, because a relaunch would send the app under test to the background and later steps would read the home screen. Depending on where the crash lands, the step's error is a connection failure (`fetch failed`) or an error containing `WebDriverAgent stopped responding partway through this run or tool call` (inside a WebView context, a `when:` check or the WebView tap bridge, it arrives wrapped in their own prefix). The next run or tool call relaunches WDA.
- **A run says when its session was recreated.** Recovery is silent by design, so a run whose WDA session was replaced now carries one warning — in the `run_flow` summary, the CLI output and `report.html` — saying how many times, without failing a flow that recovered. Relatedly, a `dismiss:` watcher whose screen capture fails for any reason now warns that it could not look. It used to warn only for a screen that never idles, so a watcher blinded by a WDA error said nothing at all. This second change is in the platform-neutral runner, so Android runs can show that warning too.
## What's new in 3.7.2
- **`ai-mobile-tester run` runs an iOS flow without `--device`.** The CLI used to pick its device through adb before it looked at the flow's `platform:`, so a `platform: ios` flow could only run with an explicit `--device <udid>`. Without one it failed in one of four ways, depending on the machine: with one Android device attached, the adb serial was handed to the iOS driver (`No iOS simulator with UDID <serial>`); with several, it asked you to pick one with `--device <serial>`; with none, it said to start an Android emulator; and on a Mac with no Android SDK, it asked whether the Android SDK platform-tools were on your PATH. The CLI now takes the platform from the flow — or, for a flow that declares none, from `--device` — and an iOS run uses the only booted simulator, exactly as the `run_flow` MCP tool always has. Android runs keep the same default device and every adb message.
- **A `--device` that contradicts the flow's declared `platform:` now says so**, in either direction — `--device <id> is not an iOS simulator UDID, but <flow> declares platform: ios`, or `--device <udid> looks like an iOS simulator UDID, but <flow> declares platform: android` — instead of failing later on a missing simulator or device. A flow that declares no platform is never refused: that is how one shared flow runs on iOS, by being given a simulator UDID.
- **A flow without `platform:` keeps separate self-heal memory per platform.** Its iOS runs used to read and write the Android file, `<flow>.fingerprints.json`, where a `when: { platform }` branch that runs on only one platform shifts which stored locator each later step is paired with — so an iOS step could heal onto a different element and pass. Heal memory now follows the device a run actually got: iOS runs use `<flow>.fingerprints.ios.json`, from `run_flow` and the CLI alike. If you ran such a flow on iOS before: when it only ever ran on iOS, rename its `<flow>.fingerprints.json` to `<flow>.fingerprints.ios.json` to keep that memory; when it ran on both platforms, delete the file once, so each platform re-learns from its own runs (a step that was passing only by healing then fails until its selector is updated).
- **The HTML report names the simulator an iOS run used** when no device id was passed, instead of `default`.
## What's new in 3.8.0
A run's report now says what happened in a way that cannot be misread — a green test used to render red, and one did get filed as a bug. **Three outputs change shape; see "What changes for you" below.**
- **A verdict.** `report.html` opens with `✓ PASSED` or `✗ FAILED`. It used to state no verdict at all.
- **A real tally.** `66 passed · 10 skipped · 0 failed` replaces the report's `66 of 76 steps passed` and the CLI's and `run_flow`'s `66/76 steps passed`. A deliberate skip — an `optional:` miss, a `when:` that did not apply — is not a shortfall, and the old wording read as ten failures.
- **Warnings look like warnings.** They render amber with `⚠`, and dismissed dialogs render neutral; red and orange are kept for step results. A warning raised more than once says how often, e.g. `(17×)`.
- **Steps after a failure are `not run`**, each with the reason — `not run — the run stopped at step 4` — instead of `skipped` with an empty detail. Inside a `runFlow` or `repeat`, the rest of the block is listed too, and a loop's unstarted iterations are counted on the loop's own row.
- **Every block has a row.** A `runFlow` that ran, and every `repeat`, gets a row with its file, its condition, `times:`, or — lacking both — a command count as the target, and what ran as the detail (`when: visible #promo — ran else`). Its steps are indented beneath it and numbered by position: `4.2` is the second step inside the block at step 4, and `6.3.1` is the first step of iteration 3 of the `repeat` at step 6.
- **Time is in the row that spent it.** With `dismiss:`, the dialog check before each step reads the screen, and that read is the step's first look — but it was timed outside every row, so a run's steps could add up to a small fraction of its duration. Each row now includes it; the header shows what is left over (`298.5 s (0.4 s outside steps)`), how many dialog checks ran, how long they took and how many could not read the screen.
**What changes for you:**
- **JUnit testcase names** are unchanged for flows without `runFlow` or `repeat`. Flows that use them now number steps by position (`4.2`), so CI shows those testcases as renamed once — and from then on the names stop changing when a `when:` branch runs on one run and not the next.
- **The summary line** reads `✓ login — 7 passed · 0 skipped · 0 failed` instead of `✓ login — 7/7 steps passed`. Anything that parses `steps passed` needs updating.
- **Steps after a failure** are `not run`, not `skipped`. JUnit reports them as skipped, now with a message.
- **Per-step times** in a flow with `dismiss:` go up by the dialog check before each step. The run's total time does not change.
## Roadmap
- **Phase 1 (current):** Android (native + WebView) via ADB, distributed as an npm package. Also: iOS simulator support (native + WebView), via `xcrun simctl` + WebDriverAgent + Apple's Web Inspector.
- **Phase 2:** web (Playwright).
- **Phase 3:** cloud device farms (Firebase Test Lab, BrowserStack), CI/CD integration.
- **Phase 4:** SaaS — web dashboard, team collaboration, history & analytics.
## License
MIT
TDQS
Scored across 38 tools
Multiple tools overlap heavily: tap_element and tap both tap by selector; type_text and input_text both type into fields; find_element, describe_element, dump_ui, and observe_ui all provide UI hierarchy/element querying. An agent would need to read detailed descriptions to avoid misselecting the wrong tool.
Most tools use a readable verb_noun snake_case pattern, but there are notable inconsistencies: tap_element and tap are near-synonyms, type_text and input_text are duplicate concepts, and webview_tap/webview_input invert the expected verb-object order. The general pattern is still recognizable.
38 tools is a heavy surface for a mobile testing server, and several tools are near-duplicates rather than genuinely distinct capabilities. The count would be more reasonable if tap_element/tap and type_text/input_text were merged.
The core mobile testing lifecycle is well covered: discovery, interaction, waiting, assertions, screenshots, app lifecycle, device management, WebView automation, and YAML flow validation. Minor gaps exist, such as clearing text fields, element-level swipes, and waiting for an element to disappear, but these are workaroundable.