Phase 3 DoD reached end to end: `artemis run spotify_play query="Rolling Stones Honky Tonk"` played the right song in 13 steps / 35.3s, with playback paused first so the pass proves the row tap started it. Two things stopped it before that, both found on the real phone: - The playback check asserted on an on-screen "Pause" label that Spotify never shows: its transport button keeps the label "Play" while music is playing. The recipe now ends with a `playing` step that reads the app's media session (state PLAYING) and confirms the track title contains the query words. A failure reports the state and the track it found. - tap_row pressed the wrong row. Once a track is loaded its row carries a `Paused` state indicator, and counting that content description as row text dropped the top hit below an identical duplicate further down, so the tap went to y=1443 instead of y=878. Rows are now scored on their title and subtitle only (state lives in content descriptions), and scores within rowScoreTolerance are treated as equal so the higher row wins, because Spotify already ranks by relevance. internal/ui/rows_test.go reproduces that results screen, including the state indicator and the duplicate row. docs/phase-3-spotify-recipe.md records the successful run, the independent media-session and audio-output reads, and the failure that preceded it.
282 lines
8.5 KiB
Go
282 lines
8.5 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 confirm 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 != "playing" || last.Args[0] != "{query}" {
|
|
t.Fatalf("the last step must confirm the right track is playing, 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 \"Search\" 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] != "Search" {
|
|
t.Fatalf("retry wait step = %+v", wait)
|
|
}
|
|
// retry without a duration still works.
|
|
rec, err = Parse("steps:\n - tap_row \"x\"\n - wait \"Search\" 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",
|
|
"on tap_row": "steps:\n - tap_row \"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 TestPlayingStep(t *testing.T) {
|
|
rec, err := Parse("app: com.spotify.music\nsteps:\n - tap_row \"{query}\"\n - playing \"{query}\" 30s retry 3\n")
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
p := rec.Steps[1]
|
|
if p.Verb != "playing" || p.Args[0] != "{query}" || p.Timeout != 30*time.Second || p.Retry != 3 {
|
|
t.Fatalf("playing step = %+v", p)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|