From 0b3141fe3f0ec662822154c5c4d9813837ecf056 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 27 Apr 2026 00:02:30 +0700 Subject: [PATCH] feat(verifier): dispatch JS array selectors to FindBySelectorPath `state.ax.find([{...}, {...}])` now walks each segment scoped under the previous match. Strings and single objects keep their existing single-shot lookup. --- internal/verifier/marshal.go | 46 +++++++++++++++++++- internal/verifier/verifier_test.go | 67 ++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/internal/verifier/marshal.go b/internal/verifier/marshal.go index 6d15f45..7055531 100644 --- a/internal/verifier/marshal.go +++ b/internal/verifier/marshal.go @@ -136,7 +136,8 @@ func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) go return object } -// findNodeFromJS dispatches a JS value (string or object) to Tree-level node lookup. +// findNodeFromJS dispatches a JS value (string, object, or array of objects) +// to Tree-level node lookup. func findNodeFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value) *hierarchy.Node { if goja.IsUndefined(arg) || goja.IsNull(arg) { return nil @@ -147,6 +148,9 @@ func findNodeFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value) if s, ok := arg.Export().(string); ok { return tree.FindNode(s) } + if path, ok := selectorPathFromJS(runtime, arg); ok { + return tree.FindBySelectorPath(path) + } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil @@ -162,6 +166,9 @@ func findAllNodesFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Va if s, ok := arg.Export().(string); ok { return tree.FindAllNodes(s) } + if path, ok := selectorPathFromJS(runtime, arg); ok { + return tree.FindAllBySelectorPath(path) + } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil @@ -177,6 +184,9 @@ func findNodeInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, arg go if s, ok := arg.Export().(string); ok { return node.Find(s) } + if path, ok := selectorPathFromJS(runtime, arg); ok { + return node.FindBySelectorPath(path) + } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil @@ -192,6 +202,9 @@ func findAllNodesInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, ar if s, ok := arg.Export().(string); ok { return node.FindAll(s) } + if path, ok := selectorPathFromJS(runtime, arg); ok { + return node.FindAllBySelectorPath(path) + } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil @@ -219,6 +232,37 @@ func selectorFromJSObject(runtime *goja.Runtime, arg goja.Value) hierarchy.Selec return sel } +// selectorPathFromJS recognizes a JS array of selector objects and converts it +// into a Selector chain. Returns ok=false for non-arrays so callers fall +// through to single-object dispatch. +func selectorPathFromJS(runtime *goja.Runtime, arg goja.Value) ([]hierarchy.Selector, bool) { + exported := arg.Export() + slice, ok := exported.([]any) + if !ok { + return nil, false + } + obj := arg.ToObject(runtime) + if obj == nil { + return nil, false + } + path := make([]hierarchy.Selector, 0, len(slice)) + for index := range slice { + entry := obj.Get(fmt.Sprintf("%d", index)) + if entry == nil || goja.IsUndefined(entry) || goja.IsNull(entry) { + return nil, false + } + sel := selectorFromJSObject(runtime, entry) + if len(sel.Filters) == 0 { + return nil, false + } + path = append(path, sel) + } + if len(path) == 0 { + return nil, false + } + return path, true +} + // selectorStringFromJS returns a string representation of the selector argument // for tagging returned element objects (used by selectorOf to reconstruct the // selector when the element is passed back as an action target). diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go index 2477266..4f5161a 100644 --- a/internal/verifier/verifier_test.go +++ b/internal/verifier/verifier_test.go @@ -7,6 +7,9 @@ import ( "strings" "testing" + "github.com/dop251/goja" + + "github.com/priyanshujain/sanderling/internal/hierarchy" "github.com/priyanshujain/sanderling/internal/ltl" ) @@ -259,6 +262,70 @@ func TestLoad_AcceptsSpecWithoutPropertiesOrActions(t *testing.T) { } } +// TestSelectorPath_ScopedDescent ensures the JS-side `find([{...}, {...}])` +// shape walks each segment scoped under the previous match. +func TestSelectorPath_ScopedDescent(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "rootView", "bounds": "[0,0,1080,2340]"}, + "children": [ + { + "attributes": {"testTag": "HomeScreen", "bounds": "[0,0,540,2340]"}, + "children": [ + { + "attributes": {"testTag": "AccountCard", "bounds": "[0,0,540,200]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Checking", "bounds": "[10,10,200,40]"}, "children": []} + ] + } + ] + }, + { + "attributes": {"testTag": "LedgerScreen", "bounds": "[540,0,1080,2340]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Other", "bounds": "[600,10,800,40]"}, "children": []} + ] + } + ] + }` + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.found = __sanderling__.extract(state => + state.ax.find([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }, { testTag: "AccountName" }]) + ); + globalThis.foundUnreachable = __sanderling__.extract(state => + state.ax.find([{ testTag: "LedgerScreen" }, { testTag: "AccountCard" }]) + ); + globalThis.allInHome = __sanderling__.extract(state => + state.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountName" }]) + ); + `) + 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) + } + found := verifier.runtime.GlobalObject().Get("found").ToObject(verifier.runtime).Get("current") + if found == nil || goja.IsUndefined(found) { + t.Fatal("expected path lookup to find AccountName under HomeScreen > AccountCard") + } + text := found.ToObject(verifier.runtime).Get("text") + if text.String() != "Checking" { + t.Fatalf("text = %q, want Checking", text.String()) + } + unreachable := verifier.runtime.GlobalObject().Get("foundUnreachable").ToObject(verifier.runtime).Get("current") + if !goja.IsUndefined(unreachable) { + t.Fatalf("AccountCard is not under LedgerScreen, expected undefined, got %v", unreachable) + } + allInHome := verifier.runtime.GlobalObject().Get("allInHome").ToObject(verifier.runtime).Get("current") + allObject := allInHome.ToObject(verifier.runtime) + length := allObject.Get("length").ToInteger() + if length != 1 { + t.Fatalf("findAll path length = %d, want 1 (Checking only, not Other in LedgerScreen)", length) + } +} + // TestFrom_SeededReplayIsDeterministic guarantees `from()` over a per-step // dynamic array picks the same element under the same seed across runs. The // folio spec relies on this to replace Math.random() in account-card taps.