Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
846cb63
Fix env var default value syntax (${VAR || "default"})
omnarayan Apr 8, 2026
12fc942
Fix when condition true: field not being parsed
omnarayan Apr 9, 2026
8891ea0
Make text matching case-insensitive on Android drivers
omnarayan Apr 9, 2026
43802f9
Fix swipe LEFT/RIGHT by using screen coordinates directly
omnarayan Apr 11, 2026
ac74ce6
Add UI.waitForSettle RPC to DeviceLab driver
omnarayan Apr 11, 2026
e6f32fe
Auto-settle before inputText/eraseText steps
omnarayan Apr 11, 2026
92654bf
Route settle through UI.waitForSettle on DeviceLab driver
omnarayan Apr 19, 2026
acea0c7
Simplify inputText without selector to SendKeyActions
omnarayan Apr 19, 2026
1096616
Revert case-insensitive text matching for DeviceLab driver
omnarayan Apr 19, 2026
6125a28
Update DeviceLab Android driver APK with waitForSettle RPC
omnarayan Apr 19, 2026
7406748
Update DeviceLab Android driver APK — honor .clickable selector filter
omnarayan Apr 19, 2026
32aab66
Re-add case-insensitive text matching for DeviceLab driver
omnarayan Apr 19, 2026
8358bf7
Prepend clickable-only strategies in DeviceLab tapOn text path
omnarayan Apr 20, 2026
fb3034c
Emit hintContains/hintMatches selectors for DeviceLab text tap
omnarayan Apr 20, 2026
0282080
Update DeviceLab Android driver APK — clickable ancestor + hintText
omnarayan Apr 20, 2026
a77d9fb
Add runFlow timeout with context propagation into drivers
omnarayan Mar 8, 2026
26013ac
Add run and flow lifecycle hooks to cloud Provider interface
omnarayan Apr 20, 2026
523e695
Use xcrun devicectl for iOS install, add timeout to both paths
omnarayan Apr 20, 2026
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
Binary file modified drivers/android/devicelab-android-driver.apk
Binary file not shown.
2 changes: 2 additions & 0 deletions pkg/cli/cli_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"context"
"fmt"
"net"
"os"
Expand Down Expand Up @@ -31,6 +32,7 @@ func (m *mockDriver) GetState() *core.StateSnapshot { return nil }
func (m *mockDriver) GetPlatformInfo() *core.PlatformInfo { return m.platformInfo }
func (m *mockDriver) SetFindTimeout(int) {}
func (m *mockDriver) SetWaitForIdleTimeout(int) error { return nil }
func (m *mockDriver) SetContext(context.Context) {}

func TestResolveOutputDir_Default(t *testing.T) {
dir, err := resolveOutputDir("", false)
Expand Down
98 changes: 90 additions & 8 deletions pkg/cli/ios.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

goios "github.com/danielpaulus/go-ios/ios"
"github.com/danielpaulus/go-ios/ios/zipconduit"
Expand All @@ -16,6 +18,11 @@ import (
"github.com/devicelab-dev/maestro-runner/pkg/logger"
)

// installTimeout is the max time we wait for an iOS app install to complete.
// Big apps on slow USB can legitimately take a while; 3 minutes is a generous
// upper bound that still catches infinite hangs.
const installTimeout = 3 * time.Minute

// simulatorInfo holds iOS simulator information.
type simulatorInfo struct {
Name string
Expand Down Expand Up @@ -342,16 +349,81 @@ func getIOSDeviceInfo(udid string) (*iosDeviceInfo, error) {
}

// installIOSApp installs an app on an iOS device (simulator or physical).
//
// Strategy for physical devices:
// 1. Prefer `xcrun devicectl device install app` (Apple's modern CoreDevice
// installer, iOS 17+ friendly) — set MAESTRO_RUNNER_IOS_INSTALLER=zipconduit
// to skip.
// 2. Fall back to go-ios zipconduit for older Xcode / macOS or on devicectl
// failure. Both paths run with installTimeout so an unresponsive install
// service surfaces as an error instead of hanging forever.
func installIOSApp(udid string, appPath string, isSimulator bool) error {
if isSimulator {
out, err := runCommand("xcrun", "simctl", "install", udid, appPath)
if err != nil {
return fmt.Errorf("simctl install failed: %w\nOutput: %s", err, out)
return installViaSimctl(udid, appPath)
}

installer := strings.ToLower(strings.TrimSpace(os.Getenv("MAESTRO_RUNNER_IOS_INSTALLER")))

// Default: prefer devicectl, fall back to zipconduit.
if installer != "zipconduit" && devicectlAvailable() {
if err := installViaDevicectl(udid, appPath); err == nil {
return nil
} else if installer == "devicectl" {
// User explicitly forced devicectl — propagate the error, don't silently fall back.
return err
} else {
logger.Warn("devicectl install failed, falling back to zipconduit: %v", err)
}
return nil
}

// Physical device - use go-ios zipconduit
return installViaZipconduit(udid, appPath)
}

// installViaSimctl installs on a simulator. Wrapped in a timeout so a stuck
// simulator can't freeze the whole run.
func installViaSimctl(udid, appPath string) error {
ctx, cancel := context.WithTimeout(context.Background(), installTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "xcrun", "simctl", "install", udid, appPath).CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("simctl install timed out after %v", installTimeout)
}
if err != nil {
return fmt.Errorf("simctl install failed: %w\nOutput: %s", err, out)
}
return nil
}

// devicectlAvailable reports whether `xcrun devicectl` works on this host.
// Requires macOS 14 / Xcode 15+.
func devicectlAvailable() bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := exec.CommandContext(ctx, "xcrun", "devicectl", "--version").Run()
return err == nil
}

// installViaDevicectl uses Apple's modern CoreDevice installer.
// Supported on iOS 17+ real devices; also works on earlier iOS via Xcode 15+.
func installViaDevicectl(udid, appPath string) error {
ctx, cancel := context.WithTimeout(context.Background(), installTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "install", "app",
"--device", udid, appPath).CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("devicectl install timed out after %v", installTimeout)
}
if err != nil {
return fmt.Errorf("devicectl install failed: %w\nOutput: %s", err, string(out))
}
return nil
}

