mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
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:
20 files changed
+323
-90
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ message Empty {}
|
||||
message LaunchRequest {
|
||||
string bundle_id = 1;
|
||||
bool clear_state = 2;
|
||||
map<string, string> env = 3;
|
||||
}
|
||||
|
||||
message Point {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user