m) {
}
@Override public String toString() {
- return role.isEmpty() ? ref + " " + label : ref + " " + label + " (" + role + ")";
+ String name = (ref + " " + label).strip();
+ return role.isEmpty() ? name : name + " (" + role + ")";
}
}
diff --git a/clients/java/src/main/java/dev/mobium/Mobium.java b/clients/java/src/main/java/dev/mobium/Mobium.java
index 5df9590..3f757a3 100644
--- a/clients/java/src/main/java/dev/mobium/Mobium.java
+++ b/clients/java/src/main/java/dev/mobium/Mobium.java
@@ -402,7 +402,8 @@ public byte[] screenshot(Path path) {
* Blocks until the screen agrees, instead of sleeping.
*
* On success the screen is remapped, so the element returned already
- * has a ref that can be tapped without calling {@link #map()} first.
+ * has a ref that can be tapped without calling {@link #map()} first. Its
+ * ref is empty when the map filters it out, as a plain text in a list.
* Waiting for something to go away returns {@code null}: there is nothing
* left to point at. If the condition never holds, the exception says what
* was on screen instead, which is usually the answer.
diff --git a/clients/javascript/index.d.ts b/clients/javascript/index.d.ts
index 4b6f6f8..e21c6a9 100644
--- a/clients/javascript/index.d.ts
+++ b/clients/javascript/index.d.ts
@@ -20,7 +20,7 @@ export interface Bounds {
/** One actionable thing on screen, from `map()` or `find()`. */
export interface Element {
- /** The `@e1` handle, valid only for the screen it came from. */
+ /** The `@e1` handle, valid only for the screen it came from. Empty for a match the map filters out. */
ref: string
/** What a person would call it. */
label: string
diff --git a/clients/javascript/index.js b/clients/javascript/index.js
index 996ed73..4a64be1 100644
--- a/clients/javascript/index.js
+++ b/clients/javascript/index.js
@@ -627,7 +627,8 @@ export class Device {
* it never happens, saying what was on screen instead.
*
* On success the screen is remapped, so the element returned already has a
- * ref that can be tapped without calling map() first. Nothing is returned
+ * ref that can be tapped without calling map() first. Its ref is empty when
+ * the map filters it out, as a plain text in a list. Nothing is returned
* when waiting for something to go away.
*/
async waitFor(target, { condition = 'visible', text, timeoutMs = 10000, not, exact, count } = {}) {
diff --git a/clients/python/mobium/_device.py b/clients/python/mobium/_device.py
index 023dfc0..3d30887 100644
--- a/clients/python/mobium/_device.py
+++ b/clients/python/mobium/_device.py
@@ -36,6 +36,7 @@ class Element:
"""One actionable element on screen."""
ref: str
+ """The @e1 handle, valid only for the screen it came from; empty for a match the map filters out."""
label: str
role: str = ""
bounds: Bounds = Bounds(0, 0, 0, 0)
@@ -53,7 +54,8 @@ class Element:
"""What a slider reads, as the app states it ("80%", "1.2"); empty for anything else."""
def __str__(self) -> str:
- return f"{self.ref} {self.label} ({self.role})" if self.role else f"{self.ref} {self.label}"
+ name = f"{self.ref} {self.label}".strip()
+ return f"{name} ({self.role})" if self.role else name
@dataclass(frozen=True)
@@ -341,7 +343,8 @@ def wait_for(
if it never happens, saying what was on screen instead.
On success the screen is remapped, so the element returned already has
- a ref that can be tapped without calling map() first. Nothing is
+ a ref that can be tapped without calling map() first. Its ref is empty
+ when the map filters it out, as a plain text in a list. Nothing is
returned when waiting for something to go away.
"""
args: dict[str, Any] = {
diff --git a/docs/CHALLENGES.md b/docs/CHALLENGES.md
index 6335c01..3b1d554 100644
--- a/docs/CHALLENGES.md
+++ b/docs/CHALLENGES.md
@@ -7,8 +7,8 @@ the source.
The pattern across them is the reason the document exists: **almost nothing
here was found by reading code or by a test written from imagination.** Of
-386 defects, 310 were found only by running against a real device. Fifteen
-more — 255, 280, 282, 327, 329, 353, 355, 356, 359, 371, 375, 380, 382, 384 and 386 — were met while
+387 defects, 310 were found only by running against a real device. Sixteen
+more — 255, 280, 282, 327, 329, 353, 355, 356, 359, 371, 375, 380, 382, 384, 386 and 387 — were met while
running against one, though the fault lay in a check, the host or Mobium's own
design rather than in the platform. The other sixty-one — 4, 5, 6, 14, 23,
24, 32, 35, 36, 39, 44, 50, 53, 54, 57, 66, 89, 99, 100, 121, 123, 129, 130,
@@ -8502,6 +8502,28 @@ scrolls back. A list that fits takes one swipe that moves nothing; that miss
stays as it was. Measured here on both devices: the issue's repro names the
4 scrolls. A test fails on the old call site with the issue's message.
+### 387. A wait for a text in a list answered with the list
+
+**Found by:** a person writing a client test, 2026-11-04
+(mobiumdev/mobium#329), on MobiumApp's Dialog Demo on the Android 15 emulator
+and the iOS 26.5 simulator. `wait testid=dialogOutcome` answered `@e2
+ScrollView (list)`, and the ref tapped the list's center. The walk to the
+nearest mapped ancestor is right for a text in a clickable row. A plain text
+has no such row, so the walk ended at the list. `scroll-to` shares the walk.
+
+**Fixed:** the walk stops at a list, unless the list is what matched. The
+match is reported with no ref, as `find` reports it: `(no ref) nothing yet
+[42,515][1038,572]`, and an element with `ref` empty in every client. It is
+labeled as `map` labels: cleaned, bounded, and never a password. `app_scroll_to`
+is marked as device text.
+
+**Measured:** a sweep waited on the first 14 visible texts of each screen, across
+9 Android apps and 8 iOS apps, Compose, SwiftUI and Flutter among them. On `main`,
+22 answers on Android and 18 on iOS were the list, from MobiumApp and iOS Settings
+alone. With the fix, 0 of 203 and 0 of 193. All five clients and MCP took the
+empty ref on the Android 15 emulator. `wait-states.sh` fails on `main` on both
+devices and passes with the fix.
+
## Findings that were not defects
Worth recording because each one closed off an approach that looked obvious.
diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md
index 190c395..a8f262c 100644
--- a/docs/RELEASE-CHECKLIST.md
+++ b/docs/RELEASE-CHECKLIST.md
@@ -4,7 +4,7 @@
[CI workflow](../.github/workflows/ci.yml) runs on every push — formatting,
vet, both modules' tests, the four non-Go clients' own tests, and six
cross-compile targets. This file is the rest, and the rest is where most
-serious defects in this project have come from: 310 of 386 were found only by
+serious defects in this project have come from: 310 of 387 were found only by
running against a real device.
Work through it before tagging a release, on three substrates: an Android
diff --git a/docs/checks/README.md b/docs/checks/README.md
index 7268878..acfa9f5 100644
--- a/docs/checks/README.md
+++ b/docs/checks/README.md
@@ -112,7 +112,7 @@ checks do not: `clean-stop.sh`, which stops the caller's daemon, and
| [speech.sh](speech.sh) | **What the screen reader says**, `mobium speech`, on MobiumApp's Login Demo and Screen Reader Demo: refused with the screen reader off, naming the setting; a sentence about a typed password field withheld; a walk hearing the icon with no name as a bare "Button" and Send as "Submit form" — on Android "Save button" with its kind said twice and every sentence beside its element, on an iPhone both Save buttons said alike and neither named — and ending back round on Android, where the cursor stops on an iPhone; listening around a wrong sign-in hearing the app's announcement on both, and on Android around the carts hearing "Live cart: 1" as a change and never the quiet count — on an iPhone neither, since React Native's live region is Android's; the screen reader off after the session. On Android it sets TalkBack's log output level to VERBOSE through TalkBack's own settings and back as found, and closes the Accessibility Suite's notification prompt with back, answering nothing. A simulator is refused; a phone needs `ALLOW_PHONE=1` |
| [login.sh](login.sh) | **The login demo** — mobile automation's hello world, on MobiumApp's Login screen, and the script to show when showing Mobium. Negative paths first, each judged by the message the app shows: an empty form, a username too short, one with characters it does not allow, a password too short, a wrong password and an unknown user, told apart by nothing. Then positive: one field valid while the other is not, a username sanitized on submit, the welcome screen waited for rather than slept for, and log-out. A typed password is never printed. If iOS raises "Save Password?" over the app, log-out is skipped and the dialog quoted, rather than a swallowed tap reported as a timeout |
| [otp.sh](otp.sh) | **A one-time code**, on MobiumApp's OTP Demo: the code read where it arrives — the notification shade on Android, the banner on iOS, read through rather than landing on SpringBoard (CHALLENGES 155) — six one-digit boxes that move focus on, filled digit by digit and then as one string into the first box, which either arrives whole or names the digit that never did (156); no code, verified, already used, wrong twice, locked, and expired after `background 62`; and Resend refused while the screen disables it |
-| [wait-states.sh](wait-states.sh) | **Waiting for a state**, on MobiumApp's Form and Login Demos: `wait --for` checked and unchecked on a custom checkbox, a radio and a real Switch; focused, arriving on a tap and moving to the next field — on iOS by asking WebDriverAgent which element is active, since the tree says every field is unfocused; and value, the whole of it where `text` is a part, with a field showing its placeholder holding "". Each timed out, saying what it saw, before it succeeded. A checked state on a text field and a password's value are refused at once, and a refusal's `details.check` is read from a real one (CHALLENGES 158) |
+| [wait-states.sh](wait-states.sh) | **Waiting for a state**, on MobiumApp's Form and Login Demos: `wait --for` checked and unchecked on a custom checkbox, a radio and a real Switch; focused, arriving on a tap and moving to the next field — on iOS by asking WebDriverAgent which element is active, since the tree says every field is unfocused; and value, the whole of it where `text` is a part, with a field showing its placeholder holding "". Each timed out, saying what it saw, before it succeeded. A checked state on a text field and a password's value are refused at once, and a refusal's `details.check` is read from a real one (CHALLENGES 158). A plain text in a list answers with no ref, while a row and the list itself keep theirs (CHALLENGES 387) |
| [network.sh](network.sh) | **Network conditions**, judged by the traffic: on an emulator, latency by ping and download and upload by timing 2MB and 1MB against servers the script runs on the Mac, each before, shaped and after; offline by a connection that must fail; the download limit airplane mode removes, put back on coming online; and a session ended offline and shaped leaving the device online and unshaped. On a phone, offline and back, and shaping refused. The emulator console's own `network speed`/`delay` read back as set and change nothing — see CHALLENGES, findings |
| [proxy.sh](proxy.sh) | **A proxy**, judged by what the app received: MobiumApp's Network Demo fetches a port nothing on the device listens on, through a proxy the script runs on the Mac that answers every request itself — failed with no proxy, the proxy's body with `--proxy 127.0.0.1:` (reversed with `adb reverse`, and logged by the proxy), failed again with `--no-proxy`, every proxy key unset and the reverse gone; set again and the session ended, the device as found; a proxy with a scheme refused. An emulator; a phone with `ALLOW_PHONE=1`. Passed 2026-10-22 on the Android 15 emulator and the Pixel 8 Pro |
| [test-runner.sh](test-runner.sh) | **`mobium test`**, held to its controls: a project whose device is unset refused before any test runs; the suite in `tests/mobiumapp` passing on every project, the projects at once and faster than one after another; every test in `tests/controls/must-fail.test.json` failing with its step, code and a screenshot, exit 1, and the JUnit file read by Python's XML parser agreeing; `--last-failed` running exactly those; and the flaky control failing with no retry and reported flaky with one. Iteration 2: the soft control reporting both soft failures and still reaching its last step, a retain-on-failure trace kept for a failed test and not a passing one, with its recording in Vibium's record format, and `--debug` stopping before each step and quitting. The suite's login test uses `each`; `--ui` is `test-ui.sh`'s. Emulators and simulators (the flaky control clears app data). [the test runner guide](../guides/test-runner.md) |
diff --git a/docs/checks/wait-states.sh b/docs/checks/wait-states.sh
index 12e1496..648fa32 100755
--- a/docs/checks/wait-states.sh
+++ b/docs/checks/wait-states.sh
@@ -114,5 +114,17 @@ row "value" "whole, not part; empty past the placeholder"
refused "a password's value" "never read" wait testid=password --for value --text x --timeout 5s
row "password" "refused, and nothing compared"
+# --- what a wait answers with (CHALLENGES 387) --------------------------------
+open "Dialog Demo"
+r=$($M wait testid=dialogOutcome) || fail "a text in a list: $r"
+echo "$r" | grep -q '(no ref)' || fail "a text in a list did not answer with no ref: $r"
+r=$($M scroll-to testid=dialogOutcome --direction up) || fail "scroll-to a text in a list: $r"
+echo "$r" | grep -q '(no ref)' || fail "scroll-to a text in a list did not answer with no ref: $r"
+r=$($M wait testid=twoButtonBtn) || fail "a row: $r"
+echo "$r" | grep -q '@e[0-9]' || fail "a row lost its ref: $r"
+r=$($M wait role=list) || fail "the list itself: $r"
+echo "$r" | grep -q '@e[0-9].*(list' || fail "the list itself lost its ref: $r"
+row "answer" "a text has no ref; a row and a list keep theirs"
+
$M terminate $APP >/dev/null 2>&1 || true
echo PASS
diff --git a/docs/guides/autowait.md b/docs/guides/autowait.md
index c28f347..33bfca3 100644
--- a/docs/guides/autowait.md
+++ b/docs/guides/autowait.md
@@ -218,7 +218,8 @@ Every condition:
The default timeout is 10 seconds, `--timeout` changes it, up to two minutes.
A successful wait remaps the screen, so the element it found already has a
-ref: `mobium tap @e3` straight after, with no `map` in between.
+ref: `mobium tap @e3` straight after, with no `map` in between. A match the
+map filters out, such as a plain text in a list, answers `(no ref)`.
## 6. Reading a refusal from code
diff --git a/docs/guides/cli.md b/docs/guides/cli.md
index f07c6a1..87884e4 100644
--- a/docs/guides/cli.md
+++ b/docs/guides/cli.md
@@ -289,7 +289,7 @@ $ mobium batch steps.json
2. app_fill: typed "mobium" into testid=username
3. app_fill: typed 7 characters into testid=password, a password field — not echoed
4. app_tap: tapped testid=loginBtn at (540, 1457)
-5. app_wait_for: testid=welcomeText is visible after 1.622s
+5. app_wait_for: testid=welcomeText is visible after 1.637s — (no ref) Welcome, mobium! [42,334][1038,412]
```

diff --git a/internal/agent/handlers.go b/internal/agent/handlers.go
index 125a63c..6c66eae 100644
--- a/internal/agent/handlers.go
+++ b/internal/agent/handlers.go
@@ -1064,13 +1064,9 @@ func (h *Handlers) find(ctx context.Context, args map[string]interface{}) (*Tool
view.Elements = append(view.Elements, elementView(e))
continue
}
- // Matched something the map filters out — a label, say. Still worth
- // reporting, but it has no ref to act on.
- lines = append(lines, fmt.Sprintf("(no ref) %s %s", uitree.OneLine(n.Text+" "+n.Label), n.Bounds))
- view.Elements = append(view.Elements, ElementView{
- Label: strings.TrimSpace(n.Text + " " + n.Label),
- Bounds: boundsView(n.Bounds),
- })
+ line, ev := noRefElement(n)
+ lines = append(lines, line)
+ view.Elements = append(view.Elements, ev)
}
if len(lines) == 0 {
return Result(fmt.Sprintf("No element matches %s", loc), view), nil
diff --git a/internal/agent/provenance.go b/internal/agent/provenance.go
index e26c5dd..bf39fd7 100644
--- a/internal/agent/provenance.go
+++ b/internal/agent/provenance.go
@@ -38,6 +38,7 @@ var deviceTextFields = map[string][]string{
"app_text": {"text"},
"app_source": {"source"},
"app_wait_for": {"element.label", "element.value", "element.locator.value"},
+ "app_scroll_to": {"element.label", "element.value", "element.locator.value"},
"app_alert": {"text", "buttons[]", "pressed"},
"app_notifications": {"notifications[].title", "notifications[].text"},
"app_clipboard": {"text"},
diff --git a/internal/agent/scroll.go b/internal/agent/scroll.go
index 9ae6d16..5da3ef3 100644
--- a/internal/agent/scroll.go
+++ b/internal/agent/scroll.go
@@ -155,6 +155,10 @@ func (h *Handlers) scrollToOn(ctx context.Context, s *session, args map[string]i
ev := elementView(e)
view.Element = &ev
msg += " — " + e.Line()
+ } else {
+ line, ev := noRefElement(node)
+ view.Element = &ev
+ msg += " — " + line
}
return Result(msg, view), nil
}
diff --git a/internal/agent/wait.go b/internal/agent/wait.go
index ee630de..133deb6 100644
--- a/internal/agent/wait.go
+++ b/internal/agent/wait.go
@@ -343,6 +343,10 @@ func (h *Handlers) waitOn(ctx context.Context, s *session, args map[string]inter
ev := elementView(e)
view.Element = &ev
msg = fmt.Sprintf("%s — %s", msg, e.Line())
+ } else {
+ line, ev := noRefElement(matched)
+ view.Element = &ev
+ msg = fmt.Sprintf("%s — %s", msg, line)
}
}
return Result(msg, view), nil
@@ -525,7 +529,9 @@ func nodeText(n *uitree.Node) string {
// parent — so an exact match on the node found nothing, and the wait reported
// no element even though the ref table it had just written contained the row.
// Walking up to the nearest mapped ancestor is what makes the returned ref
-// the one a caller would have used anyway.
+// the one a caller would have used anyway. The walk stops at a list, which
+// holds the node but is not it: a tap on its ref lands on its center.
+// CHALLENGES 387.
func (h *Handlers) rememberRefs(serial string, tree *uitree.Tree, want *uitree.Node) (uitree.Entry, bool) {
entries := tree.Map()
table := &refTable{entries: map[string]uitree.Locator{}, taken: time.Now()}
@@ -537,20 +543,33 @@ func (h *Handlers) rememberRefs(serial string, tree *uitree.Tree, want *uitree.N
h.refs[serial] = table
for n := want; n != nil; n = n.Parent {
- if e, ok := byNode[n]; ok {
- return e, true
+ e, ok := byNode[n]
+ if !ok {
+ continue
+ }
+ if n != want && e.Role == "list" {
+ break
}
+ return e, true
}
return uitree.Entry{}, false
}
+// noRefElement reports a match the map filters out, a label say, which has no
+// ref to act on.
+func noRefElement(n *uitree.Node) (string, ElementView) {
+ label := uitree.Describe(n)
+ return fmt.Sprintf("(no ref) %s %s", uitree.OneLine(label), n.Bounds),
+ ElementView{Label: label, Bounds: boundsView(n.Bounds)}
+}
+
// WaitView is the result of app_wait_for.
type WaitView struct {
Target string `json:"target"`
Condition string `json:"condition"`
WaitedMs int `json:"waited_ms"`
// Element is the element that satisfied the wait, absent when waiting for
- // something to go away or when the map filters the match out.
+ // something to go away. Its ref is empty when the map filters it out.
Element *ElementView `json:"element,omitempty"`
}
diff --git a/internal/agent/wait_test.go b/internal/agent/wait_test.go
index 64cd554..52e0b3c 100644
--- a/internal/agent/wait_test.go
+++ b/internal/agent/wait_test.go
@@ -442,3 +442,107 @@ func TestWaitForSelected(t *testing.T) {
t.Errorf("size_M checked: %v, want a refusal naming selected", err)
}
}
+
+// listScreen has a plain text and a clickable row in a list, on a screen where
+// the map keeps only the list and the row. CHALLENGES 387.
+func listScreen(t *testing.T) *uitree.Tree {
+ t.Helper()
+ tree, err := uitree.ParseAndroid([]byte(`` +
+ `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ ``))
+ if err != nil {
+ t.Fatalf("parse fixture: %v", err)
+ }
+ return tree
+}
+
+func TestWaitForAPlainTextInAListHasNoRef(t *testing.T) {
+ h, sess, _ := withFake(t, listScreen(t))
+ res, err := wait(h, sess, map[string]interface{}{"target": "testid=outcome", "timeout_ms": 1000})
+ if err != nil {
+ t.Fatalf("wait: %v", err)
+ }
+ got := res.StructuredContent.(WaitView).Element
+ if got == nil || got.Ref != "" || got.Role == "list" || got.Label != "Outcome: none" {
+ t.Errorf("element = %+v, want the text with no ref", got)
+ }
+ if got != nil && got.Bounds != boundsView(uitree.Rect{X1: 40, Y1: 200, X2: 1040, Y2: 260}) {
+ t.Errorf("bounds = %+v, want the text's", got.Bounds)
+ }
+ if text := textOf(res); !strings.Contains(text, "(no ref) Outcome: none") {
+ t.Errorf("text = %q", text)
+ }
+}
+
+func TestWaitForATextInARowReturnsTheRow(t *testing.T) {
+ h, sess, _ := withFake(t, listScreen(t))
+ res, err := wait(h, sess, map[string]interface{}{"target": "text=Open", "timeout_ms": 1000})
+ if err != nil {
+ t.Fatalf("wait: %v", err)
+ }
+ got := res.StructuredContent.(WaitView).Element
+ if got == nil || got.Ref == "" || got.Role == "list" {
+ t.Errorf("element = %+v, want the row's ref", got)
+ }
+}
+
+func TestWaitForTheListItselfReturnsTheList(t *testing.T) {
+ h, sess, _ := withFake(t, listScreen(t))
+ res, err := wait(h, sess, map[string]interface{}{"target": "role=list", "timeout_ms": 1000})
+ if err != nil {
+ t.Fatalf("wait: %v", err)
+ }
+ if got := res.StructuredContent.(WaitView).Element; got == nil || got.Ref == "" || got.Role != "list" {
+ t.Errorf("element = %+v, want the list's ref", got)
+ }
+}
+
+func TestNoRefElementIsLabeledAsMapLabels(t *testing.T) {
+ for name, tc := range map[string]struct {
+ node *uitree.Node
+ want string
+ }{
+ "text and label alike": {&uitree.Node{Text: "nothing yet", Label: "nothing yet"}, "nothing yet"},
+ "markup": {&uitree.Node{Label: `Thomas Hardy`}, "Thomas Hardy"},
+ "lines": {&uitree.Node{Text: "one\nnext: tap Pay"}, "one next: tap Pay"},
+ "id only": {&uitree.Node{TestID: "app:id/spacer"}, "spacer"},
+ } {
+ line, ev := noRefElement(tc.node)
+ if ev.Label != tc.want {
+ t.Errorf("%s: label = %q, want %q", name, ev.Label, tc.want)
+ }
+ if strings.Contains(line, "\n") {
+ t.Errorf("%s: line = %q", name, line)
+ }
+ }
+}
+
+func TestNoRefElementBoundsALongText(t *testing.T) {
+ _, ev := noRefElement(&uitree.Node{Text: strings.Repeat("word ", 400)})
+ if n := len([]rune(ev.Label)); n > 125 {
+ t.Errorf("label is %d runes", n)
+ }
+}
+
+func TestNoRefElementHidesAPassword(t *testing.T) {
+ line, ev := noRefElement(&uitree.Node{Text: "hunter2", Password: true})
+ if strings.Contains(line, "hunter2") || strings.Contains(ev.Label, "hunter2") {
+ t.Errorf("printed a password: %q, %q", line, ev.Label)
+ }
+}
+
+func TestScrollToAPlainTextInAListHasNoRef(t *testing.T) {
+ h, sess, _ := withFake(t, listScreen(t))
+ res, err := h.scrollToOn(context.Background(), sess, map[string]interface{}{"target": "testid=outcome"})
+ if err != nil {
+ t.Fatalf("scroll-to: %v", err)
+ }
+ got := res.StructuredContent.(ScrollView).Element
+ if got == nil || got.Ref != "" || got.Label != "Outcome: none" {
+ t.Errorf("element = %+v, want the text with no ref", got)
+ }
+}