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