diff --git a/cmd/sanderling/main.go b/cmd/sanderling/main.go index fb5f5cd..7211769 100644 --- a/cmd/sanderling/main.go +++ b/cmd/sanderling/main.go @@ -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 / 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 } diff --git a/cmd/sanderling/main_test.go b/cmd/sanderling/main_test.go index 607d0e4..ada500c 100644 --- a/cmd/sanderling/main_test.go +++ b/cmd/sanderling/main_test.go @@ -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 { diff --git a/cmd/sanderling/test_run.go b/cmd/sanderling/test_run.go index 60486db..253c447 100644 --- a/cmd/sanderling/test_run.go +++ b/cmd/sanderling/test_run.go @@ -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 diff --git a/go.mod b/go.mod index 35b65c3..930417f 100644 --- a/go.mod +++ b/go.mod @@ -12,11 +12,18 @@ require ( ) require ( + github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect + github.com/chromedp/chromedp v0.15.1 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.33.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect ) diff --git a/go.sum b/go.sum index ded4666..846af6d 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,12 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0 github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= +github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ= +github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk= @@ -10,12 +16,20 @@ github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo= github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -41,8 +55,11 @@ golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go new file mode 100644 index 0000000..5cfe22f --- /dev/null +++ b/internal/driver/chrome/driver.go @@ -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) diff --git a/internal/driver/driver.go b/internal/driver/driver.go index 4e9e9b3..d67603a 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -5,15 +5,12 @@ import ( "time" ) -// Driver abstracts the platform-specific UI automation backend. v0.1 surface -// matches proto/driverpb/driver.proto. The Maestro sidecar implementation -// lives under driver/maestro; tests use driver/mock. -type Driver interface { - // Launch asks the backend to bring the target app to the foreground. - // launcherActivity is an optional "/" component that - // overrides the backend's default launcher resolution — needed for - // apps that declare multiple MAIN+LAUNCHER activities. - Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error +// DeviceDriver abstracts the platform-specific UI automation backend. v0.1 +// surface matches proto/driverpb/driver.proto. The sidecar implementation +// lives under driver/sidecar; the web implementation under driver/chrome; +// tests use driver/mock. +type DeviceDriver interface { + Launch(ctx context.Context, bundleID string, clearState bool) error Terminate(ctx context.Context) error Tap(ctx context.Context, x, y int) error @@ -24,7 +21,7 @@ type Driver interface { Hierarchy(ctx context.Context) (string, error) Screenshot(ctx context.Context) (Image, error) - // RecentLogs returns logcat entries at or after `since`, filtered to + // RecentLogs returns log entries at or after `since`, filtered to // `minLevel` or above. An empty minLevel defaults to "E". RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]LogEntry, error) diff --git a/internal/driver/mock/mock.go b/internal/driver/mock/mock.go index cecbc38..c2c45a8 100644 --- a/internal/driver/mock/mock.go +++ b/internal/driver/mock/mock.go @@ -27,20 +27,19 @@ const ( ) type Action struct { - Kind ActionKind - BundleID string - LauncherActivity string - ClearState bool - X, Y int - FromX, FromY int - ToX, ToY int - Duration time.Duration - Selector string - Text string - Key string - LogLevel string - LogSince time.Time - Idle time.Duration + Kind ActionKind + BundleID string + ClearState bool + X, Y int + FromX, FromY int + ToX, ToY int + Duration time.Duration + Selector string + Text string + Key string + LogLevel string + LogSince time.Time + Idle time.Duration } // Driver is an in-memory Driver implementation for unit tests. @@ -89,11 +88,11 @@ func (d *Driver) failure(kind ActionKind) error { return d.Failures[kind] } -func (d *Driver) Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error { +func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool) error { if err := d.failure(ActionLaunch); err != nil { return err } - d.record(Action{Kind: ActionLaunch, BundleID: bundleID, LauncherActivity: launcherActivity, ClearState: clearState}) + d.record(Action{Kind: ActionLaunch, BundleID: bundleID, ClearState: clearState}) return nil } @@ -220,4 +219,4 @@ func (d *Driver) Metrics(ctx context.Context, bundleID string) (driver.Metrics, return d.MetricsData, nil } -var _ driver.Driver = (*Driver)(nil) +var _ driver.DeviceDriver = (*Driver)(nil) diff --git a/internal/driver/mock/mock_test.go b/internal/driver/mock/mock_test.go index b5a06ca..d16c791 100644 --- a/internal/driver/mock/mock_test.go +++ b/internal/driver/mock/mock_test.go @@ -29,7 +29,7 @@ func TestRecordsAllActionsInOrder(t *testing.T) { mock := New() ctx := context.Background() - if err := mock.Launch(ctx, "com.example", "com.example/.MainActivity", true); err != nil { + if err := mock.Launch(ctx, "com.example", true); err != nil { t.Fatal(err) } if err := mock.Tap(ctx, 100, 200); err != nil { @@ -137,5 +137,5 @@ func TestActionsReturnsCopy(t *testing.T) { } func TestSatisfiesDriverInterface(t *testing.T) { - var _ driver.Driver = New() + var _ driver.DeviceDriver = New() } diff --git a/internal/driver/maestro/client.go b/internal/driver/sidecar/client.go similarity index 94% rename from internal/driver/maestro/client.go rename to internal/driver/sidecar/client.go index 7737006..a002161 100644 --- a/internal/driver/maestro/client.go +++ b/internal/driver/sidecar/client.go @@ -1,4 +1,4 @@ -package maestro +package sidecar import ( "context" @@ -48,11 +48,10 @@ func (c *Client) WaitForHealth(ctx context.Context, pollInterval time.Duration) } } -func (c *Client) Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error { +func (c *Client) Launch(ctx context.Context, bundleID string, clearState bool) error { _, err := c.stub.Launch(ctx, &driverpb.LaunchRequest{ - BundleId: bundleID, - ClearState: clearState, - LauncherActivity: launcherActivity, + BundleId: bundleID, + ClearState: clearState, }) return err } @@ -165,4 +164,4 @@ func (c *Client) Metrics(ctx context.Context, bundleID string) (driver.Metrics, }, nil } -var _ driver.Driver = (*Client)(nil) +var _ driver.DeviceDriver = (*Client)(nil) diff --git a/internal/driver/maestro/client_test.go b/internal/driver/sidecar/client_test.go similarity index 96% rename from internal/driver/maestro/client_test.go rename to internal/driver/sidecar/client_test.go index 44b94bd..4ec69a9 100644 --- a/internal/driver/maestro/client_test.go +++ b/internal/driver/sidecar/client_test.go @@ -1,4 +1,4 @@ -package maestro +package sidecar import ( "context" @@ -22,7 +22,6 @@ type fakeServer struct { healthReadyAfterCall int launchedBundleID string - launcherActivity string clearState bool terminateCalls int taps []int32 @@ -55,7 +54,6 @@ func (s *fakeServer) Launch(_ context.Context, request *driverpb.LaunchRequest) s.mutex.Lock() defer s.mutex.Unlock() s.launchedBundleID = request.GetBundleId() - s.launcherActivity = request.GetLauncherActivity() s.clearState = request.GetClearState() return &driverpb.Empty{}, nil } @@ -193,10 +191,10 @@ func TestClient_LaunchAndTerminate(t *testing.T) { client, _ := Dial(state.address) defer client.Close() - if err := client.Launch(context.Background(), "com.example", "com.example/.MainActivity", true); err != nil { + if err := client.Launch(context.Background(), "com.example", true); err != nil { t.Fatal(err) } - if state.fake.launchedBundleID != "com.example" || !state.fake.clearState || state.fake.launcherActivity != "com.example/.MainActivity" { + if state.fake.launchedBundleID != "com.example" || !state.fake.clearState { t.Errorf("launch payload wrong: %+v", state.fake) } if err := client.Terminate(context.Background()); err != nil { diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 9bef7ed..7ce3613 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -1,5 +1,5 @@ -// Package hierarchy parses the XML produced by `uiautomator dump` and -// resolves selectors against it. +// Package hierarchy parses the TreeNode JSON produced by the Maestro sidecar +// and resolves selectors against it. // // Selector grammar (v0.1): // @@ -10,7 +10,7 @@ package hierarchy import ( - "encoding/xml" + "encoding/json" "fmt" "regexp" "strconv" @@ -36,7 +36,7 @@ func (b Bounds) Width() int { return b.Right - b.Left } // Height returns the bounds' height. func (b Bounds) Height() int { return b.Bottom - b.Top } -// Element is a flattened view of one uiautomator node. +// Element is a flattened view of one hierarchy node. type Element struct { ResourceID string `json:"resourceId,omitempty"` Text string `json:"text,omitempty"` @@ -56,38 +56,82 @@ type Tree struct { Elements []*Element `json:"elements"` } -// Parse parses a uiautomator-style XML dump. -func Parse(xmlText string) (*Tree, error) { - xmlText = strings.TrimSpace(xmlText) - if xmlText == "" { +// treeNodeJSON mirrors the Maestro TreeNode JSON structure. +type treeNodeJSON struct { + Attributes map[string]string `json:"attributes"` + Children []treeNodeJSON `json:"children"` + Clickable *bool `json:"clickable"` + Enabled *bool `json:"enabled"` + Focused *bool `json:"focused"` + Checked *bool `json:"checked"` + Selected *bool `json:"selected"` +} + +// Parse parses a Maestro TreeNode JSON hierarchy. +func Parse(text string) (*Tree, error) { + text = strings.TrimSpace(text) + if text == "" { return &Tree{}, nil } - decoder := xml.NewDecoder(strings.NewReader(xmlText)) - tree := &Tree{} - for { - token, err := decoder.Token() - if err != nil { - if err.Error() == "EOF" { - break - } - break - } - start, ok := token.(xml.StartElement) - if !ok { - continue - } - if start.Name.Local != "node" { - continue - } - element, parseErr := elementFromStart(start) - if parseErr != nil { - return nil, parseErr - } - tree.Elements = append(tree.Elements, element) + var root treeNodeJSON + if err := json.Unmarshal([]byte(text), &root); err != nil { + return nil, fmt.Errorf("hierarchy: %w", err) } + tree := &Tree{} + walkNode(&root, tree) return tree, nil } +func walkNode(node *treeNodeJSON, tree *Tree) { + element := elementFromNode(node) + tree.Elements = append(tree.Elements, element) + for i := range node.Children { + walkNode(&node.Children[i], tree) + } +} + +func elementFromNode(node *treeNodeJSON) *Element { + attrs := node.Attributes + element := &Element{} + + element.ResourceID = attrs["resource-id"] + if element.ResourceID == "" { + element.ResourceID = attrs["identifier"] + } + element.Text = attrs["text"] + element.Description = attrs["content-desc"] + if element.Description == "" { + element.Description = attrs["accessibilityText"] + } + element.Class = attrs["class"] + element.Package = attrs["package"] + + if node.Clickable != nil { + element.Clickable = *node.Clickable + } + if node.Enabled != nil { + element.Enabled = *node.Enabled + } + if node.Focused != nil { + element.Focused = *node.Focused + } + if node.Checked != nil { + element.Checked = *node.Checked + } + if node.Selected != nil { + element.Selected = *node.Selected + } + + if b, ok := attrs["bounds"]; ok && b != "" { + bounds, err := parseBounds(b) + if err == nil { + element.Bounds = bounds + } + } + + return element +} + // Find returns the first element matching the selector, or nil. func (t *Tree) Find(selector string) *Element { kind, value, ok := parseSelector(selector) @@ -131,11 +175,7 @@ func match(element *Element, kind, value string) bool { if element.ResourceID == value { return true } - // ResourceID looks like ":id/". - if strings.HasSuffix(element.ResourceID, ":id/"+value) { - return true - } - return false + return strings.HasSuffix(element.ResourceID, ":id/"+value) case "text": return element.Text == value case "desc": @@ -147,59 +187,21 @@ func match(element *Element, kind, value string) bool { } } -func elementFromStart(start xml.StartElement) (*Element, error) { - element := &Element{} - var boundsText string - for _, attribute := range start.Attr { - switch attribute.Name.Local { - case "resource-id": - element.ResourceID = attribute.Value - case "text": - element.Text = attribute.Value - case "content-desc": - element.Description = attribute.Value - case "class": - element.Class = attribute.Value - case "package": - element.Package = attribute.Value - case "clickable": - element.Clickable = attribute.Value == "true" - case "enabled": - element.Enabled = attribute.Value == "true" - case "checked": - element.Checked = attribute.Value == "true" - case "focused": - element.Focused = attribute.Value == "true" - case "selected": - element.Selected = attribute.Value == "true" - case "bounds": - boundsText = attribute.Value - } - } - if boundsText != "" { - bounds, err := parseBounds(boundsText) - if err != nil { - return nil, fmt.Errorf("bounds %q: %w", boundsText, err) - } - element.Bounds = bounds - } - return element, nil -} - -var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$`) +// boundsPattern matches "[l,t,r,b]" (4-value Maestro format). +var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+),(-?\d+),(-?\d+)\]$`) func parseBounds(text string) (Bounds, error) { - match := boundsPattern.FindStringSubmatch(text) - if match == nil { - return Bounds{}, fmt.Errorf("not in [L,T][R,B] form") + m := boundsPattern.FindStringSubmatch(text) + if m == nil { + return Bounds{}, fmt.Errorf("bounds %q: not in [L,T,R,B] form", text) } - coordinates := make([]int, 4) - for index := range 4 { - value, err := strconv.Atoi(match[index+1]) + coords := make([]int, 4) + for i := range 4 { + v, err := strconv.Atoi(m[i+1]) if err != nil { return Bounds{}, err } - coordinates[index] = value + coords[i] = v } - return Bounds{Left: coordinates[0], Top: coordinates[1], Right: coordinates[2], Bottom: coordinates[3]}, nil + return Bounds{Left: coords[0], Top: coords[1], Right: coords[2], Bottom: coords[3]}, nil } diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index 1ca1321..fa1c595 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -2,14 +2,30 @@ package hierarchy import "testing" -const sampleDump = ` - - - - - - -` +// sampleDump is a Maestro TreeNode JSON equivalent of the old XML fixture. +const sampleDump = `{ + "attributes": {"class": "android.widget.LinearLayout", "package": "app", "bounds": "[0,0,1080,2340]"}, + "children": [ + { + "attributes": {"resource-id": "app:id/title", "text": "Hello", "bounds": "[10,20,200,60]"}, + "children": [], + "clickable": false, + "enabled": true + }, + { + "attributes": {"resource-id": "app:id/row", "text": "Alice", "content-desc": "row", "bounds": "[0,100,1080,200]"}, + "children": [], + "clickable": true, + "enabled": true + }, + { + "attributes": {"resource-id": "app:id/row", "text": "Bob", "content-desc": "row", "bounds": "[0,200,1080,300]"}, + "children": [], + "clickable": true, + "enabled": true + } + ] +}` func TestParseCountsNodes(t *testing.T) { tree, err := Parse(sampleDump) @@ -75,14 +91,76 @@ func TestUnknownSelector(t *testing.T) { } func TestDescPrefix(t *testing.T) { - xml := ` - - - - ` - tree, _ := Parse(xml) + input := `{ + "attributes": {}, + "children": [ + {"attributes": {"content-desc": "customer_row_abc-123", "bounds": "[0,0,100,100]"}, "children": []}, + {"attributes": {"content-desc": "customer_row_def-456", "bounds": "[0,100,100,200]"}, "children": []}, + {"attributes": {"content-desc": "supplier_row_xyz", "bounds": "[0,200,100,300]"}, "children": []} + ] + }` + tree, _ := Parse(input) rows := tree.FindAll("descPrefix:customer_row_") if len(rows) != 2 { t.Fatalf("want 2 customer rows, got %d", len(rows)) } } + +func TestBoolFieldsFromNode(t *testing.T) { + input := `{ + "attributes": {"resource-id": "x", "bounds": "[0,0,100,100]"}, + "children": [], + "clickable": true, + "enabled": false, + "focused": true, + "checked": true, + "selected": false + }` + tree, err := Parse(input) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(tree.Elements) != 1 { + t.Fatalf("want 1 element, got %d", len(tree.Elements)) + } + el := tree.Elements[0] + if !el.Clickable { + t.Error("expected clickable=true") + } + if el.Enabled { + t.Error("expected enabled=false") + } + if !el.Focused { + t.Error("expected focused=true") + } + if !el.Checked { + t.Error("expected checked=true") + } + if el.Selected { + t.Error("expected selected=false") + } +} + +func TestIdentifierFallback(t *testing.T) { + input := `{ + "attributes": {"identifier": "my-button", "bounds": "[0,0,100,100]"}, + "children": [] + }` + tree, _ := Parse(input) + el := tree.Find("id:my-button") + if el == nil { + t.Fatal("expected match via identifier fallback") + } +} + +func TestAccessibilityTextFallback(t *testing.T) { + input := `{ + "attributes": {"accessibilityText": "Close dialog", "bounds": "[0,0,50,50]"}, + "children": [] + }` + tree, _ := Parse(input) + el := tree.Find("desc:Close dialog") + if el == nil { + t.Fatal("expected match via accessibilityText fallback") + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index d40e4b0..2968020 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -25,7 +25,7 @@ type Options struct { BundleID string Connection *agent.Conn - Driver driver.Driver + Driver driver.DeviceDriver Verifier *verifier.Verifier TraceWriter *trace.Writer Logger *slog.Logger @@ -272,7 +272,7 @@ func screenFromSnapshot(snapshots map[string]json.RawMessage) (string, error) { return screen, nil } -func applyAction(ctx context.Context, drv driver.Driver, action verifier.Action, tree *hierarchy.Tree) error { +func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) error { switch action.Kind { case verifier.ActionKindTap: x, y, ok := resolveCoordinates(action, tree) @@ -326,7 +326,7 @@ func applyAction(ctx context.Context, drv driver.Driver, action verifier.Action, // collectLogs pulls recent error-level log entries from the driver since the // previous fetch. A failure is warned-on but not fatal: log capture is a // best-effort observability channel, not a correctness dependency. -func collectLogs(ctx context.Context, drv driver.Driver, since time.Time) []verifier.LogEntry { +func collectLogs(ctx context.Context, drv driver.DeviceDriver, since time.Time) []verifier.LogEntry { entries, err := drv.RecentLogs(ctx, since, "E") if err != nil { return nil @@ -374,7 +374,7 @@ func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, return 0, 0, false } -func fetchHierarchy(ctx context.Context, drv driver.Driver) (*hierarchy.Tree, error) { +func fetchHierarchy(ctx context.Context, drv driver.DeviceDriver) (*hierarchy.Tree, error) { xmlText, err := drv.Hierarchy(ctx) if err != nil { return nil, err diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index c5dbc22..034b074 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -321,10 +321,7 @@ func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) { state.startSDK(t) state.acceptConnection(t) - state.mock.HierarchyJSON = ` - - -` + state.mock.HierarchyJSON = `{"attributes":{"resource-id":"com.fixture:id/next","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true}` ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/internal/verifier/ax_integration_test.go b/internal/verifier/ax_integration_test.go index 9ff5aeb..1a37e68 100644 --- a/internal/verifier/ax_integration_test.go +++ b/internal/verifier/ax_integration_test.go @@ -9,12 +9,13 @@ import ( // TestStateAxFindWorks verifies that a Parse+PushSnapshot+extract round trip // actually lets the spec resolve selectors through state.ax.find. +// Reads /tmp/live-dump.json (Maestro TreeNode JSON format); skipped if absent. func TestStateAxFindWorks(t *testing.T) { - xmlText, err := os.ReadFile("/tmp/live-dump.xml") + jsonText, err := os.ReadFile("/tmp/live-dump.json") if err != nil { - t.Skip("live-dump.xml not present") + t.Skip("live-dump.json not present") } - tree, err := hierarchy.Parse(string(xmlText)) + tree, err := hierarchy.Parse(string(jsonText)) if err != nil { t.Fatal(err) } diff --git a/internal/verifier/hierarchies_test.go b/internal/verifier/hierarchies_test.go index 49c016e..70e0711 100644 --- a/internal/verifier/hierarchies_test.go +++ b/internal/verifier/hierarchies_test.go @@ -1,19 +1,19 @@ package verifier -const listHierarchyXML = ` - - - - - - -` +const listHierarchyJSON = `{ + "attributes": {"class": "android.widget.FrameLayout", "package": "test.app", "bounds": "[0,0,1080,2400]"}, + "children": [ + {"attributes": {"class": "android.widget.TextView", "text": "Items", "bounds": "[100,200,900,300]"}, "children": []}, + {"attributes": {"content-desc": "primary_action", "bounds": "[64,2200,1016,2320]"}, "children": [], "clickable": true, "enabled": true}, + {"attributes": {"content-desc": "secondary_action", "bounds": "[980,80,1060,160]"}, "children": [], "clickable": true, "enabled": true} + ] +}` -const formHierarchyXML = ` - - - - - - -` +const formHierarchyJSON = `{ + "attributes": {"class": "android.widget.FrameLayout", "package": "test.app", "bounds": "[0,0,1080,2400]"}, + "children": [ + {"attributes": {"content-desc": "text_field", "bounds": "[64,320,1016,440]"}, "children": [], "clickable": true, "enabled": true}, + {"attributes": {"content-desc": "primary_action", "bounds": "[64,2200,1016,2320]"}, "children": [], "clickable": true, "enabled": true}, + {"attributes": {"content-desc": "secondary_action", "bounds": "[32,80,112,160]"}, "children": [], "clickable": true, "enabled": true} + ] +}` diff --git a/internal/verifier/spec_integration_test.go b/internal/verifier/spec_integration_test.go index 6c448c2..575db3a 100644 --- a/internal/verifier/spec_integration_test.go +++ b/internal/verifier/spec_integration_test.go @@ -65,7 +65,7 @@ func TestIntegrationSpecFiresInputActions(t *testing.T) { t.Fatal(err) } - tree, err := hierarchy.Parse(formHierarchyXML) + tree, err := hierarchy.Parse(formHierarchyJSON) if err != nil { t.Fatal(err) } @@ -105,7 +105,7 @@ func TestIntegrationSpecPropertiesEvaluate(t *testing.T) { t.Fatal(err) } - tree, err := hierarchy.Parse(listHierarchyXML) + tree, err := hierarchy.Parse(listHierarchyJSON) if err != nil { t.Fatal(err) } @@ -134,21 +134,21 @@ func TestIntegrationSpecPropertiesEvaluate(t *testing.T) { func TestIntegrationSpecActionsFireOnEachRoute(t *testing.T) { cases := []struct { name string - xml string + hierarchy string snapshots Snapshots expectKind ActionKind expectOns []string }{ { name: "list", - xml: listHierarchyXML, + hierarchy: listHierarchyJSON, snapshots: listSnapshots(), expectKind: ActionKindTap, expectOns: []string{"desc:primary_action", "desc:secondary_action"}, }, { name: "form", - xml: formHierarchyXML, + hierarchy: formHierarchyJSON, snapshots: formSnapshots(), expectKind: ActionKindTap, expectOns: []string{"desc:primary_action", "desc:secondary_action"}, @@ -161,7 +161,7 @@ func TestIntegrationSpecActionsFireOnEachRoute(t *testing.T) { if err := v.Load(bundleIntegrationSpec(t)); err != nil { t.Fatal(err) } - tree, err := hierarchy.Parse(tc.xml) + tree, err := hierarchy.Parse(tc.hierarchy) if err != nil { t.Fatal(err) } diff --git a/proto/driverpb/driver.pb.go b/proto/driverpb/driver.pb.go index 8b7e0c5..a01e920 100644 --- a/proto/driverpb/driver.pb.go +++ b/proto/driverpb/driver.pb.go @@ -58,15 +58,11 @@ func (*Empty) Descriptor() ([]byte, []int) { } type LaunchRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - BundleId string `protobuf:"bytes,1,opt,name=bundle_id,json=bundleId,proto3" json:"bundle_id,omitempty"` - ClearState bool `protobuf:"varint,2,opt,name=clear_state,json=clearState,proto3" json:"clear_state,omitempty"` - // Optional "/" component to pass to `am start -n`. Needed - // when an app declares multiple MAIN+LAUNCHER activities where the first - // one reported by `pm dump` is an alias whose target class is disabled. - LauncherActivity string `protobuf:"bytes,3,opt,name=launcher_activity,json=launcherActivity,proto3" json:"launcher_activity,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + BundleId string `protobuf:"bytes,1,opt,name=bundle_id,json=bundleId,proto3" json:"bundle_id,omitempty"` + ClearState bool `protobuf:"varint,2,opt,name=clear_state,json=clearState,proto3" json:"clear_state,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LaunchRequest) Reset() { @@ -113,13 +109,6 @@ func (x *LaunchRequest) GetClearState() bool { return false } -func (x *LaunchRequest) GetLauncherActivity() string { - if x != nil { - return x.LauncherActivity - } - return "" -} - type Point struct { state protoimpl.MessageState `protogen:"open.v1"` X int32 `protobuf:"varint,1,opt,name=x,proto3" json:"x,omitempty"` @@ -850,12 +839,11 @@ var File_driverpb_driver_proto protoreflect.FileDescriptor const file_driverpb_driver_proto_rawDesc = "" + "\n" + "\x15driverpb/driver.proto\x12\x14sanderling.driver.v1\"\a\n" + - "\x05Empty\"z\n" + + "\x05Empty\"M\n" + "\rLaunchRequest\x12\x1b\n" + "\tbundle_id\x18\x01 \x01(\tR\bbundleId\x12\x1f\n" + "\vclear_state\x18\x02 \x01(\bR\n" + - "clearState\x12+\n" + - "\x11launcher_activity\x18\x03 \x01(\tR\x10launcherActivity\"#\n" + + "clearState\"#\n" + "\x05Point\x12\f\n" + "\x01x\x18\x01 \x01(\x05R\x01x\x12\f\n" + "\x01y\x18\x02 \x01(\x05R\x01y\"\x1c\n" + diff --git a/proto/driverpb/driver.proto b/proto/driverpb/driver.proto index 9451e7c..ccbb255 100644 --- a/proto/driverpb/driver.proto +++ b/proto/driverpb/driver.proto @@ -30,10 +30,6 @@ message Empty {} message LaunchRequest { string bundle_id = 1; bool clear_state = 2; - // Optional "/" 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 { diff --git a/sidecar/build.gradle.kts b/sidecar/build.gradle.kts index 41b24d7..698eeaa 100644 --- a/sidecar/build.gradle.kts +++ b/sidecar/build.gradle.kts @@ -25,6 +25,7 @@ val maestroVersion = "1.40.0" dependencies { implementation("dev.mobile:maestro-client:$maestroVersion") + implementation("dev.mobile:maestro-utils:$maestroVersion") implementation("io.grpc:grpc-netty-shaded:$grpcVersion") implementation("io.grpc:grpc-protobuf:$grpcVersion") diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index 6479729..62a0a21 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -1,7 +1,7 @@ package dev.sanderling.sidecar interface DriverBackend { - fun launch(bundleId: String, launcherActivity: String, clearState: Boolean) + fun launch(bundleId: String, clearState: Boolean) fun terminate(bundleId: String) fun tap(x: Int, y: Int) fun tapSelector(selector: String) @@ -29,12 +29,96 @@ data class LogLine( val message: String, ) -/** - * StubDriverBackend records calls but takes no real device action. Real - * Maestro integration arrives in a follow-up; v0.1 wires the gRPC plumbing - * end-to-end so the Go side can be exercised against a running sidecar - * even before Maestro is plugged in. - */ +internal fun readLogcat(serial: String?, sinceUnixMillis: Long, minLevel: String): List { + val level = if (minLevel.isEmpty()) "E" else minLevel + val since = if (sinceUnixMillis > 0) StubDriverBackend.formatAdbLogcatTimestamp(sinceUnixMillis) else null + val arguments = mutableListOf("logcat", "-d", "*:$level") + if (since != null) { + arguments.add("-T") + arguments.add(since) + } + return try { + val process = ProcessBuilder(adbCmd(serial) + arguments).redirectErrorStream(false).start() + val output = process.inputStream.bufferedReader().readText() + process.waitFor() + StubDriverBackend.parseLogcatOutput(output) + } catch (cause: Exception) { + println("adb logcat failed: $cause") + emptyList() + } +} + +internal fun readProcMetrics(serial: String?, bundleId: String): MetricsSample { + if (bundleId.isEmpty()) return MetricsSample(0.0, 0L, 0L) + return try { + val pid = adbOutput(serial, listOf("shell", "pidof", bundleId)) + .trim().split(Regex("\\s+")).firstOrNull()?.toIntOrNull() + ?: return MetricsSample(0.0, 0L, 0L) + val cpu = sampleCpuTwice(serial, pid) + val (rssBytes, vmSizeBytes) = sampleProcessMemory(serial, pid) + MetricsSample(cpu, rssBytes, vmSizeBytes) + } catch (cause: Exception) { + println("metrics capture failed: $cause") + MetricsSample(0.0, 0L, 0L) + } +} + +private fun adbCmd(serial: String?): List = + if (serial == null) listOf("adb") else listOf("adb", "-s", serial) + +private fun adbOutput(serial: String?, arguments: List): 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 { + val status = adbOutput(serial, listOf("shell", "cat", "/proc/$pid/status")) + var rssKb = 0L + var vmSizeKb = 0L + for (raw in status.lineSequence()) { + val line = raw.trim() + when { + line.startsWith("VmRSS:") -> rssKb = parseKb(line) ?: rssKb + line.startsWith("VmSize:") -> vmSizeKb = parseKb(line) ?: vmSizeKb + } + } + return Pair(rssKb * 1024L, vmSizeKb * 1024L) +} + +private fun parseKb(line: String): Long? { + val parts = line.split(Regex("\\s+")) + if (parts.size < 2) return null + return parts[1].toLongOrNull() +} + class StubDriverBackend(private val platform: String) : DriverBackend { @Volatile var launchCount: Int = 0 private set @@ -47,38 +131,13 @@ class StubDriverBackend(private val platform: String) : DriverBackend { @Volatile var lastInputText: String? = null private set - override fun launch(bundleId: String, launcherActivity: String, clearState: Boolean) { + override fun launch(bundleId: String, clearState: Boolean) { launchCount++ lastBundleId = bundleId if (clearState) { runAdb(listOf("shell", "pm", "clear", bundleId)) } - val component = when { - launcherActivity.isEmpty() -> "$bundleId/${resolveLauncherActivity(bundleId)}" - launcherActivity.contains('/') -> launcherActivity - else -> "$bundleId/$launcherActivity" - } - runAdb(listOf("shell", "am", "start", "-W", "-n", component)) - } - - private fun resolveLauncherActivity(bundleId: String): String { - val output = captureAdb( - listOf( - "shell", "cmd", "package", "resolve-activity", "--brief", - "-a", "android.intent.action.MAIN", - "-c", "android.intent.category.LAUNCHER", - bundleId, - ), - ) - return parseResolvedActivity(bundleId, output) - ?: throw IllegalStateException("could not resolve launcher activity for $bundleId: $output") - } - - private fun captureAdb(arguments: List): String { - val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() - val output = process.inputStream.bufferedReader().readText() - process.waitFor() - return output + runAdb(listOf("shell", "am", "start", "-W", "-n", "$bundleId/.MainActivity")) } companion object { @@ -87,9 +146,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend { internal fun isAnimationCountIdle(grepOutput: String): Boolean = (grepOutput.trim().toIntOrNull() ?: 0) == 0 - // parseResolvedActivity extracts the activity name from the output of - // `cmd package resolve-activity --brief`. The brief output is two - // lines: metadata, then `/`. internal fun parseResolvedActivity(bundleId: String, output: String): String? { val prefix = "$bundleId/" for (line in output.lines()) { @@ -101,8 +157,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend { return null } - // Hard cap on KEYCODE_DEL events per clear. Guards against a pathological - // hierarchy that reports an enormous text length for the focused field. internal const val MAX_CLEAR_DELETES: Int = 1024 internal fun buildClearKeyevents(textLength: Int): List { @@ -113,11 +167,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend { return args } - // `adb shell input text` runs through a remote sh, so shell metacharacters - // in the payload would be interpreted by the device shell. Substitute - // spaces with %s (input's escape) and backslash-escape characters sh - // would otherwise expand. Keep this list conservative; anything not - // listed passes through literally. internal fun escapeForAdbInputText(text: String): String { val sb = StringBuilder(text.length) for (ch in text) { @@ -131,9 +180,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend { return sb.toString() } - // Matches a uiautomator-dump 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( "]*\\bfocused=\"true\"[^>]*)/?>", ) @@ -169,10 +215,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend { return "$seconds.${millis.toString().padStart(3, '0')}" } - // Logcat default threadtime format: - // MM-dd HH:mm:ss.SSS PID TID L TAG: message - // The leading date is the local year-inferred date; we convert to a - // unix-millis best-effort using the current year. private val LOGCAT_LINE = Regex( "^(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})\\.(\\d{3})" + "\\s+\\d+\\s+\\d+\\s+([VDIWEFS])\\s+([^:]+?):\\s?(.*)$", @@ -215,16 +257,10 @@ class StubDriverBackend(private val platform: String) : DriverBackend { override fun tapSelector(selector: String) { lastTapSelector = selector - // v0.1: selector resolution lives in Maestro proper; the stub - // records the selector so logs/traces show what was requested. } override fun inputText(text: String) { lastInputText = text - // `adb shell input text` types keystrokes at the caret, so repeated - // calls append. Clear the focused field first so the caller sees a - // pure replace: read the current value's length from the hierarchy, - // then move-end + N backspaces before typing. clearFocusedField() runAdb(listOf("shell", "input", "text", escapeForAdbInputText(text))) } @@ -271,31 +307,14 @@ class StubDriverBackend(private val platform: String) : DriverBackend { runAdb(listOf("shell", "input", "keyevent", keyCode)) } - override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List { - val level = if (minLevel.isEmpty()) "E" else minLevel - val since = if (sinceUnixMillis > 0) formatAdbLogcatTimestamp(sinceUnixMillis) else null - val arguments = mutableListOf("logcat", "-d", "*:$level") - if (since != null) { - arguments.add("-T") - arguments.add(since) - } - return try { - val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(false).start() - val output = process.inputStream.bufferedReader().readText() - process.waitFor() - parseLogcatOutput(output) - } catch (cause: Exception) { - println("adb logcat failed: $cause") - emptyList() - } - } + override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List = + readLogcat(null, sinceUnixMillis, minLevel) data class SwipeRecord(val fromX: Int, val fromY: Int, val toX: Int, val toY: Int, val durationMillis: Long) private fun runAdb(arguments: List) { try { val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() - // Drain output before waiting so a large write doesn't block the child. command.inputStream.bufferedReader().readText() command.waitFor() } catch (cause: Exception) { @@ -345,129 +364,138 @@ class StubDriverBackend(private val platform: String) : DriverBackend { private fun isDeviceIdle(): Boolean { return try { - val output = captureAdb( - listOf("shell", "dumpsys window -a | grep -c mAnimating=true"), - ) + val output = adbOutput(null, listOf("shell", "dumpsys window -a | grep -c mAnimating=true")) isAnimationCountIdle(output) } catch (cause: Exception) { false } } - override fun healthy(): Boolean = true - // Stateful CPU delta tracker. Reading /proc//stat gives cumulative - // utime+stime in jiffies; CPU % over a step is (delta_ticks / delta_wall) - // * 100. First call blocks briefly for a real reading instead of 0. - private data class CpuSample(val pid: Int, val ticks: Long, val wallNanos: Long) - @Volatile private var lastCpuSample: CpuSample? = null - private val clockTicksPerSecond: Long by lazy { resolveClockTicksPerSecond() } - private val cpuFirstSampleSleepMillis: Long = 50L + override fun metrics(bundleId: String): MetricsSample = readProcMetrics(null, bundleId) +} - override fun metrics(bundleId: String): MetricsSample { - if (bundleId.isEmpty()) return MetricsSample(0.0, 0L, 0L) - return try { - val pid = runAdbOutput(listOf("shell", "pidof", bundleId)).trim().split(Regex("\\s+")).firstOrNull()?.toIntOrNull() - ?: run { - lastCpuSample = null - return MetricsSample(0.0, 0L, 0L) - } - val cpu = sampleCpuPercent(pid) - val (rssBytes, vmSizeBytes) = sampleProcessMemory(pid) - MetricsSample(cpu, rssBytes, vmSizeBytes) - } catch (cause: Exception) { - println("metrics capture failed: $cause") - MetricsSample(0.0, 0L, 0L) +class MaestroDriverBackend(private val serial: String?) : DriverBackend { + private val dadb: dadb.Dadb + private val driver: maestro.drivers.AndroidDriver + + init { + dadb = buildDadb(serial) + driver = maestro.drivers.AndroidDriver(dadb, 7001, "localhost") + driver.open() + } + + override fun launch(bundleId: String, clearState: Boolean) { + if (clearState) driver.clearAppState(bundleId) + driver.launchApp(bundleId, emptyMap(), java.util.UUID.randomUUID()) + } + + override fun terminate(bundleId: String) = driver.stopApp(bundleId) + + override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y)) + + override fun tapSelector(selector: String) { + val root = driver.contentDescriptor(false) + val bounds = findBoundsBySelector(root, selector) ?: return + driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2)) + } + + override fun inputText(text: String) = driver.inputText(text) + + override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) = + driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L)) + + override fun pressKey(key: String) { + StubDriverBackend.KEY_MAP[key]?.let { keyCode -> + keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) } } } - private fun sampleCpuPercent(pid: Int): Double { - val previous = lastCpuSample - if (previous != null && previous.pid == pid) { - val ticks = readCpuTicks(pid) ?: return 0.0 - val now = System.nanoTime() - lastCpuSample = CpuSample(pid, ticks, now) - return cpuPercentFromDelta(ticks - previous.ticks, now - previous.wallNanos) - } - // No baseline for this PID: one adb round-trip with a device-side sleep - // so the first step gets a real reading instead of 0. - val pair = readCpuTicksPair(pid, cpuFirstSampleSleepMillis) ?: return 0.0 - lastCpuSample = CpuSample(pid, pair.second, System.nanoTime()) - return cpuPercentFromDelta(pair.second - pair.first, cpuFirstSampleSleepMillis * 1_000_000L) + override fun screenshot(): Triple { + val buf = okio.Buffer() + driver.takeScreenshot(buf, false) + val bytes = buf.readByteArray() + return Triple(bytes, pngWidth(bytes), pngHeight(bytes)) } - private fun readCpuTicks(pid: Int): Long? { - val stat = runAdbOutput(listOf("shell", "cat", "/proc/$pid/stat")).trim() - if (stat.isEmpty()) return null - return parseCpuTicks(stat) + override fun hierarchy(): String = + com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().writeValueAsString(driver.contentDescriptor(false)) + + override fun recentLogs(sinceUnixMillis: Long, minLevel: String) = + readLogcat(serial, sinceUnixMillis, minLevel) + + override fun waitForIdle(durationMillis: Long) { + driver.waitForAppToSettle(null, null, durationMillis.toInt()) } - private fun readCpuTicksPair(pid: Int, sleepMillis: Long): Pair? { - // "sleep 0.050" — toybox sleep accepts fractional seconds on modern Android. - val sleepArg = "0.${"%03d".format(sleepMillis)}" - val command = "cat /proc/$pid/stat; sleep $sleepArg; cat /proc/$pid/stat" - val output = runAdbOutput(listOf("shell", command)) - val lines = output.lines().filter { it.isNotBlank() } - if (lines.size < 2) return null - val first = parseCpuTicks(lines[0]) ?: return null - val second = parseCpuTicks(lines[1]) ?: return null - return Pair(first, second) - } + override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false } - private fun parseCpuTicks(statLine: String): Long? { - // /proc//stat format: pid (comm) state ppid ... utime stime ... - // comm is parenthesized and may contain spaces; rsplit on ')' to skip it. - val afterComm = statLine.substringAfterLast(')').trim() - val fields = afterComm.split(Regex("\\s+")) - // After the ')' we are at the "state" field (index 0 in afterComm). - // utime is proc(14) = afterComm[11], stime is proc(15) = afterComm[12]. - if (fields.size < 13) return null - val utime = fields[11].toLongOrNull() ?: return null - val stime = fields[12].toLongOrNull() ?: return null - return utime + stime - } + override fun metrics(bundleId: String) = readProcMetrics(serial, bundleId) +} - private fun cpuPercentFromDelta(deltaTicks: Long, deltaWallNanos: Long): Double { - if (deltaTicks < 0 || deltaWallNanos <= 0) return 0.0 - val tickHz = clockTicksPerSecond.coerceAtLeast(1L) - val deltaCpuNanos = deltaTicks * 1_000_000_000.0 / tickHz - return (deltaCpuNanos / deltaWallNanos) * 100.0 +private fun buildDadb(serial: String?): dadb.Dadb { + return if (serial == null) { + dadb.Dadb.create("localhost", 5555) + } else { + dadb.Dadb.create(serial.substringBefore(":"), serial.substringAfter(":").toIntOrNull() ?: 5555) } +} - private fun resolveClockTicksPerSecond(): Long { - val output = runAdbOutput(listOf("shell", "getconf", "CLK_TCK")).trim() - return output.toLongOrNull() ?: 100L +private fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntArray? { + val colon = selector.indexOf(':') + if (colon < 0) return null + val kind = selector.substring(0, colon) + val value = selector.substring(colon + 1) + return findBoundsInTree(root, kind, value) +} + +private fun 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 { - val status = runAdbOutput(listOf("shell", "cat", "/proc/$pid/status")) - var rssKb = 0L - var vmSizeKb = 0L - for (raw in status.lineSequence()) { - val line = raw.trim() - when { - line.startsWith("VmRSS:") -> rssKb = parseKb(line) ?: rssKb - line.startsWith("VmSize:") -> vmSizeKb = parseKb(line) ?: vmSizeKb - } - } - return Pair(rssKb * 1024L, vmSizeKb * 1024L) - } +private fun parseBounds(s: String): IntArray? { + val pattern = Regex("^\\[(-?\\d+),(-?\\d+),(-?\\d+),(-?\\d+)\\]$") + val m = pattern.matchEntire(s) ?: return null + return IntArray(4) { m.groupValues[it + 1].toInt() } +} + +private fun pngWidth(bytes: ByteArray): Int { + if (bytes.size < 24) return 0 + return (bytes[16].toInt() and 0xFF shl 24) or (bytes[17].toInt() and 0xFF shl 16) or + (bytes[18].toInt() and 0xFF shl 8) or (bytes[19].toInt() and 0xFF) +} - private fun parseKb(line: String): Long? { - val parts = line.split(Regex("\\s+")) - if (parts.size < 2) return null - return parts[1].toLongOrNull() - } +private fun pngHeight(bytes: ByteArray): Int { + if (bytes.size < 24) return 0 + return (bytes[20].toInt() and 0xFF shl 24) or (bytes[21].toInt() and 0xFF shl 16) or + (bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF) +} - private fun runAdbOutput(arguments: List): String { - return try { - val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(false).start() - val output = process.inputStream.bufferedReader().readText() - process.waitFor() - output - } catch (cause: Exception) { - "" - } +private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? { + return when (adbKeyCode) { + "KEYCODE_BACK" -> maestro.KeyCode.BACK + "KEYCODE_HOME" -> maestro.KeyCode.HOME + "KEYCODE_ENTER" -> maestro.KeyCode.ENTER + "KEYCODE_TAB" -> maestro.KeyCode.TAB + "KEYCODE_DPAD_UP" -> maestro.KeyCode.REMOTE_UP + "KEYCODE_DPAD_DOWN" -> maestro.KeyCode.REMOTE_DOWN + "KEYCODE_DPAD_LEFT" -> maestro.KeyCode.REMOTE_LEFT + "KEYCODE_DPAD_RIGHT" -> maestro.KeyCode.REMOTE_RIGHT + else -> null } } diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt index 40f2294..8a2f19b 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt @@ -23,15 +23,14 @@ import java.util.concurrent.atomic.AtomicReference class DriverService( private val platform: String = "android", - private val serial: String? = null, - private val backend: DriverBackend = StubDriverBackend(platform), + private val backend: DriverBackend, ) : DriverGrpc.DriverImplBase() { private val launchedBundleId = AtomicReference(null) override fun launch(request: LaunchRequest, responseObserver: StreamObserver) { runRpc(responseObserver) { - backend.launch(request.bundleId, request.launcherActivity, request.clearState) + backend.launch(request.bundleId, request.clearState) launchedBundleId.set(request.bundleId) Empty.getDefaultInstance() } diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt index db3b40a..929ac6d 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt @@ -45,7 +45,12 @@ fun main(arguments: Array) { if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else null } - val service = DriverService(platform = platform, serial = serial) + val backend: DriverBackend = if (platform == "android") { + MaestroDriverBackend(serial) + } else { + StubDriverBackend(platform) + } + val service = DriverService(platform = platform, backend = backend) val server = SidecarServer(port, service) val boundPort = server.start() println("sanderling-sidecar listening on 127.0.0.1:$boundPort platform=$platform") diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt index 7d43f63..03faf9e 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt @@ -26,7 +26,7 @@ class DriverServiceTest { private fun newClient(backend: DriverBackend): DriverGrpc.DriverBlockingStub { val serverName = InProcessServerBuilder.generateName() - val service = DriverService(platform = "android", serial = null, backend = backend) + val service = DriverService(platform = "android", backend = backend) grpcCleanup.register( InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start() ) @@ -43,7 +43,6 @@ class DriverServiceTest { client.launch( LaunchRequest.newBuilder() .setBundleId("com.example") - .setLauncherActivity(".MainActivity") .setClearState(true) .build(), ) diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/SidecarServerTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/SidecarServerTest.kt index 2a75eab..f449c42 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/SidecarServerTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/SidecarServerTest.kt @@ -6,7 +6,7 @@ import kotlin.test.assertTrue class SidecarServerTest { @Test fun startBindsEphemeralPortAndStopReleasesIt() { - val server = SidecarServer(port = 0, service = DriverService()) + val server = SidecarServer(port = 0, service = DriverService(backend = StubDriverBackend("android"))) val boundPort = server.start() try { assertTrue(boundPort > 0, "expected ephemeral port, got $boundPort")