From 97154cf580c989cb4c1f930e31773d74eea9f67a Mon Sep 17 00:00:00 2001 From: pjay Date: Thu, 23 Apr 2026 17:35:31 +0700 Subject: [PATCH] feat(ios): launch via XCTest with env vars for hierarchy/tap access (#39) * feat(proto): add env map to LaunchRequest * feat(driver): add env param to Launch interface and all implementations * feat(sidecar): add IosDriverBackend using Maestro IOSDriver + env pass-through * feat(testrun): launch iOS app via XCTest with env vars instead of simctl * fix(ios): replace LaunchApp with BootedUDID; simctl launch moved to XCTest path * test(cli): add tests for ios platform flag and ios-device flag parsing * fix(sdk-ios): check semaphore wait result; resolve port from args and env; register extractors before start --- cmd/sanderling/main_test.go | 29 ++++++ .../kotlin/app/folio/MainViewController.kt | 2 +- .../kotlin/app/folio/sanderling/IosPauser.kt | 3 +- .../app/folio/sanderling/SanderlingIos.kt | 18 +++- internal/driver/chrome/driver.go | 2 +- internal/driver/driver.go | 2 +- internal/driver/mock/mock.go | 2 +- internal/driver/mock/mock_test.go | 2 +- internal/driver/sidecar/client.go | 3 +- internal/driver/sidecar/client_test.go | 2 +- internal/ios/ios.go | 18 ++-- internal/ios/ios_test.go | 89 ++++++++++++++++++ internal/testrun/driver.go | 11 ++- internal/testrun/testrun.go | 39 ++++---- proto/driverpb/driver.pb.go | 90 +++++++++++-------- proto/driverpb/driver.proto | 1 + sidecar/build.gradle.kts | 2 + .../dev/sanderling/sidecar/DriverBackend.kt | 82 ++++++++++++++++- .../dev/sanderling/sidecar/DriverService.kt | 5 +- .../kotlin/dev/sanderling/sidecar/Main.kt | 11 ++- 20 files changed, 323 insertions(+), 90 deletions(-) create mode 100644 internal/ios/ios_test.go diff --git a/cmd/sanderling/main_test.go b/cmd/sanderling/main_test.go index ada500c..470f180 100644 --- a/cmd/sanderling/main_test.go +++ b/cmd/sanderling/main_test.go @@ -146,6 +146,35 @@ func TestRun_Doctor(t *testing.T) { } } +func TestParseTestArgs_AcceptsIosPlatform(t *testing.T) { + options, err := parseTestArgs([]string{ + "--spec", "s.ts", + "--bundle-id", "com.example.app", + "--platform", "ios", + }, io.Discard) + if err != nil { + t.Fatalf("unexpected error for ios platform: %v", err) + } + if options.platform != "ios" { + t.Errorf("expected platform=ios, got %q", options.platform) + } +} + +func TestParseTestArgs_IosDeviceFlag(t *testing.T) { + options, err := parseTestArgs([]string{ + "--spec", "s.ts", + "--bundle-id", "com.example.app", + "--platform", "ios", + "--ios-device", "iPhone 15 Pro", + }, io.Discard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if options.iosDevice != "iPhone 15 Pro" { + t.Errorf("expected iosDevice=iPhone 15 Pro, got %q", options.iosDevice) + } +} + func TestRun_TestSubcommand_PipelineErrors(t *testing.T) { // Without a real spec, a real device, or a bootable AVD the pipeline // must surface a specific error rather than panicking — proves the flag diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt index 1152cea..04a86e2 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt @@ -29,11 +29,11 @@ private val backGestureTarget = BackGestureTarget() @OptIn(ExperimentalForeignApi::class) fun MainViewController(): UIViewController { Repository.init() - app.folio.sanderling.SanderlingIos.start() app.folio.sanderling.AuthSnapshots app.folio.sanderling.AccountSnapshots app.folio.sanderling.LedgerSnapshots app.folio.sanderling.NavigationSnapshots + app.folio.sanderling.SanderlingIos.start() val vc = ComposeUIViewController { App() } val gesture = UIScreenEdgePanGestureRecognizer( target = backGestureTarget, diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt index e680427..e9220cd 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt @@ -15,7 +15,8 @@ internal object IosPauser { dispatch_semaphore_signal(snapshotReady) dispatch_semaphore_wait(resumeGate, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L)) } - dispatch_semaphore_wait(snapshotReady, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L)) + val waited = dispatch_semaphore_wait(snapshotReady, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L)) + check(waited == 0L) { "snapshot timed out: main thread did not signal within 5s" } return capturedSnapshot } diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt index c17fe57..f14dd0f 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt @@ -15,12 +15,24 @@ object SanderlingIos { internal val extractors = mutableMapOf Any?>() fun start() { - val env = NSProcessInfo.processInfo.environment - val portStr = env["SANDERLING_PORT"] as? String ?: return - val port = portStr.toIntOrNull() ?: return + val port = resolvePort() ?: return IosAgent.start("127.0.0.1", port) } + private fun resolvePort(): Int? { + // Env var set via SIMCTL_CHILD_SANDERLING_PORT (simctl direct launch). + (NSProcessInfo.processInfo.environment["SANDERLING_PORT"] as? String) + ?.toIntOrNull()?.let { return it } + // Launch argument -SANDERLING_PORT (Maestro simctl launch). + @Suppress("UNCHECKED_CAST") + val args = NSProcessInfo.processInfo.arguments as? List ?: return null + val idx = args.indexOfFirst { it == "-SANDERLING_PORT" } + if (idx >= 0 && idx + 1 < args.size) { + return args[idx + 1].toIntOrNull() + } + return null + } + fun extract(name: String, block: () -> Any?) { extractors[name] = block } diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index d96a8e8..d119bcd 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -77,7 +77,7 @@ func New() *Driver { return d } -func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool) error { +func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, _ map[string]string) error { if clearState { if err := chromedp.Run(d.tabCtx, network.ClearBrowserCookies()); err != nil { return fmt.Errorf("clear cookies: %w", err) diff --git a/internal/driver/driver.go b/internal/driver/driver.go index d67603a..974ca09 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -10,7 +10,7 @@ import ( // lives under driver/sidecar; the web implementation under driver/chrome; // tests use driver/mock. type DeviceDriver interface { - Launch(ctx context.Context, bundleID string, clearState bool) error + Launch(ctx context.Context, bundleID string, clearState bool, env map[string]string) error Terminate(ctx context.Context) error Tap(ctx context.Context, x, y int) error diff --git a/internal/driver/mock/mock.go b/internal/driver/mock/mock.go index c2c45a8..07adadc 100644 --- a/internal/driver/mock/mock.go +++ b/internal/driver/mock/mock.go @@ -88,7 +88,7 @@ func (d *Driver) failure(kind ActionKind) error { return d.Failures[kind] } -func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool) error { +func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool, _ map[string]string) error { if err := d.failure(ActionLaunch); err != nil { return err } diff --git a/internal/driver/mock/mock_test.go b/internal/driver/mock/mock_test.go index d16c791..d56da5c 100644 --- a/internal/driver/mock/mock_test.go +++ b/internal/driver/mock/mock_test.go @@ -29,7 +29,7 @@ func TestRecordsAllActionsInOrder(t *testing.T) { mock := New() ctx := context.Background() - if err := mock.Launch(ctx, "com.example", true); err != nil { + if err := mock.Launch(ctx, "com.example", true, nil); err != nil { t.Fatal(err) } if err := mock.Tap(ctx, 100, 200); err != nil { diff --git a/internal/driver/sidecar/client.go b/internal/driver/sidecar/client.go index a002161..5dc2a47 100644 --- a/internal/driver/sidecar/client.go +++ b/internal/driver/sidecar/client.go @@ -48,10 +48,11 @@ func (c *Client) WaitForHealth(ctx context.Context, pollInterval time.Duration) } } -func (c *Client) Launch(ctx context.Context, bundleID string, clearState bool) error { +func (c *Client) Launch(ctx context.Context, bundleID string, clearState bool, env map[string]string) error { _, err := c.stub.Launch(ctx, &driverpb.LaunchRequest{ BundleId: bundleID, ClearState: clearState, + Env: env, }) return err } diff --git a/internal/driver/sidecar/client_test.go b/internal/driver/sidecar/client_test.go index 4ec69a9..00799b7 100644 --- a/internal/driver/sidecar/client_test.go +++ b/internal/driver/sidecar/client_test.go @@ -191,7 +191,7 @@ func TestClient_LaunchAndTerminate(t *testing.T) { client, _ := Dial(state.address) defer client.Close() - if err := client.Launch(context.Background(), "com.example", true); err != nil { + if err := client.Launch(context.Background(), "com.example", true, nil); err != nil { t.Fatal(err) } if state.fake.launchedBundleID != "com.example" || !state.fake.clearState { diff --git a/internal/ios/ios.go b/internal/ios/ios.go index 36522ab..c195b20 100644 --- a/internal/ios/ios.go +++ b/internal/ios/ios.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "io" - "os" "os/exec" "strings" "time" @@ -55,18 +54,13 @@ func EnsureSimulator(ctx context.Context, deviceName string, stdout io.Writer) e return nil } -func LaunchApp(ctx context.Context, bundleID string, env map[string]string) error { - args := []string{"simctl", "launch", "--terminate-running-process", "booted", bundleID} - cmd := exec.CommandContext(ctx, "xcrun", args...) - cmd.Env = os.Environ() - for k, v := range env { - cmd.Env = append(cmd.Env, "SIMCTL_CHILD_"+k+"="+v) +// BootedUDID returns the UDID of the currently booted iOS simulator, or "" if none is booted. +func BootedUDID(ctx context.Context) string { + d, _ := bootedSimulator(ctx) + if d == nil { + return "" } - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("launch %s: %w\n%s", bundleID, err, strings.TrimSpace(string(out))) - } - return nil + return d.UDID } func bootedSimulator(ctx context.Context) (*simDevice, error) { diff --git a/internal/ios/ios_test.go b/internal/ios/ios_test.go new file mode 100644 index 0000000..d957fe6 --- /dev/null +++ b/internal/ios/ios_test.go @@ -0,0 +1,89 @@ +package ios + +import ( + "context" + "testing" +) + +func TestPickSimulator_ByName(t *testing.T) { + available := []simDevice{ + {UDID: "aaa", Name: "iPad Pro", IsAvailable: true}, + {UDID: "bbb", Name: "iPhone 15", IsAvailable: true}, + } + got, err := pickSimulator("iPhone 15", available) + if err != nil { + t.Fatal(err) + } + if got.UDID != "bbb" { + t.Errorf("got %q, want bbb", got.UDID) + } +} + +func TestPickSimulator_ByUDID(t *testing.T) { + available := []simDevice{ + {UDID: "aaa", Name: "iPad Pro", IsAvailable: true}, + {UDID: "bbb", Name: "iPhone 14", IsAvailable: true}, + } + got, err := pickSimulator("aaa", available) + if err != nil { + t.Fatal(err) + } + if got.Name != "iPad Pro" { + t.Errorf("got %q, want iPad Pro", got.Name) + } +} + +func TestPickSimulator_UnknownName(t *testing.T) { + available := []simDevice{ + {UDID: "aaa", Name: "iPad Pro", IsAvailable: true}, + } + _, err := pickSimulator("Pixel 7", available) + if err == nil { + t.Fatal("expected error for unknown simulator name") + } +} + +func TestPickSimulator_EmptyName_PrefersIPhone(t *testing.T) { + available := []simDevice{ + {UDID: "aaa", Name: "iPad mini", IsAvailable: true}, + {UDID: "bbb", Name: "iPhone 16", IsAvailable: true}, + {UDID: "ccc", Name: "Apple Watch", IsAvailable: true}, + } + got, err := pickSimulator("", available) + if err != nil { + t.Fatal(err) + } + if got.UDID != "bbb" { + t.Errorf("got %q, want bbb (iPhone)", got.UDID) + } +} + +func TestPickSimulator_EmptyName_FallsBackToFirst(t *testing.T) { + available := []simDevice{ + {UDID: "aaa", Name: "iPad Air", IsAvailable: true}, + {UDID: "bbb", Name: "Apple TV", IsAvailable: true}, + } + got, err := pickSimulator("", available) + if err != nil { + t.Fatal(err) + } + if got.UDID != "aaa" { + t.Errorf("got %q, want aaa (first available)", got.UDID) + } +} + +func TestPickSimulator_EmptyList(t *testing.T) { + _, err := pickSimulator("", nil) + if err == nil { + t.Fatal("expected error for empty simulator list") + } +} + +func TestBootedUDID_CanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + udid := BootedUDID(ctx) + if udid != "" { + t.Errorf("expected empty UDID on canceled context, got %q", udid) + } +} diff --git a/internal/testrun/driver.go b/internal/testrun/driver.go index ec2e1d7..e7336d3 100644 --- a/internal/testrun/driver.go +++ b/internal/testrun/driver.go @@ -12,6 +12,7 @@ import ( "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/ios" "github.com/priyanshujain/sanderling/internal/sidecar" ) @@ -35,10 +36,16 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver if err != nil { return nil, nil, err } - sidecarCommand := exec.CommandContext(ctx, "java", "-jar", jarPath, + sidecarArgs := []string{"-jar", jarPath, "--port", strconv.Itoa(sidecarPort), "--platform", options.Platform, - ) + } + if options.Platform == "ios" { + if udid := ios.BootedUDID(ctx); udid != "" { + sidecarArgs = append(sidecarArgs, "--udid", udid) + } + } + sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...) sidecarCommand.Stdout = stdout sidecarCommand.Stderr = stdout sidecarCommand.Env = android.EnvWithAndroidPlatformTools(os.Environ()) diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go index 3308b32..7e6a16a 100644 --- a/internal/testrun/testrun.go +++ b/internal/testrun/testrun.go @@ -110,7 +110,7 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { acceptChannel <- acceptResult{conn: conn, err: acceptErr} }() - if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil { + if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil { return fmt.Errorf("launch app: %w", err) } fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.BundleID, sdkAcceptTimeout.Seconds()) @@ -133,35 +133,40 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { defer listener.Close() agentPort := listener.Addr().(*net.TCPAddr).Port - // Launch app via simctl with SANDERLING_PORT so the SDK can connect. - if err := ios.LaunchApp(ctx, options.BundleID, map[string]string{ + agentServer := agent.NewServer(listener) + + type acceptResult struct { + conn *agent.Conn + err error + } + acceptChannel := make(chan acceptResult, 1) + go func() { + acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout) + defer cancel() + conn, acceptErr := agentServer.Accept(acceptCtx) + acceptChannel <- acceptResult{conn: conn, err: acceptErr} + }() + + // Launch app via XCTest with SANDERLING_PORT so the SDK can connect. + if err := activeDriver.Launch(ctx, options.BundleID, false, map[string]string{ "SANDERLING_PORT": strconv.Itoa(agentPort), }); err != nil { return fmt.Errorf("launch app: %w", err) } fmt.Fprintf(stdout, "iOS app launched with SANDERLING_PORT=%d; waiting for SDK (%.0fs timeout)\n", agentPort, sdkAcceptTimeout.Seconds()) - agentServer := agent.NewServer(listener) - acceptCtx, acceptCancel := context.WithTimeout(ctx, sdkAcceptTimeout) - conn, acceptErr := agentServer.Accept(acceptCtx) - acceptCancel() - if acceptErr != nil { - return fmt.Errorf("accept SDK: %w", acceptErr) + result := <-acceptChannel + if result.err != nil { + return fmt.Errorf("accept SDK: %w", result.err) } - connection = conn + connection = result.conn defer connection.Close() hello := connection.Hello() fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version) - // Initialize Maestro XCTest session after the app is running. - // clearState=false avoids relaunching the already-connected app. - if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil { - fmt.Fprintf(stdout, "warn: maestro launch: %v\n", err) - } - case "web": fmt.Fprintln(stdout, "web mode: skipping SDK") - if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil { + if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil { return fmt.Errorf("launch app: %w", err) } } diff --git a/proto/driverpb/driver.pb.go b/proto/driverpb/driver.pb.go index a01e920..a1a07f4 100644 --- a/proto/driverpb/driver.pb.go +++ b/proto/driverpb/driver.pb.go @@ -61,6 +61,7 @@ type LaunchRequest struct { state protoimpl.MessageState `protogen:"open.v1"` BundleId string `protobuf:"bytes,1,opt,name=bundle_id,json=bundleId,proto3" json:"bundle_id,omitempty"` ClearState bool `protobuf:"varint,2,opt,name=clear_state,json=clearState,proto3" json:"clear_state,omitempty"` + Env map[string]string `protobuf:"bytes,3,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -109,6 +110,13 @@ func (x *LaunchRequest) GetClearState() bool { return false } +func (x *LaunchRequest) GetEnv() map[string]string { + if x != nil { + return x.Env + } + return nil +} + type Point struct { state protoimpl.MessageState `protogen:"open.v1"` X int32 `protobuf:"varint,1,opt,name=x,proto3" json:"x,omitempty"` @@ -839,11 +847,15 @@ var File_driverpb_driver_proto protoreflect.FileDescriptor const file_driverpb_driver_proto_rawDesc = "" + "\n" + "\x15driverpb/driver.proto\x12\x14sanderling.driver.v1\"\a\n" + - "\x05Empty\"M\n" + + "\x05Empty\"\xc5\x01\n" + "\rLaunchRequest\x12\x1b\n" + "\tbundle_id\x18\x01 \x01(\tR\bbundleId\x12\x1f\n" + "\vclear_state\x18\x02 \x01(\bR\n" + - "clearState\"#\n" + + "clearState\x12>\n" + + "\x03env\x18\x03 \x03(\v2,.sanderling.driver.v1.LaunchRequest.EnvEntryR\x03env\x1a6\n" + + "\bEnvEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"#\n" + "\x05Point\x12\f\n" + "\x01x\x18\x01 \x01(\x05R\x01x\x12\f\n" + "\x01y\x18\x02 \x01(\x05R\x01y\"\x1c\n" + @@ -919,7 +931,7 @@ func file_driverpb_driver_proto_rawDescGZIP() []byte { return file_driverpb_driver_proto_rawDescData } -var file_driverpb_driver_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_driverpb_driver_proto_msgTypes = make([]protoimpl.MessageInfo, 17) var file_driverpb_driver_proto_goTypes = []any{ (*Empty)(nil), // 0: sanderling.driver.v1.Empty (*LaunchRequest)(nil), // 1: sanderling.driver.v1.LaunchRequest @@ -937,42 +949,44 @@ var file_driverpb_driver_proto_goTypes = []any{ (*LogEntry)(nil), // 13: sanderling.driver.v1.LogEntry (*MetricsRequest)(nil), // 14: sanderling.driver.v1.MetricsRequest (*MetricsResponse)(nil), // 15: sanderling.driver.v1.MetricsResponse + nil, // 16: sanderling.driver.v1.LaunchRequest.EnvEntry } var file_driverpb_driver_proto_depIdxs = []int32{ - 2, // 0: sanderling.driver.v1.SwipeRequest.from:type_name -> sanderling.driver.v1.Point - 2, // 1: sanderling.driver.v1.SwipeRequest.to:type_name -> sanderling.driver.v1.Point - 13, // 2: sanderling.driver.v1.LogEntries.entries:type_name -> sanderling.driver.v1.LogEntry - 1, // 3: sanderling.driver.v1.Driver.Launch:input_type -> sanderling.driver.v1.LaunchRequest - 0, // 4: sanderling.driver.v1.Driver.Terminate:input_type -> sanderling.driver.v1.Empty - 2, // 5: sanderling.driver.v1.Driver.Tap:input_type -> sanderling.driver.v1.Point - 4, // 6: sanderling.driver.v1.Driver.TapSelector:input_type -> sanderling.driver.v1.Selector - 3, // 7: sanderling.driver.v1.Driver.InputText:input_type -> sanderling.driver.v1.Text - 9, // 8: sanderling.driver.v1.Driver.Swipe:input_type -> sanderling.driver.v1.SwipeRequest - 10, // 9: sanderling.driver.v1.Driver.PressKey:input_type -> sanderling.driver.v1.PressKeyRequest - 0, // 10: sanderling.driver.v1.Driver.Screenshot:input_type -> sanderling.driver.v1.Empty - 0, // 11: sanderling.driver.v1.Driver.Hierarchy:input_type -> sanderling.driver.v1.Empty - 11, // 12: sanderling.driver.v1.Driver.RecentLogs:input_type -> sanderling.driver.v1.RecentLogsRequest - 7, // 13: sanderling.driver.v1.Driver.WaitForIdle:input_type -> sanderling.driver.v1.Duration - 0, // 14: sanderling.driver.v1.Driver.Health:input_type -> sanderling.driver.v1.Empty - 14, // 15: sanderling.driver.v1.Driver.Metrics:input_type -> sanderling.driver.v1.MetricsRequest - 0, // 16: sanderling.driver.v1.Driver.Launch:output_type -> sanderling.driver.v1.Empty - 0, // 17: sanderling.driver.v1.Driver.Terminate:output_type -> sanderling.driver.v1.Empty - 0, // 18: sanderling.driver.v1.Driver.Tap:output_type -> sanderling.driver.v1.Empty - 0, // 19: sanderling.driver.v1.Driver.TapSelector:output_type -> sanderling.driver.v1.Empty - 0, // 20: sanderling.driver.v1.Driver.InputText:output_type -> sanderling.driver.v1.Empty - 0, // 21: sanderling.driver.v1.Driver.Swipe:output_type -> sanderling.driver.v1.Empty - 0, // 22: sanderling.driver.v1.Driver.PressKey:output_type -> sanderling.driver.v1.Empty - 5, // 23: sanderling.driver.v1.Driver.Screenshot:output_type -> sanderling.driver.v1.Image - 6, // 24: sanderling.driver.v1.Driver.Hierarchy:output_type -> sanderling.driver.v1.HierarchyJSON - 12, // 25: sanderling.driver.v1.Driver.RecentLogs:output_type -> sanderling.driver.v1.LogEntries - 0, // 26: sanderling.driver.v1.Driver.WaitForIdle:output_type -> sanderling.driver.v1.Empty - 8, // 27: sanderling.driver.v1.Driver.Health:output_type -> sanderling.driver.v1.HealthStatus - 15, // 28: sanderling.driver.v1.Driver.Metrics:output_type -> sanderling.driver.v1.MetricsResponse - 16, // [16:29] is the sub-list for method output_type - 3, // [3:16] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 16, // 0: sanderling.driver.v1.LaunchRequest.env:type_name -> sanderling.driver.v1.LaunchRequest.EnvEntry + 2, // 1: sanderling.driver.v1.SwipeRequest.from:type_name -> sanderling.driver.v1.Point + 2, // 2: sanderling.driver.v1.SwipeRequest.to:type_name -> sanderling.driver.v1.Point + 13, // 3: sanderling.driver.v1.LogEntries.entries:type_name -> sanderling.driver.v1.LogEntry + 1, // 4: sanderling.driver.v1.Driver.Launch:input_type -> sanderling.driver.v1.LaunchRequest + 0, // 5: sanderling.driver.v1.Driver.Terminate:input_type -> sanderling.driver.v1.Empty + 2, // 6: sanderling.driver.v1.Driver.Tap:input_type -> sanderling.driver.v1.Point + 4, // 7: sanderling.driver.v1.Driver.TapSelector:input_type -> sanderling.driver.v1.Selector + 3, // 8: sanderling.driver.v1.Driver.InputText:input_type -> sanderling.driver.v1.Text + 9, // 9: sanderling.driver.v1.Driver.Swipe:input_type -> sanderling.driver.v1.SwipeRequest + 10, // 10: sanderling.driver.v1.Driver.PressKey:input_type -> sanderling.driver.v1.PressKeyRequest + 0, // 11: sanderling.driver.v1.Driver.Screenshot:input_type -> sanderling.driver.v1.Empty + 0, // 12: sanderling.driver.v1.Driver.Hierarchy:input_type -> sanderling.driver.v1.Empty + 11, // 13: sanderling.driver.v1.Driver.RecentLogs:input_type -> sanderling.driver.v1.RecentLogsRequest + 7, // 14: sanderling.driver.v1.Driver.WaitForIdle:input_type -> sanderling.driver.v1.Duration + 0, // 15: sanderling.driver.v1.Driver.Health:input_type -> sanderling.driver.v1.Empty + 14, // 16: sanderling.driver.v1.Driver.Metrics:input_type -> sanderling.driver.v1.MetricsRequest + 0, // 17: sanderling.driver.v1.Driver.Launch:output_type -> sanderling.driver.v1.Empty + 0, // 18: sanderling.driver.v1.Driver.Terminate:output_type -> sanderling.driver.v1.Empty + 0, // 19: sanderling.driver.v1.Driver.Tap:output_type -> sanderling.driver.v1.Empty + 0, // 20: sanderling.driver.v1.Driver.TapSelector:output_type -> sanderling.driver.v1.Empty + 0, // 21: sanderling.driver.v1.Driver.InputText:output_type -> sanderling.driver.v1.Empty + 0, // 22: sanderling.driver.v1.Driver.Swipe:output_type -> sanderling.driver.v1.Empty + 0, // 23: sanderling.driver.v1.Driver.PressKey:output_type -> sanderling.driver.v1.Empty + 5, // 24: sanderling.driver.v1.Driver.Screenshot:output_type -> sanderling.driver.v1.Image + 6, // 25: sanderling.driver.v1.Driver.Hierarchy:output_type -> sanderling.driver.v1.HierarchyJSON + 12, // 26: sanderling.driver.v1.Driver.RecentLogs:output_type -> sanderling.driver.v1.LogEntries + 0, // 27: sanderling.driver.v1.Driver.WaitForIdle:output_type -> sanderling.driver.v1.Empty + 8, // 28: sanderling.driver.v1.Driver.Health:output_type -> sanderling.driver.v1.HealthStatus + 15, // 29: sanderling.driver.v1.Driver.Metrics:output_type -> sanderling.driver.v1.MetricsResponse + 17, // [17:30] is the sub-list for method output_type + 4, // [4:17] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_driverpb_driver_proto_init() } @@ -986,7 +1000,7 @@ func file_driverpb_driver_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_driverpb_driver_proto_rawDesc), len(file_driverpb_driver_proto_rawDesc)), NumEnums: 0, - NumMessages: 16, + NumMessages: 17, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/driverpb/driver.proto b/proto/driverpb/driver.proto index ccbb255..8fae2f9 100644 --- a/proto/driverpb/driver.proto +++ b/proto/driverpb/driver.proto @@ -30,6 +30,7 @@ message Empty {} message LaunchRequest { string bundle_id = 1; bool clear_state = 2; + map env = 3; } message Point { diff --git a/sidecar/build.gradle.kts b/sidecar/build.gradle.kts index 698eeaa..d39585d 100644 --- a/sidecar/build.gradle.kts +++ b/sidecar/build.gradle.kts @@ -26,6 +26,8 @@ val maestroVersion = "1.40.0" dependencies { implementation("dev.mobile:maestro-client:$maestroVersion") implementation("dev.mobile:maestro-utils:$maestroVersion") + implementation("dev.mobile:maestro-ios:$maestroVersion") + implementation("dev.mobile:maestro-ios-driver:$maestroVersion") implementation("io.grpc:grpc-netty-shaded:$grpcVersion") implementation("io.grpc:grpc-protobuf:$grpcVersion") diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index 62a0a21..be84c3b 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -1,7 +1,7 @@ package dev.sanderling.sidecar interface DriverBackend { - fun launch(bundleId: String, clearState: Boolean) + fun launch(bundleId: String, clearState: Boolean, env: Map = emptyMap()) fun terminate(bundleId: String) fun tap(x: Int, y: Int) fun tapSelector(selector: String) @@ -131,7 +131,7 @@ class StubDriverBackend(private val platform: String) : DriverBackend { @Volatile var lastInputText: String? = null private set - override fun launch(bundleId: String, clearState: Boolean) { + override fun launch(bundleId: String, clearState: Boolean, env: Map) { launchCount++ lastBundleId = bundleId if (clearState) { @@ -386,9 +386,9 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend { driver.open() } - override fun launch(bundleId: String, clearState: Boolean) { + override fun launch(bundleId: String, clearState: Boolean, env: Map) { if (clearState) driver.clearAppState(bundleId) - driver.launchApp(bundleId, emptyMap(), java.util.UUID.randomUUID()) + driver.launchApp(bundleId, env, java.util.UUID.randomUUID()) } override fun terminate(bundleId: String) = driver.stopApp(bundleId) @@ -486,6 +486,80 @@ private fun pngHeight(bytes: ByteArray): Int { (bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF) } +class IosDriverBackend(private val udid: String) : DriverBackend { + private val driver: maestro.drivers.IOSDriver + + init { + val httpClient = xcuitest.api.OkHttpClientInstance.get() + val metrics = maestro.utils.NoOpMetrics() + val wdaPort = maestro.utils.SocketUtils.nextFreePort(22000, 23000) + val installer = xcuitest.installer.LocalXCTestInstaller( + udid, + "localhost", + false, + wdaPort, + metrics, + httpClient, + false, + false, + ) + val xcTestDriverClient = xcuitest.XCTestDriverClient(installer, httpClient, false) + val xcTestDevice = ios.xctest.XCTestIOSDevice(udid, xcTestDriverClient) { emptySet() } + val simctlDevice = ios.simctl.SimctlIOSDevice(udid) + val device = ios.LocalIOSDevice(udid, xcTestDevice, simctlDevice, maestro.utils.NoopInsights) + driver = maestro.drivers.IOSDriver(device, maestro.utils.NoopInsights, metrics) + driver.open() + } + + override fun launch(bundleId: String, clearState: Boolean, env: Map) { + runCatching { driver.stopApp(bundleId) } + if (clearState) driver.clearAppState(bundleId) + driver.launchApp(bundleId, env, java.util.UUID.randomUUID()) + } + + override fun terminate(bundleId: String) = driver.stopApp(bundleId) + + override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y)) + + override fun tapSelector(selector: String) { + val root = driver.contentDescriptor(false) + val bounds = findBoundsBySelector(root, selector) ?: return + driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2)) + } + + override fun inputText(text: String) = driver.inputText(text) + + override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) = + driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L)) + + override fun pressKey(key: String) { + StubDriverBackend.KEY_MAP[key]?.let { keyCode -> + keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) } + } + } + + override fun screenshot(): Triple { + val buf = okio.Buffer() + driver.takeScreenshot(buf, false) + val bytes = buf.readByteArray() + return Triple(bytes, pngWidth(bytes), pngHeight(bytes)) + } + + override fun hierarchy(): String = + com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() + .writeValueAsString(driver.contentDescriptor(false)) + + override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List = emptyList() + + override fun waitForIdle(durationMillis: Long) { + driver.waitForAppToSettle(null, null, durationMillis.toInt()) + } + + override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false } + + override fun metrics(bundleId: String) = MetricsSample(0.0, 0L, 0L) +} + private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? { return when (adbKeyCode) { "KEYCODE_BACK" -> maestro.KeyCode.BACK diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt index 8a2f19b..30f4989 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt @@ -30,7 +30,7 @@ class DriverService( override fun launch(request: LaunchRequest, responseObserver: StreamObserver) { runRpc(responseObserver) { - backend.launch(request.bundleId, request.clearState) + backend.launch(request.bundleId, request.clearState, request.envMap) launchedBundleId.set(request.bundleId) Empty.getDefaultInstance() } @@ -150,7 +150,8 @@ class DriverService( observer.onNext(block()) observer.onCompleted() } catch (cause: Exception) { - observer.onError(cause) + observer.onError(io.grpc.Status.INTERNAL.withDescription(cause.toString()) + .withCause(cause).asRuntimeException()) } } diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt index 929ac6d..4aeed6c 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt @@ -44,11 +44,14 @@ fun main(arguments: Array) { val serial = arguments.indexOf("--serial").let { index -> if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else null } + val udid = arguments.indexOf("--udid").let { index -> + if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else null + } - val backend: DriverBackend = if (platform == "android") { - MaestroDriverBackend(serial) - } else { - StubDriverBackend(platform) + val backend: DriverBackend = when (platform) { + "android" -> MaestroDriverBackend(serial) + "ios" -> IosDriverBackend(udid ?: error("--udid required for ios platform")) + else -> StubDriverBackend(platform) } val service = DriverService(platform = platform, backend = backend) val server = SidecarServer(port, service)