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")
+ }
+}