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
+835 -405

No files matched your search

+5 -5
View File
@@ -17,7 +17,6 @@ var Version = "dev"
type testOptions struct {
spec string
bundleID string
launcherActivity string
platform string
avd string
duration time.Duration
@@ -45,8 +44,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
var options testOptions
flagSet.StringVar(&options.spec, "spec", "", "path to the TypeScript spec (required)")
flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required)")
flagSet.StringVar(&options.launcherActivity, "launcher-activity", "", "optional <pkg>/<activity> to launch (overrides default resolution)")
flagSet.StringVar(&options.platform, "platform", "android", "target platform: android (ios deferred)")
flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, web")
flagSet.StringVar(&options.avd, "avd", "", "Android AVD name to boot if no device is connected")
flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration")
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
@@ -60,8 +58,10 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
if options.bundleID == "" {
return testOptions{}, errors.New("--bundle-id is required")
}
if options.platform != "android" {
return testOptions{}, fmt.Errorf("unsupported platform: %q (only android in v0.1)", options.platform)
switch options.platform {
case "android", "ios", "web":
default:
return testOptions{}, fmt.Errorf("unsupported platform: %q (android, web)", options.platform)
}
return options, nil
}
+16 -2
View File
@@ -76,11 +76,11 @@ func TestParseTestArgs_AVDIsOptional(t *testing.T) {
}
}
func TestParseTestArgs_RejectsNonAndroidPlatform(t *testing.T) {
func TestParseTestArgs_RejectsUnknownPlatform(t *testing.T) {
_, err := parseTestArgs([]string{
"--spec", "s.ts",
"--bundle-id", "com.example",
"--platform", "ios",
"--platform", "fuchsia",
"--avd", "x",
}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "unsupported platform") {
@@ -88,6 +88,20 @@ func TestParseTestArgs_RejectsNonAndroidPlatform(t *testing.T) {
}
}
func TestParseTestArgs_AcceptsWebPlatform(t *testing.T) {
options, err := parseTestArgs([]string{
"--spec", "s.ts",
"--bundle-id", "http://localhost:3000",
"--platform", "web",
}, io.Discard)
if err != nil {
t.Fatalf("unexpected error for web platform: %v", err)
}
if options.platform != "web" {
t.Errorf("expected platform=web, got %q", options.platform)
}
}
func TestRun_HelpPrintsUsage(t *testing.T) {
var stdout bytes.Buffer
if err := run([]string{"sanderling"}, &stdout, io.Discard); err != nil {
+67 -44
View File
@@ -14,7 +14,9 @@ import (
"github.com/priyanshujain/sanderling/internal/agent"
"github.com/priyanshujain/sanderling/internal/bundler"
"github.com/priyanshujain/sanderling/internal/driver/maestro"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/driver/chrome"
driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar"
"github.com/priyanshujain/sanderling/internal/runner"
"github.com/priyanshujain/sanderling/internal/sidecar"
"github.com/priyanshujain/sanderling/internal/trace"
@@ -28,7 +30,7 @@ const (
)
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error {
if options.platform == "android" {
if options.platform == "android" || options.platform == "ios" {
if err := ensureDevice(ctx, options.avd, stdout); err != nil {
return err
}
@@ -36,8 +38,6 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
aliases := map[string]string{}
if specApiPath := resolveSpecAPIPath(options.spec); specApiPath != "" {
aliases["@sanderling/spec"] = specApiPath
// Also alias published subpath exports so specs importing from
// "@sanderling/spec/defaults/properties" resolve to the in-tree source.
base := filepath.Dir(specApiPath)
aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts")
}
@@ -54,46 +54,11 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
}
fmt.Fprintf(stdout, "bundled spec: %d bytes (sha256=%s)\n", len(bundle.JavaScript), bundle.SHA256[:12])
sidecarDirectory := filepath.Join(os.TempDir(), "sanderling-sidecar")
jarPath, err := sidecar.Extract(sidecarDirectory)
if err != nil {
return fmt.Errorf("extract sidecar: %w", err)
}
fmt.Fprintf(stdout, "sidecar JAR: %s (size=%d)\n", jarPath, sidecar.EmbeddedSize())
sidecarPort, err := pickFreePort()
activeDriver, cleanup, err := buildDriver(ctx, options, stdout)
if err != nil {
return err
}
sidecarCommand := exec.CommandContext(ctx, "java", "-jar", jarPath,
"--port", strconv.Itoa(sidecarPort),
"--platform", options.platform,
)
sidecarCommand.Stdout = stdout
sidecarCommand.Stderr = stdout
sidecarCommand.Env = envWithAndroidPlatformTools(os.Environ())
if err := sidecarCommand.Start(); err != nil {
return fmt.Errorf("spawn sidecar: %w", err)
}
defer func() {
if sidecarCommand.Process != nil {
_ = sidecarCommand.Process.Kill()
}
}()
fmt.Fprintf(stdout, "sidecar pid=%d listening on 127.0.0.1:%d\n", sidecarCommand.Process.Pid, sidecarPort)
driverClient, err := maestro.Dial(fmt.Sprintf("127.0.0.1:%d", sidecarPort))
if err != nil {
return fmt.Errorf("dial sidecar: %w", err)
}
defer driverClient.Close()
healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout)
if err := driverClient.WaitForHealth(healthCtx, 250*time.Millisecond); err != nil {
healthCancel()
return fmt.Errorf("sidecar health check: %w", err)
}
healthCancel()
fmt.Fprintln(stdout, "sidecar is healthy")
defer cleanup()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -102,6 +67,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
defer listener.Close()
agentPort := listener.Addr().(*net.TCPAddr).Port
if options.platform != "web" {
if err := adbReverse(socketName, agentPort); err != nil {
return fmt.Errorf("adb reverse: %w", err)
}
@@ -111,6 +77,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
}
}()
fmt.Fprintf(stdout, "forwarded localabstract:%s -> tcp:%d\n", socketName, agentPort)
}
agentServer := agent.NewServer(listener)
@@ -126,7 +93,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
acceptChannel <- acceptResult{connection: connection, err: acceptErr}
}()
if err := driverClient.Launch(ctx, options.bundleID, options.launcherActivity, false); err != nil {
if err := activeDriver.Launch(ctx, options.bundleID, false); err != nil {
return fmt.Errorf("launch app: %w", err)
}
fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.bundleID, sdkAcceptTimeout.Seconds())
@@ -185,14 +152,14 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
IdleTimeout: 1 * time.Second,
BundleID: options.bundleID,
Connection: connection,
Driver: driverClient,
Driver: activeDriver,
Verifier: verifierInstance,
TraceWriter: traceWriter,
Logger: newProgressLogger(stdout),
})
terminateCtx, terminateCancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = driverClient.Terminate(terminateCtx)
_ = activeDriver.Terminate(terminateCtx)
terminateCancel()
if err != nil {
@@ -211,6 +178,62 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
return nil
}
// buildDriver creates the appropriate DeviceDriver for the platform and returns
// a cleanup function. For web, ChromeDriver is used directly; for android/ios
// the JVM sidecar is extracted, spawned, and dialed.
func buildDriver(ctx context.Context, options testOptions, stdout io.Writer) (driver.DeviceDriver, func(), error) {
if options.platform == "web" {
d := chrome.New()
return d, func() { _ = d.Terminate(context.Background()) }, nil
}
sidecarDirectory := filepath.Join(os.TempDir(), "sanderling-sidecar")
jarPath, err := sidecar.Extract(sidecarDirectory)
if err != nil {
return nil, nil, fmt.Errorf("extract sidecar: %w", err)
}
fmt.Fprintf(stdout, "sidecar JAR: %s (size=%d)\n", jarPath, sidecar.EmbeddedSize())
sidecarPort, err := pickFreePort()
if err != nil {
return nil, nil, err
}
sidecarCommand := exec.CommandContext(ctx, "java", "-jar", jarPath,
"--port", strconv.Itoa(sidecarPort),
"--platform", options.platform,
)
sidecarCommand.Stdout = stdout
sidecarCommand.Stderr = stdout
sidecarCommand.Env = envWithAndroidPlatformTools(os.Environ())
if err := sidecarCommand.Start(); err != nil {
return nil, nil, fmt.Errorf("spawn sidecar: %w", err)
}
fmt.Fprintf(stdout, "sidecar pid=%d listening on 127.0.0.1:%d\n", sidecarCommand.Process.Pid, sidecarPort)
driverClient, err := driverSidecar.Dial(fmt.Sprintf("127.0.0.1:%d", sidecarPort))
if err != nil {
_ = sidecarCommand.Process.Kill()
return nil, nil, fmt.Errorf("dial sidecar: %w", err)
}
healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout)
if err := driverClient.WaitForHealth(healthCtx, 250*time.Millisecond); err != nil {
healthCancel()
_ = sidecarCommand.Process.Kill()
_ = driverClient.Close()
return nil, nil, fmt.Errorf("sidecar health check: %w", err)
}
healthCancel()
fmt.Fprintln(stdout, "sidecar is healthy")
cleanup := func() {
_ = driverClient.Close()
if sidecarCommand.Process != nil {
_ = sidecarCommand.Process.Kill()
}
}
return driverClient, cleanup, nil
}
// resolveSpecAPIPath returns the path to pkg/spec-api/src/index.ts inside
// a sanderling source checkout, searched upward from the spec file and the cwd.
// Returns "" when not found, in which case esbuild resolves @sanderling/spec via
+8 -1
View File
@@ -12,11 +12,18 @@ require (
)
require (
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect
github.com/chromedp/chromedp v0.15.1 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
)
+17
View File
@@ -2,6 +2,12 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
@@ -10,12 +16,20 @@ github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -41,8 +55,11 @@ golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+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)
+3 -4
View File
@@ -29,7 +29,6 @@ const (
type Action struct {
Kind ActionKind
BundleID string
LauncherActivity string
ClearState bool
X, Y int
FromX, FromY int
@@ -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,
})
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))
var root treeNodeJSON
if err := json.Unmarshal([]byte(text), &root); err != nil {
return nil, fmt.Errorf("hierarchy: %w", err)
}
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)
}
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)
}
+2 -14
View File
@@ -61,10 +61,6 @@ type LaunchRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
BundleId string `protobuf:"bytes,1,opt,name=bundle_id,json=bundleId,proto3" json:"bundle_id,omitempty"`
ClearState bool `protobuf:"varint,2,opt,name=clear_state,json=clearState,proto3" json:"clear_state,omitempty"`
// Optional "<pkg>/<activity>" component to pass to `am start -n`. Needed
// when an app declares multiple MAIN+LAUNCHER activities where the first
// one reported by `pm dump` is an alias whose target class is disabled.
LauncherActivity string `protobuf:"bytes,3,opt,name=launcher_activity,json=launcherActivity,proto3" json:"launcher_activity,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -113,13 +109,6 @@ func (x *LaunchRequest) GetClearState() bool {
return false
}
func (x *LaunchRequest) GetLauncherActivity() string {
if x != nil {
return x.LauncherActivity
}
return ""
}
type Point struct {
state protoimpl.MessageState `protogen:"open.v1"`
X int32 `protobuf:"varint,1,opt,name=x,proto3" json:"x,omitempty"`
@@ -850,12 +839,11 @@ var File_driverpb_driver_proto protoreflect.FileDescriptor
const file_driverpb_driver_proto_rawDesc = "" +
"\n" +
"\x15driverpb/driver.proto\x12\x14sanderling.driver.v1\"\a\n" +
"\x05Empty\"z\n" +
"\x05Empty\"M\n" +
"\rLaunchRequest\x12\x1b\n" +
"\tbundle_id\x18\x01 \x01(\tR\bbundleId\x12\x1f\n" +
"\vclear_state\x18\x02 \x01(\bR\n" +
"clearState\x12+\n" +
"\x11launcher_activity\x18\x03 \x01(\tR\x10launcherActivity\"#\n" +
"clearState\"#\n" +
"\x05Point\x12\f\n" +
"\x01x\x18\x01 \x01(\x05R\x01x\x12\f\n" +
"\x01y\x18\x02 \x01(\x05R\x01y\"\x1c\n" +
-4
View File
@@ -30,10 +30,6 @@ message Empty {}
message LaunchRequest {
string bundle_id = 1;
bool clear_state = 2;
// Optional "<pkg>/<activity>" component to pass to `am start -n`. Needed
// when an app declares multiple MAIN+LAUNCHER activities where the first
// one reported by `pm dump` is an alias whose target class is disabled.
string launcher_activity = 3;
}
message Point {
+1
View File
@@ -25,6 +25,7 @@ val maestroVersion = "1.40.0"
dependencies {
implementation("dev.mobile:maestro-client:$maestroVersion")
implementation("dev.mobile:maestro-utils:$maestroVersion")
implementation("io.grpc:grpc-netty-shaded:$grpcVersion")
implementation("io.grpc:grpc-protobuf:$grpcVersion")
@@ -1,7 +1,7 @@
package dev.sanderling.sidecar
interface DriverBackend {
fun launch(bundleId: String, launcherActivity: String, clearState: Boolean)
fun launch(bundleId: String, clearState: Boolean)
fun terminate(bundleId: String)
fun tap(x: Int, y: Int)
fun tapSelector(selector: String)
@@ -29,12 +29,96 @@ data class LogLine(
val message: String,
)
/**
* StubDriverBackend records calls but takes no real device action. Real
* Maestro integration arrives in a follow-up; v0.1 wires the gRPC plumbing
* end-to-end so the Go side can be exercised against a running sidecar
* even before Maestro is plugged in.
*/
internal fun readLogcat(serial: String?, sinceUnixMillis: Long, minLevel: String): List<LogLine> {
val level = if (minLevel.isEmpty()) "E" else minLevel
val since = if (sinceUnixMillis > 0) StubDriverBackend.formatAdbLogcatTimestamp(sinceUnixMillis) else null
val arguments = mutableListOf("logcat", "-d", "*:$level")
if (since != null) {
arguments.add("-T")
arguments.add(since)
}
return try {
val process = ProcessBuilder(adbCmd(serial) + arguments).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
StubDriverBackend.parseLogcatOutput(output)
} catch (cause: Exception) {
println("adb logcat failed: $cause")
emptyList()
}
}
internal fun readProcMetrics(serial: String?, bundleId: String): MetricsSample {
if (bundleId.isEmpty()) return MetricsSample(0.0, 0L, 0L)
return try {
val pid = adbOutput(serial, listOf("shell", "pidof", bundleId))
.trim().split(Regex("\\s+")).firstOrNull()?.toIntOrNull()
?: return MetricsSample(0.0, 0L, 0L)
val cpu = sampleCpuTwice(serial, pid)
val (rssBytes, vmSizeBytes) = sampleProcessMemory(serial, pid)
MetricsSample(cpu, rssBytes, vmSizeBytes)
} catch (cause: Exception) {
println("metrics capture failed: $cause")
MetricsSample(0.0, 0L, 0L)
}
}
private fun adbCmd(serial: String?): List<String> =
if (serial == null) listOf("adb") else listOf("adb", "-s", serial)
private fun adbOutput(serial: String?, arguments: List<String>): String {
return try {
val process = ProcessBuilder(adbCmd(serial) + arguments).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
output
} catch (cause: Exception) {
""
}
}
private fun sampleCpuTwice(serial: String?, pid: Int): Double {
val sleepArg = "0.050"
val command = "cat /proc/$pid/stat; sleep $sleepArg; cat /proc/$pid/stat"
val output = adbOutput(serial, listOf("shell", command))
val lines = output.lines().filter { it.isNotBlank() }
if (lines.size < 2) return 0.0
val first = parseCpuTicks(lines[0]) ?: return 0.0
val second = parseCpuTicks(lines[1]) ?: return 0.0
val clockHz = adbOutput(serial, listOf("shell", "getconf", "CLK_TCK")).trim().toLongOrNull() ?: 100L
val deltaCpuNanos = (second - first) * 1_000_000_000.0 / clockHz.coerceAtLeast(1L)
return (deltaCpuNanos / 50_000_000.0) * 100.0
}
private fun parseCpuTicks(statLine: String): Long? {
val afterComm = statLine.substringAfterLast(')').trim()
val fields = afterComm.split(Regex("\\s+"))
if (fields.size < 13) return null
val utime = fields[11].toLongOrNull() ?: return null
val stime = fields[12].toLongOrNull() ?: return null
return utime + stime
}
private fun sampleProcessMemory(serial: String?, pid: Int): Pair<Long, Long> {
val status = adbOutput(serial, listOf("shell", "cat", "/proc/$pid/status"))
var rssKb = 0L
var vmSizeKb = 0L
for (raw in status.lineSequence()) {
val line = raw.trim()
when {
line.startsWith("VmRSS:") -> rssKb = parseKb(line) ?: rssKb
line.startsWith("VmSize:") -> vmSizeKb = parseKb(line) ?: vmSizeKb
}
}
return Pair(rssKb * 1024L, vmSizeKb * 1024L)
}
private fun parseKb(line: String): Long? {
val parts = line.split(Regex("\\s+"))
if (parts.size < 2) return null
return parts[1].toLongOrNull()
}
class StubDriverBackend(private val platform: String) : DriverBackend {
@Volatile var launchCount: Int = 0
private set
@@ -47,38 +131,13 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
@Volatile var lastInputText: String? = null
private set
override fun launch(bundleId: String, launcherActivity: String, clearState: Boolean) {
override fun launch(bundleId: String, clearState: Boolean) {
launchCount++
lastBundleId = bundleId
if (clearState) {
runAdb(listOf("shell", "pm", "clear", bundleId))
}
val component = when {
launcherActivity.isEmpty() -> "$bundleId/${resolveLauncherActivity(bundleId)}"
launcherActivity.contains('/') -> launcherActivity
else -> "$bundleId/$launcherActivity"
}
runAdb(listOf("shell", "am", "start", "-W", "-n", component))
}
private fun resolveLauncherActivity(bundleId: String): String {
val output = captureAdb(
listOf(
"shell", "cmd", "package", "resolve-activity", "--brief",
"-a", "android.intent.action.MAIN",
"-c", "android.intent.category.LAUNCHER",
bundleId,
),
)
return parseResolvedActivity(bundleId, output)
?: throw IllegalStateException("could not resolve launcher activity for $bundleId: $output")
}
private fun captureAdb(arguments: List<String>): String {
val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
return output
runAdb(listOf("shell", "am", "start", "-W", "-n", "$bundleId/.MainActivity"))
}
companion object {
@@ -87,9 +146,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
internal fun isAnimationCountIdle(grepOutput: String): Boolean =
(grepOutput.trim().toIntOrNull() ?: 0) == 0
// parseResolvedActivity extracts the activity name from the output of
// `cmd package resolve-activity --brief`. The brief output is two
// lines: metadata, then `<pkg>/<activity>`.
internal fun parseResolvedActivity(bundleId: String, output: String): String? {
val prefix = "$bundleId/"
for (line in output.lines()) {
@@ -101,8 +157,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return null
}
// Hard cap on KEYCODE_DEL events per clear. Guards against a pathological
// hierarchy that reports an enormous text length for the focused field.
internal const val MAX_CLEAR_DELETES: Int = 1024
internal fun buildClearKeyevents(textLength: Int): List<String> {
@@ -113,11 +167,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return args
}
// `adb shell input text` runs through a remote sh, so shell metacharacters
// in the payload would be interpreted by the device shell. Substitute
// spaces with %s (input's escape) and backslash-escape characters sh
// would otherwise expand. Keep this list conservative; anything not
// listed passes through literally.
internal fun escapeForAdbInputText(text: String): String {
val sb = StringBuilder(text.length)
for (ch in text) {
@@ -131,9 +180,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return sb.toString()
}
// Matches a uiautomator-dump <node ...> tag where `focused="true"` is
// present. Captures only the tag's attribute string so we can pull
// `text="..."` out of it without building a full XML tree.
private val FOCUSED_NODE = Regex(
"<node\\b([^>]*\\bfocused=\"true\"[^>]*)/?>",
)
@@ -169,10 +215,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return "$seconds.${millis.toString().padStart(3, '0')}"
}
// Logcat default threadtime format:
// MM-dd HH:mm:ss.SSS PID TID L TAG: message
// The leading date is the local year-inferred date; we convert to a
// unix-millis best-effort using the current year.
private val LOGCAT_LINE = Regex(
"^(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})\\.(\\d{3})" +
"\\s+\\d+\\s+\\d+\\s+([VDIWEFS])\\s+([^:]+?):\\s?(.*)$",
@@ -215,16 +257,10 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
override fun tapSelector(selector: String) {
lastTapSelector = selector
// v0.1: selector resolution lives in Maestro proper; the stub
// records the selector so logs/traces show what was requested.
}
override fun inputText(text: String) {
lastInputText = text
// `adb shell input text` types keystrokes at the caret, so repeated
// calls append. Clear the focused field first so the caller sees a
// pure replace: read the current value's length from the hierarchy,
// then move-end + N backspaces before typing.
clearFocusedField()
runAdb(listOf("shell", "input", "text", escapeForAdbInputText(text)))
}
@@ -271,31 +307,14 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
runAdb(listOf("shell", "input", "keyevent", keyCode))
}
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> {
val level = if (minLevel.isEmpty()) "E" else minLevel
val since = if (sinceUnixMillis > 0) formatAdbLogcatTimestamp(sinceUnixMillis) else null
val arguments = mutableListOf("logcat", "-d", "*:$level")
if (since != null) {
arguments.add("-T")
arguments.add(since)
}
return try {
val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
parseLogcatOutput(output)
} catch (cause: Exception) {
println("adb logcat failed: $cause")
emptyList()
}
}
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> =
readLogcat(null, sinceUnixMillis, minLevel)
data class SwipeRecord(val fromX: Int, val fromY: Int, val toX: Int, val toY: Int, val durationMillis: Long)
private fun runAdb(arguments: List<String>) {
try {
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start()
// Drain output before waiting so a large write doesn't block the child.
command.inputStream.bufferedReader().readText()
command.waitFor()
} catch (cause: Exception) {
@@ -345,129 +364,138 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
private fun isDeviceIdle(): Boolean {
return try {
val output = captureAdb(
listOf("shell", "dumpsys window -a | grep -c mAnimating=true"),
)
val output = adbOutput(null, listOf("shell", "dumpsys window -a | grep -c mAnimating=true"))
isAnimationCountIdle(output)
} catch (cause: Exception) {
false
}
}
override fun healthy(): Boolean = true
// Stateful CPU delta tracker. Reading /proc/<pid>/stat gives cumulative
// utime+stime in jiffies; CPU % over a step is (delta_ticks / delta_wall)
// * 100. First call blocks briefly for a real reading instead of 0.
private data class CpuSample(val pid: Int, val ticks: Long, val wallNanos: Long)
@Volatile private var lastCpuSample: CpuSample? = null
private val clockTicksPerSecond: Long by lazy { resolveClockTicksPerSecond() }
private val cpuFirstSampleSleepMillis: Long = 50L
override fun metrics(bundleId: String): MetricsSample = readProcMetrics(null, bundleId)
}
override fun metrics(bundleId: String): MetricsSample {
if (bundleId.isEmpty()) return MetricsSample(0.0, 0L, 0L)
return try {
val pid = runAdbOutput(listOf("shell", "pidof", bundleId)).trim().split(Regex("\\s+")).firstOrNull()?.toIntOrNull()
?: run {
lastCpuSample = null
return MetricsSample(0.0, 0L, 0L)
class MaestroDriverBackend(private val serial: String?) : DriverBackend {
private val dadb: dadb.Dadb
private val driver: maestro.drivers.AndroidDriver
init {
dadb = buildDadb(serial)
driver = maestro.drivers.AndroidDriver(dadb, 7001, "localhost")
driver.open()
}
val cpu = sampleCpuPercent(pid)
val (rssBytes, vmSizeBytes) = sampleProcessMemory(pid)
MetricsSample(cpu, rssBytes, vmSizeBytes)
} catch (cause: Exception) {
println("metrics capture failed: $cause")
MetricsSample(0.0, 0L, 0L)
override fun launch(bundleId: String, clearState: Boolean) {
if (clearState) driver.clearAppState(bundleId)
driver.launchApp(bundleId, emptyMap(), java.util.UUID.randomUUID())
}
override fun terminate(bundleId: String) = driver.stopApp(bundleId)
override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y))
override fun tapSelector(selector: String) {
val root = driver.contentDescriptor(false)
val bounds = findBoundsBySelector(root, selector) ?: return
driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2))
}
override fun inputText(text: String) = driver.inputText(text)
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) =
driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L))
override fun pressKey(key: String) {
StubDriverBackend.KEY_MAP[key]?.let { keyCode ->
keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) }
}
}
private fun sampleCpuPercent(pid: Int): Double {
val previous = lastCpuSample
if (previous != null && previous.pid == pid) {
val ticks = readCpuTicks(pid) ?: return 0.0
val now = System.nanoTime()
lastCpuSample = CpuSample(pid, ticks, now)
return cpuPercentFromDelta(ticks - previous.ticks, now - previous.wallNanos)
}
// No baseline for this PID: one adb round-trip with a device-side sleep
// so the first step gets a real reading instead of 0.
val pair = readCpuTicksPair(pid, cpuFirstSampleSleepMillis) ?: return 0.0
lastCpuSample = CpuSample(pid, pair.second, System.nanoTime())
return cpuPercentFromDelta(pair.second - pair.first, cpuFirstSampleSleepMillis * 1_000_000L)
override fun screenshot(): Triple<ByteArray, Int, Int> {
val buf = okio.Buffer()
driver.takeScreenshot(buf, false)
val bytes = buf.readByteArray()
return Triple(bytes, pngWidth(bytes), pngHeight(bytes))
}
private fun readCpuTicks(pid: Int): Long? {
val stat = runAdbOutput(listOf("shell", "cat", "/proc/$pid/stat")).trim()
if (stat.isEmpty()) return null
return parseCpuTicks(stat)
override fun hierarchy(): String =
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().writeValueAsString(driver.contentDescriptor(false))
override fun recentLogs(sinceUnixMillis: Long, minLevel: String) =
readLogcat(serial, sinceUnixMillis, minLevel)
override fun waitForIdle(durationMillis: Long) {
driver.waitForAppToSettle(null, null, durationMillis.toInt())
}
private fun readCpuTicksPair(pid: Int, sleepMillis: Long): Pair<Long, Long>? {
// "sleep 0.050" — toybox sleep accepts fractional seconds on modern Android.
val sleepArg = "0.${"%03d".format(sleepMillis)}"
val command = "cat /proc/$pid/stat; sleep $sleepArg; cat /proc/$pid/stat"
val output = runAdbOutput(listOf("shell", command))
val lines = output.lines().filter { it.isNotBlank() }
if (lines.size < 2) return null
val first = parseCpuTicks(lines[0]) ?: return null
val second = parseCpuTicks(lines[1]) ?: return null
return Pair(first, second)
}
override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }
private fun parseCpuTicks(statLine: String): Long? {
// /proc/<pid>/stat format: pid (comm) state ppid ... utime stime ...
// comm is parenthesized and may contain spaces; rsplit on ')' to skip it.
val afterComm = statLine.substringAfterLast(')').trim()
val fields = afterComm.split(Regex("\\s+"))
// After the ')' we are at the "state" field (index 0 in afterComm).
// utime is proc(14) = afterComm[11], stime is proc(15) = afterComm[12].
if (fields.size < 13) return null
val utime = fields[11].toLongOrNull() ?: return null
val stime = fields[12].toLongOrNull() ?: return null
return utime + stime
}
override fun metrics(bundleId: String) = readProcMetrics(serial, bundleId)
}
private fun cpuPercentFromDelta(deltaTicks: Long, deltaWallNanos: Long): Double {
if (deltaTicks < 0 || deltaWallNanos <= 0) return 0.0
val tickHz = clockTicksPerSecond.coerceAtLeast(1L)
val deltaCpuNanos = deltaTicks * 1_000_000_000.0 / tickHz
return (deltaCpuNanos / deltaWallNanos) * 100.0
private fun buildDadb(serial: String?): dadb.Dadb {
return if (serial == null) {
dadb.Dadb.create("localhost", 5555)
} else {
dadb.Dadb.create(serial.substringBefore(":"), serial.substringAfter(":").toIntOrNull() ?: 5555)
}
}
private fun resolveClockTicksPerSecond(): Long {
val output = runAdbOutput(listOf("shell", "getconf", "CLK_TCK")).trim()
return output.toLongOrNull() ?: 100L
}
private fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntArray? {
val colon = selector.indexOf(':')
if (colon < 0) return null
val kind = selector.substring(0, colon)
val value = selector.substring(colon + 1)
return findBoundsInTree(root, kind, value)
}
private fun sampleProcessMemory(pid: Int): Pair<Long, Long> {
val status = runAdbOutput(listOf("shell", "cat", "/proc/$pid/status"))
var rssKb = 0L
var vmSizeKb = 0L
for (raw in status.lineSequence()) {
val line = raw.trim()
when {
line.startsWith("VmRSS:") -> rssKb = parseKb(line) ?: rssKb
line.startsWith("VmSize:") -> vmSizeKb = parseKb(line) ?: vmSizeKb
private fun findBoundsInTree(node: maestro.TreeNode, kind: String, value: String): IntArray? {
val attrs = node.attributes
val matches = when (kind) {
"id" -> attrs["resource-id"]?.let { it == value || it.endsWith(":id/$value") } == true
"text" -> attrs["text"] == value
"desc" -> attrs["content-desc"] == value
"descPrefix" -> attrs["content-desc"]?.startsWith(value) == true
else -> false
}
if (matches) {
attrs["bounds"]?.let { b -> parseBounds(b)?.let { return it } }
}
return Pair(rssKb * 1024L, vmSizeKb * 1024L)
for (child in node.children) {
findBoundsInTree(child, kind, value)?.let { return it }
}
return null
}
private fun parseBounds(s: String): IntArray? {
val pattern = Regex("^\\[(-?\\d+),(-?\\d+),(-?\\d+),(-?\\d+)\\]$")
val m = pattern.matchEntire(s) ?: return null
return IntArray(4) { m.groupValues[it + 1].toInt() }
}
private fun pngWidth(bytes: ByteArray): Int {
if (bytes.size < 24) return 0
return (bytes[16].toInt() and 0xFF shl 24) or (bytes[17].toInt() and 0xFF shl 16) or
(bytes[18].toInt() and 0xFF shl 8) or (bytes[19].toInt() and 0xFF)
}
private fun parseKb(line: String): Long? {
val parts = line.split(Regex("\\s+"))
if (parts.size < 2) return null
return parts[1].toLongOrNull()
}
private fun pngHeight(bytes: ByteArray): Int {
if (bytes.size < 24) return 0
return (bytes[20].toInt() and 0xFF shl 24) or (bytes[21].toInt() and 0xFF shl 16) or
(bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF)
}
private fun runAdbOutput(arguments: List<String>): String {
return try {
val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(false).start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
output
} catch (cause: Exception) {
""
}
private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? {
return when (adbKeyCode) {
"KEYCODE_BACK" -> maestro.KeyCode.BACK
"KEYCODE_HOME" -> maestro.KeyCode.HOME
"KEYCODE_ENTER" -> maestro.KeyCode.ENTER
"KEYCODE_TAB" -> maestro.KeyCode.TAB
"KEYCODE_DPAD_UP" -> maestro.KeyCode.REMOTE_UP
"KEYCODE_DPAD_DOWN" -> maestro.KeyCode.REMOTE_DOWN
"KEYCODE_DPAD_LEFT" -> maestro.KeyCode.REMOTE_LEFT
"KEYCODE_DPAD_RIGHT" -> maestro.KeyCode.REMOTE_RIGHT
else -> null
}
}
@@ -23,15 +23,14 @@ import java.util.concurrent.atomic.AtomicReference
class DriverService(
private val platform: String = "android",
private val serial: String? = null,
private val backend: DriverBackend = StubDriverBackend(platform),
private val backend: DriverBackend,
) : DriverGrpc.DriverImplBase() {
private val launchedBundleId = AtomicReference<String?>(null)
override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) {
backend.launch(request.bundleId, request.launcherActivity, request.clearState)
backend.launch(request.bundleId, request.clearState)
launchedBundleId.set(request.bundleId)
Empty.getDefaultInstance()
}
@@ -45,7 +45,12 @@ fun main(arguments: Array<String>) {
if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else null
}
val service = DriverService(platform = platform, serial = serial)
val backend: DriverBackend = if (platform == "android") {
MaestroDriverBackend(serial)
} else {
StubDriverBackend(platform)
}
val service = DriverService(platform = platform, backend = backend)
val server = SidecarServer(port, service)
val boundPort = server.start()
println("sanderling-sidecar listening on 127.0.0.1:$boundPort platform=$platform")
@@ -26,7 +26,7 @@ class DriverServiceTest {
private fun newClient(backend: DriverBackend): DriverGrpc.DriverBlockingStub {
val serverName = InProcessServerBuilder.generateName()
val service = DriverService(platform = "android", serial = null, backend = backend)
val service = DriverService(platform = "android", backend = backend)
grpcCleanup.register(
InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start()
)
@@ -43,7 +43,6 @@ class DriverServiceTest {
client.launch(
LaunchRequest.newBuilder()
.setBundleId("com.example")
.setLauncherActivity(".MainActivity")
.setClearState(true)
.build(),
)
@@ -6,7 +6,7 @@ import kotlin.test.assertTrue
class SidecarServerTest {
@Test
fun startBindsEphemeralPortAndStopReleasesIt() {
val server = SidecarServer(port = 0, service = DriverService())
val server = SidecarServer(port = 0, service = DriverService(backend = StubDriverBackend("android")))
val boundPort = server.start()
try {
assertTrue(boundPort > 0, "expected ephemeral port, got $boundPort")