mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 12:07:09 +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
|
package verifier
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -325,49 +326,118 @@ func selectorObjectToString(runtime *goja.Runtime, arg goja.Value) string {
|
|||||||
return strings.Join(parts, " ")
|
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 {
|
func lastActionObject(runtime *goja.Runtime, action *Action) goja.Value {
|
||||||
if action == nil {
|
if action == nil {
|
||||||
return goja.Null()
|
return goja.Null()
|
||||||
}
|
}
|
||||||
|
return objectFromFields(runtime, lastActionFields(action))
|
||||||
|
}
|
||||||
|
|
||||||
|
func objectFromFields(runtime *goja.Runtime, fields []actionField) *goja.Object {
|
||||||
object := runtime.NewObject()
|
object := runtime.NewObject()
|
||||||
_ = object.Set("kind", string(action.Kind))
|
for _, field := range fields {
|
||||||
if action.On != "" {
|
if nested, ok := field.value.([]actionField); ok {
|
||||||
_ = object.Set("on", action.On)
|
_ = 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(field.key, field.value)
|
||||||
_ = 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)
|
|
||||||
}
|
}
|
||||||
return object
|
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 {
|
func runtimeMillis(stepTime, runStart time.Time) int64 {
|
||||||
if stepTime.IsZero() || runStart.IsZero() {
|
if stepTime.IsZero() || runStart.IsZero() {
|
||||||
return 0
|
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