Skip to content
Closed
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ maestro-runner runs Maestro YAML flows on cloud device grids via the Appium driv
maestro-runner --driver appium --appium-url <HUB_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
Expand Down
88 changes: 88 additions & 0 deletions docs/cloud-providers/saucelabs.md
Original file line number Diff line number Diff line change
@@ -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)
219 changes: 219 additions & 0 deletions pkg/cli/sauce_labs.go
Original file line number Diff line number Diff line change
@@ -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://<SAUCE_USERNAME>:<SAUCE_ACCESS_KEY>@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
}
Loading