Files
artemis_control/internal/recipe/recipe_test.go
Sam Rolfe 2604fea483 feat: Phase 3 recipes file, spotify_play, and locked-screen refusal
Phase 3 of AGENTS.md section 11.

Recipes file (FR-5)
- recipes/ holds one hand-editable file per task; recipes/spotify_play.recipe
  is the first one.
- internal/recipe parses directives, guards, parameters and steps, and runs
  them with a per-step and a whole-run timeout (FR-11).
- Steps name text ("tap the element whose text is Search"), never coordinates.
- guards name screens Artemis must never tap on, so a signed-out or
  first-run Spotify fails with its own reason (FR-8).
- Every run appends task, params, steps, result and duration to
  logs/runs.jsonl (FR-10).

spotify_play
- launch, search, play, then wait for the transport button to say "Pause".
- internal/ui/rows.go adds tap_row: a result row is a clickable container with
  a title and a subtitle, so a one-line search suggestion is not a result, and
  the tap lands on the row's centre rather than on its "More options" button.
  This fixes a real wrong tap: a plain text tap hit a 126x12 pixel
  "More options for song Honky Tonk Women - Live" button.

Locked screen (AGENTS.md section 13 Q2, decided)
- Artemis fails loudly on a secure keyguard and never types a PIN. No secret is
  stored. A non-secure keyguard is still dismissed.
- internal/ui/keyguard.go reads the KeyguardServiceDelegate block plus
  cmd lock_settings is-user-secure.
- Every acting command wakes the screen before it checks the keyguard: a dozing
  phone reports showing=false, because the lock screen is not drawn yet.
- AGENTS.md records the decision and that storing a PIN for auto-unlock is
  rejected for now.

Verified live: the phone locked itself mid-session, and keyguard, tap and
run spotify_play all refused with "phone is locked; unlock it and retry".
The live end-to-end playback run still needs an unlocked phone.
2026-10-16 13:24:22 +11:00

195 lines
5.6 KiB
Go

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)
}
}