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.
This commit is contained in:
pj committed 2026-08-13 00:36:05 +05:30
1 parent 35b0856e6c
commit 1332471b72
3 files changed
+120

No files matched your search

+10
View File
@@ -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) 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
}
+77
View File
@@ -2,6 +2,7 @@ package verifier
import ( import (
"os" "os"
"strings"
"testing" "testing"
"github.com/priyanshujain/sanderling/internal/hierarchy" "github.com/priyanshujain/sanderling/internal/hierarchy"
@@ -99,3 +100,79 @@ func TestStateAxFindWorks(t *testing.T) {
t.Fatalf("findAll count = %d, want 1", count) 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)
}
}
+33
View File
@@ -3,6 +3,7 @@ package verifier
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"slices"
"strings" "strings"
"time" "time"
@@ -151,12 +152,14 @@ func findNodeFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value)
return tree.FindNode(s) return tree.FindNode(s)
} }
if path, ok := selectorPathFromJS(runtime, arg); ok { if path, ok := selectorPathFromJS(runtime, arg); ok {
requireKnownSelectorKeys(runtime, tree, path...)
return tree.FindBySelectorPath(path) return tree.FindBySelectorPath(path)
} }
sel := selectorFromJSObject(runtime, arg) sel := selectorFromJSObject(runtime, arg)
if len(sel.Filters) == 0 { if len(sel.Filters) == 0 {
return nil return nil
} }
requireKnownSelectorKeys(runtime, tree, sel)
return tree.Root.FindBySelector(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) return tree.FindAllNodes(s)
} }
if path, ok := selectorPathFromJS(runtime, arg); ok { if path, ok := selectorPathFromJS(runtime, arg); ok {
requireKnownSelectorKeys(runtime, tree, path...)
return tree.FindAllBySelectorPath(path) return tree.FindAllBySelectorPath(path)
} }
sel := selectorFromJSObject(runtime, arg) sel := selectorFromJSObject(runtime, arg)
if len(sel.Filters) == 0 { if len(sel.Filters) == 0 {
return nil return nil
} }
requireKnownSelectorKeys(runtime, tree, sel)
return tree.Root.FindAllBySelector(sel) return tree.Root.FindAllBySelector(sel)
} }
@@ -187,12 +192,14 @@ func findNodeInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, arg go
return node.Find(s) return node.Find(s)
} }
if path, ok := selectorPathFromJS(runtime, arg); ok { if path, ok := selectorPathFromJS(runtime, arg); ok {
requireKnownSelectorKeys(runtime, node.Tree(), path...)
return node.FindBySelectorPath(path) return node.FindBySelectorPath(path)
} }
sel := selectorFromJSObject(runtime, arg) sel := selectorFromJSObject(runtime, arg)
if len(sel.Filters) == 0 { if len(sel.Filters) == 0 {
return nil return nil
} }
requireKnownSelectorKeys(runtime, node.Tree(), sel)
return node.FindBySelector(sel) return node.FindBySelector(sel)
} }
@@ -205,15 +212,41 @@ func findAllNodesInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, ar
return node.FindAll(s) return node.FindAll(s)
} }
if path, ok := selectorPathFromJS(runtime, arg); ok { if path, ok := selectorPathFromJS(runtime, arg); ok {
requireKnownSelectorKeys(runtime, node.Tree(), path...)
return node.FindAllBySelectorPath(path) return node.FindAllBySelectorPath(path)
} }
sel := selectorFromJSObject(runtime, arg) sel := selectorFromJSObject(runtime, arg)
if len(sel.Filters) == 0 { if len(sel.Filters) == 0 {
return nil return nil
} }
requireKnownSelectorKeys(runtime, node.Tree(), sel)
return node.FindAllBySelector(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. // selectorFromJSObject converts a JS object {attr: value, ...} into a Selector.
func selectorFromJSObject(runtime *goja.Runtime, arg goja.Value) hierarchy.Selector { func selectorFromJSObject(runtime *goja.Runtime, arg goja.Value) hierarchy.Selector {
obj := arg.ToObject(runtime) obj := arg.ToObject(runtime)