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.
195 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|