mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
The page gains four fields that differ one rung at a time: a bound label, a placeholder, a placeholder an aria-label outranks, and the name the form gives the field. Only the placeholder rung was reachable before, so hintText and placeholderValue named a field on the goja host and no element at all on web for the other three, and named the field here and nothing there for the rung the ladder passed over. placeholder was measured empty on both hosts because nothing on the page carried the attribute, which said nothing about it. It now names the field the markup wrote it on and not the field whose hint is its aria-label. The third resolver reads the same selectors: what TranslateStringSelector builds for a hint key has to match nothing over CDP rather than the field carrying the value as a placeholder.
1190 lines
39 KiB
Go
1190 lines
39 KiB
Go
//go:build browser
|
|
|
|
package chrome
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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) {
|
|
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)
|
|
|
|
// 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 sidecar 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) {
|
|
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)
|
|
|
|
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) {
|
|
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)
|
|
|
|
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) {
|
|
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)
|
|
|
|
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) {
|
|
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,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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)
|
|
|
|
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) {
|
|
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)
|
|
}
|
|
|
|
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) {
|
|
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)
|
|
|
|
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
|
|
}
|