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:
pj authored and GitHub committed 2026-04-20 02:19:39 +07:00
1 parent 5b5594ee05
commit 7493945251
40 files changed
+2980 -262

No files matched your search

+4
View File
@@ -36,6 +36,10 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
aliases := map[string]string{}
if specApiPath := resolveSpecAPIPath(options.spec); specApiPath != "" {
aliases["@uatu/spec"] = specApiPath
// Also alias published subpath exports so specs importing from
// "@uatu/spec/defaults/properties" resolve to the in-tree source.
base := filepath.Dir(specApiPath)
aliases["@uatu/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts")
}
bundle, err := bundler.Bundle(bundler.Options{
EntryFile: options.spec,
@@ -1,6 +1,7 @@
package dev.uatu.sample
import android.app.Application
import android.content.pm.ApplicationInfo
import dev.uatu.sdk.Uatu
class SampleApplication : Application() {
@@ -21,5 +22,16 @@ class SampleApplication : Application() {
is Route.AddTransaction -> "add-transaction"
}
}
maybeInjectDebugError()
}
// Fires a synthetic Uatu.reportError in debug builds so the sample-app
// e2e run can verify noUncaughtExceptions surfaces SDK-captured errors
// in the trace. Production builds skip this.
private fun maybeInjectDebugError() {
val isDebuggable = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
if (isDebuggable) {
Uatu.reportError(RuntimeException("synthetic"))
}
}
}
+77 -37
View File
@@ -1,62 +1,102 @@
import {
extract,
always,
actions,
weighted,
Tap,
InputText,
taps,
Tap,
actions,
always,
eventually,
extract,
from,
next,
now,
pressKey,
swipes,
taps,
waitOnce,
weighted,
} from "@uatu/spec";
import { noUncaughtExceptions } from "@uatu/spec/defaults/properties";
// ── Snapshot extractors (fed by SampleApplication.kt) ──────────
// See ./android/src/main/kotlin/dev/uatu/sample/SampleApplication.kt
const clickCount = extract<number>(
(state) => (state.snapshots.click_count as number) ?? 0,
const loggedIn = extract<boolean>(
(state) => (state.snapshots.logged_in as boolean) ?? false,
);
const username = extract<string>(
(state) => (state.snapshots.username as string) ?? "",
const route = extract<string>(
(state) => (state.snapshots.route as string) ?? "",
);
const accountCount = extract<number>(
(state) => (state.snapshots.account_count as number) ?? 0,
);
// ── UI elements ────────────────────────────────────────────────
const clickButton = extract((state) => state.ax.find("text:Click me"));
const resetButton = extract((state) => state.ax.find("text:Reset"));
const usernameField = extract((state) => state.ax.find("desc:username_field"));
const phoneField = extract((state) => state.ax.find("desc:phone_field"));
const continueButton = extract((state) => state.ax.find("text:Continue"));
const addAccountButton = extract((state) => state.ax.find("text:Add account"));
const nameField = extract((state) => state.ax.find("desc:account_name"));
const createButton = extract((state) => state.ax.find("text:Create"));
// ── Properties ─────────────────────────────────────────────────
// accountCountNonNegative: the trivial safety property.
const accountCountNonNegative = always(() => accountCount.current >= 0);
// addAccountAdvances: once we land on add-account, the next step must be on
// a different screen. Exercises now(x).implies(next(y)).
const addAccountAdvances = always(
now(() => route.current === "add-account").implies(
next(() => route.current !== "add-account"),
),
);
// eventuallyLoggedIn: within 30 seconds of the run starting, we expect to
// reach home. Exercises eventually(p).within(n, unit).
const eventuallyLoggedIn = eventually(() => loggedIn.current).within(
30,
"seconds",
);
export const properties = {
clickCountNonNegative: always(() => clickCount.current >= 0),
clickCountNeverDecreases: always(() => {
const previous = clickCount.previous;
return previous === undefined || clickCount.current >= previous;
}),
usernameNeverShrinks: always(() => {
const previous = username.previous;
return previous === undefined || username.current.length >= previous.length;
}),
accountCountNonNegative,
addAccountAdvances,
eventuallyLoggedIn,
noUncaughtExceptions,
};
// ── Actions ────────────────────────────────────────────────────
const tapClickMe = actions(() => {
return clickButton.current ? [Tap({ on: clickButton.current })] : [];
// Sampling: random phone numbers for the login screen.
const phoneSampler = from(["+919876543210", "+15555550100", "+442071234567"]);
const typePhone = actions(() => {
const field = phoneField.current;
if (!field) return [];
return [InputText({ into: field, text: phoneSampler.generate() })];
});
const typeUsername = actions(() => {
return usernameField.current
? [InputText({ into: usernameField.current, text: "alice" })]
: [];
});
const tapContinue = actions(() =>
continueButton.current ? [Tap({ on: continueButton.current })] : [],
);
const tapAddAccount = actions(() =>
addAccountButton.current ? [Tap({ on: addAccountButton.current })] : [],
);
const tapReset = actions(() => {
return resetButton.current ? [Tap({ on: resetButton.current })] : [];
const nameSampler = from(["Alice", "Bob", "Charlie", "Dana"]);
const fillName = actions(() => {
const field = nameField.current;
if (!field) return [];
return [InputText({ into: field, text: nameSampler.generate() })];
});
const tapCreate = actions(() =>
createButton.current ? [Tap({ on: createButton.current })] : [],
);
export const actionsRoot = weighted(
[50, tapClickMe],
[20, typeUsername],
[30, tapReset],
[5, taps],
[2, swipes],
[30, typePhone],
[30, tapContinue],
[20, tapAddAccount],
[20, fillName],
[20, tapCreate],
[10, taps],
[5, swipes],
[5, waitOnce],
[5, pressKey],
);
(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot;
+10 -1
View File
@@ -34,7 +34,8 @@ type Message struct {
Platform string `json:"platform,omitempty"`
AppPackage string `json:"app_package,omitempty"`
Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
Exceptions []Exception `json:"exceptions,omitempty"`
Extractor string `json:"extractor,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
@@ -43,6 +44,14 @@ type Message struct {
Reason string `json:"reason,omitempty"`
}
// Exception mirrors an uncaught throwable captured by the SDK.
type Exception struct {
Class string `json:"class"`
Message string `json:"message,omitempty"`
StackTrace string `json:"stack_trace,omitempty"`
UnixMillis int64 `json:"unix_millis,omitempty"`
}
func Hello(version, platform, appPackage string) Message {
return Message{
Type: MessageTypeHello,
+12
View File
@@ -19,14 +19,26 @@ type Driver interface {
Tap(ctx context.Context, x, y int) error
TapSelector(ctx context.Context, selector string) error
InputText(ctx context.Context, text string) error
Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error
PressKey(ctx context.Context, key string) error
Hierarchy(ctx context.Context) (string, error)
Screenshot(ctx context.Context) (Image, error)
// RecentLogs returns logcat entries at or after `since`, filtered to
// `minLevel` or above. An empty minLevel defaults to "E".
RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]LogEntry, error)
WaitForIdle(ctx context.Context, duration time.Duration) error
Health(ctx context.Context) (Health, error)
}
type LogEntry struct {
UnixMillis int64
Level string
Tag string
Message string
}
type Image struct {
PNG []byte
Width int
+39
View File
@@ -77,6 +77,45 @@ func (c *Client) InputText(ctx context.Context, text string) error {
return err
}
func (c *Client) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
_, err := c.stub.Swipe(ctx, &driverpb.SwipeRequest{
From: &driverpb.Point{X: int32(fromX), Y: int32(fromY)},
To: &driverpb.Point{X: int32(toX), Y: int32(toY)},
DurationMillis: duration.Milliseconds(),
})
return err
}
func (c *Client) PressKey(ctx context.Context, key string) error {
_, err := c.stub.PressKey(ctx, &driverpb.PressKeyRequest{Key: key})
return err
}
func (c *Client) RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]driver.LogEntry, error) {
sinceMillis := int64(0)
if !since.IsZero() {
sinceMillis = since.UnixMilli()
}
response, err := c.stub.RecentLogs(ctx, &driverpb.RecentLogsRequest{
SinceUnixMillis: sinceMillis,
LevelAtLeast: minLevel,
})
if err != nil {
return nil, err
}
entries := response.GetEntries()
result := make([]driver.LogEntry, 0, len(entries))
for _, entry := range entries {
result = append(result, driver.LogEntry{
UnixMillis: entry.GetUnixMillis(),
Level: entry.GetLevel(),
Tag: entry.GetTag(),
Message: entry.GetMessage(),
})
}
return result, nil
}
func (c *Client) Hierarchy(ctx context.Context) (string, error) {
response, err := c.stub.Hierarchy(ctx, &driverpb.Empty{})
if err != nil {
+43
View File
@@ -16,8 +16,11 @@ const (
ActionTap ActionKind = "tap"
ActionTapSelector ActionKind = "tap_selector"
ActionInputText ActionKind = "input_text"
ActionSwipe ActionKind = "swipe"
ActionPressKey ActionKind = "press_key"
ActionHierarchy ActionKind = "hierarchy"
ActionScreenshot ActionKind = "screenshot"
ActionRecentLogs ActionKind = "recent_logs"
ActionWaitForIdle ActionKind = "wait_for_idle"
ActionHealth ActionKind = "health"
)
@@ -28,8 +31,14 @@ type Action struct {
LauncherActivity string
ClearState bool
X, Y int
FromX, FromY int
ToX, ToY int
Duration time.Duration
Selector string
Text string
Key string
LogLevel string
LogSince time.Time
Idle time.Duration
}
@@ -43,6 +52,7 @@ type Driver struct {
HierarchyJSON string
ImageData driver.Image
HealthInfo driver.Health
LogEntries []driver.LogEntry
Failures map[ActionKind]error
}
@@ -117,6 +127,39 @@ func (d *Driver) InputText(ctx context.Context, text string) error {
return nil
}
func (d *Driver) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
if err := d.failure(ActionSwipe); err != nil {
return err
}
d.record(Action{
Kind: ActionSwipe,
FromX: fromX,
FromY: fromY,
ToX: toX,
ToY: toY,
Duration: duration,
})
return nil
}
func (d *Driver) PressKey(ctx context.Context, key string) error {
if err := d.failure(ActionPressKey); err != nil {
return err
}
d.record(Action{Kind: ActionPressKey, Key: key})
return nil
}
func (d *Driver) RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]driver.LogEntry, error) {
if err := d.failure(ActionRecentLogs); err != nil {
return nil, err
}
d.record(Action{Kind: ActionRecentLogs, LogSince: since, LogLevel: minLevel})
d.mutex.Lock()
defer d.mutex.Unlock()
return append([]driver.LogEntry(nil), d.LogEntries...), nil
}
func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
if err := d.failure(ActionHierarchy); err != nil {
return "", err
+189 -21
View File
@@ -1,12 +1,16 @@
package ltl
import "fmt"
import (
"fmt"
"time"
)
type Verdict int
const (
VerdictHolds Verdict = iota
VerdictViolated
VerdictPending
)
func (v Verdict) String() string {
@@ -15,46 +19,210 @@ func (v Verdict) String() string {
return "holds"
case VerdictViolated:
return "violated"
case VerdictPending:
return "pending"
default:
return fmt.Sprintf("verdict(%d)", int(v))
}
}
// Evaluator folds a formula across observed steps. v0.1 semantics:
// Always(P) is satisfied if P held at every observed step; once P is false,
// the verdict latches to Violated.
// Evaluator reduces a formula across observed steps using residual-formula
// semantics. Each step either resolves pending obligations (to holds or
// violated) or carries them forward as residuals. Once a single obligation
// violates, the overall verdict latches to Violated.
type Evaluator struct {
formula Formula
root Formula
pending []Formula
violated bool
}
func NewEvaluator(formula Formula) *Evaluator {
return &Evaluator{formula: formula}
return &Evaluator{root: formula}
}
// Observe evaluates the formula against the current state and returns the
// running verdict. Once Violated, subsequent calls keep returning Violated
// regardless of what later observations look like.
// running verdict. Uses the real wall clock for deadline-bound operators;
// callers that need reproducible time should use ObserveAt.
func (e *Evaluator) Observe() Verdict {
return e.ObserveAt(time.Now())
}
// ObserveAt is like Observe but takes the current step time explicitly.
func (e *Evaluator) ObserveAt(now time.Time) Verdict {
if e.violated {
return VerdictViolated
}
if !holdsAtCurrentStep(e.formula) {
e.violated = true
return VerdictViolated
fresh := rootObligation(e.root)
obligations := append(e.pending, fresh)
e.pending = e.pending[:0]
for _, obligation := range obligations {
result := reduce(obligation, now)
switch result.status {
case statusHolds:
// drop
case statusViolated:
e.violated = true
e.pending = nil
return VerdictViolated
case statusPending:
e.pending = append(e.pending, result.formula)
}
}
if len(e.pending) > 0 {
return VerdictPending
}
return VerdictHolds
}
func holdsAtCurrentStep(formula Formula) bool {
switch concrete := formula.(type) {
case AlwaysFormula:
return holdsAtCurrentStep(concrete.Inner)
case PureFormula:
return concrete.Value
case ThunkFormula:
return concrete.Func()
default:
panic(fmt.Sprintf("ltl: unsupported formula type %T", formula))
// rootObligation returns the formula to instantiate at each step. An outer
// Always is stripped so its inner is re-evaluated every step; any other root
// formula is itself re-instantiated each step (matching the v0.1 semantics
// where a bare Thunk is re-observed on every call).
func rootObligation(root Formula) Formula {
if always, ok := root.(AlwaysFormula); ok {
return always.Inner
}
return root
}
type residualStatus int
const (
statusHolds residualStatus = iota
statusViolated
statusPending
)
type reduceResult struct {
status residualStatus
formula Formula
}
func holds() reduceResult { return reduceResult{status: statusHolds} }
func violated() reduceResult { return reduceResult{status: statusViolated} }
func pending(f Formula) reduceResult {
return reduceResult{status: statusPending, formula: f}
}
func reduce(formula Formula, now time.Time) reduceResult {
switch concrete := formula.(type) {
case PureFormula:
if concrete.Value {
return holds()
}
return violated()
case ThunkFormula:
if concrete.Func() {
return holds()
}
return violated()
case NowFormula:
return reduce(concrete.Inner, now)
case NextFormula:
// Next defers the inner obligation to the following step without
// evaluating it now.
return pending(concrete.Inner)
case EventuallyFormula:
// First-reduction deadline resolution: if the formula was built with
// a relative duration, fix the absolute deadline to (now + duration)
// so subsequent reductions compare against a stable value.
if !concrete.HasDeadline && concrete.Duration > 0 {
concrete.Deadline = now.Add(concrete.Duration)
concrete.HasDeadline = true
}
innerResult := reduce(concrete.Inner, now)
if innerResult.status == statusHolds {
return holds()
}
if concrete.HasStepBound && concrete.StepBound <= 1 {
return violated()
}
if concrete.HasDeadline && !now.Before(concrete.Deadline) {
return violated()
}
next := concrete
if concrete.HasStepBound {
next.StepBound = concrete.StepBound - 1
}
return pending(next)
case ImpliesFormula:
antecedent := reduce(concrete.Antecedent, now)
switch antecedent.status {
case statusHolds:
return reduce(concrete.Consequent, now)
case statusViolated:
return holds()
case statusPending:
return pending(ImpliesFormula{
Antecedent: antecedent.formula,
Consequent: concrete.Consequent,
})
}
case OrFormula:
left := reduce(concrete.Left, now)
right := reduce(concrete.Right, now)
if left.status == statusHolds || right.status == statusHolds {
return holds()
}
if left.status == statusViolated && right.status == statusViolated {
return violated()
}
if left.status == statusViolated {
return pending(right.formula)
}
if right.status == statusViolated {
return pending(left.formula)
}
return pending(OrFormula{Left: left.formula, Right: right.formula})
case AndFormula:
left := reduce(concrete.Left, now)
right := reduce(concrete.Right, now)
if left.status == statusViolated || right.status == statusViolated {
return violated()
}
if left.status == statusHolds && right.status == statusHolds {
return holds()
}
if left.status == statusHolds {
return pending(right.formula)
}
if right.status == statusHolds {
return pending(left.formula)
}
return pending(AndFormula{Left: left.formula, Right: right.formula})
case NotFormula:
inner := reduce(concrete.Inner, now)
switch inner.status {
case statusHolds:
return violated()
case statusViolated:
return holds()
case statusPending:
return pending(NotFormula{Inner: inner.formula})
}
case AlwaysFormula:
innerResult := reduce(concrete.Inner, now)
if innerResult.status == statusViolated {
return violated()
}
next := AlwaysFormula{Inner: concrete.Inner}
if innerResult.status == statusHolds {
return pending(next)
}
return pending(AndFormula{Left: innerResult.formula, Right: next})
}
panic(fmt.Sprintf("ltl: unsupported formula type %T", formula))
}
+2 -1
View File
@@ -3,6 +3,7 @@ package ltl
import (
"strings"
"testing"
"time"
)
func observe(formula Formula, count int) []Verdict {
@@ -119,5 +120,5 @@ func TestObserve_PanicsOnUnknownFormulaType(t *testing.T) {
t.Errorf("expected panic on unsupported formula type")
}
}()
holdsAtCurrentStep(unsupportedFormula{})
reduce(unsupportedFormula{}, time.Now())
}
+115 -7
View File
@@ -1,10 +1,12 @@
package ltl
import "fmt"
import (
"fmt"
"strings"
"time"
)
// Formula is the AST of a temporal logic property. v0.1 supports only Always
// over Pure/Thunk leaves; eventually, next, and bounded operators are
// deferred to v0.2+.
// Formula is the AST of a temporal logic property.
type Formula interface {
isFormula()
describe() string
@@ -22,19 +24,125 @@ type ThunkFormula struct {
Func func() bool
}
// NowFormula marks its inner formula for evaluation at the current step only.
// Primarily used so that now(...).implies(...) parses unambiguously.
type NowFormula struct {
Inner Formula
}
// NextFormula obliges its inner formula to hold at the next step (not this one).
type NextFormula struct {
Inner Formula
}
// EventuallyFormula obliges its inner formula to hold at some step within the
// given bound. An unbounded eventually never triggers a violation within a
// finite run.
//
// When Duration is non-zero and Deadline is the zero time, the evaluator
// resolves the absolute deadline on first reduction using the observation
// time. This matches the "within N seconds of obligation instantiation"
// semantics used by nested Always(Eventually(...).within(...)) formulas.
type EventuallyFormula struct {
Inner Formula
StepBound int
HasStepBound bool
Duration time.Duration
Deadline time.Time
HasDeadline bool
}
type ImpliesFormula struct {
Antecedent Formula
Consequent Formula
}
type OrFormula struct {
Left Formula
Right Formula
}
type AndFormula struct {
Left Formula
Right Formula
}
type NotFormula struct {
Inner Formula
}
func Always(inner Formula) Formula { return AlwaysFormula{Inner: inner} }
func Pure(value bool) Formula { return PureFormula{Value: value} }
func Thunk(function func() bool) Formula { return ThunkFormula{Func: function} }
func (AlwaysFormula) isFormula() {}
func (PureFormula) isFormula() {}
func (ThunkFormula) isFormula() {}
func Now(inner Formula) Formula { return NowFormula{Inner: inner} }
func Next(inner Formula) Formula { return NextFormula{Inner: inner} }
func Eventually(inner Formula) Formula { return EventuallyFormula{Inner: inner} }
func EventuallyWithinSteps(inner Formula, steps int) Formula {
return EventuallyFormula{Inner: inner, StepBound: steps, HasStepBound: true}
}
func EventuallyBefore(inner Formula, deadline time.Time) Formula {
return EventuallyFormula{Inner: inner, Deadline: deadline, HasDeadline: true}
}
func EventuallyWithin(inner Formula, duration time.Duration) Formula {
return EventuallyFormula{Inner: inner, Duration: duration}
}
func Implies(antecedent, consequent Formula) Formula {
return ImpliesFormula{Antecedent: antecedent, Consequent: consequent}
}
func Or(left, right Formula) Formula { return OrFormula{Left: left, Right: right} }
func And(left, right Formula) Formula { return AndFormula{Left: left, Right: right} }
func Not(inner Formula) Formula { return NotFormula{Inner: inner} }
func (AlwaysFormula) isFormula() {}
func (PureFormula) isFormula() {}
func (ThunkFormula) isFormula() {}
func (NowFormula) isFormula() {}
func (NextFormula) isFormula() {}
func (EventuallyFormula) isFormula() {}
func (ImpliesFormula) isFormula() {}
func (OrFormula) isFormula() {}
func (AndFormula) isFormula() {}
func (NotFormula) isFormula() {}
func (a AlwaysFormula) describe() string { return "Always(" + a.Inner.describe() + ")" }
func (p PureFormula) describe() string { return fmt.Sprintf("Pure(%t)", p.Value) }
func (ThunkFormula) describe() string { return "Thunk(...)" }
func (n NowFormula) describe() string { return "Now(" + n.Inner.describe() + ")" }
func (n NextFormula) describe() string { return "Next(" + n.Inner.describe() + ")" }
func (e EventuallyFormula) describe() string {
parts := []string{e.Inner.describe()}
if e.HasStepBound {
parts = append(parts, fmt.Sprintf("steps=%d", e.StepBound))
}
if e.HasDeadline {
parts = append(parts, "deadline="+e.Deadline.Format(time.RFC3339Nano))
} else if e.Duration > 0 {
parts = append(parts, "within="+e.Duration.String())
}
return "Eventually(" + strings.Join(parts, ", ") + ")"
}
func (i ImpliesFormula) describe() string {
return "Implies(" + i.Antecedent.describe() + ", " + i.Consequent.describe() + ")"
}
func (o OrFormula) describe() string {
return "Or(" + o.Left.describe() + ", " + o.Right.describe() + ")"
}
func (a AndFormula) describe() string {
return "And(" + a.Left.describe() + ", " + a.Right.describe() + ")"
}
func (n NotFormula) describe() string { return "Not(" + n.Inner.describe() + ")" }
// Describe returns a debug-friendly representation of the formula.
func Describe(formula Formula) string { return formula.describe() }
+193
View File
@@ -0,0 +1,193 @@
package ltl
import (
"strings"
"testing"
"time"
)
func TestDescribe_NowNextEventually(t *testing.T) {
now := Always(Now(Pure(true)))
if got := Describe(now); !strings.Contains(got, "Now") || !strings.Contains(got, "Always") {
t.Errorf("Describe(Always(Now(Pure(true)))) = %q", got)
}
next := Always(Next(Pure(false)))
if got := Describe(next); !strings.Contains(got, "Next") {
t.Errorf("Describe next = %q", got)
}
ev := Always(EventuallyWithinSteps(Pure(true), 3))
if got := Describe(ev); !strings.Contains(got, "Eventually") || !strings.Contains(got, "steps=3") {
t.Errorf("Describe eventually = %q", got)
}
}
func TestDescribe_ImpliesOrAndNot(t *testing.T) {
implies := Implies(Pure(true), Pure(false))
if got := Describe(implies); !strings.Contains(got, "Implies") {
t.Errorf("Describe implies = %q", got)
}
or := Or(Pure(true), Pure(false))
if got := Describe(or); !strings.Contains(got, "Or") {
t.Errorf("Describe or = %q", got)
}
and := And(Pure(true), Pure(false))
if got := Describe(and); !strings.Contains(got, "And") {
t.Errorf("Describe and = %q", got)
}
not := Not(Pure(true))
if got := Describe(not); !strings.Contains(got, "Not") {
t.Errorf("Describe not = %q", got)
}
}
func TestAlways_Now_ViolatesImmediately(t *testing.T) {
evaluator := NewEvaluator(Always(Now(Pure(false))))
if got := evaluator.Observe(); got != VerdictViolated {
t.Errorf("step 1: got %v, want violated", got)
}
}
func TestAlways_Next_PendingThenViolated(t *testing.T) {
y := true
evaluator := NewEvaluator(Always(Next(Thunk(func() bool { return y }))))
if got := evaluator.Observe(); got != VerdictPending {
t.Errorf("step 1: got %v, want pending", got)
}
y = false
if got := evaluator.Observe(); got != VerdictViolated {
t.Errorf("step 2: got %v, want violated", got)
}
}
func TestAlways_Next_StaysPendingWhileInnerHolds(t *testing.T) {
evaluator := NewEvaluator(Always(Next(Thunk(func() bool { return true }))))
for index := range 3 {
if got := evaluator.ObserveAt(time.Unix(int64(index), 0)); got != VerdictPending {
t.Errorf("step %d: got %v, want pending", index+1, got)
}
}
}
func TestAlways_NowImpliesEventuallyWithin_ViolatesWhenYLate(t *testing.T) {
// always(now(() => x).implies(eventually(() => y).within(3, "steps")))
// x = true only at step 1; y = true only at step 4.
xValues := []bool{true, false, false, false, false}
yValues := []bool{false, false, false, true, true}
step := 0
predX := Thunk(func() bool { return xValues[step] })
predY := Thunk(func() bool { return yValues[step] })
formula := Always(Implies(Now(predX), EventuallyWithinSteps(predY, 3)))
evaluator := NewEvaluator(formula)
verdicts := make([]Verdict, 0, 5)
for range 5 {
verdicts = append(verdicts, evaluator.Observe())
step++
}
// Step 1: X true, eventually(Y, 3) spawned pending. Pending.
// Step 2: pending eventually decrements (Y false). Pending.
// Step 3: eventually bound exhausted (Y still false). Violated.
if verdicts[0] != VerdictPending {
t.Errorf("step 1: got %v, want pending", verdicts[0])
}
if verdicts[1] != VerdictPending {
t.Errorf("step 2: got %v, want pending", verdicts[1])
}
if verdicts[2] != VerdictViolated {
t.Errorf("step 3: got %v, want violated", verdicts[2])
}
}
func TestAlways_NowImpliesEventuallyWithin_HoldsWhenYInBound(t *testing.T) {
// Same formula, y = true at step 3 (within the 3-step bound).
xValues := []bool{true, false, false}
yValues := []bool{false, false, true}
step := 0
predX := Thunk(func() bool { return xValues[step] })
predY := Thunk(func() bool { return yValues[step] })
formula := Always(Implies(Now(predX), EventuallyWithinSteps(predY, 3)))
evaluator := NewEvaluator(formula)
verdicts := make([]Verdict, 0, 3)
for range 3 {
verdicts = append(verdicts, evaluator.Observe())
step++
}
if verdicts[0] != VerdictPending {
t.Errorf("step 1: got %v, want pending", verdicts[0])
}
if verdicts[1] != VerdictPending {
t.Errorf("step 2: got %v, want pending", verdicts[1])
}
if verdicts[2] != VerdictHolds {
t.Errorf("step 3: got %v, want holds", verdicts[2])
}
}
func TestEventually_DeadlineViolation(t *testing.T) {
base := time.Unix(0, 0)
deadline := base.Add(1 * time.Second)
formula := Always(EventuallyBefore(Pure(false), deadline))
evaluator := NewEvaluator(formula)
// Well before deadline: pending.
if got := evaluator.ObserveAt(base.Add(100 * time.Millisecond)); got != VerdictPending {
t.Errorf("pre-deadline: got %v, want pending", got)
}
// At or past deadline: violated.
if got := evaluator.ObserveAt(base.Add(2 * time.Second)); got != VerdictViolated {
t.Errorf("post-deadline: got %v, want violated", got)
}
}
func TestEventually_RelativeDurationResolvesOnFirstReduce(t *testing.T) {
base := time.Unix(0, 0)
// One-shot Eventually (not wrapped in Always) with a 1s relative deadline.
evaluator := NewEvaluator(EventuallyWithin(Pure(false), 1*time.Second))
if got := evaluator.ObserveAt(base); got != VerdictPending {
t.Errorf("creation step: got %v, want pending", got)
}
if got := evaluator.ObserveAt(base.Add(500 * time.Millisecond)); got != VerdictPending {
t.Errorf("mid-window: got %v, want pending", got)
}
if got := evaluator.ObserveAt(base.Add(2 * time.Second)); got != VerdictViolated {
t.Errorf("past-window: got %v, want violated", got)
}
}
func TestOr_OneBranchHolds(t *testing.T) {
evaluator := NewEvaluator(Always(Or(Pure(false), Pure(true))))
if got := evaluator.Observe(); got != VerdictHolds {
t.Errorf("or(false,true): got %v, want holds", got)
}
}
func TestAnd_OneBranchViolatesLatches(t *testing.T) {
evaluator := NewEvaluator(Always(And(Pure(true), Pure(false))))
if got := evaluator.Observe(); got != VerdictViolated {
t.Errorf("and(true,false): got %v, want violated", got)
}
}
func TestNot_InvertsPure(t *testing.T) {
holds := NewEvaluator(Always(Not(Pure(false))))
if got := holds.Observe(); got != VerdictHolds {
t.Errorf("not(false): got %v, want holds", got)
}
violates := NewEvaluator(Always(Not(Pure(true))))
if got := violates.Observe(); got != VerdictViolated {
t.Errorf("not(true): got %v, want violated", got)
}
}
func TestVerdict_StringPending(t *testing.T) {
if got := VerdictPending.String(); got != "pending" {
t.Errorf("VerdictPending.String() = %q", got)
}
}
+110 -5
View File
@@ -56,6 +56,8 @@ func Run(ctx context.Context, options Options) (Summary, error) {
summary := Summary{StartTime: time.Now()}
deadline := summary.StartTime.Add(options.Duration)
stepIndex := 0
var lastAction *verifier.Action
var lastLogTime time.Time
for time.Now().Before(deadline) {
if err := ctx.Err(); err != nil {
break
@@ -82,7 +84,20 @@ func Run(ctx context.Context, options Options) (Summary, error) {
return summary, fmt.Errorf("step %d snapshot: %w", stepIndex, err)
}
if err := options.Verifier.PushSnapshot(verifier.Snapshots(snapshot.Snapshots), tree); err != nil {
logs := collectLogs(ctx, options.Driver, lastLogTime)
lastLogTime = stepStart
exceptions := decodeExceptions(snapshot)
if err := options.Verifier.PushSnapshot(verifier.SnapshotInput{
Snapshots: verifier.Snapshots(snapshot.Snapshots),
Tree: tree,
LastAction: lastAction,
StepTime: stepStart,
RunStart: summary.StartTime,
Logs: logs,
Exceptions: exceptions,
}); err != nil {
return summary, fmt.Errorf("step %d push: %w", stepIndex, err)
}
screen, screenErr := screenFromSnapshot(snapshot.Snapshots)
@@ -108,6 +123,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
Screen: screen,
Snapshots: snapshot.Snapshots,
Action: traceAction,
Exceptions: traceExceptions(exceptions),
Violations: violations,
}
if err := options.TraceWriter.WriteStep(step); err != nil {
@@ -129,6 +145,10 @@ func Run(ctx context.Context, options Options) (Summary, error) {
if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil {
return summary, fmt.Errorf("step %d apply: %w", stepIndex, err)
}
actionCopy := nextAction
lastAction = &actionCopy
} else {
lastAction = nil
}
idleCtx, idleCancel := context.WithTimeout(ctx, options.IdleTimeout)
@@ -222,11 +242,71 @@ func applyAction(ctx context.Context, drv driver.Driver, action verifier.Action,
}
}
return drv.InputText(ctx, action.Text)
case verifier.ActionKindSwipe:
duration := time.Duration(action.DurationMillis) * time.Millisecond
if duration <= 0 {
duration = 250 * time.Millisecond
}
return drv.Swipe(ctx, action.FromX, action.FromY, action.ToX, action.ToY, duration)
case verifier.ActionKindPressKey:
if action.Key == "" {
return nil
}
return drv.PressKey(ctx, action.Key)
case verifier.ActionKindWait:
duration := time.Duration(action.DurationMillis) * time.Millisecond
if duration <= 0 {
return nil
}
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
default:
return fmt.Errorf("unknown action kind %q", action.Kind)
}
}
// collectLogs pulls recent error-level log entries from the driver since the
// previous fetch. A failure is warned-on but not fatal: log capture is a
// best-effort observability channel, not a correctness dependency.
func collectLogs(ctx context.Context, drv driver.Driver, since time.Time) []verifier.LogEntry {
entries, err := drv.RecentLogs(ctx, since, "E")
if err != nil {
return nil
}
result := make([]verifier.LogEntry, 0, len(entries))
for _, entry := range entries {
result = append(result, verifier.LogEntry{
UnixMillis: entry.UnixMillis,
Level: entry.Level,
Tag: entry.Tag,
Message: entry.Message,
})
}
return result
}
func decodeExceptions(snapshot agent.Message) []verifier.Exception {
if len(snapshot.Exceptions) == 0 {
return nil
}
result := make([]verifier.Exception, 0, len(snapshot.Exceptions))
for _, e := range snapshot.Exceptions {
result = append(result, verifier.Exception{
Class: e.Class,
Message: e.Message,
StackTrace: e.StackTrace,
UnixMillis: e.UnixMillis,
})
}
return result
}
func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) {
if action.X > 0 && action.Y > 0 {
return action.X, action.Y, true
@@ -251,15 +331,40 @@ func fetchHierarchy(ctx context.Context, drv driver.Driver) (*hierarchy.Tree, er
}
func traceActionFor(action verifier.Action) *trace.Action {
traceAction := &trace.Action{Kind: string(action.Kind)}
traceAction := &trace.Action{Kind: string(action.Kind), X: action.X, Y: action.Y}
switch action.Kind {
case verifier.ActionKindTap:
// Selector lives in the trace step's action.text field for now —
// trace.Action only has X/Y/Text and we don't resolve coordinates
// at the runner layer.
traceAction.Text = action.On
case verifier.ActionKindInputText:
traceAction.Text = action.Text
case verifier.ActionKindSwipe:
traceAction.FromX = action.FromX
traceAction.FromY = action.FromY
traceAction.ToX = action.ToX
traceAction.ToY = action.ToY
traceAction.DurationMillis = action.DurationMillis
traceAction.X = 0
traceAction.Y = 0
case verifier.ActionKindPressKey:
traceAction.Key = action.Key
case verifier.ActionKindWait:
traceAction.DurationMillis = action.DurationMillis
}
return traceAction
}
func traceExceptions(exceptions []verifier.Exception) []trace.Exception {
if len(exceptions) == 0 {
return nil
}
result := make([]trace.Exception, 0, len(exceptions))
for _, e := range exceptions {
result = append(result, trace.Exception{
Class: e.Class,
Message: e.Message,
StackTrace: e.StackTrace,
UnixMillis: e.UnixMillis,
})
}
return result
}
+18 -4
View File
@@ -16,14 +16,28 @@ type Step struct {
Screen string `json:"screen,omitempty"`
Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
Action *Action `json:"action,omitempty"`
Exceptions []Exception `json:"exceptions,omitempty"`
Violations []string `json:"violations,omitempty"`
}
type Action struct {
Kind string `json:"kind"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
Text string `json:"text,omitempty"`
Kind string `json:"kind"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
FromX int `json:"from_x,omitempty"`
FromY int `json:"from_y,omitempty"`
ToX int `json:"to_x,omitempty"`
ToY int `json:"to_y,omitempty"`
Key string `json:"key,omitempty"`
Text string `json:"text,omitempty"`
DurationMillis int `json:"duration_millis,omitempty"`
}
type Exception struct {
Class string `json:"class"`
Message string `json:"message,omitempty"`
StackTrace string `json:"stack_trace,omitempty"`
UnixMillis int64 `json:"unix_millis,omitempty"`
}
type Meta struct {
+1 -1
View File
@@ -31,7 +31,7 @@ func TestStateAxFindWorks(t *testing.T) {
globalThis.count = __uatu__.extract(state => state.ax.findAll("id:select_language").length);
`)
if err := verifier.PushSnapshot(Snapshots{}, tree); err != nil {
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil {
t.Fatal(err)
}
+305 -13
View File
@@ -2,6 +2,7 @@ package verifier
import (
"fmt"
"time"
"github.com/dop251/goja"
)
@@ -11,6 +12,9 @@ type ActionKind string
const (
ActionKindTap ActionKind = "Tap"
ActionKindInputText ActionKind = "InputText"
ActionKindSwipe ActionKind = "Swipe"
ActionKindPressKey ActionKind = "PressKey"
ActionKindWait ActionKind = "Wait"
)
type Action struct {
@@ -21,6 +25,13 @@ type Action struct {
// Tap/InputText. Zero means the runner must resolve On against the
// current hierarchy.
X, Y int
// Swipe coordinates (raw px). Used only for ActionKindSwipe.
FromX, FromY int
ToX, ToY int
// DurationMillis is the Swipe gesture duration or the Wait duration.
DurationMillis int
// Key is the logical key name for ActionKindPressKey.
Key string
}
type extractorState struct {
@@ -32,15 +43,51 @@ type formulaState struct {
predicate goja.Callable
}
type specKind int
const (
tagFormula = "__uatuFormula"
tagActionGenerator = "__uatuActionGenerator"
tagInternalKind = "__uatuKind"
tagSelector = "__uatuSelector"
internalKindActions = "actions"
internalKindWeighted = "weighted"
internalKindBuiltinTaps = "taps"
internalKindBuiltinSwipes = "swipes"
specKindPure specKind = iota
specKindThunk
specKindNow
specKindNext
specKindEventually
specKindImplies
specKindOr
specKindAnd
specKindNot
specKindAlways
)
// formulaSpec is the Go-side registry entry that mirrors a chainable JS
// formula handle. Handles reference specs by index; chaining creates new
// specs that reference their operands' indices.
type formulaSpec struct {
kind specKind
pureValue bool
predicateIndex int
childA int
childB int
stepBound int
hasStepBound bool
duration time.Duration
}
const (
tagFormula = "__uatuFormula"
tagFormulaSpecIndex = "__uatuFormulaSpec"
tagActionGenerator = "__uatuActionGenerator"
tagInternalKind = "__uatuKind"
tagSelector = "__uatuSelector"
internalKindActions = "actions"
internalKindWeighted = "weighted"
internalKindBuiltinTaps = "taps"
internalKindBuiltinSwipes = "swipes"
internalKindBuiltinWaitOnce = "waitOnce"
internalKindBuiltinPressKey = "pressKey"
)
// installRuntimeBindings exposes globalThis.__uatu__ to the loaded spec.
@@ -53,24 +100,51 @@ func (v *Verifier) installRuntimeBindings() error {
if err := uatu.Set("always", v.bindAlways); err != nil {
return err
}
if err := uatu.Set("now", v.bindNow); err != nil {
return err
}
if err := uatu.Set("next", v.bindNext); err != nil {
return err
}
if err := uatu.Set("eventually", v.bindEventually); err != nil {
return err
}
if err := uatu.Set("actions", v.bindActions); err != nil {
return err
}
if err := uatu.Set("weighted", v.bindWeighted); err != nil {
return err
}
if err := uatu.Set("from", v.bindFrom); err != nil {
return err
}
if err := uatu.Set("tap", v.bindTap); err != nil {
return err
}
if err := uatu.Set("inputText", v.bindInputText); err != nil {
return err
}
if err := uatu.Set("swipe", v.bindSwipe); err != nil {
return err
}
if err := uatu.Set("pressKey", v.bindPressKey); err != nil {
return err
}
if err := uatu.Set("wait", v.bindWait); err != nil {
return err
}
if err := uatu.Set("taps", v.builtinGenerator(internalKindBuiltinTaps)); err != nil {
return err
}
if err := uatu.Set("swipes", v.builtinGenerator(internalKindBuiltinSwipes)); err != nil {
return err
}
if err := uatu.Set("waitOnce", v.builtinGenerator(internalKindBuiltinWaitOnce)); err != nil {
return err
}
if err := uatu.Set("pressKeys", v.builtinGenerator(internalKindBuiltinPressKey)); err != nil {
return err
}
return v.runtime.GlobalObject().Set("__uatu__", uatu)
}
@@ -92,25 +166,178 @@ func (v *Verifier) bindExtract(call goja.FunctionCall) goja.Value {
return handle
}
// bindAlways accepts either a predicate function (legacy shape) or a formula
// handle (new shape). Both produce a formula handle tagged with
// __uatuFormulaSpec.
func (v *Verifier) bindAlways(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 1 {
panic(v.runtime.NewTypeError("always requires exactly one argument"))
}
arg := call.Arguments[0]
if predicate, ok := goja.AssertFunction(arg); ok {
thunkIndex := v.registerThunk(predicate)
return v.makeFormulaHandle(specKindAlways, formulaSpec{
kind: specKindAlways,
childA: thunkIndex,
})
}
childIndex, ok := v.extractSpecIndex(arg)
if !ok {
panic(v.runtime.NewTypeError("always argument must be a predicate or formula"))
}
return v.makeFormulaHandle(specKindAlways, formulaSpec{
kind: specKindAlways,
childA: childIndex,
})
}
func (v *Verifier) bindNow(call goja.FunctionCall) goja.Value {
thunkIndex := v.requirePredicate(call, "now")
return v.makeFormulaHandle(specKindNow, formulaSpec{
kind: specKindNow,
childA: thunkIndex,
})
}
func (v *Verifier) bindNext(call goja.FunctionCall) goja.Value {
thunkIndex := v.requirePredicate(call, "next")
return v.makeFormulaHandle(specKindNext, formulaSpec{
kind: specKindNext,
childA: thunkIndex,
})
}
func (v *Verifier) bindEventually(call goja.FunctionCall) goja.Value {
thunkIndex := v.requirePredicate(call, "eventually")
return v.makeFormulaHandle(specKindEventually, formulaSpec{
kind: specKindEventually,
childA: thunkIndex,
})
}
func (v *Verifier) requirePredicate(call goja.FunctionCall, name string) int {
if len(call.Arguments) != 1 {
panic(v.runtime.NewTypeError(name + " requires exactly one argument"))
}
predicate, ok := goja.AssertFunction(call.Arguments[0])
if !ok {
panic(v.runtime.NewTypeError("always argument must be a function"))
panic(v.runtime.NewTypeError(name + " argument must be a function"))
}
return v.registerThunk(predicate)
}
formula := &formulaState{predicate: predicate}
v.formulas = append(v.formulas, formula)
formulaIndex := len(v.formulas) - 1
// registerThunk stores a predicate in v.formulas and returns its index, which
// reduce can later invoke via formulaThunk.
func (v *Verifier) registerThunk(predicate goja.Callable) int {
spec := formulaSpec{kind: specKindThunk}
// predicateIndex points into v.formulas, which is a parallel slice.
spec.predicateIndex = len(v.formulas)
v.formulas = append(v.formulas, &formulaState{predicate: predicate})
v.formulaSpecs = append(v.formulaSpecs, spec)
return len(v.formulaSpecs) - 1
}
// registerSpec appends a spec and returns its index.
func (v *Verifier) registerSpec(spec formulaSpec) int {
v.formulaSpecs = append(v.formulaSpecs, spec)
return len(v.formulaSpecs) - 1
}
// makeFormulaHandle registers the spec and returns a JS handle exposing
// chainable combinators. Eventually handles additionally expose .within.
func (v *Verifier) makeFormulaHandle(kind specKind, spec formulaSpec) *goja.Object {
index := v.registerSpec(spec)
return v.formulaHandle(kind, index)
}
func (v *Verifier) formulaHandle(kind specKind, index int) *goja.Object {
handle := v.runtime.NewObject()
_ = handle.Set(tagFormula, true)
_ = handle.Set("__uatuIndex", formulaIndex)
_ = handle.Set(tagFormulaSpecIndex, index)
// Keep __uatuIndex as an alias so older property shapes that read it keep
// working during backward-compat transitions.
_ = handle.Set("__uatuIndex", index)
_ = handle.Set("implies", v.binaryChain(index, specKindImplies))
_ = handle.Set("or", v.binaryChain(index, specKindOr))
_ = handle.Set("and", v.binaryChain(index, specKindAnd))
_ = handle.Set("not", v.unaryChain(index, specKindNot))
if kind == specKindEventually {
_ = handle.Set("within", v.eventuallyWithin(index))
}
return handle
}
func (v *Verifier) binaryChain(selfIndex int, kind specKind) func(call goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 1 {
panic(v.runtime.NewTypeError("operator requires exactly one argument"))
}
otherIndex, ok := v.extractSpecIndex(call.Arguments[0])
if !ok {
panic(v.runtime.NewTypeError("operator argument must be a formula"))
}
return v.makeFormulaHandle(kind, formulaSpec{
kind: kind,
childA: selfIndex,
childB: otherIndex,
})
}
}
func (v *Verifier) unaryChain(selfIndex int, kind specKind) func(call goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
return v.makeFormulaHandle(kind, formulaSpec{
kind: kind,
childA: selfIndex,
})
}
}
func (v *Verifier) eventuallyWithin(selfIndex int) func(call goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 2 {
panic(v.runtime.NewTypeError("within requires (amount, unit)"))
}
amount := call.Argument(0).ToInteger()
unit := call.Argument(1).String()
base := v.formulaSpecs[selfIndex]
if base.kind != specKindEventually {
panic(v.runtime.NewTypeError("within only applies to eventually"))
}
switch unit {
case "steps":
base.stepBound = int(amount)
base.hasStepBound = true
case "milliseconds":
base.duration = time.Duration(amount) * time.Millisecond
case "seconds":
base.duration = time.Duration(amount) * time.Second
default:
panic(v.runtime.NewTypeError("within unit must be 'milliseconds', 'seconds', or 'steps'"))
}
return v.makeFormulaHandle(specKindEventually, base)
}
}
// extractSpecIndex reads __uatuFormulaSpec from a JS formula handle.
func (v *Verifier) extractSpecIndex(value goja.Value) (int, bool) {
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
return 0, false
}
object := value.ToObject(v.runtime)
if object == nil {
return 0, false
}
indexValue := object.Get(tagFormulaSpecIndex)
if indexValue == nil || goja.IsUndefined(indexValue) {
return 0, false
}
return int(indexValue.ToInteger()), true
}
func (v *Verifier) bindActions(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 1 {
panic(v.runtime.NewTypeError("actions requires a single generator argument"))
@@ -143,6 +370,34 @@ func (v *Verifier) bindWeighted(call goja.FunctionCall) goja.Value {
return handle
}
// bindFrom returns a `{ generate }` that picks uniformly at random from the
// provided items using the verifier's seeded rng.
func (v *Verifier) bindFrom(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 1 {
panic(v.runtime.NewTypeError("from requires an array argument"))
}
itemsValue := call.Arguments[0]
itemsObject := itemsValue.ToObject(v.runtime)
if itemsObject == nil {
panic(v.runtime.NewTypeError("from argument must be an array"))
}
lengthValue := itemsObject.Get("length")
if lengthValue == nil {
panic(v.runtime.NewTypeError("from argument must be array-like"))
}
length := int(lengthValue.ToInteger())
handle := v.runtime.NewObject()
_ = handle.Set("generate", func(goja.FunctionCall) goja.Value {
if length == 0 {
return goja.Undefined()
}
index := v.rng.IntN(length)
return itemsObject.Get(fmt.Sprintf("%d", index))
})
return handle
}
func (v *Verifier) bindTap(call goja.FunctionCall) goja.Value {
parameters := call.Argument(0).ToObject(v.runtime)
if parameters == nil {
@@ -166,6 +421,43 @@ func (v *Verifier) bindInputText(call goja.FunctionCall) goja.Value {
return handle
}
func (v *Verifier) bindSwipe(call goja.FunctionCall) goja.Value {
parameters := call.Argument(0).ToObject(v.runtime)
if parameters == nil {
panic(v.runtime.NewTypeError("Swipe requires {from, to}"))
}
handle := v.runtime.NewObject()
_ = handle.Set("kind", "Swipe")
_ = handle.Set("from", parameters.Get("from"))
_ = handle.Set("to", parameters.Get("to"))
if duration := parameters.Get("durationMillis"); duration != nil && !goja.IsUndefined(duration) {
_ = handle.Set("durationMillis", duration)
}
return handle
}
func (v *Verifier) bindPressKey(call goja.FunctionCall) goja.Value {
parameters := call.Argument(0).ToObject(v.runtime)
if parameters == nil {
panic(v.runtime.NewTypeError("PressKey requires {key}"))
}
handle := v.runtime.NewObject()
_ = handle.Set("kind", "PressKey")
_ = handle.Set("key", parameters.Get("key"))
return handle
}
func (v *Verifier) bindWait(call goja.FunctionCall) goja.Value {
parameters := call.Argument(0).ToObject(v.runtime)
if parameters == nil {
panic(v.runtime.NewTypeError("Wait requires {durationMillis}"))
}
handle := v.runtime.NewObject()
_ = handle.Set("kind", "Wait")
_ = handle.Set("durationMillis", parameters.Get("durationMillis"))
return handle
}
func (v *Verifier) builtinGenerator(kind string) *goja.Object {
handle := v.runtime.NewObject()
_ = handle.Set(tagActionGenerator, true)
+147 -7
View File
@@ -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 ""
+55 -66
View File
@@ -14,10 +14,9 @@ const sampleAppHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.widget.LinearLayout" bounds="[64,96][1016,2336]">
<node index="0" class="android.widget.TextView" text="Clicks: 0" bounds="[100,200][900,300]" />
<node index="1" class="android.widget.Button" text="Click me" clickable="true" enabled="true" bounds="[400,800][680,920]" />
<node index="2" class="android.widget.TextView" text="Username: " bounds="[100,1000][900,1080]" />
<node index="3" class="android.widget.EditText" content-desc="username_field" clickable="true" enabled="true" bounds="[100,1200][900,1320]" />
<node index="0" class="android.widget.TextView" text="Sign in" bounds="[100,200][900,300]" />
<node index="1" class="android.widget.EditText" content-desc="phone_field" clickable="true" enabled="true" bounds="[100,400][900,520]" />
<node index="2" class="android.widget.Button" text="Continue" clickable="true" enabled="true" bounds="[400,800][680,920]" />
</node>
</node>
</hierarchy>`
@@ -34,9 +33,16 @@ func bundleSampleAppSpec(t *testing.T) string {
if err != nil {
t.Fatal(err)
}
defaultsPath, err := filepath.Abs("../../pkg/spec-api/src/defaults/properties.ts")
if err != nil {
t.Fatal(err)
}
bundle, err := bundler.Bundle(bundler.Options{
EntryFile: specPath,
Aliases: map[string]string{"@uatu/spec": apiPath},
Aliases: map[string]string{
"@uatu/spec": apiPath,
"@uatu/spec/defaults/properties": defaultsPath,
},
})
if err != nil {
t.Fatal(err)
@@ -44,9 +50,10 @@ func bundleSampleAppSpec(t *testing.T) string {
return string(bundle.JavaScript)
}
// TestSampleAppSpecTapsClickMe verifies the bundled sample-app spec emits a
// Tap on the "Click me" button when that button is present in the hierarchy.
func TestSampleAppSpecTapsClickMe(t *testing.T) {
// TestSampleAppSpecFiresLoginActions verifies the bundled sample-app spec
// emits Tap/InputText actions targeting the login screen elements when they
// are present in the hierarchy.
func TestSampleAppSpecFiresLoginActions(t *testing.T) {
v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil {
t.Fatal(err)
@@ -57,85 +64,67 @@ func TestSampleAppSpecTapsClickMe(t *testing.T) {
t.Fatal(err)
}
snapshots := Snapshots{
"app_state": json.RawMessage(`"running"`),
"click_count": json.RawMessage(`0`),
"route": json.RawMessage(`"login"`),
"logged_in": json.RawMessage(`false`),
"account_count": json.RawMessage(`0`),
}
if err := v.PushSnapshot(snapshots, tree); err != nil {
if err := v.PushSnapshot(SnapshotInput{Snapshots: snapshots, Tree: tree}); err != nil {
t.Fatal(err)
}
tapHits := 0
inputHits := 0
tapContinueHits := 0
typePhoneHits := 0
for range 400 {
action, err := v.NextAction()
if err != nil {
continue
}
switch {
case action.Kind == ActionKindTap && action.On == "text:Click me":
tapHits++
case action.Kind == ActionKindInputText && action.On == "desc:username_field" && action.Text == "alice":
inputHits++
case action.Kind == ActionKindTap && action.On == "text:Continue":
tapContinueHits++
case action.Kind == ActionKindInputText && action.On == "desc:phone_field":
typePhoneHits++
}
}
if tapHits == 0 {
t.Fatal("tapClickMe never fired on sample-app hierarchy")
if tapContinueHits == 0 {
t.Fatal("tapContinue never fired on sample-app hierarchy")
}
if inputHits == 0 {
t.Fatal("typeUsername never fired on sample-app hierarchy")
if typePhoneHits == 0 {
t.Fatal("typePhone never fired on sample-app hierarchy")
}
}
// TestSampleAppSpecPropertiesHold checks the three properties declared in the
// sample-app spec evaluate correctly across a realistic snapshot sequence.
func TestSampleAppSpecPropertiesHold(t *testing.T) {
// TestSampleAppSpecPropertiesEvaluate checks the properties declared in the
// sample-app spec evaluate sensibly across a small snapshot sequence. The
// spec mixes safety and liveness properties; Pending verdicts are expected
// for liveness properties that haven't had time to resolve yet.
func TestSampleAppSpecPropertiesEvaluate(t *testing.T) {
v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil {
t.Fatal(err)
}
steps := []struct {
appState string
clickCount int
username string
want map[string]ltl.Verdict
}{
{"running", 0, "", map[string]ltl.Verdict{
"appIsRunning": ltl.VerdictHolds,
"clickCountNonNegative": ltl.VerdictHolds,
"clickCountNeverDecreases": ltl.VerdictHolds,
"usernameNeverShrinks": ltl.VerdictHolds,
}},
{"running", 5, "alice", map[string]ltl.Verdict{
"appIsRunning": ltl.VerdictHolds,
"clickCountNonNegative": ltl.VerdictHolds,
"clickCountNeverDecreases": ltl.VerdictHolds,
"usernameNeverShrinks": ltl.VerdictHolds,
}},
{"running", 3, "al", map[string]ltl.Verdict{
"appIsRunning": ltl.VerdictHolds,
"clickCountNonNegative": ltl.VerdictHolds,
"clickCountNeverDecreases": ltl.VerdictViolated,
"usernameNeverShrinks": ltl.VerdictViolated,
}},
tree, err := hierarchy.Parse(sampleAppHierarchyXML)
if err != nil {
t.Fatal(err)
}
for index, step := range steps {
stateRaw, _ := json.Marshal(step.appState)
countRaw, _ := json.Marshal(step.clickCount)
usernameRaw, _ := json.Marshal(step.username)
if err := v.PushSnapshot(Snapshots{
"app_state": stateRaw,
"click_count": countRaw,
"username": usernameRaw,
}, nil); err != nil {
t.Fatalf("step %d: %v", index, err)
}
got := v.EvaluateProperties()
for property, want := range step.want {
if got[property] != want {
t.Errorf("step %d %q: got %v, want %v", index, property, got[property], want)
}
}
snapshots := Snapshots{
"route": json.RawMessage(`"login"`),
"logged_in": json.RawMessage(`false`),
"account_count": json.RawMessage(`0`),
}
if err := v.PushSnapshot(SnapshotInput{Snapshots: snapshots, Tree: tree}); err != nil {
t.Fatal(err)
}
verdicts := v.EvaluateProperties()
if verdicts["accountCountNonNegative"] != ltl.VerdictHolds {
t.Errorf("accountCountNonNegative: got %v, want holds", verdicts["accountCountNonNegative"])
}
if verdicts["noUncaughtExceptions"] != ltl.VerdictHolds {
t.Errorf("noUncaughtExceptions: got %v, want holds", verdicts["noUncaughtExceptions"])
}
// Liveness: eventuallyLoggedIn hasn't resolved yet.
if verdicts["eventuallyLoggedIn"] != ltl.VerdictPending {
t.Errorf("eventuallyLoggedIn: got %v, want pending", verdicts["eventuallyLoggedIn"])
}
}
+9 -9
View File
@@ -60,10 +60,10 @@ func TestPushSnapshot_UpdatesExtractorCurrentAndPrevious(t *testing.T) {
verifier := newVerifier(t)
mustLoad(t, verifier, helloSpec)
if err := verifier.PushSnapshot(Snapshots{
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{
"screen": json.RawMessage(`"customer_ledger"`),
"ledger.balance": json.RawMessage(`1500`),
}, nil); err != nil {
}}); err != nil {
t.Fatal(err)
}
@@ -78,7 +78,7 @@ func TestPushSnapshot_UpdatesExtractorCurrentAndPrevious(t *testing.T) {
}
// Push again: previous should mirror the prior current.
if err := verifier.PushSnapshot(Snapshots{"ledger.balance": json.RawMessage(`2000`)}, nil); err != nil {
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": json.RawMessage(`2000`)}}); err != nil {
t.Fatal(err)
}
balanceValue = verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime)
@@ -105,7 +105,7 @@ func TestEvaluateProperties_HoldsThenViolates(t *testing.T) {
}
for index, testCase := range cases {
raw, _ := json.Marshal(testCase.balance)
if err := verifier.PushSnapshot(Snapshots{"ledger.balance": raw}, nil); err != nil {
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"ledger.balance": raw}}); err != nil {
t.Fatal(err)
}
verdicts := verifier.EvaluateProperties()
@@ -118,7 +118,7 @@ func TestEvaluateProperties_HoldsThenViolates(t *testing.T) {
func TestNextAction_FromActionsGenerator(t *testing.T) {
verifier := newVerifier(t)
mustLoad(t, verifier, helloSpec)
_ = verifier.PushSnapshot(Snapshots{}, nil)
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
action, err := verifier.NextAction()
if err != nil {
@@ -142,7 +142,7 @@ func TestNextAction_WeightedSelectsByWeight(t *testing.T) {
[99, tapAway],
);
`)
_ = verifier.PushSnapshot(Snapshots{}, nil)
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
awayCount := 0
homeCount := 0
@@ -168,7 +168,7 @@ func TestNextAction_EmptyGeneratorReturnsErrNoAction(t *testing.T) {
mustLoad(t, verifier, `
globalThis.actions = __uatu__.actions(() => []);
`)
_ = verifier.PushSnapshot(Snapshots{}, nil)
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
_, err := verifier.NextAction()
if !errors.Is(err, ErrNoAction) {
@@ -183,7 +183,7 @@ func TestInputText_RoundTrip(t *testing.T) {
__uatu__.inputText({ into: "id:phone", text: "+919876543210" }),
]);
`)
_ = verifier.PushSnapshot(Snapshots{}, nil)
_ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
action, err := verifier.NextAction()
if err != nil {
@@ -202,7 +202,7 @@ func TestPushSnapshot_FeedsSnapshotsToExtractorState(t *testing.T) {
mustLoad(t, verifier, `
globalThis.captured = __uatu__.extract(state => state.snapshots["k"]);
`)
if err := verifier.PushSnapshot(Snapshots{"k": json.RawMessage(`"hello"`)}, nil); err != nil {
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"k": json.RawMessage(`"hello"`)}}); err != nil {
t.Fatal(err)
}
value := verifier.runtime.GlobalObject().Get("captured").ToObject(verifier.runtime).Get("current")
+253 -15
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"math/rand/v2"
"time"
"github.com/dop251/goja"
@@ -12,15 +13,23 @@ import (
)
type Verifier struct {
runtime *goja.Runtime
extractors []*extractorState
formulas []*formulaState
runtime *goja.Runtime
extractors []*extractorState
formulas []*formulaState
formulaSpecs []formulaSpec
properties map[string]int // property name -> formula index
properties map[string]int // property name -> formula-spec index
actionGenerator goja.Value
evaluators map[string]*ltl.Evaluator
lastTree *hierarchy.Tree
lastAction *Action
lastLogs []LogEntry
lastExceptions []Exception
stepTime time.Time
runStart time.Time
rng *rand.Rand
}
@@ -62,13 +71,16 @@ func (v *Verifier) Load(source string) error {
if handle == nil {
return fmt.Errorf("property %q is not an object", name)
}
indexValue := handle.Get("__uatuIndex")
if indexValue == nil {
specIndex, ok := v.extractSpecIndex(handle)
if !ok {
return fmt.Errorf("property %q was not produced by always()", name)
}
index := int(indexValue.ToInteger())
v.properties[name] = index
v.evaluators[name] = ltl.NewEvaluator(ltl.Always(ltl.Thunk(v.formulaThunk(index))))
formula, err := v.buildFormula(specIndex)
if err != nil {
return fmt.Errorf("property %q: %w", name, err)
}
v.properties[name] = specIndex
v.evaluators[name] = ltl.NewEvaluator(formula)
}
}
@@ -79,11 +91,125 @@ func (v *Verifier) Load(source string) error {
return nil
}
// buildFormula walks the formula-spec registry and produces a Go ltl.Formula
// tree rooted at the given spec index. Specs built at the top level are
// always wrapped in Always unless the top-level spec is already an Always.
func (v *Verifier) buildFormula(rootIndex int) (ltl.Formula, error) {
inner, err := v.buildFormulaNode(rootIndex)
if err != nil {
return nil, err
}
if _, ok := inner.(ltl.AlwaysFormula); ok {
return inner, nil
}
return ltl.Always(inner), nil
}
func (v *Verifier) buildFormulaNode(index int) (ltl.Formula, error) {
if index < 0 || index >= len(v.formulaSpecs) {
return nil, fmt.Errorf("formula spec index %d out of range", index)
}
spec := v.formulaSpecs[index]
switch spec.kind {
case specKindPure:
return ltl.Pure(spec.pureValue), nil
case specKindThunk:
return ltl.Thunk(v.formulaThunk(spec.predicateIndex)), nil
case specKindNow:
child, err := v.buildFormulaNode(spec.childA)
if err != nil {
return nil, err
}
return ltl.Now(child), nil
case specKindNext:
child, err := v.buildFormulaNode(spec.childA)
if err != nil {
return nil, err
}
return ltl.Next(child), nil
case specKindEventually:
child, err := v.buildFormulaNode(spec.childA)
if err != nil {
return nil, err
}
formula := ltl.EventuallyFormula{Inner: child}
if spec.hasStepBound {
formula.StepBound = spec.stepBound
formula.HasStepBound = true
}
if spec.duration > 0 {
formula.Duration = spec.duration
}
return formula, nil
case specKindImplies:
left, err := v.buildFormulaNode(spec.childA)
if err != nil {
return nil, err
}
right, err := v.buildFormulaNode(spec.childB)
if err != nil {
return nil, err
}
return ltl.Implies(left, right), nil
case specKindOr:
left, err := v.buildFormulaNode(spec.childA)
if err != nil {
return nil, err
}
right, err := v.buildFormulaNode(spec.childB)
if err != nil {
return nil, err
}
return ltl.Or(left, right), nil
case specKindAnd:
left, err := v.buildFormulaNode(spec.childA)
if err != nil {
return nil, err
}
right, err := v.buildFormulaNode(spec.childB)
if err != nil {
return nil, err
}
return ltl.And(left, right), nil
case specKindNot:
child, err := v.buildFormulaNode(spec.childA)
if err != nil {
return nil, err
}
return ltl.Not(child), nil
case specKindAlways:
child, err := v.buildFormulaNode(spec.childA)
if err != nil {
return nil, err
}
return ltl.Always(child), nil
default:
return nil, fmt.Errorf("unknown formula spec kind %d", spec.kind)
}
}
// PushSnapshot updates the JS-side state and refreshes every extractor's
// current/previous values in registration order. Passing a nil tree is
// allowed and yields an empty ax scope.
func (v *Verifier) PushSnapshot(snapshots Snapshots, tree *hierarchy.Tree) error {
state, err := stateObject(v.runtime, snapshots, tree)
func (v *Verifier) PushSnapshot(input SnapshotInput) error {
v.lastTree = input.Tree
v.lastAction = input.LastAction
v.lastLogs = input.Logs
v.lastExceptions = input.Exceptions
v.stepTime = input.StepTime
if v.runStart.IsZero() {
v.runStart = input.RunStart
}
state, err := stateObject(v.runtime, stateInput{
snapshots: input.Snapshots,
tree: input.Tree,
lastAction: input.LastAction,
stepTime: input.StepTime,
runStart: v.runStart,
logs: input.Logs,
exceptions: input.Exceptions,
})
if err != nil {
return fmt.Errorf("build state: %w", err)
}
@@ -102,12 +228,47 @@ func (v *Verifier) PushSnapshot(snapshots Snapshots, tree *hierarchy.Tree) error
return nil
}
// SnapshotInput bundles everything a step feeds into the verifier. Fields
// other than Snapshots are optional; callers that only have snapshots can
// populate Snapshots alone and leave the rest zero.
type SnapshotInput struct {
Snapshots Snapshots
Tree *hierarchy.Tree
LastAction *Action
StepTime time.Time
RunStart time.Time
Logs []LogEntry
Exceptions []Exception
}
// LogEntry mirrors a logcat line captured between steps.
type LogEntry struct {
UnixMillis int64
Level string
Tag string
Message string
}
// Exception mirrors an SDK-captured uncaught throwable.
type Exception struct {
Class string
Message string
StackTrace string
UnixMillis int64
}
// EvaluateProperties returns each registered property's running verdict
// after the most recent PushSnapshot.
// after the most recent PushSnapshot. The step time passed in PushSnapshot is
// forwarded to each evaluator so deadline-bound operators see the snapshot's
// wall clock rather than time.Now().
func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict {
verdicts := map[string]ltl.Verdict{}
stepTime := v.stepTime
if stepTime.IsZero() {
stepTime = time.Now()
}
for name, evaluator := range v.evaluators {
verdicts[name] = evaluator.Observe()
verdicts[name] = evaluator.ObserveAt(stepTime)
}
return verdicts
}
@@ -178,13 +339,90 @@ func (v *Verifier) resolveGenerator(generator goja.Value) (Action, error) {
return Action{}, err
}
return v.resolveGenerator(picked)
case internalKindBuiltinTaps, internalKindBuiltinSwipes:
return Action{}, ErrNoAction
case internalKindBuiltinTaps:
return v.generateRandomTap()
case internalKindBuiltinSwipes:
return v.generateRandomSwipe()
case internalKindBuiltinWaitOnce:
return Action{Kind: ActionKindWait, DurationMillis: 500}, nil
case internalKindBuiltinPressKey:
return v.generateRandomPressKey()
default:
return Action{}, fmt.Errorf("unknown generator kind %q", kindValue.String())
}
}
// generateRandomTap picks a visible, tappable element from the last
// hierarchy snapshot and returns a Tap action targeting its center.
func (v *Verifier) generateRandomTap() (Action, error) {
if v.lastTree == nil {
return Action{}, ErrNoAction
}
candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements))
for _, element := range v.lastTree.Elements {
if !element.Clickable || !element.Enabled {
continue
}
if element.Bounds.Right-element.Bounds.Left <= 0 || element.Bounds.Bottom-element.Bounds.Top <= 0 {
continue
}
candidates = append(candidates, element)
}
if len(candidates) == 0 {
return Action{}, ErrNoAction
}
picked := candidates[v.rng.IntN(len(candidates))]
x, y := picked.Bounds.Center()
return Action{Kind: ActionKindTap, X: x, Y: y}, nil
}
// generateRandomSwipe emits a swipe over a random enabled element or the
// whole screen, in a random direction. Returns ErrNoAction only when we have
// no tree to size a gesture off of.
func (v *Verifier) generateRandomSwipe() (Action, error) {
if v.lastTree == nil || len(v.lastTree.Elements) == 0 {
return Action{}, ErrNoAction
}
element := v.lastTree.Elements[v.rng.IntN(len(v.lastTree.Elements))]
cx, cy := element.Bounds.Center()
if cx <= 0 || cy <= 0 {
return Action{}, ErrNoAction
}
// Pick a direction: 0=up 1=down 2=left 3=right; magnitude 200-600 px.
magnitude := 200 + v.rng.IntN(401)
toX, toY := cx, cy
switch v.rng.IntN(4) {
case 0:
toY = cy - magnitude
case 1:
toY = cy + magnitude
case 2:
toX = cx - magnitude
case 3:
toX = cx + magnitude
}
if toX < 0 {
toX = 0
}
if toY < 0 {
toY = 0
}
return Action{
Kind: ActionKindSwipe,
FromX: cx,
FromY: cy,
ToX: toX,
ToY: toY,
DurationMillis: 250,
}, nil
}
func (v *Verifier) generateRandomPressKey() (Action, error) {
// Keep exploration gentle: only "back" for now. Home/menu would navigate
// away from the app under test.
return Action{Kind: ActionKindPressKey, Key: "back"}, nil
}
func (v *Verifier) pickFromResult(result goja.Value) (Action, error) {
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return Action{}, ErrNoAction
+4
View File
@@ -9,6 +9,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./defaults/properties": {
"types": "./dist/defaults/properties.d.ts",
"import": "./dist/defaults/properties.js"
}
},
"files": [
+45 -11
View File
@@ -3,7 +3,13 @@ import type {
Action,
ActionGenerator,
InputTextAction,
Key,
Point,
PressKeyAction,
Sampler,
SwipeAction,
TapAction,
WaitAction,
WeightedEntry,
} from "./types.ts";
@@ -15,22 +21,50 @@ export function weighted(...entries: WeightedEntry[]): ActionGenerator {
return globalThis.__uatu__.weighted(...entries);
}
export function from<T>(items: readonly T[]): Sampler<T> {
return globalThis.__uatu__.from(items);
}
export function Tap(parameters: { on: string | AccessibilityElement }): TapAction {
return globalThis.__uatu__.tap(parameters);
}
export function InputText(parameters: { into: string | AccessibilityElement; text: string }): InputTextAction {
export function InputText(parameters: {
into: string | AccessibilityElement;
text: string;
}): InputTextAction {
return globalThis.__uatu__.inputText(parameters);
}
export const taps: ActionGenerator = new Proxy({} as ActionGenerator, {
get(_target, property) {
return (globalThis.__uatu__.taps as unknown as Record<string | symbol, unknown>)[property];
},
});
export function Swipe(parameters: {
from: Point | AccessibilityElement;
to: Point | AccessibilityElement;
durationMillis?: number;
}): SwipeAction {
return globalThis.__uatu__.swipe(parameters);
}
export const swipes: ActionGenerator = new Proxy({} as ActionGenerator, {
get(_target, property) {
return (globalThis.__uatu__.swipes as unknown as Record<string | symbol, unknown>)[property];
},
});
export function PressKey(parameters: { key: Key }): PressKeyAction {
return globalThis.__uatu__.pressKey(parameters);
}
export function Wait(parameters: { durationMillis: number }): WaitAction {
return globalThis.__uatu__.wait(parameters);
}
function builtinGenerator(name: "taps" | "swipes" | "waitOnce" | "pressKeys"): ActionGenerator {
return new Proxy({} as ActionGenerator, {
get(_target, property) {
const runtime = globalThis.__uatu__[name] as unknown as Record<
string | symbol,
unknown
>;
return runtime[property];
},
});
}
export const taps: ActionGenerator = builtinGenerator("taps");
export const swipes: ActionGenerator = builtinGenerator("swipes");
export const waitOnce: ActionGenerator = builtinGenerator("waitOnce");
export const pressKey: ActionGenerator = builtinGenerator("pressKeys");
+21
View File
@@ -0,0 +1,21 @@
import { always } from "../ltl.ts";
import { extract } from "../extract.ts";
import type { Formula } from "../types.ts";
const exceptionCount = extract<number>((state) => state.exceptions.length);
// Fails when the SDK captured an uncaught throwable or a Uatu.reportError
// call surfaced one during the run.
export const noUncaughtExceptions: Formula = always(
() => exceptionCount.current === 0,
);
const errorLogCount = extract<number>(
(state) => state.logs.reduce((count, log) => count + (log.level === "E" ? 1 : 0), 0),
);
// Fails when the runner's logcat fetch observed any error-level lines since
// the previous step.
export const noLogcatErrors: Formula = always(
() => errorLogCount.current === 0,
);
+25 -2
View File
@@ -3,15 +3,38 @@ export type {
AccessibilityTree,
Action,
ActionGenerator,
EventuallyFormula,
ExceptionRecord,
Extracted,
Formula,
InputTextAction,
Key,
LogEntry,
Point,
PressKeyAction,
Sampler,
Snapshots,
State,
SwipeAction,
TapAction,
UatuRuntime,
WaitAction,
WeightedEntry,
} from "./types.ts";
export { extract } from "./extract.ts";
export { always } from "./ltl.ts";
export { Tap, InputText, actions, weighted, taps, swipes } from "./actions.ts";
export { always, eventually, next, now } from "./ltl.ts";
export {
InputText,
PressKey,
Swipe,
Tap,
Wait,
actions,
from,
pressKey,
swipes,
taps,
waitOnce,
weighted,
} from "./actions.ts";
+18 -3
View File
@@ -1,5 +1,20 @@
import type { Formula } from "./types.ts";
import type { EventuallyFormula, Formula } from "./types.ts";
export function always(predicate: () => boolean): Formula {
return globalThis.__uatu__.always(predicate);
export function always(predicateOrFormula: (() => boolean) | Formula): Formula {
return globalThis.__uatu__.always(predicateOrFormula);
}
export function now(predicate: () => boolean): Formula {
return globalThis.__uatu__.now(predicate);
}
export function next(predicate: () => boolean): Formula {
return globalThis.__uatu__.next(predicate);
}
// An unbounded `eventually` never forces a violation within a finite run —
// prefer `.within(n, unit)` when you want the verifier to fail a property
// that stalls.
export function eventually(predicate: () => boolean): EventuallyFormula {
return globalThis.__uatu__.eventually(predicate);
}
+84 -4
View File
@@ -4,6 +4,8 @@ export interface AccessibilityElement {
id?: string;
text?: string;
bounds?: { left: number; top: number; right: number; bottom: number };
x?: number;
y?: number;
}
export interface AccessibilityTree {
@@ -11,9 +13,27 @@ export interface AccessibilityTree {
findAll(selector: string): AccessibilityElement[];
}
export interface LogEntry {
unixMillis: number;
level: string;
tag: string;
message: string;
}
export interface ExceptionRecord {
class: string;
message: string;
stackTrace: string;
unixMillis?: number;
}
export interface State {
snapshots: Snapshots;
ax: AccessibilityTree;
lastAction: Action | null;
time: number;
logs: readonly LogEntry[];
exceptions: readonly ExceptionRecord[];
}
export interface Extracted<T> {
@@ -21,9 +41,41 @@ export interface Extracted<T> {
readonly previous: T | undefined;
}
export interface Point {
x: number;
y: number;
}
export type TapAction = { kind: "Tap"; on: string | AccessibilityElement };
export type InputTextAction = { kind: "InputText"; into: string | AccessibilityElement; text: string };
export type Action = TapAction | InputTextAction;
export type InputTextAction = {
kind: "InputText";
into: string | AccessibilityElement;
text: string;
};
export type SwipeAction = {
kind: "Swipe";
from: Point | AccessibilityElement;
to: Point | AccessibilityElement;
durationMillis?: number;
};
export type PressKeyAction = { kind: "PressKey"; key: Key };
export type WaitAction = { kind: "Wait"; durationMillis: number };
export type Action =
| TapAction
| InputTextAction
| SwipeAction
| PressKeyAction
| WaitAction;
export type Key =
| "back"
| "home"
| "enter"
| "tab"
| "up"
| "down"
| "left"
| "right";
export interface ActionGenerator {
readonly __uatuActionGenerator: true;
@@ -32,17 +84,45 @@ export interface ActionGenerator {
export interface Formula {
readonly __uatuFormula: true;
implies(other: Formula): Formula;
or(other: Formula): Formula;
and(other: Formula): Formula;
not(): Formula;
}
export interface EventuallyFormula extends Formula {
within(amount: number, unit: "milliseconds" | "seconds" | "steps"): Formula;
}
export interface Sampler<T> {
generate(): T;
}
export interface UatuRuntime {
extract: <T>(getter: (state: State) => T) => Extracted<T>;
always: (predicate: () => boolean) => Formula;
always: (predicateOrFormula: (() => boolean) | Formula) => Formula;
now: (predicate: () => boolean) => Formula;
next: (predicate: () => boolean) => Formula;
eventually: (predicate: () => boolean) => EventuallyFormula;
actions: (generator: () => Action[]) => ActionGenerator;
weighted: (...entries: WeightedEntry[]) => ActionGenerator;
from: <T>(items: readonly T[]) => Sampler<T>;
tap: (parameters: { on: string | AccessibilityElement }) => TapAction;
inputText: (parameters: { into: string | AccessibilityElement; text: string }) => InputTextAction;
inputText: (parameters: {
into: string | AccessibilityElement;
text: string;
}) => InputTextAction;
swipe: (parameters: {
from: Point | AccessibilityElement;
to: Point | AccessibilityElement;
durationMillis?: number;
}) => SwipeAction;
pressKey: (parameters: { key: Key }) => PressKeyAction;
wait: (parameters: { durationMillis: number }) => WaitAction;
taps: ActionGenerator;
swipes: ActionGenerator;
waitOnce: ActionGenerator;
pressKeys: ActionGenerator;
}
export type WeightedEntry = readonly [number, ActionGenerator];
+188 -15
View File
@@ -1,38 +1,127 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { Tap, InputText, actions, always, extract, swipes, taps, weighted } from "../src/index.ts";
import {
InputText,
PressKey,
Swipe,
Tap,
Wait,
actions,
always,
eventually,
extract,
from,
next,
now,
pressKey,
swipes,
taps,
waitOnce,
weighted,
} from "../src/index.ts";
import type {
AccessibilityElement,
Action,
ActionGenerator,
EventuallyFormula,
Extracted,
Formula,
Sampler,
State,
UatuRuntime,
WeightedEntry,
} from "../src/types.ts";
function installFakeRuntime(): UatuRuntime & {
interface RecordedRuntime extends UatuRuntime {
extracts: Array<(state: State) => unknown>;
alwaysPredicates: Array<() => boolean>;
alwaysArgs: Array<(() => boolean) | Formula>;
nowPredicates: Array<() => boolean>;
nextPredicates: Array<() => boolean>;
eventuallyPredicates: Array<() => boolean>;
withinCalls: Array<{ amount: number; unit: string }>;
impliesCalls: number;
orCalls: number;
andCalls: number;
notCalls: number;
actionGenerators: Array<() => Action[]>;
weightedCalls: WeightedEntry[][];
} {
fromCalls: unknown[][];
}
function makeChainableFormula(record: RecordedRuntime): Formula {
const formula: Formula = {
__uatuFormula: true,
implies(other: Formula): Formula {
record.impliesCalls++;
void other;
return makeChainableFormula(record);
},
or(other: Formula): Formula {
record.orCalls++;
void other;
return makeChainableFormula(record);
},
and(other: Formula): Formula {
record.andCalls++;
void other;
return makeChainableFormula(record);
},
not(): Formula {
record.notCalls++;
return makeChainableFormula(record);
},
};
return formula;
}
function makeChainableEventually(record: RecordedRuntime): EventuallyFormula {
const base = makeChainableFormula(record);
return {
...base,
within(amount, unit) {
record.withinCalls.push({ amount, unit });
return makeChainableFormula(record);
},
};
}
function installFakeRuntime(): RecordedRuntime {
const calls = {
extracts: [] as Array<(state: State) => unknown>,
alwaysPredicates: [] as Array<() => boolean>,
alwaysArgs: [] as Array<(() => boolean) | Formula>,
nowPredicates: [] as Array<() => boolean>,
nextPredicates: [] as Array<() => boolean>,
eventuallyPredicates: [] as Array<() => boolean>,
withinCalls: [] as Array<{ amount: number; unit: string }>,
impliesCalls: 0,
orCalls: 0,
andCalls: 0,
notCalls: 0,
actionGenerators: [] as Array<() => Action[]>,
weightedCalls: [] as WeightedEntry[][],
fromCalls: [] as unknown[][],
};
const runtime: UatuRuntime = {
const runtime = {
extract: <T>(getter: (state: State) => T): Extracted<T> => {
calls.extracts.push(getter as (state: State) => unknown);
return { current: undefined as unknown as T, previous: undefined };
},
always: (predicate: () => boolean): Formula => {
calls.alwaysPredicates.push(predicate);
return { __uatuFormula: true };
always: (predicateOrFormula: (() => boolean) | Formula): Formula => {
calls.alwaysArgs.push(predicateOrFormula);
return makeChainableFormula(recorded);
},
now: (predicate: () => boolean): Formula => {
calls.nowPredicates.push(predicate);
return makeChainableFormula(recorded);
},
next: (predicate: () => boolean): Formula => {
calls.nextPredicates.push(predicate);
return makeChainableFormula(recorded);
},
eventually: (predicate: () => boolean): EventuallyFormula => {
calls.eventuallyPredicates.push(predicate);
return makeChainableEventually(recorded);
},
actions: (generator: () => Action[]): ActionGenerator => {
calls.actionGenerators.push(generator);
@@ -42,13 +131,28 @@ function installFakeRuntime(): UatuRuntime & {
calls.weightedCalls.push(entries);
return { __uatuActionGenerator: true, generate: () => [] };
},
from: <T>(items: readonly T[]): Sampler<T> => {
calls.fromCalls.push(items as unknown[]);
return { generate: () => items[0] as T };
},
tap: ({ on }) => ({ kind: "Tap", on }),
inputText: ({ into, text }) => ({ kind: "InputText", into, text }),
swipe: ({ from: fromPoint, to, durationMillis }) => ({
kind: "Swipe",
from: fromPoint,
to,
durationMillis,
}),
pressKey: ({ key }) => ({ kind: "PressKey", key }),
wait: ({ durationMillis }) => ({ kind: "Wait", durationMillis }),
taps: { __uatuActionGenerator: true, generate: () => [] },
swipes: { __uatuActionGenerator: true, generate: () => [] },
};
globalThis.__uatu__ = runtime;
return Object.assign(runtime, calls);
waitOnce: { __uatuActionGenerator: true, generate: () => [] },
pressKeys: { __uatuActionGenerator: true, generate: () => [] },
} satisfies UatuRuntime;
const recorded = Object.assign(runtime, calls) as RecordedRuntime;
globalThis.__uatu__ = recorded;
return recorded;
}
test("extract forwards the getter to the runtime", () => {
@@ -63,10 +167,48 @@ test("always wraps a predicate into a formula via the runtime", () => {
const runtime = installFakeRuntime();
const predicate = () => true;
const formula = always(predicate);
assert.equal(runtime.alwaysPredicates[0], predicate);
assert.equal(runtime.alwaysArgs[0], predicate);
assert.equal(formula.__uatuFormula, true);
});
test("always accepts a formula handle", () => {
const runtime = installFakeRuntime();
const inner = now(() => true);
const wrapped = always(inner);
assert.equal(runtime.alwaysArgs.at(-1), inner);
assert.equal(wrapped.__uatuFormula, true);
});
test("now/next/eventually forward predicates", () => {
const runtime = installFakeRuntime();
const p1 = () => true;
const p2 = () => false;
const p3 = () => true;
now(p1);
next(p2);
eventually(p3);
assert.equal(runtime.nowPredicates[0], p1);
assert.equal(runtime.nextPredicates[0], p2);
assert.equal(runtime.eventuallyPredicates[0], p3);
});
test("eventually().within forwards unit and amount", () => {
const runtime = installFakeRuntime();
eventually(() => true).within(3, "seconds");
assert.deepEqual(runtime.withinCalls[0], { amount: 3, unit: "seconds" });
});
test("formula chaining exposes implies/or/and/not", () => {
const runtime = installFakeRuntime();
const a = now(() => true);
const b = now(() => false);
a.implies(b).or(b).and(b).not();
assert.equal(runtime.impliesCalls, 1);
assert.equal(runtime.orCalls, 1);
assert.equal(runtime.andCalls, 1);
assert.equal(runtime.notCalls, 1);
});
test("Tap returns a TapAction with the supplied selector", () => {
installFakeRuntime();
const action = Tap({ on: "id:login_continue" });
@@ -87,6 +229,29 @@ test("InputText returns an InputTextAction", () => {
assert.deepEqual(action, { kind: "InputText", into: "id:phone", text: "+1234567890" });
});
test("Swipe returns a SwipeAction with the supplied endpoints", () => {
installFakeRuntime();
const action = Swipe({ from: { x: 10, y: 20 }, to: { x: 30, y: 40 }, durationMillis: 400 });
assert.deepEqual(action, {
kind: "Swipe",
from: { x: 10, y: 20 },
to: { x: 30, y: 40 },
durationMillis: 400,
});
});
test("PressKey returns a PressKeyAction", () => {
installFakeRuntime();
const action = PressKey({ key: "back" });
assert.deepEqual(action, { kind: "PressKey", key: "back" });
});
test("Wait returns a WaitAction", () => {
installFakeRuntime();
const action = Wait({ durationMillis: 500 });
assert.deepEqual(action, { kind: "Wait", durationMillis: 500 });
});
test("actions wraps a generator into the runtime's ActionGenerator", () => {
const runtime = installFakeRuntime();
const generator = () => [Tap({ on: "id:x" })];
@@ -105,9 +270,17 @@ test("weighted forwards weighted entries to the runtime", () => {
assert.deepEqual(runtime.weightedCalls[0], entries);
});
test("taps and swipes proxy through to the runtime defaults", () => {
test("from forwards items to the runtime", () => {
const runtime = installFakeRuntime();
const sampler = from(["a", "b", "c"]);
assert.deepEqual(runtime.fromCalls[0], ["a", "b", "c"]);
assert.equal(sampler.generate(), "a");
});
test("default generators proxy through to the runtime", () => {
installFakeRuntime();
assert.equal(taps.__uatuActionGenerator, true);
assert.equal(swipes.__uatuActionGenerator, true);
assert.equal(typeof taps.generate, "function");
assert.equal(waitOnce.__uatuActionGenerator, true);
assert.equal(pressKey.__uatuActionGenerator, true);
});
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type {
Action,
ActionGenerator,
EventuallyFormula,
Extracted,
Formula,
Sampler,
State,
UatuRuntime,
WeightedEntry,
} from "../src/types.ts";
interface RecordedRuntime extends UatuRuntime {
currentState: State;
extractors: Array<(state: State) => unknown>;
alwaysArgs: Array<(() => boolean) | Formula>;
lastPredicate: (() => boolean) | undefined;
}
function installRuntime(initialState: State): RecordedRuntime {
const extractors: Array<(state: State) => unknown> = [];
const extracted: Array<{ value: unknown }> = [];
const alwaysArgs: Array<(() => boolean) | Formula> = [];
let lastPredicate: (() => boolean) | undefined;
const runtime = {
extract: <T>(getter: (state: State) => T): Extracted<T> => {
extractors.push(getter as (state: State) => unknown);
const slot = { value: getter(state.currentState) };
extracted.push(slot);
return {
get current(): T {
return slot.value as T;
},
previous: undefined,
};
},
always: (predicateOrFormula: (() => boolean) | Formula): Formula => {
alwaysArgs.push(predicateOrFormula);
if (typeof predicateOrFormula === "function") {
lastPredicate = predicateOrFormula;
}
return { __uatuFormula: true } as Formula;
},
now: () => ({ __uatuFormula: true } as Formula),
next: () => ({ __uatuFormula: true } as Formula),
eventually: () => ({ __uatuFormula: true } as EventuallyFormula),
actions: (generator: () => Action[]): ActionGenerator => ({
__uatuActionGenerator: true,
generate: generator,
}),
weighted: (..._entries: WeightedEntry[]): ActionGenerator => ({
__uatuActionGenerator: true,
generate: () => [],
}),
from: <T>(_items: readonly T[]): Sampler<T> => ({ generate: () => _items[0] as T }),
tap: ({ on }) => ({ kind: "Tap", on }),
inputText: ({ into, text }) => ({ kind: "InputText", into, text }),
swipe: (p) => ({ kind: "Swipe", from: p.from, to: p.to, durationMillis: p.durationMillis }),
pressKey: ({ key }) => ({ kind: "PressKey", key }),
wait: ({ durationMillis }) => ({ kind: "Wait", durationMillis }),
taps: { __uatuActionGenerator: true, generate: () => [] } as ActionGenerator,
swipes: { __uatuActionGenerator: true, generate: () => [] } as ActionGenerator,
waitOnce: { __uatuActionGenerator: true, generate: () => [] } as ActionGenerator,
pressKeys: { __uatuActionGenerator: true, generate: () => [] } as ActionGenerator,
} satisfies UatuRuntime;
const state = { currentState: initialState };
const recorded = Object.assign(runtime, {
currentState: initialState,
extractors,
alwaysArgs,
get lastPredicate() {
return lastPredicate;
},
}) as unknown as RecordedRuntime;
globalThis.__uatu__ = recorded;
// Re-bind state ref so subsequent extract() calls read the up-to-date state.
Object.defineProperty(recorded, "currentState", {
get() {
return state.currentState;
},
set(next: State) {
state.currentState = next;
},
});
return recorded;
}
const emptyState: State = {
snapshots: {},
ax: { find: () => undefined, findAll: () => [] },
lastAction: null,
time: 0,
logs: [],
exceptions: [],
};
test("defaults bundle exports formulas tagged as LTL properties", async () => {
installRuntime({
...emptyState,
logs: [{ unixMillis: 1, level: "W", tag: "X", message: "warn" }],
});
const defaults = await import("../src/defaults/properties.ts");
assert.equal(defaults.noUncaughtExceptions.__uatuFormula, true);
assert.equal(defaults.noLogcatErrors.__uatuFormula, true);
});
+346 -37
View File
@@ -468,6 +468,279 @@ func (x *HealthStatus) GetPlatform() string {
return ""
}
type SwipeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
From *Point `protobuf:"bytes,1,opt,name=from,proto3" json:"from,omitempty"`
To *Point `protobuf:"bytes,2,opt,name=to,proto3" json:"to,omitempty"`
DurationMillis int64 `protobuf:"varint,3,opt,name=duration_millis,json=durationMillis,proto3" json:"duration_millis,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SwipeRequest) Reset() {
*x = SwipeRequest{}
mi := &file_driverpb_driver_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SwipeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SwipeRequest) ProtoMessage() {}
func (x *SwipeRequest) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SwipeRequest.ProtoReflect.Descriptor instead.
func (*SwipeRequest) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{9}
}
func (x *SwipeRequest) GetFrom() *Point {
if x != nil {
return x.From
}
return nil
}
func (x *SwipeRequest) GetTo() *Point {
if x != nil {
return x.To
}
return nil
}
func (x *SwipeRequest) GetDurationMillis() int64 {
if x != nil {
return x.DurationMillis
}
return 0
}
type PressKeyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Logical key name. Supported: "back", "home", "enter", "tab", "up",
// "down", "left", "right".
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PressKeyRequest) Reset() {
*x = PressKeyRequest{}
mi := &file_driverpb_driver_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PressKeyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PressKeyRequest) ProtoMessage() {}
func (x *PressKeyRequest) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PressKeyRequest.ProtoReflect.Descriptor instead.
func (*PressKeyRequest) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{10}
}
func (x *PressKeyRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
type RecentLogsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Lower bound as unix-epoch milliseconds; entries at or after this are
// returned.
SinceUnixMillis int64 `protobuf:"varint,1,opt,name=since_unix_millis,json=sinceUnixMillis,proto3" json:"since_unix_millis,omitempty"`
// Minimum level: "E", "W", or "I". Empty defaults to "E".
LevelAtLeast string `protobuf:"bytes,2,opt,name=level_at_least,json=levelAtLeast,proto3" json:"level_at_least,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RecentLogsRequest) Reset() {
*x = RecentLogsRequest{}
mi := &file_driverpb_driver_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RecentLogsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RecentLogsRequest) ProtoMessage() {}
func (x *RecentLogsRequest) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RecentLogsRequest.ProtoReflect.Descriptor instead.
func (*RecentLogsRequest) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{11}
}
func (x *RecentLogsRequest) GetSinceUnixMillis() int64 {
if x != nil {
return x.SinceUnixMillis
}
return 0
}
func (x *RecentLogsRequest) GetLevelAtLeast() string {
if x != nil {
return x.LevelAtLeast
}
return ""
}
type LogEntries struct {
state protoimpl.MessageState `protogen:"open.v1"`
Entries []*LogEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LogEntries) Reset() {
*x = LogEntries{}
mi := &file_driverpb_driver_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LogEntries) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LogEntries) ProtoMessage() {}
func (x *LogEntries) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LogEntries.ProtoReflect.Descriptor instead.
func (*LogEntries) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{12}
}
func (x *LogEntries) GetEntries() []*LogEntry {
if x != nil {
return x.Entries
}
return nil
}
type LogEntry struct {
state protoimpl.MessageState `protogen:"open.v1"`
UnixMillis int64 `protobuf:"varint,1,opt,name=unix_millis,json=unixMillis,proto3" json:"unix_millis,omitempty"`
Level string `protobuf:"bytes,2,opt,name=level,proto3" json:"level,omitempty"`
Tag string `protobuf:"bytes,3,opt,name=tag,proto3" json:"tag,omitempty"`
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LogEntry) Reset() {
*x = LogEntry{}
mi := &file_driverpb_driver_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LogEntry) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LogEntry) ProtoMessage() {}
func (x *LogEntry) ProtoReflect() protoreflect.Message {
mi := &file_driverpb_driver_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LogEntry.ProtoReflect.Descriptor instead.
func (*LogEntry) Descriptor() ([]byte, []int) {
return file_driverpb_driver_proto_rawDescGZIP(), []int{13}
}
func (x *LogEntry) GetUnixMillis() int64 {
if x != nil {
return x.UnixMillis
}
return 0
}
func (x *LogEntry) GetLevel() string {
if x != nil {
return x.Level
}
return ""
}
func (x *LogEntry) GetTag() string {
if x != nil {
return x.Tag
}
return ""
}
func (x *LogEntry) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
var File_driverpb_driver_proto protoreflect.FileDescriptor
const file_driverpb_driver_proto_rawDesc = "" +
@@ -497,16 +770,38 @@ const file_driverpb_driver_proto_rawDesc = "" +
"\fHealthStatus\x12\x14\n" +
"\x05ready\x18\x01 \x01(\bR\x05ready\x12\x18\n" +
"\aversion\x18\x02 \x01(\tR\aversion\x12\x1a\n" +
"\bplatform\x18\x03 \x01(\tR\bplatform2\xb0\x04\n" +
"\bplatform\x18\x03 \x01(\tR\bplatform\"\x89\x01\n" +
"\fSwipeRequest\x12)\n" +
"\x04from\x18\x01 \x01(\v2\x15.uatu.driver.v1.PointR\x04from\x12%\n" +
"\x02to\x18\x02 \x01(\v2\x15.uatu.driver.v1.PointR\x02to\x12'\n" +
"\x0fduration_millis\x18\x03 \x01(\x03R\x0edurationMillis\"#\n" +
"\x0fPressKeyRequest\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\"e\n" +
"\x11RecentLogsRequest\x12*\n" +
"\x11since_unix_millis\x18\x01 \x01(\x03R\x0fsinceUnixMillis\x12$\n" +
"\x0elevel_at_least\x18\x02 \x01(\tR\flevelAtLeast\"@\n" +
"\n" +
"LogEntries\x122\n" +
"\aentries\x18\x01 \x03(\v2\x18.uatu.driver.v1.LogEntryR\aentries\"m\n" +
"\bLogEntry\x12\x1f\n" +
"\vunix_millis\x18\x01 \x01(\x03R\n" +
"unixMillis\x12\x14\n" +
"\x05level\x18\x02 \x01(\tR\x05level\x12\x10\n" +
"\x03tag\x18\x03 \x01(\tR\x03tag\x12\x18\n" +
"\amessage\x18\x04 \x01(\tR\amessage2\xff\x05\n" +
"\x06Driver\x12>\n" +
"\x06Launch\x12\x1d.uatu.driver.v1.LaunchRequest\x1a\x15.uatu.driver.v1.Empty\x129\n" +
"\tTerminate\x12\x15.uatu.driver.v1.Empty\x1a\x15.uatu.driver.v1.Empty\x123\n" +
"\x03Tap\x12\x15.uatu.driver.v1.Point\x1a\x15.uatu.driver.v1.Empty\x12>\n" +
"\vTapSelector\x12\x18.uatu.driver.v1.Selector\x1a\x15.uatu.driver.v1.Empty\x128\n" +
"\tInputText\x12\x14.uatu.driver.v1.Text\x1a\x15.uatu.driver.v1.Empty\x12:\n" +
"\tInputText\x12\x14.uatu.driver.v1.Text\x1a\x15.uatu.driver.v1.Empty\x12<\n" +
"\x05Swipe\x12\x1c.uatu.driver.v1.SwipeRequest\x1a\x15.uatu.driver.v1.Empty\x12B\n" +
"\bPressKey\x12\x1f.uatu.driver.v1.PressKeyRequest\x1a\x15.uatu.driver.v1.Empty\x12:\n" +
"\n" +
"Screenshot\x12\x15.uatu.driver.v1.Empty\x1a\x15.uatu.driver.v1.Image\x12A\n" +
"\tHierarchy\x12\x15.uatu.driver.v1.Empty\x1a\x1d.uatu.driver.v1.HierarchyJSON\x12>\n" +
"\tHierarchy\x12\x15.uatu.driver.v1.Empty\x1a\x1d.uatu.driver.v1.HierarchyJSON\x12K\n" +
"\n" +
"RecentLogs\x12!.uatu.driver.v1.RecentLogsRequest\x1a\x1a.uatu.driver.v1.LogEntries\x12>\n" +
"\vWaitForIdle\x12\x18.uatu.driver.v1.Duration\x1a\x15.uatu.driver.v1.Empty\x12=\n" +
"\x06Health\x12\x15.uatu.driver.v1.Empty\x1a\x1c.uatu.driver.v1.HealthStatusBM\n" +
"\x12dev.uatu.driver.v1P\x01Z5github.com/priyanshujain/uatu/proto/driverpb;driverpbb\x06proto3"
@@ -523,42 +818,56 @@ func file_driverpb_driver_proto_rawDescGZIP() []byte {
return file_driverpb_driver_proto_rawDescData
}
var file_driverpb_driver_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_driverpb_driver_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
var file_driverpb_driver_proto_goTypes = []any{
(*Empty)(nil), // 0: uatu.driver.v1.Empty
(*LaunchRequest)(nil), // 1: uatu.driver.v1.LaunchRequest
(*Point)(nil), // 2: uatu.driver.v1.Point
(*Text)(nil), // 3: uatu.driver.v1.Text
(*Selector)(nil), // 4: uatu.driver.v1.Selector
(*Image)(nil), // 5: uatu.driver.v1.Image
(*HierarchyJSON)(nil), // 6: uatu.driver.v1.HierarchyJSON
(*Duration)(nil), // 7: uatu.driver.v1.Duration
(*HealthStatus)(nil), // 8: uatu.driver.v1.HealthStatus
(*Empty)(nil), // 0: uatu.driver.v1.Empty
(*LaunchRequest)(nil), // 1: uatu.driver.v1.LaunchRequest
(*Point)(nil), // 2: uatu.driver.v1.Point
(*Text)(nil), // 3: uatu.driver.v1.Text
(*Selector)(nil), // 4: uatu.driver.v1.Selector
(*Image)(nil), // 5: uatu.driver.v1.Image
(*HierarchyJSON)(nil), // 6: uatu.driver.v1.HierarchyJSON
(*Duration)(nil), // 7: uatu.driver.v1.Duration
(*HealthStatus)(nil), // 8: uatu.driver.v1.HealthStatus
(*SwipeRequest)(nil), // 9: uatu.driver.v1.SwipeRequest
(*PressKeyRequest)(nil), // 10: uatu.driver.v1.PressKeyRequest
(*RecentLogsRequest)(nil), // 11: uatu.driver.v1.RecentLogsRequest
(*LogEntries)(nil), // 12: uatu.driver.v1.LogEntries
(*LogEntry)(nil), // 13: uatu.driver.v1.LogEntry
}
var file_driverpb_driver_proto_depIdxs = []int32{
1, // 0: uatu.driver.v1.Driver.Launch:input_type -> uatu.driver.v1.LaunchRequest
0, // 1: uatu.driver.v1.Driver.Terminate:input_type -> uatu.driver.v1.Empty
2, // 2: uatu.driver.v1.Driver.Tap:input_type -> uatu.driver.v1.Point
4, // 3: uatu.driver.v1.Driver.TapSelector:input_type -> uatu.driver.v1.Selector
3, // 4: uatu.driver.v1.Driver.InputText:input_type -> uatu.driver.v1.Text
0, // 5: uatu.driver.v1.Driver.Screenshot:input_type -> uatu.driver.v1.Empty
0, // 6: uatu.driver.v1.Driver.Hierarchy:input_type -> uatu.driver.v1.Empty
7, // 7: uatu.driver.v1.Driver.WaitForIdle:input_type -> uatu.driver.v1.Duration
0, // 8: uatu.driver.v1.Driver.Health:input_type -> uatu.driver.v1.Empty
0, // 9: uatu.driver.v1.Driver.Launch:output_type -> uatu.driver.v1.Empty
0, // 10: uatu.driver.v1.Driver.Terminate:output_type -> uatu.driver.v1.Empty
0, // 11: uatu.driver.v1.Driver.Tap:output_type -> uatu.driver.v1.Empty
0, // 12: uatu.driver.v1.Driver.TapSelector:output_type -> uatu.driver.v1.Empty
0, // 13: uatu.driver.v1.Driver.InputText:output_type -> uatu.driver.v1.Empty
5, // 14: uatu.driver.v1.Driver.Screenshot:output_type -> uatu.driver.v1.Image
6, // 15: uatu.driver.v1.Driver.Hierarchy:output_type -> uatu.driver.v1.HierarchyJSON
0, // 16: uatu.driver.v1.Driver.WaitForIdle:output_type -> uatu.driver.v1.Empty
8, // 17: uatu.driver.v1.Driver.Health:output_type -> uatu.driver.v1.HealthStatus
9, // [9:18] is the sub-list for method output_type
0, // [0:9] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
2, // 0: uatu.driver.v1.SwipeRequest.from:type_name -> uatu.driver.v1.Point
2, // 1: uatu.driver.v1.SwipeRequest.to:type_name -> uatu.driver.v1.Point
13, // 2: uatu.driver.v1.LogEntries.entries:type_name -> uatu.driver.v1.LogEntry
1, // 3: uatu.driver.v1.Driver.Launch:input_type -> uatu.driver.v1.LaunchRequest
0, // 4: uatu.driver.v1.Driver.Terminate:input_type -> uatu.driver.v1.Empty
2, // 5: uatu.driver.v1.Driver.Tap:input_type -> uatu.driver.v1.Point
4, // 6: uatu.driver.v1.Driver.TapSelector:input_type -> uatu.driver.v1.Selector
3, // 7: uatu.driver.v1.Driver.InputText:input_type -> uatu.driver.v1.Text
9, // 8: uatu.driver.v1.Driver.Swipe:input_type -> uatu.driver.v1.SwipeRequest
10, // 9: uatu.driver.v1.Driver.PressKey:input_type -> uatu.driver.v1.PressKeyRequest
0, // 10: uatu.driver.v1.Driver.Screenshot:input_type -> uatu.driver.v1.Empty
0, // 11: uatu.driver.v1.Driver.Hierarchy:input_type -> uatu.driver.v1.Empty
11, // 12: uatu.driver.v1.Driver.RecentLogs:input_type -> uatu.driver.v1.RecentLogsRequest
7, // 13: uatu.driver.v1.Driver.WaitForIdle:input_type -> uatu.driver.v1.Duration
0, // 14: uatu.driver.v1.Driver.Health:input_type -> uatu.driver.v1.Empty
0, // 15: uatu.driver.v1.Driver.Launch:output_type -> uatu.driver.v1.Empty
0, // 16: uatu.driver.v1.Driver.Terminate:output_type -> uatu.driver.v1.Empty
0, // 17: uatu.driver.v1.Driver.Tap:output_type -> uatu.driver.v1.Empty
0, // 18: uatu.driver.v1.Driver.TapSelector:output_type -> uatu.driver.v1.Empty
0, // 19: uatu.driver.v1.Driver.InputText:output_type -> uatu.driver.v1.Empty
0, // 20: uatu.driver.v1.Driver.Swipe:output_type -> uatu.driver.v1.Empty
0, // 21: uatu.driver.v1.Driver.PressKey:output_type -> uatu.driver.v1.Empty
5, // 22: uatu.driver.v1.Driver.Screenshot:output_type -> uatu.driver.v1.Image
6, // 23: uatu.driver.v1.Driver.Hierarchy:output_type -> uatu.driver.v1.HierarchyJSON
12, // 24: uatu.driver.v1.Driver.RecentLogs:output_type -> uatu.driver.v1.LogEntries
0, // 25: uatu.driver.v1.Driver.WaitForIdle:output_type -> uatu.driver.v1.Empty
8, // 26: uatu.driver.v1.Driver.Health:output_type -> uatu.driver.v1.HealthStatus
15, // [15:27] is the sub-list for method output_type
3, // [3:15] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_driverpb_driver_proto_init() }
@@ -572,7 +881,7 @@ func file_driverpb_driver_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_driverpb_driver_proto_rawDesc), len(file_driverpb_driver_proto_rawDesc)),
NumEnums: 0,
NumMessages: 9,
NumMessages: 14,
NumExtensions: 0,
NumServices: 1,
},
+34
View File
@@ -13,9 +13,12 @@ service Driver {
rpc Tap(Point) returns (Empty);
rpc TapSelector(Selector) returns (Empty);
rpc InputText(Text) returns (Empty);
rpc Swipe(SwipeRequest) returns (Empty);
rpc PressKey(PressKeyRequest) returns (Empty);
rpc Screenshot(Empty) returns (Image);
rpc Hierarchy(Empty) returns (HierarchyJSON);
rpc RecentLogs(RecentLogsRequest) returns (LogEntries);
rpc WaitForIdle(Duration) returns (Empty);
rpc Health(Empty) returns (HealthStatus);
@@ -64,3 +67,34 @@ message HealthStatus {
string version = 2;
string platform = 3;
}
message SwipeRequest {
Point from = 1;
Point to = 2;
int64 duration_millis = 3;
}
message PressKeyRequest {
// Logical key name. Supported: "back", "home", "enter", "tab", "up",
// "down", "left", "right".
string key = 1;
}
message RecentLogsRequest {
// Lower bound as unix-epoch milliseconds; entries at or after this are
// returned.
int64 since_unix_millis = 1;
// Minimum level: "E", "W", or "I". Empty defaults to "E".
string level_at_least = 2;
}
message LogEntries {
repeated LogEntry entries = 1;
}
message LogEntry {
int64 unix_millis = 1;
string level = 2;
string tag = 3;
string message = 4;
}
+114
View File
@@ -24,8 +24,11 @@ const (
Driver_Tap_FullMethodName = "/uatu.driver.v1.Driver/Tap"
Driver_TapSelector_FullMethodName = "/uatu.driver.v1.Driver/TapSelector"
Driver_InputText_FullMethodName = "/uatu.driver.v1.Driver/InputText"
Driver_Swipe_FullMethodName = "/uatu.driver.v1.Driver/Swipe"
Driver_PressKey_FullMethodName = "/uatu.driver.v1.Driver/PressKey"
Driver_Screenshot_FullMethodName = "/uatu.driver.v1.Driver/Screenshot"
Driver_Hierarchy_FullMethodName = "/uatu.driver.v1.Driver/Hierarchy"
Driver_RecentLogs_FullMethodName = "/uatu.driver.v1.Driver/RecentLogs"
Driver_WaitForIdle_FullMethodName = "/uatu.driver.v1.Driver/WaitForIdle"
Driver_Health_FullMethodName = "/uatu.driver.v1.Driver/Health"
)
@@ -39,8 +42,11 @@ type DriverClient interface {
Tap(ctx context.Context, in *Point, opts ...grpc.CallOption) (*Empty, error)
TapSelector(ctx context.Context, in *Selector, opts ...grpc.CallOption) (*Empty, error)
InputText(ctx context.Context, in *Text, opts ...grpc.CallOption) (*Empty, error)
Swipe(ctx context.Context, in *SwipeRequest, opts ...grpc.CallOption) (*Empty, error)
PressKey(ctx context.Context, in *PressKeyRequest, opts ...grpc.CallOption) (*Empty, error)
Screenshot(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Image, error)
Hierarchy(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*HierarchyJSON, error)
RecentLogs(ctx context.Context, in *RecentLogsRequest, opts ...grpc.CallOption) (*LogEntries, error)
WaitForIdle(ctx context.Context, in *Duration, opts ...grpc.CallOption) (*Empty, error)
Health(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*HealthStatus, error)
}
@@ -103,6 +109,26 @@ func (c *driverClient) InputText(ctx context.Context, in *Text, opts ...grpc.Cal
return out, nil
}
func (c *driverClient) Swipe(ctx context.Context, in *SwipeRequest, opts ...grpc.CallOption) (*Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Empty)
err := c.cc.Invoke(ctx, Driver_Swipe_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *driverClient) PressKey(ctx context.Context, in *PressKeyRequest, opts ...grpc.CallOption) (*Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Empty)
err := c.cc.Invoke(ctx, Driver_PressKey_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *driverClient) Screenshot(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Image, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Image)
@@ -123,6 +149,16 @@ func (c *driverClient) Hierarchy(ctx context.Context, in *Empty, opts ...grpc.Ca
return out, nil
}
func (c *driverClient) RecentLogs(ctx context.Context, in *RecentLogsRequest, opts ...grpc.CallOption) (*LogEntries, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LogEntries)
err := c.cc.Invoke(ctx, Driver_RecentLogs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *driverClient) WaitForIdle(ctx context.Context, in *Duration, opts ...grpc.CallOption) (*Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Empty)
@@ -152,8 +188,11 @@ type DriverServer interface {
Tap(context.Context, *Point) (*Empty, error)
TapSelector(context.Context, *Selector) (*Empty, error)
InputText(context.Context, *Text) (*Empty, error)
Swipe(context.Context, *SwipeRequest) (*Empty, error)
PressKey(context.Context, *PressKeyRequest) (*Empty, error)
Screenshot(context.Context, *Empty) (*Image, error)
Hierarchy(context.Context, *Empty) (*HierarchyJSON, error)
RecentLogs(context.Context, *RecentLogsRequest) (*LogEntries, error)
WaitForIdle(context.Context, *Duration) (*Empty, error)
Health(context.Context, *Empty) (*HealthStatus, error)
mustEmbedUnimplementedDriverServer()
@@ -181,12 +220,21 @@ func (UnimplementedDriverServer) TapSelector(context.Context, *Selector) (*Empty
func (UnimplementedDriverServer) InputText(context.Context, *Text) (*Empty, error) {
return nil, status.Error(codes.Unimplemented, "method InputText not implemented")
}
func (UnimplementedDriverServer) Swipe(context.Context, *SwipeRequest) (*Empty, error) {
return nil, status.Error(codes.Unimplemented, "method Swipe not implemented")
}
func (UnimplementedDriverServer) PressKey(context.Context, *PressKeyRequest) (*Empty, error) {
return nil, status.Error(codes.Unimplemented, "method PressKey not implemented")
}
func (UnimplementedDriverServer) Screenshot(context.Context, *Empty) (*Image, error) {
return nil, status.Error(codes.Unimplemented, "method Screenshot not implemented")
}
func (UnimplementedDriverServer) Hierarchy(context.Context, *Empty) (*HierarchyJSON, error) {
return nil, status.Error(codes.Unimplemented, "method Hierarchy not implemented")
}
func (UnimplementedDriverServer) RecentLogs(context.Context, *RecentLogsRequest) (*LogEntries, error) {
return nil, status.Error(codes.Unimplemented, "method RecentLogs not implemented")
}
func (UnimplementedDriverServer) WaitForIdle(context.Context, *Duration) (*Empty, error) {
return nil, status.Error(codes.Unimplemented, "method WaitForIdle not implemented")
}
@@ -304,6 +352,42 @@ func _Driver_InputText_Handler(srv interface{}, ctx context.Context, dec func(in
return interceptor(ctx, in, info, handler)
}
func _Driver_Swipe_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SwipeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DriverServer).Swipe(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Driver_Swipe_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DriverServer).Swipe(ctx, req.(*SwipeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Driver_PressKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PressKeyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DriverServer).PressKey(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Driver_PressKey_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DriverServer).PressKey(ctx, req.(*PressKeyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Driver_Screenshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Empty)
if err := dec(in); err != nil {
@@ -340,6 +424,24 @@ func _Driver_Hierarchy_Handler(srv interface{}, ctx context.Context, dec func(in
return interceptor(ctx, in, info, handler)
}
func _Driver_RecentLogs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RecentLogsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DriverServer).RecentLogs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Driver_RecentLogs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DriverServer).RecentLogs(ctx, req.(*RecentLogsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Driver_WaitForIdle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Duration)
if err := dec(in); err != nil {
@@ -403,6 +505,14 @@ var Driver_ServiceDesc = grpc.ServiceDesc{
MethodName: "InputText",
Handler: _Driver_InputText_Handler,
},
{
MethodName: "Swipe",
Handler: _Driver_Swipe_Handler,
},
{
MethodName: "PressKey",
Handler: _Driver_PressKey_Handler,
},
{
MethodName: "Screenshot",
Handler: _Driver_Screenshot_Handler,
@@ -411,6 +521,10 @@ var Driver_ServiceDesc = grpc.ServiceDesc{
MethodName: "Hierarchy",
Handler: _Driver_Hierarchy_Handler,
},
{
MethodName: "RecentLogs",
Handler: _Driver_RecentLogs_Handler,
},
{
MethodName: "WaitForIdle",
Handler: _Driver_WaitForIdle_Handler,
+3
View File
@@ -18,8 +18,11 @@ func TestDriverServiceDescriptor(t *testing.T) {
"Tap": true,
"TapSelector": true,
"InputText": true,
"Swipe": true,
"PressKey": true,
"Screenshot": true,
"Hierarchy": true,
"RecentLogs": true,
"WaitForIdle": true,
"Health": true,
}
@@ -0,0 +1,62 @@
package dev.uatu.sdk
import java.io.PrintWriter
import java.io.StringWriter
internal class ExceptionRecorder(private val capacity: Int = DEFAULT_CAPACITY) {
data class Entry(
val className: String,
val message: String,
val stackTrace: String,
val unixMillis: Long,
)
private val buffer: ArrayDeque<Entry> = ArrayDeque()
private var chainedHandler: Thread.UncaughtExceptionHandler? = null
@Volatile private var installed: Boolean = false
@Synchronized
fun install() {
if (installed) return
chainedHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
record(throwable)
chainedHandler?.uncaughtException(thread, throwable)
}
installed = true
}
@Synchronized
fun uninstall() {
if (!installed) return
Thread.setDefaultUncaughtExceptionHandler(chainedHandler)
chainedHandler = null
installed = false
}
@Synchronized
fun record(throwable: Throwable, now: Long = System.currentTimeMillis()) {
val stackTrace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString()
val entry = Entry(
className = throwable.javaClass.name,
message = throwable.message ?: "",
stackTrace = stackTrace,
unixMillis = now,
)
if (buffer.size >= capacity) {
buffer.removeFirst()
}
buffer.addLast(entry)
}
@Synchronized
fun drain(): List<Entry> {
val snapshot = buffer.toList()
buffer.clear()
return snapshot
}
companion object {
const val DEFAULT_CAPACITY: Int = 50
}
}
@@ -31,6 +31,7 @@ data class Message(
val platform: String? = null,
val appPackage: String? = null,
val snapshots: Map<String, Any?>? = null,
val exceptions: List<Map<String, Any?>>? = null,
val extractor: String? = null,
val result: Any? = null,
val error: String? = null,
@@ -50,8 +51,16 @@ data class Message(
fun resume(id: Long): Message = Message(MessageType.RESUME, id = id)
fun state(id: Long, snapshots: Map<String, Any?>): Message =
Message(MessageType.STATE, id = id, snapshots = snapshots)
fun state(
id: Long,
snapshots: Map<String, Any?>,
exceptions: List<Map<String, Any?>>? = null,
): Message = Message(
MessageType.STATE,
id = id,
snapshots = snapshots,
exceptions = exceptions,
)
fun extractResult(id: Long, extractor: String, result: Any?, error: String? = null): Message =
Message(MessageType.EXTRACT_RESULT, id = id, extractor = extractor, result = result, error = error)
@@ -106,6 +115,15 @@ object Protocol {
}
json.put("snapshots", snapshotsJson)
}
message.exceptions?.let { exceptions ->
val array = JSONArray()
for (entry in exceptions) {
val entryJson = JSONObject()
for ((key, value) in entry) entryJson.put(key, wrap(value))
array.put(entryJson)
}
json.put("exceptions", array)
}
message.extractor?.let { json.put("extractor", it) }
message.result?.let { json.put("result", wrap(it)) }
message.error?.let { json.put("error", it) }
@@ -126,6 +144,14 @@ object Protocol {
snapshots = json.optJSONObject("snapshots")?.let { snapshotsJson ->
snapshotsJson.keys().asSequence().associateWith { unwrap(snapshotsJson.get(it)) }
},
exceptions = json.optJSONArray("exceptions")?.let { array ->
buildList {
for (index in 0 until array.length()) {
val item = array.optJSONObject(index) ?: continue
add(item.keys().asSequence().associateWith { unwrap(item.get(it)) })
}
}
},
extractor = json.optStringOrNull("extractor"),
result = if (json.has("result") && !json.isNull("result")) unwrap(json.get("result")) else null,
error = json.optStringOrNull("error"),
@@ -36,6 +36,16 @@ object Uatu {
activeRuntime.register(name, function)
}
/**
* Records a caught [Throwable] so it surfaces in the next STATE message's
* exceptions field. Useful for coroutine CoroutineExceptionHandler,
* OkHttp interceptors, or anywhere else the host app catches errors it
* still wants verified against properties like noUncaughtExceptions.
*/
fun reportError(throwable: Throwable) {
runtime?.reportError(throwable)
}
@Synchronized
internal fun stopForTest() {
runtime?.stop()
@@ -8,23 +8,30 @@ internal class UatuRuntime(
private val version: String,
private val platform: String,
private val appPackage: String,
private val exceptionRecorder: ExceptionRecorder = ExceptionRecorder(),
) {
private val extractors = LinkedHashMap<String, () -> Any?>()
@Volatile private var sender: SocketClient.MessageSender? = null
private val socketClient = SocketClient(transport, AgentHandler())
fun start() {
exceptionRecorder.install()
socketClient.start()
}
fun stop() {
socketClient.stop()
exceptionRecorder.uninstall()
}
fun register(name: String, extractor: () -> Any?) {
synchronized(extractors) { extractors[name] = extractor }
}
fun reportError(throwable: Throwable) {
exceptionRecorder.record(throwable)
}
internal fun snapshot(): Map<String, Any?> {
val drained = synchronized(extractors) { LinkedHashMap(extractors) }
val result = LinkedHashMap<String, Any?>(drained.size)
@@ -67,9 +74,17 @@ internal class UatuRuntime(
Log.w(LOG_TAG, "snapshot failed: $cause")
emptyMap()
}
val exceptions = exceptionRecorder.drain().map { entry ->
mapOf(
"class" to entry.className,
"message" to entry.message,
"stack_trace" to entry.stackTrace,
"unix_millis" to entry.unixMillis,
)
}.takeIf { it.isNotEmpty() }
val activeSender = sender ?: return
try {
activeSender.send(Message.state(id, snapshots))
activeSender.send(Message.state(id, snapshots, exceptions))
} catch (cause: Exception) {
Log.w(LOG_TAG, "failed to send STATE: $cause")
}
@@ -0,0 +1,64 @@
package dev.uatu.sdk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ExceptionRecorderTest {
@Test fun recordsClassMessageAndStackTrace() {
val recorder = ExceptionRecorder()
recorder.record(RuntimeException("boom"))
val drained = recorder.drain()
assertEquals(1, drained.size)
val entry = drained[0]
assertEquals("java.lang.RuntimeException", entry.className)
assertEquals("boom", entry.message)
assertTrue(
"stackTrace should include the class name, got: ${entry.stackTrace}",
entry.stackTrace.contains("RuntimeException"),
)
}
@Test fun drainClearsBuffer() {
val recorder = ExceptionRecorder()
recorder.record(RuntimeException("first"))
recorder.record(RuntimeException("second"))
assertEquals(2, recorder.drain().size)
assertEquals(0, recorder.drain().size)
}
@Test fun dropsOldestWhenOverCapacity() {
val recorder = ExceptionRecorder(capacity = 2)
recorder.record(RuntimeException("a"))
recorder.record(RuntimeException("b"))
recorder.record(RuntimeException("c"))
val drained = recorder.drain()
assertEquals(2, drained.size)
assertEquals("b", drained[0].message)
assertEquals("c", drained[1].message)
}
@Test fun installChainsExistingHandler() {
val recorder = ExceptionRecorder()
val original = Thread.getDefaultUncaughtExceptionHandler()
var chainedInvoked = false
Thread.setDefaultUncaughtExceptionHandler { _, _ -> chainedInvoked = true }
try {
recorder.install()
// Simulate an uncaught exception by invoking the installed handler
// directly — we don't need to actually terminate a thread.
Thread.getDefaultUncaughtExceptionHandler()!!.uncaughtException(
Thread.currentThread(),
IllegalStateException("chain me"),
)
assertTrue("chained handler should have fired", chainedInvoked)
assertEquals(1, recorder.drain().size)
} finally {
recorder.uninstall()
Thread.setDefaultUncaughtExceptionHandler(original)
}
}
}
@@ -116,6 +116,22 @@ class ProtocolTest {
assertTrue("expected missing-type error, got: ${error.message}", error.message!!.contains("missing type"))
}
@Test fun roundTripStateWithExceptions() {
val exceptions = listOf(
mapOf<String, Any?>(
"class" to "java.lang.RuntimeException",
"message" to "boom",
"stack_trace" to "at Foo.bar(Foo.kt:42)",
"unix_millis" to 1_700_000_000_000L,
),
)
val got = roundTrip(Message.state(3, mapOf("screen" to "home"), exceptions))
assertNotNull(got.exceptions)
assertEquals(1, got.exceptions!!.size)
assertEquals("java.lang.RuntimeException", got.exceptions[0]["class"])
assertEquals("boom", got.exceptions[0]["message"])
}
@Test fun streamsMultipleFrames() {
val messages = listOf(
Message.hello("v", "android", "com.x"),
@@ -6,12 +6,22 @@ interface DriverBackend {
fun tap(x: Int, y: Int)
fun tapSelector(selector: String)
fun inputText(text: String)
fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long)
fun pressKey(key: String)
fun screenshot(): Triple<ByteArray, Int, Int>
fun hierarchy(): String
fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine>
fun waitForIdle(durationMillis: Long)
fun healthy(): Boolean
}
data class LogLine(
val unixMillis: Long,
val level: String,
val tag: String,
val message: String,
)
/**
* StubDriverBackend records calls but takes no real device action. Real
* Maestro integration arrives in a follow-up; v0.1 wires the gRPC plumbing
@@ -78,6 +88,56 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
}
return null
}
internal val KEY_MAP: Map<String, String> = mapOf(
"back" to "KEYCODE_BACK",
"home" to "KEYCODE_HOME",
"enter" to "KEYCODE_ENTER",
"tab" to "KEYCODE_TAB",
"up" to "KEYCODE_DPAD_UP",
"down" to "KEYCODE_DPAD_DOWN",
"left" to "KEYCODE_DPAD_LEFT",
"right" to "KEYCODE_DPAD_RIGHT",
)
internal fun formatAdbLogcatTimestamp(unixMillis: Long): String {
val seconds = unixMillis / 1000
val millis = unixMillis % 1000
return "$seconds.${millis.toString().padStart(3, '0')}"
}
// Logcat default threadtime format:
// MM-dd HH:mm:ss.SSS PID TID L TAG: message
// The leading date is the local year-inferred date; we convert to a
// unix-millis best-effort using the current year.
private val LOGCAT_LINE = Regex(
"^(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})\\.(\\d{3})" +
"\\s+\\d+\\s+\\d+\\s+([VDIWEFS])\\s+([^:]+?):\\s?(.*)$",
)
internal fun parseLogcatOutput(output: String): List<LogLine> {
if (output.isBlank()) return emptyList()
val calendar = java.util.Calendar.getInstance()
val year = calendar.get(java.util.Calendar.YEAR)
val result = mutableListOf<LogLine>()
for (line in output.lines()) {
val match = LOGCAT_LINE.matchEntire(line) ?: continue
val month = match.groupValues[1].toInt() - 1
val day = match.groupValues[2].toInt()
val hour = match.groupValues[3].toInt()
val minute = match.groupValues[4].toInt()
val second = match.groupValues[5].toInt()
val millis = match.groupValues[6].toInt()
val level = match.groupValues[7]
val tag = match.groupValues[8].trim()
val message = match.groupValues[9]
calendar.clear()
calendar.set(year, month, day, hour, minute, second)
calendar.set(java.util.Calendar.MILLISECOND, millis)
result.add(LogLine(calendar.timeInMillis, level, tag, message))
}
return result
}
}
override fun terminate(bundleId: String) {
@@ -101,6 +161,52 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
runAdb(listOf("shell", "input", "text", text.replace(" ", "%s")))
}
@Volatile var lastSwipe: SwipeRecord? = null
private set
@Volatile var lastKey: String? = null
private set
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) {
lastSwipe = SwipeRecord(fromX, fromY, toX, toY, durationMillis)
val effectiveDuration = if (durationMillis > 0) durationMillis else 250L
runAdb(
listOf(
"shell", "input", "swipe",
fromX.toString(), fromY.toString(),
toX.toString(), toY.toString(),
effectiveDuration.toString(),
),
)
}
override fun pressKey(key: String) {
lastKey = key
val keyCode = KEY_MAP[key.lowercase()]
?: throw IllegalArgumentException("unsupported pressKey value: $key")
runAdb(listOf("shell", "input", "keyevent", keyCode))
}
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> {
val level = if (minLevel.isEmpty()) "E" else minLevel
val since = if (sinceUnixMillis > 0) formatAdbLogcatTimestamp(sinceUnixMillis) else null
val arguments = mutableListOf("logcat", "-d", "*:$level")
if (since != null) {
arguments.add("-T")
arguments.add(since)
}
return try {
val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
parseLogcatOutput(output)
} catch (cause: Exception) {
println("adb logcat failed: $cause")
emptyList()
}
}
data class SwipeRecord(val fromX: Int, val fromY: Int, val toX: Int, val toY: Int, val durationMillis: Long)
private fun runAdb(arguments: List<String>) {
try {
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start()
@@ -8,8 +8,13 @@ import dev.uatu.driver.v1.HealthStatus
import dev.uatu.driver.v1.HierarchyJSON
import dev.uatu.driver.v1.Image
import dev.uatu.driver.v1.LaunchRequest
import dev.uatu.driver.v1.LogEntries
import dev.uatu.driver.v1.LogEntry
import dev.uatu.driver.v1.Point
import dev.uatu.driver.v1.PressKeyRequest
import dev.uatu.driver.v1.RecentLogsRequest
import dev.uatu.driver.v1.Selector
import dev.uatu.driver.v1.SwipeRequest
import dev.uatu.driver.v1.Text
import io.grpc.stub.StreamObserver
import java.util.concurrent.atomic.AtomicReference
@@ -59,6 +64,40 @@ class DriverService(
}
}
override fun swipe(request: SwipeRequest, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) {
val from = request.from
val to = request.to
backend.swipe(from.x, from.y, to.x, to.y, request.durationMillis)
Empty.getDefaultInstance()
}
}
override fun pressKey(request: PressKeyRequest, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) {
backend.pressKey(request.key)
Empty.getDefaultInstance()
}
}
override fun recentLogs(request: RecentLogsRequest, responseObserver: StreamObserver<LogEntries>) {
runRpc(responseObserver) {
val entries = backend.recentLogs(request.sinceUnixMillis, request.levelAtLeast)
val builder = LogEntries.newBuilder()
for (entry in entries) {
builder.addEntries(
LogEntry.newBuilder()
.setUnixMillis(entry.unixMillis)
.setLevel(entry.level)
.setTag(entry.tag)
.setMessage(entry.message)
.build(),
)
}
builder.build()
}
}
override fun screenshot(request: Empty, responseObserver: StreamObserver<Image>) {
runRpc(responseObserver) {
val (png, width, height) = backend.screenshot()
@@ -5,6 +5,9 @@ import dev.uatu.driver.v1.Duration
import dev.uatu.driver.v1.Empty
import dev.uatu.driver.v1.LaunchRequest
import dev.uatu.driver.v1.Point
import dev.uatu.driver.v1.PressKeyRequest
import dev.uatu.driver.v1.RecentLogsRequest
import dev.uatu.driver.v1.SwipeRequest
import dev.uatu.driver.v1.Text
import io.grpc.ManagedChannel
import io.grpc.inprocess.InProcessChannelBuilder
@@ -15,6 +18,8 @@ import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
private data class Quintuple<A, B, C, D, E>(val a: A, val b: B, val c: C, val d: D, val e: E)
class DriverServiceTest {
@get:Rule val grpcCleanup: GrpcCleanupRule = GrpcCleanupRule()
@@ -100,6 +105,54 @@ class DriverServiceTest {
assertEquals(123L, observed)
}
@Test fun swipeForwardsEndpointsAndDuration() {
var observed: Quintuple<Int, Int, Int, Int, Long>? = null
val backend = object : DriverBackend by StubDriverBackend("android") {
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) {
observed = Quintuple(fromX, fromY, toX, toY, durationMillis)
}
}
val client = newClient(backend)
client.swipe(
SwipeRequest.newBuilder()
.setFrom(Point.newBuilder().setX(10).setY(20).build())
.setTo(Point.newBuilder().setX(30).setY(40).build())
.setDurationMillis(250)
.build(),
)
assertEquals(Quintuple(10, 20, 30, 40, 250L), observed)
}
@Test fun pressKeyForwardsValue() {
var observed: String? = null
val backend = object : DriverBackend by StubDriverBackend("android") {
override fun pressKey(key: String) {
observed = key
}
}
val client = newClient(backend)
client.pressKey(PressKeyRequest.newBuilder().setKey("back").build())
assertEquals("back", observed)
}
@Test fun recentLogsReturnsBackendEntries() {
val backend = object : DriverBackend by StubDriverBackend("android") {
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> {
return listOf(LogLine(1, "E", "AndroidRuntime", "boom"))
}
}
val client = newClient(backend)
val response = client.recentLogs(
RecentLogsRequest.newBuilder().setSinceUnixMillis(0).setLevelAtLeast("E").build(),
)
assertEquals(1, response.entriesCount)
assertEquals("AndroidRuntime", response.getEntries(0).tag)
assertEquals("boom", response.getEntries(0).message)
}
@Test fun healthReportsPlatformAndVersion() {
val backend = StubDriverBackend("android")
val client = newClient(backend)