merge origin/master into llm-recording-and-analysis

both sides independently fixed the same three bugs, so each one had to pick a
winner rather than keep both implementations.

extractor encoding: master's recordableValue in worker.go wins over ours in
marshal.go, since master's is pinned by extractor_encoding_test.go and ours had
no tests. our error semantics stay: encodeExtractorValue still returns an error
instead of nil, so an extractor cannot vanish from the trace silently.

apply errors: only the residual generic branch takes master's unconfirmed copy,
where the device may have committed the action before the call failed. the
finer branches that know nothing was dispatched keep lastAction = nil, and our
actionSkipReason taxonomy stays alongside master's held/skippedVerification.

selector matching: our matchAttr with matchSelectorKind wins over master's
match, since ours also handles idPrefix. matchSelector now calls it, which git
did not flag as a conflict and left calling a function our side had deleted.

the ltl doc comment takes master's correction: an unbounded eventually that
never fires IS violated at run end.
This commit is contained in:
pj committed 2026-08-16 18:10:55 +05:30
commit 6e85cac8b3
130 files changed
+12772 -1617

No files matched your search

+84
View File
@@ -26,6 +26,7 @@ import (
"github.com/priyanshujain/sanderling/internal/bundler"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/driver/chrome"
"github.com/priyanshujain/sanderling/internal/hierarchy"
chromerunner "github.com/priyanshujain/sanderling/internal/runner"
"github.com/priyanshujain/sanderling/internal/trace"
"github.com/priyanshujain/sanderling/internal/verifier"
@@ -275,3 +276,86 @@ func TestBrowserUncaughtExceptionReachesTheTrace(t *testing.T) {
t.Errorf("exception recorded without class or message: %+v", recorded[0])
}
}
// One page, one selector, two hosts, two answers.
//
// The V8 host resolves state.ax.find against the live DOM; the goja host
// resolves the same selector against the hierarchy dump. The fixture puts a
// shadow-hosted #x above a light-DOM #x, the one shape where the two walks can
// disagree, and on web it is V8's answer that reaches the properties. Each host
// is driven through its production path (EvaluateExtractors in the page,
// PushSnapshot over the dump) and neither is asked what the other said, so the
// comparison is evidence rather than an assertion about one of them.
func TestBrowserAxFindAgreesAcrossHosts(t *testing.T) {
server := httptest.NewServer(http.FileServer(http.Dir(testdataDir(t))))
t.Cleanup(server.Close)
gojaBundle, webBundle := bundleSpec(t, filepath.Join(testdataDir(t), "find-order", "spec.ts"))
driverInstance := chrome.New()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = driverInstance.Terminate(ctx)
})
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := driverInstance.Launch(ctx, server.URL+"/find-order/", false, nil); err != nil {
t.Fatalf("launch: %v", err)
}
if err := driverInstance.InstallBundle(ctx, webBundle); err != nil {
t.Fatalf("install web bundle: %v", err)
}
readings, err := driverInstance.EvaluateExtractors(ctx)
if err != nil {
t.Fatalf("evaluate extractors in the page: %v", err)
}
fromV8 := string(readings[0])
dump, err := driverInstance.Hierarchy(ctx)
if err != nil {
t.Fatalf("hierarchy: %v", err)
}
tree, err := hierarchy.Parse(dump)
if err != nil {
t.Fatalf("parse hierarchy: %v", err)
}
verifierInstance, err := verifier.New(
verifier.WithSeed(fixtureSeed),
verifier.WithPlatform("web"),
)
if err != nil {
t.Fatalf("verifier: %v", err)
}
if err := verifierInstance.Load(string(gojaBundle)); err != nil {
t.Fatalf("load spec: %v", err)
}
if err := verifierInstance.PushSnapshot(verifier.SnapshotInput{Tree: tree}); err != nil {
t.Fatalf("push snapshot: %v", err)
}
if count := verifierInstance.ExtractorCount(); count != 1 {
t.Fatalf("the goja host registered %d extractors, want the fixture's 1", count)
}
// ChangedExtractors omits an extractor whose reading is null and unchanged,
// which is exactly what a selector resolving nothing produces. With the
// count checked above, an absent entry is a null reading and not a missing
// extractor, so reporting it as null names the real failure.
fromGoja := "null"
if change, ok := verifierInstance.ChangedExtractors()["found"]; ok {
fromGoja = string(change.Curr)
}
// Pinned, not just compared: two hosts that both resolved nothing would
// agree on undefined and prove nothing about the walk.
if fromGoja != `"shadow"` {
t.Errorf("the goja host read %s off the dump, want the shadow-hosted %q", fromGoja, "shadow")
}
if fromV8 != fromGoja {
t.Fatalf(
"one page, one selector, two answers: the V8 host read %s and the goja host read %s",
fromV8,
fromGoja,
)
}
}