From e7ea8bea4d56613c62563f8c3225ee566fdaa15d Mon Sep 17 00:00:00 2001 From: Om Narayan Date: Mon, 6 Apr 2026 20:36:32 +0530 Subject: [PATCH 1/4] Add Appium parallel execution support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable --parallel N for the Appium driver. All N sessions hit the same Appium URL with identical capabilities — the server (local or cloud) allocates devices. Cloud providers (Sauce Labs, BrowserStack) get per-session result reporting. Changes: - determineExecutionMode: generate virtual IDs for Appium (like browser) - createAppiumWorkers: create N sessions against same URL - executeAppiumParallel: orchestrate workers via existing ParallelRunner - Per-worker cloud provider detection and reporting - Remove "parallel not yet supported" error for Appium --- pkg/cli/cli_test.go | 17 ++++-- pkg/cli/test.go | 139 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 8 deletions(-) diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go index f8baf8d..eee96ea 100644 --- a/pkg/cli/cli_test.go +++ b/pkg/cli/cli_test.go @@ -1432,15 +1432,22 @@ func TestDetermineExecutionMode_SingleExplicitDevice(t *testing.T) { // ============================================================ func TestExecuteFlowsWithMode_AppiumParallel(t *testing.T) { + // Suppress stdout from setup messages + oldStdout := os.Stdout + os.Stdout, _ = os.Open(os.DevNull) + defer func() { os.Stdout = oldStdout }() + cfg := &RunConfig{ - Driver: "appium", + Driver: "appium", + Parallel: 2, } - _, err := executeFlowsWithMode(cfg, nil, true, []string{"d1", "d2"}) + _, err := executeFlowsWithMode(cfg, nil, true, []string{"appium-1", "appium-2"}) if err == nil { - t.Error("expected error for parallel Appium execution") + t.Error("expected error for parallel Appium with no server URL") } - if !strings.Contains(err.Error(), "parallel execution not yet supported for Appium") { - t.Errorf("unexpected error: %v", err) + // Should fail on session creation (no AppiumURL), not on "not supported" + if strings.Contains(err.Error(), "not yet supported") { + t.Errorf("parallel should be supported now, got: %v", err) } } diff --git a/pkg/cli/test.go b/pkg/cli/test.go index aee288a..1d7fa2a 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -1032,6 +1032,18 @@ func determineExecutionMode(cfg *RunConfig, emulatorMgr *emulator.Manager, simul return false, nil, nil } + // Appium driver: no local device management, server allocates devices + if strings.ToLower(cfg.Driver) == "appium" { + if cfg.Parallel > 1 { + ids := make([]string, cfg.Parallel) + for i := range ids { + ids[i] = fmt.Sprintf("appium-%d", i+1) + } + return true, ids, nil + } + return false, nil, nil + } + needsParallel = cfg.Parallel > 0 || len(cfg.Devices) > 1 if needsParallel { @@ -1209,10 +1221,11 @@ func executeFlowsWithMode(cfg *RunConfig, flows []flow.Flow, needsParallel bool, driverType := strings.ToLower(cfg.Driver) if driverType == "appium" { - if needsParallel { - return nil, fmt.Errorf("parallel execution not yet supported for Appium driver") + count := cfg.Parallel + if count <= 1 { + return executeAppiumSingleSession(cfg, flows) } - return executeAppiumSingleSession(cfg, flows) + return executeAppiumParallel(cfg, count, flows) } if needsParallel { @@ -1584,6 +1597,75 @@ func executeAppiumSingleSession(cfg *RunConfig, flows []flow.Flow) (*executor.Ru return runner.Run(context.Background(), flows) } +// executeAppiumParallel runs flows across N Appium sessions in parallel. +// Each session hits the same Appium URL — the server allocates devices. +func executeAppiumParallel(cfg *RunConfig, count int, flows []flow.Flow) (*executor.RunResult, error) { + workers, cloudMetas, err := createAppiumWorkers(cfg, count) + if err != nil { + return nil, fmt.Errorf("failed to create Appium workers: %w", err) + } + + // Register all worker cleanups for signal handler + allCleanup := func() { + for _, w := range workers { + w.Cleanup() + } + } + cleanupMu.Lock() + activeCleanup = allCleanup + cleanupMu.Unlock() + defer func() { + cleanupMu.Lock() + activeCleanup = nil + cleanupMu.Unlock() + }() + + platform := strings.ToLower(cfg.Platform) + if platform == "" { + platform = "android" + } + + parallelRunner := createParallelRunner(cfg, workers, platform) + result, err := parallelRunner.Run(context.Background(), flows) + if err != nil { + return nil, err + } + + // Report to cloud providers per-worker (each session = separate cloud job) + for i, cm := range cloudMetas { + if cm.provider == nil { + continue + } + // Collect flow results that ran on this worker + var workerFlows []cloud.FlowResult + for _, f := range result.FlowResults { + workerFlows = append(workerFlows, cloud.FlowResult{ + Name: f.Name, + File: f.SourceFile, + Passed: f.Status == report.StatusPassed, + Duration: f.Duration, + Error: f.Error, + }) + } + cloudResult := &cloud.TestResult{ + Passed: result.Status == report.StatusPassed, + Total: result.TotalFlows, + PassedCount: result.PassedFlows, + FailedCount: result.FailedFlows, + Duration: result.Duration, + OutputDir: cfg.OutputDir, + Flows: workerFlows, + } + if err := cm.provider.ReportResult(cfg.AppiumURL, cm.meta, cloudResult); err != nil { + logger.Warn("[appium-%d] %s result reporting failed: %v", i+1, cm.provider.Name(), err) + } else { + logger.Info("[appium-%d] %s job updated: passed=%v", i+1, cm.provider.Name(), cloudResult.Passed) + } + } + + return result, nil +} + // CreateDriver creates the appropriate driver for the platform. // Returns the driver, a cleanup function, and any error. // Exported for library use - call once, reuse across multiple flows. @@ -2119,6 +2201,57 @@ func createBrowserWorkers(cfg *RunConfig, count int) ([]executor.DeviceWorker, e return workers, nil } +// appiumWorkerMeta holds per-worker cloud provider state for Appium parallel execution. +type appiumWorkerMeta struct { + provider cloud.Provider + meta map[string]string +} + +// createAppiumWorkers creates N Appium session workers against the same server URL. +// Each session is independent — the Appium server (local or cloud) allocates devices. +func createAppiumWorkers(cfg *RunConfig, count int) ([]executor.DeviceWorker, []appiumWorkerMeta, error) { + var workers []executor.DeviceWorker + var cloudMetas []appiumWorkerMeta + var cleanups []func() + + cleanupAll := func() { + for _, cleanup := range cleanups { + cleanup() + } + } + + for i := 0; i < count; i++ { + workerID := fmt.Sprintf("appium-%d", i+1) + printSetupStep(fmt.Sprintf("[%s] Creating Appium session...", workerID)) + + driver, cleanup, err := createAppiumDriver(cfg) + if err != nil { + logger.Warn("Failed to create session for %s: %v", workerID, err) + cleanupAll() + return nil, nil, fmt.Errorf("failed to create %s: %w", workerID, err) + } + + workers = append(workers, executor.DeviceWorker{ + ID: i, + DeviceID: workerID, + Driver: driver, + Cleanup: cleanup, + }) + cleanups = append(cleanups, cleanup) + + // Capture per-worker cloud metadata (each session = separate cloud job) + cloudMetas = append(cloudMetas, appiumWorkerMeta{ + provider: cfg.CloudProvider, + meta: cfg.CloudMeta, + }) + // Reset for next worker so createAppiumDriver detects fresh + cfg.CloudProvider = nil + cfg.CloudMeta = nil + } + + return workers, cloudMetas, nil +} + // createParallelRunner builds the parallel runner with config. func createParallelRunner(cfg *RunConfig, workers []executor.DeviceWorker, platform string) *executor.ParallelRunner { driverName := resolveDriverName(cfg, platform) From 3b36494f2b481631ba9e2f6d455e807e1af8b141 Mon Sep 17 00:00:00 2001 From: Om Narayan Date: Tue, 7 Apr 2026 17:34:24 +0530 Subject: [PATCH 2/4] Cap Appium parallel sessions to number of flows Don't create more Appium sessions than there are flows to run. With --parallel 3 and 2 flows, only 2 sessions are created instead of wasting a third device. --- pkg/cli/cli_test.go | 3 ++- pkg/cli/test.go | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go index eee96ea..ce9884f 100644 --- a/pkg/cli/cli_test.go +++ b/pkg/cli/cli_test.go @@ -1441,7 +1441,8 @@ func TestExecuteFlowsWithMode_AppiumParallel(t *testing.T) { Driver: "appium", Parallel: 2, } - _, err := executeFlowsWithMode(cfg, nil, true, []string{"appium-1", "appium-2"}) + testFlows := []flow.Flow{{}, {}} // need flows so min(parallel, flows) > 0 + _, err := executeFlowsWithMode(cfg, testFlows, true, []string{"appium-1", "appium-2"}) if err == nil { t.Error("expected error for parallel Appium with no server URL") } diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 1d7fa2a..0baa425 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -1225,6 +1225,10 @@ func executeFlowsWithMode(cfg *RunConfig, flows []flow.Flow, needsParallel bool, if count <= 1 { return executeAppiumSingleSession(cfg, flows) } + // Don't create more sessions than flows + if count > len(flows) { + count = len(flows) + } return executeAppiumParallel(cfg, count, flows) } From fbb0b0db543bd5e22c319f4976a8bb69d792fbc2 Mon Sep 17 00:00:00 2001 From: Om Narayan Date: Tue, 7 Apr 2026 17:36:06 +0530 Subject: [PATCH 3/4] Print warning when parallel count exceeds flow count --- pkg/cli/test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 0baa425..e07a8aa 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -1227,6 +1227,8 @@ func executeFlowsWithMode(cfg *RunConfig, flows []flow.Flow, needsParallel bool, } // Don't create more sessions than flows if count > len(flows) { + fmt.Printf(" %s⚠%s --parallel %d but only %d flow(s), using %d session(s)\n", + color(colorYellow), color(colorReset), count, len(flows), len(flows)) count = len(flows) } return executeAppiumParallel(cfg, count, flows) From 6e10eb7f962c2a90b3904c681f1f4e7f115f548a Mon Sep 17 00:00:00 2001 From: Om Narayan Date: Tue, 7 Apr 2026 19:22:43 +0530 Subject: [PATCH 4/4] Add --wda-bundle-id flag for custom WDA bundle identifier Allow users to specify a custom WDA bundle ID for code signing on iOS. When provided, PRODUCT_BUNDLE_IDENTIFIER is passed to xcodebuild, overriding the default com.facebook.WebDriverAgentRunner. Build cache includes the bundle ID to avoid collisions between different configs. Closes #41 Reported-by: Ma-Jiehui --- pkg/cli/cli.go | 5 +++++ pkg/cli/ios.go | 2 +- pkg/cli/test.go | 2 ++ pkg/driver/wda/runner.go | 21 +++++++++++++++------ pkg/driver/wda/runner_test.go | 6 +++--- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index 9a63316..31907a5 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -77,6 +77,11 @@ var GlobalFlags = []cli.Flag{ Usage: "Apple Development Team ID for WDA code signing (iOS)", EnvVars: []string{"MAESTRO_TEAM_ID", "DEVELOPMENT_TEAM"}, }, + &cli.StringFlag{ + Name: "wda-bundle-id", + Usage: "Custom WDA bundle identifier for code signing (iOS)", + EnvVars: []string{"MAESTRO_WDA_BUNDLE_ID"}, + }, &cli.StringFlag{ Name: "start-emulator", Usage: "Start Android emulator with AVD name (e.g., Pixel_7_API_33)", diff --git a/pkg/cli/ios.go b/pkg/cli/ios.go index 57d132e..f6d32db 100644 --- a/pkg/cli/ios.go +++ b/pkg/cli/ios.go @@ -97,7 +97,7 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { // 3. Create WDA runner printSetupStep("Building WDA...") logger.Info("Building WDA for device %s (team ID: %s)", udid, cfg.TeamID) - runner := wdadriver.NewRunner(udid, cfg.TeamID) + runner := wdadriver.NewRunner(udid, cfg.TeamID, cfg.WDABundleID) ctx := context.Background() if err := runner.Build(ctx); err != nil { diff --git a/pkg/cli/test.go b/pkg/cli/test.go index e07a8aa..f9e7a01 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -459,6 +459,7 @@ type RunConfig struct { WaitForIdleTimeout int // Wait for device idle in ms (0 = disabled, default 200) TypingFrequency int // WDA typing frequency in keys/sec (0 = use WDA default of 60) TeamID string // Apple Development Team ID for WDA code signing + WDABundleID string // Custom WDA bundle identifier // Emulator/Simulator management StartEmulator string // AVD name to start (e.g., Pixel_7_API_33) @@ -627,6 +628,7 @@ func runTest(c *cli.Context) error { WaitForIdleTimeout: getInt("wait-for-idle-timeout"), TypingFrequency: getInt("typing-frequency"), TeamID: getString("team-id"), + WDABundleID: getString("wda-bundle-id"), StartEmulator: getString("start-emulator"), StartSimulator: getString("start-simulator"), AutoStartEmulator: getBool("auto-start-emulator"), diff --git a/pkg/driver/wda/runner.go b/pkg/driver/wda/runner.go index b75fc57..d56fb05 100644 --- a/pkg/driver/wda/runner.go +++ b/pkg/driver/wda/runner.go @@ -29,6 +29,7 @@ const ( type Runner struct { deviceUDID string teamID string + wdaBundleID string port uint16 wdaPath string buildDir string @@ -41,11 +42,12 @@ type Runner struct { // NewRunner creates a new WDA runner. // The WDA port is derived from the device UDID so each simulator gets a // deterministic, unique port without scanning. -func NewRunner(deviceUDID, teamID string) *Runner { +func NewRunner(deviceUDID, teamID, wdaBundleID string) *Runner { return &Runner{ - deviceUDID: deviceUDID, - teamID: teamID, - port: PortFromUDID(deviceUDID), + deviceUDID: deviceUDID, + teamID: teamID, + wdaBundleID: wdaBundleID, + port: PortFromUDID(deviceUDID), } } @@ -122,7 +124,7 @@ func (r *Runner) Build(ctx context.Context) error { projectPath := filepath.Join(r.wdaPath, "WebDriverAgent.xcodeproj") - cmd := exec.CommandContext(buildCtx, "xcodebuild", + args := []string{ "build-for-testing", "-project", projectPath, "-scheme", "WebDriverAgentRunner", @@ -130,7 +132,11 @@ func (r *Runner) Build(ctx context.Context) error { "-derivedDataPath", r.derivedDataPath(), "-allowProvisioningUpdates", fmt.Sprintf("DEVELOPMENT_TEAM=%s", r.teamID), - ) + } + if r.wdaBundleID != "" { + args = append(args, fmt.Sprintf("PRODUCT_BUNDLE_IDENTIFIER=%s", r.wdaBundleID)) + } + cmd := exec.CommandContext(buildCtx, "xcodebuild", args...) cmd.Stdout = logFile cmd.Stderr = logFile @@ -348,6 +354,9 @@ func (r *Runner) getBuildCacheDir() (string, error) { } configName = fmt.Sprintf("device-ios%s-team%s", iosVersion, teamID) } + if r.wdaBundleID != "" { + configName += "-bundle" + r.wdaBundleID + } cacheDir := filepath.Join(config.GetCacheDir(), "wda-builds", configName) return cacheDir, nil diff --git a/pkg/driver/wda/runner_test.go b/pkg/driver/wda/runner_test.go index 41fcc96..209b1c3 100644 --- a/pkg/driver/wda/runner_test.go +++ b/pkg/driver/wda/runner_test.go @@ -129,7 +129,7 @@ func TestNewRunner_SetsPort(t *testing.T) { udid := "12345678-1234-1234-1234-ABCDEF123456" teamID := "ABC123DEF" - runner := NewRunner(udid, teamID) + runner := NewRunner(udid, teamID, "") expectedPort := PortFromUDID(udid) if runner.Port() != expectedPort { @@ -141,7 +141,7 @@ func TestNewRunner_StoresUDID(t *testing.T) { udid := "test-udid-1234" teamID := "TEAM123" - runner := NewRunner(udid, teamID) + runner := NewRunner(udid, teamID, "") if runner.deviceUDID != udid { t.Errorf("expected deviceUDID %q, got %q", udid, runner.deviceUDID) @@ -152,7 +152,7 @@ func TestNewRunner_StoresTeamID(t *testing.T) { udid := "test-udid-1234" teamID := "TEAM456" - runner := NewRunner(udid, teamID) + runner := NewRunner(udid, teamID, "") if runner.teamID != teamID { t.Errorf("expected teamID %q, got %q", teamID, runner.teamID)