Files
sanderling/internal/driver/chrome/selector_parity_test.go
pj 9a25a444e1 docs: the last two tests that credit the sidecar for an iOS fact
Same correction as the alias table they cover: accessibilityText comes from the
companion.
2026-08-22 21:48:47 +05:30

1115 lines
37 KiB
Go

//go:build browser
package chrome
import (
"context"
"encoding/json"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/chromedp/chromedp"
"github.com/priyanshujain/sanderling/internal/bundler"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
// One page, two selector matchers.
//
// A selector means whatever the runtime resolving it decides. The goja host
// resolves it against the hierarchy dump through internal/hierarchy; the V8 host
// resolves it against the live DOM through pkg/spec/src/web-runtime.ts. Nothing
// makes the two agree, so a spec that targets a list of rows can act on them on
// one platform and find nothing on the other, and finding nothing is silent: the
// generator yields no action and the run still passes.
//
// This test starts from one real page in a real browser and asks both matchers
// the same questions.
func TestSelectors_ResolveTheSameElementsAsTheWebRuntime(t *testing.T) {
d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html")
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)
// want is stated here rather than derived, so a matcher that stops matching
// on both sides at once cannot pass this by agreeing on nothing.
cases := []struct {
name string
selector string
object hierarchy.Selector
want []string
}{
{
name: "idPrefix",
selector: "idPrefix:customer_row_",
object: objectSelector("idPrefix", "customer_row_"),
want: []string{"customer_row_a1", "customer_row_b2"},
},
{
name: "idPrefix spanning the container",
selector: "idPrefix:customer_",
object: objectSelector("idPrefix", "customer_"),
want: []string{"customer_list", "customer_row_a1", "customer_row_b2"},
},
{
name: "idPrefix matching nothing",
selector: "idPrefix:invoice_row_",
object: objectSelector("idPrefix", "invoice_row_"),
want: nil,
},
{
name: "id",
selector: "id:summary_card",
object: objectSelector("id", "summary_card"),
want: []string{"summary_card"},
},
{
name: "descPrefix",
selector: "descPrefix:customer_row_",
object: objectSelector("descPrefix", "customer_row_"),
want: []string{"customer_row_a1", "customer_row_b2"},
},
{
name: "desc",
selector: "desc:customer_row_a1",
object: objectSelector("desc", "customer_row_a1"),
want: []string{"customer_row_a1"},
},
{
name: "data-testid",
selector: "data-testid:customer-row",
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"},
},
{
name: "class",
selector: "class:status",
object: objectSelector("class", "status"),
want: []string{"nested_row", "nested_badge"},
},
{
// className is the DOM property name for the same attribute, and
// the two names have to name the same elements: the web runtime
// compiles both to the same class query, while the dump carries
// the fact under `class` alone and no alias reached it, so this
// key matched the row and the badge on one host and NOTHING on the
// other. The key is accepted, so no unknown-key error fires, and a
// property over the missing element passes having checked nothing.
name: "className",
selector: "className:status",
object: objectSelector("className", "status"),
want: []string{"nested_row", "nested_badge"},
},
{
// Every name for the accessible label has to name one element. Four
// of the six reached it on one host only: label and
// accessibilityLabel aliased onto accessibilityText alone, which
// the ios companion writes and this dump does not, and alias
// expansion is one level; ariaLabel and contentDescription aliased
// onto nothing. accessibilityText was the mirror image, resolving
// against the dump and reaching no DOM attribute of that name.
name: "ariaLabel",
selector: "ariaLabel:login_email",
object: objectSelector("ariaLabel", "login_email"),
want: []string{"login_email"},
},
{
name: "contentDescription",
selector: "contentDescription:login_email",
object: objectSelector("contentDescription", "login_email"),
want: []string{"login_email"},
},
{
name: "label",
selector: "label:login_email",
object: objectSelector("label", "login_email"),
want: []string{"login_email"},
},
{
name: "accessibilityLabel",
selector: "accessibilityLabel:login_email",
object: objectSelector("accessibilityLabel", "login_email"),
want: []string{"login_email"},
},
{
name: "accessibilityText",
selector: "accessibilityText:login_email",
object: objectSelector("accessibilityText", "login_email"),
want: []string{"login_email"},
},
{
// The two ios names for the identifier, which resolved against the
// dump through an alias and against no DOM attribute at all.
name: "identifier",
selector: "identifier:summary_card",
object: objectSelector("identifier", "summary_card"),
want: []string{"summary_card"},
},
{
name: "accessibilityIdentifier",
selector: "accessibilityIdentifier:summary_card",
object: objectSelector("accessibilityIdentifier", "summary_card"),
want: []string{"summary_card"},
},
{
// The ios name for the class, the same way round.
name: "elementType",
selector: "elementType:status",
object: objectSelector("elementType", "status"),
want: []string{"nested_row", "nested_badge"},
},
{
// Compose for Web writes a test tag as data-testid. testTag reached
// the three identifier keys and not that one, and testID reached
// nothing at all, so both named every row of the list on web and no
// element here.
name: "testID",
selector: "testID:customer-row",
object: objectSelector("testID", "customer-row"),
want: []string{"customer_row_a1", "customer_row_b2"},
},
{
name: "testTag",
selector: "testTag:customer-row",
object: objectSelector("testTag", "customer-row"),
want: []string{"customer_row_a1", "customer_row_b2"},
},
{
// A custom element's tag name holds a real tag name inside it, so a
// substring rule answered tag:li with <todo-list> and tag:a with
// <todo-app>: the container, not the row the author named.
name: "tag against a page of custom elements",
selector: "tag:li",
object: objectSelector("tag", "li"),
want: []string{"todo_1", "todo_2"},
},
{
name: "tag naming the custom element itself",
selector: "tag:todo-list",
object: objectSelector("tag", "todo-list"),
want: []string{"todo_list"},
},
{
name: "tag naming an anchor",
selector: "tag:a",
object: objectSelector("tag", "a"),
want: []string{"todo_link"},
},
{
// secure names no attribute the markup writes: both producers derive
// it from the field's type. Matched as a raw attribute it reached
// nothing here while resolving fine against the dump, and `secure`
// being an accepted key meant no unknown-key error said so.
name: "secure",
selector: "secure:true",
object: objectSelector("secure", "true"),
want: []string{"login_password"},
},
{
// false is every editable field that is not a password entry, not
// everything that is not one: a control that is no field at all
// reports null, the way android reports null for everything, and
// answers to neither value.
name: "not secure",
selector: "secure:false",
object: objectSelector("secure", "false"),
want: []string{
"login_email", "login_note", "login_terms",
"hint_phone", "hint_search", "hint_amount", "hint_code",
},
},
{
// hintText is the accessible-name ladder both producers derive:
// the field's aria-label, the <label> bound to it, its placeholder,
// then its name. The dump writes what fieldHint (driver.go) answers
// and the web runtime compiled the key to [placeholder="..."], so
// every field whose hint comes from a rung above the placeholder
// resolved against the dump on the goja host and named nothing at
// all here. The key is accepted, so no unknown-key error fires.
name: "hintText from the fields aria-label",
selector: "hintText:login_email",
object: objectSelector("hintText", "login_email"),
want: []string{"login_email"},
},
{
name: "hintText from the label bound to the field",
selector: "hintText:Phone number",
object: objectSelector("hintText", "Phone number"),
want: []string{"hint_phone"},
},
{
name: "hintText from the placeholder",
selector: "hintText:Search customers",
object: objectSelector("hintText", "Search customers"),
want: []string{"hint_search"},
},
{
name: "hintText from the name the form gives the field",
selector: "hintText:verification_code",
object: objectSelector("hintText", "verification_code"),
want: []string{"hint_code"},
},
{
name: "hintText where the aria-label outranks the placeholder",
selector: "hintText:Amount in rupees",
object: objectSelector("hintText", "Amount in rupees"),
want: []string{"hint_amount"},
},
{
// The rung the ladder passed over is not the field's hint, so
// naming it names nothing. Compiling the key to the placeholder
// attribute answered with the field here and with no element on the
// goja host: matching MORE than the spec said is the half of this
// that lands a find on an element nobody wrote.
name: "hintText naming the placeholder the aria-label outranks",
selector: "hintText:0.00",
object: objectSelector("hintText", "0.00"),
want: nil,
},
{
// The ios name for the same ladder, which internal/hierarchy
// aliases onto hintText.
name: "placeholderValue",
selector: "placeholderValue:login_email",
object: objectSelector("placeholderValue", "login_email"),
want: []string{"login_email"},
},
{
name: "placeholderValue naming the placeholder the aria-label outranks",
selector: "placeholderValue:0.00",
object: objectSelector("placeholderValue", "0.00"),
want: nil,
},
{
// placeholder is the attribute the markup writes, on both hosts:
// the dump carries every attribute under the name it was written
// with, so the key reads the same string on either side of the
// wire and says nothing about the ladder above it.
name: "placeholder",
selector: "placeholder:0.00",
object: objectSelector("placeholder", "0.00"),
want: []string{"hint_amount"},
},
{
name: "placeholder on a field labelled by aria-label alone",
selector: "placeholder:login_email",
object: objectSelector("placeholder", "login_email"),
want: nil,
},
{
// The head subtree renders nothing, so the hierarchy dump drops it
// (buildTree in driver.go) and so does the enumeration the picker
// walks (targetElements in web-runtime.ts). A selector resolving
// into it names an element the goja host cannot see at all.
name: "tag naming the head element",
selector: "tag:head",
object: objectSelector("tag", "head"),
want: nil,
},
{
name: "tag naming an element inside the head",
selector: "tag:title",
object: objectSelector("tag", "title"),
want: nil,
},
{
// 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) {
native := selectorIDsFromDump(tree, testCase.selector)
web := selectorIDsFromWebRuntime(ctx, t, d, testCase.selector)
if !slices.Equal(native, testCase.want) {
t.Errorf("hierarchy matched %v, want %v", native, testCase.want)
}
if !slices.Equal(web, testCase.want) {
t.Errorf("the web runtime matched %v, want %v", web, testCase.want)
}
// The object form has to mean the same thing as the string form on
// both sides. It is the form the typed API pushes authors toward and
// the one that used to fall through to a raw attribute lookup.
nativeObject := selectorIDsFromDumpObject(tree, testCase.object)
webObject := selectorIDsFromWebRuntime(ctx, t, d, objectSelectorJSON(testCase.object))
if !slices.Equal(nativeObject, testCase.want) {
t.Errorf("hierarchy object form matched %v, want %v", nativeObject, testCase.want)
}
if !slices.Equal(webObject, testCase.want) {
t.Errorf("the web runtime object form matched %v, want %v", webObject, testCase.want)
}
})
}
}
// A multi-key object selector concatenates its parts into ONE compound CSS
// selector, and a type selector is valid only at the head of a compound. secure
// resolves to a type selector, so pairing it with any key that sorts before it
// turned the whole selector into a parse error: querySelectorAll throws, and
// what a spec sees is an exception out of the extractor rather than an element.
func TestSelectors_SecureCombinesWithAnotherKey(t *testing.T) {
d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html")
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)
selector := hierarchy.Selector{Filters: []hierarchy.AttrFilter{
{Attr: "id", Value: "login_password"},
{Attr: "secure", Value: "true"},
}}
want := []string{"login_password"}
if native := selectorIDsFromDumpObject(tree, selector); !slices.Equal(native, want) {
t.Errorf("hierarchy matched %v, want %v", native, want)
}
encoded := objectSelectorJSON(selector)
if web := selectorIDsFromWebRuntime(ctx, t, d, encoded); !slices.Equal(web, want) {
t.Errorf("the web runtime matched %v for %s, want %v", web, encoded, want)
}
}
// The other seven boolean states are derived from the live element the same way
// secure is, and were reached the same wrong way: as a markup attribute, which
// builds [clickable="true"] and matches nothing on any page. The key is
// accepted, so no unknown-key error fires, and the worked example in
// docs/manual/spec-language.md, find({testTag: ..., clickable: true}), resolved
// to no element at all on web while resolving against the dump on the goja host.
//
// Half the states are asked inside one container, because a state the whole page
// has an opinion about (everything is enabled, almost nothing is checked)
// answers with most of the document and a want list nobody can check by reading.
func TestSelectors_BooleanStatesNameWhatBothProducersReport(t *testing.T) {
d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html")
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)
cases := []struct {
name string
selector string
scope string
want []string
}{
{
name: "clickable",
selector: "clickable:true",
want: []string{
"login_email", "login_password", "login_note", "login_remember",
"hint_phone", "hint_search", "hint_amount", "hint_code",
"state_save", "state_cancel", "state_submit", "state_remember",
"state_agree", "state_month", "todo_link",
},
},
{
name: "not clickable",
selector: "clickable:false",
scope: "state_row",
want: []string{"state_january", "state_february"},
},
{
// A control that carries no disabled property is marked by
// aria-disabled alone, and both producers read both.
name: "not enabled",
selector: "enabled:false",
want: []string{"state_cancel", "state_submit"},
},
{
name: "enabled",
selector: "enabled:true",
scope: "state_row",
want: []string{
"state_save", "state_remember", "state_agree", "state_month",
"state_january", "state_february",
},
},
{
name: "focused",
selector: "focused:true",
want: []string{"state_save"},
},
{
name: "not focused",
selector: "focused:false",
scope: "state_row",
want: []string{
"state_cancel", "state_submit", "state_remember", "state_agree",
"state_month", "state_january", "state_february",
},
},
{
// state_remember was ticked by script and carries no checked
// attribute; state_agree carries the attribute and was cleared. The
// markup attribute is the page's starting state, so a selector
// reading it names the pair the wrong way round.
name: "checked",
selector: "checked:true",
want: []string{"state_remember"},
},
{
name: "not checked",
selector: "checked:false",
scope: "state_row",
want: []string{
"state_save", "state_cancel", "state_submit", "state_agree",
"state_month", "state_january", "state_february",
},
},
{
// The first option of a select is selected without the markup
// saying so anywhere.
name: "selected",
selector: "selected:true",
want: []string{"state_january"},
},
{
name: "not selected",
selector: "selected:false",
scope: "state_row",
want: []string{
"state_save", "state_cancel", "state_submit", "state_remember",
"state_agree", "state_month", "state_february",
},
},
{
// editable and scrollable are derived the same way and were reached
// the same wrong way, as markup attributes nothing carries.
name: "editable",
selector: "editable:true",
want: []string{
"login_email", "login_password", "login_note", "login_terms",
"hint_phone", "hint_search", "hint_amount", "hint_code",
},
},
{
name: "not editable",
selector: "editable:false",
scope: "login_form",
want: []string{"login_remember"},
},
{
name: "scrollable",
selector: "scrollable:true",
scope: "scroll_row",
want: []string{"scroll_box"},
},
{
// Both producers state scrollable only where it holds, so the
// container that does not scroll answers to neither value, the way
// an element that is no field at all answers to neither value of
// secure.
name: "not scrollable",
selector: "scrollable:false",
scope: "scroll_row",
want: nil,
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
key, value, _ := strings.Cut(testCase.selector, ":")
object := objectSelector(key, value)
encoded := objectSelectorJSON(object)
if testCase.scope != "" {
scope := objectSelector("id", testCase.scope)
path := []hierarchy.Selector{scope, object}
encoded = "[" + objectSelectorJSON(scope) + "," + encoded + "]"
if native := selectorIDsFromDumpPath(tree, path); !slices.Equal(
native,
testCase.want,
) {
t.Errorf("the dump matched %v under %s, want %v",
native, testCase.scope, testCase.want)
}
} else {
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)
}
if native := selectorIDsFromDumpObject(tree, object); !slices.Equal(
native,
testCase.want,
) {
t.Errorf("the dump object form matched %v, want %v",
native, testCase.want)
}
}
if web := selectorIDsFromWebRuntime(ctx, t, d, encoded); !slices.Equal(
web,
testCase.want,
) {
t.Errorf("the web runtime matched %v for %s, want %v",
web, encoded, testCase.want)
}
})
}
}
// A type selector is valid only at the head of a compound, and a multi-key
// object selector concatenates its parts into one, so {id, tag} built
// '[id="state_month"]select' and querySelectorAll threw a SyntaxError. Which of
// the two a spec got depended on the order its author wrote the keys in.
func TestSelectors_TagCombinesWithAnotherKey(t *testing.T) {
d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html")
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)
selector := hierarchy.Selector{Filters: []hierarchy.AttrFilter{
{Attr: "id", Value: "state_month"},
{Attr: "tag", Value: "select"},
}}
want := []string{"state_month"}
if native := selectorIDsFromDumpObject(tree, selector); !slices.Equal(native, want) {
t.Errorf("hierarchy matched %v, want %v", native, want)
}
encoded := objectSelectorJSON(selector)
if web := selectorIDsFromWebRuntime(ctx, t, d, encoded); !slices.Equal(web, want) {
t.Errorf("the web runtime matched %v for %s, want %v", web, encoded, want)
}
}
// `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) {
d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html")
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,
)
}
})
}
}
// text written beside another key was dropped, so the web runtime matched on
// the other key alone: {data-testid, text} selected every row carrying the tag
// where internal/hierarchy selected the one row the author named. Matching MORE
// than the spec said is silent, which is the failure this file exists to catch:
// a find lands on a row nobody wrote and every property over it still passes.
//
// The innermost rule stays where internal/hierarchy holds it, over what the
// WHOLE selector matched. nested_row keeps its match because the badge under it
// carries a different id, and state_month keeps one because the option carrying
// "January" is not clickable: resolving text to its own innermost match before
// the other keys narrow anything answers with nothing in both.
func TestSelectors_TextCombinesWithAnotherKey(t *testing.T) {
d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html")
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)
cases := []struct {
name string
filters []hierarchy.AttrFilter
want []string
}{
{
name: "text beside the tag every row of the list carries",
filters: []hierarchy.AttrFilter{
{Attr: "data-testid", Value: "customer-row"},
{Attr: "text", Value: "Alice"},
},
want: []string{"customer_row_a1"},
},
{
// Object keys iterate in the order the author wrote them, and an
// earlier fix had to keep a rule valid wherever in the compound it
// lands.
name: "text before the tag every row of the list carries",
filters: []hierarchy.AttrFilter{
{Attr: "text", Value: "Alice"},
{Attr: "data-testid", Value: "customer-row"},
},
want: []string{"customer_row_a1"},
},
{
name: "text beside the id of the row carrying it",
filters: []hierarchy.AttrFilter{
{Attr: "id", Value: "customer_row_a1"},
{Attr: "text", Value: "Alice"},
},
want: []string{"customer_row_a1"},
},
{
// The row named by the id carries different text, so the selector
// names nothing at all. Dropping text answered with the row.
name: "text beside the id of a row carrying something else",
filters: []hierarchy.AttrFilter{
{Attr: "id", Value: "customer_row_a1"},
{Attr: "text", Value: "Bob"},
},
want: nil,
},
{
name: "text before the id of a row carrying something else",
filters: []hierarchy.AttrFilter{
{Attr: "text", Value: "Bob"},
{Attr: "id", Value: "customer_row_a1"},
},
want: nil,
},
{
name: "text beside the id of the row a badge repeats it in",
filters: []hierarchy.AttrFilter{
{Attr: "id", Value: "nested_row"},
{Attr: "text", Value: "Sent"},
},
want: []string{"nested_row"},
},
{
// Both carry the class and both carry the text, so the row is a
// match the badge under it already made.
name: "text beside a class the row and the badge under it share",
filters: []hierarchy.AttrFilter{
{Attr: "class", Value: "status"},
{Attr: "text", Value: "Sent"},
},
want: []string{"nested_badge"},
},
{
name: "text beside a state no query can express",
filters: []hierarchy.AttrFilter{
{Attr: "text", Value: "January"},
{Attr: "clickable", Value: "true"},
},
want: []string{"state_month"},
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
selector := hierarchy.Selector{Filters: testCase.filters}
if native := selectorIDsFromDumpObject(tree, selector); !slices.Equal(
native,
testCase.want,
) {
t.Errorf("the dump matched %v, want %v", native, testCase.want)
}
encoded := objectSelectorJSON(selector)
if web := selectorIDsFromWebRuntime(ctx, t, d, encoded); !slices.Equal(
web,
testCase.want,
) {
t.Errorf("the web runtime matched %v for %s, want %v",
web, encoded, testCase.want)
}
})
}
}
// 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
// the two read differently taps one element and reads the text of another.
func TestSelectors_DataTestIDNamesTheSameElementInTheDumpAndOverCDP(t *testing.T) {
d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html")
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)
}
const selector = "data-testid:summary"
element := tree.Find(selector)
if element == nil {
t.Fatalf("the dump resolves %s to nothing, so every step that names a target this way loses it", selector)
}
css, isXPath, err := TranslateStringSelector(selector)
if err != nil {
t.Fatalf("TranslateStringSelector(%q): %v", selector, err)
}
if isXPath {
t.Fatalf("TranslateStringSelector(%q) returned an XPath, want CSS", selector)
}
var overCDP string
script := `(document.querySelector(` + jsArgument(css) + `) || {}).id || ""`
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &overCDP)); err != nil {
t.Fatalf("resolve %q over CDP: %v", css, err)
}
if overCDP == "" {
t.Fatalf("the CDP selector %q matched nothing", css)
}
if element.ResourceID != overCDP {
t.Errorf("the dump resolves %s to %q, the CDP selector %q to %q",
selector, element.ResourceID, css, overCDP)
}
}
// The same third resolver reads a hint selector, and no CSS says what the
// accessible-name ladder says. Building [placeholder="..."] for hintText made
// this resolver name a field whose hint is its aria-label and whose placeholder
// happens to carry the value, which is an element neither matcher names.
// TapSelector runs only where the dump resolved the target to no coordinates at
// all, so a CSS that matches there taps something nobody selected; matching
// nothing fails the step by name instead, the way every other derived key here
// already does. placeholder keeps its CSS: it is the attribute the markup
// writes, which is what all three resolvers read it as.
func TestSelectors_HintSelectorsResolveOverCDPToWhatBothMatchersName(t *testing.T) {
d, ctx := launchChrome(t, testdataServer(t).URL+"/selector-parity.html")
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)
cases := []struct {
selector string
want []string
}{
{selector: "hintText:0.00", want: nil},
{selector: "placeholderValue:0.00", want: nil},
{selector: "hintText:Amount in rupees", want: nil},
{selector: "placeholder:0.00", want: []string{"hint_amount"}},
}
for _, testCase := range cases {
t.Run(testCase.selector, func(t *testing.T) {
css, isXPath, err := TranslateStringSelector(testCase.selector)
if err != nil {
t.Fatalf("TranslateStringSelector(%q): %v", testCase.selector, err)
}
if isXPath {
t.Fatalf("TranslateStringSelector(%q) returned an XPath, want CSS", testCase.selector)
}
if overCDP := cssIDsOverCDP(ctx, t, d, css); !slices.Equal(overCDP, testCase.want) {
t.Errorf("the CDP selector %q matched %v, want %v", css, overCDP, testCase.want)
}
})
}
// hintText:Amount in rupees names the field on both matchers and on neither
// resolver CDP can express, so the step fails by name rather than tapping
// the field whose placeholder reads 0.00.
const named = "hintText:Amount in rupees"
if ids := selectorIDsFromDump(tree, named); !slices.Equal(ids, []string{"hint_amount"}) {
t.Errorf("the dump matched %v for %s, want [hint_amount]", ids, named)
}
}
// cssIDsOverCDP resolves a CSS selector the way TapSelector does, over CDP
// against the live document, and reads back the ids it matched.
func cssIDsOverCDP(ctx context.Context, t *testing.T, d *Driver, css string) []string {
t.Helper()
var ids []string
script := `Array.from(document.querySelectorAll(` + jsArgument(css) + `)).map((e) => e.id)`
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &ids)); err != nil {
t.Fatalf("resolve %q over CDP: %v", css, err)
}
return ids
}
func objectSelector(key, value string) hierarchy.Selector {
return hierarchy.Selector{Filters: []hierarchy.AttrFilter{{Attr: key, Value: value}}}
}
// objectSelectorJSON encodes the selector as the object a spec writes, keys in
// the order the filters state them. A JS object iterates in insertion order, and
// where in the compound a key lands is what decided whether the selector parsed
// at all, so a map here would only ever test the alphabetical order.
func objectSelectorJSON(sel hierarchy.Selector) string {
parts := make([]string, 0, len(sel.Filters))
for _, filter := range sel.Filters {
key, err := json.Marshal(filter.Attr)
if err != nil {
panic(err)
}
value, err := json.Marshal(filter.Value)
if err != nil {
panic(err)
}
parts = append(parts, string(key)+":"+string(value))
}
return "{" + strings.Join(parts, ",") + "}"
}
func selectorIDsFromDumpPath(tree *hierarchy.Tree, path []hierarchy.Selector) []string {
var ids []string
for _, node := range tree.FindAllBySelectorPath(path) {
ids = append(ids, node.Element.ResourceID)
}
return ids
}
func selectorIDsFromDumpObject(tree *hierarchy.Tree, sel hierarchy.Selector) []string {
var ids []string
for _, node := range tree.FindAllBySelector(sel) {
ids = append(ids, node.Element.ResourceID)
}
return ids
}
// 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 json.Valid([]byte(selector)) &&
(strings.HasPrefix(selector, "{") || strings.HasPrefix(selector, "[")) {
return selector
}
quoted, err := json.Marshal(selector)
if err != nil {
return `""`
}
return string(quoted)
}
func selectorIDsFromDump(tree *hierarchy.Tree, selector string) []string {
var ids []string
for _, element := range tree.FindAll(selector) {
ids = append(ids, element.ResourceID)
}
return ids
}
func installSelectorProbe(ctx context.Context, t *testing.T, d *Driver) {
t.Helper()
specSource := filepath.Join(repoRootDir(t), "pkg", "spec")
probe, err := bundler.BundleWeb(bundler.WebOptions{
EntryFile: filepath.Join(specSource, "test", "dom-selector-probe.ts"),
WebRuntimeFile: filepath.Join(specSource, "src", "web-runtime.ts"),
})
if err != nil {
t.Fatalf("bundle dom selector probe: %v", err)
}
if err := d.InstallBundle(ctx, probe.JavaScript); err != nil {
t.Fatalf("install dom selector probe: %v", err)
}
}
func selectorIDsFromWebRuntime(
ctx context.Context,
t *testing.T,
d *Driver,
selector string,
) []string {
t.Helper()
var encoded string
script := `JSON.stringify(window.__sanderlingSelectorMatches__(` + jsArgument(selector) + `))`
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &encoded)); err != nil {
t.Fatalf("read web runtime matches for %q: %v", selector, err)
}
var ids []string
if err := json.Unmarshal([]byte(encoded), &ids); err != nil {
t.Fatalf("decode web runtime matches: %v", err)
}
return ids
}