Files
sanderling/test/browser/browser_test.go
pj 6e85cac8b3 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.
2026-08-16 18:10:55 +05:30

362 lines
13 KiB
Go

//go:build browser
// Package browser_test drives small self-contained web fixtures through the real
// bundle -> run -> verify pipeline against headless Chrome, proving the web path
// surfaces a known violation by property name and leaves a correct page clean.
//
// The suite is gated behind the `browser` build tag so the default
// `go test ./...` (which has no Chrome dependency) never runs it. Invoke it with
// `go test -tags browser ./test/browser/...` or `make test-browser`.
package browser_test
import (
"bufio"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"testing"
"time"
"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"
)
const (
fixtureSeed = 1
fixtureMaxSteps = 12
)
// TestBrowserUncaughtExceptionSurfaces drives a page whose button throws an
// uncaught Error on the third click and asserts the default noUncaughtExceptions
// property fires within the step budget.
func TestBrowserUncaughtExceptionSurfaces(t *testing.T) {
violations := runFixture(t, "throwing")
if !slices.Contains(violations, "noUncaughtExceptions") {
t.Fatalf("noUncaughtExceptions did not fire; violations=%v", violations)
}
}
// TestBrowserCounterInvariantHolds drives a correct counter page whose buttons
// move the value by exactly one and asserts the changesByAtMostOne property
// never fires.
func TestBrowserCounterInvariantHolds(t *testing.T) {
violations := runFixture(t, "counter")
if slices.Contains(violations, "changesByAtMostOne") {
t.Fatalf("changesByAtMostOne fired on a correct page; violations=%v", violations)
}
}
// TestBrowserShadowDOMIsReachable drives a page whose entire UI (canvas, the
// button over it, and the counter) lives inside a shadow root, the shape
// Compose for Web produces. Both the enumeration and the selector lookup have
// to cross the boundary for the counter to move at all, so the property firing
// is the end-to-end evidence.
func TestBrowserShadowDOMIsReachable(t *testing.T) {
violations := runFixture(t, "shadow")
if !slices.Contains(violations, "counterNeverMoves") {
t.Fatalf("nothing inside the shadow root was ever tapped; violations=%v", violations)
}
}
// TestBrowserAriaRoleIsTappable drives a page whose only controls are
// <li role="option"> rows, the shape the replay UI gives its step list. The
// tappable set covered role="button" and nothing else, so a spec had to
// hand-write an action to reach a row; with the standard interactive roles
// covered, the default tap enumeration finds them and the counter moves.
func TestBrowserAriaRoleIsTappable(t *testing.T) {
violations := runFixture(t, "aria-roles")
if !slices.Contains(violations, "noRowWasEverSelected") {
t.Fatalf("no role=\"option\" row was ever tapped; violations=%v", violations)
}
}
// runFixture serves the named testdata case over an in-process file server,
// drives it through headless Chrome with a fixed seed and a bounded step count,
// and returns every property name that was ever reported violated.
func runFixture(t *testing.T, name string) []string {
t.Helper()
violations, _ := runFixtureRecording(t, name)
return violations
}
// runFixtureRecording is runFixture plus the run directory, for a test that
// asserts what the run left on disk rather than what it reported.
func runFixtureRecording(t *testing.T, name string) ([]string, string) {
t.Helper()
server := httptest.NewServer(http.FileServer(http.Dir(testdataDir(t))))
t.Cleanup(server.Close)
url := server.URL + "/" + name + "/"
specPath := filepath.Join(testdataDir(t), name, "spec.ts")
gojaBundle, webBundle := bundleSpec(t, specPath)
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(), 90*time.Second)
defer cancel()
if err := driverInstance.Launch(ctx, url, false, nil); err != nil {
t.Fatalf("launch %s: %v", url, err)
}
web, ok := any(driverInstance).(driver.WebDriver)
if !ok {
t.Fatal("chrome driver does not satisfy WebDriver")
}
if err := web.InstallBundle(ctx, webBundle); err != nil {
t.Fatalf("install web bundle: %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)
}
traceWriter, err := trace.NewWriter(t.TempDir())
if err != nil {
t.Fatalf("trace writer: %v", err)
}
t.Cleanup(func() { _ = traceWriter.Close() })
summary, err := chromerunner.Run(ctx, chromerunner.Options{
Duration: 90 * time.Second,
IdleTimeout: time.Second,
MaxSteps: fixtureMaxSteps,
Driver: driverInstance,
Verifier: verifierInstance,
TraceWriter: traceWriter,
})
if err != nil {
t.Fatalf("run: %v", err)
}
if err := traceWriter.Close(); err != nil {
t.Fatalf("close trace: %v", err)
}
var violated []string
for _, record := range summary.Violations {
violated = append(violated, record.Properties...)
}
return violated, traceWriter.Directory()
}
// bundleSpec compiles the fixture spec for both runtimes: the goja bundle the
// host verifier loads and the web bundle injected into the page. Aliases and
// runtime-entry paths are resolved relative to the repo's pkg/spec checkout.
func bundleSpec(t *testing.T, specPath string) (goja, web []byte) {
t.Helper()
specSrc := specSrcDir(t)
aliases := map[string]string{
"@sanderling/spec": filepath.Join(specSrc, "index.ts"),
"@sanderling/spec/defaults": filepath.Join(specSrc, "defaults/index.ts"),
"@sanderling/spec/defaults/properties": filepath.Join(specSrc, "defaults/properties.ts"),
}
defines := map[string]string{"SANDERLING_SEED": strconv.Itoa(fixtureSeed)}
gojaResult, err := bundler.Bundle(bundler.Options{
EntryFile: specPath,
RuntimeFile: filepath.Join(specSrc, "goja-runtime.ts"),
Defines: defines,
Aliases: aliases,
})
if err != nil {
t.Fatalf("bundle goja spec: %v", err)
}
webResult, err := bundler.BundleWeb(bundler.WebOptions{
EntryFile: specPath,
WebRuntimeFile: filepath.Join(specSrc, "web-runtime.ts"),
Defines: defines,
Aliases: aliases,
})
if err != nil {
t.Fatalf("bundle web spec: %v", err)
}
return gojaResult.JavaScript, webResult.JavaScript
}
// repoRoot walks up from this test file to the module root (the directory
// holding go.mod), so paths resolve regardless of the working directory.
func repoRoot(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), "..", ".."))
}
func testdataDir(t *testing.T) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot resolve caller path")
}
return filepath.Join(filepath.Dir(thisFile), "testdata")
}
func specSrcDir(t *testing.T) string {
return filepath.Join(repoRoot(t), "pkg", "spec", "src")
}
// TestBrowserUndefinedExtractorStaysUndefined drives the four layers of the
// undefined reading through one run: the page wraps each reading in a {value}
// envelope, the driver unwraps an absent value to an empty payload, the runner
// checks the page reported one reading per extractor, and the verifier decodes
// the empty payload as undefined. Each layer has its own unit test; only a run
// proves they compose. Written straight into the map instead, an undefined
// reading lost its whole index to JSON.stringify and that extractor silently
// kept goja's dump-derived value while its neighbours held the page's.
func TestBrowserUndefinedExtractorStaysUndefined(t *testing.T) {
violations := runFixture(t, "undefined-extractor")
if slices.Contains(violations, "undefinedStaysUndefined") {
t.Error("a reading the page could not take did not reach the spec as undefined")
}
if !slices.Contains(violations, "counterNeverMoves") {
t.Fatalf("nothing was ever tapped, so the property above held vacuously; violations=%v", violations)
}
}
// TestBrowserUncaughtExceptionReachesTheTrace covers the error surface a web
// run has no other source for. The page buffers its uncaught errors in V8;
// nothing used to carry them out, so state.exceptions was empty on the host
// and no trace ever held one, leaving an offline crash oracle nothing to read.
func TestBrowserUncaughtExceptionReachesTheTrace(t *testing.T) {
violations, directory := runFixtureRecording(t, "throwing")
if !slices.Contains(violations, "noUncaughtExceptions") {
t.Fatalf("the page never threw; violations=%v", violations)
}
file, err := os.Open(filepath.Join(directory, "trace.jsonl"))
if err != nil {
t.Fatal(err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
var recorded []trace.Exception
for scanner.Scan() {
var step trace.Step
if err := json.Unmarshal(scanner.Bytes(), &step); err != nil {
t.Fatalf("decode step: %v", err)
}
if step.TraceVersion != trace.TraceVersion {
t.Errorf("step %d: trace_version = %d, want %d", step.Index, step.TraceVersion, trace.TraceVersion)
}
recorded = append(recorded, step.Exceptions...)
}
if err := scanner.Err(); err != nil {
t.Fatal(err)
}
if len(recorded) == 0 {
t.Fatal("the page threw and the verdict saw it, but no trace step carries an exception")
}
if recorded[0].Class == "" || recorded[0].Message == "" {
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,
)
}
}