feat: Maestro driver integration + DeviceDriver architecture (#32)

* proto(driver): drop launcher_activity from LaunchRequest

* refactor(driver): rename Driver to DeviceDriver, drop launcherActivity from Launch

* refactor(driver): rename package maestro to sidecar

* feat(driver): add ChromeDriver backed by chromedp

* feat(hierarchy): replace XML parser with TreeNode JSON parser

* refactor(driver): update mock and runner to DeviceDriver, drop launcherActivity

* feat(runner): add platform routing for web vs sidecar

* test(verifier): update hierarchy fixtures from XML to TreeNode JSON

* feat(sidecar): extract readLogcat/readProcMetrics; add MaestroDriverBackend

- Extract readLogcat() and readProcMetrics() as internal package-level
  helpers parameterised by serial
- Add MaestroDriverBackend wiring maestro-client AndroidDriver
- Drop launcherActivity from DriverBackend interface and StubDriverBackend
- Add maestro-utils and micrometer-core as explicit compile deps

* refactor(sidecar): inject DriverBackend into DriverService; drop launcherActivity

- Remove serial and default backend from DriverService constructor
- Make backend a required parameter
- Drop launcherActivity from launch RPC handler
- Create MaestroDriverBackend in Main.kt when platform is android

* test(sidecar): update tests for dropped launcherActivity and required backend

* chore(sidecar): remove unnecessary micrometer-core direct dependency
This commit is contained in:
pj authored and GitHub committed 2026-04-22 19:02:39 +07:00
1 parent b667abbbca
commit d5b6f6ccaf
26 files changed
+880 -450

No files matched your search

+282
View File
@@ -0,0 +1,282 @@
package chrome
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/chromedp/cdproto/input"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/priyanshujain/sanderling/internal/driver"
)
// Driver implements DeviceDriver via chromedp for web platform testing.
type Driver struct {
allocCtx context.Context
allocCancel context.CancelFunc
tabCtx context.Context
tabCancel context.CancelFunc
logsMu sync.Mutex
logs []driver.LogEntry
}
// New creates a new ChromeDriver. Call Terminate when done.
func New() *Driver {
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(),
append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
)...,
)
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
d := &Driver{
allocCtx: allocCtx,
allocCancel: allocCancel,
tabCtx: tabCtx,
tabCancel: tabCancel,
}
chromedp.ListenTarget(tabCtx, func(ev any) {
e, ok := ev.(*runtime.EventConsoleAPICalled)
if !ok {
return
}
var parts []string
for _, arg := range e.Args {
if arg.Value != nil {
var s string
if err := json.Unmarshal(arg.Value, &s); err == nil {
parts = append(parts, s)
} else {
parts = append(parts, string(arg.Value))
}
}
}
level := strings.ToUpper(string(e.Type))
if level == "LOG" {
level = "I"
}
d.logsMu.Lock()
d.logs = append(d.logs, driver.LogEntry{
UnixMillis: int64(e.Timestamp.Time().UnixMilli()),
Level: level,
Tag: "console",
Message: strings.Join(parts, " "),
})
d.logsMu.Unlock()
})
return d
}
func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool) error {
if clearState {
if err := chromedp.Run(d.tabCtx, network.ClearBrowserCookies()); err != nil {
return fmt.Errorf("clear cookies: %w", err)
}
if err := chromedp.Run(d.tabCtx, chromedp.ActionFunc(func(ctx context.Context) error {
_, exp, err := runtime.Evaluate(`localStorage.clear(); sessionStorage.clear();`).Do(ctx)
if exp != nil {
return fmt.Errorf("clear storage: %s", exp.Text)
}
return err
})); err != nil {
return fmt.Errorf("clear storage: %w", err)
}
}
return chromedp.Run(d.tabCtx, chromedp.Navigate(bundleID))
}
func (d *Driver) Terminate(_ context.Context) error {
d.tabCancel()
d.allocCancel()
return nil
}
func (d *Driver) Tap(_ context.Context, x, y int) error {
return chromedp.Run(d.tabCtx,
chromedp.MouseClickXY(float64(x), float64(y)),
)
}
func (d *Driver) TapSelector(_ context.Context, selector string) error {
return chromedp.Run(d.tabCtx,
chromedp.Click(selector, chromedp.NodeVisible),
)
}
func (d *Driver) InputText(_ context.Context, text string) error {
return chromedp.Run(d.tabCtx,
chromedp.ActionFunc(func(ctx context.Context) error {
return input.DispatchKeyEvent(input.KeyChar).WithText(text).Do(ctx)
}),
)
}
func (d *Driver) Swipe(_ context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
millis := max(duration.Milliseconds(), 50)
script := fmt.Sprintf(`
(function() {
const el = document.elementFromPoint(%d, %d);
if (!el) return;
const steps = Math.max(1, Math.floor(%d / 16));
const dx = (%d - %d) / steps;
const dy = (%d - %d) / steps;
el.dispatchEvent(new PointerEvent('pointerdown', {clientX: %d, clientY: %d, bubbles: true}));
for (let i = 1; i <= steps; i++) {
el.dispatchEvent(new PointerEvent('pointermove', {clientX: %d + dx*i, clientY: %d + dy*i, bubbles: true}));
}
el.dispatchEvent(new PointerEvent('pointerup', {clientX: %d, clientY: %d, bubbles: true}));
})();`,
fromX, fromY,
millis,
toX, fromX, toY, fromY,
fromX, fromY,
fromX, fromY,
toX, toY,
)
return chromedp.Run(d.tabCtx, chromedp.Evaluate(script, nil))
}
func (d *Driver) PressKey(_ context.Context, key string) error {
k, ok := keyMap[key]
if !ok {
return fmt.Errorf("unsupported key: %q", key)
}
return chromedp.Run(d.tabCtx, chromedp.KeyEvent(k))
}
var keyMap = map[string]string{
"back": "\b",
"home": "\x00",
"enter": "\r",
"tab": "\t",
"up": "\x26",
"down": "\x28",
"left": "\x25",
"right": "\x27",
}
func (d *Driver) Hierarchy(_ context.Context) (string, error) {
script := `
(function buildTree(el) {
const rect = el.getBoundingClientRect();
const attrs = {};
const bounds = '[' + Math.round(rect.left) + ',' + Math.round(rect.top) + ',' +
Math.round(rect.right) + ',' + Math.round(rect.bottom) + ']';
if (rect.width > 0 || rect.height > 0) attrs.bounds = bounds;
const text = (el.textContent || '').trim().slice(0, 200);
if (text) attrs.text = text;
if (el.id) attrs['resource-id'] = el.id;
const label = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || '';
if (label) attrs['content-desc'] = label;
if (el.tagName) attrs['class'] = el.tagName.toLowerCase();
const isClickable = !!(el.onclick || el.tagName === 'A' || el.tagName === 'BUTTON' ||
el.tagName === 'INPUT' || el.tagName === 'SELECT' ||
el.getAttribute('role') === 'button' || el.getAttribute('onclick'));
const children = [];
for (const child of el.children) {
children.push(buildTree(child));
}
return {
attributes: attrs,
children: children,
clickable: isClickable || null,
enabled: (!el.disabled) || null,
focused: document.activeElement === el || null,
checked: el.checked || null,
selected: el.selected || null,
};
})(document.body);`
var result any
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &result)); err != nil {
return "", fmt.Errorf("hierarchy: %w", err)
}
bytes, err := json.Marshal(result)
if err != nil {
return "", fmt.Errorf("hierarchy marshal: %w", err)
}
return string(bytes), nil
}
func (d *Driver) Screenshot(_ context.Context) (driver.Image, error) {
var buf []byte
if err := chromedp.Run(d.tabCtx, chromedp.CaptureScreenshot(&buf)); err != nil {
return driver.Image{}, fmt.Errorf("screenshot: %w", err)
}
w, h := pngDimensions(buf)
return driver.Image{PNG: buf, Width: w, Height: h}, nil
}
func (d *Driver) RecentLogs(_ context.Context, since time.Time, minLevel string) ([]driver.LogEntry, error) {
sinceMillis := since.UnixMilli()
d.logsMu.Lock()
defer d.logsMu.Unlock()
var result []driver.LogEntry
for _, entry := range d.logs {
if entry.UnixMillis < sinceMillis {
continue
}
if minLevel != "" && !meetsLevel(entry.Level, minLevel) {
continue
}
result = append(result, entry)
}
return result, nil
}
func (d *Driver) WaitForIdle(_ context.Context, _ time.Duration) error {
return chromedp.Run(d.tabCtx, chromedp.WaitReady("body", chromedp.ByQuery))
}
func (d *Driver) Health(_ context.Context) (driver.Health, error) {
select {
case <-d.tabCtx.Done():
return driver.Health{Ready: false, Version: "chrome", Platform: "web"}, nil
default:
return driver.Health{Ready: true, Version: "chrome", Platform: "web"}, nil
}
}
func (d *Driver) Metrics(_ context.Context, _ string) (driver.Metrics, error) {
var result map[string]any
script := `
(function() {
const mem = performance.memory || {};
return {heap: mem.usedJSHeapSize || 0, totalMem: mem.totalJSHeapSize || 0};
})()`
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &result)); err != nil {
return driver.Metrics{}, nil
}
heap, _ := result["heap"].(float64)
total, _ := result["totalMem"].(float64)
return driver.Metrics{
HeapBytes: int64(heap),
TotalMemoryBytes: int64(total),
}, nil
}
func meetsLevel(level, minLevel string) bool {
order := map[string]int{"V": 0, "D": 1, "I": 2, "W": 3, "E": 4, "F": 5}
return order[level] >= order[minLevel]
}
func pngDimensions(png []byte) (int, int) {
if len(png) < 24 {
return 0, 0
}
w := int(png[16])<<24 | int(png[17])<<16 | int(png[18])<<8 | int(png[19])
h := int(png[20])<<24 | int(png[21])<<16 | int(png[22])<<8 | int(png[23])
return w, h
}
var _ driver.DeviceDriver = (*Driver)(nil)
+7 -10
View File
@@ -5,15 +5,12 @@ import (
"time"
)
// Driver abstracts the platform-specific UI automation backend. v0.1 surface
// matches proto/driverpb/driver.proto. The Maestro sidecar implementation
// lives under driver/maestro; tests use driver/mock.
type Driver interface {
// Launch asks the backend to bring the target app to the foreground.
// launcherActivity is an optional "<pkg>/<activity>" component that
// overrides the backend's default launcher resolution — needed for
// apps that declare multiple MAIN+LAUNCHER activities.
Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error
// DeviceDriver abstracts the platform-specific UI automation backend. v0.1
// surface matches proto/driverpb/driver.proto. The sidecar implementation
// lives under driver/sidecar; the web implementation under driver/chrome;
// tests use driver/mock.
type DeviceDriver interface {
Launch(ctx context.Context, bundleID string, clearState bool) error
Terminate(ctx context.Context) error
Tap(ctx context.Context, x, y int) error
@@ -24,7 +21,7 @@ type Driver interface {
Hierarchy(ctx context.Context) (string, error)
Screenshot(ctx context.Context) (Image, error)
// RecentLogs returns logcat entries at or after `since`, filtered to
// RecentLogs returns log entries at or after `since`, filtered to
// `minLevel` or above. An empty minLevel defaults to "E".
RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]LogEntry, error)
+16 -17
View File
@@ -27,20 +27,19 @@ const (
)
type Action struct {
Kind ActionKind
BundleID string
LauncherActivity string
ClearState bool
X, Y int
FromX, FromY int
ToX, ToY int
Duration time.Duration
Selector string
Text string
Key string
LogLevel string
LogSince time.Time
Idle time.Duration
Kind ActionKind
BundleID string
ClearState bool
X, Y int
FromX, FromY int
ToX, ToY int
Duration time.Duration
Selector string
Text string
Key string
LogLevel string
LogSince time.Time
Idle time.Duration
}
// Driver is an in-memory Driver implementation for unit tests.
@@ -89,11 +88,11 @@ func (d *Driver) failure(kind ActionKind) error {
return d.Failures[kind]
}
func (d *Driver) Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error {
func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool) error {
if err := d.failure(ActionLaunch); err != nil {
return err
}
d.record(Action{Kind: ActionLaunch, BundleID: bundleID, LauncherActivity: launcherActivity, ClearState: clearState})
d.record(Action{Kind: ActionLaunch, BundleID: bundleID, ClearState: clearState})
return nil
}
@@ -220,4 +219,4 @@ func (d *Driver) Metrics(ctx context.Context, bundleID string) (driver.Metrics,
return d.MetricsData, nil
}
var _ driver.Driver = (*Driver)(nil)
var _ driver.DeviceDriver = (*Driver)(nil)
+2 -2
View File
@@ -29,7 +29,7 @@ func TestRecordsAllActionsInOrder(t *testing.T) {
mock := New()
ctx := context.Background()
if err := mock.Launch(ctx, "com.example", "com.example/.MainActivity", true); err != nil {
if err := mock.Launch(ctx, "com.example", true); err != nil {
t.Fatal(err)
}
if err := mock.Tap(ctx, 100, 200); err != nil {
@@ -137,5 +137,5 @@ func TestActionsReturnsCopy(t *testing.T) {
}
func TestSatisfiesDriverInterface(t *testing.T) {
var _ driver.Driver = New()
var _ driver.DeviceDriver = New()
}
@@ -1,4 +1,4 @@
package maestro
package sidecar
import (
"context"
@@ -48,11 +48,10 @@ func (c *Client) WaitForHealth(ctx context.Context, pollInterval time.Duration)
}
}
func (c *Client) Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error {
func (c *Client) Launch(ctx context.Context, bundleID string, clearState bool) error {
_, err := c.stub.Launch(ctx, &driverpb.LaunchRequest{
BundleId: bundleID,
ClearState: clearState,
LauncherActivity: launcherActivity,
BundleId: bundleID,
ClearState: clearState,
})
return err
}
@@ -165,4 +164,4 @@ func (c *Client) Metrics(ctx context.Context, bundleID string) (driver.Metrics,
}, nil
}
var _ driver.Driver = (*Client)(nil)
var _ driver.DeviceDriver = (*Client)(nil)
@@ -1,4 +1,4 @@
package maestro
package sidecar
import (
"context"
@@ -22,7 +22,6 @@ type fakeServer struct {
healthReadyAfterCall int
launchedBundleID string
launcherActivity string
clearState bool
terminateCalls int
taps []int32
@@ -55,7 +54,6 @@ func (s *fakeServer) Launch(_ context.Context, request *driverpb.LaunchRequest)
s.mutex.Lock()
defer s.mutex.Unlock()
s.launchedBundleID = request.GetBundleId()
s.launcherActivity = request.GetLauncherActivity()
s.clearState = request.GetClearState()
return &driverpb.Empty{}, nil
}
@@ -193,10 +191,10 @@ func TestClient_LaunchAndTerminate(t *testing.T) {
client, _ := Dial(state.address)
defer client.Close()
if err := client.Launch(context.Background(), "com.example", "com.example/.MainActivity", true); err != nil {
if err := client.Launch(context.Background(), "com.example", true); err != nil {
t.Fatal(err)
}
if state.fake.launchedBundleID != "com.example" || !state.fake.clearState || state.fake.launcherActivity != "com.example/.MainActivity" {
if state.fake.launchedBundleID != "com.example" || !state.fake.clearState {
t.Errorf("launch payload wrong: %+v", state.fake)
}
if err := client.Terminate(context.Background()); err != nil {
+85 -83
View File
@@ -1,5 +1,5 @@
// Package hierarchy parses the XML produced by `uiautomator dump` and
// resolves selectors against it.
// Package hierarchy parses the TreeNode JSON produced by the Maestro sidecar
// and resolves selectors against it.
//
// Selector grammar (v0.1):
//
@@ -10,7 +10,7 @@
package hierarchy
import (
"encoding/xml"
"encoding/json"
"fmt"
"regexp"
"strconv"
@@ -36,7 +36,7 @@ func (b Bounds) Width() int { return b.Right - b.Left }
// Height returns the bounds' height.
func (b Bounds) Height() int { return b.Bottom - b.Top }
// Element is a flattened view of one uiautomator node.
// Element is a flattened view of one hierarchy node.
type Element struct {
ResourceID string `json:"resourceId,omitempty"`
Text string `json:"text,omitempty"`
@@ -56,38 +56,82 @@ type Tree struct {
Elements []*Element `json:"elements"`
}
// Parse parses a uiautomator-style XML dump.
func Parse(xmlText string) (*Tree, error) {
xmlText = strings.TrimSpace(xmlText)
if xmlText == "" {
// treeNodeJSON mirrors the Maestro TreeNode JSON structure.
type treeNodeJSON struct {
Attributes map[string]string `json:"attributes"`
Children []treeNodeJSON `json:"children"`
Clickable *bool `json:"clickable"`
Enabled *bool `json:"enabled"`
Focused *bool `json:"focused"`
Checked *bool `json:"checked"`
Selected *bool `json:"selected"`
}
// Parse parses a Maestro TreeNode JSON hierarchy.
func Parse(text string) (*Tree, error) {
text = strings.TrimSpace(text)
if text == "" {
return &Tree{}, nil
}
decoder := xml.NewDecoder(strings.NewReader(xmlText))
tree := &Tree{}
for {
token, err := decoder.Token()
if err != nil {
if err.Error() == "EOF" {
break
}
break
}
start, ok := token.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != "node" {
continue
}
element, parseErr := elementFromStart(start)
if parseErr != nil {
return nil, parseErr
}
tree.Elements = append(tree.Elements, element)
var root treeNodeJSON
if err := json.Unmarshal([]byte(text), &root); err != nil {
return nil, fmt.Errorf("hierarchy: %w", err)
}
tree := &Tree{}
walkNode(&root, tree)
return tree, nil
}
func walkNode(node *treeNodeJSON, tree *Tree) {
element := elementFromNode(node)
tree.Elements = append(tree.Elements, element)
for i := range node.Children {
walkNode(&node.Children[i], tree)
}
}
func elementFromNode(node *treeNodeJSON) *Element {
attrs := node.Attributes
element := &Element{}
element.ResourceID = attrs["resource-id"]
if element.ResourceID == "" {
element.ResourceID = attrs["identifier"]
}
element.Text = attrs["text"]
element.Description = attrs["content-desc"]
if element.Description == "" {
element.Description = attrs["accessibilityText"]
}
element.Class = attrs["class"]
element.Package = attrs["package"]
if node.Clickable != nil {
element.Clickable = *node.Clickable
}
if node.Enabled != nil {
element.Enabled = *node.Enabled
}
if node.Focused != nil {
element.Focused = *node.Focused
}
if node.Checked != nil {
element.Checked = *node.Checked
}
if node.Selected != nil {
element.Selected = *node.Selected
}
if b, ok := attrs["bounds"]; ok && b != "" {
bounds, err := parseBounds(b)
if err == nil {
element.Bounds = bounds
}
}
return element
}
// Find returns the first element matching the selector, or nil.
func (t *Tree) Find(selector string) *Element {
kind, value, ok := parseSelector(selector)
@@ -131,11 +175,7 @@ func match(element *Element, kind, value string) bool {
if element.ResourceID == value {
return true
}
// ResourceID looks like "<package>:id/<suffix>".
if strings.HasSuffix(element.ResourceID, ":id/"+value) {
return true
}
return false
return strings.HasSuffix(element.ResourceID, ":id/"+value)
case "text":
return element.Text == value
case "desc":
@@ -147,59 +187,21 @@ func match(element *Element, kind, value string) bool {
}
}
func elementFromStart(start xml.StartElement) (*Element, error) {
element := &Element{}
var boundsText string
for _, attribute := range start.Attr {
switch attribute.Name.Local {
case "resource-id":
element.ResourceID = attribute.Value
case "text":
element.Text = attribute.Value
case "content-desc":
element.Description = attribute.Value
case "class":
element.Class = attribute.Value
case "package":
element.Package = attribute.Value
case "clickable":
element.Clickable = attribute.Value == "true"
case "enabled":
element.Enabled = attribute.Value == "true"
case "checked":
element.Checked = attribute.Value == "true"
case "focused":
element.Focused = attribute.Value == "true"
case "selected":
element.Selected = attribute.Value == "true"
case "bounds":
boundsText = attribute.Value
}
}
if boundsText != "" {
bounds, err := parseBounds(boundsText)
if err != nil {
return nil, fmt.Errorf("bounds %q: %w", boundsText, err)
}
element.Bounds = bounds
}
return element, nil
}
var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$`)
// boundsPattern matches "[l,t,r,b]" (4-value Maestro format).
var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+),(-?\d+),(-?\d+)\]$`)
func parseBounds(text string) (Bounds, error) {
match := boundsPattern.FindStringSubmatch(text)
if match == nil {
return Bounds{}, fmt.Errorf("not in [L,T][R,B] form")
m := boundsPattern.FindStringSubmatch(text)
if m == nil {
return Bounds{}, fmt.Errorf("bounds %q: not in [L,T,R,B] form", text)
}
coordinates := make([]int, 4)
for index := range 4 {
value, err := strconv.Atoi(match[index+1])
coords := make([]int, 4)
for i := range 4 {
v, err := strconv.Atoi(m[i+1])
if err != nil {
return Bounds{}, err
}
coordinates[index] = value
coords[i] = v
}
return Bounds{Left: coordinates[0], Top: coordinates[1], Right: coordinates[2], Bottom: coordinates[3]}, nil
return Bounds{Left: coords[0], Top: coords[1], Right: coords[2], Bottom: coords[3]}, nil
}
+92 -14
View File
@@ -2,14 +2,30 @@ package hierarchy
import "testing"
const sampleDump = `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node class="android.widget.LinearLayout" package="app" bounds="[0,0][1080,2340]">
<node resource-id="app:id/title" text="Hello" bounds="[10,20][200,60]" clickable="false" enabled="true"/>
<node resource-id="app:id/row" text="Alice" content-desc="row" bounds="[0,100][1080,200]" clickable="true" enabled="true"/>
<node resource-id="app:id/row" text="Bob" content-desc="row" bounds="[0,200][1080,300]" clickable="true" enabled="true"/>
</node>
</hierarchy>`
// sampleDump is a Maestro TreeNode JSON equivalent of the old XML fixture.
const sampleDump = `{
"attributes": {"class": "android.widget.LinearLayout", "package": "app", "bounds": "[0,0,1080,2340]"},
"children": [
{
"attributes": {"resource-id": "app:id/title", "text": "Hello", "bounds": "[10,20,200,60]"},
"children": [],
"clickable": false,
"enabled": true
},
{
"attributes": {"resource-id": "app:id/row", "text": "Alice", "content-desc": "row", "bounds": "[0,100,1080,200]"},
"children": [],
"clickable": true,
"enabled": true
},
{
"attributes": {"resource-id": "app:id/row", "text": "Bob", "content-desc": "row", "bounds": "[0,200,1080,300]"},
"children": [],
"clickable": true,
"enabled": true
}
]
}`
func TestParseCountsNodes(t *testing.T) {
tree, err := Parse(sampleDump)
@@ -75,14 +91,76 @@ func TestUnknownSelector(t *testing.T) {
}
func TestDescPrefix(t *testing.T) {
xml := `<hierarchy>
<node content-desc="customer_row_abc-123" bounds="[0,0][100,100]"/>
<node content-desc="customer_row_def-456" bounds="[0,100][100,200]"/>
<node content-desc="supplier_row_xyz" bounds="[0,200][100,300]"/>
</hierarchy>`
tree, _ := Parse(xml)
input := `{
"attributes": {},
"children": [
{"attributes": {"content-desc": "customer_row_abc-123", "bounds": "[0,0,100,100]"}, "children": []},
{"attributes": {"content-desc": "customer_row_def-456", "bounds": "[0,100,100,200]"}, "children": []},
{"attributes": {"content-desc": "supplier_row_xyz", "bounds": "[0,200,100,300]"}, "children": []}
]
}`
tree, _ := Parse(input)
rows := tree.FindAll("descPrefix:customer_row_")
if len(rows) != 2 {
t.Fatalf("want 2 customer rows, got %d", len(rows))
}
}
func TestBoolFieldsFromNode(t *testing.T) {
input := `{
"attributes": {"resource-id": "x", "bounds": "[0,0,100,100]"},
"children": [],
"clickable": true,
"enabled": false,
"focused": true,
"checked": true,
"selected": false
}`
tree, err := Parse(input)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(tree.Elements) != 1 {
t.Fatalf("want 1 element, got %d", len(tree.Elements))
}
el := tree.Elements[0]
if !el.Clickable {
t.Error("expected clickable=true")
}
if el.Enabled {
t.Error("expected enabled=false")
}
if !el.Focused {
t.Error("expected focused=true")
}
if !el.Checked {
t.Error("expected checked=true")
}
if el.Selected {
t.Error("expected selected=false")
}
}
func TestIdentifierFallback(t *testing.T) {
input := `{
"attributes": {"identifier": "my-button", "bounds": "[0,0,100,100]"},
"children": []
}`
tree, _ := Parse(input)
el := tree.Find("id:my-button")
if el == nil {
t.Fatal("expected match via identifier fallback")
}
}
func TestAccessibilityTextFallback(t *testing.T) {
input := `{
"attributes": {"accessibilityText": "Close dialog", "bounds": "[0,0,50,50]"},
"children": []
}`
tree, _ := Parse(input)
el := tree.Find("desc:Close dialog")
if el == nil {
t.Fatal("expected match via accessibilityText fallback")
}
}
+4 -4
View File
@@ -25,7 +25,7 @@ type Options struct {
BundleID string
Connection *agent.Conn
Driver driver.Driver
Driver driver.DeviceDriver
Verifier *verifier.Verifier
TraceWriter *trace.Writer
Logger *slog.Logger
@@ -272,7 +272,7 @@ func screenFromSnapshot(snapshots map[string]json.RawMessage) (string, error) {
return screen, nil
}
func applyAction(ctx context.Context, drv driver.Driver, action verifier.Action, tree *hierarchy.Tree) error {
func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) error {
switch action.Kind {
case verifier.ActionKindTap:
x, y, ok := resolveCoordinates(action, tree)
@@ -326,7 +326,7 @@ func applyAction(ctx context.Context, drv driver.Driver, action verifier.Action,
// collectLogs pulls recent error-level log entries from the driver since the
// previous fetch. A failure is warned-on but not fatal: log capture is a
// best-effort observability channel, not a correctness dependency.
func collectLogs(ctx context.Context, drv driver.Driver, since time.Time) []verifier.LogEntry {
func collectLogs(ctx context.Context, drv driver.DeviceDriver, since time.Time) []verifier.LogEntry {
entries, err := drv.RecentLogs(ctx, since, "E")
if err != nil {
return nil
@@ -374,7 +374,7 @@ func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int,
return 0, 0, false
}
func fetchHierarchy(ctx context.Context, drv driver.Driver) (*hierarchy.Tree, error) {
func fetchHierarchy(ctx context.Context, drv driver.DeviceDriver) (*hierarchy.Tree, error) {
xmlText, err := drv.Hierarchy(ctx)
if err != nil {
return nil, err
+1 -4
View File
@@ -321,10 +321,7 @@ func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) {
state.startSDK(t)
state.acceptConnection(t)
state.mock.HierarchyJSON = `<?xml version="1.0"?>
<hierarchy>
<node resource-id="com.fixture:id/next" clickable="true" enabled="true" bounds="[40,80][240,160]"/>
</hierarchy>`
state.mock.HierarchyJSON = `{"attributes":{"resource-id":"com.fixture:id/next","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true}`
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+4 -3
View File
@@ -9,12 +9,13 @@ import (
// TestStateAxFindWorks verifies that a Parse+PushSnapshot+extract round trip
// actually lets the spec resolve selectors through state.ax.find.
// Reads /tmp/live-dump.json (Maestro TreeNode JSON format); skipped if absent.
func TestStateAxFindWorks(t *testing.T) {
xmlText, err := os.ReadFile("/tmp/live-dump.xml")
jsonText, err := os.ReadFile("/tmp/live-dump.json")
if err != nil {
t.Skip("live-dump.xml not present")
t.Skip("live-dump.json not present")
}
tree, err := hierarchy.Parse(string(xmlText))
tree, err := hierarchy.Parse(string(jsonText))
if err != nil {
t.Fatal(err)
}
+16 -16
View File
@@ -1,19 +1,19 @@
package verifier
const listHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="test.app" bounds="[0,0][1080,2400]">
<node index="0" class="android.widget.TextView" text="Items" bounds="[100,200][900,300]" />
<node index="1" class="android.view.View" content-desc="primary_action" clickable="true" enabled="true" bounds="[64,2200][1016,2320]" />
<node index="2" class="android.view.View" content-desc="secondary_action" clickable="true" enabled="true" bounds="[980,80][1060,160]" />
</node>
</hierarchy>`
const listHierarchyJSON = `{
"attributes": {"class": "android.widget.FrameLayout", "package": "test.app", "bounds": "[0,0,1080,2400]"},
"children": [
{"attributes": {"class": "android.widget.TextView", "text": "Items", "bounds": "[100,200,900,300]"}, "children": []},
{"attributes": {"content-desc": "primary_action", "bounds": "[64,2200,1016,2320]"}, "children": [], "clickable": true, "enabled": true},
{"attributes": {"content-desc": "secondary_action", "bounds": "[980,80,1060,160]"}, "children": [], "clickable": true, "enabled": true}
]
}`
const formHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="test.app" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="text_field" clickable="true" enabled="true" bounds="[64,320][1016,440]" />
<node index="1" class="android.view.View" content-desc="primary_action" clickable="true" enabled="true" bounds="[64,2200][1016,2320]" />
<node index="2" class="android.view.View" content-desc="secondary_action" clickable="true" enabled="true" bounds="[32,80][112,160]" />
</node>
</hierarchy>`
const formHierarchyJSON = `{
"attributes": {"class": "android.widget.FrameLayout", "package": "test.app", "bounds": "[0,0,1080,2400]"},
"children": [
{"attributes": {"content-desc": "text_field", "bounds": "[64,320,1016,440]"}, "children": [], "clickable": true, "enabled": true},
{"attributes": {"content-desc": "primary_action", "bounds": "[64,2200,1016,2320]"}, "children": [], "clickable": true, "enabled": true},
{"attributes": {"content-desc": "secondary_action", "bounds": "[32,80,112,160]"}, "children": [], "clickable": true, "enabled": true}
]
}`
+6 -6
View File
@@ -65,7 +65,7 @@ func TestIntegrationSpecFiresInputActions(t *testing.T) {
t.Fatal(err)
}
tree, err := hierarchy.Parse(formHierarchyXML)
tree, err := hierarchy.Parse(formHierarchyJSON)
if err != nil {
t.Fatal(err)
}
@@ -105,7 +105,7 @@ func TestIntegrationSpecPropertiesEvaluate(t *testing.T) {
t.Fatal(err)
}
tree, err := hierarchy.Parse(listHierarchyXML)
tree, err := hierarchy.Parse(listHierarchyJSON)
if err != nil {
t.Fatal(err)
}
@@ -134,21 +134,21 @@ func TestIntegrationSpecPropertiesEvaluate(t *testing.T) {
func TestIntegrationSpecActionsFireOnEachRoute(t *testing.T) {
cases := []struct {
name string
xml string
hierarchy string
snapshots Snapshots
expectKind ActionKind
expectOns []string
}{
{
name: "list",
xml: listHierarchyXML,
hierarchy: listHierarchyJSON,
snapshots: listSnapshots(),
expectKind: ActionKindTap,
expectOns: []string{"desc:primary_action", "desc:secondary_action"},
},
{
name: "form",
xml: formHierarchyXML,
hierarchy: formHierarchyJSON,
snapshots: formSnapshots(),
expectKind: ActionKindTap,
expectOns: []string{"desc:primary_action", "desc:secondary_action"},
@@ -161,7 +161,7 @@ func TestIntegrationSpecActionsFireOnEachRoute(t *testing.T) {
if err := v.Load(bundleIntegrationSpec(t)); err != nil {
t.Fatal(err)
}
tree, err := hierarchy.Parse(tc.xml)
tree, err := hierarchy.Parse(tc.hierarchy)
if err != nil {
t.Fatal(err)
}