package recipe import ( "os" "path/filepath" "strings" "testing" "time" ) 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 wait for playback, not assume it. 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) } }