mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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.
This commit is contained in:
1 parent
6c6bc97f40
commit
0b3141fe3f
2 files changed
+112
-1
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user