From ca667b1b77e189bb52039b59b6b387b7986aac85 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 16 Aug 2026 17:43:10 +0530 Subject: [PATCH] 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. --- .../driver/chrome/selector_parity_test.go | 208 +++++++++++++++++- .../chrome/testdata/selector-parity.html | 5 + internal/driver/chrome/translate.go | 26 ++- internal/driver/chrome/translate_test.go | 41 +++- internal/hierarchy/hierarchy.go | 120 ++++++++-- internal/hierarchy/hierarchy_test.go | 112 +++++++++- internal/verifier/marshal.go | 4 +- pkg/spec/src/web-runtime.ts | 29 ++- pkg/spec/test/web-runtime.test.ts | 30 ++- 9 files changed, 526 insertions(+), 49 deletions(-) diff --git a/internal/driver/chrome/selector_parity_test.go b/internal/driver/chrome/selector_parity_test.go index 23272e2..3a32535 100644 --- a/internal/driver/chrome/selector_parity_test.go +++ b/internal/driver/chrome/selector_parity_test.go @@ -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) diff --git a/internal/driver/chrome/testdata/selector-parity.html b/internal/driver/chrome/testdata/selector-parity.html index cd40755..63fa30a 100644 --- a/internal/driver/chrome/testdata/selector-parity.html +++ b/internal/driver/chrome/testdata/selector-parity.html @@ -11,5 +11,10 @@
Bob
Carol
+
Sent ✓
+
Sent ✓
+
3 unsent
+
Sent here
+
Sent Sent
diff --git a/internal/driver/chrome/translate.go b/internal/driver/chrome/translate.go index 75ff571..b8e2684 100644 --- a/internal/driver/chrome/translate.go +++ b/internal/driver/chrome/translate.go @@ -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 , 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 diff --git a/internal/driver/chrome/translate_test.go b/internal/driver/chrome/translate_test.go index 695ba00..a781db2 100644 --- a/internal/driver/chrome/translate_test.go +++ b/internal/driver/chrome/translate_test.go @@ -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, + ) } } diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index a6a4e7e..d65d751 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -7,7 +7,7 @@ // attribute:value - substring match; exact for "true"/"false" booleans // id: - substring on resource-id / identifier (backward compat) // idPrefix: - starts-with on resource-id / identifier, package prefix skipped -// text: - substring on text attribute +// text: - substring on text attribute, innermost match only // desc: - substring on content-desc / accessibilityText // descPrefix: - 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 } diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index dfd8191..34ebfe5 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -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") } } diff --git a/internal/verifier/marshal.go b/internal/verifier/marshal.go index 1b23546..f2ba923 100644 --- a/internal/verifier/marshal.go +++ b/internal/verifier/marshal.go @@ -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. diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 0d2d2bb..0c8636a 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -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 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: ":" is the documented // way to reach a raw driver attribute, and internal/hierarchy resolves an diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index 15386a6..c064516 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -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")])]`, }); });