fix(verifier): encode element-valued extractors into the trace

An ax element exports with its find/findAll host functions attached, and
json.Marshal refuses the whole value over them: json: unsupported type:
func(goja.FunctionCall) goja.Value. The encoding failed, curr stayed nil,
and the goja hosts (ios, android) recorded null for every element-valued
extractor in both the per-step diff and the violation witness.

Apply the web host's sanitize rule before marshaling, so one rule encodes
an element on both hosts.
This commit is contained in:
pj committed 2026-08-15 13:38:47 +05:30
1 parent b02e86b2e3
commit b1b6f8558c
1 file changed
+62 -5
+62 -5
View File
@@ -7,6 +7,8 @@ import (
"errors"
"fmt"
"maps"
"math"
"reflect"
"sort"
"time"
@@ -356,21 +358,76 @@ func (v *Verifier) runExtractor(extractor *extractorState, state goja.Value) (go
}
// encodeExtractorValue produces a stable JSON encoding of an extractor's
// current value for diff comparison. goja values that don't survive Export
// (e.g. wrapped host functions) yield nil; callers treat nil as "unknown" and
// emit no diff entry.
// current value for diff comparison. Values that still don't survive encoding
// yield nil; callers treat nil as "unknown" and emit no diff entry.
func encodeExtractorValue(value goja.Value) []byte {
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return []byte("null")
}
exported := value.Export()
body, err := json.Marshal(exported)
body, err := json.Marshal(recordableValue(value.Export(), 0, map[uintptr]bool{}))
if err != nil {
return nil
}
return body
}
// recordableMaxDepth mirrors SANITIZE_MAX_DEPTH in pkg/spec/src/web-runtime.ts.
const recordableMaxDepth = 32
// recordableValue applies the web host's sanitize rule (web-runtime.ts) to an
// exported goja value: function members are dropped, a cycle or a branch past
// the depth cap becomes null, and a non-finite number becomes null. One rule on
// both hosts is what lets the replay UI render a trace without the reader
// having to know which host produced it. An ax element carries its find and
// findAll host functions, and json.Marshal rejects the whole element over them,
// so without this an element-valued extractor reached the trace as null.
func recordableValue(value any, depth int, seen map[uintptr]bool) any {
switch typed := value.(type) {
case map[string]any:
address := reflect.ValueOf(typed).Pointer()
if depth >= recordableMaxDepth || seen[address] {
return nil
}
seen[address] = true
members := make(map[string]any, len(typed))
for key, member := range typed {
if reflect.ValueOf(member).Kind() == reflect.Func {
continue
}
members[key] = recordableValue(member, depth+1, seen)
}
return members
case []any:
if depth >= recordableMaxDepth {
return nil
}
// Every zero-length allocation shares one address, so tracking an empty
// array would identify it as every other empty array. It cannot close a
// cycle either way.
if len(typed) > 0 {
address := reflect.ValueOf(typed).Pointer()
if seen[address] {
return nil
}
seen[address] = true
}
members := make([]any, len(typed))
for index, member := range typed {
members[index] = recordableValue(member, depth+1, seen)
}
return members
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) {
return nil
}
return typed
}
if reflect.ValueOf(value).Kind() == reflect.Func {
return nil
}
return value
}
// ChangedExtractors returns the named extractors whose value changed between
// the prior PushSnapshot and the current one. The map is keyed by extractor
// name; unnamed extractors (extractor_N fallback) are included so the replay