mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
204 lines
6.1 KiB
Go
204 lines
6.1 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"},
|
|
},
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func objectSelector(key, value string) hierarchy.Selector {
|
|
return hierarchy.Selector{Filters: []hierarchy.AttrFilter{{Attr: key, Value: value}}}
|
|
}
|
|
|
|
func objectSelectorJSON(sel hierarchy.Selector) string {
|
|
fields := map[string]string{}
|
|
for _, filter := range sel.Filters {
|
|
fields[filter.Attr] = filter.Value
|
|
}
|
|
encoded, err := json.Marshal(fields)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(encoded)
|
|
}
|
|
|
|
func selectorIDsFromDumpObject(tree *hierarchy.Tree, sel hierarchy.Selector) []string {
|
|
var ids []string
|
|
for _, node := range tree.Root.FindAllBySelector(sel) {
|
|
ids = append(ids, node.Element.ResourceID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// jsArgument passes an object selector through as the JS object literal it
|
|
// already is, and quotes anything else as a string selector.
|
|
func jsArgument(selector string) string {
|
|
if 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
|
|
}
|