mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-03 03:27:10 +00:00
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:
26 files changed
+880
-450
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user