Files
artemis_control/internal/recipe/recipe_test.go
Sam Rolfe ab69f37aa4 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.
2026-10-16 14:12:01 +11:00

270 lines
8.1 KiB
Go

package recipe
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb"
)
const sample = `# a comment
task: spotify_play
description: Search Spotify for a song and play it.
app: com.spotify.music
param: query
row_id: com.spotify.music:id/row_root
step_timeout: 20s
total_timeout: 150s
guard "Log in" => Spotify is signed out
guard "Update Spotify" => Spotify wants an update
steps:
- launch
- wait "Search" 30s
- tap "Search"
- tap_if "Clear search query"
- wait_gone "Recents" 30s
- type "{query}"
- tap_row "{query}" 40s
- wait exact "Pause" 40s
- key enter
- sleep 1s
`
func TestParseSampleRecipe(t *testing.T) {
rec, err := Parse(sample)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if rec.Task != "spotify_play" || rec.App != "com.spotify.music" {
t.Fatalf("unexpected header: %+v", rec)
}
if len(rec.Params) != 1 || rec.Params[0] != "query" {
t.Fatalf("params = %v", rec.Params)
}
if rec.StepTimeout != 20*time.Second || rec.TotalTimeout != 150*time.Second {
t.Fatalf("timeouts = %s / %s", rec.StepTimeout, rec.TotalTimeout)
}
if len(rec.Guards) != 2 || rec.Guards[0].Text != "Log in" || rec.Guards[0].Reason != "Spotify is signed out" {
t.Fatalf("guards = %+v", rec.Guards)
}
if len(rec.Steps) != 10 {
t.Fatalf("steps = %d, want 10", len(rec.Steps))
}
wait := rec.Steps[1]
if wait.Verb != "wait" || wait.Args[0] != "Search" || wait.Timeout != 30*time.Second {
t.Fatalf("wait step = %+v", wait)
}
exact := rec.Steps[7]
if exact.Verb != "wait" || !exact.Exact || exact.Args[0] != "Pause" || exact.Timeout != 40*time.Second {
t.Fatalf("exact wait step = %+v", exact)
}
sleep := rec.Steps[9]
if sleep.Verb != "sleep" || sleep.Timeout != time.Second {
t.Fatalf("sleep step = %+v", sleep)
}
}
func TestParseRejectsBadInput(t *testing.T) {
cases := map[string]string{
"unknown verb": "steps:\n - teleport \"Home\"\n",
"missing settings": "steps:\n - wait \"x\"\n - frobnicate: 3\n",
"bad duration": "step_timeout: soon\nsteps:\n - wait \"x\"\n",
"no steps": "task: nothing\n",
"guard needs text": "guard only a reason => nope\nsteps:\n - home\n",
"guard needs why": "guard \"Log in\" =>\nsteps:\n - home\n",
"unknown key name": "steps:\n - key volup\n",
"unclosed quote": "steps:\n - wait \"Search\n",
"too many args": "steps:\n - type \"a\" \"b\"\n",
"unknown setting": "colour: blue\nsteps:\n - home\n",
}
for name, src := range cases {
if _, err := Parse(src); err == nil {
t.Errorf("%s: expected a parse error", name)
}
}
}
func TestSubstitute(t *testing.T) {
got, err := Substitute("play {query} now", map[string]string{"query": "Honky Tonk Women"})
if err != nil {
t.Fatalf("Substitute: %v", err)
}
if got != "play Honky Tonk Women now" {
t.Fatalf("Substitute = %q", got)
}
for _, bad := range []map[string]string{{}, {"query": " "}} {
if _, err := Substitute("{query}", bad); err == nil {
t.Fatalf("expected an error for params %v", bad)
}
}
if _, err := Substitute("{other}", map[string]string{"query": "x"}); err == nil {
t.Fatal("expected an error for an undeclared placeholder")
}
}
func TestMissingParams(t *testing.T) {
rec, err := Parse(sample)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got := rec.MissingParams(map[string]string{"query": "x"}); len(got) != 0 {
t.Fatalf("MissingParams = %v", got)
}
if got := rec.MissingParams(nil); len(got) != 1 || got[0] != "query" {
t.Fatalf("MissingParams = %v", got)
}
}
func TestParseRecipeWithoutTimeoutsUsesDefaults(t *testing.T) {
rec, err := Parse("steps:\n - home\n")
if err != nil {
t.Fatalf("Parse: %v", err)
}
if rec.StepTimeout != DefaultStepTimeout || rec.TotalTimeout != DefaultTotalTimeout {
t.Fatalf("defaults = %s / %s", rec.StepTimeout, rec.TotalTimeout)
}
}
// TestSpotifyRecipeIsValid keeps the shipped recipe honest: a typo in
// recipes/spotify_play.recipe fails the test suite, not the phone.
func TestSpotifyRecipeIsValid(t *testing.T) {
dir := filepath.Join("..", "..", "recipes")
rec, err := Load(filepath.Join(dir, "spotify_play.recipe"))
if err != nil {
t.Fatalf("Load: %v", err)
}
if rec.Task != "spotify_play" {
t.Fatalf("task = %q", rec.Task)
}
if rec.App != "com.spotify.music" {
t.Fatalf("app = %q", rec.App)
}
if len(rec.MissingParams(map[string]string{"query": "x"})) != 0 {
t.Fatal("spotify_play must accept a query parameter")
}
if len(rec.Guards) < 3 {
t.Fatalf("spotify_play needs guards for its bad screens, got %d", len(rec.Guards))
}
// Every step must be runnable: the parsed steps are what the engine gets.
for i, s := range rec.Steps {
if _, ok := verbs[s.Verb]; !ok {
t.Fatalf("step %d uses unknown verb %q", i+1, s.Verb)
}
}
// The recipe must confirm it is in Spotify before it trusts a text match,
// and it must wait for playback rather than assume it.
var sawForeground bool
for _, s := range rec.Steps {
if s.Verb == "foreground" {
sawForeground = true
}
}
if !sawForeground {
t.Fatal("spotify_play must pin the foreground app before matching text")
}
last := rec.Steps[len(rec.Steps)-1]
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) {
dir := t.TempDir()
write := func(name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("b.recipe", "task: beta\nsteps:\n - home\n")
write("a.recipe", "task: alpha\nsteps:\n - home\n")
list, err := List(dir)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 2 || list[0].Task != "alpha" || list[1].Task != "beta" {
t.Fatalf("List = %+v", list)
}
rec, err := Find(dir, "beta")
if err != nil {
t.Fatalf("Find: %v", err)
}
if rec.Task != "beta" {
t.Fatalf("Find = %+v", rec)
}
if _, err := Find(dir, "gamma"); err == nil || !strings.Contains(err.Error(), "alpha") {
t.Fatalf("Find unknown task: %v", err)
}
}
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 {
t.Fatalf("Parse: %v", err)
}
fg := rec.Steps[1]
if fg.Verb != "foreground" || len(fg.Args) != 0 || fg.Timeout != 30*time.Second {
t.Fatalf("foreground step = %+v", fg)
}
}
// TestStepDurationsAreRecorded guards the run log (FR-10): a step's duration
// must reach the caller, not be swallowed by the return value.
func TestStepDurationsAreRecorded(t *testing.T) {
rec := &Recipe{StepTimeout: time.Second}
step := Step{Verb: "sleep", Timeout: 50 * time.Millisecond}
sr := executeStep(adb.New(""), rec, step, 2, nil, 0, 0, time.Now().Add(time.Minute), func(string, ...any) {})
if !sr.OK {
t.Fatalf("sleep step failed: %s", sr.Error)
}
if sr.Index != 3 {
t.Fatalf("index = %d, want 3", sr.Index)
}
if sr.Duration < 40*time.Millisecond || sr.DurationMS < 40 {
t.Fatalf("duration not recorded: %v (%d ms)", sr.Duration, sr.DurationMS)
}
}