mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
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.
552 lines
17 KiB
Go
552 lines
17 KiB
Go
package verifier
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
|
)
|
|
|
|
// Snapshots is the per-step extractor output forwarded by the SDK.
|
|
type Snapshots map[string]json.RawMessage
|
|
|
|
type stateInput struct {
|
|
snapshots Snapshots
|
|
tree *hierarchy.Tree
|
|
lastAction *Action
|
|
stepTime time.Time
|
|
runStart time.Time
|
|
logs []LogEntry
|
|
exceptions []Exception
|
|
}
|
|
|
|
// stateObject builds the JS-side `state` object matching the State type from
|
|
// pkg/spec-api. 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()
|
|
snapshotsObject := runtime.NewObject()
|
|
for key, raw := range input.snapshots {
|
|
value, err := jsonToJSValue(runtime, raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("snapshot %q: %w", key, err)
|
|
}
|
|
if err := snapshotsObject.Set(key, value); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := state.Set("snapshots", snapshotsObject); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := state.Set("ax", accessibilityObject(runtime, input.tree)); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := state.Set("lastAction", lastActionObject(runtime, input.lastAction)); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := state.Set("time", runtimeMillis(input.stepTime, input.runStart)); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := state.Set("logs", logsArray(runtime, input.logs)); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := state.Set("exceptions", exceptionsArray(runtime, input.exceptions)); err != nil {
|
|
return nil, err
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
func accessibilityObject(runtime *goja.Runtime, tree *hierarchy.Tree) *goja.Object {
|
|
accessibility := runtime.NewObject()
|
|
find := func(call goja.FunctionCall) goja.Value {
|
|
if tree == nil {
|
|
return goja.Undefined()
|
|
}
|
|
node := findNodeFromJS(runtime, tree, call.Argument(0))
|
|
if node == nil {
|
|
return goja.Undefined()
|
|
}
|
|
return nodeObject(runtime, node, selectorStringFromJS(runtime, call.Argument(0)))
|
|
}
|
|
findAll := func(call goja.FunctionCall) goja.Value {
|
|
if tree == nil {
|
|
return goja.Undefined()
|
|
}
|
|
nodes := findAllNodesFromJS(runtime, tree, call.Argument(0))
|
|
array := runtime.NewArray()
|
|
for i, n := range nodes {
|
|
_ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(runtime, call.Argument(0))))
|
|
}
|
|
return array
|
|
}
|
|
_ = accessibility.Set("find", runtime.ToValue(find))
|
|
_ = accessibility.Set("findAll", runtime.ToValue(findAll))
|
|
return accessibility
|
|
}
|
|
|
|
func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) goja.Value {
|
|
element := &node.Element
|
|
object := runtime.NewObject()
|
|
centerX, centerY := element.Bounds.Center()
|
|
_ = object.Set("id", element.ResourceID)
|
|
_ = object.Set("text", element.Text)
|
|
_ = object.Set("desc", element.Description)
|
|
_ = object.Set("class", element.Class)
|
|
_ = object.Set("clickable", element.Clickable)
|
|
_ = object.Set("enabled", element.Enabled)
|
|
_ = object.Set("checked", element.Checked)
|
|
_ = object.Set("focused", element.Focused)
|
|
_ = object.Set("selected", element.Selected)
|
|
_ = object.Set("editable", element.Editable)
|
|
_ = object.Set("x", centerX)
|
|
_ = object.Set("y", centerY)
|
|
_ = object.Set(tagSelector, selector)
|
|
bounds := runtime.NewObject()
|
|
_ = bounds.Set("left", element.Bounds.Left)
|
|
_ = bounds.Set("top", element.Bounds.Top)
|
|
_ = bounds.Set("right", element.Bounds.Right)
|
|
_ = bounds.Set("bottom", element.Bounds.Bottom)
|
|
_ = object.Set("bounds", bounds)
|
|
attrs := runtime.NewObject()
|
|
for k, v := range element.Attributes {
|
|
_ = attrs.Set(k, v)
|
|
}
|
|
_ = object.Set("attrs", attrs)
|
|
childFind := func(call goja.FunctionCall) goja.Value {
|
|
arg := call.Argument(0)
|
|
childNode := findNodeInSubtreeFromJS(runtime, node, arg)
|
|
if childNode == nil {
|
|
return goja.Undefined()
|
|
}
|
|
return nodeObject(runtime, childNode, selectorStringFromJS(runtime, arg))
|
|
}
|
|
childFindAll := func(call goja.FunctionCall) goja.Value {
|
|
arg := call.Argument(0)
|
|
childNodes := findAllNodesInSubtreeFromJS(runtime, node, arg)
|
|
array := runtime.NewArray()
|
|
for i, n := range childNodes {
|
|
_ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, n, selectorStringFromJS(runtime, arg)))
|
|
}
|
|
return array
|
|
}
|
|
_ = object.Set("find", runtime.ToValue(childFind))
|
|
_ = object.Set("findAll", runtime.ToValue(childFindAll))
|
|
return object
|
|
}
|
|
|
|
// findNodeFromJS dispatches a JS value (string, object, or array of objects)
|
|
// to Tree-level node lookup.
|
|
func findNodeFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value) *hierarchy.Node {
|
|
if goja.IsUndefined(arg) || goja.IsNull(arg) {
|
|
return nil
|
|
}
|
|
if tree == nil {
|
|
return nil
|
|
}
|
|
if s, ok := arg.Export().(string); ok {
|
|
return tree.FindNode(s)
|
|
}
|
|
if path, ok := selectorPathFromJS(runtime, arg); ok {
|
|
return tree.FindBySelectorPath(path)
|
|
}
|
|
sel := selectorFromJSObject(runtime, arg)
|
|
if len(sel.Filters) == 0 {
|
|
return nil
|
|
}
|
|
return tree.Root.FindBySelector(sel)
|
|
}
|
|
|
|
// findAllNodesFromJS dispatches a JS value to Tree-level multi-node lookup.
|
|
func findAllNodesFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value) []*hierarchy.Node {
|
|
if goja.IsUndefined(arg) || goja.IsNull(arg) || tree == nil {
|
|
return nil
|
|
}
|
|
if s, ok := arg.Export().(string); ok {
|
|
return tree.FindAllNodes(s)
|
|
}
|
|
if path, ok := selectorPathFromJS(runtime, arg); ok {
|
|
return tree.FindAllBySelectorPath(path)
|
|
}
|
|
sel := selectorFromJSObject(runtime, arg)
|
|
if len(sel.Filters) == 0 {
|
|
return nil
|
|
}
|
|
return tree.Root.FindAllBySelector(sel)
|
|
}
|
|
|
|
// findNodeInSubtreeFromJS dispatches a JS value to Node-level scoped lookup.
|
|
func findNodeInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, arg goja.Value) *hierarchy.Node {
|
|
if goja.IsUndefined(arg) || goja.IsNull(arg) {
|
|
return nil
|
|
}
|
|
if s, ok := arg.Export().(string); ok {
|
|
return node.Find(s)
|
|
}
|
|
if path, ok := selectorPathFromJS(runtime, arg); ok {
|
|
return node.FindBySelectorPath(path)
|
|
}
|
|
sel := selectorFromJSObject(runtime, arg)
|
|
if len(sel.Filters) == 0 {
|
|
return nil
|
|
}
|
|
return node.FindBySelector(sel)
|
|
}
|
|
|
|
// findAllNodesInSubtreeFromJS dispatches a JS value to Node-level scoped multi-lookup.
|
|
func findAllNodesInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, arg goja.Value) []*hierarchy.Node {
|
|
if goja.IsUndefined(arg) || goja.IsNull(arg) {
|
|
return nil
|
|
}
|
|
if s, ok := arg.Export().(string); ok {
|
|
return node.FindAll(s)
|
|
}
|
|
if path, ok := selectorPathFromJS(runtime, arg); ok {
|
|
return node.FindAllBySelectorPath(path)
|
|
}
|
|
sel := selectorFromJSObject(runtime, arg)
|
|
if len(sel.Filters) == 0 {
|
|
return nil
|
|
}
|
|
return node.FindAllBySelector(sel)
|
|
}
|
|
|
|
// selectorFromJSObject converts a JS object {attr: value, ...} into a Selector.
|
|
func selectorFromJSObject(runtime *goja.Runtime, arg goja.Value) hierarchy.Selector {
|
|
obj := arg.ToObject(runtime)
|
|
if obj == nil {
|
|
return hierarchy.Selector{}
|
|
}
|
|
var sel hierarchy.Selector
|
|
for _, key := range obj.Keys() {
|
|
if key == tagSelector {
|
|
continue
|
|
}
|
|
val := obj.Get(key)
|
|
if val == nil || goja.IsUndefined(val) {
|
|
continue
|
|
}
|
|
sel.Filters = append(sel.Filters, hierarchy.AttrFilter{Attr: key, Value: val.String()})
|
|
}
|
|
return sel
|
|
}
|
|
|
|
// selectorPathFromJS recognizes a JS array of selector objects and converts it
|
|
// into a Selector chain. Returns ok=false for non-arrays so callers fall
|
|
// through to single-object dispatch.
|
|
func selectorPathFromJS(runtime *goja.Runtime, arg goja.Value) ([]hierarchy.Selector, bool) {
|
|
exported := arg.Export()
|
|
slice, ok := exported.([]any)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
obj := arg.ToObject(runtime)
|
|
if obj == nil {
|
|
return nil, false
|
|
}
|
|
path := make([]hierarchy.Selector, 0, len(slice))
|
|
for index := range slice {
|
|
entry := obj.Get(fmt.Sprintf("%d", index))
|
|
if entry == nil || goja.IsUndefined(entry) || goja.IsNull(entry) {
|
|
return nil, false
|
|
}
|
|
sel := selectorFromJSObject(runtime, entry)
|
|
if len(sel.Filters) == 0 {
|
|
return nil, false
|
|
}
|
|
path = append(path, sel)
|
|
}
|
|
if len(path) == 0 {
|
|
return nil, false
|
|
}
|
|
return path, true
|
|
}
|
|
|
|
// selectorStringFromJS returns a string representation of the selector argument
|
|
// for tagging returned ax element objects (state.ax.find/findAll), so a spec
|
|
// reading an element's selector sees the canonical form. Output follows the
|
|
// hierarchy selector grammar: "k:v" pairs space-joined per object, chains
|
|
// joined by " > ".
|
|
func selectorStringFromJS(runtime *goja.Runtime, arg goja.Value) string {
|
|
if arg == nil || goja.IsUndefined(arg) || goja.IsNull(arg) {
|
|
return ""
|
|
}
|
|
if s, ok := arg.Export().(string); ok {
|
|
return s
|
|
}
|
|
exported := arg.Export()
|
|
if slice, ok := exported.([]any); ok {
|
|
object := arg.ToObject(runtime)
|
|
if object == nil {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, len(slice))
|
|
for index := range slice {
|
|
entry := object.Get(fmt.Sprintf("%d", index))
|
|
if entry == nil || goja.IsUndefined(entry) || goja.IsNull(entry) {
|
|
continue
|
|
}
|
|
segment := selectorObjectToString(runtime, entry)
|
|
if segment == "" {
|
|
continue
|
|
}
|
|
parts = append(parts, segment)
|
|
}
|
|
return strings.Join(parts, " > ")
|
|
}
|
|
return selectorObjectToString(runtime, arg)
|
|
}
|
|
|
|
// selectorObjectToString formats a single JS object as a space-joined sequence
|
|
// of "k:v" pairs, mirroring the hierarchy package's predicate grammar.
|
|
func selectorObjectToString(runtime *goja.Runtime, arg goja.Value) string {
|
|
if arg == nil || goja.IsUndefined(arg) || goja.IsNull(arg) {
|
|
return ""
|
|
}
|
|
object := arg.ToObject(runtime)
|
|
if object == nil {
|
|
return ""
|
|
}
|
|
keys := object.Keys()
|
|
parts := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
if key == tagSelector {
|
|
continue
|
|
}
|
|
value := object.Get(key)
|
|
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
|
continue
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%s:%s", key, 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()
|
|
for _, field := range fields {
|
|
if nested, ok := field.value.([]actionField); ok {
|
|
_ = object.Set(field.key, objectFromFields(runtime, nested))
|
|
continue
|
|
}
|
|
_ = 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
|
|
}
|
|
return stepTime.Sub(runStart).Milliseconds()
|
|
}
|
|
|
|
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)
|
|
}
|
|
return array
|
|
}
|
|
|
|
func exceptionsArray(runtime *goja.Runtime, exceptions []Exception) *goja.Object {
|
|
array := runtime.NewArray()
|
|
for index, exception := range exceptions {
|
|
item := runtime.NewObject()
|
|
_ = item.Set("class", exception.Class)
|
|
_ = item.Set("message", exception.Message)
|
|
_ = item.Set("stackTrace", exception.StackTrace)
|
|
if exception.UnixMillis > 0 {
|
|
_ = item.Set("unixMillis", exception.UnixMillis)
|
|
}
|
|
_ = array.Set(fmt.Sprintf("%d", index), item)
|
|
}
|
|
return array
|
|
}
|
|
|
|
func jsonToJSValue(runtime *goja.Runtime, raw json.RawMessage) (goja.Value, error) {
|
|
if len(raw) == 0 {
|
|
return goja.Undefined(), nil
|
|
}
|
|
var generic any
|
|
if err := json.Unmarshal(raw, &generic); err != nil {
|
|
return nil, fmt.Errorf("decode JSON: %w", err)
|
|
}
|
|
return runtime.ToValue(generic), nil
|
|
}
|
|
|
|
// wireAction is the unified flat wire contract both runtime entries emit
|
|
// (runtime-entry.ts serializeAction). ONE decoder reads it on both the goja
|
|
// (native) and the runner (web) sides, so a field rename cannot silently turn
|
|
// an action into a no-op on one path only.
|
|
type wireAction struct {
|
|
Kind string `json:"kind"`
|
|
X int `json:"x"`
|
|
Y int `json:"y"`
|
|
Selector string `json:"selector"`
|
|
Text string `json:"text"`
|
|
Key string `json:"key"`
|
|
Direction string `json:"direction"`
|
|
FromX int `json:"fromX"`
|
|
FromY int `json:"fromY"`
|
|
ToX int `json:"toX"`
|
|
ToY int `json:"toY"`
|
|
DurationMillis int `json:"durationMillis"`
|
|
}
|
|
|
|
// DecodeAction turns one serialized action (the flat camelCase wire contract)
|
|
// into a Go Action. An empty payload or JSON "null" reports ErrNoAction.
|
|
func DecodeAction(raw json.RawMessage) (Action, error) {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return Action{}, ErrNoAction
|
|
}
|
|
var wire wireAction
|
|
if err := json.Unmarshal(raw, &wire); err != nil {
|
|
return Action{}, fmt.Errorf("decode action: %w", err)
|
|
}
|
|
switch wire.Kind {
|
|
case "Tap":
|
|
return Action{Kind: ActionKindTap, On: wire.Selector, X: wire.X, Y: wire.Y}, nil
|
|
case "DoubleTap":
|
|
return Action{Kind: ActionKindDoubleTap, On: wire.Selector, X: wire.X, Y: wire.Y}, nil
|
|
case "LongPress":
|
|
return Action{Kind: ActionKindLongPress, On: wire.Selector, X: wire.X, Y: wire.Y}, nil
|
|
case "InputText":
|
|
return Action{Kind: ActionKindInputText, On: wire.Selector, Text: wire.Text, X: wire.X, Y: wire.Y}, nil
|
|
case "Swipe":
|
|
return Action{
|
|
Kind: ActionKindSwipe,
|
|
FromX: wire.FromX,
|
|
FromY: wire.FromY,
|
|
ToX: wire.ToX,
|
|
ToY: wire.ToY,
|
|
DurationMillis: wire.DurationMillis,
|
|
}, nil
|
|
case "Scroll":
|
|
return Action{
|
|
Kind: ActionKindScroll,
|
|
Direction: wire.Direction,
|
|
FromX: wire.FromX,
|
|
FromY: wire.FromY,
|
|
ToX: wire.ToX,
|
|
ToY: wire.ToY,
|
|
DurationMillis: wire.DurationMillis,
|
|
}, nil
|
|
case "PressKey":
|
|
return Action{Kind: ActionKindPressKey, Key: wire.Key}, nil
|
|
case "Wait":
|
|
return Action{Kind: ActionKindWait, DurationMillis: wire.DurationMillis}, nil
|
|
default:
|
|
return Action{}, fmt.Errorf("unknown action kind %q", wire.Kind)
|
|
}
|
|
}
|