Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob).

## [Unreleased]

### Added
- **`Mob.Test.capabilities/1`** — ask a build which test-harness probes it can
actually serve, before choosing how to drive it. Which probes work is a
runtime fact: on Android each harness NIF bails when the app's generated
`MobBridge.kt` lacks the matching method, and that file is generated once and
never re-rendered; on iOS the harness is compiled out of release builds.
Returns `:unknown` per probe for an app predating `mob_nif:capabilities/0`
rather than guessing, and `false` everywhere with `dist_rpc: false` when
nothing answered. Backed by a new `mob_nif:capabilities/0` NIF on both
platforms.

### Fixed
- **A parked screen no longer keeps live resources running, and returning to
one no longer breaks the frame registry** (MOB-147, MOB-145). MOB-129 keeps
Expand Down
59 changes: 58 additions & 1 deletion guides/agentic_coding.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,62 @@ or directly from an agent that can run shell commands, using:
iex -S mix --eval 'IO.inspect Mob.Test.assigns(:"mob_demo_ios@127.0.0.1")'
```

**Ask what this build can be probed with, before committing to an approach.**

Which probes work is a runtime fact, not a property of the platform. On Android
most harness NIFs return `{:error, :not_loaded}` when the app's generated
`MobBridge.kt` lacks the matching method, and that file is generated once and
never re-rendered, so apps drift as the template moves. On iOS the harness is
compiled out of release builds.

```elixir
Mob.Test.capabilities(node)
#=> %{
#=> dist_rpc: true,
#=> view_tree: false, ui_tree: false, screen_info: true,
#=> tap_xy: false, tap_by_label: false, long_press_xy: false,
#=> swipe_xy: false, type_text: false, delete_backward: false,
#=> clear_text: false, ax_action: false, element_frames: true,
#=> scroll_info: true, scroll_to: true, sample_region: false,
#=> screenshot: true
#=> }
```

That is a **freshly generated Android app** — abridged only in layout, not in
content; every one of the seventeen keys is shown, because guessing at the rest
is exactly what goes wrong. The template defines `screenInfo`, `elementFrames`,
`screenshot`, `scrollInfo` and `scrollTo`, and nothing else in the harness set
(MOB-160).

Two of those `false`s bite harder than they look:

* `sample_region: false` means `Mob.Test.sample_color/2` is unavailable — there
is no Android NIF for it at all, so the call **raises** rather than returning
an error tuple. `screenshot: true` sitting next to it is not a substitute.
* `tap_by_label: false` removes the documented fallback for `tap_xy`, so this
build has no synthetic input of any kind.

`Mob.Test.tap/2` still works: it delivers the same `{:tap, tag}` message a
native tap produces straight to the screen process over dist, never touching
the bridge. It is fire-and-forget — `:ok` whether or not a screen matched — so
assert the state change, per *The honesty contract* above.

**When there is no synthetic input, that is your cue to drop to Layer 2**: drive
the UI with `mcp__adb__*` below and keep `Mob.Test` for reading state.

Four answers, and three of them are not "the app is fine":

| Result | Means |
|--------|-------|
| probes true/false, `dist_rpc: true` | the real answer |
| all `:unknown`, `dist_rpc: true` | app predates `capabilities/0` (added 0.7.40) — upgrade `mob` rather than guessing |
| all `false`, `dist_rpc: true` | `load_nif` failed on the device: every NIF is down, and the fix is a native rebuild, not the bridge |
| all `false`, `dist_rpc: false` | nothing answered. An iOS **release** build reports exactly this, because it drops `-name` and has no distribution at all — indistinguishable from a bad node name or a dead tunnel, so check which you expect |

`capabilities/2` takes a timeout, defaulting to 5s. That matters here because
this is the first call an agent makes, and a wedged-but-reachable device would
otherwise hang it indefinitely.

#### Layer 2 — MCP platform tools (for rendering and layout)

When the question is visual — "does this text overflow?", "is the button in the right
Expand Down Expand Up @@ -410,7 +466,8 @@ screenshots or adb screencap as your primary inspection method.
Instead:
1. Run `mix mob.connect --no-iex` to establish distribution tunnels (if not already running)
2. Use `Mob.Test` from IEx to query exact state:
- `Mob.Test.screen(node)` — what screen is active?
- `Mob.Test.capabilities(node) # ask FIRST: which probes does this build serve?
Mob.Test.screen(node)` — what screen is active?
- `Mob.Test.assigns(node)` — what is the live data?
- `Mob.Test.tap(node, :tag)` — drive a tap by tag atom
- `Mob.Test.find(node, "text")` — locate a widget by visible text
Expand Down
7 changes: 7 additions & 0 deletions lib/mob/test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,13 @@ defmodule Mob.Test do
A node whose `load_nif` failed reports `dist_rpc: true` with every probe
`false`: it answered, and every NIF really is down.

Two side effects worth knowing. `:mob_nif` is `-on_load`, so calling this on
a node that has not loaded it triggers the code load and the NIF load — in a
booted Mob app it is always loaded already, so this is theory rather than
practice. And the device runs an interactive code server, so probing a module
it has not loaded causes it to load; that makes the answer reflect the code
path rather than the resident set.

An app built before `mob_nif:capabilities/0` existed cannot answer. Rather
than guess from a table that would drift the same way, those report
`:unknown` for each probe with `dist_rpc: true` — the honest answer, and one
Expand Down
Loading