Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions pkg/executor/flow_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
168 changes: 168 additions & 0 deletions pkg/executor/tap_options.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading