Two defects found by running spotify_play against the real phone. - Step durations were always zero in the run log: executeStep returned a copy, so the deferred assignment never reached the caller (FR-10). It now returns a named value, with a regression test. - The phone was sitting on the Android Settings app, and wait "Search" was satisfied by Settings' own "Search Settings" entry, so the recipe tapped the Settings search bar. New "foreground" step waits until the recipe's app really is in front; spotify_play uses it right after launch. docs/phase-3-spotify-recipe.md now logs all six live attempts and states plainly that the end-to-end playback run has not completed: the phone is showing a Settings "Verify that it's you" biometric prompt, which needs Sam and is left untouched.
235 lines
6.9 KiB
Go
235 lines
6.9 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)
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|