diff --git a/docs/CHALLENGES.md b/docs/CHALLENGES.md index 03a6604..d496a03 100644 --- a/docs/CHALLENGES.md +++ b/docs/CHALLENGES.md @@ -6690,6 +6690,29 @@ for an element that is there but off screen times out saying so and naming Table Demo: Item 25 at y 5307, `--for visible` refused "off screen", `--for hidden` passed, and after `scroll-to` visible passed. +### 312. `find` inside a WebView answered from the native screen + +**Found by:** giving WebViews locators (ROADMAP, locators item 4) and +checking them on MobiumApp's Actionability and Frames pages, each tap +judged by what the page said it reached. With a WebView's context +selected, every action named the page's element, but `find role=button` +listed the app's own Back button first and then the page's buttons as +the platform's accessibility saw them: `app_find` never asked which +context was selected, read the native screen, and gave its refs. The +inspector's Find goes through `app_find`, so its count of matches was +the native screen's too, and a ref find handed out was not the one `map` +would. **Fixed:** in a WebView `find` matches the page's elements by the +rules an action uses and gives each the ref `map` gives it; a ref from +`find` reached the same button on the emulator and the simulator. + +Two refusals there said less than they knew. Three "Tap me" buttons, one +per frame, were refused as `"Tap me" (button)` three times, and now each +names its frame. And `css=#sameBtn`, a selector written for a frame's own +document, was "no element … app_map lists what it has". The button is +there, in `iframe#sameFrame`. A selector without its frame names only +the page's own elements, so the refusal, and `find`'s answer, now give +the path that reaches it: `css=iframe#sameFrame >> #sameBtn`. + ## 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 e6406fb..d5e671c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -544,10 +544,14 @@ this is what is not. All three are asserted by `docs/checks/locators.sh` on MobiumApp's Table Demo, on both platforms. - 4. **CSS selectors, and text, inside a WebView**, where only refs work - today: CSS is the page's own way to name an element, `map` already - derives a selector for each, and matching would go through the same - checks. + 4. ~~**CSS selectors, and text, inside a WebView**~~ Done 2026-10-12: + `css=` with an optional frame path, and text, label, testid, role and + class matched against the page map, quotes and `,nth=` included; the + element found goes through every web action's checks, and `find` + answers from the page (CHALLENGES 312). Checked on MobiumApp's + Actionability and Frames pages on the Android 15 emulator and the + iPhone 17 Pro simulator, cross-origin frames included. `wait` inside + a WebView is still refused — the next step for a test file in a page. 5. **An XPath over the platform's own tree, only once an app needs it** — evaluated by Mobium, so the checks still apply, and refused on a platform it was not written for. XPath is what the neutral kinds diff --git a/docs/guides/cli.md b/docs/guides/cli.md index aca5d91..5578fba 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -150,6 +150,15 @@ not move with rows kept off screen; past the last, nothing matches. It comes last, after any `,role=`. It is brittle, since a row added above moves it: prefer `within` where a row says which. +Inside a WebView, after `mobium context WEBVIEW_…`, a locator matches what +`map` reads of the page: `text=` and `label=` an element's accessible name, +`testid=` its `data-testid` or `id`, `role=` its role — quotes and `,nth=` as +above, counted down the page. `css=` takes a CSS selector: `mobium tap +'css=button.save'`, with a frame's path before it as `map` prints one, +`'css=iframe#f >> button'`. Only what `map` lists can be named, since that is +what an action can touch. `within` is for native screens; a selector says +what is inside what. + A locator that matches more than one element is refused, never guessed at; append a role — `label=Apps,role=button` — or use a ref. Every action waits for its target to be ready before it touches it — [auto-wait](autowait.md) is diff --git a/internal/agent/handlers.go b/internal/agent/handlers.go index 6828b7f..287529e 100644 --- a/internal/agent/handlers.go +++ b/internal/agent/handlers.go @@ -918,14 +918,18 @@ func (h *Handlers) text(ctx context.Context, args map[string]interface{}) (*Tool } func (h *Handlers) find(ctx context.Context, args map[string]interface{}) (*ToolsCallResult, error) { - dev, driver, err := h.session(ctx, args) + s, err := h.sessionFor(ctx, args) if err != nil { return nil, err } + dev, driver := s.dev, s.driver raw := stringArg(args, "locator") if raw == "" { return nil, mobiumerr.New(mobiumerr.InvalidArgument, "app_find needs a locator, e.g. \"text=Sign In\"") } + if s.web != nil { + return h.findInWeb(ctx, s, raw) + } loc, err := uitree.ParseLocator(raw) if err != nil { return nil, err diff --git a/internal/agent/schema.go b/internal/agent/schema.go index c5e553b..0eafae7 100644 --- a/internal/agent/schema.go +++ b/internal/agent/schema.go @@ -357,7 +357,10 @@ func GetToolSchemas() []Tool { Description: "Switch between the native shell and a WebView, or report the current " + "context when called with no argument. While a WebView context is active, " + "app_map and app_tap operate on the page's elements; taps are still delivered " + - "as real touches on the device.", + "as real touches on the device. A locator there matches what app_map reads of " + + "the page — text and label an element's accessible name, testid its " + + "data-testid or id, role its role — and css= takes a CSS selector, with a " + + "frame's path before it as app_map prints one (\"iframe#f >> button\").", InputSchema: map[string]interface{}{ "type": "object", "properties": withDevice(map[string]interface{}{ diff --git a/internal/agent/weblocator_test.go b/internal/agent/weblocator_test.go new file mode 100644 index 0000000..74235be --- /dev/null +++ b/internal/agent/weblocator_test.go @@ -0,0 +1,85 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/mobiumdev/mobium/internal/mobiumerr" + "github.com/mobiumdev/mobium/internal/webview" +) + +// A locator inside a WebView matches what the page map reads of each +// element, by a native locator's rules — it was refused outright, so a test +// file, which has no refs, could not touch a page. +func TestALocatorFindsAPageElement(t *testing.T) { + els := []webview.Element{ + {Tag: "button", Label: "Save", X: 10, Y: 20, W: 100, H: 20}, + {Tag: "button", Label: "Save draft", X: 10, Y: 60, W: 100, H: 20}, + {Tag: "a", Label: "Learn more", TestID: "more", X: 10, Y: 100, W: 100, H: 20}, + {Tag: "button", Label: "Delete", X: 10, Y: 140, W: 100, H: 20}, + {Tag: "button", Label: "Delete", X: 10, Y: 180, W: 100, H: 20}, + } + h := NewHandlers() + s := &session{dev: fakeDevice(), driver: &hostDriver{}, backend: BackendUIA2, + web: &layoutPage{els: els}, webCtx: "WEBVIEW_test"} + find := func(target string) (webview.Element, error) { + _, e, _, err := h.findWeb(context.Background(), s, target) + return e, err + } + for target, want := range map[string]string{ + "text=Save": "Save", // the whole match among a part + `text="Save draft"`: "Save draft", + "testid=more": "Learn more", + "role=link": "Learn more", + "text=Delete,nth=2": "Delete", + "label=Learn,role=link": "Learn more", + } { + e, err := find(target) + if err != nil || e.Label != want { + t.Errorf("%s: got %q, %v; want %q", target, e.Label, err, want) + } + } + if e, _ := find("text=Delete,nth=2"); e.Y != 180 { + t.Errorf("nth=2 counted down the page to y %v, want 180", e.Y) + } + if _, err := find("text=Delete"); mobiumerr.CodeOf(err) != mobiumerr.AmbiguousLocator || !strings.Contains(err.Error(), "2 elements") { + t.Errorf("two Deletes: %v", err) + } + if _, err := find("text=Nowhere"); mobiumerr.CodeOf(err) != mobiumerr.NoSuchElement { + t.Errorf("nothing: %v", err) + } + if _, err := find("label=Delete within text=Save"); mobiumerr.CodeOf(err) != mobiumerr.Unsupported || + !strings.Contains(err.Error(), "css=") { + t.Errorf("within in a page: %v", err) + } +} + +// Look-alikes in frames are told apart by their frame in a refusal, and a +// selector written for a frame's own document, without the frame, is +// answered with the path that reaches what it matched — MobiumApp's +// three frames each hold a "Tap me". +func TestAFramedMatchIsNamedByItsFrame(t *testing.T) { + els := []webview.Element{ + {Tag: "button", Label: "Tap me", Frame: "iframe#a", X: 10, Y: 20, W: 100, H: 20}, + {Tag: "button", Label: "Tap me", Frame: "iframe#a >> iframe#b", X: 10, Y: 60, W: 100, H: 20}, + } + h := NewHandlers() + s := &session{dev: fakeDevice(), driver: &hostDriver{}, backend: BackendUIA2, + web: &layoutPage{els: els}, webCtx: "WEBVIEW_test"} + _, _, _, err := h.findWeb(context.Background(), s, `text="Tap me"`) + if mobiumerr.CodeOf(err) != mobiumerr.AmbiguousLocator || + !strings.Contains(err.Error(), `"Tap me" (button) in iframe#a >> iframe#b`) { + t.Errorf("two framed look-alikes: %v", err) + } + got := framedHint(els, []int{1}, "#tap") + if !strings.Contains(got, "css=iframe#a >> iframe#b >> #tap") { + t.Errorf("a selector without its frame: %q", got) + } + if got := framedHint(els, []int{0}, "iframe#x >> #tap"); !strings.Contains(got, "css=iframe#a >> #tap") { + t.Errorf("a selector with the wrong frame: %q", got) + } + if framedHint(els, nil, "#tap") != "" { + t.Error("a hint with nothing elsewhere") + } +} diff --git a/internal/agent/webview.go b/internal/agent/webview.go index efa2873..3b24338 100644 --- a/internal/agent/webview.go +++ b/internal/agent/webview.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/mobiumdev/mobium/internal/mobiumdriver" "github.com/mobiumdev/mobium/internal/mobiumerr" + "sort" "strings" "time" @@ -319,7 +320,40 @@ func (h *Handlers) mapWeb(ctx context.Context, s *session) (*ToolsCallResult, er if err != nil { return nil, err } + return h.listWeb(ctx, s, els, unreached, nil) +} + +// findInWeb is app_find inside a WebView: the page's elements a locator +// matches, each by the ref app_map gives it. Every element still gets its +// ref, so a ref find hands out is the one map would. It answered from the +// native screen before, and so listed the app's own Back button. +func (h *Handlers) findInWeb(ctx context.Context, s *session, target string) (*ToolsCallResult, error) { + els, unreached, err := webview.MapAll(ctx, s.web) + if err != nil { + return nil, err + } + idx, hint, err := webIndexes(ctx, s, els, target) + if err != nil { + return nil, err + } + keep := map[int]bool{} + for _, i := range idx { + keep[i] = true + } + res, err := h.listWeb(ctx, s, els, unreached, keep) + if err != nil || len(idx) > 0 { + return res, err + } + text := fmt.Sprintf("No element matches %s in %s", target, s.webCtx) + if hint != "" { + text += " — " + hint + } + return Result(text, res.StructuredContent), nil +} +// listWeb records a ref for each of a page's elements and lists them, or, +// with keep, only those. +func (h *Handlers) listWeb(ctx context.Context, s *session, els []webview.Element, unreached int, keep map[int]bool) (*ToolsCallResult, error) { // Bounds are a bonus here, not a precondition. If the page and its host // element disagree about geometry — mobile Safari, where the WebView // covers the chrome too — the refs, labels and roles are still exactly @@ -336,6 +370,9 @@ func (h *Handlers) mapWeb(ctx context.Context, s *session) (*ToolsCallResult, er ref := fmt.Sprintf("@e%d", i+1) role := e.NeutralRole() table.web[ref] = webRef{Selector: e.Selector(), Label: e.Label, Role: role, Index: i} + if keep != nil && !keep[i] { + continue + } label := e.Label if label == "" { @@ -412,25 +449,19 @@ func (h *Handlers) resolveWeb(ctx context.Context, s *session, target string) (u // element's index among the page's map candidates, and the frame that // converts page pixels to device pixels. func (h *Handlers) findWeb(ctx context.Context, s *session, target string) (uitree.Rect, webview.Element, *webview.Frame, error) { + // A locator, not a ref: matched against the page's map now, as a ref's + // identity is. It was refused — "locators do not work inside a WebView" + // — so a test file, which has no refs, could not touch a page at all + // (ROADMAP, locators item 4). + if !strings.HasPrefix(target, "@") { + return h.findWebByLocator(ctx, s, target) + } table, ok := h.refs[s.dev.Serial] if !ok || table.web == nil { return uitree.Rect{}, webview.Element{}, nil, mobiumerr.New(mobiumerr.InvalidArgument, "no map for %s yet — run app_map first", s.webCtx) } ref, ok := table.web[target] if !ok { - // A locator is not a ref, and telling someone to re-map will never - // help them: the web map hands out refs and matches on the CSS - // selector behind each one, so there is nothing for `text=…` to - // resolve against. Found driving Wikipedia's article WebView, where - // `tap text=Charles Babbage` reported "unknown ref" and reading it - // literally sent you back to app_map forever. - if !strings.HasPrefix(target, "@") { - return uitree.Rect{}, webview.Element{}, nil, mobiumerr.New(mobiumerr.InvalidArgument, - "%q is a locator, and locators do not work inside a WebView — "+ - "use a @ref from app_map (you are in %s; `app_context NATIVE_APP` "+ - "switches back to the app shell, where locators do work)", - target, s.webCtx) - } return uitree.Rect{}, webview.Element{}, nil, mobiumerr.New(mobiumerr.InvalidArgument, "unknown ref %s in %s — run app_map again", target, s.webCtx) } @@ -922,3 +953,178 @@ func overlap(a, b uitree.Rect) float64 { inter := float64(ix * iy) return inter / (float64(area(a)+area(b)) - inter) } + +// findWebByLocator finds the one element of the page's map a locator names. +// +// css= takes a CSS selector, with a frame's path before it as map prints one +// ("iframe#f >> button"). The other kinds match what the map reads of each +// element — text and label its accessible name, testid its data-testid or id, +// role its role, class its tag — by the rules a native locator follows: a +// part in any case, the whole when quoted, a whole match preferred among +// several, and ,nth= counted down the page. Only the map's elements can +// match, since every web action picks its element from that list; one match +// goes on through the checks every web action makes, and none or several is +// refused saying what was found. +func (h *Handlers) findWebByLocator(ctx context.Context, s *session, target string) (uitree.Rect, webview.Element, *webview.Frame, error) { + frame, err := h.webFrame(ctx, s) + if err != nil { + return uitree.Rect{}, webview.Element{}, nil, err + } + els, err := s.web.Map(ctx) + if err != nil { + return uitree.Rect{}, webview.Element{}, nil, err + } + idx, hint, err := webIndexes(ctx, s, els, target) + if err != nil { + return uitree.Rect{}, webview.Element{}, nil, err + } + switch { + case len(idx) == 0 && hint != "": + return uitree.Rect{}, webview.Element{}, nil, mobiumerr.New(mobiumerr.NoSuchElement, "no element of the page "+ + "in %s matches %s: %s", s.webCtx, target, hint). + WithRemedy("the selector with the frame's path first, as app_map prints it") + case len(idx) == 0: + return uitree.Rect{}, webview.Element{}, nil, mobiumerr.New(mobiumerr.NoSuchElement, "no element of the page "+ + "in %s matches %s — app_map lists what it has", s.webCtx, target). + WithRemedy("app_map for the page's elements, and a locator or ref for one of them") + case len(idx) > 1: + var names []string + for _, i := range idx { + if len(names) == 5 { + names = append(names, "…") + break + } + // The frame tells apart what reads the same: MobiumApp's three + // frames each hold a "Tap me". + name := fmt.Sprintf("%q (%s)", els[i].Label, els[i].NeutralRole()) + if els[i].Frame != "" { + name += " in " + els[i].Frame + } + names = append(names, name) + } + return uitree.Rect{}, webview.Element{}, nil, mobiumerr.New(mobiumerr.AmbiguousLocator, "%s matches %d elements "+ + "of the page: %s", target, len(idx), strings.Join(names, ", ")). + WithRemedy("narrow it: a role (,role=button), the whole text in quotes, ,nth= for one of them, a css= selector, or a ref from app_map"). + WithDetail("matches", len(idx)) + } + e := els[idx[0]] + rect := frame.ToDevice(e.X, e.Y, e.W, e.H) + if rect.Empty() { + return uitree.Rect{}, webview.Element{}, nil, mobiumerr.New(mobiumerr.ElementNotReachable, "%s has no on-screen area", target) + } + return rect, e, frame, nil +} + +// webIndexes is which of a page's elements a locator names, by their index +// in els: a css= selector, or a locator matched by webMatches with a whole +// match preferred and ,nth= counted down the page. Several is not refused +// here; app_find lists them and an action refuses them. When a css= selector +// matches nothing where it looks but does inside a frame, hint names the +// path that reaches it. +func webIndexes(ctx context.Context, s *session, els []webview.Element, target string) (idx []int, hint string, err error) { + if sel, ok := strings.CutPrefix(target, "css="); ok { + var elsewhere []int + if idx, elsewhere, err = webview.MatchCSS(ctx, s.web, els, sel); err != nil { + return nil, "", err + } + hint = framedHint(els, elsewhere, sel) + } else { + loc, err := uitree.ParseLocator(target) + if err != nil { + return nil, "", err + } + if loc.Within != nil || loc.Kind == uitree.KindPath { + return nil, "", mobiumerr.New(mobiumerr.Unsupported, "%s cannot name an element "+ + "inside a WebView: within and path are for native screens — a css= selector says what is inside what", target). + WithRemedy("a css= selector, as \"css=li.order:nth-child(3) button\", or a ref from app_map") + } + for i, e := range els { + if webMatches(loc, e) { + idx = append(idx, i) + } + } + idx = preferWhole(loc, els, idx) + if loc.Nth > 0 { + sort.SliceStable(idx, func(a, b int) bool { + ea, eb := els[idx[a]], els[idx[b]] + if ea.Y != eb.Y { + return ea.Y < eb.Y + } + return ea.X < eb.X + }) + if loc.Nth > len(idx) { + idx = nil + } else { + idx = idx[loc.Nth-1 : loc.Nth] + } + } + } + return idx, hint, nil +} + +// framedHint names, for a selector that matched only inside frames, the path +// that reaches each of up to three of those elements. +func framedHint(els []webview.Element, elsewhere []int, selector string) string { + if len(elsewhere) == 0 { + return "" + } + sel := selector + if i := strings.LastIndex(sel, " >> "); i >= 0 { + sel = sel[i+len(" >> "):] + } + sel = strings.TrimSpace(sel) + var paths []string + for _, i := range elsewhere { + if len(paths) == 3 { + paths = append(paths, "…") + break + } + if f := els[i].Frame; f != "" { + paths = append(paths, "css="+f+" >> "+sel) + } else { + paths = append(paths, "css="+sel) + } + } + return "it matches in another frame, and a selector names a frame's element with the frame's path first: " + + strings.Join(paths, ", ") +} + +// webMatches says whether a page element is one a locator names. +func webMatches(loc uitree.Locator, e webview.Element) bool { + if loc.Role != "" && loc.Kind != uitree.KindRole && e.NeutralRole() != strings.ToLower(loc.Role) { + return false + } + switch loc.Kind { + case uitree.KindText, uitree.KindLabel: + return loc.MatchesString(e.Label) + case uitree.KindTestID: + return loc.MatchesString(e.TestID) || loc.MatchesString(e.ID) + case uitree.KindRole: + return e.NeutralRole() == strings.ToLower(loc.Value) + case uitree.KindClass: + return loc.MatchesString(e.Tag) + } + return false +} + +// preferWhole keeps the one whole match among several, as a native locator +// does (CHALLENGES 185): text=Save over "Save" and "Save draft". +func preferWhole(loc uitree.Locator, els []webview.Element, idx []int) []int { + if len(idx) < 2 || loc.Whole || loc.Exact { + return idx + } + var whole []int + for _, i := range idx { + v := els[i].Label + if loc.Kind == uitree.KindTestID { + v = els[i].TestID + } + if strings.EqualFold(strings.TrimSpace(v), strings.TrimSpace(loc.Value)) { + whole = append(whole, i) + } + } + if len(whole) == 1 { + return whole + } + return idx +} diff --git a/internal/uitree/locator.go b/internal/uitree/locator.go index 0803b8a..a2f936d 100644 --- a/internal/uitree/locator.go +++ b/internal/uitree/locator.go @@ -433,6 +433,11 @@ var curlyQuotes = strings.NewReplacer("\u2019", "'", "\u2018", "'", "\u201c", "\ func foldQuotes(s string) string { return curlyQuotes.Replace(s) } +// MatchesString compares one string with the locator's value as a node's +// own strings are compared — a part in any case, or the whole when quoted — +// for what is not a node: an element of a WebView's page map. +func (l Locator) MatchesString(s string) bool { return l.field(s) } + func match(field, value string, exact bool) bool { if value == "" { return false diff --git a/internal/webview/css.go b/internal/webview/css.go new file mode 100644 index 0000000..8391445 --- /dev/null +++ b/internal/webview/css.go @@ -0,0 +1,104 @@ +package webview + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/mobiumdev/mobium/internal/mobiumerr" +) + +// cssScript says, for each candidate of the map in this context, whether it +// matches a selector — or "bad" for a selector the page refuses. +const cssScript = `(() => { + ` + candidatesJS + ` + const sel = %s; + try { document.querySelector(sel); } catch (e) { return 'bad'; } + return JSON.stringify(__mobiumCandidates().map((el) => el.matches(sel))); +})()` + +// MatchCSS returns the indexes of the map's elements a CSS selector names. +// The selector may be preceded by a frame's path, as map prints one — +// "iframe#f >> button.save" — and then names only elements in that frame. +// Only the map's own elements can match: every web action picks its element +// from that list, so a selector naming anything else names nothing to act +// on. Each context the elements come from — the page, and each cross-origin +// frame (CHALLENGES 229) — is asked once. +// +// When nothing in the frame named matches, elsewhere is what the selector +// matches in the other frames, so a refusal can name the path that reaches +// it: a selector written for a frame's own document, without the frame, +// names nothing on the page. +func MatchCSS(ctx context.Context, p Page, els []Element, selector string) (out, elsewhere []int, err error) { + path, sel := "", strings.TrimSpace(selector) + if i := strings.LastIndex(sel, " >> "); i >= 0 { + path, sel = strings.TrimSpace(sel[:i]), strings.TrimSpace(sel[i+len(" >> "):]) + } + if sel == "" { + return nil, nil, mobiumerr.New(mobiumerr.InvalidArgument, "css= needs a selector") + } + script := fmt.Sprintf(cssScript, jsString(sel)) + read := func(id int) ([]bool, error) { + var raw string + var err error + if id == 0 { + raw, err = p.Evaluate(ctx, script) + } else if c, ok := p.(contextual); ok { + raw, err = c.evaluateIn(ctx, id, script) + } else { + return nil, nil + } + if err != nil { + return nil, err + } + if raw == "bad" { + return nil, mobiumerr.New(mobiumerr.InvalidArgument, "%q is not a CSS selector the page accepts", sel) + } + var m []bool + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil, mobiumerr.New(mobiumerr.DeviceServer, "could not read the page's selector answer %q: %w", raw, err) + } + return m, nil + } + answers := map[int][]bool{} + matches := func(e Element) (bool, error) { + m, ok := answers[e.context] + if !ok { + var err error + if m, err = read(e.context); err != nil { + return false, err + } + answers[e.context] = m + } + return e.local < len(m) && m[e.local], nil + } + for i, e := range els { + if e.Frame != path { + continue + } + ok, err := matches(e) + if err != nil { + return nil, nil, err + } + if ok { + out = append(out, i) + } + } + if len(out) > 0 { + return out, nil, nil + } + for i, e := range els { + if e.Frame == path { + continue + } + ok, err := matches(e) + if err != nil { + return nil, nil, err + } + if ok { + elsewhere = append(elsewhere, i) + } + } + return nil, elsewhere, nil +} diff --git a/skills/mobile-check/SKILL.md b/skills/mobile-check/SKILL.md index 87cf796..51954d0 100644 --- a/skills/mobile-check/SKILL.md +++ b/skills/mobile-check/SKILL.md @@ -499,9 +499,12 @@ parsing the sentence: `--json` prints it (`"code": "no_such_element"`, with a - `editable` — not a text field: use `tap` for it; `type` is for fields. - `no Android device or emulator is running` Ask the user to start one. Mobium cannot. -- `"…" is a locator, and locators do not work inside a WebView` - You are in a web context. Use a `@ref` from `map`, or `mobium context - NATIVE_APP` to get back to the app shell. +- `… matches N elements of the page` / `no element of the page … matches` + Inside a WebView a locator matches what `map` reads of the page: text and + label are an element's accessible name, testid its data-testid or id, + role its role. Narrow it with `,role=`, quotes, `,nth=`, or a CSS + selector: `mobium tap 'css=button.save'`, with a frame's path before it + as `map` prints one (`iframe#f >> button`). ## Verifying your work