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:
118
internal/ui/dump_test.go
Normal file
118
internal/ui/dump_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleDump = `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" text="" class="android.widget.FrameLayout" package="com.tailscale.ipn" clickable="false" enabled="true" scrollable="false" bounds="[0,0][1080,2400]">
|
||||
<node index="0" text="samuelrolfe@gmail.com" class="android.widget.TextView" package="com.tailscale.ipn" clickable="false" enabled="true" bounds="[221,155][798,217]"/>
|
||||
<node index="1" text="Connected" class="android.widget.TextView" package="com.tailscale.ipn" clickable="false" enabled="true" bounds="[221,217][453,277]"/>
|
||||
<node index="2" text="" content-desc="Navigate up" class="android.widget.ImageButton" package="com.tailscale.ipn" clickable="true" enabled="true" bounds="[42,121][189,268]"/>
|
||||
</node>
|
||||
<node index="1" text="Network and internet" class="android.widget.TextView" package="com.android.settings" clickable="true" enabled="true" bounds="[221,663][748,727]">
|
||||
<node index="0" text="Network and internet" class="android.widget.TextView" package="com.android.settings" clickable="false" enabled="true" bounds="[221,663][748,727]"/>
|
||||
</node>
|
||||
<node index="2" text="Disabled thing" class="android.widget.TextView" package="com.android.settings" clickable="true" enabled="false" bounds="[0,0][10,10]"/>
|
||||
</hierarchy>`
|
||||
|
||||
func parseSample(t *testing.T) *Hierarchy {
|
||||
t.Helper()
|
||||
h, err := parseHierarchy([]byte(sampleDump))
|
||||
if err != nil {
|
||||
t.Fatalf("parseHierarchy: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestBoundsAndCenter(t *testing.T) {
|
||||
n := &Node{Bounds: "[221,155][798,217]"}
|
||||
r, err := n.BoundsRect()
|
||||
if err != nil {
|
||||
t.Fatalf("BoundsRect: %v", err)
|
||||
}
|
||||
if r.X1 != 221 || r.Y1 != 155 || r.X2 != 798 || r.Y2 != 217 {
|
||||
t.Fatalf("unexpected rect %+v", r)
|
||||
}
|
||||
cx, cy, err := n.Center()
|
||||
if err != nil {
|
||||
t.Fatalf("Center: %v", err)
|
||||
}
|
||||
if cx != (221+798)/2 || cy != (155+217)/2 {
|
||||
t.Fatalf("unexpected centre (%d,%d)", cx, cy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundsRejectsGarbage(t *testing.T) {
|
||||
for _, bad := range []string{"", "nope", "[1,2][3,4]junk", "[10,10][10,20]"} {
|
||||
if _, err := (&Node{Bounds: bad}).BoundsRect(); err == nil {
|
||||
t.Fatalf("expected error for bounds %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTextExactAndSubstring(t *testing.T) {
|
||||
h := parseSample(t)
|
||||
|
||||
if got := len(h.FindText("Connected", true)); got != 1 {
|
||||
t.Fatalf("exact match: want 1, got %d", got)
|
||||
}
|
||||
// Substring is case-insensitive and matches more than one node.
|
||||
if got := len(h.FindText("network", false)); got != 2 {
|
||||
t.Fatalf("substring match: want 2 (parent + child), got %d", got)
|
||||
}
|
||||
// Content descriptions count as text.
|
||||
matches := h.FindText("Navigate up", true)
|
||||
if len(matches) != 1 || !matches[0].Clickable {
|
||||
t.Fatalf("content-desc match failed: %+v", matches)
|
||||
}
|
||||
if got := h.FindText("does not exist", false); got != nil {
|
||||
t.Fatalf("expected no matches, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickBestMatchPrefersClickable(t *testing.T) {
|
||||
h := parseSample(t)
|
||||
matches := h.FindText("network", false)
|
||||
if len(matches) != 2 {
|
||||
t.Fatalf("want 2 matches, got %d", len(matches))
|
||||
}
|
||||
best, err := pickBestMatch(matches)
|
||||
if err != nil {
|
||||
t.Fatalf("pickBestMatch: %v", err)
|
||||
}
|
||||
if !best.Clickable {
|
||||
t.Fatalf("expected a clickable match, got %s", best.Label())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickBestMatchRejectsDisabledOnly(t *testing.T) {
|
||||
h := parseSample(t)
|
||||
matches := h.FindText("Disabled thing", true)
|
||||
if len(matches) != 1 {
|
||||
t.Fatalf("want 1 match, got %d", len(matches))
|
||||
}
|
||||
if _, err := pickBestMatch(matches); err == nil {
|
||||
t.Fatal("expected an error when the only match is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeInputText(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"hello world": "'hello%sworld'",
|
||||
"Rolling Stones": "'Rolling%sStones'",
|
||||
"it's": `'it'\''s'`,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := escapeInputText(in); got != want {
|
||||
t.Errorf("escapeInputText(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
// A single quote must not be able to break out of the quoting.
|
||||
got := escapeInputText("a'; rm -rf /; 'b")
|
||||
if strings.Count(got, "'")%2 != 0 {
|
||||
t.Fatalf("unbalanced quotes in %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user