Add the Artemis Go CLI and its packages: - internal/adb: timeout-bounded adb wrapper, device list, wireless-debugging port scan and auto-connect (never hardcodes the moving port). - internal/ui: uiautomator dump parsing, find-by-text, tap the centre of a text match (FR-4), type, swipe, scroll, back, home, launch, wake. - cmd/artemis: CLI for the primitives plus 'demo', the Phase 2 DoD run. Failures are loud: a missing or disabled element errors instead of tapping a guessed coordinate. The screen is woken before acting, and every adb call has a deadline. Also record the section 13 Q4 decision (adb lives in the user profile on .13, wireless ADB needs no udev rules; service packaging still open until Phase 6), add unit tests, and document the phase evidence.
131 lines
3.6 KiB
Go
131 lines
3.6 KiB
Go
// Package adb is a thin, timeout-bounded wrapper around the adb command line
|
|
// tool. Artemis never talks to the ADB wire protocol directly: it shells out to
|
|
// the adb binary installed in the user profile on .13.
|
|
package adb
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DefaultTimeout bounds every single adb invocation. A recipe must never run
|
|
// forever (FR-11), so no command is allowed to run without a deadline.
|
|
const DefaultTimeout = 20 * time.Second
|
|
|
|
// Runner executes adb commands against one device.
|
|
type Runner struct {
|
|
Adb string
|
|
Serial string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// New returns a Runner. The adb binary is taken from ARTEMIS_ADB, else PATH.
|
|
func New(serial string) *Runner {
|
|
adbPath := os.Getenv("ARTEMIS_ADB")
|
|
if adbPath == "" {
|
|
adbPath = "adb"
|
|
}
|
|
return &Runner{Adb: adbPath, Serial: serial, Timeout: DefaultTimeout}
|
|
}
|
|
|
|
func (r *Runner) adbArgs(args ...string) []string {
|
|
full := make([]string, 0, len(args)+2)
|
|
if r.Serial != "" {
|
|
full = append(full, "-s", r.Serial)
|
|
}
|
|
return append(full, args...)
|
|
}
|
|
|
|
func (r *Runner) run(timeout time.Duration, args ...string) ([]byte, error) {
|
|
if timeout <= 0 {
|
|
timeout = r.Timeout
|
|
}
|
|
if timeout <= 0 {
|
|
timeout = DefaultTimeout
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, r.Adb, r.adbArgs(args...)...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
return stdout.Bytes(), fmt.Errorf("adb %s: timed out after %s", strings.Join(args, " "), timeout)
|
|
}
|
|
if err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = strings.TrimSpace(stdout.String())
|
|
}
|
|
return stdout.Bytes(), fmt.Errorf("adb %s: %w: %s", strings.Join(args, " "), err, msg)
|
|
}
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
// Shell runs a command in the device shell and returns combined stdout.
|
|
//
|
|
// The whole command is passed as one argv element; adb joins argv with spaces
|
|
// and the device's sh parses the result, exactly as a human at a terminal.
|
|
func (r *Runner) Shell(command string) (string, error) {
|
|
out, err := r.run(r.Timeout, "shell", command)
|
|
return string(out), err
|
|
}
|
|
|
|
// ExecOut runs adb exec-out, which does not add carriage returns. Use it to
|
|
// read files and binary data off the device.
|
|
func (r *Runner) ExecOut(args ...string) ([]byte, error) {
|
|
return r.run(r.Timeout, append([]string{"exec-out"}, args...)...)
|
|
}
|
|
|
|
// Device is one row of `adb devices -l`.
|
|
type Device struct {
|
|
Serial string
|
|
State string
|
|
Product string
|
|
Model string
|
|
Device string
|
|
TransportID string
|
|
}
|
|
|
|
// Devices lists the devices the adb server knows about.
|
|
func (r *Runner) Devices() ([]Device, error) {
|
|
out, err := r.run(10*time.Second, "devices", "-l")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var devices []Device
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "List of devices") {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
d := Device{Serial: fields[0], State: fields[1]}
|
|
for _, f := range fields[2:] {
|
|
switch {
|
|
case strings.HasPrefix(f, "product:"):
|
|
d.Product = strings.TrimPrefix(f, "product:")
|
|
case strings.HasPrefix(f, "model:"):
|
|
d.Model = strings.TrimPrefix(f, "model:")
|
|
case strings.HasPrefix(f, "device:"):
|
|
d.Device = strings.TrimPrefix(f, "device:")
|
|
case strings.HasPrefix(f, "transport_id:"):
|
|
d.TransportID = strings.TrimPrefix(f, "transport_id:")
|
|
}
|
|
}
|
|
devices = append(devices, d)
|
|
}
|
|
return devices, nil
|
|
}
|