mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
refactor(verifier): derive the lastAction shape from one field list
both hosts must show a spec the same lastAction. one ordered list now feeds the goja object and the json the web host installs, so they cannot drift.
This commit is contained in:
1 parent
9f75ee433d
commit
df74bf4d6f
2 files changed
+149
-33
No files matched your search
+103
-33
@@ -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)
|
||||
for _, field := range fields {
|
||||
if nested, ok := field.value.([]actionField); ok {
|
||||
_ = object.Set(field.key, objectFromFields(runtime, nested))
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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" <b>`}},
|
||||
{"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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user