mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
274 lines
7.5 KiB
Go
274 lines
7.5 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/priyanshujain/uatu/internal/agent"
|
|
"github.com/priyanshujain/uatu/internal/driver"
|
|
"github.com/priyanshujain/uatu/internal/hierarchy"
|
|
"github.com/priyanshujain/uatu/internal/ltl"
|
|
"github.com/priyanshujain/uatu/internal/trace"
|
|
"github.com/priyanshujain/uatu/internal/verifier"
|
|
)
|
|
|
|
type Options struct {
|
|
Duration time.Duration
|
|
SnapshotTimeout time.Duration
|
|
IdleTimeout time.Duration
|
|
|
|
Connection *agent.Conn
|
|
Driver driver.Driver
|
|
Verifier *verifier.Verifier
|
|
TraceWriter *trace.Writer
|
|
}
|
|
|
|
type Summary struct {
|
|
StartTime time.Time
|
|
EndTime time.Time
|
|
Steps int
|
|
Violations []ViolationRecord
|
|
}
|
|
|
|
type ViolationRecord struct {
|
|
StepIndex int
|
|
Properties []string
|
|
}
|
|
|
|
// Run drives the snapshot/evaluate/release/act loop until the duration
|
|
// elapses or the context is canceled. The caller is responsible for
|
|
// launching the app and connecting the SDK before Run is called, and for
|
|
// terminating the app afterwards.
|
|
func Run(ctx context.Context, options Options) (Summary, error) {
|
|
if err := validate(options); err != nil {
|
|
return Summary{}, err
|
|
}
|
|
|
|
summary := Summary{StartTime: time.Now()}
|
|
deadline := summary.StartTime.Add(options.Duration)
|
|
stepIndex := 0
|
|
for time.Now().Before(deadline) {
|
|
if err := ctx.Err(); err != nil {
|
|
break
|
|
}
|
|
stepIndex++
|
|
stepStart := time.Now()
|
|
|
|
// Fetch hierarchy BEFORE pausing the SDK: uiautomator dump calls
|
|
// waitForIdle internally, and the SDK's Choreographer-held pause
|
|
// stalls the main thread, so dumping during the pause yields the
|
|
// pre-pause (stale) tree. Doing this first also means the spec sees
|
|
// a hierarchy that matches the snapshots captured a moment later.
|
|
tree, hierarchyErr := fetchHierarchy(ctx, options.Driver)
|
|
if hierarchyErr != nil {
|
|
fmt.Printf("warning: step %d hierarchy: %v\n", stepIndex, hierarchyErr)
|
|
}
|
|
treeSize := 0
|
|
if tree != nil {
|
|
treeSize = len(tree.Elements)
|
|
}
|
|
|
|
snapshot, err := snapshotStep(ctx, options)
|
|
if err != nil {
|
|
return summary, fmt.Errorf("step %d snapshot: %w", stepIndex, err)
|
|
}
|
|
|
|
if err := options.Verifier.PushSnapshot(verifier.Snapshots(snapshot.Snapshots), tree); err != nil {
|
|
return summary, fmt.Errorf("step %d push: %w", stepIndex, err)
|
|
}
|
|
fmt.Printf("step %d: screen=%q hierarchy=%d nodes tags=%v\n",
|
|
stepIndex, screenFromSnapshot(snapshot.Snapshots), treeSize, interestingTags(tree))
|
|
verdicts := options.Verifier.EvaluateProperties()
|
|
violations := violationNames(verdicts)
|
|
|
|
nextAction, nextErr := options.Verifier.NextAction()
|
|
var traceAction *trace.Action
|
|
if nextErr == nil {
|
|
traceAction = traceActionFor(nextAction)
|
|
} else if !errors.Is(nextErr, verifier.ErrNoAction) {
|
|
return summary, fmt.Errorf("step %d next action: %w", stepIndex, nextErr)
|
|
}
|
|
|
|
step := trace.Step{
|
|
Index: stepIndex,
|
|
Timestamp: stepStart,
|
|
Screen: screenFromSnapshot(snapshot.Snapshots),
|
|
Snapshots: snapshot.Snapshots,
|
|
Action: traceAction,
|
|
Violations: violations,
|
|
}
|
|
if err := options.TraceWriter.WriteStep(step); err != nil {
|
|
return summary, fmt.Errorf("step %d trace: %w", stepIndex, err)
|
|
}
|
|
summary.Steps = stepIndex
|
|
if len(violations) > 0 {
|
|
summary.Violations = append(summary.Violations, ViolationRecord{
|
|
StepIndex: stepIndex,
|
|
Properties: violations,
|
|
})
|
|
}
|
|
|
|
if err := options.Connection.Release(ctx); err != nil {
|
|
return summary, fmt.Errorf("step %d release: %w", stepIndex, err)
|
|
}
|
|
|
|
if nextErr == nil {
|
|
if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil {
|
|
return summary, fmt.Errorf("step %d apply: %w", stepIndex, err)
|
|
}
|
|
}
|
|
|
|
idleCtx, idleCancel := context.WithTimeout(ctx, options.IdleTimeout)
|
|
_ = options.Driver.WaitForIdle(idleCtx, options.IdleTimeout)
|
|
idleCancel()
|
|
}
|
|
|
|
summary.EndTime = time.Now()
|
|
return summary, nil
|
|
}
|
|
|
|
func validate(options Options) error {
|
|
if options.Connection == nil {
|
|
return errors.New("runner: Connection is required")
|
|
}
|
|
if options.Driver == nil {
|
|
return errors.New("runner: Driver is required")
|
|
}
|
|
if options.Verifier == nil {
|
|
return errors.New("runner: Verifier is required")
|
|
}
|
|
if options.TraceWriter == nil {
|
|
return errors.New("runner: TraceWriter is required")
|
|
}
|
|
if options.Duration <= 0 {
|
|
return errors.New("runner: Duration must be positive")
|
|
}
|
|
if options.SnapshotTimeout <= 0 {
|
|
options.SnapshotTimeout = 5 * time.Second
|
|
}
|
|
if options.IdleTimeout <= 0 {
|
|
options.IdleTimeout = 2 * time.Second
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func snapshotStep(ctx context.Context, options Options) (agent.Message, error) {
|
|
snapshotTimeout := options.SnapshotTimeout
|
|
if snapshotTimeout <= 0 {
|
|
snapshotTimeout = 5 * time.Second
|
|
}
|
|
snapshotCtx, snapshotCancel := context.WithTimeout(ctx, snapshotTimeout)
|
|
defer snapshotCancel()
|
|
return options.Connection.Snapshot(snapshotCtx)
|
|
}
|
|
|
|
func violationNames(verdicts map[string]ltl.Verdict) []string {
|
|
var names []string
|
|
for name, verdict := range verdicts {
|
|
if verdict == ltl.VerdictViolated {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func screenFromSnapshot(snapshots map[string]json.RawMessage) string {
|
|
raw, ok := snapshots["screen"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
var screen string
|
|
_ = json.Unmarshal(raw, &screen)
|
|
return screen
|
|
}
|
|
|
|
func applyAction(ctx context.Context, drv driver.Driver, action verifier.Action, tree *hierarchy.Tree) error {
|
|
switch action.Kind {
|
|
case verifier.ActionKindTap:
|
|
x, y, ok := resolveCoordinates(action, tree)
|
|
if !ok {
|
|
if action.On == "" {
|
|
return nil
|
|
}
|
|
return drv.TapSelector(ctx, action.On)
|
|
}
|
|
return drv.Tap(ctx, x, y)
|
|
case verifier.ActionKindInputText:
|
|
if x, y, ok := resolveCoordinates(action, tree); ok {
|
|
_ = drv.Tap(ctx, x, y)
|
|
} else if action.On != "" {
|
|
_ = drv.TapSelector(ctx, action.On)
|
|
}
|
|
return drv.InputText(ctx, action.Text)
|
|
default:
|
|
return fmt.Errorf("unknown action kind %q", action.Kind)
|
|
}
|
|
}
|
|
|
|
func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) {
|
|
if action.X > 0 && action.Y > 0 {
|
|
return action.X, action.Y, true
|
|
}
|
|
if tree != nil && action.On != "" {
|
|
if element := tree.Find(action.On); element != nil {
|
|
x, y := element.Bounds.Center()
|
|
if x > 0 && y > 0 {
|
|
return x, y, true
|
|
}
|
|
}
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
|
|
func fetchHierarchy(ctx context.Context, drv driver.Driver) (*hierarchy.Tree, error) {
|
|
xmlText, err := drv.Hierarchy(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return hierarchy.Parse(xmlText)
|
|
}
|
|
|
|
// interestingTags collects a short summary of which selectors the current
|
|
// spec cares about are present, so we can read the log and see which
|
|
// generator gates will fire.
|
|
func interestingTags(tree *hierarchy.Tree) []string {
|
|
if tree == nil {
|
|
return nil
|
|
}
|
|
selectors := []string{
|
|
"id:select_language",
|
|
"id:etMobileNumber",
|
|
"id:otp",
|
|
"text:Sign Out Other Devices",
|
|
"text:Sign Out",
|
|
"text:Cancel",
|
|
"text:Skip",
|
|
"descPrefix:customer_row_",
|
|
"descPrefix:supplier_row_",
|
|
}
|
|
var hits []string
|
|
for _, selector := range selectors {
|
|
if elements := tree.FindAll(selector); len(elements) > 0 {
|
|
hits = append(hits, fmt.Sprintf("%s=%d", selector, len(elements)))
|
|
}
|
|
}
|
|
return hits
|
|
}
|
|
|
|
func traceActionFor(action verifier.Action) *trace.Action {
|
|
traceAction := &trace.Action{Kind: string(action.Kind)}
|
|
switch action.Kind {
|
|
case verifier.ActionKindTap:
|
|
// Selector lives in the trace step's action.text field for now —
|
|
// trace.Action only has X/Y/Text and we don't resolve coordinates
|
|
// at the runner layer.
|
|
traceAction.Text = action.On
|
|
case verifier.ActionKindInputText:
|
|
traceAction.Text = action.Text
|
|
}
|
|
return traceAction
|
|
}
|