fix(folio): stop abusing contentDescription as data carrier (#45)

* feat(hierarchy): full-attribute selector system

- Add Attributes map to Element (raw platform attrs + serialized booleans)
- Add Selector / AttrFilter types for multi-filter AND matching
- Add matchAttr with alias expansion and substring/boolean semantics
- Add matchSelector (AND of all filters)
- id: and desc: keep exact/suffix/prefix semantics for backward compat
- text: widens to substring via matchAttr
- default: case routes unknown kinds to matchAttr (NEW)
- Add Tree.FindNode / Tree.FindAllNodes returning *Node
- Add Node.Find / Node.FindAll for scoped subtree string search
- Add Node.FindBySelector / Node.FindAllBySelector for object AND search
- Add attributeAliases for cross-platform name expansion

* test(hierarchy): full-attribute selector coverage

- raw resource-id: substring match
- label:/content-desc: alias expansion to accessibilityText on iOS
- scrollable:true/false boolean exact match
- title: iOS-only attribute, graceful nil on Android
- text: substring widening
- Selector AND: both filters must match; single miss returns nil
- Node.Find scoped search: descendants only, not siblings

* feat(verifier): object-form selectors + attrs + element-level find

- ax.find/findAll accept string or {attr:value} JS objects
- Object form builds Selector with AND semantics
- Returned element objects expose attrs sub-object (raw platform attrs)
- Returned element objects expose .find() and .findAll() scoped to subtree
- Element-level .find/.findAll accept string or object selectors

* feat(spec): extend AccessibilityElement and AccessibilityTree types

- AccessibilityElement gains attrs, find(), findAll()
- find/findAll on both Tree and Element accept string | AttrSelector
- AttrSelector = Record<string, string> for object-form AND matching

* feat(folio): migrate to chained object-form selectors

- Replace path queries (desc:X > desc:Y) with chained API
- Screen root lookups use { accessibilityText: "ScreenName" }
- Element-scoped searches use find/findAll with string or object
- Keep string selectors for descPrefix: and desc:Back (shows both forms)

* fix(folio): use account_card:id desc, expose balance via text semantics

* fix(folio): embed accountId in LedgerScreen desc, expose values via text semantics

* fix(spec): replace desc-parsing with text-based parseDollarCents extraction
This commit is contained in:
pj authored and GitHub committed 2026-04-25 23:43:19 +07:00
1 parent 776becdf4b
commit 34fb73d6cb
7 files changed
+593 -107

No files matched your search

+217 -49
View File
@@ -1,18 +1,24 @@
// Package hierarchy parses the TreeNode JSON produced by the Maestro sidecar
// and resolves selectors against it.
//
// Selector grammar (v1.0):
// Selector grammar (v2.0):
//
// Single selectors (global scan):
// id:<suffix> - resource-id == suffix or ends with ":id/<suffix>"
// text:<value> - exact text match
// desc:<value> - exact content-desc match
// descPrefix:<prefix> - content-desc starts with prefix
// String selectors (global scan or element-scoped):
// attribute:value - substring match; exact for "true"/"false" booleans
// id:<suffix> - substring on resource-id / identifier (backward compat)
// text:<value> - substring on text attribute
// desc:<value> - substring on content-desc / accessibilityText
// descPrefix:<prefix> - starts-with on content-desc / accessibilityText
//
// Path queries (segments separated by " > "):
// <sel> > <sel> > ... - each segment is matched within the subtree of the
// previous match (any descendant, not just direct child)
// example: id:LoginScreen > desc:EmailInput
// Object selectors (multi-attribute AND, element-scoped or global):
// { attr: value, ... } - all key/value pairs must match; substring / boolean semantics
//
// Path queries (global scan only, string form):
// <sel> > <sel> > ... - each segment matched within subtree of previous match
//
// Cross-platform aliases are expanded automatically: "label" / "accessibilityLabel"
// resolve to accessibilityText; "content-desc" also checks accessibilityText and
// vice-versa; "identifier" / "accessibilityIdentifier" resolve to resource-id.
package hierarchy
import (
@@ -51,13 +57,14 @@ type Element struct {
Package string `json:"package,omitempty"`
// Screen holds the current route/screen name when set by the driver on the
// root element (web platform only; empty for native platforms).
Screen string `json:"screen,omitempty"`
Clickable bool `json:"clickable,omitempty"`
Enabled bool `json:"enabled,omitempty"`
Checked bool `json:"checked,omitempty"`
Focused bool `json:"focused,omitempty"`
Selected bool `json:"selected,omitempty"`
Bounds Bounds `json:"bounds"`
Screen string `json:"screen,omitempty"`
Clickable bool `json:"clickable,omitempty"`
Enabled bool `json:"enabled,omitempty"`
Checked bool `json:"checked,omitempty"`
Focused bool `json:"focused,omitempty"`
Selected bool `json:"selected,omitempty"`
Bounds Bounds `json:"bounds"`
Attributes map[string]string `json:"attrs,omitempty"`
}
// Node is one node in the hierarchy tree.
@@ -83,6 +90,73 @@ type treeNodeJSON struct {
Selected *bool `json:"selected"`
}
// Selector describes a multi-attribute AND match.
type Selector struct {
Filters []AttrFilter
}
// AttrFilter is a single attribute predicate within a Selector.
type AttrFilter struct {
Attr string
Value string
}
// attributeAliases maps user-written attribute names to the actual keys present
// in the TreeNode attributes map. Both directions are listed so cross-platform
// matching works regardless of which name the caller uses.
var attributeAliases = map[string][]string{
// Android XML legacy name; web driver uses content-desc; Maestro normalises to accessibilityText
"content-desc": {"accessibilityText"},
// iOS AXElement / UIKit names
"label": {"accessibilityText"},
"accessibilityLabel": {"accessibilityText"},
// accessibilityText is the canonical key; also check content-desc for Android/web
"accessibilityText": {"content-desc"},
// resource-id canonical key; also check identifier (iOS AXElement raw field)
"resource-id": {"identifier"},
// iOS identifier names
"identifier": {"resource-id"},
"accessibilityIdentifier": {"resource-id"},
// iOS AXElement raw name for hintText
"placeholderValue": {"hintText"},
// iOS AXElement raw name for class
"elementType": {"class"},
}
// matchAttr returns true when the element has an attribute matching attr:value.
// Alias expansion is applied so cross-platform names resolve correctly.
// Boolean values ("true"/"false") use exact comparison; all others use substring.
// Returns false gracefully when no candidate attribute has data.
func matchAttr(element *Element, attr, value string) bool {
candidates := append([]string{attr}, attributeAliases[attr]...)
for _, key := range candidates {
attrVal, ok := element.Attributes[key]
if !ok || attrVal == "" {
continue
}
if value == "true" || value == "false" {
if attrVal == value {
return true
}
} else {
if strings.Contains(attrVal, value) {
return true
}
}
}
return false
}
// matchSelector returns true when all filters in sel match the element (AND semantics).
func matchSelector(element *Element, sel Selector) bool {
for _, f := range sel.Filters {
if !matchAttr(element, f.Attr, f.Value) {
return false
}
}
return true
}
// Parse parses a Maestro TreeNode JSON hierarchy.
func Parse(text string) (*Tree, error) {
text = strings.TrimSpace(text)
@@ -147,45 +221,125 @@ func elementFromNode(node *treeNodeJSON) *Element {
}
}
element.Attributes = make(map[string]string, len(attrs)+5)
for k, v := range attrs {
element.Attributes[k] = v
}
if node.Clickable != nil {
element.Attributes["clickable"] = strconv.FormatBool(*node.Clickable)
}
if node.Enabled != nil {
element.Attributes["enabled"] = strconv.FormatBool(*node.Enabled)
}
if node.Focused != nil {
element.Attributes["focused"] = strconv.FormatBool(*node.Focused)
}
if node.Checked != nil {
element.Attributes["checked"] = strconv.FormatBool(*node.Checked)
}
if node.Selected != nil {
element.Attributes["selected"] = strconv.FormatBool(*node.Selected)
}
return element
}
// Find returns the first element matching the selector, or nil.
func (t *Tree) Find(selector string) *Element {
node := t.FindNode(selector)
if node == nil {
return nil
}
return &node.Element
}
// FindAll returns every element matching the selector.
func (t *Tree) FindAll(selector string) []*Element {
nodes := t.FindAllNodes(selector)
elements := make([]*Element, len(nodes))
for i, n := range nodes {
elements[i] = &n.Element
}
return elements
}
// FindNode returns the first Node matching the selector, or nil.
func (t *Tree) FindNode(selector string) *Node {
if strings.Contains(selector, " > ") {
return findPath(t.Root, strings.Split(selector, " > "))
return findPathNode(t.Root, strings.Split(selector, " > "))
}
kind, value, ok := parseSelector(selector)
if !ok {
return nil
}
for _, element := range t.Elements {
if match(element, kind, value) {
return element
nodes := searchSubtree(t.Root, kind, value)
if len(nodes) == 0 {
return nil
}
return nodes[0]
}
// FindAllNodes returns every Node matching the selector.
func (t *Tree) FindAllNodes(selector string) []*Node {
if strings.Contains(selector, " > ") {
return findPathAllNodes(t.Root, strings.Split(selector, " > "))
}
kind, value, ok := parseSelector(selector)
if !ok {
return nil
}
return searchSubtree(t.Root, kind, value)
}
// Find returns the first Node in this node's subtree (descendants only) matching
// the string selector. Path queries within the selector are not supported here.
func (n *Node) Find(selector string) *Node {
kind, value, ok := parseSelector(selector)
if !ok {
return nil
}
for _, child := range n.Children {
if nodes := searchSubtree(child, kind, value); len(nodes) > 0 {
return nodes[0]
}
}
return nil
}
// FindAll returns every element matching the selector.
func (t *Tree) FindAll(selector string) []*Element {
if strings.Contains(selector, " > ") {
return findPathAll(t.Root, strings.Split(selector, " > "))
}
// FindAll returns all Nodes in this node's subtree (descendants only) matching
// the string selector.
func (n *Node) FindAll(selector string) []*Node {
kind, value, ok := parseSelector(selector)
if !ok {
return nil
}
var matches []*Element
for _, element := range t.Elements {
if match(element, kind, value) {
matches = append(matches, element)
}
var result []*Node
for _, child := range n.Children {
result = append(result, searchSubtree(child, kind, value)...)
}
return matches
return result
}
func findPath(root *Node, segments []string) *Element {
// FindBySelector returns the first Node in this node's subtree matching sel (AND semantics).
func (n *Node) FindBySelector(sel Selector) *Node {
for _, child := range n.Children {
if nodes := searchSubtreeBySelector(child, sel); len(nodes) > 0 {
return nodes[0]
}
}
return nil
}
// FindAllBySelector returns all Nodes in this node's subtree matching sel (AND semantics).
func (n *Node) FindAllBySelector(sel Selector) []*Node {
var result []*Node
for _, child := range n.Children {
result = append(result, searchSubtreeBySelector(child, sel)...)
}
return result
}
func findPathNode(root *Node, segments []string) *Node {
if root == nil || len(segments) == 0 {
return nil
}
@@ -195,16 +349,16 @@ func findPath(root *Node, segments []string) *Element {
}
for _, node := range searchSubtree(root, kind, value) {
if len(segments) == 1 {
return &node.Element
return node
}
if result := findPathDescendants(node, segments[1:]); result != nil {
if result := findPathDescendantsNode(node, segments[1:]); result != nil {
return result
}
}
return nil
}
func findPathDescendants(root *Node, segments []string) *Element {
func findPathDescendantsNode(root *Node, segments []string) *Node {
kind, value, ok := parseSelector(segments[0])
if !ok {
return nil
@@ -212,9 +366,9 @@ func findPathDescendants(root *Node, segments []string) *Element {
for _, child := range root.Children {
for _, node := range searchSubtree(child, kind, value) {
if len(segments) == 1 {
return &node.Element
return node
}
if result := findPathDescendants(node, segments[1:]); result != nil {
if result := findPathDescendantsNode(node, segments[1:]); result != nil {
return result
}
}
@@ -222,7 +376,7 @@ func findPathDescendants(root *Node, segments []string) *Element {
return nil
}
func findPathAll(root *Node, segments []string) []*Element {
func findPathAllNodes(root *Node, segments []string) []*Node {
if root == nil || len(segments) == 0 {
return nil
}
@@ -230,30 +384,30 @@ func findPathAll(root *Node, segments []string) []*Element {
if !ok {
return nil
}
var result []*Element
var result []*Node
for _, node := range searchSubtree(root, kind, value) {
if len(segments) == 1 {
result = append(result, &node.Element)
result = append(result, node)
continue
}
result = append(result, findPathAllDescendants(node, segments[1:])...)
result = append(result, findPathAllDescendantsNodes(node, segments[1:])...)
}
return result
}
func findPathAllDescendants(root *Node, segments []string) []*Element {
func findPathAllDescendantsNodes(root *Node, segments []string) []*Node {
kind, value, ok := parseSelector(segments[0])
if !ok {
return nil
}
var result []*Element
var result []*Node
for _, child := range root.Children {
for _, node := range searchSubtree(child, kind, value) {
if len(segments) == 1 {
result = append(result, &node.Element)
result = append(result, node)
continue
}
result = append(result, findPathAllDescendants(node, segments[1:])...)
result = append(result, findPathAllDescendantsNodes(node, segments[1:])...)
}
}
return result
@@ -274,6 +428,21 @@ func searchSubtree(root *Node, kind, value string) []*Node {
return result
}
// searchSubtreeBySelector returns all nodes under root (inclusive) matching sel.
func searchSubtreeBySelector(root *Node, sel Selector) []*Node {
if root == nil {
return nil
}
var result []*Node
if matchSelector(&root.Element, sel) {
result = append(result, root)
}
for _, child := range root.Children {
result = append(result, searchSubtreeBySelector(child, sel)...)
}
return result
}
func parseSelector(selector string) (string, string, bool) {
index := strings.IndexByte(selector, ':')
if index <= 0 {
@@ -290,14 +459,13 @@ func match(element *Element, kind, value string) bool {
}
return strings.HasSuffix(element.ResourceID, ":id/"+value)
case "text":
return element.Text == value
return matchAttr(element, "text", value)
case "desc":
// Exact match, or iOS merged label "desc, child text".
return element.Description == value || strings.HasPrefix(element.Description, value+", ")
case "descPrefix":
return strings.HasPrefix(element.Description, value)
default:
return false
return matchAttr(element, kind, value)
}
}
+169
View File
@@ -307,3 +307,172 @@ func TestIOSBoundsFormat(t *testing.T) {
t.Errorf("unexpected center: (%d, %d)", cx, cy)
}
}
// --- full-attribute selector tests ---
const androidAttrDump = `{
"attributes": {"resource-id": "com.app:id/list", "bounds": "[0,0,1080,2340]"},
"children": [
{
"attributes": {"resource-id": "com.app:id/row1", "scrollable": "true", "bounds": "[0,0,1080,200]"},
"children": [],
"clickable": true,
"enabled": true
},
{
"attributes": {"resource-id": "com.app:id/row2", "scrollable": "false", "bounds": "[0,200,1080,400]"},
"children": [],
"clickable": false,
"enabled": true
}
]
}`
const iosAttrDump = `{
"attributes": {"bounds": "[0,0,390,844]"},
"children": [
{
"attributes": {"accessibilityText": "Close", "title": "Settings", "bounds": "[0,0,100,50]"},
"children": [],
"enabled": true
}
]
}`
func TestRawResourceIDSubstringMatch(t *testing.T) {
tree, _ := Parse(androidAttrDump)
el := tree.Find("resource-id:row1")
if el == nil {
t.Fatal("expected resource-id: to match via substring")
}
}
func TestLabelAliasMatchesAccessibilityText(t *testing.T) {
tree, _ := Parse(iosAttrDump)
el := tree.Find("label:Close")
if el == nil {
t.Fatal("expected label: to match accessibilityText via alias")
}
}
func TestContentDescAliasOnIOS(t *testing.T) {
tree, _ := Parse(iosAttrDump)
el := tree.Find("content-desc:Close")
if el == nil {
t.Fatal("expected content-desc: to match accessibilityText via alias on iOS")
}
}
func TestScrollableTrueMatches(t *testing.T) {
tree, _ := Parse(androidAttrDump)
el := tree.Find("scrollable:true")
if el == nil {
t.Fatal("expected scrollable:true to match")
}
if el.ResourceID != "com.app:id/row1" {
t.Fatalf("got %q, want row1", el.ResourceID)
}
}
func TestScrollableFalseMatchesSecondRow(t *testing.T) {
tree, _ := Parse(androidAttrDump)
el := tree.Find("scrollable:false")
if el == nil {
t.Fatal("expected scrollable:false to match row2")
}
if el.ResourceID != "com.app:id/row2" {
t.Fatalf("got %q, want row2", el.ResourceID)
}
}
func TestTitleMatchesIOSElement(t *testing.T) {
tree, _ := Parse(iosAttrDump)
el := tree.Find("title:Settings")
if el == nil {
t.Fatal("expected title:Settings to match iOS element")
}
}
func TestTitleReturnsNilForAndroid(t *testing.T) {
tree, _ := Parse(androidAttrDump)
el := tree.Find("title:Settings")
if el != nil {
t.Fatal("expected title:Settings to return nil for Android element (graceful ignore)")
}
}
func TestScrollableGracefulIgnoreOnIOS(t *testing.T) {
tree, _ := Parse(iosAttrDump)
el := tree.Find("scrollable:true")
if el != nil {
t.Fatal("expected scrollable:true to return nil on iOS hierarchy (graceful ignore)")
}
}
func TestTextIsNowSubstring(t *testing.T) {
tree, _ := Parse(sampleDump)
el := tree.Find("text:Hel")
if el == nil {
t.Fatal("expected text: to match substring")
}
if el.Text != "Hello" {
t.Fatalf("got %q, want Hello", el.Text)
}
}
func TestMultiFilterSelectorAND(t *testing.T) {
tree, _ := Parse(androidAttrDump)
sel := Selector{Filters: []AttrFilter{
{Attr: "scrollable", Value: "true"},
{Attr: "resource-id", Value: "row1"},
}}
node := tree.Root.FindBySelector(sel)
if node == nil {
t.Fatal("expected AND selector to find row1 (scrollable=true AND resource-id contains row1)")
}
if node.Element.ResourceID != "com.app:id/row1" {
t.Fatalf("got %q, want row1", node.Element.ResourceID)
}
}
func TestMultiFilterSelectorMissReturnsNil(t *testing.T) {
tree, _ := Parse(androidAttrDump)
sel := Selector{Filters: []AttrFilter{
{Attr: "scrollable", Value: "true"},
{Attr: "resource-id", Value: "row2"}, // row2 is not scrollable=true
}}
node := tree.Root.FindBySelector(sel)
if node != nil {
t.Fatal("expected AND selector to return nil when one filter misses")
}
}
func TestNodeFindScopedSearch(t *testing.T) {
tree, _ := Parse(pathDump)
// A2 has a child B2 with content-desc "label_b"
// Find the A node, then search its subtree for label_b -- should find B (not B2)
aNode := tree.FindNode("id:A")
if aNode == nil {
t.Fatal("expected to find A node")
}
result := aNode.Find("desc:label_b")
if result == nil {
t.Fatal("expected Node.Find to find label_b in A's subtree")
}
if result.Element.ResourceID != "B" {
t.Fatalf("got %q, want B (not B2 from sibling A2)", result.Element.ResourceID)
}
}
func TestNodeFindDoesNotReturnSiblings(t *testing.T) {
tree, _ := Parse(pathDump)
a2Node := tree.FindNode("id:A2")
if a2Node == nil {
t.Fatal("expected to find A2 node")
}
// B is under A, not A2 -- should not be found from A2's subtree
result := a2Node.Find("id:B")
if result != nil && result.Element.ResourceID == "B" {
t.Fatal("Node.Find should not return nodes from sibling subtrees")
}
}
+133 -12
View File
@@ -61,33 +61,34 @@ func stateObject(runtime *goja.Runtime, input stateInput) (*goja.Object, error)
func accessibilityObject(runtime *goja.Runtime, tree *hierarchy.Tree) *goja.Object {
accessibility := runtime.NewObject()
find := func(selector string) goja.Value {
find := func(call goja.FunctionCall) goja.Value {
if tree == nil {
return goja.Undefined()
}
element := tree.Find(selector)
if element == nil {
node := findNodeFromJS(runtime, tree, call.Argument(0))
if node == nil {
return goja.Undefined()
}
return elementObject(runtime, element, selector)
return nodeObject(runtime, node, selectorStringFromJS(call.Argument(0)))
}
findAll := func(selector string) []goja.Value {
findAll := func(call goja.FunctionCall) goja.Value {
if tree == nil {
return nil
return goja.Undefined()
}
elements := tree.FindAll(selector)
result := make([]goja.Value, len(elements))
for index, element := range elements {
result[index] = elementObject(runtime, element, selector)
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))))
}
return result
return array
}
_ = accessibility.Set("find", runtime.ToValue(find))
_ = accessibility.Set("findAll", runtime.ToValue(findAll))
return accessibility
}
func elementObject(runtime *goja.Runtime, element *hierarchy.Element, selector string) goja.Value {
func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) goja.Value {
element := &node.Element
object := runtime.NewObject()
centerX, centerY := element.Bounds.Center()
_ = object.Set("id", element.ResourceID)
@@ -108,9 +109,129 @@ func elementObject(runtime *goja.Runtime, element *hierarchy.Element, selector s
_ = bounds.Set("right", element.Bounds.Right)
_ = bounds.Set("bottom", element.Bounds.Bottom)
_ = object.Set("bounds", bounds)
attrs := runtime.NewObject()
for k, v := range element.Attributes {
_ = attrs.Set(k, v)
}
_ = object.Set("attrs", attrs)
childFind := func(call goja.FunctionCall) goja.Value {
arg := call.Argument(0)
childNode := findNodeInSubtreeFromJS(runtime, node, arg)
if childNode == nil {
return goja.Undefined()
}
return nodeObject(runtime, childNode, selectorStringFromJS(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)))
}
return array
}
_ = object.Set("find", runtime.ToValue(childFind))
_ = object.Set("findAll", runtime.ToValue(childFindAll))
return object
}
// findNodeFromJS dispatches a JS value (string or object) 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
}
if tree == nil {
return nil
}
if s, ok := arg.Export().(string); ok {
return tree.FindNode(s)
}
sel := selectorFromJSObject(runtime, arg)
if len(sel.Filters) == 0 {
return nil
}
return tree.Root.FindBySelector(sel)
}
// findAllNodesFromJS dispatches a JS value to Tree-level multi-node lookup.
func findAllNodesFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value) []*hierarchy.Node {
if goja.IsUndefined(arg) || goja.IsNull(arg) || tree == nil {
return nil
}
if s, ok := arg.Export().(string); ok {
return tree.FindAllNodes(s)
}
sel := selectorFromJSObject(runtime, arg)
if len(sel.Filters) == 0 {
return nil
}
return tree.Root.FindAllBySelector(sel)
}
// findNodeInSubtreeFromJS dispatches a JS value to Node-level scoped lookup.
func findNodeInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, arg goja.Value) *hierarchy.Node {
if goja.IsUndefined(arg) || goja.IsNull(arg) {
return nil
}
if s, ok := arg.Export().(string); ok {
return node.Find(s)
}
sel := selectorFromJSObject(runtime, arg)
if len(sel.Filters) == 0 {
return nil
}
return node.FindBySelector(sel)
}
// findAllNodesInSubtreeFromJS dispatches a JS value to Node-level scoped multi-lookup.
func findAllNodesInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, arg goja.Value) []*hierarchy.Node {
if goja.IsUndefined(arg) || goja.IsNull(arg) {
return nil
}
if s, ok := arg.Export().(string); ok {
return node.FindAll(s)
}
sel := selectorFromJSObject(runtime, arg)
if len(sel.Filters) == 0 {
return nil
}
return node.FindAllBySelector(sel)
}
// selectorFromJSObject converts a JS object {attr: value, ...} into a Selector.
func selectorFromJSObject(runtime *goja.Runtime, arg goja.Value) hierarchy.Selector {
obj := arg.ToObject(runtime)
if obj == nil {
return hierarchy.Selector{}
}
var sel hierarchy.Selector
for _, key := range obj.Keys() {
if key == tagSelector {
continue
}
val := obj.Get(key)
if val == nil || goja.IsUndefined(val) {
continue
}
sel.Filters = append(sel.Filters, hierarchy.AttrFilter{Attr: key, Value: val.String()})
}
return sel
}
// 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) {
return ""
}
if s, ok := arg.Export().(string); ok {
return s
}
return arg.String()
}
func lastActionObject(runtime *goja.Runtime, action *Action) goja.Value {
if action == nil {
return goja.Null()