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
This commit is contained in:
pj authored and GitHub committed 2026-04-23 17:35:31 +07:00
1 parent 007dcddd69
commit 97154cf580
20 files changed
+323 -90

No files matched your search

+29
View File
@@ -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
@@ -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,
@@ -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
}
@@ -15,12 +15,24 @@ object SanderlingIos {
internal val extractors = mutableMapOf<String, () -> 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 <value> (Maestro simctl launch).
@Suppress("UNCHECKED_CAST")
val args = NSProcessInfo.processInfo.arguments as? List<String> ?: 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
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+6 -12
View File
@@ -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) {
+89
View File
@@ -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)
}
}
+9 -2
View File
@@ -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())
+22 -17
View File
@@ -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)
}
}
+52 -38
View File
@@ -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,
},
+1
View File
@@ -30,6 +30,7 @@ message Empty {}
message LaunchRequest {
string bundle_id = 1;
bool clear_state = 2;
map<string, string> env = 3;
}
message Point {
+2
View File
@@ -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")
@@ -1,7 +1,7 @@
package dev.sanderling.sidecar
interface DriverBackend {
fun launch(bundleId: String, clearState: Boolean)
fun launch(bundleId: String, clearState: Boolean, env: Map<String, String> = 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<String, String>) {
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<String, String>) {
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<String, String>) {
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<ByteArray, Int, Int> {
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<LogLine> = 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
@@ -30,7 +30,7 @@ class DriverService(
override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) {
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())
}
}
@@ -44,11 +44,14 @@ fun main(arguments: Array<String>) {
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)