diff --git a/CHANGELOG.md b/CHANGELOG.md index b0eed7f..f56d258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`--persist` flag** (`MAESTRO_PERSIST` env var) — keeps WebDriverAgent running between invocations for faster repeated test runs. On startup, reuses an existing healthy WDA session (leaving the app alive); on exit, leaves WDA running instead of tearing it down. The xcodebuild process is detached into its own process group so it survives `maestro-runner` exiting. + ```bash + # First run: builds and launches WDA normally + maestro-runner --persist --platform ios test flow.yaml + + # Subsequent runs: reuses existing WDA session (~30-60s faster) + maestro-runner --persist --platform ios test flow.yaml + ``` + ## [1.1.0] - 2026-03-25 ### Added diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index 9a63316..324747c 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -103,6 +103,11 @@ var GlobalFlags = []cli.Flag{ Value: 180, Usage: "Device boot timeout in seconds", }, + &cli.BoolFlag{ + Name: "persist", + Usage: "Keep WDA running between invocations. Reuses an existing WDA session on startup and leaves it running on exit.", + EnvVars: []string{"MAESTRO_PERSIST"}, + }, } // Execute runs the CLI. diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go index f8baf8d..a16e377 100644 --- a/pkg/cli/cli_test.go +++ b/pkg/cli/cli_test.go @@ -2234,3 +2234,47 @@ func TestIsSocketInUse_SocketAndPidWithDeadProcess(t *testing.T) { t.Error("expected socket with dead owner PID to not be in use") } } + +// ============================================================ +// Tests for killProcessOnPort +// ============================================================ + +// ============================================================ +// Tests for --persist flag and RunConfig.Persist field +// ============================================================ + +func TestGlobalFlags_PersistFlag(t *testing.T) { + flagNames := make(map[string]bool) + for _, f := range GlobalFlags { + for _, name := range f.Names() { + flagNames[name] = true + } + } + if !flagNames["persist"] { + t.Error("expected --persist flag to be defined in GlobalFlags") + } +} + +func TestRunConfig_PersistField(t *testing.T) { + cfg := &RunConfig{} + if cfg.Persist { + t.Error("expected Persist to default to false") + } + cfg.Persist = true + if !cfg.Persist { + t.Error("expected Persist to be true after setting") + } +} + +func TestKillProcessOnPort_FreePort(t *testing.T) { + port := uint16(49200 + (time.Now().UnixNano() % 100)) + if isPortInUse(port) { + t.Skipf("port %d unexpectedly in use, skipping", port) + } + // Should not panic or error when nothing holds the port + killProcessOnPort(port) + // Port should still be free after the call + if isPortInUse(port) { + t.Errorf("port %d in use after killProcessOnPort on free port", port) + } +} diff --git a/pkg/cli/ios.go b/pkg/cli/ios.go index 57d132e..6551b0c 100644 --- a/pkg/cli/ios.go +++ b/pkg/cli/ios.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os/exec" "path/filepath" "strings" @@ -35,8 +36,18 @@ type iosDeviceInfo struct { func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { udid := getFirstDevice(cfg) + // --persist: before normal device detection, check if a persisted WDA is + // available. findBootedSimulator() skips port-in-use devices to prevent + // conflicts, so we need to find the device ourselves first. + if cfg.Persist && udid == "" { + if reusedUDID, driver, cleanup, ok := tryReusePersistedWDA(cfg); ok { + return driver, cleanup, nil + } else if reusedUDID != "" { + udid = reusedUDID + } + } + if udid == "" { - // Try to find booted simulator or connected physical device printSetupStep("Finding iOS device...") logger.Info("Auto-detecting iOS device (simulator or physical)...") var err error @@ -52,12 +63,56 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { logger.Info("Using specified iOS device: %s", udid) } - // Check if device port is already in use (another instance using this device) + // Check if device port is already in use port := wdadriver.PortFromUDID(udid) - if isPortInUse(port) { + + // --persist with explicit --device: try to reuse via health check. + // For physical devices the in-process port forward died when the previous + // invocation exited, so we re-establish it before checking health. + if cfg.Persist { + var fwd io.Closer + if !isIOSSimulator(udid) { + var fwdErr error + fwd, fwdErr = wdadriver.ResumePortForward(udid, port) + if fwdErr != nil { + logger.Info("--persist: failed to resume port forward for %s: %v", udid, fwdErr) + } + } + + client := wdadriver.NewClient(port) + if client.IsHealthy() { + printSetupSuccess(fmt.Sprintf("Reusing existing WDA on port %d", port)) + d, cl, err := createIOSDriverFromClient(cfg, udid, client, port) + if err != nil { + if fwd != nil { + fwd.Close() + } + return nil, nil, err + } + if fwd != nil { + origCleanup := cl + cl = func() { + origCleanup() + fwd.Close() + } + } + return d, cl, nil + } + + if fwd != nil { + fwd.Close() + } + // WDA is unhealthy — kill the stale process so the fresh WDA can bind the port + if isPortInUse(port) { + logger.Info("--persist: stale WDA on port %d is unhealthy, killing process", port) + killProcessOnPort(port) + } + } + + if isPortInUse(port) && !cfg.Persist { return nil, nil, fmt.Errorf("device %s is in use (port %d already bound)\n"+ "Another maestro-runner instance may be using this device.\n"+ - "Hint: Wait for it to finish or use a different device with --device ", udid, port) + "Hint: Wait for it to finish, use --persist to reuse WDA, or use a different device with --device ", udid, port) } // 0. Detect device type (simulator vs physical) @@ -98,6 +153,9 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { printSetupStep("Building WDA...") logger.Info("Building WDA for device %s (team ID: %s)", udid, cfg.TeamID) runner := wdadriver.NewRunner(udid, cfg.TeamID) + if cfg.Persist { + runner.SetPersist(true) + } ctx := context.Background() if err := runner.Build(ctx); err != nil { @@ -157,8 +215,12 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { wdaDrv := wdadriver.NewDriver(client, platformInfo, udid) wdaDrv.SetAppFile(cfg.AppFile) - // Cleanup function + // Cleanup function — with --persist, leave WDA running cleanup := func() { + if cfg.Persist { + logger.Info("--persist: leaving WDA running on port %d for reuse", runner.Port()) + return + } runner.Cleanup() } @@ -180,6 +242,78 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { return driver, cleanup, nil } +// createIOSDriverFromClient creates an iOS driver from an existing WDA client (for --persist reuse). +func createIOSDriverFromClient(cfg *RunConfig, udid string, client *wdadriver.Client, port uint16) (core.Driver, func(), error) { + isSimulator := isIOSSimulator(udid) + + // Adopt the existing WDA session so EnsureSession won't create a new one + // (which would relaunch the app). + if !client.ReuseSession() { + logger.Info("--persist: no existing session found, will create one on first flow") + } else { + printSetupSuccess("Reusing existing WDA session (app stays alive)") + } + + // Install app if specified + if cfg.AppFile != "" && !cfg.NoAppInstall { + printSetupStep(fmt.Sprintf("Installing app: %s", cfg.AppFile)) + if err := installIOSApp(udid, cfg.AppFile, isSimulator); err != nil { + return nil, nil, fmt.Errorf("install app failed: %w", err) + } + printSetupSuccess("App installed") + } + + deviceInfo, err := getIOSDeviceInfo(udid) + if err != nil { + return nil, nil, fmt.Errorf("get device info: %w", err) + } + + appVersion := "" + if cfg.AppID != "" && isSimulator { + appVersion = getIOSAppVersion(udid, cfg.AppID) + } + + var screenW, screenH int + if w, h, err := client.WindowSize(); err == nil { + screenW, screenH = w, h + } + + platformInfo := &core.PlatformInfo{ + Platform: "ios", + OSVersion: deviceInfo.OSVersion, + DeviceName: deviceInfo.Name, + DeviceID: udid, + IsSimulator: deviceInfo.IsSimulator, + ScreenWidth: screenW, + ScreenHeight: screenH, + AppID: cfg.AppID, + AppVersion: appVersion, + } + + wdaDrv := wdadriver.NewDriver(client, platformInfo, udid) + wdaDrv.SetAppFile(cfg.AppFile) + + cleanup := func() { + logger.Info("--persist: leaving WDA running on port %d for reuse", port) + } + + var driver core.Driver = wdaDrv + + if !cfg.NoFlutterFallback && isSimulator { + fw := flutter.WrapIOS(wdaDrv, nil, udid, cfg.AppID) + driver = fw + origCleanup := cleanup + cleanup = func() { + if fd, ok := fw.(*flutter.FlutterDriver); ok { + fd.Close() + } + origCleanup() + } + } + + return driver, cleanup, nil +} + // findIOSDevice finds an available iOS device (booted simulator or connected physical device). // Prefers simulators over physical devices. func findIOSDevice() (string, error) { @@ -198,32 +332,92 @@ func findIOSDevice() (string, error) { return "", fmt.Errorf("no iOS device found (no booted simulator or connected physical device)") } -// hasBootedSimulator returns true if any iOS simulator is currently booted. -func hasBootedSimulator() bool { - _, err := findBootedSimulator() - return err == nil +// tryReusePersistedWDA scans all available iOS devices — booted simulators and +// any connected physical device — for a healthy WDA session. Simulators and +// physical devices are peers in this search; the function returns the first +// candidate that has a live WDA session. If no healthy session is found, the +// first candidate is returned so the caller can start a fresh WDA on it. +// +// For physical devices the port forward (USB tunnel) dies when the previous +// maestro-runner process exits, so it must be re-established before the health +// check. Simulator WDA runs directly on localhost and needs no forwarding. +func tryReusePersistedWDA(cfg *RunConfig) (udid string, driver core.Driver, cleanup func(), ok bool) { + printSetupStep("Checking for persisted WDA session...") + + var candidates []string + simSet := make(map[string]bool) + if sims, err := findBootedSimulatorAll(); err == nil { + candidates = append(candidates, sims...) + for _, s := range sims { + simSet[s] = true + } + } + if physUDID, err := findConnectedDevice(); err == nil { + candidates = append(candidates, physUDID) + } + if len(candidates) == 0 { + return "", nil, nil, false + } + + for _, candidateUDID := range candidates { + port := wdadriver.PortFromUDID(candidateUDID) + + // Physical devices need the port forward re-established before health check + var fwd io.Closer + if !simSet[candidateUDID] { + var fwdErr error + fwd, fwdErr = wdadriver.ResumePortForward(candidateUDID, port) + if fwdErr != nil { + logger.Info("--persist: failed to resume port forward for %s: %v", candidateUDID, fwdErr) + continue + } + } + + client := wdadriver.NewClient(port) + if client.IsHealthy() { + printSetupSuccess(fmt.Sprintf("Reusing persisted WDA on port %d (device %s)", port, candidateUDID)) + d, cl, err := createIOSDriverFromClient(cfg, candidateUDID, client, port) + if err == nil { + if fwd != nil { + origCleanup := cl + cl = func() { + origCleanup() + fwd.Close() + } + } + return candidateUDID, d, cl, true + } + logger.Info("--persist: failed to create driver from existing WDA: %v", err) + } + + if fwd != nil { + fwd.Close() + } + } + + return candidates[0], nil, nil, false } -// findBootedSimulator finds the UDID of a booted iOS simulator. -func findBootedSimulator() (string, error) { +// findBootedSimulatorAll returns UDIDs of all booted iOS simulators, including +// those whose WDA port is in use. Used by --persist to find reusable sessions. +func findBootedSimulatorAll() ([]string, error) { out, err := runCommand("xcrun", "simctl", "list", "devices", "booted", "-j") if err != nil { - return "", err + return nil, err } - // Parse JSON to find booted device var data map[string]interface{} if err := json.Unmarshal([]byte(out), &data); err != nil { - return "", err + return nil, err } devices, ok := data["devices"].(map[string]interface{}) if !ok { - return "", fmt.Errorf("no devices in simctl output") + return nil, fmt.Errorf("no devices in simctl output") } + var udids []string for runtime, deviceList := range devices { - // Only consider iOS simulators — skip tvOS, watchOS, visionOS if !strings.Contains(runtime, "iOS-") { continue } @@ -231,19 +425,36 @@ func findBootedSimulator() (string, error) { for _, device := range list { if deviceMap, ok := device.(map[string]interface{}); ok { if udid, ok := deviceMap["udid"].(string); ok && udid != "" { - // Skip simulators whose WDA port is already in use - port := wdadriver.PortFromUDID(udid) - if isPortInUse(port) { - logger.Info("Skipping booted simulator %s: port %d in use", udid, port) - continue - } - return udid, nil + udids = append(udids, udid) } } } } } + return udids, nil +} + +// hasBootedSimulator returns true if any iOS simulator is currently booted. +func hasBootedSimulator() bool { + _, err := findBootedSimulator() + return err == nil +} +// findBootedSimulator finds the UDID of a booted iOS simulator whose WDA port +// is not already in use. Delegates to findBootedSimulatorAll for device enumeration. +func findBootedSimulator() (string, error) { + udids, err := findBootedSimulatorAll() + if err != nil { + return "", err + } + for _, udid := range udids { + port := wdadriver.PortFromUDID(udid) + if isPortInUse(port) { + logger.Info("Skipping booted simulator %s: port %d in use", udid, port) + continue + } + return udid, nil + } return "", fmt.Errorf("no available booted iOS simulator found") } diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 6376322..89dc964 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -472,6 +472,9 @@ type RunConfig struct { // Flutter NoFlutterFallback bool // Disable automatic Flutter VM Service fallback + + // Session persistence + Persist bool // Keep WDA running between invocations; reuse existing WDA on startup } func hyperlink(url, text string) string { @@ -630,6 +633,7 @@ func runTest(c *cli.Context) error { NoAppInstall: getBool("no-app-install"), NoDriverInstall: getBool("no-driver-install"), NoFlutterFallback: getBool("no-flutter-fallback"), + Persist: getBool("persist"), } // Apply waitForIdleTimeout with priority: @@ -1683,6 +1687,20 @@ func isPortInUse(port uint16) bool { return false } +// killProcessOnPort kills any process holding the given TCP port. +// Used by --persist to evict a stale, unhealthy WDA process so a fresh one +// can bind the port. Logs a warning on failure rather than returning an error +// because the caller will proceed with the fresh WDA start regardless. +func killProcessOnPort(port uint16) { + out, err := runCommand("sh", "-c", fmt.Sprintf("lsof -ti tcp:%d | xargs kill -9", port)) + if err != nil { + logger.Warn("--persist: failed to kill process on port %d: %v (output: %s)", port, err, out) + return + } + logger.Info("--persist: killed stale WDA process on port %d", port) + time.Sleep(200 * time.Millisecond) +} + // checkDeviceAvailable checks if a device is available and not in use. // Returns error if device is busy or not found. func checkDeviceAvailable(deviceID, platform string) error { diff --git a/pkg/driver/wda/client.go b/pkg/driver/wda/client.go index 35274c5..dd9ab0e 100644 --- a/pkg/driver/wda/client.go +++ b/pkg/driver/wda/client.go @@ -116,6 +116,31 @@ func (c *Client) Status() (map[string]interface{}, error) { return c.get("/status") } +// IsHealthy checks if the WDA server is reachable and responding. +func (c *Client) IsHealthy() bool { + _, err := c.Status() + return err == nil +} + +// ReuseSession queries WDA for an existing session and adopts it. +// WDA includes a top-level "sessionId" field in every response when a +// session is active. We use the /status endpoint to retrieve it because +// the W3C /sessions endpoint is not supported by Apple's WDA. +func (c *Client) ReuseSession() bool { + resp, err := c.get("/status") + if err != nil { + return false + } + + if id, ok := resp["sessionId"].(string); ok && id != "" { + c.sessionID = id + logger.Info("Reusing existing WDA session: %s", id) + return true + } + + return false +} + // App management // LaunchApp launches an app by bundle ID. diff --git a/pkg/driver/wda/client_test.go b/pkg/driver/wda/client_test.go index b064e57..3ce3f81 100644 --- a/pkg/driver/wda/client_test.go +++ b/pkg/driver/wda/client_test.go @@ -1235,3 +1235,76 @@ func TestElementAttributeInvalidResponse(t *testing.T) { t.Error("Expected error for non-string attribute value") } } + +// TestIsHealthy tests WDA health check + +func TestIsHealthy_Healthy(t *testing.T) { + server := mockWDAServer(func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]interface{}{ + "value": map[string]interface{}{"state": "idle"}, + }) + }) + defer server.Close() + + client := &Client{baseURL: server.URL, httpClient: http.DefaultClient} + + if !client.IsHealthy() { + t.Error("Expected IsHealthy to return true when /status responds") + } +} + +func TestIsHealthy_Unhealthy(t *testing.T) { + // Point at a closed server so the request fails + client := &Client{baseURL: "http://127.0.0.1:1", httpClient: http.DefaultClient} + + if client.IsHealthy() { + t.Error("Expected IsHealthy to return false when server is unreachable") + } +} + +// TestReuseSession tests adopting an existing WDA session + +func TestReuseSession_WithSession(t *testing.T) { + server := mockWDAServer(func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]interface{}{ + "sessionId": "existing-session-abc", + "value": map[string]interface{}{"state": "idle"}, + }) + }) + defer server.Close() + + client := &Client{baseURL: server.URL, httpClient: http.DefaultClient} + + if !client.ReuseSession() { + t.Error("Expected ReuseSession to return true when sessionId is present") + } + if client.sessionID != "existing-session-abc" { + t.Errorf("Expected sessionID 'existing-session-abc', got '%s'", client.sessionID) + } +} + +func TestReuseSession_NoSession(t *testing.T) { + server := mockWDAServer(func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]interface{}{ + "value": map[string]interface{}{"state": "idle"}, + }) + }) + defer server.Close() + + client := &Client{baseURL: server.URL, httpClient: http.DefaultClient} + + if client.ReuseSession() { + t.Error("Expected ReuseSession to return false when no sessionId in response") + } + if client.sessionID != "" { + t.Errorf("Expected sessionID to remain empty, got '%s'", client.sessionID) + } +} + +func TestReuseSession_ServerUnreachable(t *testing.T) { + client := &Client{baseURL: "http://127.0.0.1:1", httpClient: http.DefaultClient} + + if client.ReuseSession() { + t.Error("Expected ReuseSession to return false when server is unreachable") + } +} diff --git a/pkg/driver/wda/runner.go b/pkg/driver/wda/runner.go index b75fc57..6f42ba9 100644 --- a/pkg/driver/wda/runner.go +++ b/pkg/driver/wda/runner.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strconv" "strings" + "syscall" "time" goios "github.com/danielpaulus/go-ios/ios" @@ -36,6 +37,13 @@ type Runner struct { logFile *os.File portForwardListener io.Closer // Port forwarding for physical devices (go-ios) isSimulatorCache bool // Cached device type + persist bool // Keep WDA alive after parent exits +} + +// SetPersist enables persist mode: the WDA process is detached into its own +// process group so it survives after maestro-runner exits. +func (r *Runner) SetPersist(enabled bool) { + r.persist = enabled } // NewRunner creates a new WDA runner. @@ -175,6 +183,9 @@ func (r *Runner) Start(ctx context.Context) error { "-destination", r.destination(), "-derivedDataPath", r.derivedDataPath(), ) + if r.persist { + r.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + } r.cmd.Stdout = r.logFile r.cmd.Stderr = r.logFile @@ -203,21 +214,34 @@ func (r *Runner) Start(ctx context.Context) error { // startPortForward uses go-ios to forward the WDA port from a physical device to localhost. func (r *Runner) startPortForward() error { - entry, err := goios.GetDevice(r.deviceUDID) + listener, err := ResumePortForward(r.deviceUDID, r.port) if err != nil { - return fmt.Errorf("device %s not found: %w", r.deviceUDID, err) + return err } + r.portForwardListener = listener + return nil +} - listener, err := forward.Forward(entry, r.port, r.port) +// ResumePortForward re-establishes a USB port forward from localhost: to the +// physical device's WDA port. Used by --persist to reconnect the tunnel after the +// previous maestro-runner process exited (taking its in-process forwarder with it). +// WDA on the device is still alive; only the Mac-side tunnel needs to be recreated. +// The returned closer must be held open for the lifetime of the session. +func ResumePortForward(deviceUDID string, port uint16) (io.Closer, error) { + entry, err := goios.GetDevice(deviceUDID) if err != nil { - return fmt.Errorf("port forward %d->%d failed: %w", r.port, r.port, err) + return nil, fmt.Errorf("device %s not found: %w", deviceUDID, err) + } + + listener, err := forward.Forward(entry, port, port) + if err != nil { + return nil, fmt.Errorf("port forward %d->%d failed: %w", port, port, err) } - r.portForwardListener = listener - // Give the forward a moment to establish + // Give the tunnel a moment to establish time.Sleep(500 * time.Millisecond) - return nil + return listener, nil } // injectPort writes USE_PORT into the xctestrun plist's EnvironmentVariables diff --git a/pkg/driver/wda/runner_test.go b/pkg/driver/wda/runner_test.go index 41fcc96..e701cb6 100644 --- a/pkg/driver/wda/runner_test.go +++ b/pkg/driver/wda/runner_test.go @@ -177,3 +177,33 @@ func TestRunner_Destination(t *testing.T) { t.Errorf("expected destination %q, got %q", expected, dest) } } + +// Tests for SetPersist + +func TestSetPersist_EnablesFlag(t *testing.T) { + runner := &Runner{} + + runner.SetPersist(true) + + if !runner.persist { + t.Error("Expected persist to be true after SetPersist(true)") + } +} + +func TestSetPersist_DisablesFlag(t *testing.T) { + runner := &Runner{persist: true} + + runner.SetPersist(false) + + if runner.persist { + t.Error("Expected persist to be false after SetPersist(false)") + } +} + +func TestSetPersist_DefaultFalse(t *testing.T) { + runner := NewRunner("test-udid", "TEAM123") + + if runner.persist { + t.Error("Expected persist to be false by default") + } +}