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
21 changes: 21 additions & 0 deletions docs/CHALLENGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6736,6 +6736,27 @@ simulator. MobiumTV has no dialog of three buttons; the check returns
before the buttons are counted, so the two-button dialog takes the same
line.

### 314. A recording wrote locators that named less than `map` had, and refs

**Found by:** writing down a recording as code (ROADMAP, #202) and reading
the recorder's `target` for what it would emit. Two things, both measured
after: `map` derives every native locator as the whole value and checks it
names one element so (`uitree.Derive`), and the inspector rebuilt it
without `Exact`. So it recorded `text=Welcome`, which "Welcome back" meets
too: the race of CHALLENGES 233, written into a test by the tool meant to
write tests. And a page element with no id or `data-testid` had no locator
at all: `map` derived only a CSS selector, and the recorder fell back to
the element's ref, which names it on that screen only. That is most of a
real page's links: example.com's "Learn more" had only `@e1`. The guide
said a recording was "never by ref". **Fixed:** the recorder keeps
`Exact`, so a recording quotes each locator (`testid="username"`), and
`map` gives a page element with no id the locator its whole text names it
by, narrowed by role where the text names several, checked against the
page as a locator is resolved (#204). On the iPhone 17 Pro simulator, in
Safari: example.com's link mapped as `text="Learn more"`, a tap by it went
to iana.org's page, and the inspector, driven through its own page's
calls, showed that target and recorded `{"tap": "text=\"Learn more\""}`.

## Findings that were not defects

Worth recording because each one closed off an approach that looked obvious.
Expand Down
14 changes: 6 additions & 8 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -617,14 +617,12 @@ this is what is not.
5. **The file written as it grows**, for a person who records into an
editor, rather than downloaded at the end.

Two things to measure before any of it, both read from
`internal/inspect/inspect.go`'s `target` and neither yet run.
- It falls back to the element's ref when `map` gave no locator, and a
ref names an element on one screen only. The guide says a recording
is "never by ref".
- It rebuilds the locator without `Exact`, so a quoted locator from
`map` (CHALLENGES 306) may be recorded unquoted, matching part of a
text.
Two things to measure before any of it, both done 2026-10-12
(CHALLENGES 314): a recording dropped `Exact`, so `text=Welcome` was
recorded for an element `map` had named whole, and a page element with
no id or test id, most of a real page's links, had no locator and was
recorded by its ref. Each locator is now recorded quoted, and such an
element gets its whole text from `map`, narrowed by role where needed.

6. **Touches made on the device itself, recorded.** How the touch is
caught differs by device, but what it becomes does not: a locator
Expand Down
12 changes: 11 additions & 1 deletion docs/guides/inspector.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,24 @@ action is a step. Recorded on the Login Demo:
```

(Spaced for reading.) **Download test** saves it as `recorded.test.json`, in
the step shorthand and by locator, never by ref. It runs as it is:
the step shorthand and by locator. It runs as it is:

```
$ mobium test recorded.test.json
ok [default · emulator-5554] recorded.test.json › recorded (8.5s)
1 passed (8.5s)
```

Since 2026-10-12 a recording quotes each locator — `testid="username"`,
`text="Welcome"` — because `map` derives every one as the whole value and
checks that it names one element so; the run above predates that, and an
unquoted `text=Welcome` is met by "Welcome back" too. A page element with
no id or test id is recorded by its whole text, narrowed by its role where
the text names several: example.com's link records as `text="Learn more"`.
Only a page element nothing names alone, not even with its role, is recorded
by its ref, which names it on that screen only — replace it before running
the test.

A recording keeps what you typed, the password included; that is the demo
account's here, and worth remembering before recording against your own.

Expand Down
24 changes: 24 additions & 0 deletions internal/agent/weblocator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/mobiumdev/mobium/internal/mobiumerr"
"github.com/mobiumdev/mobium/internal/uitree"
"github.com/mobiumdev/mobium/internal/webview"
)

Expand Down Expand Up @@ -83,3 +84,26 @@ func TestAFramedMatchIsNamedByItsFrame(t *testing.T) {
t.Error("a hint with nothing elsewhere")
}
}

// A page element with no id and no test id is given the locator its whole
// text names it by, narrowed by role where the text names several — map
// gave it only a ref, and a recording wrote the ref into a test.
func TestAPageElementWithNoIDGetsALocator(t *testing.T) {
els := []webview.Element{
{Tag: "a", Label: "Learn more"},
{Tag: "a", Label: "More"},
{Tag: "button", Label: "More"},
{Tag: "button", Label: "Save"},
{Tag: "button", Label: "Save"},
{Tag: "div"},
}
for i, want := range []string{`text="Learn more"`, `text="More",role=link`, `text="More",role=button`, "", "", ""} {
got := ""
if v := deriveWebLocator(els, i); v != nil {
got = uitree.Locator{Kind: uitree.Kind(v.Kind), Value: v.Value, Exact: v.Exact, Role: v.Role}.String()
}
if got != want {
t.Errorf("element %d (%q): %q, want %q", i, els[i].Label, got, want)
}
}
}
35 changes: 35 additions & 0 deletions internal/agent/webview.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,8 @@ func (h *Handlers) listWeb(ctx context.Context, s *session, els []webview.Elemen
}
if sel := e.Selector(); sel != "" {
item.Locator = &LocatorView{Kind: "css", Value: sel, Exact: true}
} else {
item.Locator = deriveWebLocator(els, i)
}
view.Elements = append(view.Elements, item)
}
Expand Down Expand Up @@ -425,6 +427,39 @@ func (h *Handlers) listWeb(ctx context.Context, s *session, els []webview.Elemen
return Result(strings.Join(lines, "\n"), view), nil
}

// deriveWebLocator is, for a page element with no id and no test id, the
// locator its whole text gives it, narrowed by role where the text alone
// names several, checked against the page as a locator would be resolved,
// or nil when neither names it alone. Without it map gave such an element
// only a ref, and a recording wrote the ref into a test that ran on another
// screen: most of a real page's links have no id. A native element's
// locator is derived the same way (uitree.Derive).
func deriveWebLocator(els []webview.Element, i int) *LocatorView {
label := strings.TrimSpace(els[i].Label)
if label == "" {
return nil
}
names := func(loc uitree.Locator) bool {
var idx []int
for j, e := range els {
if webMatches(loc, e) {
idx = append(idx, j)
}
}
idx = preferWhole(loc, els, idx)
return len(idx) == 1 && idx[0] == i
}
loc := uitree.Locator{Kind: uitree.KindText, Value: label, Whole: true}
if names(loc) {
return &LocatorView{Kind: string(uitree.KindText), Value: label, Exact: true}
}
loc.Role = els[i].NeutralRole()
if loc.Role != "" && names(loc) {
return &LocatorView{Kind: string(uitree.KindText), Value: label, Exact: true, Role: loc.Role}
}
return nil
}

// firstSentence trims a long explanation down for a footnote, keeping the
// sentence that says what is wrong.
func firstSentence(s string) string {
Expand Down
6 changes: 5 additions & 1 deletion internal/inspect/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,11 @@ func target(e agent.ElementView) string {
if e.Locator == nil {
return e.Ref
}
return uitree.Locator{Kind: uitree.Kind(e.Locator.Kind), Value: e.Locator.Value, Role: e.Locator.Role}.String()
// Exact carried over: map derives every locator as the whole value and
// checks it names one element so, and without it the recording wrote
// text=Welcome, which "Welcome back" meets too (CHALLENGES 233).
return uitree.Locator{Kind: uitree.Kind(e.Locator.Kind), Value: e.Locator.Value, Exact: e.Locator.Exact,
Role: e.Locator.Role}.String()
}

// Found is what a locator typed on the page resolves to.
Expand Down
20 changes: 20 additions & 0 deletions internal/inspect/inspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,23 @@ func TestWhatWasDoneIsATestFile(t *testing.T) {
t.Errorf("steps\n %s\nwant\n %s", got, want)
}
}

// A recorded step names its element by the locator map derived, as map
// derived it: every one is the whole value, so it is recorded quoted. It was
// recorded unquoted, and text=Welcome is met by "Welcome back" too.
func TestARecordedLocatorKeepsItsQuotes(t *testing.T) {
for _, c := range []struct {
loc agent.LocatorView
want string
}{
{agent.LocatorView{Kind: "text", Value: "Welcome", Exact: true}, `text="Welcome"`},
{agent.LocatorView{Kind: "label", Value: "Delete", Exact: true, Role: "button"}, `label="Delete",role=button`},
{agent.LocatorView{Kind: "testid", Value: "loginBtn", Exact: true}, `testid="loginBtn"`},
{agent.LocatorView{Kind: "css", Value: `[data-testid="wPass"]`, Exact: true}, `css=[data-testid="wPass"]`},
} {
loc := c.loc
if got := target(agent.ElementView{Ref: "@e3", Locator: &loc}); got != c.want {
t.Errorf("%+v recorded as %s, want %s", c.loc, got, c.want)
}
}
}
Loading