mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
feat: Maestro driver integration + DeviceDriver architecture (#32)
* proto(driver): drop launcher_activity from LaunchRequest * refactor(driver): rename Driver to DeviceDriver, drop launcherActivity from Launch * refactor(driver): rename package maestro to sidecar * feat(driver): add ChromeDriver backed by chromedp * feat(hierarchy): replace XML parser with TreeNode JSON parser * refactor(driver): update mock and runner to DeviceDriver, drop launcherActivity * feat(runner): add platform routing for web vs sidecar * test(verifier): update hierarchy fixtures from XML to TreeNode JSON * feat(sidecar): extract readLogcat/readProcMetrics; add MaestroDriverBackend - Extract readLogcat() and readProcMetrics() as internal package-level helpers parameterised by serial - Add MaestroDriverBackend wiring maestro-client AndroidDriver - Drop launcherActivity from DriverBackend interface and StubDriverBackend - Add maestro-utils and micrometer-core as explicit compile deps * refactor(sidecar): inject DriverBackend into DriverService; drop launcherActivity - Remove serial and default backend from DriverService constructor - Make backend a required parameter - Drop launcherActivity from launch RPC handler - Create MaestroDriverBackend in Main.kt when platform is android * test(sidecar): update tests for dropped launcherActivity and required backend * chore(sidecar): remove unnecessary micrometer-core direct dependency
This commit is contained in:
26 files changed
+835
-405
No files matched your search
@@ -17,7 +17,6 @@ var Version = "dev"
|
||||
type testOptions struct {
|
||||
spec string
|
||||
bundleID string
|
||||
launcherActivity string
|
||||
platform string
|
||||
avd string
|
||||
duration time.Duration
|
||||
@@ -45,8 +44,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
|
||||
var options testOptions
|
||||
flagSet.StringVar(&options.spec, "spec", "", "path to the TypeScript spec (required)")
|
||||
flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required)")
|
||||
flagSet.StringVar(&options.launcherActivity, "launcher-activity", "", "optional <pkg>/<activity> to launch (overrides default resolution)")
|
||||
flagSet.StringVar(&options.platform, "platform", "android", "target platform: android (ios deferred)")
|
||||
flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, web")
|
||||
flagSet.StringVar(&options.avd, "avd", "", "Android AVD name to boot if no device is connected")
|
||||
flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration")
|
||||
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
|
||||
@@ -60,8 +58,10 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
|
||||
if options.bundleID == "" {
|
||||
return testOptions{}, errors.New("--bundle-id is required")
|
||||
}
|
||||
if options.platform != "android" {
|
||||
return testOptions{}, fmt.Errorf("unsupported platform: %q (only android in v0.1)", options.platform)
|
||||
switch options.platform {
|
||||
case "android", "ios", "web":
|
||||
default:
|
||||
return testOptions{}, fmt.Errorf("unsupported platform: %q (android, web)", options.platform)
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
@@ -76,11 +76,11 @@ func TestParseTestArgs_AVDIsOptional(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTestArgs_RejectsNonAndroidPlatform(t *testing.T) {
|
||||
func TestParseTestArgs_RejectsUnknownPlatform(t *testing.T) {
|
||||
_, err := parseTestArgs([]string{
|
||||
"--spec", "s.ts",
|
||||
"--bundle-id", "com.example",
|
||||
"--platform", "ios",
|
||||
"--platform", "fuchsia",
|
||||
"--avd", "x",
|
||||
}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported platform") {
|
||||
@@ -88,6 +88,20 @@ func TestParseTestArgs_RejectsNonAndroidPlatform(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTestArgs_AcceptsWebPlatform(t *testing.T) {
|
||||
options, err := parseTestArgs([]string{
|
||||
"--spec", "s.ts",
|
||||
"--bundle-id", "http://localhost:3000",
|
||||
"--platform", "web",
|
||||
}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for web platform: %v", err)
|
||||
}
|
||||
if options.platform != "web" {
|
||||
t.Errorf("expected platform=web, got %q", options.platform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_HelpPrintsUsage(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
if err := run([]string{"sanderling"}, &stdout, io.Discard); err != nil {
|
||||
|
||||
+67
-44
@@ -14,7 +14,9 @@ import (
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/agent"
|
||||
"github.com/priyanshujain/sanderling/internal/bundler"
|
||||
"github.com/priyanshujain/sanderling/internal/driver/maestro"
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
"github.com/priyanshujain/sanderling/internal/driver/chrome"
|
||||
driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar"
|
||||
"github.com/priyanshujain/sanderling/internal/runner"
|
||||
"github.com/priyanshujain/sanderling/internal/sidecar"
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
@@ -28,7 +30,7 @@ const (
|
||||
)
|
||||
|
||||
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error {
|
||||
if options.platform == "android" {
|
||||
if options.platform == "android" || options.platform == "ios" {
|
||||
if err := ensureDevice(ctx, options.avd, stdout); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -36,8 +38,6 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
||||
aliases := map[string]string{}
|
||||
if specApiPath := resolveSpecAPIPath(options.spec); specApiPath != "" {
|
||||
aliases["@sanderling/spec"] = specApiPath
|
||||
// Also alias published subpath exports so specs importing from
|
||||
// "@sanderling/spec/defaults/properties" resolve to the in-tree source.
|
||||
base := filepath.Dir(specApiPath)
|
||||
aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts")
|
||||
}
|
||||
@@ -54,46 +54,11 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
||||
}
|
||||
fmt.Fprintf(stdout, "bundled spec: %d bytes (sha256=%s)\n", len(bundle.JavaScript), bundle.SHA256[:12])
|
||||
|
||||
sidecarDirectory := filepath.Join(os.TempDir(), "sanderling-sidecar")
|
||||
jarPath, err := sidecar.Extract(sidecarDirectory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extract sidecar: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "sidecar JAR: %s (size=%d)\n", jarPath, sidecar.EmbeddedSize())
|
||||
|
||||
sidecarPort, err := pickFreePort()
|
||||
activeDriver, cleanup, err := buildDriver(ctx, options, stdout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sidecarCommand := exec.CommandContext(ctx, "java", "-jar", jarPath,
|
||||
"--port", strconv.Itoa(sidecarPort),
|
||||
"--platform", options.platform,
|
||||
)
|
||||
sidecarCommand.Stdout = stdout
|
||||
sidecarCommand.Stderr = stdout
|
||||
sidecarCommand.Env = envWithAndroidPlatformTools(os.Environ())
|
||||
if err := sidecarCommand.Start(); err != nil {
|
||||
return fmt.Errorf("spawn sidecar: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if sidecarCommand.Process != nil {
|
||||
_ = sidecarCommand.Process.Kill()
|
||||
}
|
||||
}()
|
||||
fmt.Fprintf(stdout, "sidecar pid=%d listening on 127.0.0.1:%d\n", sidecarCommand.Process.Pid, sidecarPort)
|
||||
|
||||
driverClient, err := maestro.Dial(fmt.Sprintf("127.0.0.1:%d", sidecarPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial sidecar: %w", err)
|
||||
}
|
||||
defer driverClient.Close()
|
||||
healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout)
|
||||
if err := driverClient.WaitForHealth(healthCtx, 250*time.Millisecond); err != nil {
|
||||
healthCancel()
|
||||
return fmt.Errorf("sidecar health check: %w", err)
|
||||
}
|
||||
healthCancel()
|
||||
fmt.Fprintln(stdout, "sidecar is healthy")
|
||||
defer cleanup()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
@@ -102,6 +67,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
||||
defer listener.Close()
|
||||
agentPort := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
if options.platform != "web" {
|
||||
if err := adbReverse(socketName, agentPort); err != nil {
|
||||
return fmt.Errorf("adb reverse: %w", err)
|
||||
}
|
||||
@@ -111,6 +77,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
||||
}
|
||||
}()
|
||||
fmt.Fprintf(stdout, "forwarded localabstract:%s -> tcp:%d\n", socketName, agentPort)
|
||||
}
|
||||
|
||||
agentServer := agent.NewServer(listener)
|
||||
|
||||
@@ -126,7 +93,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
||||
acceptChannel <- acceptResult{connection: connection, err: acceptErr}
|
||||
}()
|
||||
|
||||
if err := driverClient.Launch(ctx, options.bundleID, options.launcherActivity, false); err != nil {
|
||||
if err := activeDriver.Launch(ctx, options.bundleID, false); err != nil {
|
||||
return fmt.Errorf("launch app: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.bundleID, sdkAcceptTimeout.Seconds())
|
||||
@@ -185,14 +152,14 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
||||
IdleTimeout: 1 * time.Second,
|
||||
BundleID: options.bundleID,
|
||||
Connection: connection,
|
||||
Driver: driverClient,
|
||||
Driver: activeDriver,
|
||||
Verifier: verifierInstance,
|
||||
TraceWriter: traceWriter,
|
||||
Logger: newProgressLogger(stdout),
|
||||
})
|
||||
|
||||
terminateCtx, terminateCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_ = driverClient.Terminate(terminateCtx)
|
||||
_ = activeDriver.Terminate(terminateCtx)
|
||||
terminateCancel()
|
||||
|
||||
if err != nil {
|
||||
@@ -211,6 +178,62 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildDriver creates the appropriate DeviceDriver for the platform and returns
|
||||
// a cleanup function. For web, ChromeDriver is used directly; for android/ios
|
||||
// the JVM sidecar is extracted, spawned, and dialed.
|
||||
func buildDriver(ctx context.Context, options testOptions, stdout io.Writer) (driver.DeviceDriver, func(), error) {
|
||||
if options.platform == "web" {
|
||||
d := chrome.New()
|
||||
return d, func() { _ = d.Terminate(context.Background()) }, nil
|
||||
}
|
||||
|
||||
sidecarDirectory := filepath.Join(os.TempDir(), "sanderling-sidecar")
|
||||
jarPath, err := sidecar.Extract(sidecarDirectory)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("extract sidecar: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "sidecar JAR: %s (size=%d)\n", jarPath, sidecar.EmbeddedSize())
|
||||
|
||||
sidecarPort, err := pickFreePort()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sidecarCommand := exec.CommandContext(ctx, "java", "-jar", jarPath,
|
||||
"--port", strconv.Itoa(sidecarPort),
|
||||
"--platform", options.platform,
|
||||
)
|
||||
sidecarCommand.Stdout = stdout
|
||||
sidecarCommand.Stderr = stdout
|
||||
sidecarCommand.Env = envWithAndroidPlatformTools(os.Environ())
|
||||
if err := sidecarCommand.Start(); err != nil {
|
||||
return nil, nil, fmt.Errorf("spawn sidecar: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "sidecar pid=%d listening on 127.0.0.1:%d\n", sidecarCommand.Process.Pid, sidecarPort)
|
||||
|
||||
driverClient, err := driverSidecar.Dial(fmt.Sprintf("127.0.0.1:%d", sidecarPort))
|
||||
if err != nil {
|
||||
_ = sidecarCommand.Process.Kill()
|
||||
return nil, nil, fmt.Errorf("dial sidecar: %w", err)
|
||||
}
|
||||
healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout)
|
||||
if err := driverClient.WaitForHealth(healthCtx, 250*time.Millisecond); err != nil {
|
||||
healthCancel()
|
||||
_ = sidecarCommand.Process.Kill()
|
||||
_ = driverClient.Close()
|
||||
return nil, nil, fmt.Errorf("sidecar health check: %w", err)
|
||||
}
|
||||
healthCancel()
|
||||
fmt.Fprintln(stdout, "sidecar is healthy")
|
||||
|
||||
cleanup := func() {
|
||||
_ = driverClient.Close()
|
||||
if sidecarCommand.Process != nil {
|
||||
_ = sidecarCommand.Process.Kill()
|
||||
}
|
||||
}
|
||||
return driverClient, cleanup, nil
|
||||
}
|
||||
|
||||
// resolveSpecAPIPath returns the path to pkg/spec-api/src/index.ts inside
|
||||
// a sanderling source checkout, searched upward from the spec file and the cwd.
|
||||
// Returns "" when not found, in which case esbuild resolves @sanderling/spec via
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package chrome
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/input"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
)
|
||||
|
||||
// Driver implements DeviceDriver via chromedp for web platform testing.
|
||||
type Driver struct {
|
||||
allocCtx context.Context
|
||||
allocCancel context.CancelFunc
|
||||
tabCtx context.Context
|
||||
tabCancel context.CancelFunc
|
||||
|
||||
logsMu sync.Mutex
|
||||
logs []driver.LogEntry
|
||||
}
|
||||
|
||||
// New creates a new ChromeDriver. Call Terminate when done.
|
||||
func New() *Driver {
|
||||
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(),
|
||||
append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
)...,
|
||||
)
|
||||
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
|
||||
|
||||
d := &Driver{
|
||||
allocCtx: allocCtx,
|
||||
allocCancel: allocCancel,
|
||||
tabCtx: tabCtx,
|
||||
tabCancel: tabCancel,
|
||||
}
|
||||
|
||||
chromedp.ListenTarget(tabCtx, func(ev any) {
|
||||
e, ok := ev.(*runtime.EventConsoleAPICalled)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var parts []string
|
||||
for _, arg := range e.Args {
|
||||
if arg.Value != nil {
|
||||
var s string
|
||||
if err := json.Unmarshal(arg.Value, &s); err == nil {
|
||||
parts = append(parts, s)
|
||||
} else {
|
||||
parts = append(parts, string(arg.Value))
|
||||
}
|
||||
}
|
||||
}
|
||||
level := strings.ToUpper(string(e.Type))
|
||||
if level == "LOG" {
|
||||
level = "I"
|
||||
}
|
||||
d.logsMu.Lock()
|
||||
d.logs = append(d.logs, driver.LogEntry{
|
||||
UnixMillis: int64(e.Timestamp.Time().UnixMilli()),
|
||||
Level: level,
|
||||
Tag: "console",
|
||||
Message: strings.Join(parts, " "),
|
||||
})
|
||||
d.logsMu.Unlock()
|
||||
})
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool) error {
|
||||
if clearState {
|
||||
if err := chromedp.Run(d.tabCtx, network.ClearBrowserCookies()); err != nil {
|
||||
return fmt.Errorf("clear cookies: %w", err)
|
||||
}
|
||||
if err := chromedp.Run(d.tabCtx, chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
_, exp, err := runtime.Evaluate(`localStorage.clear(); sessionStorage.clear();`).Do(ctx)
|
||||
if exp != nil {
|
||||
return fmt.Errorf("clear storage: %s", exp.Text)
|
||||
}
|
||||
return err
|
||||
})); err != nil {
|
||||
return fmt.Errorf("clear storage: %w", err)
|
||||
}
|
||||
}
|
||||
return chromedp.Run(d.tabCtx, chromedp.Navigate(bundleID))
|
||||
}
|
||||
|
||||
func (d *Driver) Terminate(_ context.Context) error {
|
||||
d.tabCancel()
|
||||
d.allocCancel()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) Tap(_ context.Context, x, y int) error {
|
||||
return chromedp.Run(d.tabCtx,
|
||||
chromedp.MouseClickXY(float64(x), float64(y)),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Driver) TapSelector(_ context.Context, selector string) error {
|
||||
return chromedp.Run(d.tabCtx,
|
||||
chromedp.Click(selector, chromedp.NodeVisible),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Driver) InputText(_ context.Context, text string) error {
|
||||
return chromedp.Run(d.tabCtx,
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
return input.DispatchKeyEvent(input.KeyChar).WithText(text).Do(ctx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Driver) Swipe(_ context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
|
||||
millis := max(duration.Milliseconds(), 50)
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
const el = document.elementFromPoint(%d, %d);
|
||||
if (!el) return;
|
||||
const steps = Math.max(1, Math.floor(%d / 16));
|
||||
const dx = (%d - %d) / steps;
|
||||
const dy = (%d - %d) / steps;
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', {clientX: %d, clientY: %d, bubbles: true}));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
el.dispatchEvent(new PointerEvent('pointermove', {clientX: %d + dx*i, clientY: %d + dy*i, bubbles: true}));
|
||||
}
|
||||
el.dispatchEvent(new PointerEvent('pointerup', {clientX: %d, clientY: %d, bubbles: true}));
|
||||
})();`,
|
||||
fromX, fromY,
|
||||
millis,
|
||||
toX, fromX, toY, fromY,
|
||||
fromX, fromY,
|
||||
fromX, fromY,
|
||||
toX, toY,
|
||||
)
|
||||
return chromedp.Run(d.tabCtx, chromedp.Evaluate(script, nil))
|
||||
}
|
||||
|
||||
func (d *Driver) PressKey(_ context.Context, key string) error {
|
||||
k, ok := keyMap[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported key: %q", key)
|
||||
}
|
||||
return chromedp.Run(d.tabCtx, chromedp.KeyEvent(k))
|
||||
}
|
||||
|
||||
var keyMap = map[string]string{
|
||||
"back": "\b",
|
||||
"home": "\x00",
|
||||
"enter": "\r",
|
||||
"tab": "\t",
|
||||
"up": "\x26",
|
||||
"down": "\x28",
|
||||
"left": "\x25",
|
||||
"right": "\x27",
|
||||
}
|
||||
|
||||
func (d *Driver) Hierarchy(_ context.Context) (string, error) {
|
||||
script := `
|
||||
(function buildTree(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const attrs = {};
|
||||
const bounds = '[' + Math.round(rect.left) + ',' + Math.round(rect.top) + ',' +
|
||||
Math.round(rect.right) + ',' + Math.round(rect.bottom) + ']';
|
||||
if (rect.width > 0 || rect.height > 0) attrs.bounds = bounds;
|
||||
const text = (el.textContent || '').trim().slice(0, 200);
|
||||
if (text) attrs.text = text;
|
||||
if (el.id) attrs['resource-id'] = el.id;
|
||||
const label = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || '';
|
||||
if (label) attrs['content-desc'] = label;
|
||||
if (el.tagName) attrs['class'] = el.tagName.toLowerCase();
|
||||
const isClickable = !!(el.onclick || el.tagName === 'A' || el.tagName === 'BUTTON' ||
|
||||
el.tagName === 'INPUT' || el.tagName === 'SELECT' ||
|
||||
el.getAttribute('role') === 'button' || el.getAttribute('onclick'));
|
||||
const children = [];
|
||||
for (const child of el.children) {
|
||||
children.push(buildTree(child));
|
||||
}
|
||||
return {
|
||||
attributes: attrs,
|
||||
children: children,
|
||||
clickable: isClickable || null,
|
||||
enabled: (!el.disabled) || null,
|
||||
focused: document.activeElement === el || null,
|
||||
checked: el.checked || null,
|
||||
selected: el.selected || null,
|
||||
};
|
||||
})(document.body);`
|
||||
|
||||
var result any
|
||||
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &result)); err != nil {
|
||||
return "", fmt.Errorf("hierarchy: %w", err)
|
||||
}
|
||||
bytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hierarchy marshal: %w", err)
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func (d *Driver) Screenshot(_ context.Context) (driver.Image, error) {
|
||||
var buf []byte
|
||||
if err := chromedp.Run(d.tabCtx, chromedp.CaptureScreenshot(&buf)); err != nil {
|
||||
return driver.Image{}, fmt.Errorf("screenshot: %w", err)
|
||||
}
|
||||
w, h := pngDimensions(buf)
|
||||
return driver.Image{PNG: buf, Width: w, Height: h}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) RecentLogs(_ context.Context, since time.Time, minLevel string) ([]driver.LogEntry, error) {
|
||||
sinceMillis := since.UnixMilli()
|
||||
d.logsMu.Lock()
|
||||
defer d.logsMu.Unlock()
|
||||
var result []driver.LogEntry
|
||||
for _, entry := range d.logs {
|
||||
if entry.UnixMillis < sinceMillis {
|
||||
continue
|
||||
}
|
||||
if minLevel != "" && !meetsLevel(entry.Level, minLevel) {
|
||||
continue
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Driver) WaitForIdle(_ context.Context, _ time.Duration) error {
|
||||
return chromedp.Run(d.tabCtx, chromedp.WaitReady("body", chromedp.ByQuery))
|
||||
}
|
||||
|
||||
func (d *Driver) Health(_ context.Context) (driver.Health, error) {
|
||||
select {
|
||||
case <-d.tabCtx.Done():
|
||||
return driver.Health{Ready: false, Version: "chrome", Platform: "web"}, nil
|
||||
default:
|
||||
return driver.Health{Ready: true, Version: "chrome", Platform: "web"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) Metrics(_ context.Context, _ string) (driver.Metrics, error) {
|
||||
var result map[string]any
|
||||
script := `
|
||||
(function() {
|
||||
const mem = performance.memory || {};
|
||||
return {heap: mem.usedJSHeapSize || 0, totalMem: mem.totalJSHeapSize || 0};
|
||||
})()`
|
||||
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &result)); err != nil {
|
||||
return driver.Metrics{}, nil
|
||||
}
|
||||
heap, _ := result["heap"].(float64)
|
||||
total, _ := result["totalMem"].(float64)
|
||||
return driver.Metrics{
|
||||
HeapBytes: int64(heap),
|
||||
TotalMemoryBytes: int64(total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func meetsLevel(level, minLevel string) bool {
|
||||
order := map[string]int{"V": 0, "D": 1, "I": 2, "W": 3, "E": 4, "F": 5}
|
||||
return order[level] >= order[minLevel]
|
||||
}
|
||||
|
||||
func pngDimensions(png []byte) (int, int) {
|
||||
if len(png) < 24 {
|
||||
return 0, 0
|
||||
}
|
||||
w := int(png[16])<<24 | int(png[17])<<16 | int(png[18])<<8 | int(png[19])
|
||||
h := int(png[20])<<24 | int(png[21])<<16 | int(png[22])<<8 | int(png[23])
|
||||
return w, h
|
||||
}
|
||||
|
||||
var _ driver.DeviceDriver = (*Driver)(nil)
|
||||
@@ -5,15 +5,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Driver abstracts the platform-specific UI automation backend. v0.1 surface
|
||||
// matches proto/driverpb/driver.proto. The Maestro sidecar implementation
|
||||
// lives under driver/maestro; tests use driver/mock.
|
||||
type Driver interface {
|
||||
// Launch asks the backend to bring the target app to the foreground.
|
||||
// launcherActivity is an optional "<pkg>/<activity>" component that
|
||||
// overrides the backend's default launcher resolution — needed for
|
||||
// apps that declare multiple MAIN+LAUNCHER activities.
|
||||
Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error
|
||||
// DeviceDriver abstracts the platform-specific UI automation backend. v0.1
|
||||
// surface matches proto/driverpb/driver.proto. The sidecar implementation
|
||||
// lives under driver/sidecar; the web implementation under driver/chrome;
|
||||
// tests use driver/mock.
|
||||
type DeviceDriver interface {
|
||||
Launch(ctx context.Context, bundleID string, clearState bool) error
|
||||
Terminate(ctx context.Context) error
|
||||
|
||||
Tap(ctx context.Context, x, y int) error
|
||||
@@ -24,7 +21,7 @@ type Driver interface {
|
||||
|
||||
Hierarchy(ctx context.Context) (string, error)
|
||||
Screenshot(ctx context.Context) (Image, error)
|
||||
// RecentLogs returns logcat entries at or after `since`, filtered to
|
||||
// RecentLogs returns log entries at or after `since`, filtered to
|
||||
// `minLevel` or above. An empty minLevel defaults to "E".
|
||||
RecentLogs(ctx context.Context, since time.Time, minLevel string) ([]LogEntry, error)
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ const (
|
||||
type Action struct {
|
||||
Kind ActionKind
|
||||
BundleID string
|
||||
LauncherActivity string
|
||||
ClearState bool
|
||||
X, Y int
|
||||
FromX, FromY int
|
||||
@@ -89,11 +88,11 @@ func (d *Driver) failure(kind ActionKind) error {
|
||||
return d.Failures[kind]
|
||||
}
|
||||
|
||||
func (d *Driver) Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error {
|
||||
func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool) error {
|
||||
if err := d.failure(ActionLaunch); err != nil {
|
||||
return err
|
||||
}
|
||||
d.record(Action{Kind: ActionLaunch, BundleID: bundleID, LauncherActivity: launcherActivity, ClearState: clearState})
|
||||
d.record(Action{Kind: ActionLaunch, BundleID: bundleID, ClearState: clearState})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -220,4 +219,4 @@ func (d *Driver) Metrics(ctx context.Context, bundleID string) (driver.Metrics,
|
||||
return d.MetricsData, nil
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Driver)(nil)
|
||||
var _ driver.DeviceDriver = (*Driver)(nil)
|
||||
@@ -29,7 +29,7 @@ func TestRecordsAllActionsInOrder(t *testing.T) {
|
||||
mock := New()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := mock.Launch(ctx, "com.example", "com.example/.MainActivity", true); err != nil {
|
||||
if err := mock.Launch(ctx, "com.example", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.Tap(ctx, 100, 200); err != nil {
|
||||
@@ -137,5 +137,5 @@ func TestActionsReturnsCopy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSatisfiesDriverInterface(t *testing.T) {
|
||||
var _ driver.Driver = New()
|
||||
var _ driver.DeviceDriver = New()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package maestro
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -48,11 +48,10 @@ func (c *Client) WaitForHealth(ctx context.Context, pollInterval time.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Launch(ctx context.Context, bundleID, launcherActivity string, clearState bool) error {
|
||||
func (c *Client) Launch(ctx context.Context, bundleID string, clearState bool) error {
|
||||
_, err := c.stub.Launch(ctx, &driverpb.LaunchRequest{
|
||||
BundleId: bundleID,
|
||||
ClearState: clearState,
|
||||
LauncherActivity: launcherActivity,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -165,4 +164,4 @@ func (c *Client) Metrics(ctx context.Context, bundleID string) (driver.Metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Client)(nil)
|
||||
var _ driver.DeviceDriver = (*Client)(nil)
|
||||
@@ -1,4 +1,4 @@
|
||||
package maestro
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -22,7 +22,6 @@ type fakeServer struct {
|
||||
healthReadyAfterCall int
|
||||
|
||||
launchedBundleID string
|
||||
launcherActivity string
|
||||
clearState bool
|
||||
terminateCalls int
|
||||
taps []int32
|
||||
@@ -55,7 +54,6 @@ func (s *fakeServer) Launch(_ context.Context, request *driverpb.LaunchRequest)
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
s.launchedBundleID = request.GetBundleId()
|
||||
s.launcherActivity = request.GetLauncherActivity()
|
||||
s.clearState = request.GetClearState()
|
||||
return &driverpb.Empty{}, nil
|
||||
}
|
||||
@@ -193,10 +191,10 @@ func TestClient_LaunchAndTerminate(t *testing.T) {
|
||||
client, _ := Dial(state.address)
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Launch(context.Background(), "com.example", "com.example/.MainActivity", true); err != nil {
|
||||
if err := client.Launch(context.Background(), "com.example", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.fake.launchedBundleID != "com.example" || !state.fake.clearState || state.fake.launcherActivity != "com.example/.MainActivity" {
|
||||
if state.fake.launchedBundleID != "com.example" || !state.fake.clearState {
|
||||
t.Errorf("launch payload wrong: %+v", state.fake)
|
||||
}
|
||||
if err := client.Terminate(context.Background()); err != nil {
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package hierarchy parses the XML produced by `uiautomator dump` and
|
||||
// resolves selectors against it.
|
||||
// Package hierarchy parses the TreeNode JSON produced by the Maestro sidecar
|
||||
// and resolves selectors against it.
|
||||
//
|
||||
// Selector grammar (v0.1):
|
||||
//
|
||||
@@ -10,7 +10,7 @@
|
||||
package hierarchy
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -36,7 +36,7 @@ func (b Bounds) Width() int { return b.Right - b.Left }
|
||||
// Height returns the bounds' height.
|
||||
func (b Bounds) Height() int { return b.Bottom - b.Top }
|
||||
|
||||
// Element is a flattened view of one uiautomator node.
|
||||
// Element is a flattened view of one hierarchy node.
|
||||
type Element struct {
|
||||
ResourceID string `json:"resourceId,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
@@ -56,38 +56,82 @@ type Tree struct {
|
||||
Elements []*Element `json:"elements"`
|
||||
}
|
||||
|
||||
// Parse parses a uiautomator-style XML dump.
|
||||
func Parse(xmlText string) (*Tree, error) {
|
||||
xmlText = strings.TrimSpace(xmlText)
|
||||
if xmlText == "" {
|
||||
// treeNodeJSON mirrors the Maestro TreeNode JSON structure.
|
||||
type treeNodeJSON struct {
|
||||
Attributes map[string]string `json:"attributes"`
|
||||
Children []treeNodeJSON `json:"children"`
|
||||
Clickable *bool `json:"clickable"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Focused *bool `json:"focused"`
|
||||
Checked *bool `json:"checked"`
|
||||
Selected *bool `json:"selected"`
|
||||
}
|
||||
|
||||
// Parse parses a Maestro TreeNode JSON hierarchy.
|
||||
func Parse(text string) (*Tree, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return &Tree{}, nil
|
||||
}
|
||||
decoder := xml.NewDecoder(strings.NewReader(xmlText))
|
||||
var root treeNodeJSON
|
||||
if err := json.Unmarshal([]byte(text), &root); err != nil {
|
||||
return nil, fmt.Errorf("hierarchy: %w", err)
|
||||
}
|
||||
tree := &Tree{}
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != "node" {
|
||||
continue
|
||||
}
|
||||
element, parseErr := elementFromStart(start)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
tree.Elements = append(tree.Elements, element)
|
||||
}
|
||||
walkNode(&root, tree)
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
func walkNode(node *treeNodeJSON, tree *Tree) {
|
||||
element := elementFromNode(node)
|
||||
tree.Elements = append(tree.Elements, element)
|
||||
for i := range node.Children {
|
||||
walkNode(&node.Children[i], tree)
|
||||
}
|
||||
}
|
||||
|
||||
func elementFromNode(node *treeNodeJSON) *Element {
|
||||
attrs := node.Attributes
|
||||
element := &Element{}
|
||||
|
||||
element.ResourceID = attrs["resource-id"]
|
||||
if element.ResourceID == "" {
|
||||
element.ResourceID = attrs["identifier"]
|
||||
}
|
||||
element.Text = attrs["text"]
|
||||
element.Description = attrs["content-desc"]
|
||||
if element.Description == "" {
|
||||
element.Description = attrs["accessibilityText"]
|
||||
}
|
||||
element.Class = attrs["class"]
|
||||
element.Package = attrs["package"]
|
||||
|
||||
if node.Clickable != nil {
|
||||
element.Clickable = *node.Clickable
|
||||
}
|
||||
if node.Enabled != nil {
|
||||
element.Enabled = *node.Enabled
|
||||
}
|
||||
if node.Focused != nil {
|
||||
element.Focused = *node.Focused
|
||||
}
|
||||
if node.Checked != nil {
|
||||
element.Checked = *node.Checked
|
||||
}
|
||||
if node.Selected != nil {
|
||||
element.Selected = *node.Selected
|
||||
}
|
||||
|
||||
if b, ok := attrs["bounds"]; ok && b != "" {
|
||||
bounds, err := parseBounds(b)
|
||||
if err == nil {
|
||||
element.Bounds = bounds
|
||||
}
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
// Find returns the first element matching the selector, or nil.
|
||||
func (t *Tree) Find(selector string) *Element {
|
||||
kind, value, ok := parseSelector(selector)
|
||||
@@ -131,11 +175,7 @@ func match(element *Element, kind, value string) bool {
|
||||
if element.ResourceID == value {
|
||||
return true
|
||||
}
|
||||
// ResourceID looks like "<package>:id/<suffix>".
|
||||
if strings.HasSuffix(element.ResourceID, ":id/"+value) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return strings.HasSuffix(element.ResourceID, ":id/"+value)
|
||||
case "text":
|
||||
return element.Text == value
|
||||
case "desc":
|
||||
@@ -147,59 +187,21 @@ func match(element *Element, kind, value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func elementFromStart(start xml.StartElement) (*Element, error) {
|
||||
element := &Element{}
|
||||
var boundsText string
|
||||
for _, attribute := range start.Attr {
|
||||
switch attribute.Name.Local {
|
||||
case "resource-id":
|
||||
element.ResourceID = attribute.Value
|
||||
case "text":
|
||||
element.Text = attribute.Value
|
||||
case "content-desc":
|
||||
element.Description = attribute.Value
|
||||
case "class":
|
||||
element.Class = attribute.Value
|
||||
case "package":
|
||||
element.Package = attribute.Value
|
||||
case "clickable":
|
||||
element.Clickable = attribute.Value == "true"
|
||||
case "enabled":
|
||||
element.Enabled = attribute.Value == "true"
|
||||
case "checked":
|
||||
element.Checked = attribute.Value == "true"
|
||||
case "focused":
|
||||
element.Focused = attribute.Value == "true"
|
||||
case "selected":
|
||||
element.Selected = attribute.Value == "true"
|
||||
case "bounds":
|
||||
boundsText = attribute.Value
|
||||
}
|
||||
}
|
||||
if boundsText != "" {
|
||||
bounds, err := parseBounds(boundsText)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bounds %q: %w", boundsText, err)
|
||||
}
|
||||
element.Bounds = bounds
|
||||
}
|
||||
return element, nil
|
||||
}
|
||||
|
||||
var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$`)
|
||||
// boundsPattern matches "[l,t,r,b]" (4-value Maestro format).
|
||||
var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+),(-?\d+),(-?\d+)\]$`)
|
||||
|
||||
func parseBounds(text string) (Bounds, error) {
|
||||
match := boundsPattern.FindStringSubmatch(text)
|
||||
if match == nil {
|
||||
return Bounds{}, fmt.Errorf("not in [L,T][R,B] form")
|
||||
m := boundsPattern.FindStringSubmatch(text)
|
||||
if m == nil {
|
||||
return Bounds{}, fmt.Errorf("bounds %q: not in [L,T,R,B] form", text)
|
||||
}
|
||||
coordinates := make([]int, 4)
|
||||
for index := range 4 {
|
||||
value, err := strconv.Atoi(match[index+1])
|
||||
coords := make([]int, 4)
|
||||
for i := range 4 {
|
||||
v, err := strconv.Atoi(m[i+1])
|
||||
if err != nil {
|
||||
return Bounds{}, err
|
||||
}
|
||||
coordinates[index] = value
|
||||
coords[i] = v
|
||||
}
|
||||
return Bounds{Left: coordinates[0], Top: coordinates[1], Right: coordinates[2], Bottom: coordinates[3]}, nil
|
||||
return Bounds{Left: coords[0], Top: coords[1], Right: coords[2], Bottom: coords[3]}, nil
|
||||
}
|
||||
@@ -2,14 +2,30 @@ package hierarchy
|
||||
|
||||
import "testing"
|
||||
|
||||
const sampleDump = `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node class="android.widget.LinearLayout" package="app" bounds="[0,0][1080,2340]">
|
||||
<node resource-id="app:id/title" text="Hello" bounds="[10,20][200,60]" clickable="false" enabled="true"/>
|
||||
<node resource-id="app:id/row" text="Alice" content-desc="row" bounds="[0,100][1080,200]" clickable="true" enabled="true"/>
|
||||
<node resource-id="app:id/row" text="Bob" content-desc="row" bounds="[0,200][1080,300]" clickable="true" enabled="true"/>
|
||||
</node>
|
||||
</hierarchy>`
|
||||
// sampleDump is a Maestro TreeNode JSON equivalent of the old XML fixture.
|
||||
const sampleDump = `{
|
||||
"attributes": {"class": "android.widget.LinearLayout", "package": "app", "bounds": "[0,0,1080,2340]"},
|
||||
"children": [
|
||||
{
|
||||
"attributes": {"resource-id": "app:id/title", "text": "Hello", "bounds": "[10,20,200,60]"},
|
||||
"children": [],
|
||||
"clickable": false,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"attributes": {"resource-id": "app:id/row", "text": "Alice", "content-desc": "row", "bounds": "[0,100,1080,200]"},
|
||||
"children": [],
|
||||
"clickable": true,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"attributes": {"resource-id": "app:id/row", "text": "Bob", "content-desc": "row", "bounds": "[0,200,1080,300]"},
|
||||
"children": [],
|
||||
"clickable": true,
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestParseCountsNodes(t *testing.T) {
|
||||
tree, err := Parse(sampleDump)
|
||||
@@ -75,14 +91,76 @@ func TestUnknownSelector(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDescPrefix(t *testing.T) {
|
||||
xml := `<hierarchy>
|
||||
<node content-desc="customer_row_abc-123" bounds="[0,0][100,100]"/>
|
||||
<node content-desc="customer_row_def-456" bounds="[0,100][100,200]"/>
|
||||
<node content-desc="supplier_row_xyz" bounds="[0,200][100,300]"/>
|
||||
</hierarchy>`
|
||||
tree, _ := Parse(xml)
|
||||
input := `{
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{"attributes": {"content-desc": "customer_row_abc-123", "bounds": "[0,0,100,100]"}, "children": []},
|
||||
{"attributes": {"content-desc": "customer_row_def-456", "bounds": "[0,100,100,200]"}, "children": []},
|
||||
{"attributes": {"content-desc": "supplier_row_xyz", "bounds": "[0,200,100,300]"}, "children": []}
|
||||
]
|
||||
}`
|
||||
tree, _ := Parse(input)
|
||||
rows := tree.FindAll("descPrefix:customer_row_")
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want 2 customer rows, got %d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoolFieldsFromNode(t *testing.T) {
|
||||
input := `{
|
||||
"attributes": {"resource-id": "x", "bounds": "[0,0,100,100]"},
|
||||
"children": [],
|
||||
"clickable": true,
|
||||
"enabled": false,
|
||||
"focused": true,
|
||||
"checked": true,
|
||||
"selected": false
|
||||
}`
|
||||
tree, err := Parse(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(tree.Elements) != 1 {
|
||||
t.Fatalf("want 1 element, got %d", len(tree.Elements))
|
||||
}
|
||||
el := tree.Elements[0]
|
||||
if !el.Clickable {
|
||||
t.Error("expected clickable=true")
|
||||
}
|
||||
if el.Enabled {
|
||||
t.Error("expected enabled=false")
|
||||
}
|
||||
if !el.Focused {
|
||||
t.Error("expected focused=true")
|
||||
}
|
||||
if !el.Checked {
|
||||
t.Error("expected checked=true")
|
||||
}
|
||||
if el.Selected {
|
||||
t.Error("expected selected=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentifierFallback(t *testing.T) {
|
||||
input := `{
|
||||
"attributes": {"identifier": "my-button", "bounds": "[0,0,100,100]"},
|
||||
"children": []
|
||||
}`
|
||||
tree, _ := Parse(input)
|
||||
el := tree.Find("id:my-button")
|
||||
if el == nil {
|
||||
t.Fatal("expected match via identifier fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessibilityTextFallback(t *testing.T) {
|
||||
input := `{
|
||||
"attributes": {"accessibilityText": "Close dialog", "bounds": "[0,0,50,50]"},
|
||||
"children": []
|
||||
}`
|
||||
tree, _ := Parse(input)
|
||||
el := tree.Find("desc:Close dialog")
|
||||
if el == nil {
|
||||
t.Fatal("expected match via accessibilityText fallback")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -321,10 +321,7 @@ func TestRunner_StampsHierarchyResolvedBoundsAndResiduals(t *testing.T) {
|
||||
state.startSDK(t)
|
||||
state.acceptConnection(t)
|
||||
|
||||
state.mock.HierarchyJSON = `<?xml version="1.0"?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.fixture:id/next" clickable="true" enabled="true" bounds="[40,80][240,160]"/>
|
||||
</hierarchy>`
|
||||
state.mock.HierarchyJSON = `{"attributes":{"resource-id":"com.fixture:id/next","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true}`
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
package verifier
|
||||
|
||||
const listHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" class="android.widget.FrameLayout" package="test.app" bounds="[0,0][1080,2400]">
|
||||
<node index="0" class="android.widget.TextView" text="Items" bounds="[100,200][900,300]" />
|
||||
<node index="1" class="android.view.View" content-desc="primary_action" clickable="true" enabled="true" bounds="[64,2200][1016,2320]" />
|
||||
<node index="2" class="android.view.View" content-desc="secondary_action" clickable="true" enabled="true" bounds="[980,80][1060,160]" />
|
||||
</node>
|
||||
</hierarchy>`
|
||||
const listHierarchyJSON = `{
|
||||
"attributes": {"class": "android.widget.FrameLayout", "package": "test.app", "bounds": "[0,0,1080,2400]"},
|
||||
"children": [
|
||||
{"attributes": {"class": "android.widget.TextView", "text": "Items", "bounds": "[100,200,900,300]"}, "children": []},
|
||||
{"attributes": {"content-desc": "primary_action", "bounds": "[64,2200,1016,2320]"}, "children": [], "clickable": true, "enabled": true},
|
||||
{"attributes": {"content-desc": "secondary_action", "bounds": "[980,80,1060,160]"}, "children": [], "clickable": true, "enabled": true}
|
||||
]
|
||||
}`
|
||||
|
||||
const formHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" class="android.widget.FrameLayout" package="test.app" bounds="[0,0][1080,2400]">
|
||||
<node index="0" class="android.view.View" content-desc="text_field" clickable="true" enabled="true" bounds="[64,320][1016,440]" />
|
||||
<node index="1" class="android.view.View" content-desc="primary_action" clickable="true" enabled="true" bounds="[64,2200][1016,2320]" />
|
||||
<node index="2" class="android.view.View" content-desc="secondary_action" clickable="true" enabled="true" bounds="[32,80][112,160]" />
|
||||
</node>
|
||||
</hierarchy>`
|
||||
const formHierarchyJSON = `{
|
||||
"attributes": {"class": "android.widget.FrameLayout", "package": "test.app", "bounds": "[0,0,1080,2400]"},
|
||||
"children": [
|
||||
{"attributes": {"content-desc": "text_field", "bounds": "[64,320,1016,440]"}, "children": [], "clickable": true, "enabled": true},
|
||||
{"attributes": {"content-desc": "primary_action", "bounds": "[64,2200,1016,2320]"}, "children": [], "clickable": true, "enabled": true},
|
||||
{"attributes": {"content-desc": "secondary_action", "bounds": "[32,80,112,160]"}, "children": [], "clickable": true, "enabled": true}
|
||||
]
|
||||
}`
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -61,10 +61,6 @@ type LaunchRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
BundleId string `protobuf:"bytes,1,opt,name=bundle_id,json=bundleId,proto3" json:"bundle_id,omitempty"`
|
||||
ClearState bool `protobuf:"varint,2,opt,name=clear_state,json=clearState,proto3" json:"clear_state,omitempty"`
|
||||
// Optional "<pkg>/<activity>" component to pass to `am start -n`. Needed
|
||||
// when an app declares multiple MAIN+LAUNCHER activities where the first
|
||||
// one reported by `pm dump` is an alias whose target class is disabled.
|
||||
LauncherActivity string `protobuf:"bytes,3,opt,name=launcher_activity,json=launcherActivity,proto3" json:"launcher_activity,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -113,13 +109,6 @@ func (x *LaunchRequest) GetClearState() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *LaunchRequest) GetLauncherActivity() string {
|
||||
if x != nil {
|
||||
return x.LauncherActivity
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
X int32 `protobuf:"varint,1,opt,name=x,proto3" json:"x,omitempty"`
|
||||
@@ -850,12 +839,11 @@ var File_driverpb_driver_proto protoreflect.FileDescriptor
|
||||
const file_driverpb_driver_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x15driverpb/driver.proto\x12\x14sanderling.driver.v1\"\a\n" +
|
||||
"\x05Empty\"z\n" +
|
||||
"\x05Empty\"M\n" +
|
||||
"\rLaunchRequest\x12\x1b\n" +
|
||||
"\tbundle_id\x18\x01 \x01(\tR\bbundleId\x12\x1f\n" +
|
||||
"\vclear_state\x18\x02 \x01(\bR\n" +
|
||||
"clearState\x12+\n" +
|
||||
"\x11launcher_activity\x18\x03 \x01(\tR\x10launcherActivity\"#\n" +
|
||||
"clearState\"#\n" +
|
||||
"\x05Point\x12\f\n" +
|
||||
"\x01x\x18\x01 \x01(\x05R\x01x\x12\f\n" +
|
||||
"\x01y\x18\x02 \x01(\x05R\x01y\"\x1c\n" +
|
||||
|
||||
@@ -30,10 +30,6 @@ message Empty {}
|
||||
message LaunchRequest {
|
||||
string bundle_id = 1;
|
||||
bool clear_state = 2;
|
||||
// Optional "<pkg>/<activity>" component to pass to `am start -n`. Needed
|
||||
// when an app declares multiple MAIN+LAUNCHER activities where the first
|
||||
// one reported by `pm dump` is an alias whose target class is disabled.
|
||||
string launcher_activity = 3;
|
||||
}
|
||||
|
||||
message Point {
|
||||
|
||||
@@ -25,6 +25,7 @@ val maestroVersion = "1.40.0"
|
||||
|
||||
dependencies {
|
||||
implementation("dev.mobile:maestro-client:$maestroVersion")
|
||||
implementation("dev.mobile:maestro-utils:$maestroVersion")
|
||||
|
||||
implementation("io.grpc:grpc-netty-shaded:$grpcVersion")
|
||||
implementation("io.grpc:grpc-protobuf:$grpcVersion")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dev.sanderling.sidecar
|
||||
|
||||
interface DriverBackend {
|
||||
fun launch(bundleId: String, launcherActivity: String, clearState: Boolean)
|
||||
fun launch(bundleId: String, clearState: Boolean)
|
||||
fun terminate(bundleId: String)
|
||||
fun tap(x: Int, y: Int)
|
||||
fun tapSelector(selector: String)
|
||||
@@ -29,12 +29,96 @@ data class LogLine(
|
||||
val message: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* StubDriverBackend records calls but takes no real device action. Real
|
||||
* Maestro integration arrives in a follow-up; v0.1 wires the gRPC plumbing
|
||||
* end-to-end so the Go side can be exercised against a running sidecar
|
||||
* even before Maestro is plugged in.
|
||||
*/
|
||||
internal fun readLogcat(serial: String?, sinceUnixMillis: Long, minLevel: String): List<LogLine> {
|
||||
val level = if (minLevel.isEmpty()) "E" else minLevel
|
||||
val since = if (sinceUnixMillis > 0) StubDriverBackend.formatAdbLogcatTimestamp(sinceUnixMillis) else null
|
||||
val arguments = mutableListOf("logcat", "-d", "*:$level")
|
||||
if (since != null) {
|
||||
arguments.add("-T")
|
||||
arguments.add(since)
|
||||
}
|
||||
return try {
|
||||
val process = ProcessBuilder(adbCmd(serial) + arguments).redirectErrorStream(false).start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
StubDriverBackend.parseLogcatOutput(output)
|
||||
} catch (cause: Exception) {
|
||||
println("adb logcat failed: $cause")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun readProcMetrics(serial: String?, bundleId: String): MetricsSample {
|
||||
if (bundleId.isEmpty()) return MetricsSample(0.0, 0L, 0L)
|
||||
return try {
|
||||
val pid = adbOutput(serial, listOf("shell", "pidof", bundleId))
|
||||
.trim().split(Regex("\\s+")).firstOrNull()?.toIntOrNull()
|
||||
?: return MetricsSample(0.0, 0L, 0L)
|
||||
val cpu = sampleCpuTwice(serial, pid)
|
||||
val (rssBytes, vmSizeBytes) = sampleProcessMemory(serial, pid)
|
||||
MetricsSample(cpu, rssBytes, vmSizeBytes)
|
||||
} catch (cause: Exception) {
|
||||
println("metrics capture failed: $cause")
|
||||
MetricsSample(0.0, 0L, 0L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun adbCmd(serial: String?): List<String> =
|
||||
if (serial == null) listOf("adb") else listOf("adb", "-s", serial)
|
||||
|
||||
private fun adbOutput(serial: String?, arguments: List<String>): String {
|
||||
return try {
|
||||
val process = ProcessBuilder(adbCmd(serial) + arguments).redirectErrorStream(false).start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
output
|
||||
} catch (cause: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun sampleCpuTwice(serial: String?, pid: Int): Double {
|
||||
val sleepArg = "0.050"
|
||||
val command = "cat /proc/$pid/stat; sleep $sleepArg; cat /proc/$pid/stat"
|
||||
val output = adbOutput(serial, listOf("shell", command))
|
||||
val lines = output.lines().filter { it.isNotBlank() }
|
||||
if (lines.size < 2) return 0.0
|
||||
val first = parseCpuTicks(lines[0]) ?: return 0.0
|
||||
val second = parseCpuTicks(lines[1]) ?: return 0.0
|
||||
val clockHz = adbOutput(serial, listOf("shell", "getconf", "CLK_TCK")).trim().toLongOrNull() ?: 100L
|
||||
val deltaCpuNanos = (second - first) * 1_000_000_000.0 / clockHz.coerceAtLeast(1L)
|
||||
return (deltaCpuNanos / 50_000_000.0) * 100.0
|
||||
}
|
||||
|
||||
private fun parseCpuTicks(statLine: String): Long? {
|
||||
val afterComm = statLine.substringAfterLast(')').trim()
|
||||
val fields = afterComm.split(Regex("\\s+"))
|
||||
if (fields.size < 13) return null
|
||||
val utime = fields[11].toLongOrNull() ?: return null
|
||||
val stime = fields[12].toLongOrNull() ?: return null
|
||||
return utime + stime
|
||||
}
|
||||
|
||||
private fun sampleProcessMemory(serial: String?, pid: Int): Pair<Long, Long> {
|
||||
val status = adbOutput(serial, listOf("shell", "cat", "/proc/$pid/status"))
|
||||
var rssKb = 0L
|
||||
var vmSizeKb = 0L
|
||||
for (raw in status.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.startsWith("VmRSS:") -> rssKb = parseKb(line) ?: rssKb
|
||||
line.startsWith("VmSize:") -> vmSizeKb = parseKb(line) ?: vmSizeKb
|
||||
}
|
||||
}
|
||||
return Pair(rssKb * 1024L, vmSizeKb * 1024L)
|
||||
}
|
||||
|
||||
private fun parseKb(line: String): Long? {
|
||||
val parts = line.split(Regex("\\s+"))
|
||||
if (parts.size < 2) return null
|
||||
return parts[1].toLongOrNull()
|
||||
}
|
||||
|
||||
class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
@Volatile var launchCount: Int = 0
|
||||
private set
|
||||
@@ -47,38 +131,13 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
@Volatile var lastInputText: String? = null
|
||||
private set
|
||||
|
||||
override fun launch(bundleId: String, launcherActivity: String, clearState: Boolean) {
|
||||
override fun launch(bundleId: String, clearState: Boolean) {
|
||||
launchCount++
|
||||
lastBundleId = bundleId
|
||||
if (clearState) {
|
||||
runAdb(listOf("shell", "pm", "clear", bundleId))
|
||||
}
|
||||
val component = when {
|
||||
launcherActivity.isEmpty() -> "$bundleId/${resolveLauncherActivity(bundleId)}"
|
||||
launcherActivity.contains('/') -> launcherActivity
|
||||
else -> "$bundleId/$launcherActivity"
|
||||
}
|
||||
runAdb(listOf("shell", "am", "start", "-W", "-n", component))
|
||||
}
|
||||
|
||||
private fun resolveLauncherActivity(bundleId: String): String {
|
||||
val output = captureAdb(
|
||||
listOf(
|
||||
"shell", "cmd", "package", "resolve-activity", "--brief",
|
||||
"-a", "android.intent.action.MAIN",
|
||||
"-c", "android.intent.category.LAUNCHER",
|
||||
bundleId,
|
||||
),
|
||||
)
|
||||
return parseResolvedActivity(bundleId, output)
|
||||
?: throw IllegalStateException("could not resolve launcher activity for $bundleId: $output")
|
||||
}
|
||||
|
||||
private fun captureAdb(arguments: List<String>): String {
|
||||
val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
return output
|
||||
runAdb(listOf("shell", "am", "start", "-W", "-n", "$bundleId/.MainActivity"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -87,9 +146,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
internal fun isAnimationCountIdle(grepOutput: String): Boolean =
|
||||
(grepOutput.trim().toIntOrNull() ?: 0) == 0
|
||||
|
||||
// parseResolvedActivity extracts the activity name from the output of
|
||||
// `cmd package resolve-activity --brief`. The brief output is two
|
||||
// lines: metadata, then `<pkg>/<activity>`.
|
||||
internal fun parseResolvedActivity(bundleId: String, output: String): String? {
|
||||
val prefix = "$bundleId/"
|
||||
for (line in output.lines()) {
|
||||
@@ -101,8 +157,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
return null
|
||||
}
|
||||
|
||||
// Hard cap on KEYCODE_DEL events per clear. Guards against a pathological
|
||||
// hierarchy that reports an enormous text length for the focused field.
|
||||
internal const val MAX_CLEAR_DELETES: Int = 1024
|
||||
|
||||
internal fun buildClearKeyevents(textLength: Int): List<String> {
|
||||
@@ -113,11 +167,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
return args
|
||||
}
|
||||
|
||||
// `adb shell input text` runs through a remote sh, so shell metacharacters
|
||||
// in the payload would be interpreted by the device shell. Substitute
|
||||
// spaces with %s (input's escape) and backslash-escape characters sh
|
||||
// would otherwise expand. Keep this list conservative; anything not
|
||||
// listed passes through literally.
|
||||
internal fun escapeForAdbInputText(text: String): String {
|
||||
val sb = StringBuilder(text.length)
|
||||
for (ch in text) {
|
||||
@@ -131,9 +180,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
// Matches a uiautomator-dump <node ...> tag where `focused="true"` is
|
||||
// present. Captures only the tag's attribute string so we can pull
|
||||
// `text="..."` out of it without building a full XML tree.
|
||||
private val FOCUSED_NODE = Regex(
|
||||
"<node\\b([^>]*\\bfocused=\"true\"[^>]*)/?>",
|
||||
)
|
||||
@@ -169,10 +215,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
return "$seconds.${millis.toString().padStart(3, '0')}"
|
||||
}
|
||||
|
||||
// Logcat default threadtime format:
|
||||
// MM-dd HH:mm:ss.SSS PID TID L TAG: message
|
||||
// The leading date is the local year-inferred date; we convert to a
|
||||
// unix-millis best-effort using the current year.
|
||||
private val LOGCAT_LINE = Regex(
|
||||
"^(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})\\.(\\d{3})" +
|
||||
"\\s+\\d+\\s+\\d+\\s+([VDIWEFS])\\s+([^:]+?):\\s?(.*)$",
|
||||
@@ -215,16 +257,10 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
|
||||
override fun tapSelector(selector: String) {
|
||||
lastTapSelector = selector
|
||||
// v0.1: selector resolution lives in Maestro proper; the stub
|
||||
// records the selector so logs/traces show what was requested.
|
||||
}
|
||||
|
||||
override fun inputText(text: String) {
|
||||
lastInputText = text
|
||||
// `adb shell input text` types keystrokes at the caret, so repeated
|
||||
// calls append. Clear the focused field first so the caller sees a
|
||||
// pure replace: read the current value's length from the hierarchy,
|
||||
// then move-end + N backspaces before typing.
|
||||
clearFocusedField()
|
||||
runAdb(listOf("shell", "input", "text", escapeForAdbInputText(text)))
|
||||
}
|
||||
@@ -271,31 +307,14 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
runAdb(listOf("shell", "input", "keyevent", keyCode))
|
||||
}
|
||||
|
||||
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> {
|
||||
val level = if (minLevel.isEmpty()) "E" else minLevel
|
||||
val since = if (sinceUnixMillis > 0) formatAdbLogcatTimestamp(sinceUnixMillis) else null
|
||||
val arguments = mutableListOf("logcat", "-d", "*:$level")
|
||||
if (since != null) {
|
||||
arguments.add("-T")
|
||||
arguments.add(since)
|
||||
}
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(false).start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
parseLogcatOutput(output)
|
||||
} catch (cause: Exception) {
|
||||
println("adb logcat failed: $cause")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> =
|
||||
readLogcat(null, sinceUnixMillis, minLevel)
|
||||
|
||||
data class SwipeRecord(val fromX: Int, val fromY: Int, val toX: Int, val toY: Int, val durationMillis: Long)
|
||||
|
||||
private fun runAdb(arguments: List<String>) {
|
||||
try {
|
||||
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start()
|
||||
// Drain output before waiting so a large write doesn't block the child.
|
||||
command.inputStream.bufferedReader().readText()
|
||||
command.waitFor()
|
||||
} catch (cause: Exception) {
|
||||
@@ -345,129 +364,138 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
|
||||
private fun isDeviceIdle(): Boolean {
|
||||
return try {
|
||||
val output = captureAdb(
|
||||
listOf("shell", "dumpsys window -a | grep -c mAnimating=true"),
|
||||
)
|
||||
val output = adbOutput(null, listOf("shell", "dumpsys window -a | grep -c mAnimating=true"))
|
||||
isAnimationCountIdle(output)
|
||||
} catch (cause: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun healthy(): Boolean = true
|
||||
|
||||
// Stateful CPU delta tracker. Reading /proc/<pid>/stat gives cumulative
|
||||
// utime+stime in jiffies; CPU % over a step is (delta_ticks / delta_wall)
|
||||
// * 100. First call blocks briefly for a real reading instead of 0.
|
||||
private data class CpuSample(val pid: Int, val ticks: Long, val wallNanos: Long)
|
||||
@Volatile private var lastCpuSample: CpuSample? = null
|
||||
private val clockTicksPerSecond: Long by lazy { resolveClockTicksPerSecond() }
|
||||
private val cpuFirstSampleSleepMillis: Long = 50L
|
||||
override fun metrics(bundleId: String): MetricsSample = readProcMetrics(null, bundleId)
|
||||
}
|
||||
|
||||
override fun metrics(bundleId: String): MetricsSample {
|
||||
if (bundleId.isEmpty()) return MetricsSample(0.0, 0L, 0L)
|
||||
return try {
|
||||
val pid = runAdbOutput(listOf("shell", "pidof", bundleId)).trim().split(Regex("\\s+")).firstOrNull()?.toIntOrNull()
|
||||
?: run {
|
||||
lastCpuSample = null
|
||||
return MetricsSample(0.0, 0L, 0L)
|
||||
class MaestroDriverBackend(private val serial: String?) : DriverBackend {
|
||||
private val dadb: dadb.Dadb
|
||||
private val driver: maestro.drivers.AndroidDriver
|
||||
|
||||
init {
|
||||
dadb = buildDadb(serial)
|
||||
driver = maestro.drivers.AndroidDriver(dadb, 7001, "localhost")
|
||||
driver.open()
|
||||
}
|
||||
val cpu = sampleCpuPercent(pid)
|
||||
val (rssBytes, vmSizeBytes) = sampleProcessMemory(pid)
|
||||
MetricsSample(cpu, rssBytes, vmSizeBytes)
|
||||
} catch (cause: Exception) {
|
||||
println("metrics capture failed: $cause")
|
||||
MetricsSample(0.0, 0L, 0L)
|
||||
|
||||
override fun launch(bundleId: String, clearState: Boolean) {
|
||||
if (clearState) driver.clearAppState(bundleId)
|
||||
driver.launchApp(bundleId, emptyMap(), java.util.UUID.randomUUID())
|
||||
}
|
||||
|
||||
override fun terminate(bundleId: String) = driver.stopApp(bundleId)
|
||||
|
||||
override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y))
|
||||
|
||||
override fun tapSelector(selector: String) {
|
||||
val root = driver.contentDescriptor(false)
|
||||
val bounds = findBoundsBySelector(root, selector) ?: return
|
||||
driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2))
|
||||
}
|
||||
|
||||
override fun inputText(text: String) = driver.inputText(text)
|
||||
|
||||
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) =
|
||||
driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L))
|
||||
|
||||
override fun pressKey(key: String) {
|
||||
StubDriverBackend.KEY_MAP[key]?.let { keyCode ->
|
||||
keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sampleCpuPercent(pid: Int): Double {
|
||||
val previous = lastCpuSample
|
||||
if (previous != null && previous.pid == pid) {
|
||||
val ticks = readCpuTicks(pid) ?: return 0.0
|
||||
val now = System.nanoTime()
|
||||
lastCpuSample = CpuSample(pid, ticks, now)
|
||||
return cpuPercentFromDelta(ticks - previous.ticks, now - previous.wallNanos)
|
||||
}
|
||||
// No baseline for this PID: one adb round-trip with a device-side sleep
|
||||
// so the first step gets a real reading instead of 0.
|
||||
val pair = readCpuTicksPair(pid, cpuFirstSampleSleepMillis) ?: return 0.0
|
||||
lastCpuSample = CpuSample(pid, pair.second, System.nanoTime())
|
||||
return cpuPercentFromDelta(pair.second - pair.first, cpuFirstSampleSleepMillis * 1_000_000L)
|
||||
override fun screenshot(): Triple<ByteArray, Int, Int> {
|
||||
val buf = okio.Buffer()
|
||||
driver.takeScreenshot(buf, false)
|
||||
val bytes = buf.readByteArray()
|
||||
return Triple(bytes, pngWidth(bytes), pngHeight(bytes))
|
||||
}
|
||||
|
||||
private fun readCpuTicks(pid: Int): Long? {
|
||||
val stat = runAdbOutput(listOf("shell", "cat", "/proc/$pid/stat")).trim()
|
||||
if (stat.isEmpty()) return null
|
||||
return parseCpuTicks(stat)
|
||||
override fun hierarchy(): String =
|
||||
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().writeValueAsString(driver.contentDescriptor(false))
|
||||
|
||||
override fun recentLogs(sinceUnixMillis: Long, minLevel: String) =
|
||||
readLogcat(serial, sinceUnixMillis, minLevel)
|
||||
|
||||
override fun waitForIdle(durationMillis: Long) {
|
||||
driver.waitForAppToSettle(null, null, durationMillis.toInt())
|
||||
}
|
||||
|
||||
private fun readCpuTicksPair(pid: Int, sleepMillis: Long): Pair<Long, Long>? {
|
||||
// "sleep 0.050" — toybox sleep accepts fractional seconds on modern Android.
|
||||
val sleepArg = "0.${"%03d".format(sleepMillis)}"
|
||||
val command = "cat /proc/$pid/stat; sleep $sleepArg; cat /proc/$pid/stat"
|
||||
val output = runAdbOutput(listOf("shell", command))
|
||||
val lines = output.lines().filter { it.isNotBlank() }
|
||||
if (lines.size < 2) return null
|
||||
val first = parseCpuTicks(lines[0]) ?: return null
|
||||
val second = parseCpuTicks(lines[1]) ?: return null
|
||||
return Pair(first, second)
|
||||
}
|
||||
override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }
|
||||
|
||||
private fun parseCpuTicks(statLine: String): Long? {
|
||||
// /proc/<pid>/stat format: pid (comm) state ppid ... utime stime ...
|
||||
// comm is parenthesized and may contain spaces; rsplit on ')' to skip it.
|
||||
val afterComm = statLine.substringAfterLast(')').trim()
|
||||
val fields = afterComm.split(Regex("\\s+"))
|
||||
// After the ')' we are at the "state" field (index 0 in afterComm).
|
||||
// utime is proc(14) = afterComm[11], stime is proc(15) = afterComm[12].
|
||||
if (fields.size < 13) return null
|
||||
val utime = fields[11].toLongOrNull() ?: return null
|
||||
val stime = fields[12].toLongOrNull() ?: return null
|
||||
return utime + stime
|
||||
}
|
||||
override fun metrics(bundleId: String) = readProcMetrics(serial, bundleId)
|
||||
}
|
||||
|
||||
private fun cpuPercentFromDelta(deltaTicks: Long, deltaWallNanos: Long): Double {
|
||||
if (deltaTicks < 0 || deltaWallNanos <= 0) return 0.0
|
||||
val tickHz = clockTicksPerSecond.coerceAtLeast(1L)
|
||||
val deltaCpuNanos = deltaTicks * 1_000_000_000.0 / tickHz
|
||||
return (deltaCpuNanos / deltaWallNanos) * 100.0
|
||||
private fun buildDadb(serial: String?): dadb.Dadb {
|
||||
return if (serial == null) {
|
||||
dadb.Dadb.create("localhost", 5555)
|
||||
} else {
|
||||
dadb.Dadb.create(serial.substringBefore(":"), serial.substringAfter(":").toIntOrNull() ?: 5555)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveClockTicksPerSecond(): Long {
|
||||
val output = runAdbOutput(listOf("shell", "getconf", "CLK_TCK")).trim()
|
||||
return output.toLongOrNull() ?: 100L
|
||||
}
|
||||
private fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntArray? {
|
||||
val colon = selector.indexOf(':')
|
||||
if (colon < 0) return null
|
||||
val kind = selector.substring(0, colon)
|
||||
val value = selector.substring(colon + 1)
|
||||
return findBoundsInTree(root, kind, value)
|
||||
}
|
||||
|
||||
private fun sampleProcessMemory(pid: Int): Pair<Long, Long> {
|
||||
val status = runAdbOutput(listOf("shell", "cat", "/proc/$pid/status"))
|
||||
var rssKb = 0L
|
||||
var vmSizeKb = 0L
|
||||
for (raw in status.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.startsWith("VmRSS:") -> rssKb = parseKb(line) ?: rssKb
|
||||
line.startsWith("VmSize:") -> vmSizeKb = parseKb(line) ?: vmSizeKb
|
||||
private fun findBoundsInTree(node: maestro.TreeNode, kind: String, value: String): IntArray? {
|
||||
val attrs = node.attributes
|
||||
val matches = when (kind) {
|
||||
"id" -> attrs["resource-id"]?.let { it == value || it.endsWith(":id/$value") } == true
|
||||
"text" -> attrs["text"] == value
|
||||
"desc" -> attrs["content-desc"] == value
|
||||
"descPrefix" -> attrs["content-desc"]?.startsWith(value) == true
|
||||
else -> false
|
||||
}
|
||||
if (matches) {
|
||||
attrs["bounds"]?.let { b -> parseBounds(b)?.let { return it } }
|
||||
}
|
||||
return Pair(rssKb * 1024L, vmSizeKb * 1024L)
|
||||
for (child in node.children) {
|
||||
findBoundsInTree(child, kind, value)?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseBounds(s: String): IntArray? {
|
||||
val pattern = Regex("^\\[(-?\\d+),(-?\\d+),(-?\\d+),(-?\\d+)\\]$")
|
||||
val m = pattern.matchEntire(s) ?: return null
|
||||
return IntArray(4) { m.groupValues[it + 1].toInt() }
|
||||
}
|
||||
|
||||
private fun pngWidth(bytes: ByteArray): Int {
|
||||
if (bytes.size < 24) return 0
|
||||
return (bytes[16].toInt() and 0xFF shl 24) or (bytes[17].toInt() and 0xFF shl 16) or
|
||||
(bytes[18].toInt() and 0xFF shl 8) or (bytes[19].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
private fun parseKb(line: String): Long? {
|
||||
val parts = line.split(Regex("\\s+"))
|
||||
if (parts.size < 2) return null
|
||||
return parts[1].toLongOrNull()
|
||||
}
|
||||
private fun pngHeight(bytes: ByteArray): Int {
|
||||
if (bytes.size < 24) return 0
|
||||
return (bytes[20].toInt() and 0xFF shl 24) or (bytes[21].toInt() and 0xFF shl 16) or
|
||||
(bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
private fun runAdbOutput(arguments: List<String>): String {
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(false).start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
output
|
||||
} catch (cause: Exception) {
|
||||
""
|
||||
}
|
||||
private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? {
|
||||
return when (adbKeyCode) {
|
||||
"KEYCODE_BACK" -> maestro.KeyCode.BACK
|
||||
"KEYCODE_HOME" -> maestro.KeyCode.HOME
|
||||
"KEYCODE_ENTER" -> maestro.KeyCode.ENTER
|
||||
"KEYCODE_TAB" -> maestro.KeyCode.TAB
|
||||
"KEYCODE_DPAD_UP" -> maestro.KeyCode.REMOTE_UP
|
||||
"KEYCODE_DPAD_DOWN" -> maestro.KeyCode.REMOTE_DOWN
|
||||
"KEYCODE_DPAD_LEFT" -> maestro.KeyCode.REMOTE_LEFT
|
||||
"KEYCODE_DPAD_RIGHT" -> maestro.KeyCode.REMOTE_RIGHT
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -23,15 +23,14 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class DriverService(
|
||||
private val platform: String = "android",
|
||||
private val serial: String? = null,
|
||||
private val backend: DriverBackend = StubDriverBackend(platform),
|
||||
private val backend: DriverBackend,
|
||||
) : DriverGrpc.DriverImplBase() {
|
||||
|
||||
private val launchedBundleId = AtomicReference<String?>(null)
|
||||
|
||||
override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) {
|
||||
runRpc(responseObserver) {
|
||||
backend.launch(request.bundleId, request.launcherActivity, request.clearState)
|
||||
backend.launch(request.bundleId, request.clearState)
|
||||
launchedBundleId.set(request.bundleId)
|
||||
Empty.getDefaultInstance()
|
||||
}
|
||||
|
||||
@@ -45,7 +45,12 @@ fun main(arguments: Array<String>) {
|
||||
if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else null
|
||||
}
|
||||
|
||||
val service = DriverService(platform = platform, serial = serial)
|
||||
val backend: DriverBackend = if (platform == "android") {
|
||||
MaestroDriverBackend(serial)
|
||||
} else {
|
||||
StubDriverBackend(platform)
|
||||
}
|
||||
val service = DriverService(platform = platform, backend = backend)
|
||||
val server = SidecarServer(port, service)
|
||||
val boundPort = server.start()
|
||||
println("sanderling-sidecar listening on 127.0.0.1:$boundPort platform=$platform")
|
||||
|
||||
@@ -26,7 +26,7 @@ class DriverServiceTest {
|
||||
|
||||
private fun newClient(backend: DriverBackend): DriverGrpc.DriverBlockingStub {
|
||||
val serverName = InProcessServerBuilder.generateName()
|
||||
val service = DriverService(platform = "android", serial = null, backend = backend)
|
||||
val service = DriverService(platform = "android", backend = backend)
|
||||
grpcCleanup.register(
|
||||
InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start()
|
||||
)
|
||||
@@ -43,7 +43,6 @@ class DriverServiceTest {
|
||||
client.launch(
|
||||
LaunchRequest.newBuilder()
|
||||
.setBundleId("com.example")
|
||||
.setLauncherActivity(".MainActivity")
|
||||
.setClearState(true)
|
||||
.build(),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import kotlin.test.assertTrue
|
||||
class SidecarServerTest {
|
||||
@Test
|
||||
fun startBindsEphemeralPortAndStopReleasesIt() {
|
||||
val server = SidecarServer(port = 0, service = DriverService())
|
||||
val server = SidecarServer(port = 0, service = DriverService(backend = StubDriverBackend("android")))
|
||||
val boundPort = server.start()
|
||||
try {
|
||||
assertTrue(boundPort > 0, "expected ephemeral port, got $boundPort")
|
||||
|
||||
Reference in new issue
Block a user