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,
)
}
}
+215
View File
@@ -0,0 +1,215 @@
//go:build browser
package browser_test
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/driver/chrome"
"github.com/priyanshujain/sanderling/internal/hierarchy"
"github.com/priyanshujain/sanderling/internal/verifier"
)
// TestBrowserConsoleErrorReachesTheSpec drives a page that calls console.error
// and follows the entry the whole way a run does: the driver's log fetch at the
// runner's minimum level, then into the verifier state a property reads. The
// default noLogcatErrors counts entries whose level is "E", so a driver that
// spells the level any other way leaves the property permanently satisfied on
// web with nothing reporting that it never saw anything.
func TestBrowserConsoleErrorReachesTheSpec(t *testing.T) {
ctx, driverInstance, since := launchConsoleFixture(t)
entries, err := driverInstance.RecentLogs(ctx, since, "E")
if err != nil {
t.Fatalf("recent logs: %v", err)
}
if len(entries) != 2 {
t.Fatalf("the runner's error-level fetch returned %d entries, want the page's two console.error calls: %+v", len(entries), entries)
}
for _, entry := range entries {
if entry.Level != "E" {
t.Errorf("console.error %q arrived as level %q, want %q", entry.Message, entry.Level, "E")
}
}
// console.error(err) is the ordinary way a page reports a failure, and the
// argument is then an object rather than a string. An entry that arrives
// with the right level and no message names nothing a reader can act on.
if !messageSeen(entries, "boom from the page") {
t.Errorf("the page's console.error string never arrived: %+v", entries)
}
if !messageSeen(entries, "object arg detail") {
t.Errorf("console.error(new Error(...)) arrived with no message: %+v", entries)
}
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)
}
gojaBundle, _ := bundleSpec(t, filepath.Join(testdataDir(t), "console-levels", "spec.ts"))
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,
Logs: asVerifierLogs(entries),
}); err != nil {
t.Fatalf("push snapshot: %v", err)
}
verifierInstance.EvaluateProperties()
if violated := verifierInstance.NewlyViolatedProperties(); !slices.Contains(violated, "noLogcatErrors") {
t.Fatalf("a console.error on the page left noLogcatErrors satisfied; violations=%v", violated)
}
}
// TestBrowserConsoleErrorFiresTheLogProperty drives the same page through the
// whole bundle -> run -> verify pipeline instead of hand-assembling a snapshot.
// The driver holding the entry is not enough on web: every extractor reading is
// replaced by the one the page computed, so state.logs is whatever the page
// says it is, and a page that answers "no logs" leaves noLogcatErrors green on
// a run whose console was full of errors.
func TestBrowserConsoleErrorFiresTheLogProperty(t *testing.T) {
violations := runFixture(t, "console-levels")
if !slices.Contains(violations, "noLogcatErrors") {
t.Fatalf("a page calling console.error ran a whole run without noLogcatErrors firing; violations=%v", violations)
}
}
// TestBrowserQuietPageKeepsTheLogPropertySatisfied is the other half: a page
// whose console never reaches the error level must leave noLogcatErrors alone,
// so the property is reporting what the page logged rather than being on
// whenever the run is web.
func TestBrowserQuietPageKeepsTheLogPropertySatisfied(t *testing.T) {
violations := runFixture(t, "console-quiet")
if slices.Contains(violations, "noLogcatErrors") {
t.Errorf("noLogcatErrors fired on a page that logged nothing at error level; violations=%v", violations)
}
if !slices.Contains(violations, "counterNeverMoves") {
t.Fatalf("nothing was ever pressed, so the run proves nothing about a property that can fire; violations=%v", violations)
}
}
// TestBrowserConsoleLevelsMapToTheLogcatScale pins what each console verb
// becomes once it crosses the driver. driver.LogEntry.Level is the single-letter
// logcat scale on every platform, so a spec asking for warnings or debug lines
// by letter has to get the same answer on web as it does on Android, and a
// console verb the driver has no mapping for still has to arrive rather than be
// silently discarded.
func TestBrowserConsoleLevelsMapToTheLogcatScale(t *testing.T) {
ctx, driverInstance, since := launchConsoleFixture(t)
entries, err := driverInstance.RecentLogs(ctx, since, "V")
if err != nil {
t.Fatalf("recent logs: %v", err)
}
if len(entries) != 7 {
t.Fatalf("the page made 7 console calls, the driver kept %d: %+v", len(entries), entries)
}
wantLevels := map[string]string{
"boom from the page": "E",
"a warning": "W",
"a plain log": "I",
"a debug line": "D",
"an info line": "I",
}
seen := map[string]bool{}
for _, entry := range entries {
if !slices.Contains([]string{"V", "D", "I", "W", "E", "F"}, entry.Level) {
t.Errorf("entry %q carries level %q, which is not on the logcat scale a spec compares against", entry.Message, entry.Level)
}
for message, level := range wantLevels {
if !strings.Contains(entry.Message, message) {
continue
}
seen[message] = true
if entry.Level != level {
t.Errorf("console message %q arrived as level %q, want %q", message, entry.Level, level)
}
}
}
for message := range wantLevels {
if !seen[message] {
t.Errorf("console message %q never reached the driver's log buffer", message)
}
}
errorsOnly, err := driverInstance.RecentLogs(ctx, since, "E")
if err != nil {
t.Fatalf("recent logs: %v", err)
}
if len(errorsOnly) != 2 {
t.Fatalf("the error-level fetch kept %d of the 7 entries, want only the console.error calls: %+v", len(errorsOnly), errorsOnly)
}
warningsUp, err := driverInstance.RecentLogs(ctx, since, "W")
if err != nil {
t.Fatalf("recent logs: %v", err)
}
if len(warningsUp) != 3 {
t.Fatalf("the warning-level fetch kept %d entries, want the console.error calls and the console.warn: %+v", len(warningsUp), warningsUp)
}
}
// launchConsoleFixture serves the console-levels page and drives headless Chrome
// to it, returning the driver plus the instant before the page ran so a log
// fetch can ask for everything the page emitted.
func launchConsoleFixture(t *testing.T) (context.Context, *chrome.Driver, time.Time) {
t.Helper()
server := httptest.NewServer(http.FileServer(http.Dir(testdataDir(t))))
t.Cleanup(server.Close)
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)
t.Cleanup(cancel)
since := time.Now()
if err := driverInstance.Launch(ctx, server.URL+"/console-levels/", false, nil); err != nil {
t.Fatalf("launch: %v", err)
}
return ctx, driverInstance, since
}
func messageSeen(entries []driver.LogEntry, want string) bool {
return slices.ContainsFunc(entries, func(entry driver.LogEntry) bool {
return strings.Contains(entry.Message, want)
})
}
func asVerifierLogs(entries []driver.LogEntry) []verifier.LogEntry {
out := make([]verifier.LogEntry, 0, len(entries))
for _, entry := range entries {
out = append(out, verifier.LogEntry{
UnixMillis: entry.UnixMillis,
Level: entry.Level,
Tag: entry.Tag,
Message: entry.Message,
})
}
return out
}
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>console-levels</title>
</head>
<body>
<button id="press">press</button>
<div id="count">0</div>
<script>
console.error("boom from the page");
console.error(new Error("object arg detail"));
console.warn("a warning");
console.log("a plain log");
console.debug("a debug line");
console.info("an info line");
console.table({ unmapped: 1 });
let presses = 0;
document.getElementById("press").addEventListener("click", function () {
presses += 1;
document.getElementById("count").textContent = String(presses);
});
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { taps } from "@sanderling/spec";
import { noLogcatErrors } from "@sanderling/spec/defaults/properties";
export const properties = { noLogcatErrors };
export const actionsRoot = taps;
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>console-quiet</title>
</head>
<body>
<button id="press" style="width:200px;height:80px">press</button>
<div id="count">0</div>
<script>
// Noisy below the error level. A run that reports these as errors is
// reporting a healthy page as broken.
console.warn("a warning");
console.log("a plain log");
console.info("an info line");
let presses = 0;
document.getElementById("press").addEventListener("click", function () {
presses += 1;
document.getElementById("count").textContent = String(presses);
});
</script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
import { always, extract, taps } from "@sanderling/spec";
import { noLogcatErrors } from "@sanderling/spec/defaults/properties";
const presses = extract((s) => {
const el = s.ax.find({ id: "count" });
return el ? parseInt(el.text, 10) || 0 : 0;
}).named("presses");
// The run has to actually be driving the page, or noLogcatErrors staying
// satisfied says nothing about whether it can fire at all.
const counterNeverMoves = always(() => presses.current === 0);
export const properties = { noLogcatErrors, counterNeverMoves };
export const actionsRoot = taps;
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html>
<body>
<!-- Two elements share the id, one inside a shadow root on the mount and
one later in the light DOM. The hierarchy dump orders a host's shadow
children before its light ones, so a pre-order search there reaches the
shadow one first; a light-DOM sweep that only descends afterwards
reaches the other. The page exists to make the two walks disagree. -->
<div id="mount" style="width: 200px; height: 80px"></div>
<span id="x">light</span>
<script>
document.getElementById("mount").attachShadow({ mode: "open" }).innerHTML =
'<span id="x">shadow</span>';
</script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
import { always, extract, taps } from "@sanderling/spec";
// Which of the two #x a selector means is the whole fixture. The reading is
// compared between the two hosts by TestBrowserAxFindAgreesAcrossHosts, which
// drives each host's production path and never asks one what the other said.
//
// Written in the object form on purpose. It used to reach the goja host as a
// plain attribute filter looking for an `id` attribute a dump never carries
// (the web dump files the DOM id under resource-id), so it resolved nothing
// there while the V8 host resolved it against the live DOM.
const found = extract((s) => s.ax.find({ id: "x" })?.text).named("found");
const findsTheShadowMatch = always(() => found.current === "shadow");
export const properties = { findsTheShadowMatch };
export const actionsRoot = taps;