From 591471ca74747910ebcaa21f5da42469ffb68e7f Mon Sep 17 00:00:00 2001 From: Om Narayan Date: Fri, 10 Apr 2026 02:24:12 +0530 Subject: [PATCH] Implement tap options (repeat, delay, retryTapIfNoChange, waitToSettleTimeoutMs) Add executor-level tap option handling that wraps driver.Execute() for TapOnStep, DoubleTapOnStep, and LongPressOnStep. All 5 drivers benefit automatically with no driver-side changes. - repeat/delay: loop taps with configurable delay between them - retryTapIfNoChange: compare UI hierarchy before/after, retry if unchanged - waitToSettleTimeoutMs: poll hierarchy until stable before/after tap Matches Maestro's execution semantics. Fixes #52 --- pkg/executor/flow_runner.go | 10 + pkg/executor/tap_options.go | 168 ++++++++++++++++ pkg/executor/tap_options_test.go | 334 +++++++++++++++++++++++++++++++ 3 files changed, 512 insertions(+) create mode 100644 pkg/executor/tap_options.go create mode 100644 pkg/executor/tap_options_test.go diff --git a/pkg/executor/flow_runner.go b/pkg/executor/flow_runner.go index c538798..f694cc3 100644 --- a/pkg/executor/flow_runner.go +++ b/pkg/executor/flow_runner.go @@ -432,6 +432,11 @@ func (fr *FlowRunner) executeStep(idx int, step flow.Step) (report.Status, strin result = fr.driver.Execute(step) } + // Tap steps - apply repeat/delay/retry/settle options + case *flow.TapOnStep, *flow.DoubleTapOnStep, *flow.LongPressOnStep: + opts, _ := extractTapOptions(step) + result = fr.executeTapWithOptions(step, opts) + // All other steps - delegate to driver default: result = fr.driver.Execute(step) @@ -787,6 +792,11 @@ func (fr *FlowRunner) executeNestedStep(step flow.Step) *core.CommandResult { fr.script.SetCopiedText(text) } } + case *flow.TapOnStep, *flow.DoubleTapOnStep, *flow.LongPressOnStep: + fr.script.ExpandStep(step) + opts, _ := extractTapOptions(step) + result = fr.executeTapWithOptions(step, opts) + default: // Expand variables before driver execution fr.script.ExpandStep(step) diff --git a/pkg/executor/tap_options.go b/pkg/executor/tap_options.go new file mode 100644 index 0000000..ff006fb --- /dev/null +++ b/pkg/executor/tap_options.go @@ -0,0 +1,168 @@ +package executor + +import ( + "bytes" + "time" + + "github.com/devicelab-dev/maestro-runner/pkg/core" + "github.com/devicelab-dev/maestro-runner/pkg/flow" + "github.com/devicelab-dev/maestro-runner/pkg/logger" +) + +// tapOptions holds tap-related execution options extracted from a step. +type tapOptions struct { + Repeat int // Number of times to execute the tap (0 or 1 = once) + DelayMs int // Delay between repeated taps in ms + RetryTapIfNoChange *bool // If true, retry tap if hierarchy unchanged + WaitToSettleTimeoutMs int // Wait for UI to settle before/after tap (ms) +} + +// extractTapOptions extracts tap options from a tap-type step. +// Returns false for non-tap steps. +func extractTapOptions(step flow.Step) (tapOptions, bool) { + switch s := step.(type) { + case *flow.TapOnStep: + return tapOptions{ + Repeat: s.Repeat, + DelayMs: s.DelayMs, + RetryTapIfNoChange: s.RetryTapIfNoChange, + WaitToSettleTimeoutMs: s.WaitToSettleTimeoutMs, + }, true + case *flow.DoubleTapOnStep: + return tapOptions{ + RetryTapIfNoChange: s.RetryTapIfNoChange, + WaitToSettleTimeoutMs: s.WaitToSettleTimeoutMs, + }, true + case *flow.LongPressOnStep: + return tapOptions{ + RetryTapIfNoChange: s.RetryTapIfNoChange, + WaitToSettleTimeoutMs: s.WaitToSettleTimeoutMs, + }, true + default: + return tapOptions{}, false + } +} + +// hasTapOptions returns true if any non-default options are set. +func (opts tapOptions) hasTapOptions() bool { + return opts.Repeat > 1 || opts.DelayMs > 0 || + opts.RetryTapIfNoChange != nil || opts.WaitToSettleTimeoutMs > 0 +} + +const ( + defaultRepeatDelay = 100 // ms, matches Maestro's DEFAULT_REPEAT_DELAY + settleInterval = 200 * time.Millisecond +) + +// waitForSettle polls Hierarchy() until two consecutive snapshots match, +// or the timeout is reached. Returns the final hierarchy snapshot. +// If timeoutMs <= 0, returns the current hierarchy without polling. +func (fr *FlowRunner) waitForSettle(timeoutMs int) []byte { + hierarchy, err := fr.driver.Hierarchy() + if err != nil { + logger.Debug("waitForSettle: Hierarchy() error: %v", err) + return nil + } + if timeoutMs <= 0 { + return hierarchy + } + + deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + for time.Now().Before(deadline) { + time.Sleep(settleInterval) + next, err := fr.driver.Hierarchy() + if err != nil { + logger.Debug("waitForSettle: Hierarchy() error: %v", err) + return hierarchy + } + if bytes.Equal(hierarchy, next) { + return next + } + hierarchy = next + } + return hierarchy +} + +// executeTapWithOptions wraps a tap step with repeat, delay, +// retryTapIfNoChange, and waitToSettleTimeoutMs logic. +// +// Execution order (matching Maestro): +// 1. hierarchyBefore = waitForSettle(waitToSettleTimeoutMs) +// 2. retryLoop (retryTapIfNoChange ? 2 : 1): +// a. repeatLoop with delay between taps +// b. hierarchyAfter = waitForSettle(waitToSettleTimeoutMs) +// c. if hierarchy changed → return +// 3. return last result +func (fr *FlowRunner) executeTapWithOptions(step flow.Step, opts tapOptions) *core.CommandResult { + if !opts.hasTapOptions() { + return fr.driver.Execute(step) + } + + settleTimeout := opts.WaitToSettleTimeoutMs + + // Capture hierarchy before tap (for settle and/or retry comparison) + var hierarchyBefore []byte + if settleTimeout > 0 || opts.RetryTapIfNoChange != nil { + hierarchyBefore = fr.waitForSettle(settleTimeout) + } + + // Retry count: 2 if retryTapIfNoChange, else 1 + retryCount := 1 + if opts.RetryTapIfNoChange != nil && *opts.RetryTapIfNoChange { + retryCount = 2 + } + + repeatCount := opts.Repeat + if repeatCount <= 0 { + repeatCount = 1 + } + + delayMs := opts.DelayMs + if delayMs <= 0 && repeatCount > 1 { + delayMs = defaultRepeatDelay + } + + var lastResult *core.CommandResult + + for attempt := 0; attempt < retryCount; attempt++ { + if fr.ctx.Err() != nil { + return &core.CommandResult{ + Success: false, + Error: fr.ctx.Err(), + Message: "Tap cancelled", + } + } + + // Execute tap (possibly repeated) + for i := 0; i < repeatCount; i++ { + tapStart := time.Now() + lastResult = fr.driver.Execute(step) + if !lastResult.Success { + return lastResult + } + + // Delay between repeated taps (not after the last one) + if repeatCount > 1 && i < repeatCount-1 { + sleepTime := time.Duration(delayMs)*time.Millisecond - time.Since(tapStart) + if sleepTime > 0 { + time.Sleep(sleepTime) + } + } + } + + // Check if UI changed (for retry and settle logic) + if settleTimeout > 0 || opts.RetryTapIfNoChange != nil { + hierarchyAfter := fr.waitForSettle(settleTimeout) + if hierarchyBefore != nil && hierarchyAfter != nil && + !bytes.Equal(hierarchyBefore, hierarchyAfter) { + logger.Debug("Tap caused UI change (attempt %d)", attempt+1) + return lastResult + } + if attempt < retryCount-1 { + logger.Debug("Tap had no UI change, retrying (attempt %d/%d)", attempt+1, retryCount) + } + } + } + + return lastResult +} diff --git a/pkg/executor/tap_options_test.go b/pkg/executor/tap_options_test.go new file mode 100644 index 0000000..c28f529 --- /dev/null +++ b/pkg/executor/tap_options_test.go @@ -0,0 +1,334 @@ +package executor + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/devicelab-dev/maestro-runner/pkg/core" + "github.com/devicelab-dev/maestro-runner/pkg/flow" +) + +func boolPtr(b bool) *bool { return &b } + +// --- extractTapOptions --- + +func TestExtractTapOptions_TapOnStep(t *testing.T) { + step := &flow.TapOnStep{ + Repeat: 3, + DelayMs: 2000, + RetryTapIfNoChange: boolPtr(true), + WaitToSettleTimeoutMs: 5000, + } + opts, ok := extractTapOptions(step) + if !ok { + t.Fatal("expected ok=true for TapOnStep") + } + if opts.Repeat != 3 { + t.Errorf("Repeat=%d, want 3", opts.Repeat) + } + if opts.DelayMs != 2000 { + t.Errorf("DelayMs=%d, want 2000", opts.DelayMs) + } + if opts.RetryTapIfNoChange == nil || !*opts.RetryTapIfNoChange { + t.Error("RetryTapIfNoChange should be true") + } + if opts.WaitToSettleTimeoutMs != 5000 { + t.Errorf("WaitToSettleTimeoutMs=%d, want 5000", opts.WaitToSettleTimeoutMs) + } +} + +func TestExtractTapOptions_DoubleTapOnStep(t *testing.T) { + step := &flow.DoubleTapOnStep{ + RetryTapIfNoChange: boolPtr(false), + WaitToSettleTimeoutMs: 1000, + } + opts, ok := extractTapOptions(step) + if !ok { + t.Fatal("expected ok=true for DoubleTapOnStep") + } + if opts.Repeat != 0 { + t.Errorf("Repeat=%d, want 0", opts.Repeat) + } + if opts.DelayMs != 0 { + t.Errorf("DelayMs=%d, want 0", opts.DelayMs) + } +} + +func TestExtractTapOptions_LongPressOnStep(t *testing.T) { + step := &flow.LongPressOnStep{ + RetryTapIfNoChange: boolPtr(true), + } + opts, ok := extractTapOptions(step) + if !ok { + t.Fatal("expected ok=true for LongPressOnStep") + } + if opts.RetryTapIfNoChange == nil || !*opts.RetryTapIfNoChange { + t.Error("RetryTapIfNoChange should be true") + } +} + +func TestExtractTapOptions_NonTapStep(t *testing.T) { + step := &flow.SwipeStep{} + _, ok := extractTapOptions(step) + if ok { + t.Error("expected ok=false for SwipeStep") + } +} + +// --- hasTapOptions --- + +func TestHasTapOptions(t *testing.T) { + tests := []struct { + name string + opts tapOptions + want bool + }{ + {"all zero", tapOptions{}, false}, + {"repeat=1", tapOptions{Repeat: 1}, false}, + {"repeat=2", tapOptions{Repeat: 2}, true}, + {"delay only", tapOptions{DelayMs: 100}, true}, + {"retry true", tapOptions{RetryTapIfNoChange: boolPtr(true)}, true}, + {"retry false", tapOptions{RetryTapIfNoChange: boolPtr(false)}, true}, + {"settle timeout", tapOptions{WaitToSettleTimeoutMs: 1000}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.opts.hasTapOptions(); got != tt.want { + t.Errorf("hasTapOptions()=%v, want %v", got, tt.want) + } + }) + } +} + +// --- waitForSettle --- + +func TestWaitForSettle_NoTimeout(t *testing.T) { + driver := &mockDriver{ + hierarchyFunc: func() ([]byte, error) { + return []byte(`stable`), nil + }, + } + fr := &FlowRunner{driver: driver} + result := fr.waitForSettle(0) + if string(result) != `stable` { + t.Errorf("expected current hierarchy, got %q", result) + } +} + +func TestWaitForSettle_SettlesQuickly(t *testing.T) { + var calls int32 + driver := &mockDriver{ + hierarchyFunc: func() ([]byte, error) { + n := atomic.AddInt32(&calls, 1) + if n <= 2 { + // First two calls return different values (UI still changing) + return []byte(string(rune('a' + n))), nil + } + // Third call onwards returns stable value + return []byte("stable"), nil + }, + } + fr := &FlowRunner{driver: driver} + result := fr.waitForSettle(3000) + if string(result) != "stable" { + t.Errorf("expected 'stable', got %q", result) + } +} + +func TestWaitForSettle_Timeout(t *testing.T) { + var calls int32 + driver := &mockDriver{ + hierarchyFunc: func() ([]byte, error) { + n := atomic.AddInt32(&calls, 1) + // Always return different content + return []byte(string(rune('a' + n))), nil + }, + } + fr := &FlowRunner{driver: driver} + start := time.Now() + fr.waitForSettle(500) + elapsed := time.Since(start) + if elapsed < 400*time.Millisecond { + t.Errorf("expected to wait ~500ms, only waited %v", elapsed) + } +} + +// --- executeTapWithOptions --- + +func TestExecuteTapWithOptions_NoOptions(t *testing.T) { + var execCount int + driver := &mockDriver{ + executeFunc: func(step flow.Step) *core.CommandResult { + execCount++ + return &core.CommandResult{Success: true} + }, + } + fr := &FlowRunner{ctx: context.Background(), driver: driver} + + result := fr.executeTapWithOptions(&flow.TapOnStep{}, tapOptions{}) + if !result.Success { + t.Error("expected success") + } + if execCount != 1 { + t.Errorf("execCount=%d, want 1", execCount) + } +} + +func TestExecuteTapWithOptions_Repeat(t *testing.T) { + var execCount int + driver := &mockDriver{ + executeFunc: func(step flow.Step) *core.CommandResult { + execCount++ + return &core.CommandResult{Success: true} + }, + } + fr := &FlowRunner{ctx: context.Background(), driver: driver} + + opts := tapOptions{Repeat: 3} + result := fr.executeTapWithOptions(&flow.TapOnStep{}, opts) + if !result.Success { + t.Error("expected success") + } + if execCount != 3 { + t.Errorf("execCount=%d, want 3", execCount) + } +} + +func TestExecuteTapWithOptions_RepeatWithDelay(t *testing.T) { + var timestamps []time.Time + driver := &mockDriver{ + executeFunc: func(step flow.Step) *core.CommandResult { + timestamps = append(timestamps, time.Now()) + return &core.CommandResult{Success: true} + }, + } + fr := &FlowRunner{ctx: context.Background(), driver: driver} + + opts := tapOptions{Repeat: 3, DelayMs: 300} + fr.executeTapWithOptions(&flow.TapOnStep{}, opts) + + if len(timestamps) != 3 { + t.Fatalf("expected 3 taps, got %d", len(timestamps)) + } + // Check delay between taps (at least 200ms to account for timing variance) + for i := 1; i < len(timestamps); i++ { + gap := timestamps[i].Sub(timestamps[i-1]) + if gap < 200*time.Millisecond { + t.Errorf("gap between tap %d and %d = %v, want >= 200ms", i-1, i, gap) + } + } +} + +func TestExecuteTapWithOptions_TapFails(t *testing.T) { + var execCount int + driver := &mockDriver{ + executeFunc: func(step flow.Step) *core.CommandResult { + execCount++ + return &core.CommandResult{Success: false, Message: "element not found"} + }, + } + fr := &FlowRunner{ctx: context.Background(), driver: driver} + + opts := tapOptions{Repeat: 5} + result := fr.executeTapWithOptions(&flow.TapOnStep{}, opts) + if result.Success { + t.Error("expected failure") + } + if execCount != 1 { + t.Errorf("execCount=%d, want 1 (should stop on first failure)", execCount) + } +} + +func TestExecuteTapWithOptions_RetryOnNoChange(t *testing.T) { + var execCount int + driver := &mockDriver{ + executeFunc: func(step flow.Step) *core.CommandResult { + execCount++ + return &core.CommandResult{Success: true} + }, + hierarchyFunc: func() ([]byte, error) { + // Hierarchy never changes — should trigger retry + return []byte("unchanged"), nil + }, + } + fr := &FlowRunner{ctx: context.Background(), driver: driver} + + opts := tapOptions{RetryTapIfNoChange: boolPtr(true)} + result := fr.executeTapWithOptions(&flow.TapOnStep{}, opts) + if !result.Success { + t.Error("expected success") + } + // Should tap twice (retry once) + if execCount != 2 { + t.Errorf("execCount=%d, want 2 (initial + 1 retry)", execCount) + } +} + +func TestExecuteTapWithOptions_RetryOnChange(t *testing.T) { + var execCount int + var hierarchyCall int32 + driver := &mockDriver{ + executeFunc: func(step flow.Step) *core.CommandResult { + execCount++ + return &core.CommandResult{Success: true} + }, + hierarchyFunc: func() ([]byte, error) { + n := atomic.AddInt32(&hierarchyCall, 1) + if n == 1 { + return []byte("before"), nil // before tap + } + return []byte("after"), nil // after tap — changed + }, + } + fr := &FlowRunner{ctx: context.Background(), driver: driver} + + opts := tapOptions{RetryTapIfNoChange: boolPtr(true)} + result := fr.executeTapWithOptions(&flow.TapOnStep{}, opts) + if !result.Success { + t.Error("expected success") + } + // Should tap only once — hierarchy changed, no retry needed + if execCount != 1 { + t.Errorf("execCount=%d, want 1 (UI changed, no retry)", execCount) + } +} + +func TestExecuteTapWithOptions_WaitToSettle(t *testing.T) { + var hierarchyCalls int32 + driver := &mockDriver{ + executeFunc: func(step flow.Step) *core.CommandResult { + return &core.CommandResult{Success: true} + }, + hierarchyFunc: func() ([]byte, error) { + atomic.AddInt32(&hierarchyCalls, 1) + return []byte("stable"), nil + }, + } + fr := &FlowRunner{ctx: context.Background(), driver: driver} + + opts := tapOptions{WaitToSettleTimeoutMs: 1000} + result := fr.executeTapWithOptions(&flow.TapOnStep{}, opts) + if !result.Success { + t.Error("expected success") + } + // Should call Hierarchy at least twice (before settle + after settle) + if atomic.LoadInt32(&hierarchyCalls) < 2 { + t.Errorf("hierarchyCalls=%d, want >= 2", hierarchyCalls) + } +} + +func TestExecuteTapWithOptions_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + driver := &mockDriver{} + fr := &FlowRunner{ctx: ctx, driver: driver} + + opts := tapOptions{Repeat: 5} + result := fr.executeTapWithOptions(&flow.TapOnStep{}, opts) + if result.Success { + t.Error("expected failure due to context cancellation") + } +}