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 a94a818af0921ffacce257f7e8f9909db0b3083e Mon Sep 17 00:00:00 2001 From: Om Narayan Date: Wed, 8 Apr 2026 04:11:57 +0530 Subject: [PATCH 4/4] Add device info and session ID to Appium reports and console output Populate DeviceName, DeviceID, OSVersion from Appium session caps in GetPlatformInfo. Add SessionID to report.Device (omitempty, only shows for Appium). Session ID now appears in parallel console output, per-flow detail section, and JSON/HTML reports. No impact on non-Appium drivers. --- pkg/cli/test.go | 29 +++++++++++++++++++----- pkg/cli/test_unified_output.go | 10 +++++++-- pkg/driver/appium/client.go | 41 +++++++++++++++++++++++++++++++++- pkg/driver/appium/driver.go | 4 ++++ pkg/executor/parallel.go | 17 +++++++++----- pkg/report/types.go | 1 + 6 files changed, 87 insertions(+), 15 deletions(-) diff --git a/pkg/cli/test.go b/pkg/cli/test.go index e07a8aa..9e7eee6 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -1765,7 +1765,8 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { logger.Error("Failed to create Appium session: %v", err) return nil, nil, fmt.Errorf("create Appium session: %w", err) } - logger.Info("Appium session created successfully: %s", driver.GetPlatformInfo().DeviceID) + info := driver.GetPlatformInfo() + logger.Info("Appium session created successfully: %s", info.DeviceID) // Detect cloud provider and extract metadata if p := cloud.Detect(cfg.AppiumURL); p != nil { @@ -1775,7 +1776,16 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { logger.Info("Cloud provider detected: %s", p.Name()) } - printSetupSuccess("Appium session created") + // Print session details + sessionDetail := fmt.Sprintf("Appium session created (session: %s", driver.SessionID()) + if info.DeviceName != "" { + sessionDetail += fmt.Sprintf(", device: %s", info.DeviceName) + } + if info.OSVersion != "" { + sessionDetail += fmt.Sprintf(", OS: %s %s", info.Platform, info.OSVersion) + } + sessionDetail += ")" + printSetupSuccess(sessionDetail) // Cleanup function cleanup := func() { @@ -2237,11 +2247,18 @@ func createAppiumWorkers(cfg *RunConfig, count int) ([]executor.DeviceWorker, [] return nil, nil, fmt.Errorf("failed to create %s: %w", workerID, err) } + // Extract session ID for parallel output + var sessionID string + if appDrv, ok := driver.(*appiumdriver.Driver); ok { + sessionID = appDrv.SessionID() + } + workers = append(workers, executor.DeviceWorker{ - ID: i, - DeviceID: workerID, - Driver: driver, - Cleanup: cleanup, + ID: i, + DeviceID: workerID, + SessionID: sessionID, + Driver: driver, + Cleanup: cleanup, }) cleanups = append(cleanups, cleanup) diff --git a/pkg/cli/test_unified_output.go b/pkg/cli/test_unified_output.go index e75fc08..c69b5e5 100644 --- a/pkg/cli/test_unified_output.go +++ b/pkg/cli/test_unified_output.go @@ -73,10 +73,16 @@ func formatDeviceLabel(device *report.Device) string { return "Unknown" } + var label string if device.OSVersion != "" { - return fmt.Sprintf("%s (%s %s)", device.Name, device.Platform, device.OSVersion) + label = fmt.Sprintf("%s (%s %s)", device.Name, device.Platform, device.OSVersion) + } else { + label = fmt.Sprintf("%s (%s)", device.Name, device.Platform) + } + if device.SessionID != "" { + label += fmt.Sprintf(" [session: %s]", device.SessionID) } - return fmt.Sprintf("%s (%s)", device.Name, device.Platform) + return label } // printDetailedFlowResults prints flow-by-flow results with all commands. diff --git a/pkg/driver/appium/client.go b/pkg/driver/appium/client.go index be838c3..33b7196 100644 --- a/pkg/driver/appium/client.go +++ b/pkg/driver/appium/client.go @@ -25,6 +25,9 @@ type Client struct { sessionCaps map[string]interface{} // merged capabilities from session response client *http.Client platform string // ios, android + deviceName string // e.g., "Pixel 8", "iPhone 15 Pro" + deviceUDID string // device identifier from session + osVersion string // e.g., "14", "17.0" screenW int screenH int isRealDevice bool // true for physical devices, false for simulators @@ -84,12 +87,33 @@ func (c *Client) Connect(capabilities map[string]interface{}) error { return fmt.Errorf("no session ID in response") } - // Extract platform and device type from capabilities + // Extract platform, device info, and device type from capabilities if caps, ok := value["capabilities"].(map[string]interface{}); ok { c.sessionCaps = caps if platform, ok := caps["platformName"].(string); ok { c.platform = strings.ToLower(platform) } + // Extract device name from session caps + for _, key := range []string{"deviceName", "appium:deviceName", "device"} { + if name, ok := caps[key].(string); ok && name != "" { + c.deviceName = name + break + } + } + // Extract device UDID from session caps + for _, key := range []string{"udid", "appium:udid", "deviceUDID"} { + if udid, ok := caps[key].(string); ok && udid != "" { + c.deviceUDID = udid + break + } + } + // Extract OS version from session caps + for _, key := range []string{"platformVersion", "appium:platformVersion"} { + if ver, ok := caps[key].(string); ok && ver != "" { + c.osVersion = ver + break + } + } // Detect real device vs simulator from session response if isReal, ok := caps["isRealDevice"].(bool); ok { c.isRealDevice = isReal @@ -188,6 +212,21 @@ func (c *Client) Platform() string { return c.platform } +// DeviceName returns the device name from session caps (e.g., "Pixel 8", "iPhone 15 Pro"). +func (c *Client) DeviceName() string { + return c.deviceName +} + +// DeviceUDID returns the device UDID from session caps. +func (c *Client) DeviceUDID() string { + return c.deviceUDID +} + +// OSVersion returns the OS version from session caps (e.g., "14", "17.0"). +func (c *Client) OSVersion() string { + return c.osVersion +} + // IsRealDevice returns true for physical devices, false for simulators/emulators. func (c *Client) IsRealDevice() bool { return c.isRealDevice diff --git a/pkg/driver/appium/driver.go b/pkg/driver/appium/driver.go index 67e8552..c970e59 100644 --- a/pkg/driver/appium/driver.go +++ b/pkg/driver/appium/driver.go @@ -225,6 +225,10 @@ func (d *Driver) GetPlatformInfo() *core.PlatformInfo { w, h := d.client.ScreenSize() return &core.PlatformInfo{ Platform: d.platform, + DeviceName: d.client.DeviceName(), + DeviceID: d.client.DeviceUDID(), + OSVersion: d.client.OSVersion(), + IsSimulator: !d.client.IsRealDevice(), ScreenWidth: w, ScreenHeight: h, AppID: d.appID, diff --git a/pkg/executor/parallel.go b/pkg/executor/parallel.go index 9f92761..96991d4 100644 --- a/pkg/executor/parallel.go +++ b/pkg/executor/parallel.go @@ -13,10 +13,11 @@ import ( // DeviceWorker represents a single device worker that pulls from the queue. type DeviceWorker struct { - ID int - DeviceID string - Driver core.Driver - Cleanup func() + ID int + DeviceID string + SessionID string // Appium session ID (empty for non-Appium drivers) + Driver core.Driver + Cleanup func() } // workItem represents a flow and its index in the original flow list. @@ -50,8 +51,11 @@ func formatDeviceLabel(device *report.Device) string { if device == nil { return "Unknown" } - // For event logs, just show device name - return device.Name + label := device.Name + if device.SessionID != "" { + label += " (session: " + device.SessionID + ")" + } + return label } // formatDuration formats milliseconds as human-readable duration @@ -140,6 +144,7 @@ func (pr *ParallelRunner) Run(ctx context.Context, flows []flow.Flow) (*RunResul Name: platformInfo.DeviceName, Platform: platformInfo.Platform, OSVersion: platformInfo.OSVersion, + SessionID: w.SessionID, IsSimulator: platformInfo.IsSimulator, } diff --git a/pkg/report/types.go b/pkg/report/types.go index 069cbd7..7e575ad 100644 --- a/pkg/report/types.go +++ b/pkg/report/types.go @@ -59,6 +59,7 @@ type Device struct { Platform string `json:"platform"` // ios, android OSVersion string `json:"osVersion"` Model string `json:"model,omitempty"` + SessionID string `json:"sessionId,omitempty"` // Appium session ID IsSimulator bool `json:"isSimulator"` }