# Testing WebView / hybrid apps

Many Android apps render most of their UI in a **WebView** (Cordova, Ionic, Capacitor, React Native
WebView, or a custom `WebView` loading a web app). Native UiAutomator can't see inside those — it
just reports one big opaque `WebView` node. This project drives WebViews directly over the Chrome
DevTools Protocol instead, so you address elements by **CSS selector** (`#id`, `[data-testid=…]`, a
tag path) exactly like the web.

This guide covers validating a WebView app on a real device. Once it works, you wire it into a flow
with the `switchContext` command (see [yaml-flow-format.md](yaml-flow-format.md)).

## The one hard prerequisite: a debuggable WebView

The app's WebView must have been built with:

```kotlin
WebView.setWebContentsDebuggingEnabled(true)
```

Debug builds of hybrid frameworks usually enable this; release builds usually don't. There is **no
way around it** — without it the WebView exposes no DevTools socket and there is nothing to connect
to. (This is the same flag Chrome's `chrome://inspect` needs.)

Chrome **Custom Tabs** are _not_ inspectable WebViews — the tool reports `CustomTabUnsupported` for
those.

## Step 1 — confirm the WebView is debuggable

Launch the app and navigate to a WebView screen, then scan for its DevTools socket (this is exactly
what the server's discovery does):

```bash
adb shell cat /proc/net/unix | grep -a webview_devtools_remote
```

You want a line like `@webview_devtools_remote_<pid>`. If you get nothing:

- the app isn't foregrounded on a WebView screen yet, or
- the build didn't enable debugging, or
- it's a Custom Tab (you'll see `@chrome_devtools_remote` instead).

The socket is named by **pid**, not package — the server resolves the package via `/proc/<pid>/cmdline`.

## Step 2 — observe the DOM

> **Installed via npm?** `node scripts/smoke-webview.mjs` is a **repo-only developer tool** — it is
> not included in the npm package. If you installed `ai-mobile-tester` via npm, drive WebView
> discovery through the **`observe_webview`** MCP tool in Claude Code instead, or run an existing
> flow with `ai-mobile-tester run <flow.yaml>`. The MCP tool returns the same compact DOM snapshot
> shown below.

If you are working inside the source repo, you can validate the whole stack with the standalone
smoke script, which drives the built `dist/` code directly:

```bash
npm run build                                    # compile dist/ from src/ (once)
node scripts/smoke-webview.mjs com.your.app      # discover -> connect -> walk the DOM
```

You get the same compact snapshot the `observe_webview` MCP tool returns — one line per element:

```
button "Passenger" [css=[data-testid="passenger-tools"]] [ref=e1]
edit   "Open search" [css=#search] [ref=e3]
link   "Go" [css=[data-testid="search-bar-go"]] [ref=e4]
```

Read it as `role "name" [css=…] [ref=eN]`. The **`[css=…]`** is the selector you act with; the
generator prefers stable selectors (`[data-testid]` → `#id` → a short `nth-of-type` path). Only
**actionable** elements (buttons, links, inputs…) get a **`[ref=eN]`** — so listing tap targets is
the `[ref=e` lines in the output. Npm-installed users: call **`observe_webview`** in Claude Code —
it returns the same snapshot. From the repo you can also pipe the script output through `grep`:

```bash
node scripts/smoke-webview.mjs com.your.app | grep '\[ref=e'   # repo-only
```

## Multi-page apps — pick the right page with `--match`

A single app often exposes **several** WebView pages (a chat widget, the main app, blank helper
pages, service workers). By default the **first** page wins, which is frequently the wrong one — you
connect and see **0 nodes**. Inspect the available pages:

```bash
PORT=9223
adb forward tcp:$PORT localabstract:webview_devtools_remote_<pid>
curl -s http://127.0.0.1:$PORT/json | grep -E '"(title|url)"'
adb forward --remove tcp:$PORT
```

Then select the right one by a URL substring/regex. From the repo:

```bash
node scripts/smoke-webview.mjs com.your.app --match "yourdomain\.com"   # repo-only
```

Npm-installed users: pass the `match` parameter to the **`observe_webview`** MCP tool, or use the
`@<url-match>` suffix in `switchContext` (see below).

> In a flow, select the page with the `@<url-match>` suffix:
> `switchContext: "WEBVIEW_com.your.app@yourdomain.com"`. The `observe_webview` tool takes a `match`
> parameter for the same purpose. The match is a regex (a plain domain substring works as one) — keep
> the dot **unescaped** so the value stays valid YAML (a backslash inside double quotes breaks parsing).
> For deterministic CI replay, pin the page with a **stable** domain/path segment — not a full URL
> with session tokens or query strings (e.g. `"WEBVIEW_com.your.app@yourdomain.com/explore"`).

## Step 3 — act on elements

| Action         | by css                        | by ref (no quoting needed)     |
| -------------- | ----------------------------- | ------------------------------ |
| assert visible | `--assert '#email'`           | `--assert-ref e1`              |
| tap            | `--tap '[data-testid="go"]'`  | `--tap-ref e4`                 |
| input text     | `--input '#email=test@x.com'` | `--input-ref 'e3=Honda Civic'` |

After an action the tool re-walks the DOM and returns it again, so a changed element list confirms the
action landed. Example — open a search modal and watch the snapshot change. **Npm-installed users:**
use the **`webview_tap`** and **`webview_input`** MCP tools (the native `tap`/`input_text` tools are
native-only and cannot address WebView DOM elements). Each takes a `css` (or `text`) selector and
returns the updated WebView DOM — an `observe_webview → webview_tap → observe_webview` loop with no flow
authoring, e.g. `webview_tap { package, css: "[data-testid='go']" }`. From the repo you can also use the
smoke script:

```bash
node scripts/smoke-webview.mjs com.your.app --match "yourdomain\.com" --tap-ref e2   # repo-only
```

### Shell quoting (important for the repo smoke script)

A css selector with `[`, `]`, `"`, or spaces **must be in single quotes** `'…'`:

- ✅ `--tap '[data-testid="go"]'`
- ❌ `` --tap `[data-testid="go"]` `` — backticks run a command
- ❌ `--tap [data-testid="go"]` — zsh globs the `[ ]` → _"no matches found"_

The `--tap-ref` / `--assert-ref` / `--input-ref` flags take a plain `eN` handle and sidestep quoting
entirely — prefer them.

## Error cheat-sheet

| Message                  | Meaning                                                | Fix                                                                        |
| ------------------------ | ------------------------------------------------------ | -------------------------------------------------------------------------- |
| `NoDebuggableWebView`    | no DevTools socket for the package                     | enable `setWebContentsDebuggingEnabled(true)`; foreground a WebView screen |
| `CustomTabUnsupported`   | only a Chrome socket — it's a Custom Tab               | not inspectable; test the in-app WebView instead                           |
| `WebviewHasNoPages`      | socket exists but no inspectable page                  | open/let the screen load, then retry                                       |
| connects but **0 nodes** | wrong page picked, or content in a cross-origin iframe | use `--match`; cross-origin iframes aren't traversed                       |

## From smoke test to a flow

Once you know the selectors, the same actions become a YAML flow (`run_flow`):

```yaml
appId: com.your.app
---
- launchApp
- switchContext: "WEBVIEW_com.your.app"
- assertVisible: { css: "#email" }
- inputText: { into: { css: "#email" }, text: "${USER}" }
- tapOn: { css: '[data-testid="submit"]' }
- switchContext: NATIVE_APP
```

See [yaml-flow-format.md](yaml-flow-format.md) for `switchContext` and the `css` selector.
