mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
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:
7 files changed
+593
-107
No files matched your search
+7
-2
@@ -123,7 +123,7 @@ private fun AccountCard(
|
|||||||
.clip(RoundedCornerShape(RadiusLg))
|
.clip(RoundedCornerShape(RadiusLg))
|
||||||
.background(t.surface)
|
.background(t.surface)
|
||||||
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
|
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
|
||||||
.semantics(mergeDescendants = true) { contentDescription = "account:$id:$balance" }
|
.semantics { contentDescription = "account_card:$id" }
|
||||||
.clickable(role = Role.Button, onClick = onClick)
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -148,6 +148,11 @@ private fun AccountCard(
|
|||||||
)
|
)
|
||||||
Text(txnLabel, style = Type.caption, color = t.textMuted)
|
Text(txnLabel, style = Type.caption, color = t.textMuted)
|
||||||
}
|
}
|
||||||
Text(formatCents(balance), style = Type.bodyStrong, color = t.text)
|
Text(
|
||||||
|
formatCents(balance),
|
||||||
|
style = Type.bodyStrong,
|
||||||
|
color = t.text,
|
||||||
|
modifier = Modifier.semantics { contentDescription = "account_balance" },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-5
@@ -75,7 +75,7 @@ fun LedgerScreen(accountId: String) {
|
|||||||
val balance = balanceOf(txns)
|
val balance = balanceOf(txns)
|
||||||
|
|
||||||
Screen(
|
Screen(
|
||||||
modifier = Modifier.testTag("LedgerScreen").semantics { contentDescription = "LedgerScreen" },
|
modifier = Modifier.testTag("LedgerScreen").semantics { contentDescription = "LedgerScreen:$accountId" },
|
||||||
header = {
|
header = {
|
||||||
Header(
|
Header(
|
||||||
title = account.name,
|
title = account.name,
|
||||||
@@ -92,11 +92,14 @@ fun LedgerScreen(accountId: String) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Box(Modifier.size(1.dp).semantics { contentDescription = "active_account:$accountId" })
|
|
||||||
Box(Modifier.size(1.dp).semantics { contentDescription = "ledger_balance:$balance" })
|
|
||||||
Card {
|
Card {
|
||||||
Text("BALANCE", style = Type.label, color = t.textMuted)
|
Text("BALANCE", style = Type.label, color = t.textMuted)
|
||||||
Text(formatCents(balance), style = Type.balance, color = t.text)
|
Text(
|
||||||
|
formatCents(balance),
|
||||||
|
style = Type.balance,
|
||||||
|
color = t.text,
|
||||||
|
modifier = Modifier.semantics { contentDescription = "ledger_balance_display" },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"ACTIVITY",
|
"ACTIVITY",
|
||||||
@@ -138,7 +141,7 @@ private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date:
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 14.dp)
|
.padding(vertical = 14.dp)
|
||||||
.semantics(mergeDescendants = true) { contentDescription = "ledger_row:$id:$signed" },
|
.semantics { contentDescription = "ledger_row:$id" },
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
@@ -166,6 +169,7 @@ private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date:
|
|||||||
formatCents(signed, signed = true),
|
formatCents(signed, signed = true),
|
||||||
style = Type.bodyStrong,
|
style = Type.bodyStrong,
|
||||||
color = t.text,
|
color = t.text,
|
||||||
|
modifier = Modifier.semantics { contentDescription = "txn_amount" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,57 +20,72 @@ interface LedgerRow {
|
|||||||
signed: number;
|
signed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAccount(desc: string | undefined): Account {
|
// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents.
|
||||||
const parts = (desc ?? "").split(":");
|
// formatCents always uses $ prefix, . as decimal, , as thousands separator.
|
||||||
return { id: parts[1] ?? "", balance: Number(parts[2]) };
|
function parseDollarCents(text: string | undefined): number {
|
||||||
}
|
if (!text) return 0;
|
||||||
|
const sign = text.startsWith("-") ? -1 : 1;
|
||||||
function parseLedgerRow(desc: string | undefined): LedgerRow {
|
const digits = text.replace(/[^0-9]/g, "");
|
||||||
const parts = (desc ?? "").split(":");
|
return digits ? sign * parseInt(digits, 10) : 0;
|
||||||
return { id: parts[1] ?? "", signed: Number(parts[2]) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCents(desc: string | null | undefined): number {
|
|
||||||
if (!desc) return 0;
|
|
||||||
const parts = desc.split(":");
|
|
||||||
return Number(parts[1]) || 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route and auth state derived from screen root nodes
|
// Route and auth state derived from screen root nodes
|
||||||
const loggedIn = extract(s => s.ax.find("desc:LoginScreen") == null);
|
const loggedIn = extract(s => s.ax.find({ accessibilityText: "LoginScreen" }) == null);
|
||||||
const route = extract<string | null>(s => {
|
const route = extract<string | null>(s => {
|
||||||
if (s.ax.find("desc:LoginScreen")) return "login";
|
if (s.ax.find({ accessibilityText: "LoginScreen" })) return "login";
|
||||||
if (s.ax.find("desc:HomeScreen")) return "home";
|
if (s.ax.find({ accessibilityText: "HomeScreen" })) return "home";
|
||||||
if (s.ax.find("desc:AddAccountScreen")) return "add-account";
|
if (s.ax.find({ accessibilityText: "AddAccountScreen" })) return "add-account";
|
||||||
if (s.ax.find("desc:LedgerScreen")) return "ledger";
|
if (s.ax.find({ accessibilityText: "LedgerScreen" })) return "ledger";
|
||||||
if (s.ax.find("desc:AddTransactionScreen")) return "add-transaction";
|
if (s.ax.find({ accessibilityText: "AddTransactionScreen" })) return "add-transaction";
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// All element lookups scoped through their screen root
|
// All element lookups scoped through their screen root
|
||||||
const accounts = extract(s => s.ax.findAll("desc:HomeScreen > descPrefix:account:")
|
const accounts = extract(s =>
|
||||||
.map(el => parseAccount(el.desc)));
|
s.ax.find({ accessibilityText: "HomeScreen" })?.findAll("descPrefix:account_card:")
|
||||||
const ledgerRows = extract(s => s.ax.findAll("desc:LedgerScreen > descPrefix:ledger_row:")
|
.map(el => ({
|
||||||
.map(el => parseLedgerRow(el.desc)));
|
id: el.desc?.split(":")[1] ?? "",
|
||||||
|
balance: parseDollarCents(el.find({ accessibilityText: "account_balance" })?.text),
|
||||||
|
})) ?? []);
|
||||||
|
const ledgerRows = extract(s =>
|
||||||
|
s.ax.find({ accessibilityText: "LedgerScreen" })?.findAll("descPrefix:ledger_row:")
|
||||||
|
.map(el => ({
|
||||||
|
id: el.desc?.split(":")[1] ?? "",
|
||||||
|
signed: parseDollarCents(el.find({ accessibilityText: "txn_amount" })?.text),
|
||||||
|
})) ?? []);
|
||||||
const ledgerBalance = extract(s =>
|
const ledgerBalance = extract(s =>
|
||||||
parseCents(s.ax.find("desc:LedgerScreen > descPrefix:ledger_balance:")?.desc));
|
parseDollarCents(
|
||||||
const activeAccountId = extract(s =>
|
s.ax.find({ accessibilityText: "LedgerScreen" })?.find({ accessibilityText: "ledger_balance_display" })?.text
|
||||||
s.ax.find("desc:LedgerScreen > descPrefix:active_account:")?.desc?.split(":")[1] ?? null);
|
));
|
||||||
|
const activeAccountId = extract(s => {
|
||||||
|
const desc = s.ax.find("descPrefix:LedgerScreen:")?.desc;
|
||||||
|
return desc ? (desc.split(":")[1] ?? null) : null;
|
||||||
|
});
|
||||||
|
|
||||||
// focusedInput lives in the app root (not inside any screen), so unscoped
|
// focusedInput lives in the app root (not inside any screen), so unscoped
|
||||||
const focusedInput = extract(s =>
|
const focusedInput = extract(s =>
|
||||||
s.ax.find("descPrefix:focused_input:")?.desc?.split(":")[1] ?? null);
|
s.ax.find("descPrefix:focused_input:")?.desc?.split(":")[1] ?? null);
|
||||||
|
|
||||||
const loginEmailField = extract(s => s.ax.find("desc:LoginScreen > desc:login_email"));
|
const loginEmailField = extract(s =>
|
||||||
const loginPasswordField = extract(s => s.ax.find("desc:LoginScreen > desc:login_password"));
|
s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_email" }));
|
||||||
const loginSubmit = extract(s => s.ax.find("desc:LoginScreen > desc:login_submit"));
|
const loginPasswordField = extract(s =>
|
||||||
const addAccountButton = extract(s => s.ax.find("desc:HomeScreen > desc:add_account_button"));
|
s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_password" }));
|
||||||
const accountNameField = extract(s => s.ax.find("desc:AddAccountScreen > desc:account_name_field"));
|
const loginSubmit = extract(s =>
|
||||||
const addAccountSubmit = extract(s => s.ax.find("desc:AddAccountScreen > desc:add_account_submit"));
|
s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_submit" }));
|
||||||
const addTxnButton = extract(s => s.ax.find("desc:LedgerScreen > desc:add_txn_button"));
|
const addAccountButton = extract(s =>
|
||||||
const txnAmountField = extract(s => s.ax.find("desc:AddTransactionScreen > desc:txn_amount"));
|
s.ax.find({ accessibilityText: "HomeScreen" })?.find({ accessibilityText: "add_account_button" }));
|
||||||
const txnSubmit = extract(s => s.ax.find("desc:AddTransactionScreen > desc:txn_submit"));
|
const accountNameField = extract(s =>
|
||||||
const accountCards = extract(s => s.ax.findAll("desc:HomeScreen > descPrefix:account:"));
|
s.ax.find({ accessibilityText: "AddAccountScreen" })?.find({ accessibilityText: "account_name_field" }));
|
||||||
|
const addAccountSubmit = extract(s =>
|
||||||
|
s.ax.find({ accessibilityText: "AddAccountScreen" })?.find({ accessibilityText: "add_account_submit" }));
|
||||||
|
const addTxnButton = extract(s =>
|
||||||
|
s.ax.find({ accessibilityText: "LedgerScreen" })?.find({ accessibilityText: "add_txn_button" }));
|
||||||
|
const txnAmountField = extract(s =>
|
||||||
|
s.ax.find({ accessibilityText: "AddTransactionScreen" })?.find({ accessibilityText: "txn_amount" }));
|
||||||
|
const txnSubmit = extract(s =>
|
||||||
|
s.ax.find({ accessibilityText: "AddTransactionScreen" })?.find({ accessibilityText: "txn_submit" }));
|
||||||
|
const accountCards = extract(s =>
|
||||||
|
s.ax.find({ accessibilityText: "HomeScreen" })?.findAll("descPrefix:account_card:") ?? []);
|
||||||
const backButton = extract(s => s.ax.find("desc:Back"));
|
const backButton = extract(s => s.ax.find("desc:Back"));
|
||||||
|
|
||||||
// Property 1: every new account starts with balance === 0
|
// Property 1: every new account starts with balance === 0
|
||||||
|
|||||||
+217
-49
@@ -1,18 +1,24 @@
|
|||||||
// Package hierarchy parses the TreeNode JSON produced by the Maestro sidecar
|
// Package hierarchy parses the TreeNode JSON produced by the Maestro sidecar
|
||||||
// and resolves selectors against it.
|
// and resolves selectors against it.
|
||||||
//
|
//
|
||||||
// Selector grammar (v1.0):
|
// Selector grammar (v2.0):
|
||||||
//
|
//
|
||||||
// Single selectors (global scan):
|
// String selectors (global scan or element-scoped):
|
||||||
// id:<suffix> - resource-id == suffix or ends with ":id/<suffix>"
|
// attribute:value - substring match; exact for "true"/"false" booleans
|
||||||
// text:<value> - exact text match
|
// id:<suffix> - substring on resource-id / identifier (backward compat)
|
||||||
// desc:<value> - exact content-desc match
|
// text:<value> - substring on text attribute
|
||||||
// descPrefix:<prefix> - content-desc starts with prefix
|
// desc:<value> - substring on content-desc / accessibilityText
|
||||||
|
// descPrefix:<prefix> - starts-with on content-desc / accessibilityText
|
||||||
//
|
//
|
||||||
// Path queries (segments separated by " > "):
|
// Object selectors (multi-attribute AND, element-scoped or global):
|
||||||
// <sel> > <sel> > ... - each segment is matched within the subtree of the
|
// { attr: value, ... } - all key/value pairs must match; substring / boolean semantics
|
||||||
// previous match (any descendant, not just direct child)
|
//
|
||||||
// example: id:LoginScreen > desc:EmailInput
|
// 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
|
package hierarchy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -51,13 +57,14 @@ type Element struct {
|
|||||||
Package string `json:"package,omitempty"`
|
Package string `json:"package,omitempty"`
|
||||||
// Screen holds the current route/screen name when set by the driver on the
|
// Screen holds the current route/screen name when set by the driver on the
|
||||||
// root element (web platform only; empty for native platforms).
|
// root element (web platform only; empty for native platforms).
|
||||||
Screen string `json:"screen,omitempty"`
|
Screen string `json:"screen,omitempty"`
|
||||||
Clickable bool `json:"clickable,omitempty"`
|
Clickable bool `json:"clickable,omitempty"`
|
||||||
Enabled bool `json:"enabled,omitempty"`
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
Checked bool `json:"checked,omitempty"`
|
Checked bool `json:"checked,omitempty"`
|
||||||
Focused bool `json:"focused,omitempty"`
|
Focused bool `json:"focused,omitempty"`
|
||||||
Selected bool `json:"selected,omitempty"`
|
Selected bool `json:"selected,omitempty"`
|
||||||
Bounds Bounds `json:"bounds"`
|
Bounds Bounds `json:"bounds"`
|
||||||
|
Attributes map[string]string `json:"attrs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Node is one node in the hierarchy tree.
|
// Node is one node in the hierarchy tree.
|
||||||
@@ -83,6 +90,73 @@ type treeNodeJSON struct {
|
|||||||
Selected *bool `json:"selected"`
|
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.
|
// Parse parses a Maestro TreeNode JSON hierarchy.
|
||||||
func Parse(text string) (*Tree, error) {
|
func Parse(text string) (*Tree, error) {
|
||||||
text = strings.TrimSpace(text)
|
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
|
return element
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find returns the first element matching the selector, or nil.
|
// Find returns the first element matching the selector, or nil.
|
||||||
func (t *Tree) Find(selector string) *Element {
|
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, " > ") {
|
if strings.Contains(selector, " > ") {
|
||||||
return findPath(t.Root, strings.Split(selector, " > "))
|
return findPathNode(t.Root, strings.Split(selector, " > "))
|
||||||
}
|
}
|
||||||
kind, value, ok := parseSelector(selector)
|
kind, value, ok := parseSelector(selector)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, element := range t.Elements {
|
nodes := searchSubtree(t.Root, kind, value)
|
||||||
if match(element, kind, value) {
|
if len(nodes) == 0 {
|
||||||
return element
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindAll returns every element matching the selector.
|
// FindAll returns all Nodes in this node's subtree (descendants only) matching
|
||||||
func (t *Tree) FindAll(selector string) []*Element {
|
// the string selector.
|
||||||
if strings.Contains(selector, " > ") {
|
func (n *Node) FindAll(selector string) []*Node {
|
||||||
return findPathAll(t.Root, strings.Split(selector, " > "))
|
|
||||||
}
|
|
||||||
kind, value, ok := parseSelector(selector)
|
kind, value, ok := parseSelector(selector)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var matches []*Element
|
var result []*Node
|
||||||
for _, element := range t.Elements {
|
for _, child := range n.Children {
|
||||||
if match(element, kind, value) {
|
result = append(result, searchSubtree(child, kind, value)...)
|
||||||
matches = append(matches, element)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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 {
|
if root == nil || len(segments) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -195,16 +349,16 @@ func findPath(root *Node, segments []string) *Element {
|
|||||||
}
|
}
|
||||||
for _, node := range searchSubtree(root, kind, value) {
|
for _, node := range searchSubtree(root, kind, value) {
|
||||||
if len(segments) == 1 {
|
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 result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findPathDescendants(root *Node, segments []string) *Element {
|
func findPathDescendantsNode(root *Node, segments []string) *Node {
|
||||||
kind, value, ok := parseSelector(segments[0])
|
kind, value, ok := parseSelector(segments[0])
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -212,9 +366,9 @@ func findPathDescendants(root *Node, segments []string) *Element {
|
|||||||
for _, child := range root.Children {
|
for _, child := range root.Children {
|
||||||
for _, node := range searchSubtree(child, kind, value) {
|
for _, node := range searchSubtree(child, kind, value) {
|
||||||
if len(segments) == 1 {
|
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 result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,7 +376,7 @@ func findPathDescendants(root *Node, segments []string) *Element {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findPathAll(root *Node, segments []string) []*Element {
|
func findPathAllNodes(root *Node, segments []string) []*Node {
|
||||||
if root == nil || len(segments) == 0 {
|
if root == nil || len(segments) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -230,30 +384,30 @@ func findPathAll(root *Node, segments []string) []*Element {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var result []*Element
|
var result []*Node
|
||||||
for _, node := range searchSubtree(root, kind, value) {
|
for _, node := range searchSubtree(root, kind, value) {
|
||||||
if len(segments) == 1 {
|
if len(segments) == 1 {
|
||||||
result = append(result, &node.Element)
|
result = append(result, node)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result = append(result, findPathAllDescendants(node, segments[1:])...)
|
result = append(result, findPathAllDescendantsNodes(node, segments[1:])...)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func findPathAllDescendants(root *Node, segments []string) []*Element {
|
func findPathAllDescendantsNodes(root *Node, segments []string) []*Node {
|
||||||
kind, value, ok := parseSelector(segments[0])
|
kind, value, ok := parseSelector(segments[0])
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var result []*Element
|
var result []*Node
|
||||||
for _, child := range root.Children {
|
for _, child := range root.Children {
|
||||||
for _, node := range searchSubtree(child, kind, value) {
|
for _, node := range searchSubtree(child, kind, value) {
|
||||||
if len(segments) == 1 {
|
if len(segments) == 1 {
|
||||||
result = append(result, &node.Element)
|
result = append(result, node)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result = append(result, findPathAllDescendants(node, segments[1:])...)
|
result = append(result, findPathAllDescendantsNodes(node, segments[1:])...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -274,6 +428,21 @@ func searchSubtree(root *Node, kind, value string) []*Node {
|
|||||||
return result
|
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) {
|
func parseSelector(selector string) (string, string, bool) {
|
||||||
index := strings.IndexByte(selector, ':')
|
index := strings.IndexByte(selector, ':')
|
||||||
if index <= 0 {
|
if index <= 0 {
|
||||||
@@ -290,14 +459,13 @@ func match(element *Element, kind, value string) bool {
|
|||||||
}
|
}
|
||||||
return strings.HasSuffix(element.ResourceID, ":id/"+value)
|
return strings.HasSuffix(element.ResourceID, ":id/"+value)
|
||||||
case "text":
|
case "text":
|
||||||
return element.Text == value
|
return matchAttr(element, "text", value)
|
||||||
case "desc":
|
case "desc":
|
||||||
// Exact match, or iOS merged label "desc, child text".
|
|
||||||
return element.Description == value || strings.HasPrefix(element.Description, value+", ")
|
return element.Description == value || strings.HasPrefix(element.Description, value+", ")
|
||||||
case "descPrefix":
|
case "descPrefix":
|
||||||
return strings.HasPrefix(element.Description, value)
|
return strings.HasPrefix(element.Description, value)
|
||||||
default:
|
default:
|
||||||
return false
|
return matchAttr(element, kind, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,3 +307,172 @@ func TestIOSBoundsFormat(t *testing.T) {
|
|||||||
t.Errorf("unexpected center: (%d, %d)", cx, cy)
|
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
@@ -61,33 +61,34 @@ func stateObject(runtime *goja.Runtime, input stateInput) (*goja.Object, error)
|
|||||||
|
|
||||||
func accessibilityObject(runtime *goja.Runtime, tree *hierarchy.Tree) *goja.Object {
|
func accessibilityObject(runtime *goja.Runtime, tree *hierarchy.Tree) *goja.Object {
|
||||||
accessibility := runtime.NewObject()
|
accessibility := runtime.NewObject()
|
||||||
find := func(selector string) goja.Value {
|
find := func(call goja.FunctionCall) goja.Value {
|
||||||
if tree == nil {
|
if tree == nil {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
element := tree.Find(selector)
|
node := findNodeFromJS(runtime, tree, call.Argument(0))
|
||||||
if element == nil {
|
if node == nil {
|
||||||
return goja.Undefined()
|
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 {
|
if tree == nil {
|
||||||
return nil
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
elements := tree.FindAll(selector)
|
nodes := findAllNodesFromJS(runtime, tree, call.Argument(0))
|
||||||
result := make([]goja.Value, len(elements))
|
array := runtime.NewArray()
|
||||||
for index, element := range elements {
|
for i, n := range nodes {
|
||||||
result[index] = elementObject(runtime, element, selector)
|
_ = 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("find", runtime.ToValue(find))
|
||||||
_ = accessibility.Set("findAll", runtime.ToValue(findAll))
|
_ = accessibility.Set("findAll", runtime.ToValue(findAll))
|
||||||
return accessibility
|
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()
|
object := runtime.NewObject()
|
||||||
centerX, centerY := element.Bounds.Center()
|
centerX, centerY := element.Bounds.Center()
|
||||||
_ = object.Set("id", element.ResourceID)
|
_ = 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("right", element.Bounds.Right)
|
||||||
_ = bounds.Set("bottom", element.Bounds.Bottom)
|
_ = bounds.Set("bottom", element.Bounds.Bottom)
|
||||||
_ = object.Set("bounds", bounds)
|
_ = 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
|
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 {
|
func lastActionObject(runtime *goja.Runtime, action *Action) goja.Value {
|
||||||
if action == nil {
|
if action == nil {
|
||||||
return goja.Null()
|
return goja.Null()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type Snapshots = Record<string, unknown>;
|
export type Snapshots = Record<string, unknown>;
|
||||||
|
export type AttrSelector = Record<string, string>;
|
||||||
|
|
||||||
export interface AccessibilityElement {
|
export interface AccessibilityElement {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -13,11 +14,14 @@ export interface AccessibilityElement {
|
|||||||
bounds?: { left: number; top: number; right: number; bottom: number };
|
bounds?: { left: number; top: number; right: number; bottom: number };
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
|
attrs?: Record<string, string>;
|
||||||
|
find(selector: string | AttrSelector): AccessibilityElement | undefined;
|
||||||
|
findAll(selector: string | AttrSelector): AccessibilityElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccessibilityTree {
|
export interface AccessibilityTree {
|
||||||
find(selector: string): AccessibilityElement | undefined;
|
find(selector: string | AttrSelector): AccessibilityElement | undefined;
|
||||||
findAll(selector: string): AccessibilityElement[];
|
findAll(selector: string | AttrSelector): AccessibilityElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
|
|||||||
Reference in new issue
Block a user