The Maps recipe: open the search box, type the place, and confirm the result list names it. Every label comes from a live uiautomator dump (search_omnibox_text_box, typed_suggest_container, the account sheet), never from a guess. Text only, one step_timeout/total_timeout, guards for the account sheet. The first live run exposed an ordering bug in expect_change. A dump is not instant, so a baseline taken after the input already showed the app's response and the check could never see a wedge; it failed against a healthy Maps. The engine now reads the screen before the step above runs and hands that signature to the check. Each retry re-reads it. Live: maps_navigate ok in 27.6s, result list names "Melbourne Museum". The wedge reason and its bounded retry are demonstrated with a probe recipe whose input changes nothing; a real wedge could not be forced, and docs/phase-5-maps-recipe.md says so plainly. Co-Authored-By: Paperclip <noreply@paperclip.ing>
521 lines
15 KiB
Go
521 lines
15 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)
|
|
// row_menu: More options label of a row's own action button (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)
|
|
// - playing "{query}" 30s retry 3
|
|
// wait until the app's media session is PLAYING and its
|
|
// title matches the text; with no text, wait for PLAYING
|
|
// alone (a radio plays a different track every few minutes)
|
|
// - wait_gone "Recents" 30s wait until no element matches the text
|
|
// - expect_change 10s the screen is read before the step above, so this
|
|
// fails with "the app stopped consuming input" when
|
|
// that step's input left the screen unchanged (a wedged app)
|
|
// - 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
|
|
// - menu_row "{query}" 30s open the More options menu of that row
|
|
// - scroll down scroll the current list ("down" or "up")
|
|
// - 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
|
|
//
|
|
// A step may name its own failure reason with "else":
|
|
//
|
|
// - wait "Go to song radio" 20s else "this track offers no Song radio"
|
|
//
|
|
// {name} placeholders are replaced with the value of parameter "name". The
|
|
// trailing "30s" on wait/tap/tap_row steps is that step's timeout.
|
|
//
|
|
// tap_row and menu_row also record the row they chose as "row", so a later step
|
|
// can refer to it with {row} — for example to check that the context menu that
|
|
// opened belongs to the row that was pressed:
|
|
//
|
|
// - menu_row "{query}" 40s
|
|
// - wait "{row}" 20s else "the context menu opened on the wrong row"
|
|
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
|
|
RowMenu 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
|
|
// Reason, when set with "else", is the step's named failure (FR-8). A wait
|
|
// for a label a screen may simply not offer reads better as "this track
|
|
// offers no Song radio" than as "text did not appear".
|
|
Reason string
|
|
Line int
|
|
}
|
|
|
|
// Defaults used when a recipe does not set its own timeouts or labels.
|
|
const (
|
|
DefaultStepTimeout = 20 * time.Second
|
|
DefaultTotalTimeout = 120 * time.Second
|
|
// DefaultRowMenu is the label of a row's own action button. Spotify's list
|
|
// row action carries the content description "More options for song ...".
|
|
DefaultRowMenu = "More options"
|
|
)
|
|
|
|
// 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,
|
|
"row_menu": 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": {0, 2, false, false},
|
|
"wait_gone": {1, 2, false, true},
|
|
"expect_change": {1, 1, true, false},
|
|
"tap": {1, 2, false, true},
|
|
"tap_if": {1, 1, false, true},
|
|
"tap_row": {1, 2, false, true},
|
|
"menu_row": {1, 2, false, false},
|
|
"scroll": {1, 1, false, false},
|
|
"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,
|
|
RowMenu: DefaultRowMenu,
|
|
}
|
|
|
|
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 "row_menu":
|
|
if value == "" {
|
|
return fmt.Errorf("line %d: row_menu needs a label", lineNo)
|
|
}
|
|
rec.RowMenu = 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" && verb != "expect_change" {
|
|
return nil, fmt.Errorf("line %d: retry is only allowed on a wait, playing or expect_change step", lineNo)
|
|
}
|
|
step.Retry = n
|
|
args = append(args[:i], args[i+2:]...)
|
|
break
|
|
}
|
|
|
|
// "else \"reason\"" names this step's own failure, so a screen that simply
|
|
// lacks a label can fail with the reason a human needs (FR-8).
|
|
for i := 0; i < len(args); i++ {
|
|
if !strings.EqualFold(args[i], "else") {
|
|
continue
|
|
}
|
|
if i+1 >= len(args) {
|
|
return nil, fmt.Errorf("line %d: else needs a reason, as in else \"that track has no radio\"", lineNo)
|
|
}
|
|
step.Reason = args[i+1]
|
|
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
|
|
}
|