mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-05 04:27:09 +00:00
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:
1 parent
b02e86b2e3
commit
b1b6f8558c
1 file changed
+62
-5
@@ -7,6 +7,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"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
|
// encodeExtractorValue produces a stable JSON encoding of an extractor's
|
||||||
// current value for diff comparison. goja values that don't survive Export
|
// current value for diff comparison. Values that still don't survive encoding
|
||||||
// (e.g. wrapped host functions) yield nil; callers treat nil as "unknown" and
|
// yield nil; callers treat nil as "unknown" and emit no diff entry.
|
||||||
// emit no diff entry.
|
|
||||||
func encodeExtractorValue(value goja.Value) []byte {
|
func encodeExtractorValue(value goja.Value) []byte {
|
||||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||||
return []byte("null")
|
return []byte("null")
|
||||||
}
|
}
|
||||||
exported := value.Export()
|
body, err := json.Marshal(recordableValue(value.Export(), 0, map[uintptr]bool{}))
|
||||||
body, err := json.Marshal(exported)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return body
|
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
|
// ChangedExtractors returns the named extractors whose value changed between
|
||||||
// the prior PushSnapshot and the current one. The map is keyed by extractor
|
// the prior PushSnapshot and the current one. The map is keyed by extractor
|
||||||
// name; unnamed extractors (extractor_N fallback) are included so the replay
|
// name; unnamed extractors (extractor_N fallback) are included so the replay
|
||||||
|
|||||||
Reference in new issue
Block a user