fix(verifier): canonicalize selector strings

Object/chain JS selectors used to fall through to goja's default
stringification, producing "[object Object]" tags that surfaced as
garbage in trace.action.selector. Emit canonical "k:v" / " > "-joined
strings instead so the tag round-trips back through the hierarchy
selector grammar.
This commit is contained in:
pj committed 2026-05-30 21:43:27 +05:30
1 parent 09c1b8df26
commit 352118c199
2 files changed
+131 -8

No files matched your search

+56 -8
View File
@@ -3,6 +3,7 @@ package verifier
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/dop251/goja"
@@ -69,7 +70,7 @@ func accessibilityObject(runtime *goja.Runtime, tree *hierarchy.Tree) *goja.Obje
if node == nil {
return goja.Undefined()
}
return nodeObject(runtime, node, selectorStringFromJS(call.Argument(0)))
return nodeObject(runtime, node, selectorStringFromJS(runtime, call.Argument(0)))
}
findAll := func(call goja.FunctionCall) goja.Value {
if tree == nil {
@@ -78,7 +79,7 @@ func accessibilityObject(runtime *goja.Runtime, tree *hierarchy.Tree) *goja.Obje
nodes := findAllNodesFromJS(runtime, tree, call.Argument(0))
array := runtime.NewArray()
for i, n := range nodes {
_ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(call.Argument(0))))
_ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(runtime, call.Argument(0))))
}
return array
}
@@ -121,14 +122,14 @@ func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) go
if childNode == nil {
return goja.Undefined()
}
return nodeObject(runtime, childNode, selectorStringFromJS(arg))
return nodeObject(runtime, childNode, selectorStringFromJS(runtime, arg))
}
childFindAll := func(call goja.FunctionCall) goja.Value {
arg := call.Argument(0)
childNodes := findAllNodesInSubtreeFromJS(runtime, node, arg)
array := runtime.NewArray()
for i, n := range childNodes {
_ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(arg)))
_ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(runtime, arg)))
}
return array
}
@@ -266,15 +267,62 @@ func selectorPathFromJS(runtime *goja.Runtime, arg goja.Value) ([]hierarchy.Sele
// 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).
func selectorStringFromJS(arg goja.Value) string {
if goja.IsUndefined(arg) || goja.IsNull(arg) {
// selector when the element is passed back as an action target). Output
// follows the canonical hierarchy selector grammar: "k:v" pairs space-joined
// per object, chains joined by " > ".
func selectorStringFromJS(runtime *goja.Runtime, arg goja.Value) string {
if arg == nil || goja.IsUndefined(arg) || goja.IsNull(arg) {
return ""
}
if s, ok := arg.Export().(string); ok {
return s
}
return arg.String()
exported := arg.Export()
if slice, ok := exported.([]any); ok {
object := arg.ToObject(runtime)
if object == nil {
return ""
}
parts := make([]string, 0, len(slice))
for index := range slice {
entry := object.Get(fmt.Sprintf("%d", index))
if entry == nil || goja.IsUndefined(entry) || goja.IsNull(entry) {
continue
}
segment := selectorObjectToString(runtime, entry)
if segment == "" {
continue
}
parts = append(parts, segment)
}
return strings.Join(parts, " > ")
}
return selectorObjectToString(runtime, arg)
}
// selectorObjectToString formats a single JS object as a space-joined sequence
// of "k:v" pairs, mirroring the hierarchy package's predicate grammar.
func selectorObjectToString(runtime *goja.Runtime, arg goja.Value) string {
if arg == nil || goja.IsUndefined(arg) || goja.IsNull(arg) {
return ""
}
object := arg.ToObject(runtime)
if object == nil {
return ""
}
keys := object.Keys()
parts := make([]string, 0, len(keys))
for _, key := range keys {
if key == tagSelector {
continue
}
value := object.Get(key)
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
continue
}
parts = append(parts, fmt.Sprintf("%s:%s", key, value.String()))
}
return strings.Join(parts, " ")
}
func lastActionObject(runtime *goja.Runtime, action *Action) goja.Value {
+75
View File
@@ -831,6 +831,81 @@ globalThis.properties = {
globalThis.actions = __sanderling__.actions(() => []);
`
// TestSelectorStringFromJS_CanonicalGrammar guarantees the selector tag stamped
// on returned AX nodes round-trips back to a parseable selector string when the
// node is later used as an action target. Without this, an action emitted from
// `tap({ on: state.ax.find({ testTag: "LoginEmail" }) })` ends up with
// `action.selector = "[object Object]"` in the trace.
func TestSelectorStringFromJS_CanonicalGrammar(t *testing.T) {
const treeJSON = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"},
"children": [
{"attributes": {"testTag": "LoginScreen", "bounds": "[0,0,100,40]"},
"children": [
{"attributes": {"testTag": "LoginEmail", "bounds": "[0,0,100,20]"}, "editable": true, "enabled": true, "children": []}
]}
]
}`
verifier := newVerifier(t)
mustLoad(t, verifier, `
globalThis.objectSelector = __sanderling__.extract(state =>
state.ax.find({ testTag: "LoginScreen" })
);
globalThis.chainSelector = __sanderling__.extract(state =>
state.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }])
);
globalThis.stringSelector = __sanderling__.extract(state =>
state.ax.find("testTag:LoginScreen")
);
`)
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)
}
read := func(name string) string {
handle := verifier.runtime.GlobalObject().Get(name).ToObject(verifier.runtime)
current := handle.Get("current")
if goja.IsUndefined(current) || goja.IsNull(current) {
return ""
}
object := current.ToObject(verifier.runtime)
return object.Get(tagSelector).String()
}
cases := []struct {
name string
want string
}{
{"objectSelector", "testTag:LoginScreen"},
{"chainSelector", "testTag:LoginScreen > testTag:LoginEmail"},
{"stringSelector", "testTag:LoginScreen"},
}
for _, testCase := range cases {
got := read(testCase.name)
if got != testCase.want {
t.Errorf("%s: got %q, want %q", testCase.name, got, testCase.want)
}
if strings.Contains(got, "[object") {
t.Errorf("%s: selector contains garbage %q", testCase.name, got)
}
}
}
// TestSelectorStringFromJS_NullEmpty verifies that nil/undefined args produce
// an empty string instead of "null"/"undefined" garbage.
func TestSelectorStringFromJS_NullEmpty(t *testing.T) {
verifier := newVerifier(t)
if got := selectorStringFromJS(verifier.runtime, goja.Undefined()); got != "" {
t.Errorf("undefined: got %q, want empty", got)
}
if got := selectorStringFromJS(verifier.runtime, goja.Null()); got != "" {
t.Errorf("null: got %q, want empty", got)
}
}
func TestOverrideExtractorValues_PropagatesNestedObjectFields(t *testing.T) {
verifier := newVerifier(t)
mustLoad(t, verifier, objectExtractorSpec)