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{} aliases := map[string]string{}
if specApiPath := resolveSpecAPIPath(options.spec); specApiPath != "" { if specApiPath := resolveSpecAPIPath(options.spec); specApiPath != "" {
aliases["@uatu/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{ bundle, err := bundler.Bundle(bundler.Options{
EntryFile: options.spec, EntryFile: options.spec,
@@ -1,6 +1,7 @@
package dev.uatu.sample package dev.uatu.sample
import android.app.Application import android.app.Application
import android.content.pm.ApplicationInfo
import dev.uatu.sdk.Uatu import dev.uatu.sdk.Uatu
class SampleApplication : Application() { class SampleApplication : Application() {
@@ -21,5 +22,16 @@ class SampleApplication : Application() {
is Route.AddTransaction -> "add-transaction" 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 { import {
extract,
always,
actions,
weighted,
Tap,
InputText, InputText,
taps, Tap,
actions,
always,
eventually,
extract,
from,
next,
now,
pressKey,
swipes, swipes,
taps,
waitOnce,
weighted,
} from "@uatu/spec"; } from "@uatu/spec";
import { noUncaughtExceptions } from "@uatu/spec/defaults/properties";
// ── Snapshot extractors (fed by SampleApplication.kt) ────────── // ── Snapshot extractors (fed by SampleApplication.kt) ──────────
// See ./android/src/main/kotlin/dev/uatu/sample/SampleApplication.kt const loggedIn = extract<boolean>(
const clickCount = extract<number>( (state) => (state.snapshots.logged_in as boolean) ?? false,
(state) => (state.snapshots.click_count as number) ?? 0,
); );
const username = extract<string>( const route = extract<string>(
(state) => (state.snapshots.username as string) ?? "", (state) => (state.snapshots.route as string) ?? "",
);
const accountCount = extract<number>(
(state) => (state.snapshots.account_count as number) ?? 0,
); );
// ── UI elements ──────────────────────────────────────────────── // ── UI elements ────────────────────────────────────────────────
const clickButton = extract((state) => state.ax.find("text:Click me")); const phoneField = extract((state) => state.ax.find("desc:phone_field"));
const resetButton = extract((state) => state.ax.find("text:Reset")); const continueButton = extract((state) => state.ax.find("text:Continue"));
const usernameField = extract((state) => state.ax.find("desc:username_field")); 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 ───────────────────────────────────────────────── // ── 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 = { export const properties = {
clickCountNonNegative: always(() => clickCount.current >= 0), accountCountNonNegative,
clickCountNeverDecreases: always(() => { addAccountAdvances,
const previous = clickCount.previous; eventuallyLoggedIn,
return previous === undefined || clickCount.current >= previous; noUncaughtExceptions,
}),
usernameNeverShrinks: always(() => {
const previous = username.previous;
return previous === undefined || username.current.length >= previous.length;
}),
}; };
// ── Actions ──────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────
const tapClickMe = actions(() => { // Sampling: random phone numbers for the login screen.
return clickButton.current ? [Tap({ on: clickButton.current })] : []; 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(() => { const tapContinue = actions(() =>
return usernameField.current continueButton.current ? [Tap({ on: continueButton.current })] : [],
? [InputText({ into: usernameField.current, text: "alice" })] );
: []; const tapAddAccount = actions(() =>
}); addAccountButton.current ? [Tap({ on: addAccountButton.current })] : [],
);
const tapReset = actions(() => { const nameSampler = from(["Alice", "Bob", "Charlie", "Dana"]);
return resetButton.current ? [Tap({ on: resetButton.current })] : []; 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( export const actionsRoot = weighted(
[50, tapClickMe], [30, typePhone],
[20, typeUsername], [30, tapContinue],
[30, tapReset], [20, tapAddAccount],
[5, taps], [20, fillName],
[2, swipes], [20, tapCreate],
[10, taps],
[5, swipes],
[5, waitOnce],
[5, pressKey],
); );
(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot; (globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot;
+10 -1
View File
@@ -34,7 +34,8 @@ type Message struct {
Platform string `json:"platform,omitempty"` Platform string `json:"platform,omitempty"`
AppPackage string `json:"app_package,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"` Extractor string `json:"extractor,omitempty"`
Result json.RawMessage `json:"result,omitempty"` Result json.RawMessage `json:"result,omitempty"`
@@ -43,6 +44,14 @@ type Message struct {
Reason string `json:"reason,omitempty"` 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 { func Hello(version, platform, appPackage string) Message {
return Message{ return Message{
Type: MessageTypeHello, Type: MessageTypeHello,
+12
View File
@@ -19,14 +19,26 @@ type Driver interface {
Tap(ctx context.Context, x, y int) error Tap(ctx context.Context, x, y int) error
TapSelector(ctx context.Context, selector string) error TapSelector(ctx context.Context, selector string) error
InputText(ctx context.Context, text 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) Hierarchy(ctx context.Context) (string, error)
Screenshot(ctx context.Context) (Image, 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 WaitForIdle(ctx context.Context, duration time.Duration) error
Health(ctx context.Context) (Health, error) Health(ctx context.Context) (Health, error)
} }
type LogEntry struct {
UnixMillis int64
Level string
Tag string
Message string
}
type Image struct { type Image struct {
PNG []byte PNG []byte
Width int Width int
+39
View File
@@ -77,6 +77,45 @@ func (c *Client) InputText(ctx context.Context, text string) error {
return err 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) { func (c *Client) Hierarchy(ctx context.Context) (string, error) {
response, err := c.stub.Hierarchy(ctx, &driverpb.Empty{}) response, err := c.stub.Hierarchy(ctx, &driverpb.Empty{})
if err != nil { if err != nil {
+43
View File
@@ -16,8 +16,11 @@ const (
ActionTap ActionKind = "tap" ActionTap ActionKind = "tap"
ActionTapSelector ActionKind = "tap_selector" ActionTapSelector ActionKind = "tap_selector"
ActionInputText ActionKind = "input_text" ActionInputText ActionKind = "input_text"
ActionSwipe ActionKind = "swipe"
ActionPressKey ActionKind = "press_key"
ActionHierarchy ActionKind = "hierarchy" ActionHierarchy ActionKind = "hierarchy"
ActionScreenshot ActionKind = "screenshot" ActionScreenshot ActionKind = "screenshot"
ActionRecentLogs ActionKind = "recent_logs"
ActionWaitForIdle ActionKind = "wait_for_idle" ActionWaitForIdle ActionKind = "wait_for_idle"
ActionHealth ActionKind = "health" ActionHealth ActionKind = "health"
) )
@@ -28,8 +31,14 @@ type Action struct {
LauncherActivity string LauncherActivity string
ClearState bool ClearState bool
X, Y int X, Y int
FromX, FromY int
ToX, ToY int
Duration time.Duration
Selector string Selector string
Text string Text string
Key string
LogLevel string
LogSince time.Time
Idle time.Duration Idle time.Duration
} }
@@ -43,6 +52,7 @@ type Driver struct {
HierarchyJSON string HierarchyJSON string
ImageData driver.Image ImageData driver.Image
HealthInfo driver.Health HealthInfo driver.Health
LogEntries []driver.LogEntry
Failures map[ActionKind]error Failures map[ActionKind]error
} }
@@ -117,6 +127,39 @@ func (d *Driver) InputText(ctx context.Context, text string) error {
return nil 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) { func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
if err := d.failure(ActionHierarchy); err != nil { if err := d.failure(ActionHierarchy); err != nil {
return "", err return "", err
+189 -21
View File
@@ -1,12 +1,16 @@
package ltl package ltl
import "fmt" import (
"fmt"
"time"
)
type Verdict int type Verdict int
const ( const (
VerdictHolds Verdict = iota VerdictHolds Verdict = iota
VerdictViolated VerdictViolated
VerdictPending
) )
func (v Verdict) String() string { func (v Verdict) String() string {
@@ -15,46 +19,210 @@ func (v Verdict) String() string {
return "holds" return "holds"
case VerdictViolated: case VerdictViolated:
return "violated" return "violated"
case VerdictPending:
return "pending"
default: default:
return fmt.Sprintf("verdict(%d)", int(v)) return fmt.Sprintf("verdict(%d)", int(v))
} }
} }
// Evaluator folds a formula across observed steps. v0.1 semantics: // Evaluator reduces a formula across observed steps using residual-formula
// Always(P) is satisfied if P held at every observed step; once P is false, // semantics. Each step either resolves pending obligations (to holds or
// the verdict latches to Violated. // violated) or carries them forward as residuals. Once a single obligation
// violates, the overall verdict latches to Violated.
type Evaluator struct { type Evaluator struct {
formula Formula root Formula
pending []Formula
violated bool violated bool
} }
func NewEvaluator(formula Formula) *Evaluator { func NewEvaluator(formula Formula) *Evaluator {
return &Evaluator{formula: formula} return &Evaluator{root: formula}
} }
// Observe evaluates the formula against the current state and returns the // Observe evaluates the formula against the current state and returns the
// running verdict. Once Violated, subsequent calls keep returning Violated // running verdict. Uses the real wall clock for deadline-bound operators;
// regardless of what later observations look like. // callers that need reproducible time should use ObserveAt.
func (e *Evaluator) Observe() Verdict { 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 { if e.violated {
return VerdictViolated return VerdictViolated
} }
if !holdsAtCurrentStep(e.formula) {
e.violated = true fresh := rootObligation(e.root)
return VerdictViolated 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 return VerdictHolds
} }
func holdsAtCurrentStep(formula Formula) bool { // rootObligation returns the formula to instantiate at each step. An outer
switch concrete := formula.(type) { // Always is stripped so its inner is re-evaluated every step; any other root
case AlwaysFormula: // formula is itself re-instantiated each step (matching the v0.1 semantics
return holdsAtCurrentStep(concrete.Inner) // where a bare Thunk is re-observed on every call).
case PureFormula: func rootObligation(root Formula) Formula {
return concrete.Value if always, ok := root.(AlwaysFormula); ok {
case ThunkFormula: return always.Inner
return concrete.Func()
default:
panic(fmt.Sprintf("ltl: unsupported formula type %T", formula))
} }
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 ( import (
"strings" "strings"
"testing" "testing"
"time"
) )
func observe(formula Formula, count int) []Verdict { 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") t.Errorf("expected panic on unsupported formula type")
} }
}() }()
holdsAtCurrentStep(unsupportedFormula{}) reduce(unsupportedFormula{}, time.Now())
} }
+115 -7
View File
@@ -1,10 +1,12 @@
package ltl package ltl
import "fmt" import (
"fmt"
"strings"
"time"
)
// Formula is the AST of a temporal logic property. v0.1 supports only Always // Formula is the AST of a temporal logic property.
// over Pure/Thunk leaves; eventually, next, and bounded operators are
// deferred to v0.2+.
type Formula interface { type Formula interface {
isFormula() isFormula()
describe() string describe() string
@@ -22,19 +24,125 @@ type ThunkFormula struct {
Func func() bool 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 Always(inner Formula) Formula { return AlwaysFormula{Inner: inner} }
func Pure(value bool) Formula { return PureFormula{Value: value} } func Pure(value bool) Formula { return PureFormula{Value: value} }
func Thunk(function func() bool) Formula { return ThunkFormula{Func: function} } func Thunk(function func() bool) Formula { return ThunkFormula{Func: function} }
func (AlwaysFormula) isFormula() {} func Now(inner Formula) Formula { return NowFormula{Inner: inner} }
func (PureFormula) isFormula() {}
func (ThunkFormula) isFormula() {} 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 (a AlwaysFormula) describe() string { return "Always(" + a.Inner.describe() + ")" }
func (p PureFormula) describe() string { return fmt.Sprintf("Pure(%t)", p.Value) } func (p PureFormula) describe() string { return fmt.Sprintf("Pure(%t)", p.Value) }
func (ThunkFormula) describe() string { return "Thunk(...)" } 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. // Describe returns a debug-friendly representation of the formula.
func Describe(formula Formula) string { return formula.describe() } 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()} summary := Summary{StartTime: time.Now()}
deadline := summary.StartTime.Add(options.Duration) deadline := summary.StartTime.Add(options.Duration)
stepIndex := 0 stepIndex := 0
var lastAction *verifier.Action
var lastLogTime time.Time
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
break break
@@ -82,7 +84,20 @@ func Run(ctx context.Context, options Options) (Summary, error) {
return summary, fmt.Errorf("step %d snapshot: %w", stepIndex, err) 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) return summary, fmt.Errorf("step %d push: %w", stepIndex, err)
} }
screen, screenErr := screenFromSnapshot(snapshot.Snapshots) screen, screenErr := screenFromSnapshot(snapshot.Snapshots)
@@ -108,6 +123,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
Screen: screen, Screen: screen,
Snapshots: snapshot.Snapshots, Snapshots: snapshot.Snapshots,
Action: traceAction, Action: traceAction,
Exceptions: traceExceptions(exceptions),
Violations: violations, Violations: violations,
} }
if err := options.TraceWriter.WriteStep(step); err != nil { 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 { if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil {
return summary, fmt.Errorf("step %d apply: %w", stepIndex, err) return summary, fmt.Errorf("step %d apply: %w", stepIndex, err)
} }
actionCopy := nextAction
lastAction = &actionCopy
} else {
lastAction = nil
} }
idleCtx, idleCancel := context.WithTimeout(ctx, options.IdleTimeout) 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) 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: default:
return fmt.Errorf("unknown action kind %q", action.Kind) 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) { func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) {
if action.X > 0 && action.Y > 0 { if action.X > 0 && action.Y > 0 {
return action.X, action.Y, true 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 { 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 { switch action.Kind {
case verifier.ActionKindTap: 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 traceAction.Text = action.On
case verifier.ActionKindInputText: case verifier.ActionKindInputText:
traceAction.Text = action.Text 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 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"` Screen string `json:"screen,omitempty"`
Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"` Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
Action *Action `json:"action,omitempty"` Action *Action `json:"action,omitempty"`
Exceptions []Exception `json:"exceptions,omitempty"`
Violations []string `json:"violations,omitempty"` Violations []string `json:"violations,omitempty"`
} }
type Action struct { type Action struct {
Kind string `json:"kind"` Kind string `json:"kind"`
X int `json:"x,omitempty"` X int `json:"x,omitempty"`
Y int `json:"y,omitempty"` Y int `json:"y,omitempty"`
Text string `json:"text,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 { 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); 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) t.Fatal(err)
} }
+305 -13
View File
@@ -2,6 +2,7 @@ package verifier
import ( import (
"fmt" "fmt"
"time"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
@@ -11,6 +12,9 @@ type ActionKind string
const ( const (
ActionKindTap ActionKind = "Tap" ActionKindTap ActionKind = "Tap"
ActionKindInputText ActionKind = "InputText" ActionKindInputText ActionKind = "InputText"
ActionKindSwipe ActionKind = "Swipe"
ActionKindPressKey ActionKind = "PressKey"
ActionKindWait ActionKind = "Wait"
) )
type Action struct { type Action struct {
@@ -21,6 +25,13 @@ type Action struct {
// Tap/InputText. Zero means the runner must resolve On against the // Tap/InputText. Zero means the runner must resolve On against the
// current hierarchy. // current hierarchy.
X, Y int 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 { type extractorState struct {
@@ -32,15 +43,51 @@ type formulaState struct {
predicate goja.Callable predicate goja.Callable
} }
type specKind int
const ( const (
tagFormula = "__uatuFormula" specKindPure specKind = iota
tagActionGenerator = "__uatuActionGenerator" specKindThunk
tagInternalKind = "__uatuKind" specKindNow
tagSelector = "__uatuSelector" specKindNext
internalKindActions = "actions" specKindEventually
internalKindWeighted = "weighted" specKindImplies
internalKindBuiltinTaps = "taps" specKindOr
internalKindBuiltinSwipes = "swipes" 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. // 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 { if err := uatu.Set("always", v.bindAlways); err != nil {
return err 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 { if err := uatu.Set("actions", v.bindActions); err != nil {
return err return err
} }
if err := uatu.Set("weighted", v.bindWeighted); err != nil { if err := uatu.Set("weighted", v.bindWeighted); err != nil {
return err return err
} }
if err := uatu.Set("from", v.bindFrom); err != nil {
return err
}
if err := uatu.Set("tap", v.bindTap); err != nil { if err := uatu.Set("tap", v.bindTap); err != nil {
return err return err
} }
if err := uatu.Set("inputText", v.bindInputText); err != nil { if err := uatu.Set("inputText", v.bindInputText); err != nil {
return err 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 { if err := uatu.Set("taps", v.builtinGenerator(internalKindBuiltinTaps)); err != nil {
return err return err
} }
if err := uatu.Set("swipes", v.builtinGenerator(internalKindBuiltinSwipes)); err != nil { if err := uatu.Set("swipes", v.builtinGenerator(internalKindBuiltinSwipes)); err != nil {
return err 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) return v.runtime.GlobalObject().Set("__uatu__", uatu)
} }
@@ -92,25 +166,178 @@ func (v *Verifier) bindExtract(call goja.FunctionCall) goja.Value {
return handle 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 { func (v *Verifier) bindAlways(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 1 { if len(call.Arguments) != 1 {
panic(v.runtime.NewTypeError("always requires exactly one argument")) 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]) predicate, ok := goja.AssertFunction(call.Arguments[0])
if !ok { 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} // registerThunk stores a predicate in v.formulas and returns its index, which
v.formulas = append(v.formulas, formula) // reduce can later invoke via formulaThunk.
formulaIndex := len(v.formulas) - 1 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 := v.runtime.NewObject()
_ = handle.Set(tagFormula, true) _ = 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 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 { func (v *Verifier) bindActions(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 1 { if len(call.Arguments) != 1 {
panic(v.runtime.NewTypeError("actions requires a single generator argument")) 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 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 { func (v *Verifier) bindTap(call goja.FunctionCall) goja.Value {
parameters := call.Argument(0).ToObject(v.runtime) parameters := call.Argument(0).ToObject(v.runtime)
if parameters == nil { if parameters == nil {
@@ -166,6 +421,43 @@ func (v *Verifier) bindInputText(call goja.FunctionCall) goja.Value {
return handle 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 { func (v *Verifier) builtinGenerator(kind string) *goja.Object {
handle := v.runtime.NewObject() handle := v.runtime.NewObject()
_ = handle.Set(tagActionGenerator, true) _ = handle.Set(tagActionGenerator, true)
+147 -7
View File
@@ -3,6 +3,7 @@ package verifier
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
"github.com/dop251/goja" "github.com/dop251/goja"
@@ -12,13 +13,23 @@ import (
// Snapshots is the per-step extractor output forwarded by the SDK. // Snapshots is the per-step extractor output forwarded by the SDK.
type Snapshots map[string]json.RawMessage type Snapshots map[string]json.RawMessage
// stateObject builds a JS-side `{ snapshots, ax }` matching the State type type stateInput struct {
// from pkg/spec-api. ax is backed by the parsed uiautomator hierarchy when snapshots Snapshots
// one is provided. tree *hierarchy.Tree
func stateObject(runtime *goja.Runtime, snapshots Snapshots, tree *hierarchy.Tree) (*goja.Object, error) { 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() state := runtime.NewObject()
snapshotsObject := runtime.NewObject() snapshotsObject := runtime.NewObject()
for key, raw := range snapshots { for key, raw := range input.snapshots {
value, err := jsonToJSValue(runtime, raw) value, err := jsonToJSValue(runtime, raw)
if err != nil { if err != nil {
return nil, fmt.Errorf("snapshot %q: %w", key, err) 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 { if err := state.Set("snapshots", snapshotsObject); err != nil {
return nil, err 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 nil, err
} }
return state, nil return state, nil
@@ -88,6 +111,74 @@ func elementObject(runtime *goja.Runtime, element *hierarchy.Element, selector s
return object 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) { func jsonToJSValue(runtime *goja.Runtime, raw json.RawMessage) (goja.Value, error) {
if len(raw) == 0 { if len(raw) == 0 {
return goja.Undefined(), nil return goja.Undefined(), nil
@@ -99,7 +190,7 @@ func jsonToJSValue(runtime *goja.Runtime, raw json.RawMessage) (goja.Value, erro
return runtime.ToValue(generic), nil 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) { func jsValueToAction(runtime *goja.Runtime, value goja.Value) (Action, error) {
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) { if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
return Action{}, fmt.Errorf("nil action") return Action{}, fmt.Errorf("nil action")
@@ -120,11 +211,60 @@ func jsValueToAction(runtime *goja.Runtime, value goja.Value) (Action, error) {
text := object.Get("text") text := object.Get("text")
x, y := coordinatesOf(runtime, into) x, y := coordinatesOf(runtime, into)
return Action{Kind: ActionKindInputText, On: selectorOf(runtime, into), Text: stringOf(text), X: x, Y: y}, nil 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: default:
return Action{}, fmt.Errorf("unknown action kind %q", kind) 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 { func selectorOf(runtime *goja.Runtime, value goja.Value) string {
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) { if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
return "" return ""
+55 -66
View File
@@ -14,10 +14,9 @@ const sampleAppHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0"> <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.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.LinearLayout" bounds="[64,96][1016,2336]">
<node index="0" class="android.widget.TextView" text="Clicks: 0" bounds="[100,200][900,300]" /> <node index="0" class="android.widget.TextView" text="Sign in" 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="1" class="android.widget.EditText" content-desc="phone_field" clickable="true" enabled="true" bounds="[100,400][900,520]" />
<node index="2" class="android.widget.TextView" text="Username: " bounds="[100,1000][900,1080]" /> <node index="2" class="android.widget.Button" text="Continue" clickable="true" enabled="true" bounds="[400,800][680,920]" />
<node index="3" class="android.widget.EditText" content-desc="username_field" clickable="true" enabled="true" bounds="[100,1200][900,1320]" />
</node> </node>
</node> </node>
</hierarchy>` </hierarchy>`
@@ -34,9 +33,16 @@ func bundleSampleAppSpec(t *testing.T) string {
if err != nil { if err != nil {
t.Fatal(err) 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{ bundle, err := bundler.Bundle(bundler.Options{
EntryFile: specPath, EntryFile: specPath,
Aliases: map[string]string{"@uatu/spec": apiPath}, Aliases: map[string]string{
"@uatu/spec": apiPath,
"@uatu/spec/defaults/properties": defaultsPath,
},
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -44,9 +50,10 @@ func bundleSampleAppSpec(t *testing.T) string {
return string(bundle.JavaScript) return string(bundle.JavaScript)
} }
// TestSampleAppSpecTapsClickMe verifies the bundled sample-app spec emits a // TestSampleAppSpecFiresLoginActions verifies the bundled sample-app spec
// Tap on the "Click me" button when that button is present in the hierarchy. // emits Tap/InputText actions targeting the login screen elements when they
func TestSampleAppSpecTapsClickMe(t *testing.T) { // are present in the hierarchy.
func TestSampleAppSpecFiresLoginActions(t *testing.T) {
v := newVerifier(t) v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil { if err := v.Load(bundleSampleAppSpec(t)); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -57,85 +64,67 @@ func TestSampleAppSpecTapsClickMe(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
snapshots := Snapshots{ snapshots := Snapshots{
"app_state": json.RawMessage(`"running"`), "route": json.RawMessage(`"login"`),
"click_count": json.RawMessage(`0`), "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) t.Fatal(err)
} }
tapHits := 0 tapContinueHits := 0
inputHits := 0 typePhoneHits := 0
for range 400 { for range 400 {
action, err := v.NextAction() action, err := v.NextAction()
if err != nil { if err != nil {
continue continue
} }
switch { switch {
case action.Kind == ActionKindTap && action.On == "text:Click me": case action.Kind == ActionKindTap && action.On == "text:Continue":
tapHits++ tapContinueHits++
case action.Kind == ActionKindInputText && action.On == "desc:username_field" && action.Text == "alice": case action.Kind == ActionKindInputText && action.On == "desc:phone_field":
inputHits++ typePhoneHits++
} }
} }
if tapHits == 0 { if tapContinueHits == 0 {
t.Fatal("tapClickMe never fired on sample-app hierarchy") t.Fatal("tapContinue never fired on sample-app hierarchy")
} }
if inputHits == 0 { if typePhoneHits == 0 {
t.Fatal("typeUsername never fired on sample-app hierarchy") t.Fatal("typePhone never fired on sample-app hierarchy")
} }
} }
// TestSampleAppSpecPropertiesHold checks the three properties declared in the // TestSampleAppSpecPropertiesEvaluate checks the properties declared in the
// sample-app spec evaluate correctly across a realistic snapshot sequence. // sample-app spec evaluate sensibly across a small snapshot sequence. The
func TestSampleAppSpecPropertiesHold(t *testing.T) { // 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) v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil { if err := v.Load(bundleSampleAppSpec(t)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
steps := []struct { tree, err := hierarchy.Parse(sampleAppHierarchyXML)
appState string if err != nil {
clickCount int t.Fatal(err)
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,
}},
} }
snapshots := Snapshots{
for index, step := range steps { "route": json.RawMessage(`"login"`),
stateRaw, _ := json.Marshal(step.appState) "logged_in": json.RawMessage(`false`),
countRaw, _ := json.Marshal(step.clickCount) "account_count": json.RawMessage(`0`),
usernameRaw, _ := json.Marshal(step.username) }
if err := v.PushSnapshot(Snapshots{ if err := v.PushSnapshot(SnapshotInput{Snapshots: snapshots, Tree: tree}); err != nil {
"app_state": stateRaw, t.Fatal(err)
"click_count": countRaw, }
"username": usernameRaw, verdicts := v.EvaluateProperties()
}, nil); err != nil { if verdicts["accountCountNonNegative"] != ltl.VerdictHolds {
t.Fatalf("step %d: %v", index, err) t.Errorf("accountCountNonNegative: got %v, want holds", verdicts["accountCountNonNegative"])
} }
got := v.EvaluateProperties() if verdicts["noUncaughtExceptions"] != ltl.VerdictHolds {
for property, want := range step.want { t.Errorf("noUncaughtExceptions: got %v, want holds", verdicts["noUncaughtExceptions"])
if got[property] != want { }
t.Errorf("step %d %q: got %v, want %v", index, property, got[property], want) // 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) verifier := newVerifier(t)
mustLoad(t, verifier, helloSpec) mustLoad(t, verifier, helloSpec)
if err := verifier.PushSnapshot(Snapshots{ if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{
"screen": json.RawMessage(`"customer_ledger"`), "screen": json.RawMessage(`"customer_ledger"`),
"ledger.balance": json.RawMessage(`1500`), "ledger.balance": json.RawMessage(`1500`),
}, nil); err != nil { }}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -78,7 +78,7 @@ func TestPushSnapshot_UpdatesExtractorCurrentAndPrevious(t *testing.T) {
} }
// Push again: previous should mirror the prior current. // 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) t.Fatal(err)
} }
balanceValue = verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime) balanceValue = verifier.runtime.GlobalObject().Get("balance").ToObject(verifier.runtime)
@@ -105,7 +105,7 @@ func TestEvaluateProperties_HoldsThenViolates(t *testing.T) {
} }
for index, testCase := range cases { for index, testCase := range cases {
raw, _ := json.Marshal(testCase.balance) 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) t.Fatal(err)
} }
verdicts := verifier.EvaluateProperties() verdicts := verifier.EvaluateProperties()
@@ -118,7 +118,7 @@ func TestEvaluateProperties_HoldsThenViolates(t *testing.T) {
func TestNextAction_FromActionsGenerator(t *testing.T) { func TestNextAction_FromActionsGenerator(t *testing.T) {
verifier := newVerifier(t) verifier := newVerifier(t)
mustLoad(t, verifier, helloSpec) mustLoad(t, verifier, helloSpec)
_ = verifier.PushSnapshot(Snapshots{}, nil) _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
action, err := verifier.NextAction() action, err := verifier.NextAction()
if err != nil { if err != nil {
@@ -142,7 +142,7 @@ func TestNextAction_WeightedSelectsByWeight(t *testing.T) {
[99, tapAway], [99, tapAway],
); );
`) `)
_ = verifier.PushSnapshot(Snapshots{}, nil) _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
awayCount := 0 awayCount := 0
homeCount := 0 homeCount := 0
@@ -168,7 +168,7 @@ func TestNextAction_EmptyGeneratorReturnsErrNoAction(t *testing.T) {
mustLoad(t, verifier, ` mustLoad(t, verifier, `
globalThis.actions = __uatu__.actions(() => []); globalThis.actions = __uatu__.actions(() => []);
`) `)
_ = verifier.PushSnapshot(Snapshots{}, nil) _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
_, err := verifier.NextAction() _, err := verifier.NextAction()
if !errors.Is(err, ErrNoAction) { if !errors.Is(err, ErrNoAction) {
@@ -183,7 +183,7 @@ func TestInputText_RoundTrip(t *testing.T) {
__uatu__.inputText({ into: "id:phone", text: "+919876543210" }), __uatu__.inputText({ into: "id:phone", text: "+919876543210" }),
]); ]);
`) `)
_ = verifier.PushSnapshot(Snapshots{}, nil) _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}})
action, err := verifier.NextAction() action, err := verifier.NextAction()
if err != nil { if err != nil {
@@ -202,7 +202,7 @@ func TestPushSnapshot_FeedsSnapshotsToExtractorState(t *testing.T) {
mustLoad(t, verifier, ` mustLoad(t, verifier, `
globalThis.captured = __uatu__.extract(state => state.snapshots["k"]); 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) t.Fatal(err)
} }
value := verifier.runtime.GlobalObject().Get("captured").ToObject(verifier.runtime).Get("current") value := verifier.runtime.GlobalObject().Get("captured").ToObject(verifier.runtime).Get("current")
+253 -15
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math/rand/v2" "math/rand/v2"
"time"
"github.com/dop251/goja" "github.com/dop251/goja"
@@ -12,15 +13,23 @@ import (
) )
type Verifier struct { type Verifier struct {
runtime *goja.Runtime runtime *goja.Runtime
extractors []*extractorState extractors []*extractorState
formulas []*formulaState formulas []*formulaState
formulaSpecs []formulaSpec
properties map[string]int // property name -> formula index properties map[string]int // property name -> formula-spec index
actionGenerator goja.Value actionGenerator goja.Value
evaluators map[string]*ltl.Evaluator evaluators map[string]*ltl.Evaluator
lastTree *hierarchy.Tree
lastAction *Action
lastLogs []LogEntry
lastExceptions []Exception
stepTime time.Time
runStart time.Time
rng *rand.Rand rng *rand.Rand
} }
@@ -62,13 +71,16 @@ func (v *Verifier) Load(source string) error {
if handle == nil { if handle == nil {
return fmt.Errorf("property %q is not an object", name) return fmt.Errorf("property %q is not an object", name)
} }
indexValue := handle.Get("__uatuIndex") specIndex, ok := v.extractSpecIndex(handle)
if indexValue == nil { if !ok {
return fmt.Errorf("property %q was not produced by always()", name) return fmt.Errorf("property %q was not produced by always()", name)
} }
index := int(indexValue.ToInteger()) formula, err := v.buildFormula(specIndex)
v.properties[name] = index if err != nil {
v.evaluators[name] = ltl.NewEvaluator(ltl.Always(ltl.Thunk(v.formulaThunk(index)))) 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 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 // PushSnapshot updates the JS-side state and refreshes every extractor's
// current/previous values in registration order. Passing a nil tree is // current/previous values in registration order. Passing a nil tree is
// allowed and yields an empty ax scope. // allowed and yields an empty ax scope.
func (v *Verifier) PushSnapshot(snapshots Snapshots, tree *hierarchy.Tree) error { func (v *Verifier) PushSnapshot(input SnapshotInput) error {
state, err := stateObject(v.runtime, snapshots, tree) 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 { if err != nil {
return fmt.Errorf("build state: %w", err) return fmt.Errorf("build state: %w", err)
} }
@@ -102,12 +228,47 @@ func (v *Verifier) PushSnapshot(snapshots Snapshots, tree *hierarchy.Tree) error
return nil 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 // 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 { func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict {
verdicts := map[string]ltl.Verdict{} verdicts := map[string]ltl.Verdict{}
stepTime := v.stepTime
if stepTime.IsZero() {
stepTime = time.Now()
}
for name, evaluator := range v.evaluators { for name, evaluator := range v.evaluators {
verdicts[name] = evaluator.Observe() verdicts[name] = evaluator.ObserveAt(stepTime)
} }
return verdicts return verdicts
} }
@@ -178,13 +339,90 @@ func (v *Verifier) resolveGenerator(generator goja.Value) (Action, error) {
return Action{}, err return Action{}, err
} }
return v.resolveGenerator(picked) return v.resolveGenerator(picked)
case internalKindBuiltinTaps, internalKindBuiltinSwipes: case internalKindBuiltinTaps:
return Action{}, ErrNoAction return v.generateRandomTap()
case internalKindBuiltinSwipes:
return v.generateRandomSwipe()
case internalKindBuiltinWaitOnce:
return Action{Kind: ActionKindWait, DurationMillis: 500}, nil
case internalKindBuiltinPressKey:
return v.generateRandomPressKey()
default: default:
return Action{}, fmt.Errorf("unknown generator kind %q", kindValue.String()) 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) { func (v *Verifier) pickFromResult(result goja.Value) (Action, error) {
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return Action{}, ErrNoAction return Action{}, ErrNoAction
+4
View File
@@ -9,6 +9,10 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
},
"./defaults/properties": {
"types": "./dist/defaults/properties.d.ts",
"import": "./dist/defaults/properties.js"
} }
}, },
"files": [ "files": [
+45 -11
View File
@@ -3,7 +3,13 @@ import type {
Action, Action,
ActionGenerator, ActionGenerator,
InputTextAction, InputTextAction,
Key,
Point,
PressKeyAction,
Sampler,
SwipeAction,
TapAction, TapAction,
WaitAction,
WeightedEntry, WeightedEntry,
} from "./types.ts"; } from "./types.ts";
@@ -15,22 +21,50 @@ export function weighted(...entries: WeightedEntry[]): ActionGenerator {
return globalThis.__uatu__.weighted(...entries); 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 { export function Tap(parameters: { on: string | AccessibilityElement }): TapAction {
return globalThis.__uatu__.tap(parameters); 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); return globalThis.__uatu__.inputText(parameters);
} }
export const taps: ActionGenerator = new Proxy({} as ActionGenerator, { export function Swipe(parameters: {
get(_target, property) { from: Point | AccessibilityElement;
return (globalThis.__uatu__.taps as unknown as Record<string | symbol, unknown>)[property]; to: Point | AccessibilityElement;
}, durationMillis?: number;
}); }): SwipeAction {
return globalThis.__uatu__.swipe(parameters);
}
export const swipes: ActionGenerator = new Proxy({} as ActionGenerator, { export function PressKey(parameters: { key: Key }): PressKeyAction {
get(_target, property) { return globalThis.__uatu__.pressKey(parameters);
return (globalThis.__uatu__.swipes as unknown as Record<string | symbol, unknown>)[property]; }
},
}); 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, AccessibilityTree,
Action, Action,
ActionGenerator, ActionGenerator,
EventuallyFormula,
ExceptionRecord,
Extracted, Extracted,
Formula, Formula,
InputTextAction, InputTextAction,
Key,
LogEntry,
Point,
PressKeyAction,
Sampler,
Snapshots, Snapshots,
State, State,
SwipeAction,
TapAction, TapAction,
UatuRuntime,
WaitAction,
WeightedEntry, WeightedEntry,
} from "./types.ts"; } from "./types.ts";
export { extract } from "./extract.ts"; export { extract } from "./extract.ts";
export { always } from "./ltl.ts"; export { always, eventually, next, now } from "./ltl.ts";
export { Tap, InputText, actions, weighted, taps, swipes } from "./actions.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 { export function always(predicateOrFormula: (() => boolean) | Formula): Formula {
return globalThis.__uatu__.always(predicate); 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; id?: string;
text?: string; text?: string;
bounds?: { left: number; top: number; right: number; bottom: number }; bounds?: { left: number; top: number; right: number; bottom: number };
x?: number;
y?: number;
} }
export interface AccessibilityTree { export interface AccessibilityTree {
@@ -11,9 +13,27 @@ export interface AccessibilityTree {
findAll(selector: string): AccessibilityElement[]; 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 { export interface State {
snapshots: Snapshots; snapshots: Snapshots;
ax: AccessibilityTree; ax: AccessibilityTree;
lastAction: Action | null;
time: number;
logs: readonly LogEntry[];
exceptions: readonly ExceptionRecord[];
} }
export interface Extracted<T> { export interface Extracted<T> {
@@ -21,9 +41,41 @@ export interface Extracted<T> {
readonly previous: T | undefined; readonly previous: T | undefined;
} }
export interface Point {
x: number;
y: number;
}
export type TapAction = { kind: "Tap"; on: string | AccessibilityElement }; export type TapAction = { kind: "Tap"; on: string | AccessibilityElement };
export type InputTextAction = { kind: "InputText"; into: string | AccessibilityElement; text: string }; export type InputTextAction = {
export type Action = TapAction | 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 { export interface ActionGenerator {
readonly __uatuActionGenerator: true; readonly __uatuActionGenerator: true;
@@ -32,17 +84,45 @@ export interface ActionGenerator {
export interface Formula { export interface Formula {
readonly __uatuFormula: true; 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 { export interface UatuRuntime {
extract: <T>(getter: (state: State) => T) => Extracted<T>; 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; actions: (generator: () => Action[]) => ActionGenerator;
weighted: (...entries: WeightedEntry[]) => ActionGenerator; weighted: (...entries: WeightedEntry[]) => ActionGenerator;
from: <T>(items: readonly T[]) => Sampler<T>;
tap: (parameters: { on: string | AccessibilityElement }) => TapAction; 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; taps: ActionGenerator;
swipes: ActionGenerator; swipes: ActionGenerator;
waitOnce: ActionGenerator;
pressKeys: ActionGenerator;
} }
export type WeightedEntry = readonly [number, ActionGenerator]; export type WeightedEntry = readonly [number, ActionGenerator];
+188 -15
View File
@@ -1,38 +1,127 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; 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 { import type {
AccessibilityElement, AccessibilityElement,
Action, Action,
ActionGenerator, ActionGenerator,
EventuallyFormula,
Extracted, Extracted,
Formula, Formula,
Sampler,
State, State,
UatuRuntime, UatuRuntime,
WeightedEntry, WeightedEntry,
} from "../src/types.ts"; } from "../src/types.ts";
function installFakeRuntime(): UatuRuntime & { interface RecordedRuntime extends UatuRuntime {
extracts: Array<(state: State) => unknown>; 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[]>; actionGenerators: Array<() => Action[]>;
weightedCalls: WeightedEntry[][]; 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 = { const calls = {
extracts: [] as Array<(state: State) => unknown>, 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[]>, actionGenerators: [] as Array<() => Action[]>,
weightedCalls: [] as WeightedEntry[][], weightedCalls: [] as WeightedEntry[][],
fromCalls: [] as unknown[][],
}; };
const runtime: UatuRuntime = { const runtime = {
extract: <T>(getter: (state: State) => T): Extracted<T> => { extract: <T>(getter: (state: State) => T): Extracted<T> => {
calls.extracts.push(getter as (state: State) => unknown); calls.extracts.push(getter as (state: State) => unknown);
return { current: undefined as unknown as T, previous: undefined }; return { current: undefined as unknown as T, previous: undefined };
}, },
always: (predicate: () => boolean): Formula => { always: (predicateOrFormula: (() => boolean) | Formula): Formula => {
calls.alwaysPredicates.push(predicate); calls.alwaysArgs.push(predicateOrFormula);
return { __uatuFormula: true }; 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 => { actions: (generator: () => Action[]): ActionGenerator => {
calls.actionGenerators.push(generator); calls.actionGenerators.push(generator);
@@ -42,13 +131,28 @@ function installFakeRuntime(): UatuRuntime & {
calls.weightedCalls.push(entries); calls.weightedCalls.push(entries);
return { __uatuActionGenerator: true, generate: () => [] }; 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 }), tap: ({ on }) => ({ kind: "Tap", on }),
inputText: ({ into, text }) => ({ kind: "InputText", into, text }), 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: () => [] }, taps: { __uatuActionGenerator: true, generate: () => [] },
swipes: { __uatuActionGenerator: true, generate: () => [] }, swipes: { __uatuActionGenerator: true, generate: () => [] },
}; waitOnce: { __uatuActionGenerator: true, generate: () => [] },
globalThis.__uatu__ = runtime; pressKeys: { __uatuActionGenerator: true, generate: () => [] },
return Object.assign(runtime, calls); } satisfies UatuRuntime;
const recorded = Object.assign(runtime, calls) as RecordedRuntime;
globalThis.__uatu__ = recorded;
return recorded;
} }
test("extract forwards the getter to the runtime", () => { 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 runtime = installFakeRuntime();
const predicate = () => true; const predicate = () => true;
const formula = always(predicate); const formula = always(predicate);
assert.equal(runtime.alwaysPredicates[0], predicate); assert.equal(runtime.alwaysArgs[0], predicate);
assert.equal(formula.__uatuFormula, true); 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", () => { test("Tap returns a TapAction with the supplied selector", () => {
installFakeRuntime(); installFakeRuntime();
const action = Tap({ on: "id:login_continue" }); 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" }); 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", () => { test("actions wraps a generator into the runtime's ActionGenerator", () => {
const runtime = installFakeRuntime(); const runtime = installFakeRuntime();
const generator = () => [Tap({ on: "id:x" })]; const generator = () => [Tap({ on: "id:x" })];
@@ -105,9 +270,17 @@ test("weighted forwards weighted entries to the runtime", () => {
assert.deepEqual(runtime.weightedCalls[0], entries); 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(); installFakeRuntime();
assert.equal(taps.__uatuActionGenerator, true); assert.equal(taps.__uatuActionGenerator, true);
assert.equal(swipes.__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 "" 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 var File_driverpb_driver_proto protoreflect.FileDescriptor
const file_driverpb_driver_proto_rawDesc = "" + const file_driverpb_driver_proto_rawDesc = "" +
@@ -497,16 +770,38 @@ const file_driverpb_driver_proto_rawDesc = "" +
"\fHealthStatus\x12\x14\n" + "\fHealthStatus\x12\x14\n" +
"\x05ready\x18\x01 \x01(\bR\x05ready\x12\x18\n" + "\x05ready\x18\x01 \x01(\bR\x05ready\x12\x18\n" +
"\aversion\x18\x02 \x01(\tR\aversion\x12\x1a\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" + "\x06Driver\x12>\n" +
"\x06Launch\x12\x1d.uatu.driver.v1.LaunchRequest\x1a\x15.uatu.driver.v1.Empty\x129\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" + "\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" + "\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" + "\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" + "\n" +
"Screenshot\x12\x15.uatu.driver.v1.Empty\x1a\x15.uatu.driver.v1.Image\x12A\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" + "\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" + "\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" "\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 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{ var file_driverpb_driver_proto_goTypes = []any{
(*Empty)(nil), // 0: uatu.driver.v1.Empty (*Empty)(nil), // 0: uatu.driver.v1.Empty
(*LaunchRequest)(nil), // 1: uatu.driver.v1.LaunchRequest (*LaunchRequest)(nil), // 1: uatu.driver.v1.LaunchRequest
(*Point)(nil), // 2: uatu.driver.v1.Point (*Point)(nil), // 2: uatu.driver.v1.Point
(*Text)(nil), // 3: uatu.driver.v1.Text (*Text)(nil), // 3: uatu.driver.v1.Text
(*Selector)(nil), // 4: uatu.driver.v1.Selector (*Selector)(nil), // 4: uatu.driver.v1.Selector
(*Image)(nil), // 5: uatu.driver.v1.Image (*Image)(nil), // 5: uatu.driver.v1.Image
(*HierarchyJSON)(nil), // 6: uatu.driver.v1.HierarchyJSON (*HierarchyJSON)(nil), // 6: uatu.driver.v1.HierarchyJSON
(*Duration)(nil), // 7: uatu.driver.v1.Duration (*Duration)(nil), // 7: uatu.driver.v1.Duration
(*HealthStatus)(nil), // 8: uatu.driver.v1.HealthStatus (*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{ var file_driverpb_driver_proto_depIdxs = []int32{
1, // 0: uatu.driver.v1.Driver.Launch:input_type -> uatu.driver.v1.LaunchRequest 2, // 0: uatu.driver.v1.SwipeRequest.from:type_name -> uatu.driver.v1.Point
0, // 1: uatu.driver.v1.Driver.Terminate:input_type -> uatu.driver.v1.Empty 2, // 1: uatu.driver.v1.SwipeRequest.to:type_name -> uatu.driver.v1.Point
2, // 2: uatu.driver.v1.Driver.Tap:input_type -> uatu.driver.v1.Point 13, // 2: uatu.driver.v1.LogEntries.entries:type_name -> uatu.driver.v1.LogEntry
4, // 3: uatu.driver.v1.Driver.TapSelector:input_type -> uatu.driver.v1.Selector 1, // 3: uatu.driver.v1.Driver.Launch:input_type -> uatu.driver.v1.LaunchRequest
3, // 4: uatu.driver.v1.Driver.InputText:input_type -> uatu.driver.v1.Text 0, // 4: uatu.driver.v1.Driver.Terminate:input_type -> uatu.driver.v1.Empty
0, // 5: uatu.driver.v1.Driver.Screenshot:input_type -> uatu.driver.v1.Empty 2, // 5: uatu.driver.v1.Driver.Tap:input_type -> uatu.driver.v1.Point
0, // 6: uatu.driver.v1.Driver.Hierarchy:input_type -> uatu.driver.v1.Empty 4, // 6: uatu.driver.v1.Driver.TapSelector:input_type -> uatu.driver.v1.Selector
7, // 7: uatu.driver.v1.Driver.WaitForIdle:input_type -> uatu.driver.v1.Duration 3, // 7: uatu.driver.v1.Driver.InputText:input_type -> uatu.driver.v1.Text
0, // 8: uatu.driver.v1.Driver.Health:input_type -> uatu.driver.v1.Empty 9, // 8: uatu.driver.v1.Driver.Swipe:input_type -> uatu.driver.v1.SwipeRequest
0, // 9: uatu.driver.v1.Driver.Launch:output_type -> uatu.driver.v1.Empty 10, // 9: uatu.driver.v1.Driver.PressKey:input_type -> uatu.driver.v1.PressKeyRequest
0, // 10: uatu.driver.v1.Driver.Terminate:output_type -> uatu.driver.v1.Empty 0, // 10: uatu.driver.v1.Driver.Screenshot:input_type -> uatu.driver.v1.Empty
0, // 11: uatu.driver.v1.Driver.Tap:output_type -> uatu.driver.v1.Empty 0, // 11: uatu.driver.v1.Driver.Hierarchy:input_type -> uatu.driver.v1.Empty
0, // 12: uatu.driver.v1.Driver.TapSelector:output_type -> uatu.driver.v1.Empty 11, // 12: uatu.driver.v1.Driver.RecentLogs:input_type -> uatu.driver.v1.RecentLogsRequest
0, // 13: uatu.driver.v1.Driver.InputText:output_type -> uatu.driver.v1.Empty 7, // 13: uatu.driver.v1.Driver.WaitForIdle:input_type -> uatu.driver.v1.Duration
5, // 14: uatu.driver.v1.Driver.Screenshot:output_type -> uatu.driver.v1.Image 0, // 14: uatu.driver.v1.Driver.Health:input_type -> uatu.driver.v1.Empty
6, // 15: uatu.driver.v1.Driver.Hierarchy:output_type -> uatu.driver.v1.HierarchyJSON 0, // 15: uatu.driver.v1.Driver.Launch:output_type -> uatu.driver.v1.Empty
0, // 16: uatu.driver.v1.Driver.WaitForIdle:output_type -> uatu.driver.v1.Empty 0, // 16: uatu.driver.v1.Driver.Terminate:output_type -> uatu.driver.v1.Empty
8, // 17: uatu.driver.v1.Driver.Health:output_type -> uatu.driver.v1.HealthStatus 0, // 17: uatu.driver.v1.Driver.Tap:output_type -> uatu.driver.v1.Empty
9, // [9:18] is the sub-list for method output_type 0, // 18: uatu.driver.v1.Driver.TapSelector:output_type -> uatu.driver.v1.Empty
0, // [0:9] is the sub-list for method input_type 0, // 19: uatu.driver.v1.Driver.InputText:output_type -> uatu.driver.v1.Empty
0, // [0:0] is the sub-list for extension type_name 0, // 20: uatu.driver.v1.Driver.Swipe:output_type -> uatu.driver.v1.Empty
0, // [0:0] is the sub-list for extension extendee 0, // 21: uatu.driver.v1.Driver.PressKey:output_type -> uatu.driver.v1.Empty
0, // [0:0] is the sub-list for field type_name 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() } func init() { file_driverpb_driver_proto_init() }
@@ -572,7 +881,7 @@ func file_driverpb_driver_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_driverpb_driver_proto_rawDesc), len(file_driverpb_driver_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_driverpb_driver_proto_rawDesc), len(file_driverpb_driver_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 9, NumMessages: 14,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },
+34
View File
@@ -13,9 +13,12 @@ service Driver {
rpc Tap(Point) returns (Empty); rpc Tap(Point) returns (Empty);
rpc TapSelector(Selector) returns (Empty); rpc TapSelector(Selector) returns (Empty);
rpc InputText(Text) returns (Empty); rpc InputText(Text) returns (Empty);
rpc Swipe(SwipeRequest) returns (Empty);
rpc PressKey(PressKeyRequest) returns (Empty);
rpc Screenshot(Empty) returns (Image); rpc Screenshot(Empty) returns (Image);
rpc Hierarchy(Empty) returns (HierarchyJSON); rpc Hierarchy(Empty) returns (HierarchyJSON);
rpc RecentLogs(RecentLogsRequest) returns (LogEntries);
rpc WaitForIdle(Duration) returns (Empty); rpc WaitForIdle(Duration) returns (Empty);
rpc Health(Empty) returns (HealthStatus); rpc Health(Empty) returns (HealthStatus);
@@ -64,3 +67,34 @@ message HealthStatus {
string version = 2; string version = 2;
string platform = 3; 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_Tap_FullMethodName = "/uatu.driver.v1.Driver/Tap"
Driver_TapSelector_FullMethodName = "/uatu.driver.v1.Driver/TapSelector" Driver_TapSelector_FullMethodName = "/uatu.driver.v1.Driver/TapSelector"
Driver_InputText_FullMethodName = "/uatu.driver.v1.Driver/InputText" 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_Screenshot_FullMethodName = "/uatu.driver.v1.Driver/Screenshot"
Driver_Hierarchy_FullMethodName = "/uatu.driver.v1.Driver/Hierarchy" 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_WaitForIdle_FullMethodName = "/uatu.driver.v1.Driver/WaitForIdle"
Driver_Health_FullMethodName = "/uatu.driver.v1.Driver/Health" 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) Tap(ctx context.Context, in *Point, opts ...grpc.CallOption) (*Empty, error)
TapSelector(ctx context.Context, in *Selector, 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) 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) Screenshot(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Image, error)
Hierarchy(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*HierarchyJSON, 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) WaitForIdle(ctx context.Context, in *Duration, opts ...grpc.CallOption) (*Empty, error)
Health(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*HealthStatus, 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 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) { func (c *driverClient) Screenshot(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Image, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Image) out := new(Image)
@@ -123,6 +149,16 @@ func (c *driverClient) Hierarchy(ctx context.Context, in *Empty, opts ...grpc.Ca
return out, nil 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) { func (c *driverClient) WaitForIdle(ctx context.Context, in *Duration, opts ...grpc.CallOption) (*Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Empty) out := new(Empty)
@@ -152,8 +188,11 @@ type DriverServer interface {
Tap(context.Context, *Point) (*Empty, error) Tap(context.Context, *Point) (*Empty, error)
TapSelector(context.Context, *Selector) (*Empty, error) TapSelector(context.Context, *Selector) (*Empty, error)
InputText(context.Context, *Text) (*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) Screenshot(context.Context, *Empty) (*Image, error)
Hierarchy(context.Context, *Empty) (*HierarchyJSON, error) Hierarchy(context.Context, *Empty) (*HierarchyJSON, error)
RecentLogs(context.Context, *RecentLogsRequest) (*LogEntries, error)
WaitForIdle(context.Context, *Duration) (*Empty, error) WaitForIdle(context.Context, *Duration) (*Empty, error)
Health(context.Context, *Empty) (*HealthStatus, error) Health(context.Context, *Empty) (*HealthStatus, error)
mustEmbedUnimplementedDriverServer() mustEmbedUnimplementedDriverServer()
@@ -181,12 +220,21 @@ func (UnimplementedDriverServer) TapSelector(context.Context, *Selector) (*Empty
func (UnimplementedDriverServer) InputText(context.Context, *Text) (*Empty, error) { func (UnimplementedDriverServer) InputText(context.Context, *Text) (*Empty, error) {
return nil, status.Error(codes.Unimplemented, "method InputText not implemented") 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) { func (UnimplementedDriverServer) Screenshot(context.Context, *Empty) (*Image, error) {
return nil, status.Error(codes.Unimplemented, "method Screenshot not implemented") return nil, status.Error(codes.Unimplemented, "method Screenshot not implemented")
} }
func (UnimplementedDriverServer) Hierarchy(context.Context, *Empty) (*HierarchyJSON, error) { func (UnimplementedDriverServer) Hierarchy(context.Context, *Empty) (*HierarchyJSON, error) {
return nil, status.Error(codes.Unimplemented, "method Hierarchy not implemented") 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) { func (UnimplementedDriverServer) WaitForIdle(context.Context, *Duration) (*Empty, error) {
return nil, status.Error(codes.Unimplemented, "method WaitForIdle not implemented") 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) 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) { func _Driver_Screenshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Empty) in := new(Empty)
if err := dec(in); err != nil { 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) 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) { func _Driver_WaitForIdle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Duration) in := new(Duration)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@@ -403,6 +505,14 @@ var Driver_ServiceDesc = grpc.ServiceDesc{
MethodName: "InputText", MethodName: "InputText",
Handler: _Driver_InputText_Handler, Handler: _Driver_InputText_Handler,
}, },
{
MethodName: "Swipe",
Handler: _Driver_Swipe_Handler,
},
{
MethodName: "PressKey",
Handler: _Driver_PressKey_Handler,
},
{ {
MethodName: "Screenshot", MethodName: "Screenshot",
Handler: _Driver_Screenshot_Handler, Handler: _Driver_Screenshot_Handler,
@@ -411,6 +521,10 @@ var Driver_ServiceDesc = grpc.ServiceDesc{
MethodName: "Hierarchy", MethodName: "Hierarchy",
Handler: _Driver_Hierarchy_Handler, Handler: _Driver_Hierarchy_Handler,
}, },
{
MethodName: "RecentLogs",
Handler: _Driver_RecentLogs_Handler,
},
{ {
MethodName: "WaitForIdle", MethodName: "WaitForIdle",
Handler: _Driver_WaitForIdle_Handler, Handler: _Driver_WaitForIdle_Handler,
+3
View File
@@ -18,8 +18,11 @@ func TestDriverServiceDescriptor(t *testing.T) {
"Tap": true, "Tap": true,
"TapSelector": true, "TapSelector": true,
"InputText": true, "InputText": true,
"Swipe": true,
"PressKey": true,
"Screenshot": true, "Screenshot": true,
"Hierarchy": true, "Hierarchy": true,
"RecentLogs": true,
"WaitForIdle": true, "WaitForIdle": true,
"Health": 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 platform: String? = null,
val appPackage: String? = null, val appPackage: String? = null,
val snapshots: Map<String, Any?>? = null, val snapshots: Map<String, Any?>? = null,
val exceptions: List<Map<String, Any?>>? = null,
val extractor: String? = null, val extractor: String? = null,
val result: Any? = null, val result: Any? = null,
val error: String? = null, val error: String? = null,
@@ -50,8 +51,16 @@ data class Message(
fun resume(id: Long): Message = Message(MessageType.RESUME, id = id) fun resume(id: Long): Message = Message(MessageType.RESUME, id = id)
fun state(id: Long, snapshots: Map<String, Any?>): Message = fun state(
Message(MessageType.STATE, id = id, snapshots = snapshots) 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 = fun extractResult(id: Long, extractor: String, result: Any?, error: String? = null): Message =
Message(MessageType.EXTRACT_RESULT, id = id, extractor = extractor, result = result, error = error) Message(MessageType.EXTRACT_RESULT, id = id, extractor = extractor, result = result, error = error)
@@ -106,6 +115,15 @@ object Protocol {
} }
json.put("snapshots", snapshotsJson) 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.extractor?.let { json.put("extractor", it) }
message.result?.let { json.put("result", wrap(it)) } message.result?.let { json.put("result", wrap(it)) }
message.error?.let { json.put("error", it) } message.error?.let { json.put("error", it) }
@@ -126,6 +144,14 @@ object Protocol {
snapshots = json.optJSONObject("snapshots")?.let { snapshotsJson -> snapshots = json.optJSONObject("snapshots")?.let { snapshotsJson ->
snapshotsJson.keys().asSequence().associateWith { unwrap(snapshotsJson.get(it)) } 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"), extractor = json.optStringOrNull("extractor"),
result = if (json.has("result") && !json.isNull("result")) unwrap(json.get("result")) else null, result = if (json.has("result") && !json.isNull("result")) unwrap(json.get("result")) else null,
error = json.optStringOrNull("error"), error = json.optStringOrNull("error"),
@@ -36,6 +36,16 @@ object Uatu {
activeRuntime.register(name, function) 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 @Synchronized
internal fun stopForTest() { internal fun stopForTest() {
runtime?.stop() runtime?.stop()
@@ -8,23 +8,30 @@ internal class UatuRuntime(
private val version: String, private val version: String,
private val platform: String, private val platform: String,
private val appPackage: String, private val appPackage: String,
private val exceptionRecorder: ExceptionRecorder = ExceptionRecorder(),
) { ) {
private val extractors = LinkedHashMap<String, () -> Any?>() private val extractors = LinkedHashMap<String, () -> Any?>()
@Volatile private var sender: SocketClient.MessageSender? = null @Volatile private var sender: SocketClient.MessageSender? = null
private val socketClient = SocketClient(transport, AgentHandler()) private val socketClient = SocketClient(transport, AgentHandler())
fun start() { fun start() {
exceptionRecorder.install()
socketClient.start() socketClient.start()
} }
fun stop() { fun stop() {
socketClient.stop() socketClient.stop()
exceptionRecorder.uninstall()
} }
fun register(name: String, extractor: () -> Any?) { fun register(name: String, extractor: () -> Any?) {
synchronized(extractors) { extractors[name] = extractor } synchronized(extractors) { extractors[name] = extractor }
} }
fun reportError(throwable: Throwable) {
exceptionRecorder.record(throwable)
}
internal fun snapshot(): Map<String, Any?> { internal fun snapshot(): Map<String, Any?> {
val drained = synchronized(extractors) { LinkedHashMap(extractors) } val drained = synchronized(extractors) { LinkedHashMap(extractors) }
val result = LinkedHashMap<String, Any?>(drained.size) val result = LinkedHashMap<String, Any?>(drained.size)
@@ -67,9 +74,17 @@ internal class UatuRuntime(
Log.w(LOG_TAG, "snapshot failed: $cause") Log.w(LOG_TAG, "snapshot failed: $cause")
emptyMap() 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 val activeSender = sender ?: return
try { try {
activeSender.send(Message.state(id, snapshots)) activeSender.send(Message.state(id, snapshots, exceptions))
} catch (cause: Exception) { } catch (cause: Exception) {
Log.w(LOG_TAG, "failed to send STATE: $cause") 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")) 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() { @Test fun streamsMultipleFrames() {
val messages = listOf( val messages = listOf(
Message.hello("v", "android", "com.x"), Message.hello("v", "android", "com.x"),
@@ -6,12 +6,22 @@ interface DriverBackend {
fun tap(x: Int, y: Int) fun tap(x: Int, y: Int)
fun tapSelector(selector: String) fun tapSelector(selector: String)
fun inputText(text: 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 screenshot(): Triple<ByteArray, Int, Int>
fun hierarchy(): String fun hierarchy(): String
fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine>
fun waitForIdle(durationMillis: Long) fun waitForIdle(durationMillis: Long)
fun healthy(): Boolean 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 * StubDriverBackend records calls but takes no real device action. Real
* Maestro integration arrives in a follow-up; v0.1 wires the gRPC plumbing * 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 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) { override fun terminate(bundleId: String) {
@@ -101,6 +161,52 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
runAdb(listOf("shell", "input", "text", text.replace(" ", "%s"))) 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>) { private fun runAdb(arguments: List<String>) {
try { try {
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() 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.HierarchyJSON
import dev.uatu.driver.v1.Image import dev.uatu.driver.v1.Image
import dev.uatu.driver.v1.LaunchRequest 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.Point
import dev.uatu.driver.v1.PressKeyRequest
import dev.uatu.driver.v1.RecentLogsRequest
import dev.uatu.driver.v1.Selector import dev.uatu.driver.v1.Selector
import dev.uatu.driver.v1.SwipeRequest
import dev.uatu.driver.v1.Text import dev.uatu.driver.v1.Text
import io.grpc.stub.StreamObserver import io.grpc.stub.StreamObserver
import java.util.concurrent.atomic.AtomicReference 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>) { override fun screenshot(request: Empty, responseObserver: StreamObserver<Image>) {
runRpc(responseObserver) { runRpc(responseObserver) {
val (png, width, height) = backend.screenshot() 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.Empty
import dev.uatu.driver.v1.LaunchRequest import dev.uatu.driver.v1.LaunchRequest
import dev.uatu.driver.v1.Point 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 dev.uatu.driver.v1.Text
import io.grpc.ManagedChannel import io.grpc.ManagedChannel
import io.grpc.inprocess.InProcessChannelBuilder import io.grpc.inprocess.InProcessChannelBuilder
@@ -15,6 +18,8 @@ import org.junit.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue 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 { class DriverServiceTest {
@get:Rule val grpcCleanup: GrpcCleanupRule = GrpcCleanupRule() @get:Rule val grpcCleanup: GrpcCleanupRule = GrpcCleanupRule()
@@ -100,6 +105,54 @@ class DriverServiceTest {
assertEquals(123L, observed) 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() { @Test fun healthReportsPlatformAndVersion() {
val backend = StubDriverBackend("android") val backend = StubDriverBackend("android")
val client = newClient(backend) val client = newClient(backend)