From 57977cde0acd44aaa71c0540a3ca3a76b56cfc35 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 13:59:17 +0530 Subject: [PATCH] fix(verifier): name an element only when the selector names it alone ax.findAll stamped every result with the query selector, and resolveCoordinates prefers the tree lookup over the element's own coordinates, so N sibling candidates all executed on the first match. On folio's Home screen the fuzzer could never open any account but the first. The gate tests identity rather than cardinality: no node other than this one answers to the rendered string, checked with the same lookup the runner runs. A rendered object selector can resolve somewhere the query never matched, so counting the query would call that unique. Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX --- internal/verifier/marshal.go | 33 ++++++++++--- internal/verifier/verifier_test.go | 79 ++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/internal/verifier/marshal.go b/internal/verifier/marshal.go index 1ebcbf5..cb83b46 100644 --- a/internal/verifier/marshal.go +++ b/internal/verifier/marshal.go @@ -72,16 +72,17 @@ func accessibilityObject(runtime *goja.Runtime, tree *hierarchy.Tree) *goja.Obje if node == nil { return goja.Undefined() } - return nodeObject(runtime, node, selectorStringFromJS(runtime, call.Argument(0))) + return nodeObject(runtime, tree, node, selectorStringFromJS(runtime, call.Argument(0))) } findAll := func(call goja.FunctionCall) goja.Value { if tree == nil { return goja.Undefined() } nodes := findAllNodesFromJS(runtime, tree, call.Argument(0)) + selector := selectorStringFromJS(runtime, call.Argument(0)) array := runtime.NewArray() for i, n := range nodes { - _ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(runtime, call.Argument(0)))) + _ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, tree, n, selector)) } return array } @@ -90,7 +91,26 @@ func accessibilityObject(runtime *goja.Runtime, tree *hierarchy.Tree) *goja.Obje return accessibility } -func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) goja.Value { +// unambiguousSelector returns selector only when no node other than this one +// answers to it. The runner prefers tree.Find(action.On) over the coordinates +// the element reported (resolveCoordinates) and Find takes the first match, so +// naming an element by a selector its siblings share sends every one of their +// actions to the first sibling. An unnamed element keeps its own coordinates, +// which are already right, matching what selectorsFor does for the builtin +// target enumeration in pkg/spec/src/web-runtime.ts. +func unambiguousSelector(tree *hierarchy.Tree, node *hierarchy.Node, selector string) string { + if tree == nil || selector == "" { + return "" + } + for _, match := range tree.FindAllNodes(selector) { + if match != node { + return "" + } + } + return selector +} + +func nodeObject(runtime *goja.Runtime, tree *hierarchy.Tree, node *hierarchy.Node, selector string) goja.Value { element := &node.Element object := runtime.NewObject() centerX, centerY := element.Bounds.Center() @@ -106,7 +126,7 @@ func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) go _ = object.Set("editable", element.Editable) _ = object.Set("x", centerX) _ = object.Set("y", centerY) - _ = object.Set(tagSelector, selector) + _ = object.Set(tagSelector, unambiguousSelector(tree, node, selector)) bounds := runtime.NewObject() _ = bounds.Set("left", element.Bounds.Left) _ = bounds.Set("top", element.Bounds.Top) @@ -124,14 +144,15 @@ func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) go if childNode == nil { return goja.Undefined() } - return nodeObject(runtime, childNode, selectorStringFromJS(runtime, arg)) + return nodeObject(runtime, tree, childNode, selectorStringFromJS(runtime, arg)) } childFindAll := func(call goja.FunctionCall) goja.Value { arg := call.Argument(0) childNodes := findAllNodesInSubtreeFromJS(runtime, node, arg) + childSelector := selectorStringFromJS(runtime, arg) array := runtime.NewArray() for i, n := range childNodes { - _ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(runtime, arg))) + _ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, tree, n, childSelector)) } return array } diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go index 5b7a6f9..0248a63 100644 --- a/internal/verifier/verifier_test.go +++ b/internal/verifier/verifier_test.go @@ -3,9 +3,11 @@ package verifier import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" "slices" + "strconv" "strings" "testing" @@ -1433,3 +1435,80 @@ func TestExtractorCount_ReportsEveryRegisteredExtractor(t *testing.T) { t.Errorf("ExtractorCount() = %d, want 2 (helloSpec registers screen and balance)", got) } } + +// TestAxSelectorTag_OnlyNamesTheElementItAloneResolvesTo pins the rule the +// runner depends on: resolveCoordinates prefers tree.Find(action.On) over the +// coordinates the element reported, and Find takes the first match, so a +// selector three sibling cards share would send all three taps to the first +// card. Siblings therefore carry no selector and keep their own coordinates; +// an element the selector alone resolves to still carries it. +func TestAxSelectorTag_OnlyNamesTheElementItAloneResolvesTo(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,100,400]"}, + "children": [ + {"attributes": {"testTag": "AccountCard", "text": "Alpha", "bounds": "[0,0,100,100]"}, "clickable": true, "children": []}, + {"attributes": {"testTag": "AccountCard", "text": "Beta", "bounds": "[0,100,100,200]"}, "clickable": true, "children": []}, + {"attributes": {"testTag": "AccountCard", "text": "Gamma", "bounds": "[0,200,100,300]"}, "clickable": true, "children": []}, + {"attributes": {"testTag": "AddAccount", "bounds": "[0,300,100,400]"}, "clickable": true, "children": []} + ] + }` + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.cards = __sanderling__.extract(state => + state.ax.findAll({ testTag: "AccountCard" }) + ); + globalThis.sole = __sanderling__.extract(state => + state.ax.findAll({ testTag: "AddAccount" }) + ); + globalThis.soleFind = __sanderling__.extract(state => + state.ax.find({ testTag: "AddAccount" }) + ); + `) + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatal(err) + } + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatal(err) + } + + current := func(name string) *goja.Object { + handle := verifier.runtime.GlobalObject().Get(name).ToObject(verifier.runtime) + value := handle.Get("current") + if goja.IsUndefined(value) || goja.IsNull(value) { + t.Fatalf("%s: extractor produced no value", name) + } + return value.ToObject(verifier.runtime) + } + element := func(array *goja.Object, index int) *goja.Object { + return array.Get(strconv.Itoa(index)).ToObject(verifier.runtime) + } + + cards := current("cards") + if got := cards.Get("length").ToInteger(); got != 3 { + t.Fatalf("findAll returned %d cards, want 3", got) + } + centers := map[string]bool{} + for index := range 3 { + card := element(cards, index) + if got := card.Get(tagSelector).String(); got != "" { + t.Errorf("card %d (%s) carries selector %q; three cards answer to it, so the runner would tap the first card three times", + index, card.Get("text"), got) + } + centers[fmt.Sprintf("%d,%d", card.Get("x").ToInteger(), card.Get("y").ToInteger())] = true + } + if len(centers) != 3 { + t.Errorf("the three cards report %d distinct centers, want 3: %v", len(centers), centers) + } + + soleAll := current("sole") + if got := soleAll.Get("length").ToInteger(); got != 1 { + t.Fatalf("findAll returned %d AddAccount elements, want 1", got) + } + if got := element(soleAll, 0).Get(tagSelector).String(); got != "testTag:AddAccount" { + t.Errorf("findAll over a single match: selector = %q, want %q", got, "testTag:AddAccount") + } + if got := current("soleFind").Get(tagSelector).String(); got != "testTag:AddAccount" { + t.Errorf("find over a single match: selector = %q, want %q", got, "testTag:AddAccount") + } +}