mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
fix(selectors): resolve text to the innermost match and scan the root in both forms
an element's text is its whole subtree's text on web and on ios, so every ancestor of a matching element matched too, up to the root. a match a descendant also makes is now dropped, in internal/hierarchy, in the chrome xpath translation and in the page-side web runtime, so all three resolvers name the same element. a raw attribute now matches on a substring (exact for true/false) the way the docs describe, and tree-level FindBySelector considers the root, so ax.find("id:page") and ax.find({id: "page"}) agree.
This commit is contained in:
1 parent
a245194120
commit
ca667b1b77
9 files changed
+526
-49
No files matched your search
@@ -102,6 +102,21 @@ func TestSelectors_ResolveTheSameElementsAsTheWebRuntime(t *testing.T) {
|
||||
object: objectSelector("data-testid", "customer-row"),
|
||||
want: []string{"customer_row_a1", "customer_row_b2"},
|
||||
},
|
||||
{
|
||||
name: "raw attribute, matched on a substring",
|
||||
selector: "data-state:sent",
|
||||
object: objectSelector("data-state", "sent"),
|
||||
want: []string{"status_badge"},
|
||||
},
|
||||
{
|
||||
// The root element answers a selector like any other: the string
|
||||
// form scans from the root down, and the object form used to start
|
||||
// at the root's children and lose it.
|
||||
name: "id naming the root element",
|
||||
selector: "id:page",
|
||||
object: objectSelector("id", "page"),
|
||||
want: []string{"page"},
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
@@ -129,6 +144,189 @@ func TestSelectors_ResolveTheSameElementsAsTheWebRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// `text:` is a substring match on text content wherever the spec runs
|
||||
// (docs/manual/spec-language.md), so a badge reading "Sent ✓" answers to
|
||||
// text:Sent on web the way it already does on Android and iOS, and one reading
|
||||
// "3 unsent" out of two text nodes answers to text:unsent. It names the
|
||||
// innermost match: an element's text is its whole subtree's text, so a badge's
|
||||
// ancestors up to the document root read as matches too, and the deepest one is
|
||||
// the element the author meant.
|
||||
func TestSelectors_TextNamesTheInnermostMatchInEveryResolver(t *testing.T) {
|
||||
server := httptest.NewServer(http.FileServer(http.Dir("testdata")))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL+"/selector-parity.html", false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
dump, err := d.Hierarchy(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Hierarchy: %v", err)
|
||||
}
|
||||
tree, err := hierarchy.Parse(dump)
|
||||
if err != nil {
|
||||
t.Fatalf("parse hierarchy: %v", err)
|
||||
}
|
||||
installSelectorProbe(ctx, t, d)
|
||||
|
||||
// split_row is the ancestor that keeps its match: its own text carries the
|
||||
// value where no descendant of it does. nested_row is the ancestor that
|
||||
// loses it: its badge carries the value too, and the badge is the match.
|
||||
cases := []struct {
|
||||
name string
|
||||
selector string
|
||||
want []string
|
||||
scope string
|
||||
scopedWant []string
|
||||
}{
|
||||
{
|
||||
name: "one text node",
|
||||
selector: "text:Sent",
|
||||
want: []string{
|
||||
"status_badge",
|
||||
"draft_badge",
|
||||
"split_row",
|
||||
"nested_badge",
|
||||
},
|
||||
scope: "status_row",
|
||||
scopedWant: []string{"status_badge"},
|
||||
},
|
||||
{
|
||||
// React writes `{count} unsent` as two text nodes, and an XPath over
|
||||
// text() reads only the first of them.
|
||||
name: "text split across text nodes",
|
||||
selector: "text:unsent",
|
||||
want: []string{"unsent_badge"},
|
||||
scope: "unsent_row",
|
||||
scopedWant: []string{"unsent_badge"},
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
value := strings.TrimPrefix(testCase.selector, "text:")
|
||||
if native := selectorIDsFromDump(tree, testCase.selector); !slices.Equal(
|
||||
native,
|
||||
testCase.want,
|
||||
) {
|
||||
t.Errorf(
|
||||
"the dump matched %v for %s, want %v",
|
||||
native,
|
||||
testCase.selector,
|
||||
testCase.want,
|
||||
)
|
||||
}
|
||||
if web := selectorIDsFromWebRuntime(ctx, t, d, testCase.selector); !slices.Equal(
|
||||
web,
|
||||
testCase.want,
|
||||
) {
|
||||
t.Errorf(
|
||||
"the web runtime matched %v for %s, want %v",
|
||||
web,
|
||||
testCase.selector,
|
||||
testCase.want,
|
||||
)
|
||||
}
|
||||
object := objectSelectorJSON(objectSelector("text", value))
|
||||
if web := selectorIDsFromWebRuntime(ctx, t, d, object); !slices.Equal(
|
||||
web,
|
||||
testCase.want,
|
||||
) {
|
||||
t.Errorf(
|
||||
"the web runtime matched %v for %s, want %v",
|
||||
web,
|
||||
object,
|
||||
testCase.want,
|
||||
)
|
||||
}
|
||||
|
||||
xpath, isXPath, err := TranslateStringSelector(testCase.selector)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"TranslateStringSelector(%q): %v",
|
||||
testCase.selector,
|
||||
err,
|
||||
)
|
||||
}
|
||||
if !isXPath {
|
||||
t.Fatalf(
|
||||
"TranslateStringSelector(%q) returned CSS, want an XPath",
|
||||
testCase.selector,
|
||||
)
|
||||
}
|
||||
if overCDP := xpathIDsOverCDP(ctx, t, d, xpath); !slices.Equal(
|
||||
overCDP,
|
||||
testCase.want,
|
||||
) {
|
||||
t.Errorf(
|
||||
"the CDP selector %q matched %v, want %v",
|
||||
xpath,
|
||||
overCDP,
|
||||
testCase.want,
|
||||
)
|
||||
}
|
||||
|
||||
// Scoped to one row, `text:` reads that row, the way every other
|
||||
// selector does: an XPath anchored at the document root answers for
|
||||
// the whole page however the caller scoped the lookup.
|
||||
path := []hierarchy.Selector{
|
||||
objectSelector("id", testCase.scope),
|
||||
objectSelector("text", value),
|
||||
}
|
||||
var scoped []string
|
||||
for _, node := range tree.FindAllBySelectorPath(path) {
|
||||
scoped = append(scoped, node.Element.ResourceID)
|
||||
}
|
||||
if !slices.Equal(scoped, testCase.scopedWant) {
|
||||
t.Errorf(
|
||||
"the dump matched %v within %s, want %v",
|
||||
scoped,
|
||||
testCase.scope,
|
||||
testCase.scopedWant,
|
||||
)
|
||||
}
|
||||
pathJSON := objectSelectorJSON(objectSelector("id", testCase.scope))
|
||||
pathJSON = "[" + pathJSON + "," + object + "]"
|
||||
if web := selectorIDsFromWebRuntime(ctx, t, d, pathJSON); !slices.Equal(
|
||||
web,
|
||||
testCase.scopedWant,
|
||||
) {
|
||||
t.Errorf(
|
||||
"the web runtime matched %v for %s, want %v",
|
||||
web,
|
||||
pathJSON,
|
||||
testCase.scopedWant,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// xpathIDsOverCDP resolves an XPath the way TapSelector does, over CDP against
|
||||
// the live document, and reads back the ids it matched in document order.
|
||||
func xpathIDsOverCDP(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
d *Driver,
|
||||
xpath string,
|
||||
) []string {
|
||||
t.Helper()
|
||||
var ids []string
|
||||
script := `(() => {
|
||||
const found = document.evaluate(` + jsArgument(xpath) + `, document, null,
|
||||
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
||||
const ids = [];
|
||||
for (let i = 0; i < found.snapshotLength; i++) ids.push(found.snapshotItem(i).id);
|
||||
return ids;
|
||||
})()`
|
||||
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &ids)); err != nil {
|
||||
t.Fatalf("resolve %q over CDP: %v", xpath, err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// A third resolver reads the same selector: TapSelector hands it to CDP as the
|
||||
// CSS TranslateStringSelector builds. The runner uses both within one InputText
|
||||
// step, resolving the target in the dump and tapping it over CDP, so a selector
|
||||
@@ -197,16 +395,18 @@ func objectSelectorJSON(sel hierarchy.Selector) string {
|
||||
|
||||
func selectorIDsFromDumpObject(tree *hierarchy.Tree, sel hierarchy.Selector) []string {
|
||||
var ids []string
|
||||
for _, node := range tree.Root.FindAllBySelector(sel) {
|
||||
for _, node := range tree.FindAllBySelector(sel) {
|
||||
ids = append(ids, node.Element.ResourceID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// jsArgument passes an object selector through as the JS object literal it
|
||||
// already is, and quotes anything else as a string selector.
|
||||
// jsArgument passes an object or path selector through as the JS literal it
|
||||
// already is, and quotes anything else as a string selector. A CSS attribute
|
||||
// selector opens with a bracket too, so only well-formed JSON passes through.
|
||||
func jsArgument(selector string) string {
|
||||
if strings.HasPrefix(selector, "{") {
|
||||
if json.Valid([]byte(selector)) &&
|
||||
(strings.HasPrefix(selector, "{") || strings.HasPrefix(selector, "[")) {
|
||||
return selector
|
||||
}
|
||||
quoted, err := json.Marshal(selector)
|
||||
|
||||
@@ -11,5 +11,10 @@
|
||||
<div id="customer_row_b2" data-testid="customer-row" aria-label="customer_row_b2, Bob">Bob</div>
|
||||
<div id="supplier_row_c3" aria-label="supplier_row_c3, Carol">Carol</div>
|
||||
</div>
|
||||
<div id="status_row"><span id="status_badge" data-state="sent-badge">Sent ✓</span></div>
|
||||
<div id="draft_row"><span id="draft_badge">Sent ✓</span></div>
|
||||
<div id="unsent_row"><span id="unsent_badge">3<!-- --> unsent</span></div>
|
||||
<div id="split_row">Sen<span id="split_tail">t here</span></div>
|
||||
<div id="nested_row">Sent <span id="nested_badge">Sent</span></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -39,7 +39,14 @@ func TranslateStringSelector(selector string) (string, bool, error) {
|
||||
case "tag":
|
||||
return cssEscape(value), false, nil
|
||||
case "text":
|
||||
return `//*[normalize-space(text())=` + xpathStringLiteral(value) + `]`, true, nil
|
||||
// Substring of the element's whole text, the way internal/hierarchy
|
||||
// reads the same selector: an element reading "Sent ✓" answers to
|
||||
// text:Sent on every platform, and one React wrote as `{count} unsent`
|
||||
// answers to text:unsent though its text arrives as two text nodes.
|
||||
// normalize-space(text()) would read only the first of them. The
|
||||
// not() clause is what keeps the badge's ancestors, up to <html>, from
|
||||
// answering for it.
|
||||
return `//*[` + innermostTextPredicate(value) + `]`, true, nil
|
||||
case "desc":
|
||||
// Mirrors the native rule: the label itself, or the label at the head of
|
||||
// an iOS merged label ("account_card:7, Tim, $100").
|
||||
@@ -63,7 +70,11 @@ func TranslateStringSelector(selector string) (string, bool, error) {
|
||||
if !attrNamePattern.MatchString(kind) {
|
||||
return "", false, fmt.Errorf("unsafe selector prefix %q", kind)
|
||||
}
|
||||
return `[` + kind + `="` + cssEscape(value) + `"]`, false, nil
|
||||
operator := `*=`
|
||||
if value == "true" || value == "false" {
|
||||
operator = `=`
|
||||
}
|
||||
return `[` + kind + operator + `"` + cssEscape(value) + `"]`, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +106,17 @@ func cssEscape(value string) string {
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// innermostTextPredicate matches an element whose text contains value and whose
|
||||
// descendants do not, which is the innermost match internal/hierarchy resolves
|
||||
// the same selector to. The same predicate appears in
|
||||
// pkg/spec/src/web-runtime.ts.
|
||||
func innermostTextPredicate(value string) string {
|
||||
contains := `contains(normalize-space(.), ` + xpathStringLiteral(
|
||||
value,
|
||||
) + `)`
|
||||
return contains + ` and not(.//*[` + contains + `])`
|
||||
}
|
||||
|
||||
// xpathStringLiteral wraps the value in a valid XPath 1.0 string literal.
|
||||
// XPath 1.0 has no escape syntax, so a value containing both ' and " must be
|
||||
// composed via concat(). The output already includes the surrounding quotes
|
||||
|
||||
@@ -12,10 +12,26 @@ func TestTranslateStringSelector_KnownKeys(t *testing.T) {
|
||||
{"resource-id:account-name", `[id="account-name"]`, false},
|
||||
{"class:btn-primary", `[class~="btn-primary"]`, false},
|
||||
{"tag:button", `button`, false},
|
||||
{"text:Sign in", `//*[normalize-space(text())="Sign in"]`, true},
|
||||
{`text:Say "hi"`, `//*[normalize-space(text())='Say "hi"']`, true},
|
||||
{`text:it's`, `//*[normalize-space(text())="it's"]`, true},
|
||||
{`text:it's "fine"`, `//*[normalize-space(text())=concat("it's ", '"', "fine", '"', "")]`, true},
|
||||
{
|
||||
"text:Sign in",
|
||||
`//*[contains(normalize-space(.), "Sign in") and not(.//*[contains(normalize-space(.), "Sign in")])]`,
|
||||
true,
|
||||
},
|
||||
{
|
||||
`text:Say "hi"`,
|
||||
`//*[contains(normalize-space(.), 'Say "hi"') and not(.//*[contains(normalize-space(.), 'Say "hi"')])]`,
|
||||
true,
|
||||
},
|
||||
{
|
||||
`text:it's`,
|
||||
`//*[contains(normalize-space(.), "it's") and not(.//*[contains(normalize-space(.), "it's")])]`,
|
||||
true,
|
||||
},
|
||||
{
|
||||
`text:it's "fine"`,
|
||||
`//*[contains(normalize-space(.), concat("it's ", '"', "fine", '"', "")) and not(.//*[contains(normalize-space(.), concat("it's ", '"', "fine", '"', ""))])]`,
|
||||
true,
|
||||
},
|
||||
// desc also accepts an iOS merged label, the way internal/hierarchy does.
|
||||
{"desc:logout", `:is([aria-label="logout"], [aria-label^="logout, "])`, false},
|
||||
{"label:logout", `[aria-label="logout"]`, false},
|
||||
@@ -47,8 +63,21 @@ func TestTranslateStringSelector_UnknownPrefixPassesThrough(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != `[role="button"]` {
|
||||
t.Errorf("unknown prefix should map to attribute selector, got %q", got)
|
||||
if got != `[role*="button"]` {
|
||||
t.Errorf(
|
||||
"unknown prefix should map to a substring attribute selector, got %q",
|
||||
got,
|
||||
)
|
||||
}
|
||||
got, _, err = TranslateStringSelector("aria-expanded:true")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != `[aria-expanded="true"]` {
|
||||
t.Errorf(
|
||||
"a boolean value should map to an exact attribute selector, got %q",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+101
-19
@@ -7,7 +7,7 @@
|
||||
// attribute:value - substring match; exact for "true"/"false" booleans
|
||||
// id:<suffix> - substring on resource-id / identifier (backward compat)
|
||||
// idPrefix:<prefix> - starts-with on resource-id / identifier, package prefix skipped
|
||||
// text:<value> - substring on text attribute
|
||||
// text:<value> - substring on text attribute, innermost match only
|
||||
// desc:<value> - substring on content-desc / accessibilityText
|
||||
// descPrefix:<prefix> - starts-with on content-desc / accessibilityText
|
||||
//
|
||||
@@ -325,6 +325,46 @@ func matchSelector(element *Element, sel Selector) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func selectorReadsText(sel Selector) bool {
|
||||
for _, f := range sel.Filters {
|
||||
if f.Attr == "text" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// innermostMatches drops a match a descendant of it also makes. An element's
|
||||
// text is its whole subtree's text on web and on iOS, so every ancestor of a
|
||||
// matching element matches too, up to the root, and the deepest match is the
|
||||
// element the author named. An ancestor whose own text carries the value where
|
||||
// no descendant of it does keeps its match.
|
||||
func innermostMatches(nodes []*Node) []*Node {
|
||||
if len(nodes) == 0 {
|
||||
return nodes
|
||||
}
|
||||
matched := make(map[*Node]bool, len(nodes))
|
||||
for _, node := range nodes {
|
||||
matched[node] = true
|
||||
}
|
||||
var kept []*Node
|
||||
for _, node := range nodes {
|
||||
if !hasMatchingDescendant(node, matched) {
|
||||
kept = append(kept, node)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func hasMatchingDescendant(node *Node, matched map[*Node]bool) bool {
|
||||
for _, child := range node.Children {
|
||||
if matched[child] || hasMatchingDescendant(child, matched) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse parses a sidecar TreeNode JSON hierarchy.
|
||||
func Parse(text string) (*Tree, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
@@ -497,20 +537,54 @@ func (t *Tree) FindAllNodes(selector string) []*Node {
|
||||
return searchSubtree(t.Root, kind, value)
|
||||
}
|
||||
|
||||
// FindBySelectorPath walks the selector chain starting from the tree root.
|
||||
func (t *Tree) FindBySelectorPath(path []Selector) *Node {
|
||||
if t == nil || t.Root == nil {
|
||||
// FindBySelector returns the first Node in the tree matching sel, or nil. The
|
||||
// root is a candidate, the way it is for the string form: one selector cannot
|
||||
// mean one thing written "id:page" and another written {id: "page"}.
|
||||
func (t *Tree) FindBySelector(sel Selector) *Node {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.Root.FindBySelectorPath(path)
|
||||
return firstNode(searchSubtreeBySelector(t.Root, sel))
|
||||
}
|
||||
|
||||
// FindAllBySelector returns every Node in the tree matching sel, root included.
|
||||
func (t *Tree) FindAllBySelector(sel Selector) []*Node {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return searchSubtreeBySelector(t.Root, sel)
|
||||
}
|
||||
|
||||
// FindBySelectorPath walks the selector chain starting from the tree root.
|
||||
func (t *Tree) FindBySelectorPath(path []Selector) *Node {
|
||||
if t == nil || t.Root == nil || len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, candidate := range t.FindAllBySelector(path[0]) {
|
||||
if len(path) == 1 {
|
||||
return candidate
|
||||
}
|
||||
if deeper := candidate.FindBySelectorPath(path[1:]); deeper != nil {
|
||||
return deeper
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindAllBySelectorPath walks the selector chain starting from the tree root.
|
||||
func (t *Tree) FindAllBySelectorPath(path []Selector) []*Node {
|
||||
if t == nil || t.Root == nil {
|
||||
if t == nil || t.Root == nil || len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
return t.Root.FindAllBySelectorPath(path)
|
||||
var result []*Node
|
||||
for _, candidate := range t.FindAllBySelector(path[0]) {
|
||||
if len(path) == 1 {
|
||||
result = append(result, candidate)
|
||||
continue
|
||||
}
|
||||
result = append(result, candidate.FindAllBySelectorPath(path[1:])...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Find returns the first Node scoped to this node (descendants, with spatial
|
||||
@@ -527,9 +601,13 @@ func (n *Node) FindAll(selector string) []*Node {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return n.scopedNodes(func(element *Element) bool {
|
||||
nodes := n.scopedNodes(func(element *Element) bool {
|
||||
return matchAttr(element, kind, value)
|
||||
})
|
||||
if kind == "text" {
|
||||
return innermostMatches(nodes)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// FindBySelector returns the first Node scoped to this node matching sel (AND semantics).
|
||||
@@ -539,9 +617,13 @@ func (n *Node) FindBySelector(sel Selector) *Node {
|
||||
|
||||
// FindAllBySelector returns all Nodes scoped to this node matching sel (AND semantics).
|
||||
func (n *Node) FindAllBySelector(sel Selector) []*Node {
|
||||
return n.scopedNodes(func(element *Element) bool {
|
||||
nodes := n.scopedNodes(func(element *Element) bool {
|
||||
return matchSelector(element, sel)
|
||||
})
|
||||
if selectorReadsText(sel) {
|
||||
return innermostMatches(nodes)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// FindBySelectorPath walks a chain of selectors. The first selector is matched
|
||||
@@ -729,11 +811,11 @@ func searchSubtree(root *Node, kind, value string) []*Node {
|
||||
return nil
|
||||
}
|
||||
var result []*Node
|
||||
if matchAttr(&root.Element, kind, value) {
|
||||
result = append(result, root)
|
||||
}
|
||||
for _, child := range root.Children {
|
||||
result = append(result, searchSubtree(child, kind, value)...)
|
||||
collectMatches(root, func(element *Element) bool {
|
||||
return matchAttr(element, kind, value)
|
||||
}, &result)
|
||||
if kind == "text" {
|
||||
return innermostMatches(result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -744,11 +826,11 @@ func searchSubtreeBySelector(root *Node, sel Selector) []*Node {
|
||||
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)...)
|
||||
collectMatches(root, func(element *Element) bool {
|
||||
return matchSelector(element, sel)
|
||||
}, &result)
|
||||
if selectorReadsText(sel) {
|
||||
return innermostMatches(result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package hierarchy
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -575,6 +576,113 @@ func TestTextIsNowSubstring(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// subtreeTextDump is the shape web and iOS report: an element's text is its
|
||||
// whole subtree's text, so a badge's ancestors carry the badge's words.
|
||||
// split_row is the ancestor whose own text carries the value where no
|
||||
// descendant of it does; nested_row is the one whose badge carries it too.
|
||||
const subtreeTextDump = `{
|
||||
"attributes": {"resource-id": "page", "text": "Sent Sent here Sent Sent", "bounds": "[0,0,1080,2340]"},
|
||||
"children": [
|
||||
{
|
||||
"attributes": {"resource-id": "status_row", "text": "Sent", "bounds": "[0,0,1080,100]"},
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "status_badge", "text": "Sent", "bounds": "[0,0,200,100]"}, "children": []}
|
||||
]
|
||||
},
|
||||
{
|
||||
"attributes": {"resource-id": "split_row", "text": "Sent here", "bounds": "[0,100,1080,200]"},
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "split_tail", "text": "t here", "bounds": "[0,100,200,200]"}, "children": []}
|
||||
]
|
||||
},
|
||||
{
|
||||
"attributes": {"resource-id": "nested_row", "text": "Sent Sent", "bounds": "[0,200,1080,300]"},
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "nested_badge", "text": "Sent", "bounds": "[0,200,200,300]"}, "children": []}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestTextNamesTheInnermostMatch(t *testing.T) {
|
||||
tree, _ := Parse(subtreeTextDump)
|
||||
want := []string{"status_badge", "split_row", "nested_badge"}
|
||||
if got := resourceIDsOf(tree.FindAllNodes("text:Sent")); !slices.Equal(
|
||||
got,
|
||||
want,
|
||||
) {
|
||||
t.Errorf("text:Sent matched %v, want %v", got, want)
|
||||
}
|
||||
sel := Selector{Filters: []AttrFilter{{Attr: "text", Value: "Sent"}}}
|
||||
if got := resourceIDsOf(tree.FindAllBySelector(sel)); !slices.Equal(
|
||||
got,
|
||||
want,
|
||||
) {
|
||||
t.Errorf("{text: Sent} matched %v, want %v", got, want)
|
||||
}
|
||||
node := tree.FindNode("text:Sent")
|
||||
if node == nil || node.ResourceID != "status_badge" {
|
||||
t.Errorf("find named %v, want the deepest match status_badge", node)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedTextNamesTheInnermostMatch(t *testing.T) {
|
||||
tree, _ := Parse(subtreeTextDump)
|
||||
want := []string{"status_badge", "split_row", "nested_badge"}
|
||||
if got := resourceIDsOf(tree.Root.FindAll("text:Sent")); !slices.Equal(
|
||||
got,
|
||||
want,
|
||||
) {
|
||||
t.Errorf("scoped text:Sent matched %v, want %v", got, want)
|
||||
}
|
||||
sel := Selector{Filters: []AttrFilter{{Attr: "text", Value: "Sent"}}}
|
||||
if got := resourceIDsOf(tree.Root.FindAllBySelector(sel)); !slices.Equal(
|
||||
got,
|
||||
want,
|
||||
) {
|
||||
t.Errorf("scoped {text: Sent} matched %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The root answers a selector whichever form the selector is written in: the
|
||||
// string form scans the tree from the root down, and the object form used to
|
||||
// start at the root's children and lose it.
|
||||
func TestRootMatchesInBothSelectorForms(t *testing.T) {
|
||||
tree, _ := Parse(subtreeTextDump)
|
||||
sel := Selector{Filters: []AttrFilter{{Attr: "id", Value: "page"}}}
|
||||
want := []string{"page"}
|
||||
if got := resourceIDsOf(tree.FindAllNodes("id:page")); !slices.Equal(
|
||||
got,
|
||||
want,
|
||||
) {
|
||||
t.Errorf("id:page matched %v, want %v", got, want)
|
||||
}
|
||||
if got := resourceIDsOf(tree.FindAllBySelector(sel)); !slices.Equal(
|
||||
got,
|
||||
want,
|
||||
) {
|
||||
t.Errorf("{id: page} matched %v, want %v", got, want)
|
||||
}
|
||||
if got := resourceIDsOf(tree.FindAllBySelectorPath([]Selector{sel})); !slices.Equal(
|
||||
got,
|
||||
want,
|
||||
) {
|
||||
t.Errorf("[{id: page}] matched %v, want %v", got, want)
|
||||
}
|
||||
if node := tree.FindBySelector(sel); node == nil ||
|
||||
node.ResourceID != "page" {
|
||||
t.Errorf("find({id: page}) named %v, want page", node)
|
||||
}
|
||||
}
|
||||
|
||||
func resourceIDsOf(nodes []*Node) []string {
|
||||
var ids []string
|
||||
for _, node := range nodes {
|
||||
ids = append(ids, node.ResourceID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestMultiFilterSelectorAND(t *testing.T) {
|
||||
tree, _ := Parse(androidAttrDump)
|
||||
sel := Selector{Filters: []AttrFilter{
|
||||
@@ -1142,7 +1250,7 @@ func TestTreeTransitional(t *testing.T) {
|
||||
func TestObjectSelectorIDMatchesTheSameElementsAsTheStringForm(t *testing.T) {
|
||||
tree, _ := Parse(sampleDump)
|
||||
sel := Selector{Filters: []AttrFilter{{Attr: "id", Value: "row"}}}
|
||||
object := tree.Root.FindAllBySelector(sel)
|
||||
object := tree.FindAllBySelector(sel)
|
||||
if len(object) != len(tree.FindAll("id:row")) {
|
||||
t.Fatalf("object form matched %d, string form %d", len(object), len(tree.FindAll("id:row")))
|
||||
}
|
||||
@@ -1154,7 +1262,7 @@ func TestObjectSelectorIDMatchesTheSameElementsAsTheStringForm(t *testing.T) {
|
||||
func TestObjectSelectorDescMatchesTheSameElementsAsTheStringForm(t *testing.T) {
|
||||
tree, _ := Parse(sampleDump)
|
||||
sel := Selector{Filters: []AttrFilter{{Attr: "desc", Value: "row"}}}
|
||||
if len(tree.Root.FindAllBySelector(sel)) != len(tree.FindAll("desc:row")) {
|
||||
if len(tree.FindAllBySelector(sel)) != len(tree.FindAll("desc:row")) {
|
||||
t.Fatal("object and string form disagree on desc")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ func findNodeFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value)
|
||||
return nil
|
||||
}
|
||||
requireKnownSelectorKeys(runtime, tree, sel)
|
||||
return tree.Root.FindBySelector(sel)
|
||||
return tree.FindBySelector(sel)
|
||||
}
|
||||
|
||||
// findAllNodesFromJS dispatches a JS value to Tree-level multi-node lookup.
|
||||
@@ -204,7 +204,7 @@ func findAllNodesFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Va
|
||||
return nil
|
||||
}
|
||||
requireKnownSelectorKeys(runtime, tree, sel)
|
||||
return tree.Root.FindAllBySelector(sel)
|
||||
return tree.FindAllBySelector(sel)
|
||||
}
|
||||
|
||||
// findNodeInSubtreeFromJS dispatches a JS value to Node-level scoped lookup.
|
||||
|
||||
@@ -213,10 +213,15 @@ function unknownSelectorKeyMessage(keys: string[]): string {
|
||||
);
|
||||
}
|
||||
|
||||
// A key naming no rule is a raw attribute name, and a raw attribute matches on a
|
||||
// substring, a boolean value exactly (docs/manual/spec-language.md), which is
|
||||
// what internal/hierarchy does with the same key. Matching exactly here made
|
||||
// `data-state:sent` name the badge on Android and nothing at all on web.
|
||||
function cssPart(key: string, value: string): string {
|
||||
const builder = KNOWN_KEY_TO_CSS[key];
|
||||
if (builder) return builder(value);
|
||||
return `[${key}="${cssEscape(value)}"]`;
|
||||
const operator = value === "true" || value === "false" ? "=" : "*=";
|
||||
return `[${key}${operator}"${cssEscape(value)}"]`;
|
||||
}
|
||||
|
||||
// A selector key that can never match yields an empty result, which reads
|
||||
@@ -248,13 +253,21 @@ function selectorFromObject(selector: Record<string, string | boolean | undefine
|
||||
throw new Error(unknownSelectorKeyMessage(unknown));
|
||||
}
|
||||
if (textValue !== undefined && parts.length === 0) {
|
||||
return {
|
||||
xpath: `//*[normalize-space(text())=${xpathStringLiteral(textValue)}]`,
|
||||
};
|
||||
return { xpath: innermostTextXPath(textValue) };
|
||||
}
|
||||
return { css: parts.join("") };
|
||||
}
|
||||
|
||||
// innermostTextXPath matches an element whose text contains value and whose
|
||||
// descendants do not. An element's XPath string value is its whole subtree's
|
||||
// text, so without the not() clause a badge's ancestors up to <html> answer for
|
||||
// it and find lands on the document. internal/hierarchy suppresses the same
|
||||
// matches, and internal/driver/chrome/translate.go builds the same predicate.
|
||||
function innermostTextXPath(value: string): string {
|
||||
const contains = `contains(normalize-space(.), ${xpathStringLiteral(value)})`;
|
||||
return `.//*[${contains} and not(.//*[${contains}])]`;
|
||||
}
|
||||
|
||||
// xpathStringLiteral wraps the value in a valid XPath 1.0 string literal.
|
||||
// XPath 1.0 has no escape syntax, so a value containing both ' and " must be
|
||||
// composed via concat().
|
||||
@@ -272,8 +285,14 @@ function selectorFromString(selector: string): { css?: string; xpath?: string }
|
||||
}
|
||||
const kind = selector.slice(0, colon);
|
||||
const value = selector.slice(colon + 1);
|
||||
// Substring of the element's whole text, the way internal/hierarchy reads the
|
||||
// same selector: an element reading "Sent ✓" answers to text:Sent on every
|
||||
// platform, and one React wrote as `{count} unsent` answers to text:unsent
|
||||
// though its text arrives as two text nodes, which normalize-space(text())
|
||||
// reads only the first of. Anchored at the context node, so a scoped .find
|
||||
// reads its own subtree rather than the page.
|
||||
if (kind === "text") {
|
||||
return { xpath: `//*[normalize-space(text())=${xpathStringLiteral(value)}]` };
|
||||
return { xpath: innermostTextXPath(value) };
|
||||
}
|
||||
// The string form's kind space stays open: "<attr>:<value>" is the documented
|
||||
// way to reach a raw driver attribute, and internal/hierarchy resolves an
|
||||
|
||||
@@ -383,27 +383,33 @@ test("xpathStringLiteral table: quote handling stays well-formed", () => {
|
||||
});
|
||||
|
||||
// selectorFromString routes a "kind:value" prefix; text becomes an XPath
|
||||
// equality, everything else a CSS attribute selector. A value containing a
|
||||
// substring test, everything else a CSS attribute selector. A value containing a
|
||||
// colon must not be re-split, and a quote in a text value must reach the
|
||||
// well-formed XPath literal rather than corrupting the predicate.
|
||||
const { selectorFromString, selectorFromObject } = __testing__;
|
||||
|
||||
test("selectorFromString routes text to a normalize-space XPath", () => {
|
||||
test("selectorFromString routes text to a substring XPath", () => {
|
||||
assert.deepEqual(selectorFromString("text:Hello"), {
|
||||
xpath: `//*[normalize-space(text())="Hello"]`,
|
||||
xpath:
|
||||
`.//*[contains(normalize-space(.), "Hello") ` +
|
||||
`and not(.//*[contains(normalize-space(.), "Hello")])]`,
|
||||
});
|
||||
});
|
||||
|
||||
test("selectorFromString keeps colons in the value intact", () => {
|
||||
// Only the first colon splits kind from value; the rest is the value.
|
||||
assert.deepEqual(selectorFromString("text:a:b:c"), {
|
||||
xpath: `//*[normalize-space(text())="a:b:c"]`,
|
||||
xpath:
|
||||
`.//*[contains(normalize-space(.), "a:b:c") ` +
|
||||
`and not(.//*[contains(normalize-space(.), "a:b:c")])]`,
|
||||
});
|
||||
});
|
||||
|
||||
test("selectorFromString text value with both quote kinds uses concat", () => {
|
||||
assert.deepEqual(selectorFromString(`text:say "hi" o'clock`), {
|
||||
xpath: `//*[normalize-space(text())=concat("say ", '"', "hi", '"', " o'clock")]`,
|
||||
xpath:
|
||||
`.//*[contains(normalize-space(.), concat("say ", '"', "hi", '"', " o'clock")) ` +
|
||||
`and not(.//*[contains(normalize-space(.), concat("say ", '"', "hi", '"', " o'clock"))])]`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -488,11 +494,15 @@ test("selectorFromObject rejects a key no element can carry", () => {
|
||||
});
|
||||
|
||||
// Raw attributes the key list does not enumerate stay reachable when the page
|
||||
// actually carries them.
|
||||
// actually carries them, matched on a substring the way internal/hierarchy
|
||||
// matches the same key.
|
||||
test("selectorFromObject accepts a raw attribute the page carries", () => {
|
||||
withDocumentCarrying(["data-foo"], () => {
|
||||
assert.deepEqual(selectorFromObject({ "data-foo": "bar" }), {
|
||||
css: `[data-foo="bar"]`,
|
||||
css: `[data-foo*="bar"]`,
|
||||
});
|
||||
assert.deepEqual(selectorFromObject({ "data-foo": "true" }), {
|
||||
css: `[data-foo="true"]`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -502,7 +512,7 @@ test("selectorFromObject accepts a raw attribute the page carries", () => {
|
||||
// resolves an unknown kind to an empty result rather than an error.
|
||||
test("selectorFromString accepts a kind the object form would reject", () => {
|
||||
assert.deepEqual(selectorFromString("descripton:Supplier"), {
|
||||
css: `[descripton="Supplier"]`,
|
||||
css: `[descripton*="Supplier"]`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -522,7 +532,9 @@ function withDocumentCarrying(attributes: string[], run: () => void): void {
|
||||
|
||||
test("selectorFromObject text-only selector becomes an XPath", () => {
|
||||
assert.deepEqual(selectorFromObject({ text: "Go" }), {
|
||||
xpath: `//*[normalize-space(text())="Go"]`,
|
||||
xpath:
|
||||
`.//*[contains(normalize-space(.), "Go") ` +
|
||||
`and not(.//*[contains(normalize-space(.), "Go")])]`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user