diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go index f8baf8d..ce9884f 100644 --- a/pkg/cli/cli_test.go +++ b/pkg/cli/cli_test.go @@ -1432,15 +1432,23 @@ 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"}) + 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 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..9e7eee6 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,17 @@ 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) + } + // 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 executeAppiumSingleSession(cfg, flows) + return executeAppiumParallel(cfg, count, flows) } if needsParallel { @@ -1584,6 +1603,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. @@ -1677,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 { @@ -1687,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() { @@ -2119,6 +2217,64 @@ 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) + } + + // 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, + SessionID: sessionID, + 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) 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"` }