Files
artemis_control/internal/recipe/recipe.go
Sam Rolfe c178f92851 feat: confirm playback from the media session, and stop demoting the top row
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.
2026-10-16 14:32:24 +11:00

468 lines
13 KiB
Go

// Package recipe loads and runs named, deterministic, hand-editable tasks.
//
// A recipe is one plain-text file in recipes/, written so that Sam can change
// it by hand without a build step. It names the app, the parameters it needs,
// the screens it must never tap on, and the ordered steps that do the work.
// Steps talk about text ("tap the element whose text is Search"), never about
// coordinates, so a recipe never taps blindly (FR-4, FR-5, FR-8).
//
// # File format
//
// A recipe file is a list of lines. Blank lines and lines starting with "#"
// are ignored.
//
// Directives are "key: value" lines placed before the steps:
//
// task: spotify_play unique name, must match the file name
// description: ... one line, for humans
// app: com.spotify.music the package the recipe drives
// param: query a parameter the caller must supply (repeatable)
// row_id: com.spotify:id/row_root container id for list rows (optional)
// step_timeout: 20s default per-step timeout (FR-11)
// total_timeout: 120s whole-recipe timeout (FR-11)
//
// A guard names a screen Artemis must never act on. Guards are checked when a
// step cannot find its target, so the failure names the real problem:
//
// guard "Log in" => Spotify is signed out; sign in on the phone and retry
//
// Steps are "- " lines, run in order:
//
// - launch start the recipe's app
// - foreground 30s wait until the recipe's app is in front
// - wait "Search" 30s wait for an element whose text matches
// - wait exact "Pause" 60s retry 3
// if it never appears, re-run the step above and
// wait again (a busy phone can drop a tap)
// - wait_gone "Recents" 30s wait until no element matches the text
// - tap "Search" tap the element whose text matches (FR-4)
// - tap exact "Pause" tap an element whose text matches exactly
// - tap_if "Clear search query" tap it when it is on screen, else skip
// - tap_row "{query}" 30s tap the list row that best matches the text
// - type "{query}" type into the focused field
// - key enter send a named key event
// - back / home press a system key
// - sleep 1s wait a fixed time
//
// {name} placeholders are replaced with the value of parameter "name". The
// trailing "30s" on wait/tap/tap_row steps is that step's timeout.
package recipe
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// Recipe is one parsed task.
type Recipe struct {
Task string
Description string
App string
Params []string
RowID string
StepTimeout time.Duration
TotalTimeout time.Duration
Guards []Guard
Steps []Step
Source string
}
// Guard is a screen Artemis must never act on, with the reason to report.
type Guard struct {
Text string
Reason string
}
// Step is one ordered action.
type Step struct {
Verb string
Args []string
Exact bool
Timeout time.Duration
// Retry, on a wait step, re-runs the preceding step and waits again, up to
// this many extra times. A busy phone can drop a tap.
Retry int
Line int
}
// Defaults used when a recipe does not set its own timeouts.
const (
DefaultStepTimeout = 20 * time.Second
DefaultTotalTimeout = 120 * time.Second
)
// validKeys are the directive names a recipe file may use.
var validKeys = map[string]bool{
"task": true,
"description": true,
"app": true,
"param": true,
"row_id": true,
"step_timeout": true,
"total_timeout": true,
}
// verbs lists the step verbs and how many arguments each takes.
var verbs = map[string]struct {
min, max int
durArg bool // the single argument is a duration
allowExact bool
}{
"launch": {0, 1, false, false},
"foreground": {0, 1, false, false},
"wait": {1, 2, false, true},
"playing": {1, 2, false, false},
"wait_gone": {1, 2, false, true},
"tap": {1, 2, false, true},
"tap_if": {1, 1, false, true},
"tap_row": {1, 2, false, true},
"type": {1, 1, false, false},
"key": {1, 1, false, false},
"back": {0, 0, false, false},
"home": {0, 0, false, false},
"sleep": {1, 1, true, false},
}
// keyNames are the key event names a recipe may use.
var keyNames = map[string]bool{
"enter": true,
"back": true,
"home": true,
"power": true,
"wakeup": true,
"escape": true,
}
// Load reads and parses one recipe file.
func Load(path string) (*Recipe, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
rec, err := Parse(string(raw))
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
rec.Source = path
if rec.Task == "" {
rec.Task = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
return rec, nil
}
// List loads every recipe in dir, sorted by task name.
func List(dir string) ([]*Recipe, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read recipes directory %s: %w", dir, err)
}
var out []*Recipe
for _, e := range entries {
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
rec, err := Load(filepath.Join(dir, e.Name()))
if err != nil {
return nil, err
}
out = append(out, rec)
}
sort.Slice(out, func(i, j int) bool { return out[i].Task < out[j].Task })
return out, nil
}
// Find loads the recipe for one task name from dir.
func Find(dir, task string) (*Recipe, error) {
recipes, err := List(dir)
if err != nil {
return nil, err
}
for _, r := range recipes {
if r.Task == task {
return r, nil
}
}
names := make([]string, 0, len(recipes))
for _, r := range recipes {
names = append(names, r.Task)
}
if len(names) == 0 {
return nil, fmt.Errorf("no recipes in %s", dir)
}
return nil, fmt.Errorf("no recipe named %q in %s (have: %s)", task, dir, strings.Join(names, ", "))
}
// Parse turns the body of a recipe file into a Recipe.
func Parse(src string) (*Recipe, error) {
rec := &Recipe{
StepTimeout: DefaultStepTimeout,
TotalTimeout: DefaultTotalTimeout,
}
for i, raw := range strings.Split(src, "\n") {
lineNo := i + 1
line := strings.TrimSpace(raw)
switch {
case line == "", strings.HasPrefix(line, "#"):
continue
case line == "steps:":
continue
case strings.HasPrefix(line, "-"):
step, err := parseStep(strings.TrimSpace(strings.TrimPrefix(line, "-")), lineNo)
if err != nil {
return nil, err
}
rec.Steps = append(rec.Steps, *step)
case strings.HasPrefix(line, "guard "):
guard, err := parseGuard(line, lineNo)
if err != nil {
return nil, err
}
rec.Guards = append(rec.Guards, *guard)
default:
if err := rec.parseDirective(line, lineNo); err != nil {
return nil, err
}
}
}
if len(rec.Steps) == 0 {
return nil, fmt.Errorf("recipe has no steps")
}
return rec, nil
}
func (rec *Recipe) parseDirective(line string, lineNo int) error {
key, value, ok := strings.Cut(line, ":")
if !ok {
return fmt.Errorf("line %d: expected \"key: value\", a \"guard ...\" line or a \"- step\" line, got %q", lineNo, line)
}
key = strings.ToLower(strings.TrimSpace(key))
value = strings.TrimSpace(value)
if !validKeys[key] {
return fmt.Errorf("line %d: unknown setting %q", lineNo, key)
}
switch key {
case "task":
rec.Task = value
case "description":
rec.Description = value
case "app":
rec.App = value
case "param":
if value == "" {
return fmt.Errorf("line %d: param needs a name", lineNo)
}
rec.Params = append(rec.Params, value)
case "row_id":
rec.RowID = value
case "step_timeout":
d, err := parseDuration(value)
if err != nil {
return fmt.Errorf("line %d: step_timeout: %w", lineNo, err)
}
rec.StepTimeout = d
case "total_timeout":
d, err := parseDuration(value)
if err != nil {
return fmt.Errorf("line %d: total_timeout: %w", lineNo, err)
}
rec.TotalTimeout = d
}
return nil
}
func parseGuard(line string, lineNo int) (*Guard, error) {
body := strings.TrimSpace(strings.TrimPrefix(line, "guard"))
text, reason, ok := strings.Cut(body, "=>")
if !ok {
return nil, fmt.Errorf("line %d: a guard line needs \"guard \\\"text\\\" => reason\"", lineNo)
}
tokens, err := tokenize(text, lineNo)
if err != nil {
return nil, err
}
if len(tokens) != 1 {
return nil, fmt.Errorf("line %d: a guard needs exactly one quoted text, got %d", lineNo, len(tokens))
}
reason = strings.TrimSpace(reason)
if reason == "" {
return nil, fmt.Errorf("line %d: a guard needs a reason after =>", lineNo)
}
return &Guard{Text: tokens[0], Reason: reason}, nil
}
func parseStep(body string, lineNo int) (*Step, error) {
tokens, err := tokenize(body, lineNo)
if err != nil {
return nil, err
}
if len(tokens) == 0 {
return nil, fmt.Errorf("line %d: empty step", lineNo)
}
verb := strings.ToLower(tokens[0])
spec, ok := verbs[verb]
if !ok {
return nil, fmt.Errorf("line %d: unknown step verb %q", lineNo, tokens[0])
}
step := &Step{Verb: verb, Line: lineNo}
args := tokens[1:]
if spec.allowExact && len(args) > 0 && strings.EqualFold(args[0], "exact") {
step.Exact = true
args = args[1:]
}
// "retry N" is pulled out first, so the timeout can still be the last word.
for i := 0; i < len(args); i++ {
if !strings.EqualFold(args[i], "retry") {
continue
}
if i+1 >= len(args) {
return nil, fmt.Errorf("line %d: retry needs a number, as in retry 3", lineNo)
}
n, err := strconv.Atoi(args[i+1])
if err != nil || n < 1 {
return nil, fmt.Errorf("line %d: retry needs a positive number, got %q", lineNo, args[i+1])
}
if verb != "wait" && verb != "playing" {
return nil, fmt.Errorf("line %d: retry is only allowed on a wait or playing step", lineNo)
}
step.Retry = n
args = append(args[:i], args[i+2:]...)
break
}
// A trailing duration is the step timeout, except for sleep where the
// duration is the step itself.
if !spec.durArg && len(args) > spec.min {
if d, derr := parseDuration(args[len(args)-1]); derr == nil {
step.Timeout = d
args = args[:len(args)-1]
}
}
if len(args) < spec.min || len(args) > spec.max {
return nil, fmt.Errorf("line %d: %s takes %s, got %d", lineNo, verb, argRange(spec.min, spec.max), len(args))
}
step.Args = args
if spec.durArg {
d, derr := parseDuration(args[0])
if derr != nil {
return nil, fmt.Errorf("line %d: sleep: %w", lineNo, derr)
}
step.Timeout = d
}
if verb == "key" && !keyNames[strings.ToLower(args[0])] {
return nil, fmt.Errorf("line %d: unknown key %q", lineNo, args[0])
}
return step, nil
}
func argRange(min, max int) string {
switch {
case min == max:
return fmt.Sprintf("exactly %d argument(s)", min)
case min == 0:
return fmt.Sprintf("up to %d argument(s)", max)
default:
return fmt.Sprintf("%d or %d arguments", min, max)
}
}
func parseDuration(s string) (time.Duration, error) {
d, err := time.ParseDuration(strings.TrimSpace(s))
if err != nil {
return 0, fmt.Errorf("%q is not a duration (use 20s, 1500ms, 2m)", s)
}
if d <= 0 {
return 0, fmt.Errorf("duration %q must be positive", s)
}
return d, nil
}
// tokenize splits a recipe line into words, treating a double-quoted run as one
// word. Backslash escapes the next character inside quotes.
func tokenize(s string, lineNo int) ([]string, error) {
var out []string
for i := 0; i < len(s); {
switch {
case s[i] == ' ' || s[i] == '\t':
i++
case s[i] == '"':
var b strings.Builder
j := i + 1
for j < len(s) && s[j] != '"' {
if s[j] == '\\' && j+1 < len(s) {
j++
}
b.WriteByte(s[j])
j++
}
if j >= len(s) {
return nil, fmt.Errorf("line %d: unterminated quoted string", lineNo)
}
out = append(out, b.String())
i = j + 1
default:
j := i
for j < len(s) && s[j] != ' ' && s[j] != '\t' {
j++
}
out = append(out, s[i:j])
i = j
}
}
return out, nil
}
// Substitute replaces {name} placeholders with parameter values.
//
// Every declared parameter must be present, and every placeholder must be
// declared: an unset value would otherwise be typed into the phone as an empty
// string.
func Substitute(text string, params map[string]string) (string, error) {
var out strings.Builder
for i := 0; i < len(text); {
if text[i] != '{' {
out.WriteByte(text[i])
i++
continue
}
end := strings.IndexByte(text[i:], '}')
if end < 0 {
return "", fmt.Errorf("unclosed { in %q", text)
}
name := text[i+1 : i+end]
value, ok := params[name]
if !ok {
return "", fmt.Errorf("no value for parameter %q in %q", name, text)
}
if strings.TrimSpace(value) == "" {
return "", fmt.Errorf("parameter %q is empty", name)
}
out.WriteString(value)
i += end + 1
}
return out.String(), nil
}
// MissingParams returns the declared parameters that the caller did not supply.
func (rec *Recipe) MissingParams(params map[string]string) []string {
var missing []string
for _, name := range rec.Params {
if strings.TrimSpace(params[name]) == "" {
missing = append(missing, name)
}
}
return missing
}