diff --git a/README.md b/README.md index 6ddcbb2..8ef7094 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ maestro-runner runs Maestro YAML flows on cloud device grids via the Appium driv maestro-runner --driver appium --appium-url --caps caps.json test flows/ ``` +- **[Run Maestro on any cloud provider](https://devicelab.dev/blog/run-maestro-flows-any-cloud)** — Appium cloud setup overview +- **[Sauce Labs](docs/cloud-providers/saucelabs.md)** — Setup guide for running on Sauce Labs Appium cloud - **[TestingBot](docs/cloud-providers/testingbot.md)** — Setup guide for running on TestingBot's real device cloud ## Contributing diff --git a/docs/cloud-providers/saucelabs.md b/docs/cloud-providers/saucelabs.md new file mode 100644 index 0000000..acd6fde --- /dev/null +++ b/docs/cloud-providers/saucelabs.md @@ -0,0 +1,88 @@ +# Sauce Labs (Appium) + +Use Appium driver mode with a Sauce Labs URL and provider capabilities. + +## Run command + +```bash +maestro-runner \ + --driver appium \ + --appium-url "https://$SAUCE_USERNAME:$SAUCE_ACCESS_KEY@ondemand.us-west-1.saucelabs.com:443/wd/hub" \ + --caps provider-caps.json \ + test flows/ +``` + +- Default example uses `us-west-1`. Replace the Sauce Labs endpoints with your region as needed (for example `eu-central-1`, `us-east-4`). +- The Appium URL should include Sauce credentials (`$SAUCE_USERNAME` and `$SAUCE_ACCESS_KEY`) or be provided via environment variables. + +## Example capabilities + +Example `provider-caps.json` for Android real device: + +```json +{ + "platformName": "Android", + "appium:automationName": "UiAutomator2", + "appium:deviceName": "Samsung.*", + "appium:platformVersion": "^1[5-6].*", + "appium:app": "storage:filename=mda-2.2.0-25.apk", + "sauce:options": { + "build": "Maestro Android Run", + "appiumVersion": "latest" + } +} +``` + +Example `provider-caps.json` for iOS real device: + +```json +{ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "appium:deviceName": "iPhone.*", + "appium:platformVersion": "^(18|26).*", + "appium:app": "storage:filename=SauceLabs-Demo-App.ipa", + "sauce:options": { + "build": "Maestro iOS Run", + "appiumVersion": "latest", + "resigningEnabled": true + } +} +``` + +Example `provider-caps.json` for Android emulator: + +```json +{ + "platformName": "Android", + "appium:automationName": "UiAutomator2", + "appium:deviceName": "Google Pixel 9 Emulator", + "appium:platformVersion": "16.0", + "appium:app": "storage:filename=mda-2.2.0-25.apk", + "sauce:options": { + "build": "Maestro Android Emulator Run", + "appiumVersion": "2.11.0" + } +} +``` + +Example `provider-caps.json` for iOS simulator: + +```json +{ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "appium:deviceName": "iPhone Simulator", + "appium:platformVersion": "17.0", + "appium:app": "storage:filename=SauceLabs-Demo-App.Simulator.zip", + "sauce:options": { + "build": "Maestro iOS Simulator Run", + "appiumVersion": "2.11.3" + } +} +``` + +## References + +- [Run Maestro Flows on Any Cloud Provider](https://devicelab.dev/blog/run-maestro-flows-any-cloud) +- [Sauce Labs: Mobile Appium capabilities](https://docs.saucelabs.com/dev/test-configuration-options/#mobile-appium-capabilities) diff --git a/pkg/cli/sauce_labs.go b/pkg/cli/sauce_labs.go new file mode 100644 index 0000000..d0ca85d --- /dev/null +++ b/pkg/cli/sauce_labs.go @@ -0,0 +1,219 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/devicelab-dev/maestro-runner/pkg/logger" +) + +// Sauce Labs (SL): the functions below are only used when --appium-url points at Sauce Labs. + +// sauceLabsAPIBaseFromAppiumURL returns the Sauce Labs REST API base URL for a given Appium hub URL. +// Used for RDC and other regional API calls (emulators/simulators may share the same regional hosts). +// Region is inferred from substrings in the full URL. +// +// Rules when the URL contains "saucelabs": +// - "eu-central-1" -> https://api.eu-central-1.saucelabs.com +// - "us-east-4" -> https://api.us-east-4.saucelabs.com +// - else -> https://api.us-west-1.saucelabs.com +// +// Real Device job updates: https://docs.saucelabs.com/dev/api/rdc/#update-a-job +func sauceLabsAPIBaseFromAppiumURL(appiumURL string) (string, error) { + raw := strings.TrimSpace(appiumURL) + if raw == "" { + return "", fmt.Errorf("empty appium url") + } + if _, err := url.Parse(raw); err != nil { + return "", fmt.Errorf("parse appium url: %w", err) + } + lower := strings.ToLower(raw) + if !strings.Contains(lower, "saucelabs") { + return "", fmt.Errorf("not a Sauce Labs appium url") + } + switch { + case strings.Contains(lower, "eu-central-1"): + return "https://api.eu-central-1.saucelabs.com", nil + case strings.Contains(lower, "us-east-4"): + return "https://api.us-east-4.saucelabs.com", nil + default: + return "https://api.us-west-1.saucelabs.com", nil + } +} + +// sauceCredentialsFromAppiumURL returns the Sauce username and access key for REST API basic auth. +// +// Primary source is the Appium hub URL userinfo (same credentials Appium uses), for example: +// +// https://:@ondemand.eu-central-1.saucelabs.com:443/wd/hub +// +// net/url decodes percent-encoding in the userinfo (needed if the access key contains reserved characters). +// If either field is missing from the URL, falls back to SAUCE_USERNAME and SAUCE_ACCESS_KEY. +func sauceCredentialsFromAppiumURL(appiumURL string) (username, accessKey string, err error) { + u, err := url.Parse(strings.TrimSpace(appiumURL)) + if err != nil { + return "", "", fmt.Errorf("parse appium url: %w", err) + } + if u.User != nil { + username = strings.TrimSpace(u.User.Username()) + if pw, ok := u.User.Password(); ok { + accessKey = strings.TrimSpace(pw) + } + } + if username != "" && accessKey != "" { + return username, accessKey, nil + } + username = strings.TrimSpace(os.Getenv("SAUCE_USERNAME")) + accessKey = strings.TrimSpace(os.Getenv("SAUCE_ACCESS_KEY")) + if username == "" || accessKey == "" { + return "", "", fmt.Errorf("sauce credentials missing: use https://USERNAME:ACCESS_KEY@... in --appium-url or set SAUCE_USERNAME and SAUCE_ACCESS_KEY") + } + return username, accessKey, nil +} + +// SL_CapsDeviceNameIndicatesEmuSim returns true when any capability key +// whose name contains "deviceName" (case-insensitive) has a string value containing +// "Emulator" or "Simulator" (case-insensitive), including nested maps (e.g. sauce:options). +// Sauce Labs (SL) only: used to choose VMs API (VDC) vs RDC API for pass/fail updates. +func SL_CapsDeviceNameIndicatesEmuSim(caps map[string]interface{}) bool { + if caps == nil { + return false + } + mentionsSimOrEmu := func(s string) bool { + if s == "" { + return false + } + lower := strings.ToLower(s) + return strings.Contains(lower, "emulator") || strings.Contains(lower, "simulator") + } + var walk func(map[string]interface{}, int) bool + walk = func(m map[string]interface{}, depth int) bool { + if m == nil || depth > 4 { + return false + } + for k, v := range m { + if strings.Contains(strings.ToLower(k), "devicename") { + if s, ok := v.(string); ok && mentionsSimOrEmu(s) { + return true + } + } + if sub, ok := v.(map[string]interface{}); ok { + if walk(sub, depth+1) { + return true + } + } + } + return false + } + return walk(caps, 0) +} + +// SL_UpdateSauceLabsVMsAPIPassed calls PUT /rest/v1/{username}/jobs/{job_id} with {"passed": true|false}. +// Sauce Labs (SL) emulators/simulators only: slSessionID is the WebDriver session id (not appium:jobUuid). +// See https://docs.saucelabs.com/dev/api/jobs/#update-a-job +func SL_UpdateSauceLabsVMsAPIPassed(appiumURL, slSessionID string, passed bool) error { + slSessionID = strings.TrimSpace(slSessionID) + if slSessionID == "" { + return fmt.Errorf("empty job id") + } + base, err := sauceLabsAPIBaseFromAppiumURL(appiumURL) + if err != nil { + return err + } + user, key, err := sauceCredentialsFromAppiumURL(appiumURL) + if err != nil { + return err + } + endpoint := strings.TrimSuffix(base, "/") + "/rest/v1/" + url.PathEscape(user) + "/jobs/" + url.PathEscape(slSessionID) + payload, err := json.Marshal(map[string]bool{"passed": passed}) + if err != nil { + return fmt.Errorf("marshal body: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.SetBasicAuth(user, key) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 31 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("http put: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + logger.Debug("sauce labs: close response body: %v", err) + } + }() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("sauce labs jobs api %s: status %d, body: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + +// SL_UpdateSauceLabsRDCJobPassed calls PUT /v1/rdc/jobs/{job_id} with {"passed": true|false}. +// Sauce Labs (SL) real devices only: slJobUUID is appium:jobUuid from the session. +// See https://docs.saucelabs.com/dev/api/rdc/#update-a-job +func SL_UpdateSauceLabsRDCJobPassed(appiumURL, slJobUUID string, passed bool) error { + slJobUUID = strings.TrimSpace(slJobUUID) + if slJobUUID == "" { + return fmt.Errorf("empty job id") + } + base, err := sauceLabsAPIBaseFromAppiumURL(appiumURL) + if err != nil { + return err + } + user, key, err := sauceCredentialsFromAppiumURL(appiumURL) + if err != nil { + return err + } + endpoint := strings.TrimSuffix(base, "/") + "/v1/rdc/jobs/" + url.PathEscape(slJobUUID) + payload, err := json.Marshal(map[string]bool{"passed": passed}) + if err != nil { + return fmt.Errorf("marshal body: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.SetBasicAuth(user, key) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 31 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("http put: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + logger.Debug("sauce labs: close response body: %v", err) + } + }() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("sauce labs rdc api %s: status %d, body: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 6376322..bf74168 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -438,8 +438,8 @@ type RunConfig struct { // Execution Continuous bool - Headed bool // Show browser window (web only, default is headless) - Browser string // chrome, chromium, or path to binary (web only) + Headed bool // Show browser window (web only, default is headless) + Browser string // chrome, chromium, or path to binary (web only) // Device Platform string @@ -472,6 +472,13 @@ type RunConfig struct { // Flutter NoFlutterFallback bool // Disable automatic Flutter VM Service fallback + + // Sauce Labs (SL) only — RDC job id from session capability appium:jobUuid (real devices). + SL_AppiumJobUUID string + // Sauce Labs (SL) only — WebDriver session id for VMs API pass/fail on emulators/simulators. + SL_AppiumSessionID string + // Sauce Labs (SL) only — true when merged --caps indicate emulator/simulator via deviceName (see sauce_labs.go). + SL_IsEmuSim bool } func hyperlink(url, text string) string { @@ -805,6 +812,28 @@ func executeTest(cfg *RunConfig) error { logger.Info("Flow execution completed: %d passed, %d failed, %d skipped", result.PassedFlows, result.FailedFlows, result.SkippedFlows) + // Sauce Labs: set job passed/fail via REST API. + // RDC (real devices): PUT /v1/rdc/jobs/{appium:jobUuid} + // Emulators/simulators (VDC): PUT /rest/v1/{username}/jobs/{sessionId} + // https://docs.saucelabs.com/dev/api/rdc/#update-a-job + // https://docs.saucelabs.com/dev/api/jobs/#update-a-job + if strings.EqualFold(cfg.Driver, "appium") && appiumURLIsSauceLabs(cfg.AppiumURL) { + slRunPassed := result.Status == report.StatusPassed + if cfg.SL_IsEmuSim && strings.TrimSpace(cfg.SL_AppiumSessionID) != "" { + if err := SL_UpdateSauceLabsVMsAPIPassed(cfg.AppiumURL, cfg.SL_AppiumSessionID, slRunPassed); err != nil { + logger.Warn("Sauce Labs VMs API update failed (session %s): %v", cfg.SL_AppiumSessionID, err) + } else { + logger.Info("Sauce Labs job updated (VMs API, session=%s) passed=%v", cfg.SL_AppiumSessionID, slRunPassed) + } + } else if strings.TrimSpace(cfg.SL_AppiumJobUUID) != "" { + if err := SL_UpdateSauceLabsRDCJobPassed(cfg.AppiumURL, cfg.SL_AppiumJobUUID, slRunPassed); err != nil { + logger.Warn("Sauce Labs RDC job update failed (job %s): %v", cfg.SL_AppiumJobUUID, err) + } else { + logger.Info("Sauce Labs RDC job updated: job=%s passed=%v", cfg.SL_AppiumJobUUID, slRunPassed) + } + } + } + // 6. Print unified output (works for both single and parallel) if err := printUnifiedOutput(cfg.OutputDir, result); err != nil { fmt.Printf("Warning: Failed to print unified output: %v\n", err) @@ -1473,6 +1502,11 @@ func printSummary(result *executor.RunResult) { fmt.Println(strings.Repeat("═", tableWidth)) } +// appiumURLIsSauceLabs returns true when --appium-url points at Sauce Labs (SL) Appium hubs. +func appiumURLIsSauceLabs(appiumURL string) bool { + return strings.Contains(strings.ToLower(strings.TrimSpace(appiumURL)), "saucelabs") +} + // formatDuration formats milliseconds to a human-readable string. // Shows milliseconds for values < 1s, seconds otherwise. func formatDuration(ms int64) string { @@ -1639,6 +1673,13 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { settings["waitForIdleTimeout"] = cfg.WaitForIdleTimeout } + slHub := appiumURLIsSauceLabs(cfg.AppiumURL) + if slHub { + cfg.SL_IsEmuSim = SL_CapsDeviceNameIndicatesEmuSim(caps) + } else { + cfg.SL_IsEmuSim = false + } + printSetupStep("Creating Appium session...") logger.Info("Creating Appium session with capabilities: %v", caps) driver, err := appiumdriver.NewDriver(cfg.AppiumURL, caps) @@ -1647,6 +1688,27 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { return nil, nil, fmt.Errorf("create Appium session: %w", err) } logger.Info("Appium session created successfully: %s", driver.GetPlatformInfo().DeviceID) + if slHub { + // For Sauce VMs, Sauce's {job_id} is the Appium WebDriver session id. + if sid := driver.SessionID(); sid != "" { + cfg.SL_AppiumSessionID = sid + } + + // For Sauce RDC, Sauce's {job_id} is appium:jobUuid. + if u := driver.SLJobUUID(); u != "" { + cfg.SL_AppiumJobUUID = u + if !cfg.SL_IsEmuSim { + logger.Info("Sauce Labs real device session id: %s", u) + } + } + + if cfg.SL_IsEmuSim && strings.TrimSpace(cfg.SL_AppiumSessionID) != "" { + logger.Info("Sauce Labs emulator/simulator session id: %s", cfg.SL_AppiumSessionID) + } + } else { + cfg.SL_AppiumJobUUID = "" + cfg.SL_AppiumSessionID = "" + } printSetupSuccess("Appium session created") // Cleanup function diff --git a/pkg/driver/appium/client.go b/pkg/driver/appium/client.go index f3400ec..38ee1c4 100644 --- a/pkg/driver/appium/client.go +++ b/pkg/driver/appium/client.go @@ -22,6 +22,7 @@ const w3cElementKey = "element-6066-11e4-a52e-4f735466cecf" type Client struct { serverURL string sessionID string + slJobUUID string // Sauce Labs (SL) only: appium:jobUuid from session response capabilities client *http.Client platform string // ios, android screenW int @@ -95,6 +96,10 @@ func (c *Client) Connect(capabilities map[string]interface{}) error { // Simulator UDIDs are UUID format (8-4-4-4-12 hex with dashes) c.isRealDevice = !isUUIDFormat(udid) } + // appium:jobUuid is Sauce Labs (SL) only; ignore on other Appium hubs. + if appiumHubURLIsSauceLabs(c.serverURL) { + c.slJobUUID = SL_JobUUIDFromSessionCaps(caps) + } } // Get screen size @@ -166,9 +171,40 @@ func (c *Client) Disconnect() error { } _, err := c.delete(c.sessionPath()) c.sessionID = "" + c.slJobUUID = "" return err } +// SLJobUUID returns the Sauce Labs (SL) job id from capability appium:jobUuid when the hub is Sauce Labs. +// Empty on non-Sauce hubs or when the capability is not returned. +func (c *Client) SLJobUUID() string { + return c.slJobUUID +} + +// SessionID returns the WebDriver session id. (On Sauce Labs SL VMs, REST pass/fail uses this as job_id.) +func (c *Client) SessionID() string { + return c.sessionID +} + +// appiumHubURLIsSauceLabs reports whether the Appium server URL is a Sauce Labs (SL) hub. +func appiumHubURLIsSauceLabs(serverURL string) bool { + return strings.Contains(strings.ToLower(strings.TrimSpace(serverURL)), "saucelabs") +} + +// SL_JobUUIDFromSessionCaps reads the Sauce Labs (SL) job id from merged session capabilities +// (appium:jobUuid, or jobUuid). Only called when the hub is Sauce Labs. +func SL_JobUUIDFromSessionCaps(caps map[string]interface{}) string { + if caps == nil { + return "" + } + for _, key := range []string{"appium:jobUuid", "jobUuid"} { + if s, ok := caps[key].(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + } + return "" +} + // Platform returns the platform (ios/android). func (c *Client) Platform() string { return c.platform diff --git a/pkg/driver/appium/driver.go b/pkg/driver/appium/driver.go index b075756..c272fc1 100644 --- a/pkg/driver/appium/driver.go +++ b/pkg/driver/appium/driver.go @@ -73,6 +73,16 @@ func (d *Driver) Close() error { return d.client.Disconnect() } +// SLJobUUID returns the Sauce Labs (SL) appium:jobUuid from the session response when available. +func (d *Driver) SLJobUUID() string { + return d.client.SLJobUUID() +} + +// SessionID returns the WebDriver session id (see Client.SessionID for Sauce Labs SL note). +func (d *Driver) SessionID() string { + return d.client.SessionID() +} + // RestartSession closes the existing Appium session and creates a fresh one. func (d *Driver) RestartSession() error { if err := d.client.Disconnect(); err != nil {