From fdc616247a3c7fda9962a02c89f7a57096071177 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 16 Aug 2026 01:23:58 +0530 Subject: [PATCH] test(browser): drive a console error through to the spec Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ --- test/browser/console_levels_test.go | 174 ++++++++++++++++++ .../testdata/console-levels/index.html | 24 +++ test/browser/testdata/console-levels/spec.ts | 6 + 3 files changed, 204 insertions(+) create mode 100644 test/browser/console_levels_test.go create mode 100644 test/browser/testdata/console-levels/index.html create mode 100644 test/browser/testdata/console-levels/spec.ts diff --git a/test/browser/console_levels_test.go b/test/browser/console_levels_test.go new file mode 100644 index 0000000..ac3d625 --- /dev/null +++ b/test/browser/console_levels_test.go @@ -0,0 +1,174 @@ +//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) != 1 { + t.Fatalf("the runner's error-level fetch returned %d entries, want the page's one console.error: %+v", len(entries), entries) + } + if entries[0].Level != "E" { + t.Errorf("console.error arrived as level %q, want %q", entries[0].Level, "E") + } + if !strings.Contains(entries[0].Message, "boom from the page") { + t.Errorf("console.error arrived as %q, want the page's message", entries[0].Message) + } + + 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) + } +} + +// 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) != 6 { + t.Fatalf("the page made 6 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) != 1 { + t.Fatalf("the error-level fetch kept %d of the 6 entries, want only the console.error: %+v", len(errorsOnly), errorsOnly) + } + warningsUp, err := driverInstance.RecentLogs(ctx, since, "W") + if err != nil { + t.Fatalf("recent logs: %v", err) + } + if len(warningsUp) != 2 { + t.Fatalf("the warning-level fetch kept %d entries, want the console.error 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 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 +} diff --git a/test/browser/testdata/console-levels/index.html b/test/browser/testdata/console-levels/index.html new file mode 100644 index 0000000..56da280 --- /dev/null +++ b/test/browser/testdata/console-levels/index.html @@ -0,0 +1,24 @@ + + + + + console-levels + + + +
0
+ + + diff --git a/test/browser/testdata/console-levels/spec.ts b/test/browser/testdata/console-levels/spec.ts new file mode 100644 index 0000000..e4a513d --- /dev/null +++ b/test/browser/testdata/console-levels/spec.ts @@ -0,0 +1,6 @@ +import { taps } from "@sanderling/spec"; +import { noLogcatErrors } from "@sanderling/spec/defaults/properties"; + +export const properties = { noLogcatErrors }; + +export const actionsRoot = taps;