From e18dafeba96bb3976b7a2699fbedc268e27baeda Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 16 Aug 2026 01:38:42 +0530 Subject: [PATCH] feat(verifier): encode state.logs for the web host Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ --- internal/verifier/marshal.go | 37 ++++++++++++++++++++---- internal/verifier/marshal_test.go | 47 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/internal/verifier/marshal.go b/internal/verifier/marshal.go index 18b2ac6..31c9e99 100644 --- a/internal/verifier/marshal.go +++ b/internal/verifier/marshal.go @@ -468,19 +468,44 @@ func runtimeMillis(stepTime, runStart time.Time) int64 { return stepTime.Sub(runStart).Milliseconds() } +// logFields is the ONE description of a state.logs entry, for the same reason +// lastActionFields is: the goja host turns it into a JS object (logsArray) and +// the web host receives the same fields as JSON (EncodeLogs), so a property +// counting error-level lines cannot read one shape on native and another on web. +func logFields(entry LogEntry) []actionField { + return []actionField{ + {key: "unixMillis", value: entry.UnixMillis}, + {key: "level", value: entry.Level}, + {key: "tag", value: entry.Tag}, + {key: "message", value: entry.Message}, + } +} + func logsArray(runtime *goja.Runtime, logs []LogEntry) *goja.Object { array := runtime.NewArray() for index, entry := range logs { - item := runtime.NewObject() - _ = item.Set("unixMillis", entry.UnixMillis) - _ = item.Set("level", entry.Level) - _ = item.Set("tag", entry.Tag) - _ = item.Set("message", entry.Message) - _ = array.Set(fmt.Sprintf("%d", index), item) + _ = array.Set(fmt.Sprintf("%d", index), objectFromFields(runtime, logFields(entry))) } return array } +// EncodeLogs renders this step's log entries for the web host, which has no +// Go-side state object to read: the runner pushes this JSON into the page +// before each extractor evaluation. No entries encodes as an empty array, the +// same value the goja host reports for a step whose log fetch found nothing. +func EncodeLogs(logs []LogEntry) json.RawMessage { + var buffer bytes.Buffer + buffer.WriteByte('[') + for index, entry := range logs { + if index > 0 { + buffer.WriteByte(',') + } + buffer.Write(encodeFields(logFields(entry))) + } + buffer.WriteByte(']') + return buffer.Bytes() +} + func exceptionsArray(runtime *goja.Runtime, exceptions []Exception) *goja.Object { array := runtime.NewArray() for index, exception := range exceptions { diff --git a/internal/verifier/marshal_test.go b/internal/verifier/marshal_test.go index b3a1a56..942aec0 100644 --- a/internal/verifier/marshal_test.go +++ b/internal/verifier/marshal_test.go @@ -282,3 +282,50 @@ func TestLastAction_ReportsARelaunchSeparatelyFromTheDispatch(t *testing.T) { }) } } + +// TestLogs_WebJSONMatchesTheGojaObject pins state.logs to ONE shape across the +// two hosts, for the same reason lastAction is pinned. On web the page's +// reading of every extractor replaces the host's, so state.logs is whatever +// EncodeLogs put in the page: a field this side renames or cases differently +// leaves the default noLogcatErrors counting nothing on web while it counts on +// native, with nothing reporting that it never saw an entry. +func TestLogs_WebJSONMatchesTheGojaObject(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.lines = __sanderling__.extract(state => JSON.stringify(state.logs)); + `) + + for _, testCase := range []struct { + name string + logs []LogEntry + }{ + {"none", nil}, + {"empty", []LogEntry{}}, + { + "one error", + []LogEntry{{UnixMillis: 1700000000123, Level: "E", Tag: "console", Message: "boom from the page"}}, + }, + { + "mixed levels", + []LogEntry{ + {UnixMillis: 1, Level: "E", Tag: "console", Message: `say "hi" & co`}, + {UnixMillis: 2, Level: "W", Tag: "AndroidRuntime", Message: "a warning"}, + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + if err := verifier.PushSnapshot(SnapshotInput{ + Snapshots: Snapshots{}, + Logs: testCase.logs, + }); err != nil { + t.Fatal(err) + } + handle := verifier.runtime.GlobalObject().Get("lines").ToObject(verifier.runtime) + goja := handle.Get("current").String() + web := string(EncodeLogs(testCase.logs)) + if goja != web { + t.Errorf("the two hosts disagree on state.logs\n goja: %s\n web: %s", goja, web) + } + }) + } +}