# Authoring UI tests — the full workflow

A practical, end-to-end guide: find the on-screen elements, write a YAML flow, validate it, run it,
and debug failures. For the exhaustive command/selector reference see
[yaml-flow-format.md](yaml-flow-format.md); for making Compose elements addressable see
[compose-testability.md](compose-testability.md).

> This guide covers native Views + Compose. Self-healing locators are built in (M4) and WebView/hybrid
> support is covered in [webview-testing.md](webview-testing.md) (M5).

> **Installed via npm?** The `npx tsx scripts/smoke.ts …` commands in §§2, 5, and 7 are **repo-only
> developer tools** — they are not shipped in the npm package and will not be available if you
> installed with `npm install -g ai-mobile-tester`. Use the **installed CLI** instead:
>
> - `ai-mobile-tester validate <flow.yaml>` — offline schema/selector lint (no device)
> - `ai-mobile-tester run <flow.yaml>` — run on device → HTML report (exit 0 pass / 1 step-failed /
>   2 could-not-run)
>
> For interactive element discovery, use the **`observe_ui`** and **`check_testability`** MCP tools
> in Claude Code — they give the same compact element list.

---

## 0. Prerequisites

- Android SDK **platform-tools** on your PATH (`adb`), and a device/emulator connected
  (`adb devices` should list one as `device`).
- _(Recommended)_ **ADBKeyboard** installed on the device — the runner types through it (a headless
  IME: no on-screen keyboard, reliable spaces/unicode). `adb install ADBKeyboard.apk`
  (github.com/senzhk/ADBKeyBoard).

Two ways to drive the tools:

- **The installed CLI**: `ai-mobile-tester validate <flow.yaml>` / `ai-mobile-tester run <flow.yaml>`
  — lint and run flows from any terminal.
- **The MCP tools** (when the server is wired into a Claude session): `observe_ui`, `check_testability`,
  `validate_flow`, `run_flow`, `tap`, `input_text`. Same capabilities, driven conversationally.

---

## 1. The authoring loop

You don't write a flow blind. You walk the app **one screen at a time**, dump its elements, copy the
selectors into your YAML, then advance:

```
open a screen → dump it → pick selectors → tap to advance → dump the next screen → … → validate → run
```

---

## 2. Find the elements (the most important skill)

### Find the app's package (the `appId`)

```bash
adb shell pm list packages | grep -i <part-of-your-app-name>
```

The part after `package:` is your `appId`.

### Dump the current screen

Open the app to the screen you want, then use the **`observe_ui`** MCP tool in Claude Code, or
(if working from this repo) the smoke script:

```bash
npx tsx scripts/smoke.ts   # repo-only; npm-installed users: use observe_ui in Claude Code
```

You get a compact, token-frugal list — one element per line. Read it like this:

```
button "Sign in" [id=sign_in_button] [clickable] [ref=e2]
  │       │            │                  │
 role  visible text   selector id        tappable
```

- **`[clickable]`** → it's a tap target. These are the lines you care about.
- **`[id=…]`** → the bare resource-id. **Best selector** — use `{ id: sign_in_button }`.
- **`"text"`** → if there's no `[id=…]`, target by text: `{ text: "Sign in" }` (it also matches an
  element's `contentDescription`).

### See which elements have stable ids

Use the **`check_testability`** MCP tool in Claude Code. From the repo you can also run:

```bash
npx tsx scripts/smoke.ts check   # repo-only; npm-installed users: use check_testability in Claude Code
```

Reports id-coverage of the actionable elements and lists the ones with no id. If many are missing ids
(common with Jetpack Compose), either use text selectors or add `testTag`s — see
[compose-testability.md](compose-testability.md).

### Advance and dump again

Tapping an element returns the _next_ screen's dump, so you can map a multi-screen path. Use the
**`tap`** and **`observe_ui`** MCP tools in Claude Code, or (from the repo):

```bash
npx tsx scripts/smoke.ts tap-text "Account"   # repo-only; npm-installed users: use tap + observe_ui in Claude Code
npx tsx scripts/smoke.ts                        # dump the next screen, grab its ids/text
```

---

## 3. Write the flow

