mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07: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
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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 {
|
||||
Reference in new issue
Block a user