mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 12:07:09 +00:00
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:
commit
6e85cac8b3
130 files changed
+12772
-1617
No files matched your search
@@ -2,6 +2,7 @@ package verifier
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -176,3 +177,77 @@ func TestStateAxObjectSelectorKeepsCrossPlatformKeysSilent(t *testing.T) {
|
||||
t.Fatalf("probe = %q, want miss", got)
|
||||
}
|
||||
}
|
||||
|
||||
// axSelectorFormsTree carries one node per id shape a dump produces: the bare
|
||||
// tag Compose and the web driver emit, the package-qualified resource id
|
||||
// Android emits, and the iOS accessibility identifier.
|
||||
const axSelectorFormsTree = `{
|
||||
"attributes": {"resource-id": "root", "bounds": "[0,0,400,800]"},
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "BareThing", "text": "bare", "bounds": "[0,0,100,50]"},
|
||||
"children": []},
|
||||
{"attributes": {"resource-id": "com.example.app:id/AndroidThing", "text": "android",
|
||||
"bounds": "[0,50,100,100]"}, "children": []},
|
||||
{"attributes": {"accessibilityIdentifier": "IosThing", "text": "ios",
|
||||
"bounds": "[0,100,100,150]"}, "children": []}
|
||||
]
|
||||
}`
|
||||
|
||||
// TestStateAxSelectorFormsAgree drives both selector forms a spec can write
|
||||
// through state.ax.find and holds them to the same element. The two forms
|
||||
// dispatch to different lookups (findNodeFromJS sends a string to FindNode and
|
||||
// an object to FindBySelector), and the object one used to skip the id rule
|
||||
// that knows an Android resource id is package-qualified, so a spec that wrote
|
||||
// ax.find({id: "AddAccountSubmit"}) got undefined on Android and every property
|
||||
// reading it passed while checking nothing.
|
||||
func TestStateAxSelectorFormsAgree(t *testing.T) {
|
||||
tree, err := hierarchy.Parse(axSelectorFormsTree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"BareThing", "bare"},
|
||||
{"AndroidThing", "android"},
|
||||
{"com.example.app:id/AndroidThing", "android"},
|
||||
{"IosThing", "ios"},
|
||||
} {
|
||||
t.Run(test.value, func(t *testing.T) {
|
||||
verifier := newVerifier(t)
|
||||
mustLoad(t, verifier, `
|
||||
globalThis.fromObject = __sanderling__.extract(
|
||||
state => state.ax.find({ id: `+strconv.Quote(test.value)+` })?.text, "fromObject");
|
||||
globalThis.fromString = __sanderling__.extract(
|
||||
state => state.ax.find("id:" + `+strconv.Quote(test.value)+`)?.text, "fromString");
|
||||
globalThis.properties = {};
|
||||
`)
|
||||
if err := verifier.PushSnapshot(SnapshotInput{Tree: tree}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromObject := readCurrent(t, verifier, "fromObject")
|
||||
fromString := readCurrent(t, verifier, "fromString")
|
||||
if fromString != test.want {
|
||||
t.Fatalf(`ax.find("id:%s") read %v, want %q`, test.value, fromString, test.want)
|
||||
}
|
||||
if fromObject != fromString {
|
||||
t.Errorf(
|
||||
`one selector, two answers: ax.find({id: %q}) read %v and ax.find("id:%s") read %v`,
|
||||
test.value, fromObject, test.value, fromString,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// readCurrent returns a named extractor's current value, or nil when the getter
|
||||
// returned undefined, which is what an unresolved selector produces.
|
||||
func readCurrent(t *testing.T, verifier *Verifier, name string) any {
|
||||
t.Helper()
|
||||
handle := verifier.runtime.GlobalObject().Get(name)
|
||||
if handle == nil {
|
||||
t.Fatalf("%s is not defined", name)
|
||||
}
|
||||
return handle.ToObject(verifier.runtime).Get("current").Export()
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const elementTreeJSON = `{
|
||||
"attributes": {"resource-id": "root", "bounds": "[0,0,400,800]"},
|
||||
"enabled": true,
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "TxnAmountField", "text": "199", "bounds": "[0,100,400,160]"},
|
||||
"editable": true, "enabled": true, "children": []}
|
||||
]
|
||||
}`
|
||||
|
||||
const elementExtractorSpec = `
|
||||
const field = __sanderling__.extract(state => state.ax.find({ "resource-id": "TxnAmountField" }), "field");
|
||||
globalThis.properties = {};
|
||||
`
|
||||
|
||||
// canonicalElement is the trace's record of one ax element, written out in the
|
||||
// key order encoding/json emits. It is the contract both hosts owe the replay
|
||||
// UI: an element the reader can read, with no host-function members and nothing
|
||||
// dropped. Keys the two hosts disagree on (a DOM has no `checked`, a native
|
||||
// tree has no `dataset`) are each host's own business; the ENCODING is not.
|
||||
const canonicalElement = `{
|
||||
"__sanderlingSelector": "resource-id:TxnAmountField",
|
||||
"attrs": {
|
||||
"bounds": "[0,100,400,160]",
|
||||
"editable": "true",
|
||||
"enabled": "true",
|
||||
"resource-id": "TxnAmountField",
|
||||
"text": "199"
|
||||
},
|
||||
"bounds": {"bottom": 160, "left": 0, "right": 400, "top": 100},
|
||||
"checked": false,
|
||||
"class": "",
|
||||
"clickable": false,
|
||||
"desc": "",
|
||||
"editable": true,
|
||||
"enabled": true,
|
||||
"focused": false,
|
||||
"id": "TxnAmountField",
|
||||
"selected": false,
|
||||
"text": "199",
|
||||
"x": 200,
|
||||
"y": 130
|
||||
}`
|
||||
|
||||
// TestExtractorEncoding_ElementIsIdenticalOnBothHosts holds the two extractor
|
||||
// paths to one encoding of one element. The goja hosts (ios, android) run the
|
||||
// getter in-process and encode the value it returned; the web host runs it in
|
||||
// V8 and injects the page's reading through OverrideExtractorValues. A reader
|
||||
// opening a trace does not know which host wrote it, so the same element has to
|
||||
// land as the same bytes either way.
|
||||
//
|
||||
// The goja side used to write null here: an ax element carries find/findAll as
|
||||
// host functions and json.Marshal refuses the whole object over them.
|
||||
func TestExtractorEncoding_ElementIsIdenticalOnBothHosts(t *testing.T) {
|
||||
want := compactJSON(t, canonicalElement)
|
||||
|
||||
native := newVerifier(t)
|
||||
mustLoad(t, native, elementExtractorSpec)
|
||||
pushTree(t, native, elementTreeJSON)
|
||||
fromGoja := string(native.extractors[0].curr)
|
||||
if fromGoja != want {
|
||||
t.Errorf("goja host encoded the element as\n %s\nwant\n %s", fromGoja, want)
|
||||
}
|
||||
|
||||
web := newVerifier(t)
|
||||
mustLoad(t, web, elementExtractorSpec)
|
||||
if err := web.PushSnapshot(SnapshotInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := web.OverrideExtractorValues(map[int]json.RawMessage{0: json.RawMessage(want)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromWeb := string(web.extractors[0].curr)
|
||||
if fromWeb != fromGoja {
|
||||
t.Errorf("the same element reaches the trace as\n %s\non the web host and\n %s\non goja",
|
||||
fromWeb, fromGoja)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractorEncoding_MirrorsTheWebSanitizeRule pins the goja host to the
|
||||
// rule the web host applies before a reading leaves the page (sanitize in
|
||||
// pkg/spec/src/web-runtime.ts, asserted there by the "sanitize ..." tests in
|
||||
// pkg/spec/test/web-runtime.test.ts). Two hosts encoding one value two ways is
|
||||
// the same defect as encoding it not at all: the reader cannot line the traces
|
||||
// up.
|
||||
func TestExtractorEncoding_MirrorsTheWebSanitizeRule(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
expression string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "function-valued properties are dropped",
|
||||
expression: `({ keep: 1, fn: () => 7 })`,
|
||||
want: `{"keep":1}`,
|
||||
},
|
||||
{
|
||||
name: "a top-level function is not a value",
|
||||
expression: `(() => 7)`,
|
||||
want: `null`,
|
||||
},
|
||||
{
|
||||
name: "a self-referential cycle breaks instead of overflowing",
|
||||
expression: `(() => { const a = { name: "root" }; a.self = a; return a; })()`,
|
||||
want: `{"name":"root","self":null}`,
|
||||
},
|
||||
{
|
||||
name: "arrays and nested plain values are preserved",
|
||||
expression: `({ items: [1, "two", { ok: true }] })`,
|
||||
want: `{"items":[1,"two",{"ok":true}]}`,
|
||||
},
|
||||
{
|
||||
name: "a non-finite number is not a value",
|
||||
expression: `Number("nope")`,
|
||||
want: `null`,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := encodeSpecValue(t, test.expression); got != test.want {
|
||||
t.Errorf("encoded as %s, want %s", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractorEncoding_BoundsRecursionPastTheDepthLimit mirrors the web host's
|
||||
// depth cap. state.ax hands out no cyclic element, but a spec returning a value
|
||||
// it built itself can nest without end, and a walk with no bound takes the run
|
||||
// down with a stack overflow.
|
||||
func TestExtractorEncoding_BoundsRecursionPastTheDepthLimit(t *testing.T) {
|
||||
encoded := encodeSpecValue(t, `(() => {
|
||||
let deep = { leaf: true };
|
||||
for (let i = 0; i < 40; i++) deep = { next: deep };
|
||||
return deep;
|
||||
})()`)
|
||||
|
||||
var node any
|
||||
if err := json.Unmarshal([]byte(encoded), &node); err != nil {
|
||||
t.Fatalf("decode %s: %v", encoded, err)
|
||||
}
|
||||
for depth := 0; depth < recordableMaxDepth; depth++ {
|
||||
object, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("depth %d: recursion stopped early at %v", depth, node)
|
||||
}
|
||||
node = object["next"]
|
||||
}
|
||||
if node != nil {
|
||||
t.Errorf("depth %d is %v, want null", recordableMaxDepth, node)
|
||||
}
|
||||
}
|
||||
|
||||
// encodeSpecValue returns what the trace records for an extractor whose getter
|
||||
// returned the given expression.
|
||||
func encodeSpecValue(t *testing.T, expression string) string {
|
||||
t.Helper()
|
||||
verifier := newVerifier(t)
|
||||
mustLoad(t, verifier, "__sanderling__.extract(state => "+expression+", \"value\");\nglobalThis.properties = {};")
|
||||
if err := verifier.PushSnapshot(SnapshotInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(verifier.extractors[0].curr)
|
||||
}
|
||||
|
||||
func compactJSON(t *testing.T, source string) string {
|
||||
t.Helper()
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, []byte(source)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return compact.String()
|
||||
}
|
||||
|
||||
// TestExtractorEncoding_NestedUndefinedIsNotOnTheWire pins the one reading
|
||||
// shape the two hosts do NOT encode alike, rather than hiding it.
|
||||
//
|
||||
// JSON has no undefined, so the page loses the whole key (asserted in
|
||||
// pkg/spec/test/web-runtime.test.ts) while goja writes null. goja cannot mirror
|
||||
// the drop: Export reports an undefined member and a null member identically as
|
||||
// nil, so dropping those keys here would drop the genuine nulls the page keeps.
|
||||
// Mirroring the other way, by writing null on the page, would break the one
|
||||
// thing that does agree. Carrying the member across takes a wire format that
|
||||
// can express undefined, which is a change to every layer that parses a reading
|
||||
// and to the replay UI that renders one.
|
||||
//
|
||||
// So the guarantee is narrower than "the same object": both hosts answer
|
||||
// undefined when a property READS the member. Key presence (`in`, Object.keys)
|
||||
// is not part of it, and this test says so out loud, so closing the gap has to
|
||||
// be a deliberate change to both hosts at once.
|
||||
func TestExtractorEncoding_NestedUndefinedIsNotOnTheWire(t *testing.T) {
|
||||
const reading = `({ absent: undefined, empty: null, present: 1 })`
|
||||
const fromGoja = `{"absent":null,"empty":null,"present":1}`
|
||||
// What the page sends for the same getter, with the key gone.
|
||||
const fromWeb = `{"empty":null,"present":1}`
|
||||
|
||||
if got := encodeSpecValue(t, reading); got != fromGoja {
|
||||
t.Errorf("goja encoded the reading as %s, want %s", got, fromGoja)
|
||||
}
|
||||
|
||||
native := newVerifier(t)
|
||||
mustLoad(t, native, "__sanderling__.extract(state => "+reading+", \"value\");\nglobalThis.properties = {};")
|
||||
if err := native.PushSnapshot(SnapshotInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
web := newVerifier(t)
|
||||
mustLoad(t, web, "__sanderling__.extract(state => null, \"value\");\nglobalThis.properties = {};")
|
||||
if err := web.PushSnapshot(SnapshotInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := web.OverrideExtractorValues(map[int]json.RawMessage{0: json.RawMessage(fromWeb)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, probe := range []struct {
|
||||
expression string
|
||||
native bool
|
||||
web bool
|
||||
}{
|
||||
{"reading.absent === undefined", true, true},
|
||||
{"reading.empty === null", true, true},
|
||||
{"reading.present === 1", true, true},
|
||||
// The half that does not survive the wire.
|
||||
{`"absent" in reading`, true, false},
|
||||
} {
|
||||
if got := evaluateAgainstReading(t, native, probe.expression); got != probe.native {
|
||||
t.Errorf("goja host: %s is %v, want %v", probe.expression, got, probe.native)
|
||||
}
|
||||
if got := evaluateAgainstReading(t, web, probe.expression); got != probe.web {
|
||||
t.Errorf("web host: %s is %v, want %v", probe.expression, got, probe.web)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateAgainstReading answers a boolean expression over the value a property
|
||||
// would read out of the first extractor, which is where the two hosts have to
|
||||
// agree.
|
||||
func evaluateAgainstReading(t *testing.T, verifier *Verifier, expression string) bool {
|
||||
t.Helper()
|
||||
if err := verifier.runtime.GlobalObject().Set("reading", verifier.extractors[0].currentValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := verifier.runtime.RunString(expression)
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate %s: %v", expression, err)
|
||||
}
|
||||
return value.ToBoolean()
|
||||
}
|
||||
+57
-111
@@ -4,8 +4,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -29,7 +27,7 @@ type stateInput struct {
|
||||
}
|
||||
|
||||
// stateObject builds the JS-side `state` object matching the State type from
|
||||
// pkg/spec-api. Fields beyond snapshots/ax are included when the caller
|
||||
// pkg/spec. Fields beyond snapshots/ax are included when the caller
|
||||
// populated them on stateInput.
|
||||
func stateObject(runtime *goja.Runtime, input stateInput) (*goja.Object, error) {
|
||||
state := runtime.NewObject()
|
||||
@@ -400,7 +398,30 @@ func lastActionFields(action *Action) []actionField {
|
||||
point := func(x, y int) []actionField {
|
||||
return []actionField{{key: "x", value: x}, {key: "y", value: y}}
|
||||
}
|
||||
fields := []actionField{{key: "kind", value: string(action.Kind)}}
|
||||
// An action whose apply call failed is not an action that did not happen:
|
||||
// the dispatch may have landed before the error. That is unknown, and
|
||||
// unknown is null here for the same reason every other absence in the spec
|
||||
// surface is, so a property decides for itself instead of being handed a
|
||||
// "nothing happened" the runner cannot vouch for.
|
||||
var applied any
|
||||
if action.Applied {
|
||||
applied = true
|
||||
}
|
||||
// A relaunch between two readings is not "no action happened", which is
|
||||
// what dropping the action reported instead: the app restarted after an
|
||||
// action that did run. Only the positive report is a fact the runner can
|
||||
// vouch for, so the other side is null rather than false: a target whose
|
||||
// foreground the runner cannot read (web, iOS) never relaunches the app and
|
||||
// still cannot promise it never restarted.
|
||||
var relaunched any
|
||||
if action.Relaunched {
|
||||
relaunched = true
|
||||
}
|
||||
fields := []actionField{
|
||||
{key: "kind", value: string(action.Kind)},
|
||||
{key: "applied", value: applied},
|
||||
{key: "relaunched", value: relaunched},
|
||||
}
|
||||
if action.On != "" {
|
||||
fields = append(fields, actionField{key: "on", value: action.On})
|
||||
}
|
||||
@@ -453,7 +474,7 @@ func objectFromFields(runtime *goja.Runtime, fields []actionField) *goja.Object
|
||||
// has no Go-side state object to read: the runner pushes this JSON into the
|
||||
// page before each extractor evaluation. A nil action encodes as JSON null,
|
||||
// the same value the goja host reports on the first step of a run and after a
|
||||
// step whose action was never applied.
|
||||
// step whose action was never dispatched.
|
||||
func EncodeLastAction(action *Action) json.RawMessage {
|
||||
if action == nil {
|
||||
return json.RawMessage("null")
|
||||
@@ -501,19 +522,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 {
|
||||
@@ -529,106 +575,6 @@ func exceptionsArray(runtime *goja.Runtime, exceptions []Exception) *goja.Object
|
||||
return array
|
||||
}
|
||||
|
||||
// traceValueMaxDepth bounds how far recordableValue walks. It mirrors
|
||||
// SANITIZE_MAX_DEPTH in pkg/spec/src/web-runtime.ts, whose sanitize does this
|
||||
// same job for the values the page reports, so both hosts record the same JSON
|
||||
// for the same extractor.
|
||||
const traceValueMaxDepth = 32
|
||||
|
||||
// recordableValue rewrites an exported goja value into one json.Marshal
|
||||
// accepts. An accessibility element is a plain object carrying two host
|
||||
// functions (find/findAll); marshalling it fails on those alone, so the whole
|
||||
// element used to go unrecorded. Dropping them leaves the element's data (id,
|
||||
// text, desc, class, the flags, bounds, attrs), which is what a trace reader
|
||||
// wants and is a subset of the hierarchy the same step already records.
|
||||
//
|
||||
// ok is false for a value with no JSON form at all: callers drop that key from
|
||||
// its object, matching the web host, where a function-valued property is
|
||||
// skipped and a function inside an array stringifies to null.
|
||||
func recordableValue(value any, depth int, seen map[uintptr]bool) (any, bool) {
|
||||
if value == nil {
|
||||
return nil, true
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return finiteOrNull(typed), true
|
||||
case float32:
|
||||
return finiteOrNull(float64(typed)), true
|
||||
}
|
||||
reflected := reflect.ValueOf(value)
|
||||
switch reflected.Kind() {
|
||||
case reflect.Func:
|
||||
return nil, false
|
||||
case reflect.Map:
|
||||
if reflected.Type().Key().Kind() != reflect.String || !holdsAny(reflected.Type().Elem()) {
|
||||
return value, true
|
||||
}
|
||||
if depth >= traceValueMaxDepth || !firstVisit(reflected, seen) {
|
||||
return nil, true
|
||||
}
|
||||
out := make(map[string]any, reflected.Len())
|
||||
iterator := reflected.MapRange()
|
||||
for iterator.Next() {
|
||||
entry, ok := recordableValue(iterator.Value().Interface(), depth+1, seen)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[iterator.Key().String()] = entry
|
||||
}
|
||||
return out, true
|
||||
case reflect.Slice, reflect.Array:
|
||||
if !holdsAny(reflected.Type().Elem()) {
|
||||
return value, true
|
||||
}
|
||||
if depth >= traceValueMaxDepth || !firstVisit(reflected, seen) {
|
||||
return nil, true
|
||||
}
|
||||
out := make([]any, reflected.Len())
|
||||
for index := range out {
|
||||
entry, ok := recordableValue(reflected.Index(index).Interface(), depth+1, seen)
|
||||
if !ok {
|
||||
entry = nil
|
||||
}
|
||||
out[index] = entry
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
|
||||
// holdsAny reports whether a container's elements can hide a host function or
|
||||
// a cycle. Concretely typed containers ([]string, []byte, map[string]string)
|
||||
// can hold neither, and walking them would rewrite shapes json.Marshal already
|
||||
// handles, such as []byte's base64 form.
|
||||
func holdsAny(elem reflect.Type) bool {
|
||||
return elem.Kind() == reflect.Interface
|
||||
}
|
||||
|
||||
// firstVisit reports whether a container has not been walked yet, so a cyclic
|
||||
// value terminates. Empty containers are never recorded: they cannot close a
|
||||
// cycle, and Go may hand every one of them the same address.
|
||||
func firstVisit(container reflect.Value, seen map[uintptr]bool) bool {
|
||||
if container.Kind() == reflect.Array || container.Len() == 0 {
|
||||
return true
|
||||
}
|
||||
address := container.Pointer()
|
||||
if seen[address] {
|
||||
return false
|
||||
}
|
||||
seen[address] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// finiteOrNull maps NaN and the infinities to JSON null, which is what
|
||||
// JSON.stringify does with them on the web host.
|
||||
func finiteOrNull(value float64) any {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func jsonToJSValue(runtime *goja.Runtime, raw json.RawMessage) (goja.Value, error) {
|
||||
if len(raw) == 0 {
|
||||
return goja.Undefined(), nil
|
||||
|
||||
@@ -175,6 +175,8 @@ func TestLastAction_WebJSONMatchesTheGojaObject(t *testing.T) {
|
||||
}{
|
||||
{"nil", nil},
|
||||
{"Tap", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", X: 12, Y: 34}},
|
||||
{"TapApplied", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", Applied: true}},
|
||||
{"TapRelaunched", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", Applied: true, Relaunched: true}},
|
||||
{"TapWithoutSelector", &Action{Kind: ActionKindTap, X: 12, Y: 34}},
|
||||
{"DoubleTap", &Action{Kind: ActionKindDoubleTap, On: `desc:say "hi" <b>`}},
|
||||
{"InputText", &Action{Kind: ActionKindInputText, On: "id:field", Text: "50"}},
|
||||
@@ -200,3 +202,137 @@ func TestLastAction_WebJSONMatchesTheGojaObject(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A spec has to be able to tell three things apart: no action ran, an action
|
||||
// ran, and an action was dispatched whose fate the runner cannot vouch for.
|
||||
// The third used to be reported as the first, which is how a property that
|
||||
// reasons "an effect landed with no action to cause it" convicts an app over
|
||||
// an RPC deadline.
|
||||
func TestLastAction_SeparatesNoActionFromAnActionOfUnknownFate(t *testing.T) {
|
||||
verifier := newVerifier(t)
|
||||
mustLoad(t, verifier, `
|
||||
globalThis.fate = __sanderling__.extract(state =>
|
||||
state.lastAction === null ? "no action"
|
||||
: state.lastAction.applied === true ? "applied"
|
||||
: state.lastAction.applied === null ? "unknown"
|
||||
: "unreadable");
|
||||
`)
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
action *Action
|
||||
want string
|
||||
}{
|
||||
{"nothing ran", nil, "no action"},
|
||||
{"dispatch confirmed", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", Applied: true}, "applied"},
|
||||
{"dispatch unconfirmed", &Action{Kind: ActionKindTap, On: "id:TxnSubmit"}, "unknown"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if err := verifier.PushSnapshot(SnapshotInput{
|
||||
Snapshots: Snapshots{},
|
||||
LastAction: testCase.action,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle := verifier.runtime.GlobalObject().Get("fate").ToObject(verifier.runtime)
|
||||
if got := handle.Get("current").String(); got != testCase.want {
|
||||
t.Errorf("the spec read %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The runner relaunches the app when it leaves the foreground, which used to be
|
||||
// reported to the spec as "no action ran between these two readings". The
|
||||
// action did run; what a property cannot assume across it is that app state was
|
||||
// continuous, so the relaunch is its own fact on an action that keeps its
|
||||
// confirmed dispatch.
|
||||
func TestLastAction_ReportsARelaunchSeparatelyFromTheDispatch(t *testing.T) {
|
||||
verifier := newVerifier(t)
|
||||
mustLoad(t, verifier, `
|
||||
globalThis.continuity = __sanderling__.extract(state =>
|
||||
state.lastAction === null ? "no action"
|
||||
: state.lastAction.applied !== true ? "unconfirmed"
|
||||
: state.lastAction.relaunched === true ? "applied across a relaunch"
|
||||
: state.lastAction.relaunched === null ? "applied, no relaunch reported"
|
||||
: "unreadable");
|
||||
`)
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
action *Action
|
||||
want string
|
||||
}{
|
||||
{"nothing ran", nil, "no action"},
|
||||
{
|
||||
"confirmed, app stayed",
|
||||
&Action{Kind: ActionKindTap, On: "id:TxnSubmit", Applied: true},
|
||||
"applied, no relaunch reported",
|
||||
},
|
||||
{
|
||||
"confirmed, app relaunched after it",
|
||||
&Action{Kind: ActionKindTap, On: "id:TxnSubmit", Applied: true, Relaunched: true},
|
||||
"applied across a relaunch",
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if err := verifier.PushSnapshot(SnapshotInput{
|
||||
Snapshots: Snapshots{},
|
||||
LastAction: testCase.action,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle := verifier.runtime.GlobalObject().Get("continuity").ToObject(verifier.runtime)
|
||||
if got := handle.Get("current").String(); got != testCase.want {
|
||||
t.Errorf("the spec read %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,17 @@ type Action struct {
|
||||
// Direction is the scroll direction for ActionKindScroll: one of "up",
|
||||
// "down", "left", "right". Empty for every other kind.
|
||||
Direction string
|
||||
// Applied is meaningful only on the action a step reports to the spec as
|
||||
// state.lastAction: true when the runner saw the dispatch succeed, false
|
||||
// when the apply call failed and nothing can say whether the action
|
||||
// reached the app. The spec is told which of the two it is.
|
||||
Applied bool
|
||||
// Relaunched, like Applied, is meaningful only on state.lastAction: the
|
||||
// runner brought the app back to the foreground after this action, so the
|
||||
// two readings the spec compares straddle a restart. The action still
|
||||
// happened; what a property cannot assume across it is that app state ran
|
||||
// continuously from one reading to the next.
|
||||
Relaunched bool
|
||||
}
|
||||
|
||||
// LogEntry mirrors a logcat line captured between steps.
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -383,17 +385,70 @@ func encodeExtractorValue(value goja.Value) ([]byte, error) {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
recordable, ok := recordableValue(value.Export(), 0, map[uintptr]bool{})
|
||||
if !ok {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
body, err := json.Marshal(recordable)
|
||||
body, err := json.Marshal(recordableValue(value.Export(), 0, map[uintptr]bool{}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in new issue
Block a user