diff --git a/internal/verifier/marshal.go b/internal/verifier/marshal.go index ead3430..f22ab21 100644 --- a/internal/verifier/marshal.go +++ b/internal/verifier/marshal.go @@ -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 { diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go index 681cc60..5e275ab 100644 --- a/internal/verifier/verifier_test.go +++ b/internal/verifier/verifier_test.go @@ -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)