feat: bounded retry for a dropped tap, and poll the screen more gently

Running spotify_play against the real phone showed a tap can be delivered
to the app and then never acted on (the dispatcher lists the Spotify window
as the touch target, and the screen does not move). The run log also showed
step durations were printed as 0s while the recorded ones were correct.

- wait steps accept "retry N": on timeout, re-run the step above and wait
  again, up to N extra times. Only a wait may use it, it needs a step above
  it, and it can only press something already matched by text. spotify_play
  uses it on the search box and on the playback check.
- The success log line printed the duration before the deferred assignment
  ran. It now records the duration first.
- uiautomator dump starts a JVM on the phone, and the wait helpers polled
  every 400ms. ui.PollInterval is now 1500ms, which cuts that load on a
  phone that is already short of memory.

docs/phase-3-spotify-recipe.md logs attempts 7-10: the recipe now reaches
tap_row and picks "Honky Tonk Women" at (540,878), but the app stops acting
on input on this loaded phone, so the end-to-end run is still outstanding.
This commit is contained in:
2026-10-16 14:12:01 +11:00
parent 767844cacc
commit ab69f37aa4
6 changed files with 175 additions and 23 deletions

View File

@@ -96,14 +96,34 @@ func Run(r *adb.Runner, rec *Recipe, opts Options) *RunResult {
w, h, _ := ui.ScreenSize(r) // best effort: bounds checking degrades gracefully
deadline := started.Add(rec.TotalTimeout)
for i, step := range rec.Steps {
if remaining := time.Until(deadline); remaining <= 0 {
return fail(fmt.Errorf("recipe timed out after %s at step %d (%s)", rec.TotalTimeout, i+1, step.Verb))
}
sr := executeStep(r, rec, step, i, params, w, h, deadline, logf)
res.Steps = append(res.Steps, sr)
if !sr.OK {
return fail(fmt.Errorf("step %d (%s %s) failed: %s", i+1, step.Verb, strings.Join(step.Args, " "), sr.Error))
for i := 0; i < len(rec.Steps); i++ {
step := rec.Steps[i]
var sr StepResult
for attempt := 0; ; attempt++ {
if remaining := time.Until(deadline); remaining <= 0 {
return fail(fmt.Errorf("recipe timed out after %s at step %d (%s)", rec.TotalTimeout, i+1, step.Verb))
}
sr = executeStep(r, rec, step, i, params, w, h, deadline, logf)
res.Steps = append(res.Steps, sr)
if sr.OK {
break
}
// A wait step with "retry N" presses the step above again: on a
// busy phone a tap can be delivered but never acted on, or dropped.
if attempt >= step.Retry {
return fail(fmt.Errorf("step %d (%s %s) failed: %s", i+1, step.Verb, strings.Join(step.Args, " "), sr.Error))
}
if i == 0 {
return fail(fmt.Errorf("step %d: retry needs a step above it to repeat", i+1))
}
logf(" step %d %s: retrying, pressing %s again", i+1, step.Verb, rec.Steps[i-1].Verb)
again := executeStep(r, rec, rec.Steps[i-1], i-1, params, w, h, deadline, logf)
res.Steps = append(res.Steps, again)
if !again.OK {
// The wait is the real failure; the repeat just could not run.
return fail(fmt.Errorf("step %d (%s %s) failed: %s; repeating %s failed too: %s",
i+1, step.Verb, strings.Join(step.Args, " "), sr.Error, rec.Steps[i-1].Verb, again.Error))
}
}
}
@@ -184,7 +204,7 @@ func executeStep(r *adb.Runner, rec *Recipe, step Step, index int, params map[st
}
return fmt.Errorf("%s never came to the foreground within %s (foreground=%s)", pkg, timeout, got)
}
time.Sleep(400 * time.Millisecond)
time.Sleep(ui.PollInterval)
}
case "wait":
@@ -269,6 +289,8 @@ func executeStep(r *adb.Runner, rec *Recipe, step Step, index int, params map[st
}
sr.OK = true
sr.Duration = time.Since(started)
sr.DurationMS = sr.Duration.Milliseconds()
logf(" step %d %s %v: ok (%s)", sr.Index, step.Verb, args, sr.Duration.Round(time.Millisecond))
return sr
}
@@ -321,7 +343,7 @@ func tapRow(r *adb.Runner, rec *Recipe, query string, timeout time.Duration, w,
} else {
lastErr = err
}
time.Sleep(500 * time.Millisecond)
time.Sleep(ui.PollInterval)
}
if lastErr == nil {
lastErr = fmt.Errorf("no result row matches %q", query)

View File

@@ -31,6 +31,9 @@
// - launch start the recipe's app
// - foreground 30s wait until the recipe's app is in front
// - wait "Search" 30s wait for an element whose text matches
// - wait exact "Pause" 60s retry 3
// if it never appears, re-run the step above and
// wait again (a busy phone can drop a tap)
// - wait_gone "Recents" 30s wait until no element matches the text
// - tap "Search" tap the element whose text matches (FR-4)
// - tap exact "Pause" tap an element whose text matches exactly
@@ -50,6 +53,7 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
@@ -80,7 +84,10 @@ type Step struct {
Args []string
Exact bool
Timeout time.Duration
Line int
// Retry, on a wait step, re-runs the preceding step and waits again, up to
// this many extra times. A busy phone can drop a tap.
Retry int
Line int
}
// Defaults used when a recipe does not set its own timeouts.
@@ -312,6 +319,26 @@ func parseStep(body string, lineNo int) (*Step, error) {
args = args[1:]
}
// "retry N" is pulled out first, so the timeout can still be the last word.
for i := 0; i < len(args); i++ {
if !strings.EqualFold(args[i], "retry") {
continue
}
if i+1 >= len(args) {
return nil, fmt.Errorf("line %d: retry needs a number, as in retry 3", lineNo)
}
n, err := strconv.Atoi(args[i+1])
if err != nil || n < 1 {
return nil, fmt.Errorf("line %d: retry needs a positive number, got %q", lineNo, args[i+1])
}
if verb != "wait" {
return nil, fmt.Errorf("line %d: retry is only allowed on a wait step", lineNo)
}
step.Retry = n
args = append(args[:i], args[i+2:]...)
break
}
// A trailing duration is the step timeout, except for sleep where the
// duration is the step itself.
if !spec.durArg && len(args) > spec.min {

View File

@@ -172,6 +172,9 @@ func TestSpotifyRecipeIsValid(t *testing.T) {
if last.Verb != "wait" || last.Args[0] != "Pause" {
t.Fatalf("the last step must confirm playback, got %+v", last)
}
if last.Retry < 1 {
t.Fatal("the playback check must be allowed to press the row again")
}
}
func TestLoadAndList(t *testing.T) {
@@ -205,6 +208,38 @@ func TestLoadAndList(t *testing.T) {
}
}
func TestRetryOnWait(t *testing.T) {
rec, err := Parse("steps:\n - tap_row \"{query}\" 40s\n - wait exact \"Pause\" 45s retry 3\n")
if err != nil {
t.Fatalf("Parse: %v", err)
}
wait := rec.Steps[1]
if wait.Retry != 3 || wait.Timeout != 45*time.Second || !wait.Exact || wait.Args[0] != "Pause" {
t.Fatalf("retry wait step = %+v", wait)
}
// retry without a duration still works.
rec, err = Parse("steps:\n - tap_row \"x\"\n - wait \"Pause\" retry 2\n")
if err != nil {
t.Fatalf("Parse: %v", err)
}
if rec.Steps[1].Retry != 2 || rec.Steps[1].Timeout != 0 {
t.Fatalf("retry step = %+v", rec.Steps[1])
}
}
func TestRetryRejectsBadUse(t *testing.T) {
for name, src := range map[string]string{
"on a tap": "steps:\n - tap \"x\" retry 3\n",
"no number": "steps:\n - tap_row \"x\"\n - wait \"y\" retry\n",
"zero": "steps:\n - tap_row \"x\"\n - wait \"y\" retry 0\n",
"not a number": "steps:\n - tap_row \"x\"\n - wait \"y\" retry soon\n",
} {
if _, err := Parse(src); err == nil {
t.Errorf("%s: expected a parse error", name)
}
}
}
func TestForegroundStep(t *testing.T) {
rec, err := Parse("app: com.spotify.music\nsteps:\n - launch\n - foreground 30s\n")
if err != nil {

View File

@@ -11,6 +11,13 @@ import (
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb"
)
// PollInterval is how long the wait helpers sleep between screen dumps.
//
// A dump runs a whole uiautomator JVM on the phone, which is expensive on a
// loaded device: polling every 400ms starves the very app Artemis is driving.
// A slower poll costs a moment of latency and keeps the phone responsive.
const PollInterval = 1500 * time.Millisecond
// Android key events we use.
const (
KeyHome = 3
@@ -173,7 +180,7 @@ func WaitForText(r *adb.Runner, query string, exact bool, timeout time.Duration)
}
}
}
time.Sleep(400 * time.Millisecond)
time.Sleep(PollInterval)
}
if len(seen) > 0 {
return nil, fmt.Errorf("text %q is on screen but no enabled match could be tapped", query)
@@ -194,7 +201,7 @@ func WaitForNoText(r *adb.Runner, query string, exact bool, timeout time.Duratio
if err == nil && len(h.FindText(query, exact)) == 0 {
return nil
}
time.Sleep(400 * time.Millisecond)
time.Sleep(PollInterval)
}
return fmt.Errorf("text %q was still on screen after %s", query, timeout)
}