feat: Phase 2 act primitives
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.
This commit is contained in:
130
internal/adb/adb.go
Normal file
130
internal/adb/adb.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// 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
|
||||
}
|
||||
157
internal/adb/discover.go
Normal file
157
internal/adb/discover.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package adb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"net"
|
||||
)
|
||||
|
||||
// The wireless-debugging port changes on every phone reboot or toggle of
|
||||
// wireless debugging, so it is never hardcoded. Android opens two TCP ports:
|
||||
// a pairing port (useless here) and the connect port. `adb connect` succeeds on
|
||||
// the connect port and is refused on the pairing port.
|
||||
|
||||
// DefaultScanRange is the range Android picks wireless-debugging ports from.
|
||||
const (
|
||||
DefaultScanFrom = 30000
|
||||
DefaultScanTo = 65535
|
||||
)
|
||||
|
||||
// Connect asks the adb server to connect to host:port.
|
||||
func (r *Runner) Connect(address string) (string, error) {
|
||||
out, err := r.run(15*time.Second, "connect", address)
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
// adb connect can exit 0 while reporting failure in its output.
|
||||
lower := strings.ToLower(msg)
|
||||
if strings.Contains(lower, "failed") || strings.Contains(lower, "cannot") {
|
||||
return msg, fmt.Errorf("adb connect %s: %s", address, msg)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// DeviceOnline reports whether serial is present and in state "device".
|
||||
func (r *Runner) DeviceOnline(serial string) bool {
|
||||
devices, err := r.Devices()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, d := range devices {
|
||||
if d.Serial == serial && d.State == "device" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ScanPorts does a concurrent TCP connect scan of host:from-to and returns the
|
||||
// open ports in ascending order.
|
||||
func ScanPorts(host string, from, to int, timeout time.Duration) []int {
|
||||
if timeout <= 0 {
|
||||
timeout = 500 * time.Millisecond
|
||||
}
|
||||
const workers = 512
|
||||
|
||||
type result struct {
|
||||
port int
|
||||
open bool
|
||||
}
|
||||
|
||||
jobs := make(chan int)
|
||||
results := make(chan result)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for port := range jobs {
|
||||
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
results <- result{port: port, open: false}
|
||||
continue
|
||||
}
|
||||
conn.Close()
|
||||
results <- result{port: port, open: true}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
for p := from; p <= to; p++ {
|
||||
jobs <- p
|
||||
}
|
||||
close(jobs)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
var open []int
|
||||
for res := range results {
|
||||
if res.open {
|
||||
open = append(open, res.port)
|
||||
}
|
||||
}
|
||||
sort.Ints(open)
|
||||
return open
|
||||
}
|
||||
|
||||
// ConnectAuto brings the phone online. It reuses an already-connected device if
|
||||
// one is present, otherwise it scans host for open ports and tries
|
||||
// `adb connect` on each one until a port comes up in state "device".
|
||||
//
|
||||
// It returns the serial it connected to.
|
||||
func (r *Runner) ConnectAuto(host string) (string, error) {
|
||||
// Already connected? Find a device whose serial is host:port.
|
||||
for _, d := range mustDevices(r) {
|
||||
if d.State == "device" && strings.HasPrefix(d.Serial, host+":") {
|
||||
r.Serial = d.Serial
|
||||
return d.Serial, nil
|
||||
}
|
||||
}
|
||||
|
||||
ports := ScanPorts(host, DefaultScanFrom, DefaultScanTo, 0)
|
||||
if len(ports) == 0 {
|
||||
return "", fmt.Errorf("no open TCP ports on %s in %d-%d; is wireless debugging on?",
|
||||
host, DefaultScanFrom, DefaultScanTo)
|
||||
}
|
||||
|
||||
var errs []string
|
||||
for _, port := range ports {
|
||||
serial := fmt.Sprintf("%s:%d", host, port)
|
||||
if _, err := r.Connect(serial); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%d: %v", port, err))
|
||||
continue
|
||||
}
|
||||
// Give the adb server a moment to move the device to "device".
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if r.DeviceOnline(serial) {
|
||||
r.Serial = serial
|
||||
return serial, nil
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
errs = append(errs, fmt.Sprintf("%d: connect accepted but device never came online", port))
|
||||
}
|
||||
return "", fmt.Errorf("open ports on %s were %v but none accepted adb connect: %s",
|
||||
host, ports, strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
func mustDevices(r *Runner) []Device {
|
||||
devices, err := r.Devices()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return devices
|
||||
}
|
||||
Reference in New Issue
Block a user