A flow is **two YAML documents**: a config header, `---`, then an ordered step list. Create a new file
(e.g. `flows/my-login.yaml`) using the template below:

```yaml
appId: com.yourcompany.yourapp
name: Login smoke test
env:
  USER: "you@example.com" # referenced in steps as ${USER}
  PASS: "your-test-password"
---
- launchApp: { clearState: true } # fresh start, logged out
- tapOn: { id: login_tab } # tap → login (or: - tapOn: "Account")
- inputText: { into: { id: email_field }, text: "${USER}" } # focus the field + type
- inputText: { into: { id: password_field }, text: "${PASS}" }
- tapOn: { id: sign_in_button } # submit
- assertVisible: { id: home_header } # the SUCCESS condition
- takeScreenshot: logged_in
```

What the pieces do:

- `launchApp` starts the app (`clearState: true` wipes data so you always start logged-out).
- `tapOn` **waits up to 10s** for the element, then taps its center.
- `inputText { into }` taps the field to focus it, then types. `${USER}`/`${PASS}` come from `env:`.
- `assertVisible` is your **pass/fail gate** — the test fails if the element never appears.

The full command list (launchApp, tapOn, inputText, assertVisible, assertNotVisible, scroll,
scrollUntilVisible, pressKey, takeScreenshot, runFlow, repeat, back) is in
[yaml-flow-format.md](yaml-flow-format.md).

---

## 4. Selectors — the rules that bite people

A selector is `{ id?, text?, index?, enabled?, checked?, focused?, selected?, optional? }`; a bare
string is shorthand for `{ text }`.

`optional: true` makes a step **conditional** — if the target is absent (after a short lookup) the
step is skipped and the flow continues instead of failing. Handy for a dialog, permission prompt, or
promo that may not always appear.

1. **Prefer `id`** — it survives text/locale changes. Use `text` only when there's no id.

2. **Use the _bare_ id** — `{ id: sign_in_button }`, never the package-qualified
   `com.app:id/sign_in_button` (that silently matches nothing). For Compose, the id is your `testTag`.

3. **`text` and `id` are anchored regex, full-match.** `{ text: "Login" }` matches _exactly_ `Login`
   (case-sensitive), not "Login now". For contains/partial, wrap with `.*`:

   ```yaml
   - tapOn: { text: ".*Sign in.*" }
   ```

4. **Regex metacharacters in a label must be escaped — the #1 gotcha.** `? * . + ( ) [ ] { } ^ $ | \`
   are regex operators, not literal characters. So a literal label like `Don't have an account? Sign-up`
   or `First Name *` will **not** match unless you handle the `?`/`*`. Two options:

   ```yaml
   # A) escape it (keep the exact label). In double-quoted YAML write \\?  (YAML eats one backslash,
   #    so \\ → \, and the regex receives \? = a literal question mark):
   - tapOn: { text: "Don't have an account\\? Sign-up" }
   - inputText: { into: { text: "First Name \\*" }, text: "${FIRST_NAME}" }

   # B) partial-match a clean chunk (simpler, more resilient):
   - tapOn: { text: ".*Sign-up.*" }
   - inputText: { into: { text: ".*First Name.*" }, text: "${FIRST_NAME}" }
   ```

   A single `\?` in a double-quoted string is an **invalid YAML escape** and the file won't parse —
   it must be `\\?`. (Single-quoted YAML keeps backslashes literal, so `\?` works there, but an
   apostrophe in the label then needs doubling.)

5. **Disambiguate duplicates with `index`** (0-based). If three rows say "Delete":

   ```yaml
   - tapOn: { text: "Delete", index: 1 } # the 2nd one
   ```

   To target "Email" but not "Confirm Email", anchor at the start instead: `{ text: "Email.*" }`
   matches only strings _starting_ with "Email".

6. **Tapping by text works even when the text sits on a child node.** In Compose the clickable wrapper
   often has no text of its own; the selector resolves to the inner `Text`, and the tap lands on its
   center — inside the clickable area — so it still triggers. You don't need to find the wrapper.