// installViaZipconduit uses the go-ios Go-native installer. Kept as a fallback
// for hosts without devicectl (older macOS / Xcode). Wrapped in a timeout —
// without it, SendFile can hang indefinitely when the install service accepts
// the connection but never acks completion (observed on iOS 26 / iPhone 13).
func installViaZipconduit(udid, appPath string) error {
entry, err := goios.GetDevice(udid)
if err != nil {
return fmt.Errorf("device %s not found: %w", udid, err)
Expand All @@ -360,10 +432,20 @@ func installIOSApp(udid string, appPath string, isSimulator bool) error {
if err != nil {
return fmt.Errorf("failed to connect to device install service: %w", err)
}
if err := conn.SendFile(appPath); err != nil {
return fmt.Errorf("failed to install app: %w", err)

done := make(chan error, 1)
go func() { done <- conn.SendFile(appPath) }()

select {
case err := <-done:
if err != nil {
return fmt.Errorf("failed to install app: %w", err)
}
return nil
case <-time.After(installTimeout):
return fmt.Errorf("app install timed out after %v (try: xcrun devicectl device install app --device %s %s)",
installTimeout, udid, appPath)
}
return nil
}

// getSimulatorInfo gets information about an iOS simulator.
Expand Down
60 changes: 54 additions & 6 deletions pkg/cli/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1283,13 +1283,14 @@ func executeSingleDevice(cfg *RunConfig, flows []flow.Flow) (*executor.RunResult
WaitForIdleTimeout: cfg.WaitForIdleTimeout,
TypingFrequency: cfg.TypingFrequency,
DeviceInfo: &deviceInfo,
OnFlowStart: onFlowStart,
OnFlowStart: onFlowStartWithCloud(cfg),
OnStepComplete: onStepComplete,
OnNestedStep: onNestedStep,
OnNestedFlowStart: onNestedFlowStart,
OnFlowEnd: onFlowEnd,
OnFlowEnd: onFlowEndWithCloud(cfg),
})

notifyCloudRunStart(cfg, len(flows))
return runner.Run(context.Background(), flows)
}

Expand Down Expand Up @@ -1323,13 +1324,14 @@ func ExecuteFlowWithDriver(driver core.Driver, cfg *RunConfig, f flow.Flow) (*ex
WaitForIdleTimeout: cfg.WaitForIdleTimeout,
TypingFrequency: cfg.TypingFrequency,
DeviceInfo: &deviceInfo,
OnFlowStart: onFlowStart,
OnFlowStart: onFlowStartWithCloud(cfg),
OnStepComplete: onStepComplete,
OnNestedStep: onNestedStep,
OnNestedFlowStart: onNestedFlowStart,
OnFlowEnd: onFlowEnd,
OnFlowEnd: onFlowEndWithCloud(cfg),
})

notifyCloudRunStart(cfg, 1)
return runner.Run(context.Background(), []flow.Flow{f})
}

Expand Down Expand Up @@ -1383,6 +1385,51 @@ func onFlowStart(flowIdx, totalFlows int, name, file string) {
fmt.Println(strings.Repeat("─", 60))
}

// notifyCloudRunStart fires CloudProvider.OnRunStart once before the first flow.
// Errors are logged and do not abort the run.
func notifyCloudRunStart(cfg *RunConfig, totalFlows int) {
if cfg.CloudProvider == nil {
return
}
if err := cfg.CloudProvider.OnRunStart(cfg.CloudMeta, totalFlows); err != nil {
logger.Warn("%s OnRunStart failed: %v", cfg.CloudProvider.Name(), err)
}
}

