//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"}, }, { // A custom element's tag name holds a real tag name inside it, so a // substring rule answered tag:li with and tag:a with // : 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"}, }, { // 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 five 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", "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", }, }, } 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) } } 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 }