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