diff --git a/internal/verifier/marshal.go b/internal/verifier/marshal.go index ff40d9e..f9604b5 100644 --- a/internal/verifier/marshal.go +++ b/internal/verifier/marshal.go @@ -1,6 +1,7 @@ package verifier import ( + "bytes" "encoding/json" "fmt" "strings" @@ -325,49 +326,118 @@ func selectorObjectToString(runtime *goja.Runtime, arg goja.Value) string { return strings.Join(parts, " ") } +// actionField is one property of the lastAction object, in the order the +// object is built. Value is a string, an int, or a nested []actionField for +// the from/to points. +type actionField struct { + key string + value any +} + +// lastActionFields is the ONE description of the lastAction shape. The goja +// host turns it into a JS object (lastActionObject); the web host receives the +// same fields as JSON (EncodeLastAction) and installs them as state.lastAction +// in the page. Both hosts therefore expose identical field names, casing, +// presence and order, so a property reading state.lastAction cannot mean one +// thing on native and another on web. +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)}} + if action.On != "" { + fields = append(fields, actionField{key: "on", value: action.On}) + } + if action.Text != "" { + fields = append(fields, actionField{key: "text", value: action.Text}) + } + switch action.Kind { + case ActionKindSwipe: + fields = append(fields, + actionField{key: "from", value: point(action.FromX, action.FromY)}, + actionField{key: "to", value: point(action.ToX, action.ToY)}) + if action.DurationMillis > 0 { + fields = append(fields, + actionField{key: "durationMillis", value: action.DurationMillis}) + } + case ActionKindScroll: + fields = append(fields, + actionField{key: "direction", value: action.Direction}, + actionField{key: "from", value: point(action.FromX, action.FromY)}, + actionField{key: "to", value: point(action.ToX, action.ToY)}) + case ActionKindPressKey: + fields = append(fields, actionField{key: "key", value: action.Key}) + case ActionKindWait: + fields = append(fields, + actionField{key: "durationMillis", value: action.DurationMillis}) + } + return fields +} + func lastActionObject(runtime *goja.Runtime, action *Action) goja.Value { if action == nil { return goja.Null() } + return objectFromFields(runtime, lastActionFields(action)) +} + +func objectFromFields(runtime *goja.Runtime, fields []actionField) *goja.Object { object := runtime.NewObject() - _ = object.Set("kind", string(action.Kind)) - if action.On != "" { - _ = object.Set("on", action.On) - } - if action.Text != "" { - _ = object.Set("text", action.Text) - } - switch action.Kind { - case ActionKindSwipe: - from := runtime.NewObject() - _ = from.Set("x", action.FromX) - _ = from.Set("y", action.FromY) - to := runtime.NewObject() - _ = to.Set("x", action.ToX) - _ = to.Set("y", action.ToY) - _ = object.Set("from", from) - _ = object.Set("to", to) - if action.DurationMillis > 0 { - _ = object.Set("durationMillis", action.DurationMillis) + for _, field := range fields { + if nested, ok := field.value.([]actionField); ok { + _ = object.Set(field.key, objectFromFields(runtime, nested)) + continue } - case ActionKindScroll: - _ = object.Set("direction", action.Direction) - from := runtime.NewObject() - _ = from.Set("x", action.FromX) - _ = from.Set("y", action.FromY) - to := runtime.NewObject() - _ = to.Set("x", action.ToX) - _ = to.Set("y", action.ToY) - _ = object.Set("from", from) - _ = object.Set("to", to) - case ActionKindPressKey: - _ = object.Set("key", action.Key) - case ActionKindWait: - _ = object.Set("durationMillis", action.DurationMillis) + _ = object.Set(field.key, field.value) } return object } +// EncodeLastAction renders the previous step's action 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. 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. +func EncodeLastAction(action *Action) json.RawMessage { + if action == nil { + return json.RawMessage("null") + } + return encodeFields(lastActionFields(action)) +} + +func encodeFields(fields []actionField) json.RawMessage { + var buffer bytes.Buffer + buffer.WriteByte('{') + for index, field := range fields { + if index > 0 { + buffer.WriteByte(',') + } + buffer.Write(encodeJSValue(field.key)) + buffer.WriteByte(':') + if nested, ok := field.value.([]actionField); ok { + buffer.Write(encodeFields(nested)) + continue + } + buffer.Write(encodeJSValue(field.value)) + } + buffer.WriteByte('}') + return buffer.Bytes() +} + +// encodeJSValue encodes one value the way JS JSON.stringify would, so the JSON +// the web host parses is byte-identical to what the goja object stringifies to. +// Go escapes <, > and & by default, which JSON.stringify does not, and that +// alone would make the two hosts encode the same selector differently. +func encodeJSValue(value any) []byte { + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return []byte("null") + } + return bytes.TrimRight(buffer.Bytes(), "\n") +} + func runtimeMillis(stepTime, runStart time.Time) int64 { if stepTime.IsZero() || runStart.IsZero() { return 0 diff --git a/internal/verifier/marshal_test.go b/internal/verifier/marshal_test.go index 6013faa..1b91d1f 100644 --- a/internal/verifier/marshal_test.go +++ b/internal/verifier/marshal_test.go @@ -147,3 +147,49 @@ func TestLastActionObject_ExposesKindSpecificFields(t *testing.T) { } }) } + +// TestLastAction_WebJSONMatchesTheGojaObject pins the two hosts to ONE shape. +// The goja host builds state.lastAction as a JS object; the web host receives +// EncodeLastAction's JSON and installs the parsed value as state.lastAction in +// the page. A field this side renames, drops or cases differently would leave a +// spec reading state.lastAction working on native and silently mismatching on +// web, which is the failure this whole path exists to prevent. Comparing +// goja's own JSON.stringify against the encoder is the strongest available +// statement that the two are the same object. +func TestLastAction_WebJSONMatchesTheGojaObject(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.last = __sanderling__.extract(state => JSON.stringify(state.lastAction)); + `) + + for _, testCase := range []struct { + name string + action *Action + }{ + {"nil", nil}, + {"Tap", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", X: 12, Y: 34}}, + {"TapWithoutSelector", &Action{Kind: ActionKindTap, X: 12, Y: 34}}, + {"DoubleTap", &Action{Kind: ActionKindDoubleTap, On: `desc:say "hi" `}}, + {"InputText", &Action{Kind: ActionKindInputText, On: "id:field", Text: "50"}}, + {"Swipe", &Action{Kind: ActionKindSwipe, FromX: 1, FromY: 2, ToX: 3, ToY: 4, DurationMillis: 250}}, + {"SwipeNoDuration", &Action{Kind: ActionKindSwipe, FromX: 1, FromY: 2, ToX: 3, ToY: 4}}, + {"Scroll", &Action{Kind: ActionKindScroll, Direction: "down", FromX: 5, FromY: 6, ToX: 5, ToY: 1}}, + {"PressKey", &Action{Kind: ActionKindPressKey, Key: "enter"}}, + {"Wait", &Action{Kind: ActionKindWait, DurationMillis: 500}}, + } { + 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("last").ToObject(verifier.runtime) + goja := handle.Get("current").String() + web := string(EncodeLastAction(testCase.action)) + if goja != web { + t.Errorf("the two hosts disagree on state.lastAction\n goja: %s\n web: %s", goja, web) + } + }) + } +}