Files
sanderling/internal/driver/chrome/fact_parity_test.go
T

355 lines
11 KiB
Go

//go:build browser
package chrome
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"slices"
"testing"
"time"
"github.com/chromedp/chromedp"
"github.com/priyanshujain/sanderling/internal/bundler"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
// One page, two fact producers.
//
// Sanderling enumerates candidate actions from two independent hosts. The goja
// host reads the hierarchy dump this driver builds; the V8 host reads the live
// DOM through collectTargets in pkg/spec/src/web-runtime.ts. Both feed the SAME
// eligibility rule (pkg/spec/src/targets.ts), so the two enumerations agree only
// as far as the two producers agree on the facts.
//
// internal/verifier/host_parity_test.go and pkg/spec/test/host-parity.test.ts
// pin the other half: given identical facts, both hosts select identical
// candidates. They hand-author those facts on both sides, so neither producer is
// on their path, and three producer bugs lived in that gap: the dump emitted no
// `scrollable` attribute at all, it derived `clickable` from el.onclick (which
// React assigns to its root container, making the whole viewport a tap target
// here and nowhere else), and it rooted at document.body while the web runtime
// walks the whole document, hiding `html` and with it every page-level scroll.
//
// This test starts from one real DOM in a real browser and compares what the two
// producers derive from it, element by element. It found a fourth: the dump left
// `editable` unset rather than false, and an unset field sends the parser into
// the native EditText class-name heuristic, which reads a CSS class as an
// Android text widget.
// elementFacts is one element as a producer reports it: the tag, for readable
// failures, and every fact acceptsTarget consults.
type elementFacts struct {
tag string
clickable bool
enabled bool
editable bool
scrollable bool
positiveBounds bool
}
// factRow pairs an element's facts with the id both producers key on, kept in
// enumeration order because the order is part of the picker's parity contract.
type factRow struct {
id string
facts elementFacts
}
// parityPages are the pages both producers are compared on. Each one must
// exercise every fact both ways on its own (requireBothPolarities), so adding a
// page never weakens the comparison. fact-parity-shadow.html is shaped like a
// Compose for Web app: the whole UI lives inside a shadow root, which both
// producers have to descend into or they enumerate one node for an entire app.
var parityPages = []string{"fact-parity.html", "fact-parity-shadow.html"}
func TestHierarchy_DerivesTheSameFactsAsTheWebRuntime(t *testing.T) {
server := httptest.NewServer(http.FileServer(http.Dir("testdata")))
defer server.Close()
for _, page := range parityPages {
t.Run(page, func(t *testing.T) {
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+"/"+page, false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
dump, err := d.Hierarchy(ctx)
if err != nil {
t.Fatalf("Hierarchy: %v", err)
}
fromDump := factsFromHierarchyDump(t, dump)
fromWebRuntime := factsFromWebRuntime(ctx, t, d)
requireEveryElementNamed(t, "the hierarchy dump", fromDump)
requireEveryElementNamed(t, "the web runtime", fromWebRuntime)
requireBothPolarities(t, fromWebRuntime)
compareEnumeratedElements(t, fromDump, fromWebRuntime)
compareDerivedFacts(t, fromDump, fromWebRuntime)
})
}
}
// factsFromHierarchyDump reads the dump the way the goja host does: parse it,
// then take exactly the fields internal/verifier/worker.go targets() reads off
// each element.
func factsFromHierarchyDump(t *testing.T, dump string) []factRow {
t.Helper()
tree, err := hierarchy.Parse(dump)
if err != nil {
t.Fatalf("parse hierarchy: %v", err)
}
rows := make([]factRow, 0, len(tree.Elements))
for _, element := range tree.Elements {
rows = append(rows, factRow{
id: element.ResourceID,
facts: elementFacts{
tag: element.Attributes["tag"],
clickable: element.Clickable,
enabled: element.Enabled,
editable: element.Editable,
scrollable: element.Attributes["scrollable"] == "true",
positiveBounds: hasPositiveBounds(
element.Bounds.Width(),
element.Bounds.Height(),
),
},
})
}
return rows
}
// factsFromWebRuntime installs pkg/spec/test/dom-facts-probe.ts into the page
// and calls it. The probe is bundled through the production web bundler and
// reports what the production collectTargets derives, so the facts come from the
// shipped runtime rather than a copy of it; only the id-carrying readback is
// test-only.
func factsFromWebRuntime(
ctx context.Context,
t *testing.T,
d *Driver,
) []factRow {
t.Helper()
specSource := filepath.Join(repoRootDir(t), "pkg", "spec")
probe, err := bundler.BundleWeb(bundler.WebOptions{
EntryFile: filepath.Join(specSource, "test", "dom-facts-probe.ts"),
WebRuntimeFile: filepath.Join(specSource, "src", "web-runtime.ts"),
})
if err != nil {
t.Fatalf("bundle dom facts probe: %v", err)
}
if err := d.InstallBundle(ctx, probe.JavaScript); err != nil {
t.Fatalf("install dom facts probe: %v", err)
}
var encoded string
script := `JSON.stringify(window.__sanderlingDomFacts__())`
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &encoded)); err != nil {
t.Fatalf("read web runtime facts: %v", err)
}
var wire []struct {
ID string `json:"id"`
Tag string `json:"tag"`
Clickable bool `json:"clickable"`
Enabled bool `json:"enabled"`
Editable bool `json:"editable"`
Scrollable bool `json:"scrollable"`
Width int `json:"width"`
Height int `json:"height"`
}
if err := json.Unmarshal([]byte(encoded), &wire); err != nil {
t.Fatalf("decode web runtime facts: %v", err)
}
rows := make([]factRow, 0, len(wire))
for _, item := range wire {
rows = append(rows, factRow{
id: item.ID,
facts: elementFacts{
tag: item.Tag,
clickable: item.Clickable,
enabled: item.Enabled,
editable: item.Editable,
scrollable: item.Scrollable,
positiveBounds: hasPositiveBounds(item.Width, item.Height),
},
})
}
return rows
}
// hasPositiveBounds is the positiveBounds fact of pkg/spec/src/targets.ts,
// applied to both producers so the geometry comparison cannot drift from the
// rule it stands in for.
func hasPositiveBounds(width, height int) bool {
return width > 0 && height > 0
}
// requireEveryElementNamed fails when a producer enumerates an element the
// fixture gave no id, because such an element cannot be joined and would sit in
// the comparison invisibly.
func requireEveryElementNamed(t *testing.T, producer string, rows []factRow) {
t.Helper()
for index, row := range rows {
if row.id == "" {
t.Fatalf(
"%s enumerated an unnamed <%s> at position %d; every element in "+
"testdata/fact-parity.html must carry an id to be comparable",
producer,
row.facts.tag,
index,
)
}
}
}
// requireBothPolarities keeps the comparison from going vacuous. A fixture, or
// an emulated viewport, that leaves a fact constant would compare false against
// false on every element and pass while proving nothing about that fact.
func requireBothPolarities(t *testing.T, rows []factRow) {
t.Helper()
for _, fact := range []struct {
name string
read func(elementFacts) bool
}{
{"clickable", func(f elementFacts) bool { return f.clickable }},
{"enabled", func(f elementFacts) bool { return f.enabled }},
{"editable", func(f elementFacts) bool { return f.editable }},
{"scrollable", func(f elementFacts) bool { return f.scrollable }},
{"positiveBounds", func(f elementFacts) bool { return f.positiveBounds }},
} {
var sawTrue, sawFalse bool
for _, row := range rows {
if fact.read(row.facts) {
sawTrue = true
} else {
sawFalse = true
}
}
if !sawTrue || !sawFalse {
t.Errorf(
"testdata/fact-parity.html no longer exercises %s both ways (the web "+
"runtime saw true=%v, false=%v), so comparing it proves nothing",
fact.name,
sawTrue,
sawFalse,
)
}
}
}
// compareEnumeratedElements is the check that the two producers walk the same
// document. It is what notices a producer that roots at body and never sees
// `html`, or one that enumerates the head subtree the other drops.
func compareEnumeratedElements(
t *testing.T,
fromDump, fromWebRuntime []factRow,
) {
t.Helper()
dumpIDs := enumeratedIDs(fromDump)
webIDs := enumeratedIDs(fromWebRuntime)
for _, row := range fromWebRuntime {
if !slices.Contains(dumpIDs, row.id) {
t.Errorf(
"the hierarchy dump never enumerated %q (<%s>); the web runtime does, "+
"so the goja host cannot offer a single action on it",
row.id,
row.facts.tag,
)
}
}
for _, row := range fromDump {
if !slices.Contains(webIDs, row.id) {
t.Errorf(
"the web runtime never enumerated %q (<%s>); the hierarchy dump does, "+
"so the V8 host cannot offer a single action on it",
row.id,
row.facts.tag,
)
}
}
if len(dumpIDs) == len(webIDs) && !slices.Equal(dumpIDs, webIDs) {
t.Errorf(
"the two producers enumerate the same elements in different order\n"+
" dump=%v\n web=%v",
dumpIDs,
webIDs,
)
}
}
// compareDerivedFacts compares the facts fact by fact, over every element both
// producers saw, so a failure names the element and the fact that diverged.
func compareDerivedFacts(t *testing.T, fromDump, fromWebRuntime []factRow) {
t.Helper()
webByID := map[string]elementFacts{}
for _, row := range fromWebRuntime {
webByID[row.id] = row.facts
}
for _, row := range fromDump {
web, ok := webByID[row.id]
if !ok {
continue
}
dump := row.facts
if dump.tag != web.tag {
t.Errorf(
"%q: the hierarchy dump calls it <%s>, the web runtime <%s>; the id "+
"join is resolving to different elements",
row.id,
dump.tag,
web.tag,
)
}
for _, fact := range []struct {
name string
dump bool
web bool
}{
{"clickable", dump.clickable, web.clickable},
{"enabled", dump.enabled, web.enabled},
{"editable", dump.editable, web.editable},
{"scrollable", dump.scrollable, web.scrollable},
{"positiveBounds", dump.positiveBounds, web.positiveBounds},
} {
if fact.dump != fact.web {
t.Errorf(
"%q (<%s>): the hierarchy dump derives %s=%v, the web runtime "+
"derives %s=%v",
row.id,
dump.tag,
fact.name,
fact.dump,
fact.name,
fact.web,
)
}
}
}
}
func enumeratedIDs(rows []factRow) []string {
ids := make([]string, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.id)
}
return ids
}
func repoRootDir(t *testing.T) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot resolve caller path")
}
return filepath.Clean(
filepath.Join(filepath.Dir(thisFile), "..", "..", ".."),
)
}