7. **Truly anonymous elements can't be targeted.** No id, no text, no `contentDescription` → neither
   `id` nor `text` can find it (there's no coordinate/relational selector in this version). Fix it in
   the app: add a `contentDescription` (then `{ text: "<that label>" }`) or a Compose `testTag` (then
   `{ id: "<tag>" }`).

---

## 5. Validate, run, debug

### Validate offline (no device)

```bash
ai-mobile-tester validate flows/my-login.yaml
```

Catches schema mistakes, undefined `${vars}`, fragile selectors, and missing subflow files **before**
you spend a device run. You can also use the **`validate_flow`** MCP tool in Claude Code.

### Run it

```bash
ai-mobile-tester run flows/my-login.yaml
# with options:
ai-mobile-tester run flows/my-login.yaml --junit results.xml --env USER=test@x.com
```

Exit codes: `0` = all steps passed, `1` = a step failed, `2` = could not run (device not found, etc.).
You can also trigger a run via the **`run_flow`** MCP tool in Claude Code.

Prints a summary (`✓ … 7/7 steps passed` or `✗ … failed at step N`) and a path to `report.html` under
`.mobile-runs/<timestamp>/`. The run is **deterministic and fail-fast**: it stops at the first failed
step and marks the rest skipped.

### Read the report

Open the `report.html`. Each step shows status (✓/✗/–), duration, and — on a failure — the exact error
plus a **screenshot of the screen where it broke**. That screenshot is usually all you need to see why a
selector missed (wrong screen, wrong text, keyboard covering the element).

### Debugging a "not found" (the element is clearly there)

Almost always one of:

- **A regex metacharacter in the text** (`?`, `*`, `.`) — see §4.4. (This is the most common one.)
- **Not a full match** — your text is a substring of the real label; wrap with `.*`.
- **Wrong screen** — a prior step didn't advance; the report's failing step tells you where you are.
- **Timing** — the element appears after >10s; rare, but `scrollUntilVisible` or splitting the step helps.

Confirm fast by dumping the failing screen (the app is left there after a fail-fast stop). Use the
**`observe_ui`** and **`tap`** MCP tools in Claude Code, or (from the repo):

```bash
npx tsx scripts/smoke.ts                       # repo-only: see the exact rendered text
npx tsx scripts/smoke.ts tap-text ".*Sign.*"   # repo-only: test a candidate selector live
```

---

## 6. Handy patterns

- **Keyboard covering a button:** use `- hideKeyboard` (it checks whether the keyboard is up and
  presses BACK only if so — safe). The older manual trick `- back` works too, but only when the
  keyboard is actually showing. (Installing ADBKeyboard avoids the on-screen keyboard entirely.)
- **Reuse steps across flows:** `- runFlow: { file: subflows/logout.yaml }` (validated + run inline).
- **Repeat:** `- repeat: { times: 3, commands: [ { tapOn: { id: load_more } } ] }`.
- **Variables:** define under `env:` and reference as `${VAR}` in any step string; pass/override at run
  time with the tool's `env` argument.

---

## 7. Quick command crib

**Installed CLI** (available after `npm install -g ai-mobile-tester`):

```bash
ai-mobile-tester validate flows/my-flow.yaml          # offline lint — no device needed
ai-mobile-tester run      flows/my-flow.yaml          # run on device → HTML report
ai-mobile-tester run      flows/my-flow.yaml \
  --junit results.xml --env USER=test@x.com           # with JUnit output + env overrides
```

**MCP tools** (in Claude Code, once `ai-mobile-tester serve` is wired in):

```
observe_ui            → dump current screen's elements
check_testability     → id-coverage audit (Compose testability)
tap                   → tap by text or id, reprint next screen
input_text            → type into a field
validate_flow         → static check, no device
run_flow              → run on device → HTML report
```

**Repo-only developer tools** (not shipped in the npm package):

```bash
npx tsx scripts/smoke.ts                      # dump current screen's elements
npx tsx scripts/smoke.ts check                # id-coverage audit (Compose testability)
npx tsx scripts/smoke.ts tap-text "Login"     # tap by text, reprint next screen
npx tsx scripts/smoke.ts tap-id  login_btn    # tap by bare resource-id
npx tsx scripts/smoke.ts validate flow.yaml   # static check, no device
npx tsx scripts/smoke.ts run      flow.yaml   # run on device → HTML report
```
