mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17: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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user