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