diff --git a/docs/CHALLENGES.md b/docs/CHALLENGES.md index 4af2d37..ba9a057 100644 --- a/docs/CHALLENGES.md +++ b/docs/CHALLENGES.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0d87fe8..61858fa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/guides/inspector.md b/docs/guides/inspector.md index b269683..624d92a 100644 --- a/docs/guides/inspector.md +++ b/docs/guides/inspector.md @@ -81,7 +81,7 @@ 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 @@ -89,6 +89,16 @@ $ mobium test recorded.test.json 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. diff --git a/internal/agent/weblocator_test.go b/internal/agent/weblocator_test.go index 74235be..aadb348 100644 --- a/internal/agent/weblocator_test.go +++ b/internal/agent/weblocator_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/mobiumdev/mobium/internal/mobiumerr" + "github.com/mobiumdev/mobium/internal/uitree" "github.com/mobiumdev/mobium/internal/webview" ) @@ -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) + } + } +} diff --git a/internal/agent/webview.go b/internal/agent/webview.go index 3b24338..7eda098 100644 --- a/internal/agent/webview.go +++ b/internal/agent/webview.go @@ -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) } @@ -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 { diff --git a/internal/inspect/inspect.go b/internal/inspect/inspect.go index a9d22ac..93ccc7f 100644 --- a/internal/inspect/inspect.go +++ b/internal/inspect/inspect.go @@ -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. diff --git a/internal/inspect/inspect_test.go b/internal/inspect/inspect_test.go index f5ebf4d..b8f3b10 100644 --- a/internal/inspect/inspect_test.go +++ b/internal/inspect/inspect_test.go @@ -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) + } + } +}