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"
type testOptions struct {
spec string
bundleID string
launcherActivity string
platform string
avd string
duration time.Duration
seed int64
output string
spec string
bundleID string
platform string
avd string
duration time.Duration
seed int64
output string
}
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
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 {
+82 -59
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,15 +67,17 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
defer listener.Close()
agentPort := listener.Addr().(*net.TCPAddr).Port
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)
if options.platform != "web" {
if err := adbReverse(socketName, agentPort); err != nil {
return fmt.Errorf("adb reverse: %w", err)
}
}()
fmt.Fprintf(stdout, "forwarded localabstract:%s -> tcp:%d\n", socketName, agentPort)
defer func() {
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)
@@ -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())
@@ -160,13 +127,13 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
}
defer traceWriter.Close()
meta := trace.Meta{
Seed: seed,
SpecPath: options.spec,
BundleSHA256: bundle.SHA256,
Platform: options.platform,
BundleID: options.bundleID,
StartedAt: time.Now().UTC(),
SanderlingVersion: "0.0.1",
Seed: seed,
SpecPath: options.spec,
BundleSHA256: bundle.SHA256,
Platform: options.platform,
BundleID: options.bundleID,
StartedAt: time.Now().UTC(),
SanderlingVersion: "0.0.1",
}
if err := traceWriter.WriteMeta(meta); err != nil {
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,
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