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

+64 -12
View File
@@ -79,23 +79,27 @@ func New() *Driver {
}
var parts []string
for _, arg := range e.Args {
if arg.Value != nil {
var s string
if err := json.Unmarshal(arg.Value, &s); err == nil {
parts = append(parts, s)
} else {
parts = append(parts, string(arg.Value))
// An object argument, which is what console.error(err) passes,
// carries no value at all: CDP sends a description instead. Reading
// only the value logged those calls with an empty message, so the
// entry named a level and nothing a reader could act on.
if arg.Value == nil {
if arg.Description != "" {
parts = append(parts, arg.Description)
}
continue
}
var s string
if err := json.Unmarshal(arg.Value, &s); err == nil {
parts = append(parts, s)
} else {
parts = append(parts, string(arg.Value))
}
}
level := strings.ToUpper(string(e.Type))
if level == "LOG" {
level = "I"
}
d.logsMu.Lock()
d.logs = append(d.logs, driver.LogEntry{
UnixMillis: int64(e.Timestamp.Time().UnixMilli()),
Level: level,
Level: consoleLevel(e.Type),
Tag: "console",
Message: strings.Join(parts, " "),
})
@@ -940,9 +944,33 @@ func (d *Driver) Metrics(ctx context.Context, _ string) (driver.Metrics, error)
}, nil
}
// consoleLevel places a console call on driver.LogEntry's logcat scale. The
// verbs a spec acts on are all named here; the rest are info rather than "E"
// because promoting them would convict an app of an error it never logged.
func consoleLevel(apiType runtime.APIType) string {
switch apiType {
case runtime.APITypeError, runtime.APITypeAssert:
return "E"
case runtime.APITypeWarning:
return "W"
case runtime.APITypeDebug:
return "D"
default:
return "I"
}
}
// meetsLevel keeps an entry whose level the scale cannot rank. Ranking an
// unknown level below every threshold drops it, and a dropped entry is
// indistinguishable from a quiet app: the caller sees silence and reports it as
// health.
func meetsLevel(level, minLevel string) bool {
order := map[string]int{"V": 0, "D": 1, "I": 2, "W": 3, "E": 4, "F": 5}
return order[level] >= order[minLevel]
rank, ranked := order[level]
if !ranked {
return true
}
return rank >= order[minLevel]
}
func pngDimensions(png []byte) (int, int) {
@@ -1087,6 +1115,30 @@ func (d *Driver) SetLastAction(ctx context.Context, encoded json.RawMessage) err
return nil
}
// SetLogs installs the entries this step's log fetch returned as state.logs
// inside the page runtime. The page cannot derive them: console output reaches
// the driver over CDP and nothing in the page reads it back. Without this call
// every web state.logs is empty, and since the page's reading of an extractor
// replaces the host's, the default noLogcatErrors then reports green on a run
// whose console was full of errors.
//
// Unguarded for the same reason as SetLastAction: on a page with no setter,
// "the page cannot accept logs" has to fail the run rather than be reported as
// a successful install.
func (d *Driver) SetLogs(ctx context.Context, encoded json.RawMessage) error {
payload := strings.TrimSpace(string(encoded))
if payload == "" {
payload = "[]"
}
script := fmt.Sprintf(`window.__sanderlingSetLogs__(%s)`, payload)
runCtx, cancel := d.runCtx(ctx)
defer cancel()
if err := chromedp.Run(runCtx, chromedp.Evaluate(script, nil)); err != nil {
return fmt.Errorf("set logs: %w", err)
}
return nil
}
// extractorScript resolves the extractor table once the page is not mid route
// transition, giving up on that wait after %d ms.
//
+48
View File
@@ -936,6 +936,54 @@ func TestSetLastAction_ReportsAPageThatCannotTakeIt(t *testing.T) {
}
}
// TestSetLogs_ReportsAPageThatCannotTakeThem is the same install on the channel
// the log properties hang off. The driver holding a console error changes
// nothing on web: the page's reading of every extractor replaces the host's, so
// unless the entries are put back into the page, noLogcatErrors counts an empty
// array and stays green through a run full of errors.
func TestSetLogs_ReportsAPageThatCannotTakeThem(t *testing.T) {
const withSetter = `<body><script>
window.__logsSeen = null;
window.__sanderlingSetLogs__ = function (value) { window.__logsSeen = value; };
</script></body>`
const withoutSetter = `<body><div id="app">no sanderling runtime here</div></body>`
pages := map[string]string{"/with": withSetter, "/without": withoutSetter}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(pages[r.URL.Path]))
}))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL+"/with", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
logs := json.RawMessage(`[{"unixMillis":1,"level":"E","tag":"console","message":"boom"}]`)
if err := d.SetLogs(ctx, logs); err != nil {
t.Fatalf("SetLogs on a page that defines the setter: %v", err)
}
var seen []map[string]any
if err := chromedp.Run(d.tabCtx,
chromedp.Evaluate(`window.__logsSeen`, &seen)); err != nil {
t.Fatalf("read installed logs: %v", err)
}
if len(seen) != 1 || seen[0]["level"] != "E" || seen[0]["message"] != "boom" {
t.Errorf("the page received %v, want the error-level entry the driver captured", seen)
}
if err := d.Launch(ctx, server.URL+"/without", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := d.SetLogs(ctx, logs); err == nil {
t.Error("SetLogs reported success on a page with no setter; " +
"a runtime that cannot take the step's logs is indistinguishable from one that did")
}
}
// TestEvaluateExtractors_ReportsAMissingTable is the same failure on the other
// sampler. An empty override map is what a spec with no extractors returns, so
// treating a missing table as {} makes "this page has no sanderling runtime"
+60
View File
@@ -0,0 +1,60 @@
package chrome
import (
"testing"
"github.com/chromedp/cdproto/runtime"
)
// Every console verb has to land on the logcat scale driver.LogEntry declares:
// the runner fetches at "E" and the default properties count entries whose
// level equals "E", so a level spelled any other way is an error the spec never
// sees. A verb with no mapping is info, which is honest about severity without
// fabricating an error the page never logged.
func TestConsoleLevel(t *testing.T) {
cases := map[runtime.APIType]string{
runtime.APITypeError: "E",
runtime.APITypeAssert: "E",
runtime.APITypeWarning: "W",
runtime.APITypeDebug: "D",
runtime.APITypeLog: "I",
runtime.APITypeInfo: "I",
runtime.APITypeTable: "I",
runtime.APIType("countReset"): "I",
}
for apiType, want := range cases {
if got := consoleLevel(apiType); got != want {
t.Errorf("consoleLevel(%q) = %q, want %q", apiType, got, want)
}
}
}
// A level the scale does not name is unknown, not verbose. Ranking it below
// every threshold is what silently emptied the web log channel: the entries
// existed, the filter dropped them, and the run reported nothing. Evidence the
// filter cannot rank has to reach the caller, who can at least see it.
func TestMeetsLevel(t *testing.T) {
cases := []struct {
level string
minLevel string
want bool
}{
{"E", "E", true},
{"F", "E", true},
{"W", "E", false},
{"I", "E", false},
{"D", "E", false},
{"V", "E", false},
{"W", "W", true},
{"I", "W", false},
{"D", "V", true},
{"ERROR", "E", true},
{"WARNING", "E", true},
{"", "E", true},
}
for _, tc := range cases {
if got := meetsLevel(tc.level, tc.minLevel); got != tc.want {
t.Errorf("meetsLevel(%q, %q) = %v, want %v", tc.level, tc.minLevel, got, tc.want)
}
}
}