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.
This commit is contained in:
376
internal/recipe/engine.go
Normal file
376
internal/recipe/engine.go
Normal file
@@ -0,0 +1,376 @@
|
||||
package recipe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb"
|
||||
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/ui"
|
||||
)
|
||||
|
||||
// StepResult is the record of one executed step (FR-10).
|
||||
type StepResult struct {
|
||||
Index int `json:"step"`
|
||||
Verb string `json:"verb"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Duration time.Duration `json:"-"`
|
||||
}
|
||||
|
||||
// RunResult is the record of one whole recipe run (FR-10).
|
||||
type RunResult struct {
|
||||
Task string `json:"task"`
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Steps []StepResult `json:"steps"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Duration time.Duration `json:"-"`
|
||||
}
|
||||
|
||||
// Options carry the caller's parameters and where to write the step log.
|
||||
type Options struct {
|
||||
Params map[string]string
|
||||
Log io.Writer
|
||||
}
|
||||
|
||||
// Run executes a recipe against the phone.
|
||||
//
|
||||
// It refuses to start on a locked phone or a missing app, bounds every step
|
||||
// (FR-11), and reports the reason for the first failing step. A run never
|
||||
// returns an error silently: the returned RunResult always says what happened.
|
||||
func Run(r *adb.Runner, rec *Recipe, opts Options) *RunResult {
|
||||
started := time.Now()
|
||||
params := opts.Params
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
res := &RunResult{Task: rec.Task, Params: params}
|
||||
defer func() {
|
||||
res.Duration = time.Since(started)
|
||||
res.DurationMS = res.Duration.Milliseconds()
|
||||
}()
|
||||
|
||||
logf := func(format string, args ...any) {
|
||||
if opts.Log != nil {
|
||||
fmt.Fprintf(opts.Log, format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
fail := func(err error) *RunResult {
|
||||
res.OK = false
|
||||
res.Error = err.Error()
|
||||
logf("FAILED %s after %s: %s", rec.Task, time.Since(started).Round(time.Millisecond), res.Error)
|
||||
return res
|
||||
}
|
||||
|
||||
logf("task %s (%s)", rec.Task, rec.Description)
|
||||
|
||||
if missing := rec.MissingParams(params); len(missing) > 0 {
|
||||
return fail(fmt.Errorf("missing required parameter(s): %s", strings.Join(missing, ", ")))
|
||||
}
|
||||
if rec.App != "" {
|
||||
installed, err := ui.AppInstalled(r, rec.App)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("could not check whether %s is installed: %w", rec.App, err))
|
||||
}
|
||||
if !installed {
|
||||
return fail(fmt.Errorf("app is not installed: %s", rec.App))
|
||||
}
|
||||
}
|
||||
|
||||
if err := ui.Wake(r); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
// A secure keyguard is a hard stop: Artemis has no credential and must
|
||||
// never type one (AGENTS.md section 13 Q2).
|
||||
if err := ui.RequireUnlocked(r); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
w, h, _ := ui.ScreenSize(r) // best effort: bounds checking degrades gracefully
|
||||
|
||||
deadline := started.Add(rec.TotalTimeout)
|
||||
for i, step := range rec.Steps {
|
||||
if remaining := time.Until(deadline); remaining <= 0 {
|
||||
return fail(fmt.Errorf("recipe timed out after %s at step %d (%s)", rec.TotalTimeout, i+1, step.Verb))
|
||||
}
|
||||
sr := executeStep(r, rec, step, i, params, w, h, deadline, logf)
|
||||
res.Steps = append(res.Steps, sr)
|
||||
if !sr.OK {
|
||||
return fail(fmt.Errorf("step %d (%s %s) failed: %s", i+1, step.Verb, strings.Join(step.Args, " "), sr.Error))
|
||||
}
|
||||
}
|
||||
|
||||
res.OK = true
|
||||
logf("OK %s in %s", rec.Task, time.Since(started).Round(time.Millisecond))
|
||||
return res
|
||||
}
|
||||
|
||||
func executeStep(r *adb.Runner, rec *Recipe, step Step, index int, params map[string]string,
|
||||
w, h int, deadline time.Time, logf func(string, ...any)) StepResult {
|
||||
|
||||
started := time.Now()
|
||||
sr := StepResult{Index: index + 1, Verb: step.Verb, Args: step.Args}
|
||||
defer func() {
|
||||
sr.Duration = time.Since(started)
|
||||
sr.DurationMS = sr.Duration.Milliseconds()
|
||||
}()
|
||||
|
||||
timeout := step.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = rec.StepTimeout
|
||||
}
|
||||
if remaining := time.Until(deadline); timeout > remaining {
|
||||
timeout = remaining
|
||||
}
|
||||
if timeout <= 0 {
|
||||
sr.Error = "no time left in the recipe budget"
|
||||
return sr
|
||||
}
|
||||
|
||||
args := make([]string, len(step.Args))
|
||||
for i, a := range step.Args {
|
||||
v, err := Substitute(a, params)
|
||||
if err != nil {
|
||||
sr.Error = err.Error()
|
||||
return sr
|
||||
}
|
||||
args[i] = v
|
||||
}
|
||||
|
||||
stepErr := func() error {
|
||||
switch step.Verb {
|
||||
case "launch":
|
||||
pkg := rec.App
|
||||
if len(args) == 1 {
|
||||
pkg = args[0]
|
||||
}
|
||||
if pkg == "" {
|
||||
return fmt.Errorf("no app named: add an \"app:\" setting or pass a package")
|
||||
}
|
||||
if err := ui.Launch(r, pkg); err != nil {
|
||||
return err
|
||||
}
|
||||
sr.Detail = "launched " + pkg
|
||||
return nil
|
||||
|
||||
case "wait":
|
||||
node, err := ui.WaitForText(r, args[0], step.Exact, timeout)
|
||||
if err != nil {
|
||||
return describeFailure(r, rec, err)
|
||||
}
|
||||
sr.Detail = "found " + node.Label()
|
||||
return nil
|
||||
|
||||
case "wait_gone":
|
||||
if err := ui.WaitForNoText(r, args[0], step.Exact, timeout); err != nil {
|
||||
return describeFailure(r, rec, err)
|
||||
}
|
||||
sr.Detail = fmt.Sprintf("%q is gone", args[0])
|
||||
return nil
|
||||
|
||||
case "tap":
|
||||
return tapByText(r, rec, args[0], step.Exact, timeout, &sr)
|
||||
|
||||
case "tap_if":
|
||||
// Tap only if the text is already on screen. This makes a recipe
|
||||
// tolerant of a screen that remembers state, without ever tapping
|
||||
// something that was not matched by text.
|
||||
h, err := ui.Dump(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
matches := h.FindText(args[0], step.Exact)
|
||||
if len(matches) == 0 {
|
||||
sr.Detail = fmt.Sprintf("%q not on screen, skipped", args[0])
|
||||
return nil
|
||||
}
|
||||
node, err := ui.TapNode(r, matches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cx, cy, _ := node.Center()
|
||||
sr.Detail = fmt.Sprintf("tapped %q at (%d,%d)", args[0], cx, cy)
|
||||
return nil
|
||||
|
||||
case "tap_row":
|
||||
return tapRow(r, rec, args[0], timeout, w, h, &sr)
|
||||
|
||||
case "type":
|
||||
if err := ui.Type(r, args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
sr.Detail = fmt.Sprintf("typed %d characters", len(args[0]))
|
||||
return nil
|
||||
|
||||
case "key":
|
||||
return sendKey(r, args[0], &sr)
|
||||
|
||||
case "back":
|
||||
if err := ui.Back(r); err != nil {
|
||||
return err
|
||||
}
|
||||
sr.Detail = "pressed back"
|
||||
return nil
|
||||
|
||||
case "home":
|
||||
if err := ui.Home(r); err != nil {
|
||||
return err
|
||||
}
|
||||
sr.Detail = "pressed home"
|
||||
return nil
|
||||
|
||||
case "sleep":
|
||||
time.Sleep(timeout)
|
||||
sr.Detail = "slept " + timeout.String()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown step verb %q", step.Verb)
|
||||
}()
|
||||
|
||||
if stepErr != nil {
|
||||
sr.OK = false
|
||||
sr.Error = stepErr.Error()
|
||||
logf(" step %d %s %v: %s", sr.Index, step.Verb, args, sr.Error)
|
||||
return sr
|
||||
}
|
||||
|
||||
sr.OK = true
|
||||
logf(" step %d %s %v: ok (%s)", sr.Index, step.Verb, args, sr.Duration.Round(time.Millisecond))
|
||||
return sr
|
||||
}
|
||||
|
||||
// tapByText finds an element by text and taps its centre (FR-4).
|
||||
//
|
||||
// The element is looked up twice: once to wait for it, and again immediately
|
||||
// before the tap, so the coordinates come from the newest dump. On a busy
|
||||
// phone the first dump can be seconds old by the time the tap is sent.
|
||||
func tapByText(r *adb.Runner, rec *Recipe, query string, exact bool, timeout time.Duration, sr *StepResult) error {
|
||||
if _, err := ui.WaitForText(r, query, exact, timeout); err != nil {
|
||||
return describeFailure(r, rec, err)
|
||||
}
|
||||
h, err := ui.Dump(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
matches := h.FindText(query, exact)
|
||||
if len(matches) == 0 {
|
||||
return describeFailure(r, rec, fmt.Errorf("element with text %q disappeared before the tap", query))
|
||||
}
|
||||
node, err := ui.TapNode(r, matches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cx, cy, _ := node.Center()
|
||||
sr.Detail = fmt.Sprintf("tapped %q at (%d,%d) %s", query, cx, cy, node.Label())
|
||||
return nil
|
||||
}
|
||||
|
||||
// tapRow taps the list row that best matches the query. It retries the dump
|
||||
// until a matching row is on screen, then taps a point derived from that row's
|
||||
// own bounds.
|
||||
func tapRow(r *adb.Runner, rec *Recipe, query string, timeout time.Duration, w, h int, sr *StepResult) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
hier, err := ui.Dump(r)
|
||||
if err == nil {
|
||||
row, rerr := hier.BestRow(query, rec.RowID, w, h)
|
||||
if rerr == nil {
|
||||
if err := ui.Tap(r, row.Centre.X, row.Centre.Y); err != nil {
|
||||
return err
|
||||
}
|
||||
sr.Detail = fmt.Sprintf("tapped row %q at (%d,%d) from %s", row.Title, row.Centre.X, row.Centre.Y,
|
||||
strings.Join(row.Texts, " / "))
|
||||
return nil
|
||||
}
|
||||
lastErr = rerr
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no result row matches %q", query)
|
||||
}
|
||||
return describeFailure(r, rec, lastErr)
|
||||
}
|
||||
|
||||
func sendKey(r *adb.Runner, name string, sr *StepResult) error {
|
||||
codes := map[string]int{
|
||||
"enter": ui.KeyEnter,
|
||||
"back": ui.KeyBack,
|
||||
"home": ui.KeyHome,
|
||||
"power": 26,
|
||||
"wakeup": ui.KeyWakeup,
|
||||
"escape": 111,
|
||||
}
|
||||
code, ok := codes[strings.ToLower(name)]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown key %q", name)
|
||||
}
|
||||
if err := ui.KeyEvent(r, code); err != nil {
|
||||
return err
|
||||
}
|
||||
sr.Detail = "pressed " + strings.ToLower(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// describeFailure turns a step failure into a clean, named failure (FR-8).
|
||||
//
|
||||
// A guard match wins: "Spotify is signed out" is far more useful than "text
|
||||
// Search did not appear". Otherwise the message names the screen that was on
|
||||
// the phone when the step gave up.
|
||||
func describeFailure(r *adb.Runner, rec *Recipe, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
h, dumpErr := ui.Dump(r)
|
||||
if dumpErr == nil {
|
||||
for _, g := range rec.Guards {
|
||||
if len(h.FindText(g.Text, false)) > 0 {
|
||||
return fmt.Errorf("%s (phone is showing %q): %w", g.Reason, g.Text, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
screen := describeScreen(r, h, dumpErr)
|
||||
if screen == "" {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w; %s", err, screen)
|
||||
}
|
||||
|
||||
// describeScreen names the screen Artemis gave up on: the foreground package
|
||||
// plus the first visible lines of text.
|
||||
func describeScreen(r *adb.Runner, h *ui.Hierarchy, dumpErr error) string {
|
||||
var parts []string
|
||||
if fg, err := ui.ForegroundPackage(r); err == nil {
|
||||
parts = append(parts, "foreground="+fg)
|
||||
}
|
||||
if dumpErr == nil && h != nil {
|
||||
var labels []string
|
||||
for _, n := range h.TextNodes() {
|
||||
label := strings.TrimSpace(n.Text)
|
||||
if label == "" {
|
||||
label = strings.TrimSpace(n.ContentDesc)
|
||||
}
|
||||
if label == "" || len(label) > 60 {
|
||||
continue
|
||||
}
|
||||
labels = append(labels, fmt.Sprintf("%q", label))
|
||||
if len(labels) == 6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(labels) > 0 {
|
||||
parts = append(parts, "screen shows: "+strings.Join(labels, ", "))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
437
internal/recipe/recipe.go
Normal file
437
internal/recipe/recipe.go
Normal file
@@ -0,0 +1,437 @@
|
||||
// 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
|
||||
// - wait "Search" 30s wait for an element whose text matches
|
||||
// - 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"
|
||||
"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
|
||||
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},
|
||||
"wait": {1, 2, false, true},
|
||||
"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:]
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
194
internal/recipe/recipe_test.go
Normal file
194
internal/recipe/recipe_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user