diff --git a/internal/driver/chrome/selector_parity_test.go b/internal/driver/chrome/selector_parity_test.go new file mode 100644 index 0000000..6d4f1a9 --- /dev/null +++ b/internal/driver/chrome/selector_parity_test.go @@ -0,0 +1,124 @@ +//go:build browser + +package chrome + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "slices" + "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 { + selector string + want []string + }{ + {"idPrefix:customer_row_", []string{"customer_row_a1", "customer_row_b2"}}, + {"idPrefix:customer_", []string{"customer_list", "customer_row_a1", "customer_row_b2"}}, + {"idPrefix:invoice_row_", nil}, + {"id:summary_card", []string{"summary_card"}}, + {"descPrefix:customer_row_", []string{"customer_row_a1", "customer_row_b2"}}, + } + for _, testCase := range cases { + t.Run(testCase.selector, 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) + } + }) + } +} + +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() + request, err := json.Marshal(selector) + if err != nil { + t.Fatalf("encode selector: %v", err) + } + var encoded string + script := `JSON.stringify(window.__sanderlingSelectorMatches__(` + string(request) + `))` + 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 +} diff --git a/internal/driver/chrome/testdata/selector-parity.html b/internal/driver/chrome/testdata/selector-parity.html new file mode 100644 index 0000000..2892f46 --- /dev/null +++ b/internal/driver/chrome/testdata/selector-parity.html @@ -0,0 +1,15 @@ + + + + + selector parity + + +
summary
+
+
Alice
+
Bob
+
Carol
+
+ + diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 4c38e23..0072926 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -589,6 +589,7 @@ installRuntime( // so these are stripped from production output; they only exist for unit tests. export const __testing__ = { host, + buildAx, seedBigInt, collectTargets, targetElements, diff --git a/pkg/spec/test/dom-selector-probe.ts b/pkg/spec/test/dom-selector-probe.ts new file mode 100644 index 0000000..8d930f6 --- /dev/null +++ b/pkg/spec/test/dom-selector-probe.ts @@ -0,0 +1,33 @@ +/// + +// The V8 host's selector matcher, read back match by match. +// +// internal/driver/chrome/selector_parity_test.go bundles this probe into a live +// page and compares what it returns against what internal/hierarchy resolves +// from the dump of the SAME page. Selector matching is written once per runtime, +// and only a comparison over one page can say whether the two mean the same +// thing by one selector. buildAx is the shipped `state.ax`, so the answer comes +// from production code rather than a copy of it. + +import { __testing__ } from "../src/web-runtime.ts"; + +const { buildAx } = __testing__; + +interface Handle { + id?: string; +} + +interface Ax { + findAll(selector: unknown): Handle[]; +} + +function selectorMatches(selector: string): string[] { + const ax = buildAx() as Ax; + return ax.findAll(selector).map((element) => element.id ?? ""); +} + +type SelectorGlobal = { + __sanderlingSelectorMatches__: (selector: string) => string[]; +}; + +(globalThis as unknown as SelectorGlobal).__sanderlingSelectorMatches__ = selectorMatches;