feat(verifier): encode state.logs for the web host

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
This commit is contained in:
pj committed 2026-08-16 01:38:42 +05:30
1 parent 226f5805aa
commit e18dafeba9
2 files changed
+78 -6

No files matched your search

+31 -6
View File
@@ -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 {
+47
View File
@@ -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" <b> & 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)
}
})
}
}