From 975e6ba0e2b75f9ee31aa40cea506eec4a03cdb3 Mon Sep 17 00:00:00 2001 From: eyaly Date: Sun, 29 Mar 2026 17:15:12 +0100 Subject: [PATCH 01/15] feat(cli): Sauce Labs test status logging and RDC job update - Log final pass/fail with Sauce platform and appium:jobUuid in maestro-runner.log - Parse job UUID from session on Sauce hubs only; expose via Driver.JobUUID - PUT RDC /v1/rdc/jobs/{id} passed flag via sauce_labs.go (regional API base) Made-with: Cursor --- pkg/cli/sauce_labs.go | 129 ++++++++++++++++++++++++++++++++++++ pkg/cli/test.go | 47 +++++++++++++ pkg/driver/appium/client.go | 30 +++++++++ pkg/driver/appium/driver.go | 5 ++ 4 files changed, 211 insertions(+) create mode 100644 pkg/cli/sauce_labs.go diff --git a/pkg/cli/sauce_labs.go b/pkg/cli/sauce_labs.go new file mode 100644 index 0000000..cd697ce --- /dev/null +++ b/pkg/cli/sauce_labs.go @@ -0,0 +1,129 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/devicelab-dev/maestro-runner/pkg/logger" +) + +// 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 +} + +// updateSauceLabsRDCJobPassed calls PUT /v1/rdc/jobs/{job_id} with {"passed": true|false}. +// job_id is the value from capability appium:jobUuid for applicable Appium sessions on Sauce Labs. +// See https://docs.saucelabs.com/dev/api/rdc/#update-a-job +func updateSauceLabsRDCJobPassed(appiumURL, jobUUID string, passed bool) error { + jobUUID = strings.TrimSpace(jobUUID) + if jobUUID == "" { + 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(jobUUID) + 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..f4a6f5a 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -472,6 +472,9 @@ type RunConfig struct { // Flutter NoFlutterFallback bool // Disable automatic Flutter VM Service fallback + + // Appium (filled when session returns appium:jobUuid, e.g. Sauce Labs) + AppiumJobUUID string } func hyperlink(url, text string) string { @@ -805,6 +808,17 @@ func executeTest(cfg *RunConfig) error { logger.Info("Flow execution completed: %d passed, %d failed, %d skipped", result.PassedFlows, result.FailedFlows, result.SkippedFlows) + // Sauce Labs RDC: set job passed/fail via REST API (job id = appium:jobUuid). + // https://docs.saucelabs.com/dev/api/rdc/#update-a-job + if strings.EqualFold(cfg.Driver, "appium") && appiumURLIsSauceLabs(cfg.AppiumURL) && strings.TrimSpace(cfg.AppiumJobUUID) != "" { + rdcPassed := result.Status == report.StatusPassed + if err := updateSauceLabsRDCJobPassed(cfg.AppiumURL, cfg.AppiumJobUUID, rdcPassed); err != nil { + logger.Warn("Sauce Labs RDC job update failed (job %s): %v", cfg.AppiumJobUUID, err) + } else { + logger.Info("Sauce Labs RDC job updated: job=%s passed=%v", cfg.AppiumJobUUID, rdcPassed) + } + } + // 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) @@ -875,6 +889,20 @@ func executeTest(cfg *RunConfig) error { // 8. Print footer printFooter() + // Final outcome in maestro-runner.log (not stdout) + sauceNote := "" + if appiumURLIsSauceLabs(cfg.AppiumURL) { + sauceNote = " — platform: Sauce Labs (Appium cloud)" + } + jobNote := appiumJobUUIDLogFragment(cfg.AppiumJobUUID) + if result.Status == report.StatusPassed { + logger.Info("=== Test run finished: PASSED (exit 0) — %d/%d flows passed, duration %s%s%s ===", + result.PassedFlows, result.TotalFlows, formatDuration(result.Duration), sauceNote, jobNote) + } else { + logger.Info("=== Test run finished: %s (exit 1) — %d/%d flows passed, %d failed, duration %s%s%s ===", + result.Status, result.PassedFlows, result.TotalFlows, result.FailedFlows, formatDuration(result.Duration), sauceNote, jobNote) + } + // Exit with code 1 if any flows failed (summary already printed) if result.Status != report.StatusPassed { return cli.Exit("", 1) @@ -1473,6 +1501,19 @@ func printSummary(result *executor.RunResult) { fmt.Println(strings.Repeat("═", tableWidth)) } +// appiumURLIsSauceLabs returns true when --appium-url points at Sauce Labs hubs. +func appiumURLIsSauceLabs(appiumURL string) bool { + return strings.Contains(strings.ToLower(strings.TrimSpace(appiumURL)), "saucelabs") +} + +// appiumJobUUIDLogFragment appends appium:jobUuid to summary logs when known. +func appiumJobUUIDLogFragment(jobUUID string) string { + if strings.TrimSpace(jobUUID) == "" { + return "" + } + return ", appium:jobUuid=" + strings.TrimSpace(jobUUID) +} + // formatDuration formats milliseconds to a human-readable string. // Shows milliseconds for values < 1s, seconds otherwise. func formatDuration(ms int64) string { @@ -1647,6 +1688,12 @@ 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 u := driver.JobUUID(); u != "" { + cfg.AppiumJobUUID = u + if appiumURLIsSauceLabs(cfg.AppiumURL) { + logger.Info("Sauce Labs appium:jobUuid=%s", u) + } + } printSetupSuccess("Appium session created") // Cleanup function diff --git a/pkg/driver/appium/client.go b/pkg/driver/appium/client.go index f3400ec..a1af977 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 + jobUUID string // Sauce Labs 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–specific; ignore on other Appium hubs. + if appiumHubURLIsSauceLabs(c.serverURL) { + c.jobUUID = appiumJobUUIDFromCaps(caps) + } } // Get screen size @@ -166,9 +171,34 @@ func (c *Client) Disconnect() error { } _, err := c.delete(c.sessionPath()) c.sessionID = "" + c.jobUUID = "" return err } +// JobUUID returns Sauce Labs job id from capability appium:jobUuid when present in the session response. +func (c *Client) JobUUID() string { + return c.jobUUID +} + +// appiumHubURLIsSauceLabs returns true when the Appium server URL points at Sauce Labs. +func appiumHubURLIsSauceLabs(serverURL string) bool { + return strings.Contains(strings.ToLower(strings.TrimSpace(serverURL)), "saucelabs") +} + +// appiumJobUUIDFromCaps reads Sauce Labs job id from merged session capabilities +// (appium:jobUuid, or jobUuid). Only called when the hub is Sauce Labs. +func appiumJobUUIDFromCaps(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..afff388 100644 --- a/pkg/driver/appium/driver.go +++ b/pkg/driver/appium/driver.go @@ -73,6 +73,11 @@ func (d *Driver) Close() error { return d.client.Disconnect() } +// JobUUID returns Sauce Labs appium:jobUuid from the session response when available. +func (d *Driver) JobUUID() string { + return d.client.JobUUID() +} + // RestartSession closes the existing Appium session and creates a fresh one. func (d *Driver) RestartSession() error { if err := d.client.Disconnect(); err != nil { From 19f7b8fa1937a0a5f9ed6bbe059cc7e24ce4b0ad Mon Sep 17 00:00:00 2001 From: eyaly Date: Sun, 29 Mar 2026 20:56:42 +0100 Subject: [PATCH 02/15] Add support for emulators and simulators on Sauce Labs --- pkg/cli/sauce_labs.go | 88 +++++++++++++++++++++++++++++++++++++ pkg/cli/test.go | 67 ++++++++++++++++++++++------ pkg/driver/appium/client.go | 5 +++ pkg/driver/appium/driver.go | 5 +++ 4 files changed, 151 insertions(+), 14 deletions(-) diff --git a/pkg/cli/sauce_labs.go b/pkg/cli/sauce_labs.go index cd697ce..c58da55 100644 --- a/pkg/cli/sauce_labs.go +++ b/pkg/cli/sauce_labs.go @@ -77,6 +77,94 @@ func sauceCredentialsFromAppiumURL(appiumURL string) (username, accessKey string return username, accessKey, nil } +// sauceCapsDeviceNameIndicatesSimulatorOrEmulator 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). +// Used to choose Sauce VMs API (VDC) vs RDC API for pass/fail updates. +func sauceCapsDeviceNameIndicatesSimulatorOrEmulator(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) +} + +// updateSauceLabsVMsAPIPassed calls PUT /rest/v1/{username}/jobs/{job_id} with {"passed": true|false}. +// For Appium on Sauce emulators/simulators, job_id is the WebDriver session id (not appium:jobUuid). +// See https://docs.saucelabs.com/dev/api/jobs/#update-a-job +func updateSauceLabsVMsAPIPassed(appiumURL, jobID string, passed bool) error { + jobID = strings.TrimSpace(jobID) + if jobID == "" { + 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(jobID) + 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 +} + // updateSauceLabsRDCJobPassed calls PUT /v1/rdc/jobs/{job_id} with {"passed": true|false}. // job_id is the value from capability appium:jobUuid for applicable Appium sessions on Sauce Labs. // See https://docs.saucelabs.com/dev/api/rdc/#update-a-job diff --git a/pkg/cli/test.go b/pkg/cli/test.go index f4a6f5a..13c3c48 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -473,8 +473,12 @@ type RunConfig struct { // Flutter NoFlutterFallback bool // Disable automatic Flutter VM Service fallback - // Appium (filled when session returns appium:jobUuid, e.g. Sauce Labs) + // Appium (filled when session returns appium:jobUuid, e.g. Sauce Labs RDC) AppiumJobUUID string + // Appium WebDriver session id (used for Sauce Labs VMs API on emulators/simulators). + AppiumSessionID string + // True when --caps (merged) indicate a Sauce emulator/simulator via deviceName (see sauce_labs.go). + SauceCapsIndicateSimulatorOrEmulator bool } func hyperlink(url, text string) string { @@ -808,14 +812,25 @@ func executeTest(cfg *RunConfig) error { logger.Info("Flow execution completed: %d passed, %d failed, %d skipped", result.PassedFlows, result.FailedFlows, result.SkippedFlows) - // Sauce Labs RDC: set job passed/fail via REST API (job id = appium:jobUuid). + // 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 - if strings.EqualFold(cfg.Driver, "appium") && appiumURLIsSauceLabs(cfg.AppiumURL) && strings.TrimSpace(cfg.AppiumJobUUID) != "" { + // https://docs.saucelabs.com/dev/api/jobs/#update-a-job + if strings.EqualFold(cfg.Driver, "appium") && appiumURLIsSauceLabs(cfg.AppiumURL) { rdcPassed := result.Status == report.StatusPassed - if err := updateSauceLabsRDCJobPassed(cfg.AppiumURL, cfg.AppiumJobUUID, rdcPassed); err != nil { - logger.Warn("Sauce Labs RDC job update failed (job %s): %v", cfg.AppiumJobUUID, err) - } else { - logger.Info("Sauce Labs RDC job updated: job=%s passed=%v", cfg.AppiumJobUUID, rdcPassed) + if cfg.SauceCapsIndicateSimulatorOrEmulator && strings.TrimSpace(cfg.AppiumSessionID) != "" { + if err := updateSauceLabsVMsAPIPassed(cfg.AppiumURL, cfg.AppiumSessionID, rdcPassed); err != nil { + logger.Warn("Sauce Labs VMs API update failed (session %s): %v", cfg.AppiumSessionID, err) + } else { + logger.Info("Sauce Labs job updated (VMs API, session=%s) passed=%v", cfg.AppiumSessionID, rdcPassed) + } + } else if strings.TrimSpace(cfg.AppiumJobUUID) != "" { + if err := updateSauceLabsRDCJobPassed(cfg.AppiumURL, cfg.AppiumJobUUID, rdcPassed); err != nil { + logger.Warn("Sauce Labs RDC job update failed (job %s): %v", cfg.AppiumJobUUID, err) + } else { + logger.Info("Sauce Labs RDC job updated: job=%s passed=%v", cfg.AppiumJobUUID, rdcPassed) + } } } @@ -894,7 +909,7 @@ func executeTest(cfg *RunConfig) error { if appiumURLIsSauceLabs(cfg.AppiumURL) { sauceNote = " — platform: Sauce Labs (Appium cloud)" } - jobNote := appiumJobUUIDLogFragment(cfg.AppiumJobUUID) + jobNote := sauceLabsJobLogFragment(cfg) if result.Status == report.StatusPassed { logger.Info("=== Test run finished: PASSED (exit 0) — %d/%d flows passed, duration %s%s%s ===", result.PassedFlows, result.TotalFlows, formatDuration(result.Duration), sauceNote, jobNote) @@ -1506,12 +1521,21 @@ func appiumURLIsSauceLabs(appiumURL string) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(appiumURL)), "saucelabs") } -// appiumJobUUIDLogFragment appends appium:jobUuid to summary logs when known. -func appiumJobUUIDLogFragment(jobUUID string) string { - if strings.TrimSpace(jobUUID) == "" { +// sauceLabsJobLogFragment appends Sauce job identity to summary logs when known (RDC job UUID or VDC session id). +func sauceLabsJobLogFragment(cfg *RunConfig) string { + if cfg == nil { return "" } - return ", appium:jobUuid=" + strings.TrimSpace(jobUUID) + if cfg.SauceCapsIndicateSimulatorOrEmulator { + if sid := strings.TrimSpace(cfg.AppiumSessionID); sid != "" { + return ", sessionId=" + sid + } + return "" + } + if u := strings.TrimSpace(cfg.AppiumJobUUID); u != "" { + return ", appium:jobUuid=" + u + } + return "" } // formatDuration formats milliseconds to a human-readable string. @@ -1591,7 +1615,14 @@ func executeAppiumSingleSession(cfg *RunConfig, flows []flow.Flow) (*executor.Ru OnFlowEnd: onFlowEnd, }) - return runner.Run(context.Background(), flows) + result, runErr := runner.Run(context.Background(), flows) + // Refresh session id for Sauce VMs API (flows may call RestartSession). + if ad, ok := driver.(*appiumdriver.Driver); ok { + if sid := ad.SessionID(); sid != "" { + cfg.AppiumSessionID = sid + } + } + return result, runErr } // CreateDriver creates the appropriate driver for the platform. @@ -1680,6 +1711,8 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { settings["waitForIdleTimeout"] = cfg.WaitForIdleTimeout } + cfg.SauceCapsIndicateSimulatorOrEmulator = sauceCapsDeviceNameIndicatesSimulatorOrEmulator(caps) + printSetupStep("Creating Appium session...") logger.Info("Creating Appium session with capabilities: %v", caps) driver, err := appiumdriver.NewDriver(cfg.AppiumURL, caps) @@ -1688,12 +1721,18 @@ 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 sid := driver.SessionID(); sid != "" { + cfg.AppiumSessionID = sid + } if u := driver.JobUUID(); u != "" { cfg.AppiumJobUUID = u - if appiumURLIsSauceLabs(cfg.AppiumURL) { + if appiumURLIsSauceLabs(cfg.AppiumURL) && !cfg.SauceCapsIndicateSimulatorOrEmulator { logger.Info("Sauce Labs appium:jobUuid=%s", u) } } + if appiumURLIsSauceLabs(cfg.AppiumURL) && cfg.SauceCapsIndicateSimulatorOrEmulator && strings.TrimSpace(cfg.AppiumSessionID) != "" { + logger.Info("Sauce Labs emulator/simulator session: sessionId=%s (VMs API for pass/fail)", cfg.AppiumSessionID) + } printSetupSuccess("Appium session created") // Cleanup function diff --git a/pkg/driver/appium/client.go b/pkg/driver/appium/client.go index a1af977..aca3797 100644 --- a/pkg/driver/appium/client.go +++ b/pkg/driver/appium/client.go @@ -180,6 +180,11 @@ func (c *Client) JobUUID() string { return c.jobUUID } +// SessionID returns the WebDriver session id (Sauce Labs VMs API job_id for emulators/simulators). +func (c *Client) SessionID() string { + return c.sessionID +} + // appiumHubURLIsSauceLabs returns true when the Appium server URL points at Sauce Labs. func appiumHubURLIsSauceLabs(serverURL string) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(serverURL)), "saucelabs") diff --git a/pkg/driver/appium/driver.go b/pkg/driver/appium/driver.go index afff388..bc743dd 100644 --- a/pkg/driver/appium/driver.go +++ b/pkg/driver/appium/driver.go @@ -78,6 +78,11 @@ func (d *Driver) JobUUID() string { return d.client.JobUUID() } +// SessionID returns the WebDriver session id. +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 { From 8ed1381394f9424a669c1016c60f39e57d8967b9 Mon Sep 17 00:00:00 2001 From: eyaly Date: Sun, 29 Mar 2026 22:00:25 +0100 Subject: [PATCH 03/15] Change parameters to include SL (Sauce Labs) --- pkg/cli/sauce_labs.go | 28 ++++++++------- pkg/cli/test.go | 68 +++++++++++++++++++------------------ pkg/driver/appium/client.go | 23 +++++++------ pkg/driver/appium/driver.go | 8 ++--- 4 files changed, 66 insertions(+), 61 deletions(-) diff --git a/pkg/cli/sauce_labs.go b/pkg/cli/sauce_labs.go index c58da55..4ba076b 100644 --- a/pkg/cli/sauce_labs.go +++ b/pkg/cli/sauce_labs.go @@ -15,6 +15,8 @@ import ( "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. @@ -77,11 +79,11 @@ func sauceCredentialsFromAppiumURL(appiumURL string) (username, accessKey string return username, accessKey, nil } -// sauceCapsDeviceNameIndicatesSimulatorOrEmulator returns true when any capability key +// slCapsDeviceNameIndicatesSimulatorOrEmulator 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). -// Used to choose Sauce VMs API (VDC) vs RDC API for pass/fail updates. -func sauceCapsDeviceNameIndicatesSimulatorOrEmulator(caps map[string]interface{}) bool { +// Sauce Labs (SL) only: used to choose VMs API (VDC) vs RDC API for pass/fail updates. +func slCapsDeviceNameIndicatesSimulatorOrEmulator(caps map[string]interface{}) bool { if caps == nil { return false } @@ -115,11 +117,11 @@ func sauceCapsDeviceNameIndicatesSimulatorOrEmulator(caps map[string]interface{} } // updateSauceLabsVMsAPIPassed calls PUT /rest/v1/{username}/jobs/{job_id} with {"passed": true|false}. -// For Appium on Sauce emulators/simulators, job_id is the WebDriver session id (not appium:jobUuid). +// 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 updateSauceLabsVMsAPIPassed(appiumURL, jobID string, passed bool) error { - jobID = strings.TrimSpace(jobID) - if jobID == "" { +func updateSauceLabsVMsAPIPassed(appiumURL, slSessionID string, passed bool) error { + slSessionID = strings.TrimSpace(slSessionID) + if slSessionID == "" { return fmt.Errorf("empty job id") } base, err := sauceLabsAPIBaseFromAppiumURL(appiumURL) @@ -130,7 +132,7 @@ func updateSauceLabsVMsAPIPassed(appiumURL, jobID string, passed bool) error { if err != nil { return err } - endpoint := strings.TrimSuffix(base, "/") + "/rest/v1/" + url.PathEscape(user) + "/jobs/" + url.PathEscape(jobID) + 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) @@ -166,11 +168,11 @@ func updateSauceLabsVMsAPIPassed(appiumURL, jobID string, passed bool) error { } // updateSauceLabsRDCJobPassed calls PUT /v1/rdc/jobs/{job_id} with {"passed": true|false}. -// job_id is the value from capability appium:jobUuid for applicable Appium sessions on Sauce Labs. +// 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 updateSauceLabsRDCJobPassed(appiumURL, jobUUID string, passed bool) error { - jobUUID = strings.TrimSpace(jobUUID) - if jobUUID == "" { +func updateSauceLabsRDCJobPassed(appiumURL, slJobUUID string, passed bool) error { + slJobUUID = strings.TrimSpace(slJobUUID) + if slJobUUID == "" { return fmt.Errorf("empty job id") } base, err := sauceLabsAPIBaseFromAppiumURL(appiumURL) @@ -181,7 +183,7 @@ func updateSauceLabsRDCJobPassed(appiumURL, jobUUID string, passed bool) error { if err != nil { return err } - endpoint := strings.TrimSuffix(base, "/") + "/v1/rdc/jobs/" + url.PathEscape(jobUUID) + 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) diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 13c3c48..71c151c 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -473,12 +473,12 @@ type RunConfig struct { // Flutter NoFlutterFallback bool // Disable automatic Flutter VM Service fallback - // Appium (filled when session returns appium:jobUuid, e.g. Sauce Labs RDC) - AppiumJobUUID string - // Appium WebDriver session id (used for Sauce Labs VMs API on emulators/simulators). - AppiumSessionID string - // True when --caps (merged) indicate a Sauce emulator/simulator via deviceName (see sauce_labs.go). - SauceCapsIndicateSimulatorOrEmulator bool + // Sauce Labs (SL) only — RDC job id from session capability appium:jobUuid (real devices). + AppiumSLJobUUID string + // Sauce Labs (SL) only — WebDriver session id for VMs API pass/fail on emulators/simulators. + AppiumSLSessionID string + // Sauce Labs (SL) only — true when merged --caps indicate emulator/simulator via deviceName (see sauce_labs.go). + SLCapsIndicateSimulatorOrEmulator bool } func hyperlink(url, text string) string { @@ -818,18 +818,18 @@ func executeTest(cfg *RunConfig) error { // 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) { - rdcPassed := result.Status == report.StatusPassed - if cfg.SauceCapsIndicateSimulatorOrEmulator && strings.TrimSpace(cfg.AppiumSessionID) != "" { - if err := updateSauceLabsVMsAPIPassed(cfg.AppiumURL, cfg.AppiumSessionID, rdcPassed); err != nil { - logger.Warn("Sauce Labs VMs API update failed (session %s): %v", cfg.AppiumSessionID, err) + slRunPassed := result.Status == report.StatusPassed + if cfg.SLCapsIndicateSimulatorOrEmulator && strings.TrimSpace(cfg.AppiumSLSessionID) != "" { + if err := updateSauceLabsVMsAPIPassed(cfg.AppiumURL, cfg.AppiumSLSessionID, slRunPassed); err != nil { + logger.Warn("Sauce Labs VMs API update failed (session %s): %v", cfg.AppiumSLSessionID, err) } else { - logger.Info("Sauce Labs job updated (VMs API, session=%s) passed=%v", cfg.AppiumSessionID, rdcPassed) + logger.Info("Sauce Labs job updated (VMs API, session=%s) passed=%v", cfg.AppiumSLSessionID, slRunPassed) } - } else if strings.TrimSpace(cfg.AppiumJobUUID) != "" { - if err := updateSauceLabsRDCJobPassed(cfg.AppiumURL, cfg.AppiumJobUUID, rdcPassed); err != nil { - logger.Warn("Sauce Labs RDC job update failed (job %s): %v", cfg.AppiumJobUUID, err) + } else if strings.TrimSpace(cfg.AppiumSLJobUUID) != "" { + if err := updateSauceLabsRDCJobPassed(cfg.AppiumURL, cfg.AppiumSLJobUUID, slRunPassed); err != nil { + logger.Warn("Sauce Labs RDC job update failed (job %s): %v", cfg.AppiumSLJobUUID, err) } else { - logger.Info("Sauce Labs RDC job updated: job=%s passed=%v", cfg.AppiumJobUUID, rdcPassed) + logger.Info("Sauce Labs RDC job updated: job=%s passed=%v", cfg.AppiumSLJobUUID, slRunPassed) } } } @@ -909,7 +909,7 @@ func executeTest(cfg *RunConfig) error { if appiumURLIsSauceLabs(cfg.AppiumURL) { sauceNote = " — platform: Sauce Labs (Appium cloud)" } - jobNote := sauceLabsJobLogFragment(cfg) + jobNote := slRunLogSuffix(cfg) if result.Status == report.StatusPassed { logger.Info("=== Test run finished: PASSED (exit 0) — %d/%d flows passed, duration %s%s%s ===", result.PassedFlows, result.TotalFlows, formatDuration(result.Duration), sauceNote, jobNote) @@ -1516,23 +1516,23 @@ func printSummary(result *executor.RunResult) { fmt.Println(strings.Repeat("═", tableWidth)) } -// appiumURLIsSauceLabs returns true when --appium-url points at Sauce Labs hubs. +// 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") } -// sauceLabsJobLogFragment appends Sauce job identity to summary logs when known (RDC job UUID or VDC session id). -func sauceLabsJobLogFragment(cfg *RunConfig) string { +// slRunLogSuffix appends Sauce Labs (SL) job identity to summary logs when known (RDC job UUID or VMs session id). +func slRunLogSuffix(cfg *RunConfig) string { if cfg == nil { return "" } - if cfg.SauceCapsIndicateSimulatorOrEmulator { - if sid := strings.TrimSpace(cfg.AppiumSessionID); sid != "" { + if cfg.SLCapsIndicateSimulatorOrEmulator { + if sid := strings.TrimSpace(cfg.AppiumSLSessionID); sid != "" { return ", sessionId=" + sid } return "" } - if u := strings.TrimSpace(cfg.AppiumJobUUID); u != "" { + if u := strings.TrimSpace(cfg.AppiumSLJobUUID); u != "" { return ", appium:jobUuid=" + u } return "" @@ -1616,10 +1616,10 @@ func executeAppiumSingleSession(cfg *RunConfig, flows []flow.Flow) (*executor.Ru }) result, runErr := runner.Run(context.Background(), flows) - // Refresh session id for Sauce VMs API (flows may call RestartSession). - if ad, ok := driver.(*appiumdriver.Driver); ok { + // Refresh Sauce Labs (SL) session id for VMs API (flows may call RestartSession). + if ad, ok := driver.(*appiumdriver.Driver); ok && appiumURLIsSauceLabs(cfg.AppiumURL) { if sid := ad.SessionID(); sid != "" { - cfg.AppiumSessionID = sid + cfg.AppiumSLSessionID = sid } } return result, runErr @@ -1711,7 +1711,7 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { settings["waitForIdleTimeout"] = cfg.WaitForIdleTimeout } - cfg.SauceCapsIndicateSimulatorOrEmulator = sauceCapsDeviceNameIndicatesSimulatorOrEmulator(caps) + cfg.SLCapsIndicateSimulatorOrEmulator = slCapsDeviceNameIndicatesSimulatorOrEmulator(caps) printSetupStep("Creating Appium session...") logger.Info("Creating Appium session with capabilities: %v", caps) @@ -1721,17 +1721,19 @@ 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 sid := driver.SessionID(); sid != "" { - cfg.AppiumSessionID = sid + if appiumURLIsSauceLabs(cfg.AppiumURL) { + if sid := driver.SessionID(); sid != "" { + cfg.AppiumSLSessionID = sid + } } - if u := driver.JobUUID(); u != "" { - cfg.AppiumJobUUID = u - if appiumURLIsSauceLabs(cfg.AppiumURL) && !cfg.SauceCapsIndicateSimulatorOrEmulator { + if u := driver.SLJobUUID(); u != "" { + cfg.AppiumSLJobUUID = u + if appiumURLIsSauceLabs(cfg.AppiumURL) && !cfg.SLCapsIndicateSimulatorOrEmulator { logger.Info("Sauce Labs appium:jobUuid=%s", u) } } - if appiumURLIsSauceLabs(cfg.AppiumURL) && cfg.SauceCapsIndicateSimulatorOrEmulator && strings.TrimSpace(cfg.AppiumSessionID) != "" { - logger.Info("Sauce Labs emulator/simulator session: sessionId=%s (VMs API for pass/fail)", cfg.AppiumSessionID) + if appiumURLIsSauceLabs(cfg.AppiumURL) && cfg.SLCapsIndicateSimulatorOrEmulator && strings.TrimSpace(cfg.AppiumSLSessionID) != "" { + logger.Info("Sauce Labs emulator/simulator session: sessionId=%s (VMs API for pass/fail)", cfg.AppiumSLSessionID) } printSetupSuccess("Appium session created") diff --git a/pkg/driver/appium/client.go b/pkg/driver/appium/client.go index aca3797..dcfcf8e 100644 --- a/pkg/driver/appium/client.go +++ b/pkg/driver/appium/client.go @@ -22,7 +22,7 @@ const w3cElementKey = "element-6066-11e4-a52e-4f735466cecf" type Client struct { serverURL string sessionID string - jobUUID string // Sauce Labs appium:jobUuid from session response capabilities + slJobUUID string // Sauce Labs (SL) only: appium:jobUuid from session response capabilities client *http.Client platform string // ios, android screenW int @@ -96,9 +96,9 @@ 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–specific; ignore on other Appium hubs. + // appium:jobUuid is Sauce Labs (SL) only; ignore on other Appium hubs. if appiumHubURLIsSauceLabs(c.serverURL) { - c.jobUUID = appiumJobUUIDFromCaps(caps) + c.slJobUUID = slJobUUIDFromSessionCaps(caps) } } @@ -171,28 +171,29 @@ func (c *Client) Disconnect() error { } _, err := c.delete(c.sessionPath()) c.sessionID = "" - c.jobUUID = "" + c.slJobUUID = "" return err } -// JobUUID returns Sauce Labs job id from capability appium:jobUuid when present in the session response. -func (c *Client) JobUUID() string { - return c.jobUUID +// 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 (Sauce Labs VMs API job_id for emulators/simulators). +// 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 returns true when the Appium server URL points at Sauce Labs. +// 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") } -// appiumJobUUIDFromCaps reads Sauce Labs job id from merged session capabilities +// slJobUUIDFromSessionCaps reads the Sauce Labs (SL) job id from merged session capabilities // (appium:jobUuid, or jobUuid). Only called when the hub is Sauce Labs. -func appiumJobUUIDFromCaps(caps map[string]interface{}) string { +func slJobUUIDFromSessionCaps(caps map[string]interface{}) string { if caps == nil { return "" } diff --git a/pkg/driver/appium/driver.go b/pkg/driver/appium/driver.go index bc743dd..c272fc1 100644 --- a/pkg/driver/appium/driver.go +++ b/pkg/driver/appium/driver.go @@ -73,12 +73,12 @@ func (d *Driver) Close() error { return d.client.Disconnect() } -// JobUUID returns Sauce Labs appium:jobUuid from the session response when available. -func (d *Driver) JobUUID() string { - return d.client.JobUUID() +// 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. +// SessionID returns the WebDriver session id (see Client.SessionID for Sauce Labs SL note). func (d *Driver) SessionID() string { return d.client.SessionID() } From bb23424cfaf772905b4b2afac9c2671aecc73742 Mon Sep 17 00:00:00 2001 From: eyaly Date: Tue, 31 Mar 2026 18:03:21 +0100 Subject: [PATCH 04/15] Amend Sauce Labs parameters --- pkg/cli/sauce_labs.go | 12 +++++----- pkg/cli/test.go | 48 ++++++++++++++++++------------------- pkg/driver/appium/client.go | 6 ++--- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/pkg/cli/sauce_labs.go b/pkg/cli/sauce_labs.go index 4ba076b..4fbc496 100644 --- a/pkg/cli/sauce_labs.go +++ b/pkg/cli/sauce_labs.go @@ -79,11 +79,11 @@ func sauceCredentialsFromAppiumURL(appiumURL string) (username, accessKey string return username, accessKey, nil } -// slCapsDeviceNameIndicatesSimulatorOrEmulator returns true when any capability key +// SL_CapsDeviceNameIndicatesSimulatorOrEmulator 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 slCapsDeviceNameIndicatesSimulatorOrEmulator(caps map[string]interface{}) bool { +func SL_CapsDeviceNameIndicatesSimulatorOrEmulator(caps map[string]interface{}) bool { if caps == nil { return false } @@ -116,10 +116,10 @@ func slCapsDeviceNameIndicatesSimulatorOrEmulator(caps map[string]interface{}) b return walk(caps, 0) } -// updateSauceLabsVMsAPIPassed calls PUT /rest/v1/{username}/jobs/{job_id} with {"passed": true|false}. +// 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 updateSauceLabsVMsAPIPassed(appiumURL, slSessionID string, passed bool) error { +func SL_UpdateSauceLabsVMsAPIPassed(appiumURL, slSessionID string, passed bool) error { slSessionID = strings.TrimSpace(slSessionID) if slSessionID == "" { return fmt.Errorf("empty job id") @@ -167,10 +167,10 @@ func updateSauceLabsVMsAPIPassed(appiumURL, slSessionID string, passed bool) err return nil } -// updateSauceLabsRDCJobPassed calls PUT /v1/rdc/jobs/{job_id} with {"passed": true|false}. +// 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 updateSauceLabsRDCJobPassed(appiumURL, slJobUUID string, passed bool) error { +func SL_UpdateSauceLabsRDCJobPassed(appiumURL, slJobUUID string, passed bool) error { slJobUUID = strings.TrimSpace(slJobUUID) if slJobUUID == "" { return fmt.Errorf("empty job id") diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 71c151c..610fa59 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -474,11 +474,11 @@ type RunConfig struct { NoFlutterFallback bool // Disable automatic Flutter VM Service fallback // Sauce Labs (SL) only — RDC job id from session capability appium:jobUuid (real devices). - AppiumSLJobUUID string + SL_AppiumJobUUID string // Sauce Labs (SL) only — WebDriver session id for VMs API pass/fail on emulators/simulators. - AppiumSLSessionID string + SL_AppiumSessionID string // Sauce Labs (SL) only — true when merged --caps indicate emulator/simulator via deviceName (see sauce_labs.go). - SLCapsIndicateSimulatorOrEmulator bool + SL_CapsIndicateSimulatorOrEmulator bool } func hyperlink(url, text string) string { @@ -819,17 +819,17 @@ func executeTest(cfg *RunConfig) error { // 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.SLCapsIndicateSimulatorOrEmulator && strings.TrimSpace(cfg.AppiumSLSessionID) != "" { - if err := updateSauceLabsVMsAPIPassed(cfg.AppiumURL, cfg.AppiumSLSessionID, slRunPassed); err != nil { - logger.Warn("Sauce Labs VMs API update failed (session %s): %v", cfg.AppiumSLSessionID, err) + if cfg.SL_CapsIndicateSimulatorOrEmulator && 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.AppiumSLSessionID, slRunPassed) + logger.Info("Sauce Labs job updated (VMs API, session=%s) passed=%v", cfg.SL_AppiumSessionID, slRunPassed) } - } else if strings.TrimSpace(cfg.AppiumSLJobUUID) != "" { - if err := updateSauceLabsRDCJobPassed(cfg.AppiumURL, cfg.AppiumSLJobUUID, slRunPassed); err != nil { - logger.Warn("Sauce Labs RDC job update failed (job %s): %v", cfg.AppiumSLJobUUID, err) + } 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.AppiumSLJobUUID, slRunPassed) + logger.Info("Sauce Labs RDC job updated: job=%s passed=%v", cfg.SL_AppiumJobUUID, slRunPassed) } } } @@ -909,7 +909,7 @@ func executeTest(cfg *RunConfig) error { if appiumURLIsSauceLabs(cfg.AppiumURL) { sauceNote = " — platform: Sauce Labs (Appium cloud)" } - jobNote := slRunLogSuffix(cfg) + jobNote := SL_RunLogSuffix(cfg) if result.Status == report.StatusPassed { logger.Info("=== Test run finished: PASSED (exit 0) — %d/%d flows passed, duration %s%s%s ===", result.PassedFlows, result.TotalFlows, formatDuration(result.Duration), sauceNote, jobNote) @@ -1521,18 +1521,18 @@ func appiumURLIsSauceLabs(appiumURL string) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(appiumURL)), "saucelabs") } -// slRunLogSuffix appends Sauce Labs (SL) job identity to summary logs when known (RDC job UUID or VMs session id). -func slRunLogSuffix(cfg *RunConfig) string { +// SL_RunLogSuffix appends Sauce Labs (SL) job identity to summary logs when known (RDC job UUID or VMs session id). +func SL_RunLogSuffix(cfg *RunConfig) string { if cfg == nil { return "" } - if cfg.SLCapsIndicateSimulatorOrEmulator { - if sid := strings.TrimSpace(cfg.AppiumSLSessionID); sid != "" { + if cfg.SL_CapsIndicateSimulatorOrEmulator { + if sid := strings.TrimSpace(cfg.SL_AppiumSessionID); sid != "" { return ", sessionId=" + sid } return "" } - if u := strings.TrimSpace(cfg.AppiumSLJobUUID); u != "" { + if u := strings.TrimSpace(cfg.SL_AppiumJobUUID); u != "" { return ", appium:jobUuid=" + u } return "" @@ -1619,7 +1619,7 @@ func executeAppiumSingleSession(cfg *RunConfig, flows []flow.Flow) (*executor.Ru // Refresh Sauce Labs (SL) session id for VMs API (flows may call RestartSession). if ad, ok := driver.(*appiumdriver.Driver); ok && appiumURLIsSauceLabs(cfg.AppiumURL) { if sid := ad.SessionID(); sid != "" { - cfg.AppiumSLSessionID = sid + cfg.SL_AppiumSessionID = sid } } return result, runErr @@ -1711,7 +1711,7 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { settings["waitForIdleTimeout"] = cfg.WaitForIdleTimeout } - cfg.SLCapsIndicateSimulatorOrEmulator = slCapsDeviceNameIndicatesSimulatorOrEmulator(caps) + cfg.SL_CapsIndicateSimulatorOrEmulator = SL_CapsDeviceNameIndicatesSimulatorOrEmulator(caps) printSetupStep("Creating Appium session...") logger.Info("Creating Appium session with capabilities: %v", caps) @@ -1723,17 +1723,17 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { logger.Info("Appium session created successfully: %s", driver.GetPlatformInfo().DeviceID) if appiumURLIsSauceLabs(cfg.AppiumURL) { if sid := driver.SessionID(); sid != "" { - cfg.AppiumSLSessionID = sid + cfg.SL_AppiumSessionID = sid } } if u := driver.SLJobUUID(); u != "" { - cfg.AppiumSLJobUUID = u - if appiumURLIsSauceLabs(cfg.AppiumURL) && !cfg.SLCapsIndicateSimulatorOrEmulator { + cfg.SL_AppiumJobUUID = u + if appiumURLIsSauceLabs(cfg.AppiumURL) && !cfg.SL_CapsIndicateSimulatorOrEmulator { logger.Info("Sauce Labs appium:jobUuid=%s", u) } } - if appiumURLIsSauceLabs(cfg.AppiumURL) && cfg.SLCapsIndicateSimulatorOrEmulator && strings.TrimSpace(cfg.AppiumSLSessionID) != "" { - logger.Info("Sauce Labs emulator/simulator session: sessionId=%s (VMs API for pass/fail)", cfg.AppiumSLSessionID) + if appiumURLIsSauceLabs(cfg.AppiumURL) && cfg.SL_CapsIndicateSimulatorOrEmulator && strings.TrimSpace(cfg.SL_AppiumSessionID) != "" { + logger.Info("Sauce Labs emulator/simulator session: sessionId=%s (VMs API for pass/fail)", cfg.SL_AppiumSessionID) } printSetupSuccess("Appium session created") diff --git a/pkg/driver/appium/client.go b/pkg/driver/appium/client.go index dcfcf8e..38ee1c4 100644 --- a/pkg/driver/appium/client.go +++ b/pkg/driver/appium/client.go @@ -98,7 +98,7 @@ func (c *Client) Connect(capabilities map[string]interface{}) error { } // appium:jobUuid is Sauce Labs (SL) only; ignore on other Appium hubs. if appiumHubURLIsSauceLabs(c.serverURL) { - c.slJobUUID = slJobUUIDFromSessionCaps(caps) + c.slJobUUID = SL_JobUUIDFromSessionCaps(caps) } } @@ -191,9 +191,9 @@ func appiumHubURLIsSauceLabs(serverURL string) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(serverURL)), "saucelabs") } -// slJobUUIDFromSessionCaps reads the Sauce Labs (SL) job id from merged session capabilities +// 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 slJobUUIDFromSessionCaps(caps map[string]interface{}) string { +func SL_JobUUIDFromSessionCaps(caps map[string]interface{}) string { if caps == nil { return "" } From 74083c2a05062357b9c89d4e3eb0073ee6dc4016 Mon Sep 17 00:00:00 2001 From: eyaly Date: Tue, 31 Mar 2026 19:55:52 +0100 Subject: [PATCH 05/15] wrap code with checking if this is for sauce labs --- pkg/cli/test.go | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 610fa59..7816afe 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -1526,6 +1526,9 @@ func SL_RunLogSuffix(cfg *RunConfig) string { if cfg == nil { return "" } + if !appiumURLIsSauceLabs(cfg.AppiumURL) { + return "" + } if cfg.SL_CapsIndicateSimulatorOrEmulator { if sid := strings.TrimSpace(cfg.SL_AppiumSessionID); sid != "" { return ", sessionId=" + sid @@ -1711,7 +1714,11 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { settings["waitForIdleTimeout"] = cfg.WaitForIdleTimeout } - cfg.SL_CapsIndicateSimulatorOrEmulator = SL_CapsDeviceNameIndicatesSimulatorOrEmulator(caps) + if appiumURLIsSauceLabs(cfg.AppiumURL) { + cfg.SL_CapsIndicateSimulatorOrEmulator = SL_CapsDeviceNameIndicatesSimulatorOrEmulator(caps) + } else { + cfg.SL_CapsIndicateSimulatorOrEmulator = false + } printSetupStep("Creating Appium session...") logger.Info("Creating Appium session with capabilities: %v", caps) @@ -1726,11 +1733,15 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { cfg.SL_AppiumSessionID = sid } } - if u := driver.SLJobUUID(); u != "" { - cfg.SL_AppiumJobUUID = u - if appiumURLIsSauceLabs(cfg.AppiumURL) && !cfg.SL_CapsIndicateSimulatorOrEmulator { - logger.Info("Sauce Labs appium:jobUuid=%s", u) + if appiumURLIsSauceLabs(cfg.AppiumURL) { + if u := driver.SLJobUUID(); u != "" { + cfg.SL_AppiumJobUUID = u + if !cfg.SL_CapsIndicateSimulatorOrEmulator { + logger.Info("Sauce Labs appium:jobUuid=%s", u) + } } + } else { + cfg.SL_AppiumJobUUID = "" } if appiumURLIsSauceLabs(cfg.AppiumURL) && cfg.SL_CapsIndicateSimulatorOrEmulator && strings.TrimSpace(cfg.SL_AppiumSessionID) != "" { logger.Info("Sauce Labs emulator/simulator session: sessionId=%s (VMs API for pass/fail)", cfg.SL_AppiumSessionID) From 6f5e4e20785cb797e652feb901646cf2ec818223 Mon Sep 17 00:00:00 2001 From: eyaly Date: Wed, 1 Apr 2026 11:35:45 +0100 Subject: [PATCH 06/15] add saucelabs readme guide --- README.md | 2 + docs/cloud-providers/saucelabs.md | 88 +++++++++++++++++++++++++++++++ pkg/cli/sauce_labs.go | 4 +- pkg/cli/test.go | 14 ++--- 4 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 docs/cloud-providers/saucelabs.md 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..5304ea0 --- /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=", + "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=", + "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=", + "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 index 4fbc496..0419b2d 100644 --- a/pkg/cli/sauce_labs.go +++ b/pkg/cli/sauce_labs.go @@ -79,11 +79,11 @@ func sauceCredentialsFromAppiumURL(appiumURL string) (username, accessKey string return username, accessKey, nil } -// SL_CapsDeviceNameIndicatesSimulatorOrEmulator returns true when any capability key +// 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_CapsDeviceNameIndicatesSimulatorOrEmulator(caps map[string]interface{}) bool { +func SL_CapsDeviceNameIndicatesEmuSim(caps map[string]interface{}) bool { if caps == nil { return false } diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 7816afe..9128b15 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -478,7 +478,7 @@ type RunConfig struct { // 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_CapsIndicateSimulatorOrEmulator bool + SL_IsEmuSim bool } func hyperlink(url, text string) string { @@ -819,7 +819,7 @@ func executeTest(cfg *RunConfig) error { // 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_CapsIndicateSimulatorOrEmulator && strings.TrimSpace(cfg.SL_AppiumSessionID) != "" { + 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 { @@ -1529,7 +1529,7 @@ func SL_RunLogSuffix(cfg *RunConfig) string { if !appiumURLIsSauceLabs(cfg.AppiumURL) { return "" } - if cfg.SL_CapsIndicateSimulatorOrEmulator { + if cfg.SL_IsEmuSim { if sid := strings.TrimSpace(cfg.SL_AppiumSessionID); sid != "" { return ", sessionId=" + sid } @@ -1715,9 +1715,9 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { } if appiumURLIsSauceLabs(cfg.AppiumURL) { - cfg.SL_CapsIndicateSimulatorOrEmulator = SL_CapsDeviceNameIndicatesSimulatorOrEmulator(caps) + cfg.SL_IsEmuSim = SL_CapsDeviceNameIndicatesEmuSim(caps) } else { - cfg.SL_CapsIndicateSimulatorOrEmulator = false + cfg.SL_IsEmuSim = false } printSetupStep("Creating Appium session...") @@ -1736,14 +1736,14 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { if appiumURLIsSauceLabs(cfg.AppiumURL) { if u := driver.SLJobUUID(); u != "" { cfg.SL_AppiumJobUUID = u - if !cfg.SL_CapsIndicateSimulatorOrEmulator { + if !cfg.SL_IsEmuSim { logger.Info("Sauce Labs appium:jobUuid=%s", u) } } } else { cfg.SL_AppiumJobUUID = "" } - if appiumURLIsSauceLabs(cfg.AppiumURL) && cfg.SL_CapsIndicateSimulatorOrEmulator && strings.TrimSpace(cfg.SL_AppiumSessionID) != "" { + if appiumURLIsSauceLabs(cfg.AppiumURL) && cfg.SL_IsEmuSim && strings.TrimSpace(cfg.SL_AppiumSessionID) != "" { logger.Info("Sauce Labs emulator/simulator session: sessionId=%s (VMs API for pass/fail)", cfg.SL_AppiumSessionID) } printSetupSuccess("Appium session created") From 022594338af211034ec574011d7cd38530f95bf6 Mon Sep 17 00:00:00 2001 From: eyaly Date: Wed, 1 Apr 2026 11:38:36 +0100 Subject: [PATCH 07/15] Update saucelabs.md --- docs/cloud-providers/saucelabs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cloud-providers/saucelabs.md b/docs/cloud-providers/saucelabs.md index 5304ea0..0e3214d 100644 --- a/docs/cloud-providers/saucelabs.md +++ b/docs/cloud-providers/saucelabs.md @@ -58,7 +58,7 @@ Example `provider-caps.json` for Android emulator: "appium:automationName": "UiAutomator2", "appium:deviceName": "Google Pixel 9 Emulator", "appium:platformVersion": "16.0", - "appium:app": "storage:filename=", + "appium:app": "storage:filename=", "sauce:options": { "build": "Maestro Android Emulator Run", "appiumVersion": "2.11.0" From 961da32aea97a4471e8216360ec5018bb4ad9bb4 Mon Sep 17 00:00:00 2001 From: eyaly Date: Wed, 1 Apr 2026 11:40:13 +0100 Subject: [PATCH 08/15] Update saucelabs.md --- docs/cloud-providers/saucelabs.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cloud-providers/saucelabs.md b/docs/cloud-providers/saucelabs.md index 0e3214d..0faec17 100644 --- a/docs/cloud-providers/saucelabs.md +++ b/docs/cloud-providers/saucelabs.md @@ -58,7 +58,7 @@ Example `provider-caps.json` for Android emulator: "appium:automationName": "UiAutomator2", "appium:deviceName": "Google Pixel 9 Emulator", "appium:platformVersion": "16.0", - "appium:app": "storage:filename=", + "appium:app": "storage:filename=", "sauce:options": { "build": "Maestro Android Emulator Run", "appiumVersion": "2.11.0" @@ -74,7 +74,7 @@ Example `provider-caps.json` for iOS simulator: "appium:automationName": "XCUITest", "appium:deviceName": "iPhone Simulator", "appium:platformVersion": "17.0", - "appium:app": "storage:filename=SauceLabs-Demo-App.Simulator.zip", + "appium:app": "storage:filename=ZIP_FILE_NAME_IN_SAUCE_STORAGE", "sauce:options": { "build": "Maestro iOS Simulator Run", "appiumVersion": "2.11.3" From 16e21dd8b1484d6a7cc283ddd9b0102424a045fa Mon Sep 17 00:00:00 2001 From: eyaly Date: Wed, 1 Apr 2026 11:40:54 +0100 Subject: [PATCH 09/15] Update saucelabs.md --- docs/cloud-providers/saucelabs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cloud-providers/saucelabs.md b/docs/cloud-providers/saucelabs.md index 0faec17..de96a93 100644 --- a/docs/cloud-providers/saucelabs.md +++ b/docs/cloud-providers/saucelabs.md @@ -74,7 +74,7 @@ Example `provider-caps.json` for iOS simulator: "appium:automationName": "XCUITest", "appium:deviceName": "iPhone Simulator", "appium:platformVersion": "17.0", - "appium:app": "storage:filename=ZIP_FILE_NAME_IN_SAUCE_STORAGE", + "appium:app": "storage:filename=", "sauce:options": { "build": "Maestro iOS Simulator Run", "appiumVersion": "2.11.3" From dd7591f869a539fadbf3916bebbc920c69da90de Mon Sep 17 00:00:00 2001 From: eyaly Date: Wed, 1 Apr 2026 11:45:13 +0100 Subject: [PATCH 10/15] Update saucelabs.md --- docs/cloud-providers/saucelabs.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cloud-providers/saucelabs.md b/docs/cloud-providers/saucelabs.md index de96a93..acd6fde 100644 --- a/docs/cloud-providers/saucelabs.md +++ b/docs/cloud-providers/saucelabs.md @@ -25,7 +25,7 @@ Example `provider-caps.json` for Android real device: "appium:automationName": "UiAutomator2", "appium:deviceName": "Samsung.*", "appium:platformVersion": "^1[5-6].*", - "appium:app": "storage:filename=", + "appium:app": "storage:filename=mda-2.2.0-25.apk", "sauce:options": { "build": "Maestro Android Run", "appiumVersion": "latest" @@ -41,7 +41,7 @@ Example `provider-caps.json` for iOS real device: "appium:automationName": "XCUITest", "appium:deviceName": "iPhone.*", "appium:platformVersion": "^(18|26).*", - "appium:app": "storage:filename=", + "appium:app": "storage:filename=SauceLabs-Demo-App.ipa", "sauce:options": { "build": "Maestro iOS Run", "appiumVersion": "latest", @@ -58,7 +58,7 @@ Example `provider-caps.json` for Android emulator: "appium:automationName": "UiAutomator2", "appium:deviceName": "Google Pixel 9 Emulator", "appium:platformVersion": "16.0", - "appium:app": "storage:filename=", + "appium:app": "storage:filename=mda-2.2.0-25.apk", "sauce:options": { "build": "Maestro Android Emulator Run", "appiumVersion": "2.11.0" @@ -74,7 +74,7 @@ Example `provider-caps.json` for iOS simulator: "appium:automationName": "XCUITest", "appium:deviceName": "iPhone Simulator", "appium:platformVersion": "17.0", - "appium:app": "storage:filename=", + "appium:app": "storage:filename=SauceLabs-Demo-App.Simulator.zip", "sauce:options": { "build": "Maestro iOS Simulator Run", "appiumVersion": "2.11.3" From 989864270444ff50522a96a5e1026e1e08ebee39 Mon Sep 17 00:00:00 2001 From: eyaly Date: Wed, 1 Apr 2026 13:27:29 +0100 Subject: [PATCH 11/15] Update sauce_labs.go --- pkg/cli/sauce_labs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cli/sauce_labs.go b/pkg/cli/sauce_labs.go index 0419b2d..d0ca85d 100644 --- a/pkg/cli/sauce_labs.go +++ b/pkg/cli/sauce_labs.go @@ -74,7 +74,7 @@ func sauceCredentialsFromAppiumURL(appiumURL string) (username, accessKey string 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 "", "", 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 } From d25f3133ba216aadbc83c8a226c7ff054a0d00ef Mon Sep 17 00:00:00 2001 From: eyaly Date: Wed, 1 Apr 2026 14:59:00 +0100 Subject: [PATCH 12/15] Update test.go --- pkg/cli/test.go | 33 ++++----------------------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 9128b15..28c40e3 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -905,17 +905,12 @@ func executeTest(cfg *RunConfig) error { printFooter() // Final outcome in maestro-runner.log (not stdout) - sauceNote := "" - if appiumURLIsSauceLabs(cfg.AppiumURL) { - sauceNote = " — platform: Sauce Labs (Appium cloud)" - } - jobNote := SL_RunLogSuffix(cfg) if result.Status == report.StatusPassed { - logger.Info("=== Test run finished: PASSED (exit 0) — %d/%d flows passed, duration %s%s%s ===", - result.PassedFlows, result.TotalFlows, formatDuration(result.Duration), sauceNote, jobNote) + logger.Info("=== Test run finished: PASSED (exit 0) — %d/%d flows passed, duration %s ===", + result.PassedFlows, result.TotalFlows, formatDuration(result.Duration)) } else { - logger.Info("=== Test run finished: %s (exit 1) — %d/%d flows passed, %d failed, duration %s%s%s ===", - result.Status, result.PassedFlows, result.TotalFlows, result.FailedFlows, formatDuration(result.Duration), sauceNote, jobNote) + logger.Info("=== Test run finished: %s (exit 1) — %d/%d flows passed, %d failed, duration %s ===", + result.Status, result.PassedFlows, result.TotalFlows, result.FailedFlows, formatDuration(result.Duration)) } // Exit with code 1 if any flows failed (summary already printed) @@ -1521,26 +1516,6 @@ func appiumURLIsSauceLabs(appiumURL string) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(appiumURL)), "saucelabs") } -// SL_RunLogSuffix appends Sauce Labs (SL) job identity to summary logs when known (RDC job UUID or VMs session id). -func SL_RunLogSuffix(cfg *RunConfig) string { - if cfg == nil { - return "" - } - if !appiumURLIsSauceLabs(cfg.AppiumURL) { - return "" - } - if cfg.SL_IsEmuSim { - if sid := strings.TrimSpace(cfg.SL_AppiumSessionID); sid != "" { - return ", sessionId=" + sid - } - return "" - } - if u := strings.TrimSpace(cfg.SL_AppiumJobUUID); u != "" { - return ", appium:jobUuid=" + u - } - return "" -} - // formatDuration formats milliseconds to a human-readable string. // Shows milliseconds for values < 1s, seconds otherwise. func formatDuration(ms int64) string { From 2b7e934622649c528004358bda0606b24cf0b859 Mon Sep 17 00:00:00 2001 From: eyaly Date: Wed, 1 Apr 2026 15:36:58 +0100 Subject: [PATCH 13/15] Update test.go --- pkg/cli/test.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 28c40e3..3a205a4 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -904,15 +904,6 @@ func executeTest(cfg *RunConfig) error { // 8. Print footer printFooter() - // Final outcome in maestro-runner.log (not stdout) - if result.Status == report.StatusPassed { - logger.Info("=== Test run finished: PASSED (exit 0) — %d/%d flows passed, duration %s ===", - result.PassedFlows, result.TotalFlows, formatDuration(result.Duration)) - } else { - logger.Info("=== Test run finished: %s (exit 1) — %d/%d flows passed, %d failed, duration %s ===", - result.Status, result.PassedFlows, result.TotalFlows, result.FailedFlows, formatDuration(result.Duration)) - } - // Exit with code 1 if any flows failed (summary already printed) if result.Status != report.StatusPassed { return cli.Exit("", 1) From 6dea1d37a20c239f63b1f852c6a83ddc96aabd4a Mon Sep 17 00:00:00 2001 From: eyaly Date: Wed, 1 Apr 2026 15:41:25 +0100 Subject: [PATCH 14/15] Update test.go --- pkg/cli/test.go | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 3a205a4..68a4c72 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -1584,14 +1584,7 @@ func executeAppiumSingleSession(cfg *RunConfig, flows []flow.Flow) (*executor.Ru OnFlowEnd: onFlowEnd, }) - result, runErr := runner.Run(context.Background(), flows) - // Refresh Sauce Labs (SL) session id for VMs API (flows may call RestartSession). - if ad, ok := driver.(*appiumdriver.Driver); ok && appiumURLIsSauceLabs(cfg.AppiumURL) { - if sid := ad.SessionID(); sid != "" { - cfg.SL_AppiumSessionID = sid - } - } - return result, runErr + return runner.Run(context.Background(), flows) } // CreateDriver creates the appropriate driver for the platform. From 5ed81f29dff2bdde81194b6dd5d9b49cfbc29411 Mon Sep 17 00:00:00 2001 From: eyaly Date: Thu, 2 Apr 2026 12:20:33 +0100 Subject: [PATCH 15/15] Update test.go --- pkg/cli/test.go | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 68a4c72..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 @@ -1673,7 +1673,8 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) { settings["waitForIdleTimeout"] = cfg.WaitForIdleTimeout } - if appiumURLIsSauceLabs(cfg.AppiumURL) { + slHub := appiumURLIsSauceLabs(cfg.AppiumURL) + if slHub { cfg.SL_IsEmuSim = SL_CapsDeviceNameIndicatesEmuSim(caps) } else { cfg.SL_IsEmuSim = false @@ -1687,23 +1688,26 @@ 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 appiumURLIsSauceLabs(cfg.AppiumURL) { + if slHub { + // For Sauce VMs, Sauce's {job_id} is the Appium WebDriver session id. if sid := driver.SessionID(); sid != "" { cfg.SL_AppiumSessionID = sid } - } - if appiumURLIsSauceLabs(cfg.AppiumURL) { + + // 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 appium:jobUuid=%s", u) + 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 = "" - } - if appiumURLIsSauceLabs(cfg.AppiumURL) && cfg.SL_IsEmuSim && strings.TrimSpace(cfg.SL_AppiumSessionID) != "" { - logger.Info("Sauce Labs emulator/simulator session: sessionId=%s (VMs API for pass/fail)", cfg.SL_AppiumSessionID) + cfg.SL_AppiumSessionID = "" } printSetupSuccess("Appium session created")