Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
18 changes: 13 additions & 5 deletions pkg/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/ios.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
147 changes: 144 additions & 3 deletions pkg/cli/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -1032,6 +1034,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 {
Expand Down Expand Up @@ -1209,10 +1223,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 {
Expand Down Expand Up @@ -1584,6 +1605,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.
Expand Down Expand Up @@ -2119,6 +2209,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)
Expand Down
21 changes: 15 additions & 6 deletions pkg/driver/wda/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const (
type Runner struct {
deviceUDID string
teamID string
wdaBundleID string
port uint16
wdaPath string
buildDir string
Expand All @@ -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),
}
}

Expand Down Expand Up @@ -122,15 +124,19 @@ 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",
"-destination", r.destination(),
"-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

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions pkg/driver/wda/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading