mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
feat: LTL operators, sampling, and default generators (#17)
* feat(ltl): add Now/Next/Eventually/Implies/Or/And/Not formulas Replace the fold-with-latch evaluator with a residual-formula reducer. Each Observe() instantiates a fresh obligation from the root (stripping an outer Always), reduces each pending obligation against current state, latches Violated on first failure, and surfaces Pending verdicts for deferred obligations. Existing Always/Pure/Thunk tests continue to pass. * feat(ltl): support relative duration for eventually().within() * feat(proto): add Swipe, PressKey, RecentLogs RPCs * feat(verifier,runner): formula handles, new action kinds, rich state - verifier: add formula-spec registry; bindNow/bindNext/bindEventually with chainable .implies/.or/.and/.not and .within(n,unit) on eventually; bindFrom for uniform sampling. bindAlways keeps accepting plain predicates. - verifier: store lastTree, lastAction, step time, logs, exceptions on the Verifier; SnapshotInput replaces the (snapshots, tree) pair. stateObject now produces state.lastAction/time/logs/exceptions matching the TS State type. - verifier: make taps/swipes/waitOnce/pressKey built-in generators actually fire; taps picks a clickable, enabled element from the last hierarchy. - agent: add exceptions field to Message wire format. - driver: add Swipe/PressKey/RecentLogs to Driver interface; wire maestro client and mock driver. LogEntry exposed for runner consumption. - runner: apply Swipe/PressKey/Wait actions; collect logcat and exceptions; pass lastAction and step time into PushSnapshot. * feat(spec-api): LTL operators, new actions, richer State - ltl.ts exports now/next/eventually; always overload accepts a Formula - types.ts: Formula gains implies/or/and/not; EventuallyFormula adds .within; State gains lastAction/time/logs/exceptions; Swipe/PressKey/Wait action types - actions.ts: Swipe/PressKey/Wait/from constructors; waitOnce + pressKey default generators - tests exercise the chaining, sampling, and new actions through a recorded fake runtime * feat(sidecar): add swipe, pressKey, recentLogs RPC handlers * feat(sdk-android): capture uncaught exceptions Install a default uncaught handler on Uatu.start, chained with any existing handler so Android's crash reporter still runs. Expose Uatu.reportError for callers to forward caught throwables. A bounded circular buffer (default 50) drains into each STATE message's new exceptions field. Protocol.kt serializes/deserializes the field, matching the Go wire format added to internal/agent/protocol.go. * feat(spec-api): add @uatu/spec/defaults/properties bundle * feat(sample-app): exercise new LTL operators + defaults spec.ts now imports eventually/next/now/from from @uatu/spec and noUncaughtExceptions from @uatu/spec/defaults/properties. It declares three properties that exercise the new surface: - accountCountNonNegative: plain always() safety - addAccountAdvances: always(now(x).implies(next(y))) - eventuallyLoggedIn: eventually(p).within(30, "seconds") - noUncaughtExceptions: imported default The weighted actions root uses from() for random phone/name sampling and entries for taps/swipes/waitOnce/pressKey built-ins. SampleApplication gains a debug hook gated on the system property uatu.inject_error so the e2e run can synthesize an Uatu.reportError and verify noUncaughtExceptions violates. cmd/uatu/test_run.go adds a subpath alias so specs importing "@uatu/spec/defaults/properties" resolve against the in-tree source when running from the uatu checkout. The spec-integration tests swap the old click-counter fixtures for the new login hierarchy. * feat(trace): record swipe/key/wait details + exceptions trace.Step gains an Exceptions array so the trace captures the class/message/stackTrace for each SDK-reported throwable in a step. trace.Action gains FromX/FromY/ToX/ToY/Key/DurationMillis so the full payload of Swipe/PressKey/Wait actions is visible in trace.jsonl. sample-app's debug error hook now gates on ApplicationInfo.DEBUGGABLE instead of a system property (adb setprop fails on non-rooted emulators).
This commit is contained in:
40 files changed
+2980
-262
No files matched your search
@@ -3,6 +3,7 @@ package verifier
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
|
||||
@@ -12,13 +13,23 @@ import (
|
||||
// Snapshots is the per-step extractor output forwarded by the SDK.
|
||||
type Snapshots map[string]json.RawMessage
|
||||
|
||||
// stateObject builds a JS-side `{ snapshots, ax }` matching the State type
|
||||
// from pkg/spec-api. ax is backed by the parsed uiautomator hierarchy when
|
||||
// one is provided.
|
||||
func stateObject(runtime *goja.Runtime, snapshots Snapshots, tree *hierarchy.Tree) (*goja.Object, error) {
|
||||
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 snapshots {
|
||||
for key, raw := range input.snapshots {
|
||||
value, err := jsonToJSValue(runtime, raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("snapshot %q: %w", key, err)
|
||||
@@ -30,7 +41,19 @@ func stateObject(runtime *goja.Runtime, snapshots Snapshots, tree *hierarchy.Tre
|
||||
if err := state.Set("snapshots", snapshotsObject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := state.Set("ax", accessibilityObject(runtime, tree)); err != nil {
|
||||
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
|
||||
@@ -88,6 +111,74 @@ func elementObject(runtime *goja.Runtime, element *hierarchy.Element, selector s
|
||||
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
|
||||
@@ -99,7 +190,7 @@ func jsonToJSValue(runtime *goja.Runtime, raw json.RawMessage) (goja.Value, erro
|
||||
return runtime.ToValue(generic), nil
|
||||
}
|
||||
|
||||
// jsValueToAction converts a JS-side {kind, on?, into?, text?} into a Go Action.
|
||||
// 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")
|
||||
@@ -120,11 +211,60 @@ func jsValueToAction(runtime *goja.Runtime, value goja.Value) (Action, error) {
|
||||
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 ""
|
||||
|
||||
Reference in new issue
Block a user