mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
A folio login run wrote the account email and password in cleartext into llm-calls.jsonl, 166 times in one run, beside screenshots of the same screens. Three sites rendered it: the recent-action memory, the candidate list, and the trace. One helper now covers all three so a fourth cannot bypass it, and the driver still receives the real text. Android redacts every typed value because it cannot tell a secure field from any other. That asymmetry is deliberate and documented: safe by default on the target that cannot tell.
663 lines
22 KiB
Go
663 lines
22 KiB
Go
package verifier
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"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. 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, tree, 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))
|
|
selector := selectorStringFromJS(runtime, call.Argument(0))
|
|
array := runtime.NewArray()
|
|
for i, n := range nodes {
|
|
_ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, tree, n, selector))
|
|
}
|
|
return array
|
|
}
|
|
_ = accessibility.Set("find", runtime.ToValue(find))
|
|
_ = accessibility.Set("findAll", runtime.ToValue(findAll))
|
|
return accessibility
|
|
}
|
|
|
|
// unambiguousSelector returns selector only when no node other than this one
|
|
// answers to it. The runner prefers tree.Find(action.On) over the coordinates
|
|
// the element reported (resolveCoordinates) and Find takes the first match, so
|
|
// naming an element by a selector its siblings share sends every one of their
|
|
// actions to the first sibling. An unnamed element keeps its own coordinates,
|
|
// which are already right, matching what selectorsFor does for the builtin
|
|
// target enumeration in pkg/spec/src/web-runtime.ts.
|
|
func unambiguousSelector(tree *hierarchy.Tree, node *hierarchy.Node, selector string) string {
|
|
if tree == nil || selector == "" {
|
|
return ""
|
|
}
|
|
for _, match := range tree.FindAllNodes(selector) {
|
|
if match != node {
|
|
return ""
|
|
}
|
|
}
|
|
return selector
|
|
}
|
|
|
|
func nodeObject(runtime *goja.Runtime, tree *hierarchy.Tree, 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)
|
|
// Three-valued, unlike the other state flags: null where the platform
|
|
// reported nothing at all, which is what separates an ordinary field from a
|
|
// password field on a platform that cannot tell them apart.
|
|
var secure any
|
|
if element.SecureReported() {
|
|
secure = element.Secure
|
|
}
|
|
_ = object.Set("secure", secure)
|
|
_ = object.Set("x", centerX)
|
|
_ = object.Set("y", centerY)
|
|
_ = object.Set(tagSelector, unambiguousSelector(tree, node, 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, tree, childNode, selectorStringFromJS(runtime, arg))
|
|
}
|
|
childFindAll := func(call goja.FunctionCall) goja.Value {
|
|
arg := call.Argument(0)
|
|
childNodes := findAllNodesInSubtreeFromJS(runtime, node, arg)
|
|
childSelector := selectorStringFromJS(runtime, arg)
|
|
array := runtime.NewArray()
|
|
for i, n := range childNodes {
|
|
_ = array.Set(fmt.Sprintf("%d", i), nodeObject(runtime, tree, n, childSelector))
|
|
}
|
|
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 {
|
|
requireKnownSelectorKeys(runtime, tree, path...)
|
|
return tree.FindBySelectorPath(path)
|
|
}
|
|
sel := selectorFromJSObject(runtime, arg)
|
|
if len(sel.Filters) == 0 {
|
|
return nil
|
|
}
|
|
requireKnownSelectorKeys(runtime, tree, sel)
|
|
return tree.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 {
|
|
requireKnownSelectorKeys(runtime, tree, path...)
|
|
return tree.FindAllBySelectorPath(path)
|
|
}
|
|
sel := selectorFromJSObject(runtime, arg)
|
|
if len(sel.Filters) == 0 {
|
|
return nil
|
|
}
|
|
requireKnownSelectorKeys(runtime, tree, sel)
|
|
return tree.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 {
|
|
requireKnownSelectorKeys(runtime, node.Tree(), path...)
|
|
return node.FindBySelectorPath(path)
|
|
}
|
|
sel := selectorFromJSObject(runtime, arg)
|
|
if len(sel.Filters) == 0 {
|
|
return nil
|
|
}
|
|
requireKnownSelectorKeys(runtime, node.Tree(), sel)
|
|
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 {
|
|
requireKnownSelectorKeys(runtime, node.Tree(), path...)
|
|
return node.FindAllBySelectorPath(path)
|
|
}
|
|
sel := selectorFromJSObject(runtime, arg)
|
|
if len(sel.Filters) == 0 {
|
|
return nil
|
|
}
|
|
requireKnownSelectorKeys(runtime, node.Tree(), sel)
|
|
return node.FindAllBySelector(sel)
|
|
}
|
|
|
|
// requireKnownSelectorKeys throws a JS error when a selector names a key that
|
|
// can never match. Returning an empty result instead is indistinguishable from
|
|
// a screen that simply has no such element, so a spec built on a mistyped key
|
|
// generates no action, the runner waits out every step, and the campaign
|
|
// finishes clean having explored nothing.
|
|
func requireKnownSelectorKeys(
|
|
runtime *goja.Runtime,
|
|
tree *hierarchy.Tree,
|
|
selectors ...hierarchy.Selector,
|
|
) {
|
|
var unknown []string
|
|
for _, sel := range selectors {
|
|
for _, key := range tree.UnknownSelectorKeys(sel) {
|
|
if !slices.Contains(unknown, key) {
|
|
unknown = append(unknown, key)
|
|
}
|
|
}
|
|
}
|
|
if len(unknown) == 0 {
|
|
return
|
|
}
|
|
panic(runtime.NewTypeError(hierarchy.UnknownSelectorKeyMessage(unknown)))
|
|
}
|
|
|
|
// 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}}
|
|
}
|
|
// 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})
|
|
}
|
|
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 dispatched.
|
|
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()
|
|
}
|
|
|
|
// 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 {
|
|
_ = 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 {
|
|
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,
|
|
On: wire.Selector,
|
|
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)
|
|
}
|
|
}
|