// onFlowStartWithCloud returns a flow-start callback that logs console progress
// and fires CloudProvider.OnFlowStart when a cloud provider is configured.
func onFlowStartWithCloud(cfg *RunConfig) func(flowIdx, totalFlows int, name, file string) {
return func(flowIdx, totalFlows int, name, file string) {
onFlowStart(flowIdx, totalFlows, name, file)
if cfg.CloudProvider == nil {
return
}
if err := cfg.CloudProvider.OnFlowStart(cfg.CloudMeta, flowIdx, totalFlows, name, file); err != nil {
logger.Warn("%s OnFlowStart failed: %v", cfg.CloudProvider.Name(), err)
}
}
}

// onFlowEndWithCloud returns a flow-end callback that logs console progress
// and fires CloudProvider.OnFlowEnd when a cloud provider is configured.
func onFlowEndWithCloud(cfg *RunConfig) func(name string, passed bool, durationMs int64, errMsg string) {
return func(name string, passed bool, durationMs int64, errMsg string) {
onFlowEnd(name, passed, durationMs, errMsg)
if cfg.CloudProvider == nil {
return
}
fr := &cloud.FlowResult{
Name: name,
Passed: passed,
Duration: durationMs,
Error: errMsg,
}
if err := cfg.CloudProvider.OnFlowEnd(cfg.CloudMeta, fr); err != nil {
logger.Warn("%s OnFlowEnd failed: %v", cfg.CloudProvider.Name(), err)
}
}
}

func onStepComplete(idx int, desc string, passed bool, durationMs int64, errMsg string) {
// Don't mark runFlow/repeat/retry as slow - they contain multiple steps
isCompoundStep := strings.HasPrefix(desc, "runFlow:") ||
Expand Down Expand Up @@ -1595,13 +1642,14 @@ func executeAppiumSingleSession(cfg *RunConfig, flows []flow.Flow) (*executor.Ru
WaitForIdleTimeout: cfg.WaitForIdleTimeout,
TypingFrequency: cfg.TypingFrequency,
DeviceInfo: &deviceInfo,
OnFlowStart: onFlowStart,
OnFlowStart: onFlowStartWithCloud(cfg),
OnStepComplete: onStepComplete,
OnNestedStep: onNestedStep,
OnNestedFlowStart: onNestedFlowStart,
OnFlowEnd: onFlowEnd,
OnFlowEnd: onFlowEndWithCloud(cfg),
})

notifyCloudRunStart(cfg, len(flows))
return runner.Run(context.Background(), flows)
}

Expand Down
19 changes: 19 additions & 0 deletions pkg/cloud/example_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,25 @@ func (p *exampleProvider) ExtractMeta(sessionID string, caps map[string]interfac
meta["jobID"] = sessionID
}

func (p *exampleProvider) OnRunStart(meta map[string]string, totalFlows int) error {
// TODO (optional): signal run start to your provider's dashboard
// (e.g., create a suite, tag the job with totalFlows, etc.)
return nil
}

func (p *exampleProvider) OnFlowStart(meta map[string]string, flowIdx, totalFlows int, name, file string) error {
// TODO (optional): mark test case as "started" in your provider's dashboard
// flowIdx is 0-based, totalFlows is the count, name is the flow title
return nil
}

func (p *exampleProvider) OnFlowEnd(meta map[string]string, result *FlowResult) error {
// TODO (optional): mark test case as completed (pass/fail) in your
// provider's dashboard. Useful for live updates before the run ends.
// result.Name, result.File, result.Passed, result.Duration, result.Error
return nil
}

func (p *exampleProvider) ReportResult(appiumURL string, meta map[string]string, result *TestResult) error {
jobID := meta["jobID"]
if jobID == "" {
Expand Down
17 changes: 17 additions & 0 deletions pkg/cloud/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ type Provider interface {
// from the session response; meta is the output map to populate.
ExtractMeta(sessionID string, caps map[string]interface{}, meta map[string]string)

// OnRunStart fires once, after ExtractMeta and before the first flow runs.
// Providers can use this to record run-level metadata (tags, build ID, total
// flow count) with the upstream dashboard. Errors are logged but do not
// abort the run.
OnRunStart(meta map[string]string, totalFlows int) error

// OnFlowStart fires before each flow begins executing. Providers can use
// this to mark a test case as "started" in the upstream dashboard. Errors
// are logged but do not abort the flow.
OnFlowStart(meta map[string]string, flowIdx, totalFlows int, name, file string) error

// OnFlowEnd fires after each flow finishes (pass, fail, or skip).
// Providers can use this to mark a test case as completed in the upstream
// dashboard before the full run has finished. Errors are logged but do not
// abort subsequent flows.
OnFlowEnd(meta map[string]string, result *FlowResult) error

// ReportResult reports the test result to the cloud provider.
// Called once after all flows and reports have completed.
ReportResult(appiumURL string, meta map[string]string, result *TestResult) error
Expand Down
Loading
Loading