Files
sanderling/internal/verifier/marshal.go
T
pj 8ccf95c1cf refactor: rename project uatu -> sanderling (#24)
* refactor: rename Go module path uatu -> sanderling

Module path github.com/priyanshujain/uatu -> github.com/priyanshujain/sanderling,
including all imports and the proto go_package option. Generated .pb.go files
rewritten in-place; safe to regenerate with protoc later.

* chore(proto): regenerate driverpb after module path rename

The previous sed-based module rename corrupted the embedded descriptor
byte lengths. buf generate rewrites them cleanly.

* refactor: rename CLI binary uatu -> sanderling

Updates Makefile target + UATU_BIN var, .goreleaser project/build IDs,
.gitignore comment, and all user-facing strings in the CLI help text,
error messages, and tests. Binary is now bin/sanderling.

* refactor(sdk): rename Kotlin package dev.uatu.sdk -> dev.sanderling.sdk

Moves sdk/android/src/{main,test}/kotlin/dev/uatu -> dev/sanderling and
rewrites package declarations, imports, and the Gradle namespace. Class
names (Uatu, UatuRuntime) are renamed in a follow-up commit.

* refactor(sidecar): rename Kotlin package dev.uatu.sidecar -> dev.sanderling.sidecar

Moves sidecar/src/{main,test}/kotlin/dev/uatu -> dev/sanderling and
rewrites package declarations, imports, and the application mainClass.

* refactor: rename Uatu API surface -> Sanderling

- Kotlin: Uatu -> Sanderling, UatuRuntime -> SanderlingRuntime (+ files).
- JS host binding: globalThis.__uatu__ -> __sanderling__ (Go verifier,
  spec-api, tests).
- TS interface: UatuRuntime -> SanderlingRuntime; internal tags
  __uatuFormula / __uatuActionGenerator -> __sanderling* variants.
- Go trace: UatuVersion field + uatu_version JSON tag renamed.
- Socket naming: uatu-agent / uatu-agent-reader -> sanderling-agent*.
- Sample app, docs, inline-JS test strings updated to match.

* refactor(examples): rename examples/folio/uatu -> examples/folio/sanderling

Renames the example spec directory; updates justfile paths + gitignore
entries accordingly. Package.json name/description and @uatu/spec
dependency are renamed in the npm + docs commits.

* chore(build): rename gradle property + rootProject.name uatu -> sanderling

- Renames the uatu.version gradle property and all its -P references in
  Makefile, build.gradle.kts files, and .github/workflows/release.yml.
- settings.gradle.kts rootProject.name = "sanderling".
- Renames .env.local.example header + release-cli workflow job name.

* refactor(proto): rename proto package uatu.driver.v1 -> sanderling.driver.v1

Updates the proto package and java_package, regenerates driver.pb.go +
driver_grpc.pb.go, rewrites Kotlin imports and the gRPC ServiceName
assertion in driver_test.go.

* refactor: rename npm package @uatu/spec -> @sanderling/spec

Renames package name in pkg/spec-api/package.json + lockfile, all
consumer imports (examples/folio spec, testdata, verifier tests), the
esbuild alias in cmd/sanderling/test_run.go, and related doc references.

* docs: rename uatu -> sanderling in README, docs, and URLs

- README + docs/{manual,development}/*: narrative + GitHub + Pages URLs.
- POM + npm package.json repo/homepage/bugs URLs.
- .gitignore + embed_stub + Makefile-comment references updated to
  'make sanderling'.
- Minor narrative comments in cmd/sanderling/test_run.go and
  internal/inspect/server.go.

* refactor: rename remaining internal uatu strings -> sanderling

- SANDERLING_TEST_PHONE/OTP env vars (cmd + bundler tests).
- sanderling-sidecar runtime tmp dir + extracted JAR filename.
- Inspect web UI: @sanderling/inspect-web package, title, theme
  localStorage key, RunList empty-state copy, uatu_version TS field.
- Sample app storage key sanderling.ledger.v1.
- Test data: sanderling_test AVD name + com.example.sanderling_test.
- Release docs tarball name template.
2026-04-21 11:57:49 +07:00

304 lines
8.7 KiB
Go

package verifier
import (
"encoding/json"
"fmt"
"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(selector string) goja.Value {
if tree == nil {
return goja.Undefined()
}
element := tree.Find(selector)
if element == nil {
return goja.Undefined()
}
return elementObject(runtime, element, selector)
}
findAll := func(selector string) []goja.Value {
if tree == nil {
return nil
}
elements := tree.FindAll(selector)
result := make([]goja.Value, len(elements))
for index, element := range elements {
result[index] = elementObject(runtime, element, selector)
}
return result
}
_ = accessibility.Set("find", runtime.ToValue(find))
_ = accessibility.Set("findAll", runtime.ToValue(findAll))
return accessibility
}
func elementObject(runtime *goja.Runtime, element *hierarchy.Element, selector string) goja.Value {
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("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)
return object
}
func lastActionObject(runtime *goja.Runtime, action *Action) goja.Value {
if action == nil {
return goja.Null()
}
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)
}
case ActionKindPressKey:
_ = object.Set("key", action.Key)
case ActionKindWait:
_ = object.Set("durationMillis", action.DurationMillis)
}
return object
}
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
}
// jsValueToAction converts a JS-side action object into a Go Action.
func jsValueToAction(runtime *goja.Runtime, value goja.Value) (Action, error) {
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
return Action{}, fmt.Errorf("nil action")
}
object := value.ToObject(runtime)
kindValue := object.Get("kind")
if kindValue == nil {
return Action{}, fmt.Errorf("action missing kind")
}
kind := kindValue.String()
switch kind {
case "Tap":
on := object.Get("on")
x, y := coordinatesOf(runtime, on)
return Action{Kind: ActionKindTap, On: selectorOf(runtime, on), X: x, Y: y}, nil
case "InputText":
into := object.Get("into")
text := object.Get("text")
x, y := coordinatesOf(runtime, into)
return Action{Kind: ActionKindInputText, On: selectorOf(runtime, into), Text: stringOf(text), X: x, Y: y}, nil
case "Swipe":
from := object.Get("from")
to := object.Get("to")
fromX, fromY := coordinatesOf(runtime, from)
toX, toY := coordinatesOf(runtime, to)
if fromX == 0 && fromY == 0 {
fromX, fromY = pointCoordinates(runtime, from)
}
if toX == 0 && toY == 0 {
toX, toY = pointCoordinates(runtime, to)
}
return Action{
Kind: ActionKindSwipe,
FromX: fromX,
FromY: fromY,
ToX: toX,
ToY: toY,
DurationMillis: intField(object, "durationMillis"),
}, nil
case "PressKey":
return Action{Kind: ActionKindPressKey, Key: stringOf(object.Get("key"))}, nil
case "Wait":
return Action{Kind: ActionKindWait, DurationMillis: intField(object, "durationMillis")}, nil
default:
return Action{}, fmt.Errorf("unknown action kind %q", kind)
}
}
// pointCoordinates reads a plain {x, y} literal (not an AX element), which is
// how Swipe endpoints are commonly expressed in specs.
func pointCoordinates(runtime *goja.Runtime, value goja.Value) (int, int) {
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
return 0, 0
}
object := value.ToObject(runtime)
if object == nil {
return 0, 0
}
x := object.Get("x")
y := object.Get("y")
if x == nil || y == nil {
return 0, 0
}
return int(x.ToInteger()), int(y.ToInteger())
}
func intField(object *goja.Object, name string) int {
value := object.Get(name)
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return 0
}
return int(value.ToInteger())
}
func selectorOf(runtime *goja.Runtime, value goja.Value) string {
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
return ""
}
object := value.ToObject(runtime)
if object == nil {
return value.String()
}
if tag := object.Get(tagSelector); tag != nil && !goja.IsUndefined(tag) {
return tag.String()
}
return value.String()
}
func coordinatesOf(runtime *goja.Runtime, value goja.Value) (int, int) {
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
return 0, 0
}
object := value.ToObject(runtime)
if object == nil {
return 0, 0
}
xValue := object.Get("x")
yValue := object.Get("y")
if xValue == nil || yValue == nil || goja.IsUndefined(xValue) || goja.IsUndefined(yValue) {
return 0, 0
}
return int(xValue.ToInteger()), int(yValue.ToInteger())
}
func stringOf(value goja.Value) string {
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
return ""
}
return value.String()
}