test(chrome): compare both selector matchers over one live page

Selector matching is written once per runtime: internal/hierarchy over the
dump, web-runtime.ts over the DOM. Nothing made the two agree, and a
selector that resolves on one and not the other is silent, since an empty
match yields no action and the run still passes.
This commit is contained in:
pj committed 2026-08-13 00:28:16 +05:30
1 parent bf3f230b1b
commit c77b78d63b
4 files changed
+173

No files matched your search

@@ -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
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html id="page" lang="en">
<head id="page-head">
<meta id="page-charset" charset="utf-8" />
<title id="page-title">selector parity</title>
</head>
<body id="page-body">
<div id="summary_card" aria-label="summary_card, 3 customers">summary</div>
<div id="customer_list" aria-label="customer_list">
<div id="customer_row_a1" aria-label="customer_row_a1, Alice">Alice</div>
<div id="customer_row_b2" aria-label="customer_row_b2, Bob">Bob</div>
<div id="supplier_row_c3" aria-label="supplier_row_c3, Carol">Carol</div>
</div>
</body>
</html>
+1
View File
@@ -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,
+33
View File
@@ -0,0 +1,33 @@
/// <reference lib="dom" />
// 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;