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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user