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

* proto(driver): drop launcher_activity from LaunchRequest

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

* refactor(driver): rename package maestro to sidecar

* feat(driver): add ChromeDriver backed by chromedp

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

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

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

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

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

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

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

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

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

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

No files matched your search

+12 -12
View File
@@ -15,14 +15,13 @@ import (
var Version = "dev" var Version = "dev"
type testOptions struct { type testOptions struct {
spec string spec string
bundleID string bundleID string
launcherActivity string platform string
platform string avd string
avd string duration time.Duration
duration time.Duration seed int64
seed int64 output string
output string
} }
const topUsage = `sanderling is a property-based UI fuzzer for mobile apps. const topUsage = `sanderling is a property-based UI fuzzer for mobile apps.
@@ -45,8 +44,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
var options testOptions var options testOptions
flagSet.StringVar(&options.spec, "spec", "", "path to the TypeScript spec (required)") 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.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, web")
flagSet.StringVar(&options.platform, "platform", "android", "target platform: android (ios deferred)")
flagSet.StringVar(&options.avd, "avd", "", "Android AVD name to boot if no device is connected") 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.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration")
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)") 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 == "" { if options.bundleID == "" {
return testOptions{}, errors.New("--bundle-id is required") return testOptions{}, errors.New("--bundle-id is required")
} }
if options.platform != "android" { switch options.platform {
return testOptions{}, fmt.Errorf("unsupported platform: %q (only android in v0.1)", options.platform) case "android", "ios", "web":
default:
return testOptions{}, fmt.Errorf("unsupported platform: %q (android, web)", options.platform)
} }
return options, nil 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{ _, err := parseTestArgs([]string{
"--spec", "s.ts", "--spec", "s.ts",
"--bundle-id", "com.example", "--bundle-id", "com.example",
"--platform", "ios", "--platform", "fuchsia",
"--avd", "x", "--avd", "x",
}, io.Discard) }, io.Discard)
if err == nil || !strings.Contains(err.Error(), "unsupported platform") { 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) { func TestRun_HelpPrintsUsage(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
if err := run([]string{"sanderling"}, &stdout, io.Discard); err != nil { if err := run([]string{"sanderling"}, &stdout, io.Discard); err != nil {
+82 -59
View File
@@ -14,7 +14,9 @@ import (
"github.com/priyanshujain/sanderling/internal/agent" "github.com/priyanshujain/sanderling/internal/agent"
"github.com/priyanshujain/sanderling/internal/bundler" "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/runner"
"github.com/priyanshujain/sanderling/internal/sidecar" "github.com/priyanshujain/sanderling/internal/sidecar"
"github.com/priyanshujain/sanderling/internal/trace" "github.com/priyanshujain/sanderling/internal/trace"
@@ -28,7 +30,7 @@ const (
) )
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error { 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 { if err := ensureDevice(ctx, options.avd, stdout); err != nil {
return err return err
} }
@@ -36,8 +38,6 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
aliases := map[string]string{} aliases := map[string]string{}
if specApiPath := resolveSpecAPIPath(options.spec); specApiPath != "" { if specApiPath := resolveSpecAPIPath(options.spec); specApiPath != "" {
aliases["@sanderling/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) base := filepath.Dir(specApiPath)
aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts") 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]) fmt.Fprintf(stdout, "bundled spec: %d bytes (sha256=%s)\n", len(bundle.JavaScript), bundle.SHA256[:12])
sidecarDirectory := filepath.Join(os.TempDir(), "sanderling-sidecar") activeDriver, cleanup, err := buildDriver(ctx, options, stdout)
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()
if err != nil { if err != nil {
return err return err
} }
sidecarCommand := exec.CommandContext(ctx, "java", "-jar", jarPath, defer cleanup()
"--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")
listener, err := net.Listen("tcp", "127.0.0.1:0") listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
@@ -102,15 +67,17 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
defer listener.Close() defer listener.Close()
agentPort := listener.Addr().(*net.TCPAddr).Port agentPort := listener.Addr().(*net.TCPAddr).Port
if err := adbReverse(socketName, agentPort); err != nil { if options.platform != "web" {
return fmt.Errorf("adb reverse: %w", err) if err := adbReverse(socketName, agentPort); err != nil {
} return fmt.Errorf("adb reverse: %w", err)
defer func() {
if err := adbReverseRemove(socketName); err != nil {
fmt.Fprintf(stdout, "warning: adb reverse cleanup: %v\n", err)
} }
}() defer func() {
fmt.Fprintf(stdout, "forwarded localabstract:%s -> tcp:%d\n", socketName, agentPort) if err := adbReverseRemove(socketName); err != nil {
fmt.Fprintf(stdout, "warning: adb reverse cleanup: %v\n", err)
}
}()
fmt.Fprintf(stdout, "forwarded localabstract:%s -> tcp:%d\n", socketName, agentPort)
}
agentServer := agent.NewServer(listener) agentServer := agent.NewServer(listener)
@@ -126,7 +93,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
acceptChannel <- acceptResult{connection: connection, err: acceptErr} 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) return fmt.Errorf("launch app: %w", err)
} }
fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.bundleID, sdkAcceptTimeout.Seconds()) fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.bundleID, sdkAcceptTimeout.Seconds())
@@ -160,13 +127,13 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
} }
defer traceWriter.Close() defer traceWriter.Close()
meta := trace.Meta{ meta := trace.Meta{
Seed: seed, Seed: seed,
SpecPath: options.spec, SpecPath: options.spec,
BundleSHA256: bundle.SHA256, BundleSHA256: bundle.SHA256,
Platform: options.platform, Platform: options.platform,
BundleID: options.bundleID, BundleID: options.bundleID,
StartedAt: time.Now().UTC(), StartedAt: time.Now().UTC(),
SanderlingVersion: "0.0.1", SanderlingVersion: "0.0.1",
} }
if err := traceWriter.WriteMeta(meta); err != nil { if err := traceWriter.WriteMeta(meta); err != nil {
return fmt.Errorf("trace meta: %w", err) return fmt.Errorf("trace meta: %w", err)
@@ -185,14 +152,14 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
IdleTimeout: 1 * time.Second, IdleTimeout: 1 * time.Second,
BundleID: options.bundleID, BundleID: options.bundleID,
Connection: connection, Connection: connection,
Driver: driverClient, Driver: activeDriver,
Verifier: verifierInstance, Verifier: verifierInstance,
TraceWriter: traceWriter, TraceWriter: traceWriter,
Logger: newProgressLogger(stdout), Logger: newProgressLogger(stdout),
}) })
terminateCtx, terminateCancel := context.WithTimeout(context.Background(), 5*time.Second) terminateCtx, terminateCancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = driverClient.Terminate(terminateCtx) _ = activeDriver.Terminate(terminateCtx)
terminateCancel() terminateCancel()
if err != nil { if err != nil {
@@ -211,6 +178,62 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
return nil 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 // 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. // 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 // Returns "" when not found, in which case esbuild resolves @sanderling/spec via
+8 -1
View File
@@ -12,11 +12,18 @@ require (
) )
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/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/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 github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
golang.org/x/net v0.49.0 // 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 golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // 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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk= 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/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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 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 h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.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 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= 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= 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" "time"
) )
// Driver abstracts the platform-specific UI automation backend. v0.1 surface // DeviceDriver abstracts the platform-specific UI automation backend. v0.1
// matches proto/driverpb/driver.proto. The Maestro sidecar implementation // surface matches proto/driverpb/driver.proto. The sidecar implementation
// lives under driver/maestro; tests use driver/mock. // lives under driver/sidecar; the web implementation under driver/chrome;
type Driver interface { // tests use driver/mock.
// Launch asks the backend to bring the target app to the foreground. type DeviceDriver interface {
// launcherActivity is an optional "<pkg>/<activity>" component that Launch(ctx context.Context, bundleID string, clearState bool) error
// 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
Terminate(ctx context.Context) error Terminate(ctx context.Context) error
Tap(ctx context.Context, x, y int) error Tap(ctx context.Context, x, y int) error
@@ -24,7 +21,7 @@ type Driver interface {
Hierarchy(ctx context.Context) (string, error) Hierarchy(ctx context.Context) (string, error)
Screenshot(ctx context.Context) (Image, 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". // `minLevel` or above. An empty minLevel defaults to "E".
RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]LogEntry, error) RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]LogEntry, error)
+16 -17
View File
@@ -27,20 +27,19 @@ const (
) )
type Action struct { type Action struct {
Kind ActionKind Kind ActionKind
BundleID string BundleID string
LauncherActivity string ClearState bool
ClearState bool X, Y int
X, Y int FromX, FromY int
FromX, FromY int ToX, ToY int
ToX, ToY int Duration time.Duration
Duration time.Duration Selector string
Selector string Text string
Text string Key string
Key string LogLevel string
LogLevel string LogSince time.Time
LogSince time.Time Idle time.Duration
Idle time.Duration
} }
// Driver is an in-memory Driver implementation for unit tests. // 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] 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 { if err := d.failure(ActionLaunch); err != nil {
return err return err
} }
d.record(Action{Kind: ActionLaunch, BundleID: bundleID, LauncherActivity: launcherActivity, ClearState: clearState}) d.record(Action{Kind: ActionLaunch, BundleID: bundleID, ClearState: clearState})
return nil return nil
} }
@@ -220,4 +219,4 @@ func (d *Driver) Metrics(ctx context.Context, bundleID string) (driver.Metrics,
return d.MetricsData, nil 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() mock := New()
ctx := context.Background() 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) t.Fatal(err)
} }
if err := mock.Tap(ctx, 100, 200); err != nil { if err := mock.Tap(ctx, 100, 200); err != nil {
@@ -137,5 +137,5 @@ func TestActionsReturnsCopy(t *testing.T) {
} }
func TestSatisfiesDriverInterface(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 ( import (
"context" "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{ _, err := c.stub.Launch(ctx, &driverpb.LaunchRequest{
BundleId: bundleID, BundleId: bundleID,
ClearState: clearState, ClearState: clearState,
LauncherActivity: launcherActivity,
}) })
return err return err
} }
@@ -165,4 +164,4 @@ func (c *Client) Metrics(ctx context.Context, bundleID string) (driver.Metrics,
}, nil }, nil
} }
var _ driver.Driver = (*Client)(nil) var _ driver.DeviceDriver = (*Client)(nil)
@@ -1,4 +1,4 @@
package maestro package sidecar
import ( import (
"context" "context"
@@ -22,7 +22,6 @@ type fakeServer struct {
healthReadyAfterCall int healthReadyAfterCall int
launchedBundleID string launchedBundleID string
launcherActivity string
clearState bool clearState bool
terminateCalls int terminateCalls int
taps []int32 taps []int32
@@ -55,7 +54,6 @@ func (s *fakeServer) Launch(_ context.Context, request *driverpb.LaunchRequest)
s.mutex.Lock() s.mutex.Lock()
defer s.mutex.Unlock() defer s.mutex.Unlock()
s.launchedBundleID = request.GetBundleId() s.launchedBundleID = request.GetBundleId()
s.launcherActivity = request.GetLauncherActivity()
s.clearState = request.GetClearState() s.clearState = request.GetClearState()
return &driverpb.Empty{}, nil return &driverpb.Empty{}, nil
} }
@@ -193,10 +191,10 @@ func TestClient_LaunchAndTerminate(t *testing.T) {
client, _ := Dial(state.address) client, _ := Dial(state.address)
defer client.Close() 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) 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) t.Errorf("launch payload wrong: %+v", state.fake)
} }
if err := client.Terminate(context.Background()); err != nil { 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 // Package hierarchy parses the TreeNode JSON produced by the Maestro sidecar
// resolves selectors against it. // and resolves selectors against it.
// //
// Selector grammar (v0.1): // Selector grammar (v0.1):
// //
@@ -10,7 +10,7 @@
package hierarchy package hierarchy
import ( import (
"encoding/xml" "encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"strconv" "strconv"
@@ -36,7 +36,7 @@ func (b Bounds) Width() int { return b.Right - b.Left }
// Height returns the bounds' height. // Height returns the bounds' height.
func (b Bounds) Height() int { return b.Bottom - b.Top } 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 { type Element struct {
ResourceID string `json:"resourceId,omitempty"` ResourceID string `json:"resourceId,omitempty"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
@@ -56,38 +56,82 @@ type Tree struct {
Elements []*Element `json:"elements"` Elements []*Element `json:"elements"`
} }
// Parse parses a uiautomator-style XML dump. // treeNodeJSON mirrors the Maestro TreeNode JSON structure.
func Parse(xmlText string) (*Tree, error) { type treeNodeJSON struct {
xmlText = strings.TrimSpace(xmlText) Attributes map[string]string `json:"attributes"`
if xmlText == "" { 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 return &Tree{}, nil
} }
decoder := xml.NewDecoder(strings.NewReader(xmlText)) var root treeNodeJSON
tree := &Tree{} if err := json.Unmarshal([]byte(text), &root); err != nil {
for { return nil, fmt.Errorf("hierarchy: %w", err)
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)
} }
tree := &Tree{}
walkNode(&root, tree)
return tree, nil 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. // Find returns the first element matching the selector, or nil.
func (t *Tree) Find(selector string) *Element { func (t *Tree) Find(selector string) *Element {
kind, value, ok := parseSelector(selector) kind, value, ok := parseSelector(selector)
@@ -131,11 +175,7 @@ func match(element *Element, kind, value string) bool {
if element.ResourceID == value { if element.ResourceID == value {
return true return true
} }
// ResourceID looks like "<package>:id/<suffix>". return strings.HasSuffix(element.ResourceID, ":id/"+value)
if strings.HasSuffix(element.ResourceID, ":id/"+value) {
return true
}
return false
case "text": case "text":
return element.Text == value return element.Text == value
case "desc": case "desc":
@@ -147,59 +187,21 @@ func match(element *Element, kind, value string) bool {
} }
} }
func elementFromStart(start xml.StartElement) (*Element, error) { // boundsPattern matches "[l,t,r,b]" (4-value Maestro format).
element := &Element{} var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+),(-?\d+),(-?\d+)\]$`)
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+)\]$`)
func parseBounds(text string) (Bounds, error) { func parseBounds(text string) (Bounds, error) {
match := boundsPattern.FindStringSubmatch(text) m := boundsPattern.FindStringSubmatch(text)
if match == nil { if m == nil {
return Bounds{}, fmt.Errorf("not in [L,T][R,B] form") return Bounds{}, fmt.Errorf("bounds %q: not in [L,T,R,B] form", text)
} }
coordinates := make([]int, 4) coords := make([]int, 4)
for index := range 4 { for i := range 4 {
value, err := strconv.Atoi(match[index+1]) v, err := strconv.Atoi(m[i+1])
if err != nil { if err != nil {
return Bounds{}, err 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" import "testing"
const sampleDump = `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?> // sampleDump is a Maestro TreeNode JSON equivalent of the old XML fixture.
<hierarchy rotation="0"> const sampleDump = `{
<node class="android.widget.LinearLayout" package="app" bounds="[0,0][1080,2340]"> "attributes": {"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"/> "children": [
<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"/> "attributes": {"resource-id": "app:id/title", "text": "Hello", "bounds": "[10,20,200,60]"},
</node> "children": [],
</hierarchy>` "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) { func TestParseCountsNodes(t *testing.T) {
tree, err := Parse(sampleDump) tree, err := Parse(sampleDump)
@@ -75,14 +91,76 @@ func TestUnknownSelector(t *testing.T) {
} }
func TestDescPrefix(t *testing.T) { func TestDescPrefix(t *testing.T) {
xml := `<hierarchy> input := `{
<node content-desc="customer_row_abc-123" bounds="[0,0][100,100]"/> "attributes": {},
<node content-desc="customer_row_def-456" bounds="[0,100][100,200]"/> "children": [
<node content-desc="supplier_row_xyz" bounds="[0,200][100,300]"/> {"attributes": {"content-desc": "customer_row_abc-123", "bounds": "[0,0,100,100]"}, "children": []},
</hierarchy>` {"attributes": {"content-desc": "customer_row_def-456", "bounds": "[0,100,100,200]"}, "children": []},
tree, _ := Parse(xml) {"attributes": {"content-desc": "supplier_row_xyz", "bounds": "[0,200,100,300]"}, "children": []}
]
}`
tree, _ := Parse(input)
rows := tree.FindAll("descPrefix:customer_row_") rows := tree.FindAll("descPrefix:customer_row_")
if len(rows) != 2 { if len(rows) != 2 {
t.Fatalf("want 2 customer rows, got %d", len(rows)) 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 BundleID string
Connection *agent.Conn Connection *agent.Conn
Driver driver.Driver Driver driver.DeviceDriver
Verifier *verifier.Verifier Verifier *verifier.Verifier
TraceWriter *trace.Writer TraceWriter *trace.Writer
Logger *slog.Logger Logger *slog.Logger
@@ -272,7 +272,7 @@ func screenFromSnapshot(snapshots map[string]json.RawMessage) (string, error) {
return screen, nil 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 { switch action.Kind {
case verifier.ActionKindTap: case verifier.ActionKindTap:
x, y, ok := resolveCoordinates(action, tree) 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 // 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 // previous fetch. A failure is warned-on but not fatal: log capture is a
// best-effort observability channel, not a correctness dependency. // 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") entries, err := drv.RecentLogs(ctx, since, "E")
if err != nil { if err != nil {
return nil return nil
@@ -374,7 +374,7 @@ func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int,
return 0, 0, false 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) xmlText, err := drv.Hierarchy(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
+1 -4
View File
@@ -321,10 +321,7 @@ func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) {
state.startSDK(t) state.startSDK(t)
state.acceptConnection(t) state.acceptConnection(t)
state.mock.HierarchyJSON = `<?xml version="1.0"?> state.mock.HierarchyJSON = `{"attributes":{"resource-id":"com.fixture:id/next","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true}`
<hierarchy>
<node resource-id="com.fixture:id/next" clickable="true" enabled="true" bounds="[40,80][240,160]"/>
</hierarchy>`
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
+4 -3
View File
@@ -9,12 +9,13 @@ import (
// TestStateAxFindWorks verifies that a Parse+PushSnapshot+extract round trip // TestStateAxFindWorks verifies that a Parse+PushSnapshot+extract round trip
// actually lets the spec resolve selectors through state.ax.find. // 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) { func TestStateAxFindWorks(t *testing.T) {
xmlText, err := os.ReadFile("/tmp/live-dump.xml") jsonText, err := os.ReadFile("/tmp/live-dump.json")
if err != nil { 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+16 -16
View File
@@ -1,19 +1,19 @@
package verifier package verifier
const listHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?> const listHierarchyJSON = `{
<hierarchy rotation="0"> "attributes": {"class": "android.widget.FrameLayout", "package": "test.app", "bounds": "[0,0,1080,2400]"},
<node index="0" class="android.widget.FrameLayout" package="test.app" bounds="[0,0][1080,2400]"> "children": [
<node index="0" class="android.widget.TextView" text="Items" bounds="[100,200][900,300]" /> {"attributes": {"class": "android.widget.TextView", "text": "Items", "bounds": "[100,200,900,300]"}, "children": []},
<node index="1" class="android.view.View" content-desc="primary_action" clickable="true" enabled="true" bounds="[64,2200][1016,2320]" /> {"attributes": {"content-desc": "primary_action", "bounds": "[64,2200,1016,2320]"}, "children": [], "clickable": true, "enabled": true},
<node index="2" class="android.view.View" content-desc="secondary_action" clickable="true" enabled="true" bounds="[980,80][1060,160]" /> {"attributes": {"content-desc": "secondary_action", "bounds": "[980,80,1060,160]"}, "children": [], "clickable": true, "enabled": true}
</node> ]
</hierarchy>` }`
const formHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?> const formHierarchyJSON = `{
<hierarchy rotation="0"> "attributes": {"class": "android.widget.FrameLayout", "package": "test.app", "bounds": "[0,0,1080,2400]"},
<node index="0" class="android.widget.FrameLayout" package="test.app" bounds="[0,0][1080,2400]"> "children": [
<node index="0" class="android.view.View" content-desc="text_field" clickable="true" enabled="true" bounds="[64,320][1016,440]" /> {"attributes": {"content-desc": "text_field", "bounds": "[64,320,1016,440]"}, "children": [], "clickable": true, "enabled": true},
<node index="1" class="android.view.View" content-desc="primary_action" clickable="true" enabled="true" bounds="[64,2200][1016,2320]" /> {"attributes": {"content-desc": "primary_action", "bounds": "[64,2200,1016,2320]"}, "children": [], "clickable": true, "enabled": true},
<node index="2" class="android.view.View" content-desc="secondary_action" clickable="true" enabled="true" bounds="[32,80][112,160]" /> {"attributes": {"content-desc": "secondary_action", "bounds": "[32,80,112,160]"}, "children": [], "clickable": true, "enabled": true}
</node> ]
</hierarchy>` }`
+6 -6
View File
@@ -65,7 +65,7 @@ func TestIntegrationSpecFiresInputActions(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
tree, err := hierarchy.Parse(formHierarchyXML) tree, err := hierarchy.Parse(formHierarchyJSON)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -105,7 +105,7 @@ func TestIntegrationSpecPropertiesEvaluate(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
tree, err := hierarchy.Parse(listHierarchyXML) tree, err := hierarchy.Parse(listHierarchyJSON)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -134,21 +134,21 @@ func TestIntegrationSpecPropertiesEvaluate(t *testing.T) {
func TestIntegrationSpecActionsFireOnEachRoute(t *testing.T) { func TestIntegrationSpecActionsFireOnEachRoute(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
xml string hierarchy string
snapshots Snapshots snapshots Snapshots
expectKind ActionKind expectKind ActionKind
expectOns []string expectOns []string
}{ }{
{ {
name: "list", name: "list",
xml: listHierarchyXML, hierarchy: listHierarchyJSON,
snapshots: listSnapshots(), snapshots: listSnapshots(),
expectKind: ActionKindTap, expectKind: ActionKindTap,
expectOns: []string{"desc:primary_action", "desc:secondary_action"}, expectOns: []string{"desc:primary_action", "desc:secondary_action"},
}, },
{ {
name: "form", name: "form",
xml: formHierarchyXML, hierarchy: formHierarchyJSON,
snapshots: formSnapshots(), snapshots: formSnapshots(),
expectKind: ActionKindTap, expectKind: ActionKindTap,
expectOns: []string{"desc:primary_action", "desc:secondary_action"}, 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 { if err := v.Load(bundleIntegrationSpec(t)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
tree, err := hierarchy.Parse(tc.xml) tree, err := hierarchy.Parse(tc.hierarchy)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+7 -19
View File
@@ -58,15 +58,11 @@ func (*Empty) Descriptor() ([]byte, []int) {
} }
type LaunchRequest struct { type LaunchRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
BundleId string `protobuf:"bytes,1,opt,name=bundle_id,json=bundleId,proto3" json:"bundle_id,omitempty"` 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"` 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 unknownFields protoimpl.UnknownFields
// when an app declares multiple MAIN+LAUNCHER activities where the first sizeCache protoimpl.SizeCache
// 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
} }
func (x *LaunchRequest) Reset() { func (x *LaunchRequest) Reset() {
@@ -113,13 +109,6 @@ func (x *LaunchRequest) GetClearState() bool {
return false return false
} }
func (x *LaunchRequest) GetLauncherActivity() string {
if x != nil {
return x.LauncherActivity
}
return ""
}
type Point struct { type Point struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
X int32 `protobuf:"varint,1,opt,name=x,proto3" json:"x,omitempty"` 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 = "" + const file_driverpb_driver_proto_rawDesc = "" +
"\n" + "\n" +
"\x15driverpb/driver.proto\x12\x14sanderling.driver.v1\"\a\n" + "\x15driverpb/driver.proto\x12\x14sanderling.driver.v1\"\a\n" +
"\x05Empty\"z\n" + "\x05Empty\"M\n" +
"\rLaunchRequest\x12\x1b\n" + "\rLaunchRequest\x12\x1b\n" +
"\tbundle_id\x18\x01 \x01(\tR\bbundleId\x12\x1f\n" + "\tbundle_id\x18\x01 \x01(\tR\bbundleId\x12\x1f\n" +
"\vclear_state\x18\x02 \x01(\bR\n" + "\vclear_state\x18\x02 \x01(\bR\n" +
"clearState\x12+\n" + "clearState\"#\n" +
"\x11launcher_activity\x18\x03 \x01(\tR\x10launcherActivity\"#\n" +
"\x05Point\x12\f\n" + "\x05Point\x12\f\n" +
"\x01x\x18\x01 \x01(\x05R\x01x\x12\f\n" + "\x01x\x18\x01 \x01(\x05R\x01x\x12\f\n" +
"\x01y\x18\x02 \x01(\x05R\x01y\"\x1c\n" + "\x01y\x18\x02 \x01(\x05R\x01y\"\x1c\n" +
-4
View File
@@ -30,10 +30,6 @@ message Empty {}
message LaunchRequest { message LaunchRequest {
string bundle_id = 1; string bundle_id = 1;
bool clear_state = 2; 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 { message Point {
+1
View File
@@ -25,6 +25,7 @@ val maestroVersion = "1.40.0"
dependencies { dependencies {
implementation("dev.mobile:maestro-client:$maestroVersion") implementation("dev.mobile:maestro-client:$maestroVersion")
implementation("dev.mobile:maestro-utils:$maestroVersion")
implementation("io.grpc:grpc-netty-shaded:$grpcVersion") implementation("io.grpc:grpc-netty-shaded:$grpcVersion")
implementation("io.grpc:grpc-protobuf:$grpcVersion") implementation("io.grpc:grpc-protobuf:$grpcVersion")
@@ -1,7 +1,7 @@
package dev.sanderling.sidecar package dev.sanderling.sidecar
interface DriverBackend { interface DriverBackend {
fun launch(bundleId: String, launcherActivity: String, clearState: Boolean) fun launch(bundleId: String, clearState: Boolean)
fun terminate(bundleId: String) fun terminate(bundleId: String)
fun tap(x: Int, y: Int) fun tap(x: Int, y: Int)
fun tapSelector(selector: String) fun tapSelector(selector: String)
@@ -29,12 +29,96 @@ data class LogLine(
val message: String, val message: String,
) )
/** internal fun readLogcat(serial: String?, sinceUnixMillis: Long, minLevel: String): List<LogLine> {
* StubDriverBackend records calls but takes no real device action. Real val level = if (minLevel.isEmpty()) "E" else minLevel
* Maestro integration arrives in a follow-up; v0.1 wires the gRPC plumbing val since = if (sinceUnixMillis > 0) StubDriverBackend.formatAdbLogcatTimestamp(sinceUnixMillis) else null
* end-to-end so the Go side can be exercised against a running sidecar val arguments = mutableListOf("logcat", "-d", "*:$level")
* even before Maestro is plugged in. 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 { class StubDriverBackend(private val platform: String) : DriverBackend {
@Volatile var launchCount: Int = 0 @Volatile var launchCount: Int = 0
private set private set
@@ -47,38 +131,13 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
@Volatile var lastInputText: String? = null @Volatile var lastInputText: String? = null
private set private set
override fun launch(bundleId: String, launcherActivity: String, clearState: Boolean) { override fun launch(bundleId: String, clearState: Boolean) {
launchCount++ launchCount++
lastBundleId = bundleId lastBundleId = bundleId
if (clearState) { if (clearState) {
runAdb(listOf("shell", "pm", "clear", bundleId)) runAdb(listOf("shell", "pm", "clear", bundleId))
} }
val component = when { runAdb(listOf("shell", "am", "start", "-W", "-n", "$bundleId/.MainActivity"))
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
} }
companion object { companion object {
@@ -87,9 +146,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
internal fun isAnimationCountIdle(grepOutput: String): Boolean = internal fun isAnimationCountIdle(grepOutput: String): Boolean =
(grepOutput.trim().toIntOrNull() ?: 0) == 0 (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? { internal fun parseResolvedActivity(bundleId: String, output: String): String? {
val prefix = "$bundleId/" val prefix = "$bundleId/"
for (line in output.lines()) { for (line in output.lines()) {
@@ -101,8 +157,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return null 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 const val MAX_CLEAR_DELETES: Int = 1024
internal fun buildClearKeyevents(textLength: Int): List<String> { internal fun buildClearKeyevents(textLength: Int): List<String> {
@@ -113,11 +167,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return args 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 { internal fun escapeForAdbInputText(text: String): String {
val sb = StringBuilder(text.length) val sb = StringBuilder(text.length)
for (ch in text) { for (ch in text) {
@@ -131,9 +180,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return sb.toString() 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( private val FOCUSED_NODE = Regex(
"<node\\b([^>]*\\bfocused=\"true\"[^>]*)/?>", "<node\\b([^>]*\\bfocused=\"true\"[^>]*)/?>",
) )
@@ -169,10 +215,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return "$seconds.${millis.toString().padStart(3, '0')}" 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( private val LOGCAT_LINE = Regex(
"^(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})\\.(\\d{3})" + "^(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})\\.(\\d{3})" +
"\\s+\\d+\\s+\\d+\\s+([VDIWEFS])\\s+([^:]+?):\\s?(.*)$", "\\s+\\d+\\s+\\d+\\s+([VDIWEFS])\\s+([^:]+?):\\s?(.*)$",
@@ -215,16 +257,10 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
override fun tapSelector(selector: String) { override fun tapSelector(selector: String) {
lastTapSelector = selector 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) { override fun inputText(text: String) {
lastInputText = text 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() clearFocusedField()
runAdb(listOf("shell", "input", "text", escapeForAdbInputText(text))) runAdb(listOf("shell", "input", "text", escapeForAdbInputText(text)))
} }
@@ -271,31 +307,14 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
runAdb(listOf("shell", "input", "keyevent", keyCode)) runAdb(listOf("shell", "input", "keyevent", keyCode))
} }
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> { override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> =
val level = if (minLevel.isEmpty()) "E" else minLevel readLogcat(null, sinceUnixMillis, 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()
}
}
data class SwipeRecord(val fromX: Int, val fromY: Int, val toX: Int, val toY: Int, val durationMillis: Long) data class SwipeRecord(val fromX: Int, val fromY: Int, val toX: Int, val toY: Int, val durationMillis: Long)
private fun runAdb(arguments: List<String>) { private fun runAdb(arguments: List<String>) {
try { try {
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() 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.inputStream.bufferedReader().readText()
command.waitFor() command.waitFor()
} catch (cause: Exception) { } catch (cause: Exception) {
@@ -345,129 +364,138 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
private fun isDeviceIdle(): Boolean { private fun isDeviceIdle(): Boolean {
return try { return try {
val output = captureAdb( val output = adbOutput(null, listOf("shell", "dumpsys window -a | grep -c mAnimating=true"))
listOf("shell", "dumpsys window -a | grep -c mAnimating=true"),
)
isAnimationCountIdle(output) isAnimationCountIdle(output)
} catch (cause: Exception) { } catch (cause: Exception) {
false false
} }
} }
override fun healthy(): Boolean = true override fun healthy(): Boolean = true
// Stateful CPU delta tracker. Reading /proc/<pid>/stat gives cumulative override fun metrics(bundleId: String): MetricsSample = readProcMetrics(null, bundleId)
// 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 { class MaestroDriverBackend(private val serial: String?) : DriverBackend {
if (bundleId.isEmpty()) return MetricsSample(0.0, 0L, 0L) private val dadb: dadb.Dadb
return try { private val driver: maestro.drivers.AndroidDriver
val pid = runAdbOutput(listOf("shell", "pidof", bundleId)).trim().split(Regex("\\s+")).firstOrNull()?.toIntOrNull()
?: run { init {
lastCpuSample = null dadb = buildDadb(serial)
return MetricsSample(0.0, 0L, 0L) driver = maestro.drivers.AndroidDriver(dadb, 7001, "localhost")
} driver.open()
val cpu = sampleCpuPercent(pid) }
val (rssBytes, vmSizeBytes) = sampleProcessMemory(pid)
MetricsSample(cpu, rssBytes, vmSizeBytes) override fun launch(bundleId: String, clearState: Boolean) {
} catch (cause: Exception) { if (clearState) driver.clearAppState(bundleId)
println("metrics capture failed: $cause") driver.launchApp(bundleId, emptyMap(), java.util.UUID.randomUUID())
MetricsSample(0.0, 0L, 0L) }
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 { override fun screenshot(): Triple<ByteArray, Int, Int> {
val previous = lastCpuSample val buf = okio.Buffer()
if (previous != null && previous.pid == pid) { driver.takeScreenshot(buf, false)
val ticks = readCpuTicks(pid) ?: return 0.0 val bytes = buf.readByteArray()
val now = System.nanoTime() return Triple(bytes, pngWidth(bytes), pngHeight(bytes))
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)
} }
private fun readCpuTicks(pid: Int): Long? { override fun hierarchy(): String =
val stat = runAdbOutput(listOf("shell", "cat", "/proc/$pid/stat")).trim() com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().writeValueAsString(driver.contentDescriptor(false))
if (stat.isEmpty()) return null
return parseCpuTicks(stat) 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>? { override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }
// "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)
}
private fun parseCpuTicks(statLine: String): Long? { override fun metrics(bundleId: String) = readProcMetrics(serial, bundleId)
// /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
}
private fun cpuPercentFromDelta(deltaTicks: Long, deltaWallNanos: Long): Double { private fun buildDadb(serial: String?): dadb.Dadb {
if (deltaTicks < 0 || deltaWallNanos <= 0) return 0.0 return if (serial == null) {
val tickHz = clockTicksPerSecond.coerceAtLeast(1L) dadb.Dadb.create("localhost", 5555)
val deltaCpuNanos = deltaTicks * 1_000_000_000.0 / tickHz } else {
return (deltaCpuNanos / deltaWallNanos) * 100.0 dadb.Dadb.create(serial.substringBefore(":"), serial.substringAfter(":").toIntOrNull() ?: 5555)
} }
}
private fun resolveClockTicksPerSecond(): Long { private fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntArray? {
val output = runAdbOutput(listOf("shell", "getconf", "CLK_TCK")).trim() val colon = selector.indexOf(':')
return output.toLongOrNull() ?: 100L if (colon < 0) return null
val kind = selector.substring(0, colon)
val value = selector.substring(colon + 1)
return findBoundsInTree(root, kind, value)
}
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 } }
}
for (child in node.children) {
findBoundsInTree(child, kind, value)?.let { return it }
} }
return null
}
private fun sampleProcessMemory(pid: Int): Pair<Long, Long> { private fun parseBounds(s: String): IntArray? {
val status = runAdbOutput(listOf("shell", "cat", "/proc/$pid/status")) val pattern = Regex("^\\[(-?\\d+),(-?\\d+),(-?\\d+),(-?\\d+)\\]$")
var rssKb = 0L val m = pattern.matchEntire(s) ?: return null
var vmSizeKb = 0L return IntArray(4) { m.groupValues[it + 1].toInt() }
for (raw in status.lineSequence()) { }
val line = raw.trim()
when { private fun pngWidth(bytes: ByteArray): Int {
line.startsWith("VmRSS:") -> rssKb = parseKb(line) ?: rssKb if (bytes.size < 24) return 0
line.startsWith("VmSize:") -> vmSizeKb = parseKb(line) ?: vmSizeKb 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)
} }
return Pair(rssKb * 1024L, vmSizeKb * 1024L)
}
private fun parseKb(line: String): Long? { private fun pngHeight(bytes: ByteArray): Int {
val parts = line.split(Regex("\\s+")) if (bytes.size < 24) return 0
if (parts.size < 2) return null return (bytes[20].toInt() and 0xFF shl 24) or (bytes[21].toInt() and 0xFF shl 16) or
return parts[1].toLongOrNull() (bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF)
} }
private fun runAdbOutput(arguments: List<String>): String { private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? {
return try { return when (adbKeyCode) {
val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(false).start() "KEYCODE_BACK" -> maestro.KeyCode.BACK
val output = process.inputStream.bufferedReader().readText() "KEYCODE_HOME" -> maestro.KeyCode.HOME
process.waitFor() "KEYCODE_ENTER" -> maestro.KeyCode.ENTER
output "KEYCODE_TAB" -> maestro.KeyCode.TAB
} catch (cause: Exception) { "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( class DriverService(
private val platform: String = "android", private val platform: String = "android",
private val serial: String? = null, private val backend: DriverBackend,
private val backend: DriverBackend = StubDriverBackend(platform),
) : DriverGrpc.DriverImplBase() { ) : DriverGrpc.DriverImplBase() {
private val launchedBundleId = AtomicReference<String?>(null) private val launchedBundleId = AtomicReference<String?>(null)
override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) { override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) { runRpc(responseObserver) {
backend.launch(request.bundleId, request.launcherActivity, request.clearState) backend.launch(request.bundleId, request.clearState)
launchedBundleId.set(request.bundleId) launchedBundleId.set(request.bundleId)
Empty.getDefaultInstance() Empty.getDefaultInstance()
} }
@@ -45,7 +45,12 @@ fun main(arguments: Array<String>) {
if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else null 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 server = SidecarServer(port, service)
val boundPort = server.start() val boundPort = server.start()
println("sanderling-sidecar listening on 127.0.0.1:$boundPort platform=$platform") 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 { private fun newClient(backend: DriverBackend): DriverGrpc.DriverBlockingStub {
val serverName = InProcessServerBuilder.generateName() val serverName = InProcessServerBuilder.generateName()
val service = DriverService(platform = "android", serial = null, backend = backend) val service = DriverService(platform = "android", backend = backend)
grpcCleanup.register( grpcCleanup.register(
InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start() InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start()
) )
@@ -43,7 +43,6 @@ class DriverServiceTest {
client.launch( client.launch(
LaunchRequest.newBuilder() LaunchRequest.newBuilder()
.setBundleId("com.example") .setBundleId("com.example")
.setLauncherActivity(".MainActivity")
.setClearState(true) .setClearState(true)
.build(), .build(),
) )
@@ -6,7 +6,7 @@ import kotlin.test.assertTrue
class SidecarServerTest { class SidecarServerTest {
@Test @Test
fun startBindsEphemeralPortAndStopReleasesIt() { fun startBindsEphemeralPortAndStopReleasesIt() {
val server = SidecarServer(port = 0, service = DriverService()) val server = SidecarServer(port = 0, service = DriverService(backend = StubDriverBackend("android")))
val boundPort = server.start() val boundPort = server.start()
try { try {
assertTrue(boundPort > 0, "expected ephemeral port, got $boundPort") assertTrue(boundPort > 0, "expected ephemeral port, got $boundPort")