From 1332471b7296875eef0a9053ae741e64b5482723 Mon Sep 17 00:00:00 2001 From: PJ Date: Thu, 13 Aug 2026 00:36:05 +0530 Subject: [PATCH] feat(verifier): fail the spec on a selector key that cannot match An empty match is indistinguishable from a screen with no such element, so a mistyped key generates no action for the whole run and the campaign finishes clean having explored nothing. The goja boundary now throws, naming the key and the accepted list. --- internal/hierarchy/hierarchy.go | 10 +++ internal/verifier/ax_integration_test.go | 77 ++++++++++++++++++++++++ internal/verifier/marshal.go | 33 ++++++++++ 3 files changed, 120 insertions(+) diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 82fd1cc..a6a4e7e 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -792,3 +792,13 @@ func parseBounds(text string) (Bounds, error) { } return Bounds{}, fmt.Errorf("bounds %q: not in [L,T,R,B] or [x1,y1][x2,y2] form", text) } + +// Tree returns the tree this node belongs to, or nil for a node built outside +// Parse. Selector validation needs the whole tree: a key absent from one +// subtree but present elsewhere is a key that can match. +func (n *Node) Tree() *Tree { + if n == nil { + return nil + } + return n.tree +} diff --git a/internal/verifier/ax_integration_test.go b/internal/verifier/ax_integration_test.go index 97fd596..5a632c8 100644 --- a/internal/verifier/ax_integration_test.go +++ b/internal/verifier/ax_integration_test.go @@ -2,6 +2,7 @@ package verifier import ( "os" + "strings" "testing" "github.com/priyanshujain/sanderling/internal/hierarchy" @@ -99,3 +100,79 @@ func TestStateAxFindWorks(t *testing.T) { t.Fatalf("findAll count = %d, want 1", count) } } + +// A selector key that can never match is a spec bug, and an empty result hides +// it: the generator yields no action, the runner waits out the step, and the +// run ends clean having explored nothing. The spec must fail instead. +func TestStateAxObjectSelectorRejectsAnUnknownKey(t *testing.T) { + tree, err := hierarchy.Parse(`{ + "attributes": {"resource-id": "root"}, + "children": [{"attributes": {"content-desc": "Supplier"}, "children": []}] + }`) + if err != nil { + t.Fatal(err) + } + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.probe = __sanderling__.extract(state => !!state.ax.find({ descripton: "Supplier" })); + `) + + err = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}) + if err == nil { + t.Fatal("expected an unknown selector key to fail the spec") + } + if !strings.Contains(err.Error(), "descripton") { + t.Errorf("error does not name the offending key: %v", err) + } + if !strings.Contains(err.Error(), "accepted keys") { + t.Errorf("error does not list the accepted keys: %v", err) + } +} + +// desc names the accessibility description in the element fields and in the +// string form, so the object form answers to it too rather than reporting it as +// a mistake. +func TestStateAxObjectSelectorAcceptsDesc(t *testing.T) { + tree, err := hierarchy.Parse(`{ + "attributes": {"resource-id": "root"}, + "children": [{"attributes": {"content-desc": "Supplier"}, "children": []}] + }`) + if err != nil { + t.Fatal(err) + } + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.probe = __sanderling__.extract(state => state.ax.find({ desc: "Supplier" }) ? "matched" : "miss"); + `) + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatal(err) + } + got := verifier.runtime.GlobalObject().Get("probe").ToObject(verifier.runtime).Get("current").String() + if got != "matched" { + t.Fatalf("probe = %q, want matched", got) + } +} + +// A key that belongs to another platform must stay silent: one spec runs on +// every platform, and iOS-only attributes are absent from an Android tree by +// design rather than by mistake. +func TestStateAxObjectSelectorKeepsCrossPlatformKeysSilent(t *testing.T) { + tree, err := hierarchy.Parse(`{ + "attributes": {"resource-id": "root"}, + "children": [] + }`) + if err != nil { + t.Fatal(err) + } + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.probe = __sanderling__.extract(state => state.ax.find({ title: "Settings" }) ? "matched" : "miss"); + `) + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatalf("a platform-specific key must not fail the run: %v", err) + } + got := verifier.runtime.GlobalObject().Get("probe").ToObject(verifier.runtime).Get("current").String() + if got != "miss" { + t.Fatalf("probe = %q, want miss", got) + } +} diff --git a/internal/verifier/marshal.go b/internal/verifier/marshal.go index cfac347..cb7852a 100644 --- a/internal/verifier/marshal.go +++ b/internal/verifier/marshal.go @@ -3,6 +3,7 @@ package verifier import ( "encoding/json" "fmt" + "slices" "strings" "time" @@ -151,12 +152,14 @@ func findNodeFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value) return tree.FindNode(s) } if path, ok := selectorPathFromJS(runtime, arg); ok { + requireKnownSelectorKeys(runtime, tree, path...) return tree.FindBySelectorPath(path) } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil } + requireKnownSelectorKeys(runtime, tree, sel) return tree.Root.FindBySelector(sel) } @@ -169,12 +172,14 @@ func findAllNodesFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Va return tree.FindAllNodes(s) } if path, ok := selectorPathFromJS(runtime, arg); ok { + requireKnownSelectorKeys(runtime, tree, path...) return tree.FindAllBySelectorPath(path) } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil } + requireKnownSelectorKeys(runtime, tree, sel) return tree.Root.FindAllBySelector(sel) } @@ -187,12 +192,14 @@ func findNodeInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, arg go return node.Find(s) } if path, ok := selectorPathFromJS(runtime, arg); ok { + requireKnownSelectorKeys(runtime, node.Tree(), path...) return node.FindBySelectorPath(path) } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil } + requireKnownSelectorKeys(runtime, node.Tree(), sel) return node.FindBySelector(sel) } @@ -205,15 +212,41 @@ func findAllNodesInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, ar return node.FindAll(s) } if path, ok := selectorPathFromJS(runtime, arg); ok { + requireKnownSelectorKeys(runtime, node.Tree(), path...) return node.FindAllBySelectorPath(path) } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil } + requireKnownSelectorKeys(runtime, node.Tree(), sel) return node.FindAllBySelector(sel) } +// requireKnownSelectorKeys throws a JS error when a selector names a key that +// can never match. Returning an empty result instead is indistinguishable from +// a screen that simply has no such element, so a spec built on a mistyped key +// generates no action, the runner waits out every step, and the campaign +// finishes clean having explored nothing. +func requireKnownSelectorKeys( + runtime *goja.Runtime, + tree *hierarchy.Tree, + selectors ...hierarchy.Selector, +) { + var unknown []string + for _, sel := range selectors { + for _, key := range tree.UnknownSelectorKeys(sel) { + if !slices.Contains(unknown, key) { + unknown = append(unknown, key) + } + } + } + if len(unknown) == 0 { + return + } + panic(runtime.NewTypeError(hierarchy.UnknownSelectorKeyMessage(unknown))) +} + // selectorFromJSObject converts a JS object {attr: value, ...} into a Selector. func selectorFromJSObject(runtime *goja.Runtime, arg goja.Value) hierarchy.Selector { obj := arg.ToObject(runtime)