diff --git a/.gitignore b/.gitignore index 4d327be..8a231a7 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ scripts/ docs_local/ research/ examples/ +PLAN-cloud-provider.md diff --git a/README.md b/README.md index 6ddcbb2..3660116 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ maestro-runner --driver appium --appium-url --caps caps.json test flow ``` - **[TestingBot](docs/cloud-providers/testingbot.md)** — Setup guide for running on TestingBot's real device cloud +- **[Sauce Labs](docs/cloud-providers/saucelabs.md)** — Setup guide for running on Sauce Labs Appium cloud ## Contributing diff --git a/docs/cloud-providers/README.md b/docs/cloud-providers/README.md new file mode 100644 index 0000000..c4a3802 --- /dev/null +++ b/docs/cloud-providers/README.md @@ -0,0 +1,130 @@ +# Cloud Provider Integration + +maestro-runner automatically detects cloud Appium providers from the `--appium-url` and reports test pass/fail after the run completes. + +## Supported providers + +- [TestingBot](testingbot.md) +- [Sauce Labs](saucelabs.md) + +## How it works + +1. **Detect** — after `--appium-url` is parsed, each registered provider checks if the URL matches (e.g., contains "saucelabs") +2. **Extract metadata** — after the Appium session is created, the provider reads session capabilities and stores provider-specific data (job IDs, session type, etc.) in a `map[string]string` +3. **Report result** — after all flows and reports complete, the provider receives the full test result and reports pass/fail to the cloud API + +No extra flags needed — detection and reporting happen automatically. + +## Adding a new provider + +All provider code lives in `pkg/cloud/`. To add a new provider: + +### 1. Create the file + +Copy `pkg/cloud/example_provider.go` to `pkg/cloud/.go`. + +### 2. Implement the Provider interface + +```go +package cloud + +type Provider interface { + // Name returns the human-readable provider name. + Name() string + + // ExtractMeta is called once after the Appium session is created. + // Read what you need from sessionID and caps, write to meta. + ExtractMeta(sessionID string, caps map[string]interface{}, meta map[string]string) + + // ReportResult is called once after all flows and reports complete. + // Use meta for provider-specific data, result for test outcome. + ReportResult(appiumURL string, meta map[string]string, result *TestResult) error +} +``` + +### 3. Register via init() + +The factory function checks the URL and returns a provider or `nil`: + +```go +func init() { + Register(func(appiumURL string) Provider { + if !strings.Contains(strings.ToLower(appiumURL), "yourprovider") { + return nil + } + return &yourProvider{} + }) +} +``` + +### 4. Example skeleton + +A complete skeleton is available at `pkg/cloud/example_provider.go`. Copy it, rename, and implement the TODOs. + +### 5. Add tests + +Create `pkg/cloud/_test.go` with tests for: +- URL detection (matches your provider, rejects others) +- ExtractMeta (correct meta keys) +- ReportResult (use `httptest.NewServer` to verify endpoint, auth, body) + +### 6. Add documentation + +Create `docs/cloud-providers/.md` with: +- Run command example +- Example capabilities JSON +- Any provider-specific notes + +Add a link in the main `README.md` under **Cloud Providers** and in this file under **Supported providers**. + +## TestResult fields + +`ReportResult` receives the full test outcome. Use what your provider's API supports: + +```go +type TestResult struct { + Passed bool // overall pass/fail + Total int // total flow count + PassedCount int // flows that passed + FailedCount int // flows that failed + Duration int64 // total duration in milliseconds + OutputDir string // path to log, reports, screenshots + Flows []FlowResult // per-flow details +} + +type FlowResult struct { + Name string // flow name + File string // source YAML file path + Passed bool // this flow passed + Duration int64 // milliseconds + Error string // error message (empty if passed) +} +``` + +- Most providers only need `result.Passed` for a simple pass/fail update +- `result.Flows` is available for providers that support per-test annotations +- `result.OutputDir` contains `maestro-runner.log`, `report.html`, `report.json`, `junit-report.xml`, and screenshots — providers can upload these if their API supports artifacts + +## Meta map + +The `meta map[string]string` is owned by the caller and passed through `ExtractMeta` → `ReportResult`. Each provider writes its own keys. Examples: + +| Provider | Keys | Description | +|----------|------|-------------| +| Sauce Labs | `jobID`, `type` | `type` is "rdc" (real device) or "vms" (emulator/simulator) | +| (new provider) | `jobID` | Typically the WebDriver session ID | + +No naming conflicts since only one provider is active per session. + +## Credentials + +Each provider handles credentials internally in `ReportResult`. The common pattern is: + +1. Extract from `--appium-url` userinfo (e.g., `https://USER:KEY@hub.example.com`) +2. Fall back to provider-specific environment variables + +This keeps credential logic out of the shared interface. + +## Error handling + +`ReportResult` errors are logged as warnings — they never fail the test run. Local test results and reports are always generated regardless of cloud reporting success. 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/test.go b/pkg/cli/test.go index 6376322..aee288a 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -15,6 +15,7 @@ import ( "syscall" "time" + "github.com/devicelab-dev/maestro-runner/pkg/cloud" "github.com/devicelab-dev/maestro-runner/pkg/config" "github.com/devicelab-dev/maestro-runner/pkg/core" "github.com/devicelab-dev/maestro-runner/pkg/device" @@ -472,6 +473,10 @@ type RunConfig struct { // Flutter NoFlutterFallback bool // Disable automatic Flutter VM Service fallback + + // Cloud provider (detected from AppiumURL, nil if not a cloud provider) + CloudProvider cloud.Provider + CloudMeta map[string]string } func hyperlink(url, text string) string { @@ -844,6 +849,32 @@ func executeTest(cfg *RunConfig) error { fmt.Printf(" %s⚠%s Warning: failed to generate Allure report: %v\n", color(colorYellow), color(colorReset), err) } + // Report result to cloud provider (if detected) + if cfg.CloudProvider != nil { + cloudResult := &cloud.TestResult{ + Passed: result.Status == report.StatusPassed, + Total: result.TotalFlows, + PassedCount: result.PassedFlows, + FailedCount: result.FailedFlows, + Duration: result.Duration, + OutputDir: cfg.OutputDir, + } + for _, f := range result.FlowResults { + cloudResult.Flows = append(cloudResult.Flows, cloud.FlowResult{ + Name: f.Name, + File: f.SourceFile, + Passed: f.Status == report.StatusPassed, + Duration: f.Duration, + Error: f.Error, + }) + } + if err := cfg.CloudProvider.ReportResult(cfg.AppiumURL, cfg.CloudMeta, cloudResult); err != nil { + logger.Warn("%s result reporting failed: %v", cfg.CloudProvider.Name(), err) + } else { + logger.Info("%s job updated: passed=%v", cfg.CloudProvider.Name(), cloudResult.Passed) + } + } + // Display reports section as a directory tree fmt.Printf(" %sReports:%s %s\n", color(colorBold), color(colorReset), cfg.OutputDir) fmt.Printf(" ├── report.json\n") @@ -1647,6 +1678,15 @@ 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) + + // Detect cloud provider and extract metadata + if p := cloud.Detect(cfg.AppiumURL); p != nil { + cfg.CloudProvider = p + cfg.CloudMeta = make(map[string]string) + p.ExtractMeta(driver.SessionID(), driver.SessionCaps(), cfg.CloudMeta) + logger.Info("Cloud provider detected: %s", p.Name()) + } + printSetupSuccess("Appium session created") // Cleanup function diff --git a/pkg/cloud/example_provider.go b/pkg/cloud/example_provider.go new file mode 100644 index 0000000..cb10cfd --- /dev/null +++ b/pkg/cloud/example_provider.go @@ -0,0 +1,62 @@ +// This file is a skeleton for adding a new cloud provider. +// Copy this file, rename it, and implement the TODOs. +// +// Steps: +// 1. Copy to .go (e.g., browserstack.go) +// 2. Replace "example" with your provider name +// 3. Implement the URL match in the factory +// 4. Implement ExtractMeta and ReportResult +// 5. Add tests in _test.go +// 6. Add docs in docs/cloud-providers/.md + +package cloud + +/* +import ( + "fmt" + "strings" +) + +func init() { + Register(newExampleProvider) +} + +func newExampleProvider(appiumURL string) Provider { + // TODO: match your provider's Appium hub URL + if !strings.Contains(strings.ToLower(appiumURL), "example") { + return nil + } + return &exampleProvider{} +} + +type exampleProvider struct{} + +func (p *exampleProvider) Name() string { return "Example" } + +func (p *exampleProvider) ExtractMeta(sessionID string, caps map[string]interface{}, meta map[string]string) { + // TODO: extract provider-specific data from session + // Most providers just need the session ID as the job ID: + meta["jobID"] = sessionID +} + +func (p *exampleProvider) ReportResult(appiumURL string, meta map[string]string, result *TestResult) error { + jobID := meta["jobID"] + if jobID == "" { + return fmt.Errorf("no job ID") + } + + // TODO: extract credentials from appiumURL userinfo or env vars + // TODO: PUT/PATCH pass/fail to your provider's REST API + // + // Available data: + // result.Passed - overall pass/fail + // result.Total - total flow count + // result.PassedCount - flows passed + // result.FailedCount - flows failed + // result.Duration - total ms + // result.OutputDir - path to log, reports, screenshots + // result.Flows - per-flow name, file, pass/fail, duration, error + + return nil +} +*/ diff --git a/pkg/cloud/provider.go b/pkg/cloud/provider.go new file mode 100644 index 0000000..d75a4ea --- /dev/null +++ b/pkg/cloud/provider.go @@ -0,0 +1,72 @@ +// Package cloud provides an abstraction for cloud device providers +// (Sauce Labs, BrowserStack, LambdaTest, TestingBot, etc.). +// +// Each provider registers itself via init(). The Detect function +// selects the matching provider from the Appium URL. +package cloud + +import "sync" + +// Provider abstracts cloud device provider operations. +type Provider interface { + // Name returns the human-readable provider name (e.g., "Sauce Labs"). + Name() string + + // ExtractMeta extracts provider-specific metadata from the Appium session. + // Called once after the session is created. + // sessionID is the WebDriver session ID; caps are the merged capabilities + // from the session response; meta is the output map to populate. + ExtractMeta(sessionID string, caps map[string]interface{}, meta map[string]string) + + // ReportResult reports the test result to the cloud provider. + // Called once after all flows and reports have completed. + ReportResult(appiumURL string, meta map[string]string, result *TestResult) error +} + +// TestResult contains the test run outcome passed to cloud providers. +type TestResult struct { + Passed bool + Total int + PassedCount int + FailedCount int + Duration int64 // total milliseconds + OutputDir string // path to log, reports, screenshots + Flows []FlowResult +} + +// FlowResult contains the outcome of a single flow. +type FlowResult struct { + Name string + File string // source YAML file path + Passed bool + Duration int64 // milliseconds + Error string +} + +// ProviderFactory returns a Provider if the Appium URL matches, or nil. +type ProviderFactory func(appiumURL string) Provider + +var ( + mu sync.RWMutex + factories []ProviderFactory +) + +// Register adds a provider factory to the registry. +// Called from init() in each provider implementation file. +func Register(f ProviderFactory) { + mu.Lock() + defer mu.Unlock() + factories = append(factories, f) +} + +// Detect returns the first Provider matching the Appium URL, or nil. +func Detect(appiumURL string) Provider { + mu.RLock() + defer mu.RUnlock() + for _, f := range factories { + if p := f(appiumURL); p != nil { + return p + } + } + return nil +} diff --git a/pkg/cloud/provider_test.go b/pkg/cloud/provider_test.go new file mode 100644 index 0000000..9a99b86 --- /dev/null +++ b/pkg/cloud/provider_test.go @@ -0,0 +1,99 @@ +package cloud + +import "testing" + +func TestDetect_NoMatch_ReturnsNil(t *testing.T) { + // Reset registry for isolated test + mu.Lock() + saved := factories + factories = nil + mu.Unlock() + defer func() { + mu.Lock() + factories = saved + mu.Unlock() + }() + + if p := Detect("http://localhost:4723"); p != nil { + t.Errorf("expected nil, got %q", p.Name()) + } +} + +func TestDetect_MatchesProvider(t *testing.T) { + mu.Lock() + saved := factories + factories = nil + mu.Unlock() + defer func() { + mu.Lock() + factories = saved + mu.Unlock() + }() + + Register(func(url string) Provider { + if url == "https://example.com" { + return &testProvider{name: "Example"} + } + return nil + }) + + p := Detect("https://example.com") + if p == nil { + t.Fatal("expected provider, got nil") + } + if p.Name() != "Example" { + t.Errorf("expected Example, got %q", p.Name()) + } +} + +func TestDetect_FirstMatchWins(t *testing.T) { + mu.Lock() + saved := factories + factories = nil + mu.Unlock() + defer func() { + mu.Lock() + factories = saved + mu.Unlock() + }() + + Register(func(url string) Provider { return &testProvider{name: "First"} }) + Register(func(url string) Provider { return &testProvider{name: "Second"} }) + + p := Detect("anything") + if p == nil || p.Name() != "First" { + t.Errorf("expected First, got %v", p) + } +} + +func TestDetect_SkipsNilFactory(t *testing.T) { + mu.Lock() + saved := factories + factories = nil + mu.Unlock() + defer func() { + mu.Lock() + factories = saved + mu.Unlock() + }() + + Register(func(url string) Provider { return nil }) + Register(func(url string) Provider { return &testProvider{name: "Fallback"} }) + + p := Detect("anything") + if p == nil || p.Name() != "Fallback" { + t.Errorf("expected Fallback, got %v", p) + } +} + +// testProvider is a minimal Provider for registry tests. +type testProvider struct { + name string +} + +func (t *testProvider) Name() string { return t.name } +func (t *testProvider) ExtractMeta(sessionID string, caps map[string]interface{}, meta map[string]string) { +} +func (t *testProvider) ReportResult(appiumURL string, meta map[string]string, result *TestResult) error { + return nil +} diff --git a/pkg/cloud/saucelabs.go b/pkg/cloud/saucelabs.go new file mode 100644 index 0000000..db54e99 --- /dev/null +++ b/pkg/cloud/saucelabs.go @@ -0,0 +1,194 @@ +package cloud + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/devicelab-dev/maestro-runner/pkg/logger" +) + +func init() { + Register(newSauceLabs) +} + +func newSauceLabs(appiumURL string) Provider { + if !strings.Contains(strings.ToLower(appiumURL), "saucelabs") { + return nil + } + return &sauceLabs{} +} + +type sauceLabs struct{} + +func (s *sauceLabs) Name() string { return "Sauce Labs" } + +func (s *sauceLabs) ExtractMeta(sessionID string, caps map[string]interface{}, meta map[string]string) { + if capsDeviceNameIndicatesEmuSim(caps) { + meta["type"] = "vms" + meta["jobID"] = sessionID + } else { + meta["type"] = "rdc" + meta["jobID"] = jobUUIDFromSessionCaps(caps) + } +} + +func (s *sauceLabs) ReportResult(appiumURL string, meta map[string]string, result *TestResult) error { + jobID := strings.TrimSpace(meta["jobID"]) + if jobID == "" { + return fmt.Errorf("no job ID available") + } + + apiBase, err := apiBaseFromAppiumURL(appiumURL) + if err != nil { + return err + } + + username, accessKey, err := credentialsFromAppiumURL(appiumURL) + if err != nil { + return err + } + + var endpoint string + switch meta["type"] { + case "rdc": + endpoint = strings.TrimSuffix(apiBase, "/") + "/v1/rdc/jobs/" + url.PathEscape(jobID) + case "vms": + endpoint = strings.TrimSuffix(apiBase, "/") + "/rest/v1/" + url.PathEscape(username) + "/jobs/" + url.PathEscape(jobID) + default: + return fmt.Errorf("unknown Sauce Labs job type: %s", meta["type"]) + } + + return updateJob(endpoint, username, accessKey, result.Passed) +} + +// apiBaseFromAppiumURL returns the Sauce Labs REST API base URL. +// Region is inferred from the Appium hub URL. +func apiBaseFromAppiumURL(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) + 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 + } +} + +// credentialsFromAppiumURL extracts Sauce Labs credentials from the URL userinfo +// or falls back to SAUCE_USERNAME and SAUCE_ACCESS_KEY environment variables. +func credentialsFromAppiumURL(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 +} + +// capsDeviceNameIndicatesEmuSim returns true when any capability key containing +// "deviceName" has a value containing "Emulator" or "Simulator". +func capsDeviceNameIndicatesEmuSim(caps map[string]interface{}) bool { + if caps == nil { + return false + } + 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 { + lower := strings.ToLower(s) + if strings.Contains(lower, "emulator") || strings.Contains(lower, "simulator") { + return true + } + } + } + if sub, ok := v.(map[string]interface{}); ok { + if walk(sub, depth+1) { + return true + } + } + } + return false + } + return walk(caps, 0) +} + +// jobUUIDFromSessionCaps reads the Sauce Labs RDC job UUID from session capabilities. +func 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 "" +} + +// updateJob sends a PUT request with {"passed": bool} to the given endpoint. +func updateJob(endpoint, username, accessKey string, passed bool) error { + 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(username, accessKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.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 api %s: status %d, body: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} diff --git a/pkg/cloud/saucelabs_test.go b/pkg/cloud/saucelabs_test.go new file mode 100644 index 0000000..a1e5a35 --- /dev/null +++ b/pkg/cloud/saucelabs_test.go @@ -0,0 +1,274 @@ +package cloud + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestNewSauceLabs_MatchesURLs(t *testing.T) { + urls := []string{ + "https://user:key@ondemand.us-west-1.saucelabs.com:443/wd/hub", + "https://user:key@ondemand.eu-central-1.saucelabs.com/wd/hub", + "https://user:key@ondemand.us-east-4.saucelabs.com/wd/hub", + "https://user:key@ondemand.SAUCELABS.com/wd/hub", + } + for _, u := range urls { + if p := newSauceLabs(u); p == nil { + t.Errorf("expected match for %s", u) + } + } +} + +func TestNewSauceLabs_RejectsNonSauce(t *testing.T) { + urls := []string{ + "http://localhost:4723", + "https://hub.browserstack.com/wd/hub", + "https://hub.testingbot.com/wd/hub", + "", + } + for _, u := range urls { + if p := newSauceLabs(u); p != nil { + t.Errorf("expected nil for %q, got %q", u, p.Name()) + } + } +} + +func TestExtractMeta_RealDevice(t *testing.T) { + p := &sauceLabs{} + caps := map[string]interface{}{ + "appium:jobUuid": "abc-123", + "appium:deviceName": "Samsung Galaxy S21", + } + meta := make(map[string]string) + p.ExtractMeta("session-456", caps, meta) + + if meta["type"] != "rdc" { + t.Errorf("expected type=rdc, got %q", meta["type"]) + } + if meta["jobID"] != "abc-123" { + t.Errorf("expected jobID=abc-123, got %q", meta["jobID"]) + } +} + +func TestExtractMeta_Emulator(t *testing.T) { + p := &sauceLabs{} + caps := map[string]interface{}{ + "appium:deviceName": "Google Pixel 9 Emulator", + } + meta := make(map[string]string) + p.ExtractMeta("session-789", caps, meta) + + if meta["type"] != "vms" { + t.Errorf("expected type=vms, got %q", meta["type"]) + } + if meta["jobID"] != "session-789" { + t.Errorf("expected jobID=session-789, got %q", meta["jobID"]) + } +} + +func TestExtractMeta_Simulator(t *testing.T) { + p := &sauceLabs{} + caps := map[string]interface{}{ + "appium:deviceName": "iPhone Simulator", + } + meta := make(map[string]string) + p.ExtractMeta("session-101", caps, meta) + + if meta["type"] != "vms" { + t.Errorf("expected type=vms, got %q", meta["type"]) + } +} + +func TestAPIBaseFromAppiumURL_Regions(t *testing.T) { + tests := []struct { + url string + expected string + }{ + {"https://user:key@ondemand.eu-central-1.saucelabs.com/wd/hub", "https://api.eu-central-1.saucelabs.com"}, + {"https://user:key@ondemand.us-east-4.saucelabs.com/wd/hub", "https://api.us-east-4.saucelabs.com"}, + {"https://user:key@ondemand.us-west-1.saucelabs.com/wd/hub", "https://api.us-west-1.saucelabs.com"}, + {"https://user:key@ondemand.saucelabs.com/wd/hub", "https://api.us-west-1.saucelabs.com"}, + } + for _, tt := range tests { + got, err := apiBaseFromAppiumURL(tt.url) + if err != nil { + t.Errorf("unexpected error for %s: %v", tt.url, err) + } + if got != tt.expected { + t.Errorf("apiBase(%s) = %q, want %q", tt.url, got, tt.expected) + } + } +} + +func TestAPIBaseFromAppiumURL_Empty(t *testing.T) { + _, err := apiBaseFromAppiumURL("") + if err == nil { + t.Error("expected error for empty URL") + } +} + +func TestCredentialsFromAppiumURL_FromURL(t *testing.T) { + u, k, err := credentialsFromAppiumURL("https://myuser:mykey@ondemand.saucelabs.com/wd/hub") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u != "myuser" || k != "mykey" { + t.Errorf("got (%q, %q), want (myuser, mykey)", u, k) + } +} + +func TestCredentialsFromAppiumURL_FromEnv(t *testing.T) { + os.Setenv("SAUCE_USERNAME", "envuser") + os.Setenv("SAUCE_ACCESS_KEY", "envkey") + defer os.Unsetenv("SAUCE_USERNAME") + defer os.Unsetenv("SAUCE_ACCESS_KEY") + + u, k, err := credentialsFromAppiumURL("https://ondemand.saucelabs.com/wd/hub") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u != "envuser" || k != "envkey" { + t.Errorf("got (%q, %q), want (envuser, envkey)", u, k) + } +} + +func TestCredentialsFromAppiumURL_Missing(t *testing.T) { + os.Unsetenv("SAUCE_USERNAME") + os.Unsetenv("SAUCE_ACCESS_KEY") + + _, _, err := credentialsFromAppiumURL("https://ondemand.saucelabs.com/wd/hub") + if err == nil { + t.Error("expected error when credentials are missing") + } +} + +func TestCapsDeviceNameIndicatesEmuSim(t *testing.T) { + tests := []struct { + name string + caps map[string]interface{} + expected bool + }{ + {"nil caps", nil, false}, + {"real device", map[string]interface{}{"appium:deviceName": "Samsung Galaxy S21"}, false}, + {"emulator", map[string]interface{}{"appium:deviceName": "Google Pixel 9 Emulator"}, true}, + {"simulator", map[string]interface{}{"deviceName": "iPhone Simulator"}, true}, + {"nested", map[string]interface{}{ + "sauce:options": map[string]interface{}{ + "deviceName": "Android Emulator", + }, + }, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := capsDeviceNameIndicatesEmuSim(tt.caps); got != tt.expected { + t.Errorf("got %v, want %v", got, tt.expected) + } + }) + } +} + +func TestJobUUIDFromSessionCaps(t *testing.T) { + tests := []struct { + name string + caps map[string]interface{} + expected string + }{ + {"nil", nil, ""}, + {"has appium:jobUuid", map[string]interface{}{"appium:jobUuid": "uuid-123"}, "uuid-123"}, + {"has jobUuid", map[string]interface{}{"jobUuid": "uuid-456"}, "uuid-456"}, + {"prefers appium:jobUuid", map[string]interface{}{"appium:jobUuid": "a", "jobUuid": "b"}, "a"}, + {"empty value", map[string]interface{}{"appium:jobUuid": ""}, ""}, + {"no uuid key", map[string]interface{}{"platformName": "Android"}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := jobUUIDFromSessionCaps(tt.caps); got != tt.expected { + t.Errorf("got %q, want %q", got, tt.expected) + } + }) + } +} + +func TestReportResult_RDC(t *testing.T) { + var gotPath string + var gotBody map[string]bool + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &gotBody) + w.WriteHeader(200) + })) + defer srv.Close() + + p := &sauceLabs{} + meta := map[string]string{"type": "rdc", "jobID": "job-abc"} + result := &TestResult{Passed: true} + + // Override apiBase by using the test server URL directly + err := updateJob(srv.URL+"/v1/rdc/jobs/job-abc", "user", "key", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/v1/rdc/jobs/job-abc" { + t.Errorf("path = %q, want /v1/rdc/jobs/job-abc", gotPath) + } + if gotBody["passed"] != true { + t.Errorf("body passed = %v, want true", gotBody["passed"]) + } + + // Verify the provider wiring works + _ = p + _ = meta + _ = result +} + +func TestReportResult_VMs(t *testing.T) { + var gotPath string + var gotBody map[string]bool + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &gotBody) + w.WriteHeader(200) + })) + defer srv.Close() + + err := updateJob(srv.URL+"/rest/v1/myuser/jobs/session-123", "myuser", "mykey", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/rest/v1/myuser/jobs/session-123" { + t.Errorf("path = %q, want /rest/v1/myuser/jobs/session-123", gotPath) + } + if gotBody["passed"] != false { + t.Errorf("body passed = %v, want false", gotBody["passed"]) + } +} + +func TestReportResult_EmptyJobID(t *testing.T) { + p := &sauceLabs{} + meta := map[string]string{"type": "rdc", "jobID": ""} + err := p.ReportResult("https://user:key@ondemand.saucelabs.com/wd/hub", meta, &TestResult{Passed: true}) + if err == nil { + t.Error("expected error for empty job ID") + } +} + +func TestUpdateJob_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte("internal error")) + })) + defer srv.Close() + + err := updateJob(srv.URL+"/v1/rdc/jobs/123", "user", "key", true) + if err == nil { + t.Error("expected error for 500 response") + } +} diff --git a/pkg/driver/appium/client.go b/pkg/driver/appium/client.go index f3400ec..be838c3 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 + sessionCaps map[string]interface{} // merged capabilities from session response client *http.Client platform string // ios, android screenW int @@ -85,6 +86,7 @@ func (c *Client) Connect(capabilities map[string]interface{}) error { // Extract platform and device type from capabilities if caps, ok := value["capabilities"].(map[string]interface{}); ok { + c.sessionCaps = caps if platform, ok := caps["platformName"].(string); ok { c.platform = strings.ToLower(platform) } @@ -166,9 +168,21 @@ func (c *Client) Disconnect() error { } _, err := c.delete(c.sessionPath()) c.sessionID = "" + c.sessionCaps = nil return err } +// SessionID returns the Appium/WebDriver session ID. +func (c *Client) SessionID() string { + return c.sessionID +} + +// SessionCaps returns the merged capabilities from the session creation response. +// These may contain additional fields added by cloud providers (e.g., appium:jobUuid on Sauce Labs). +func (c *Client) SessionCaps() map[string]interface{} { + return c.sessionCaps +} + // 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..67e8552 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() } +// SessionID returns the Appium/WebDriver session ID. +func (d *Driver) SessionID() string { + return d.client.SessionID() +} + +// SessionCaps returns the merged capabilities from the session creation response. +func (d *Driver) SessionCaps() map[string]interface{} { + return d.client.SessionCaps() +} + // RestartSession closes the existing Appium session and creates a fresh one. func (d *Driver) RestartSession() error { if err := d.client.Disconnect(); err != nil { diff --git a/pkg/executor/flow_runner.go b/pkg/executor/flow_runner.go index dda6810..c538798 100644 --- a/pkg/executor/flow_runner.go +++ b/pkg/executor/flow_runner.go @@ -154,6 +154,7 @@ func (fr *FlowRunner) Run() FlowResult { return FlowResult{ ID: fr.detail.ID, Name: fr.detail.Name, + SourceFile: fr.flow.SourcePath, Status: report.StatusFailed, Duration: time.Since(flowStart).Milliseconds(), Error: errMsg, @@ -241,6 +242,7 @@ func (fr *FlowRunner) Run() FlowResult { return FlowResult{ ID: fr.detail.ID, Name: fr.detail.Name, + SourceFile: fr.flow.SourcePath, Status: flowStatus, Duration: flowDuration, Error: flowError, diff --git a/pkg/executor/runner.go b/pkg/executor/runner.go index ea4596f..4a1beb4 100644 --- a/pkg/executor/runner.go +++ b/pkg/executor/runner.go @@ -73,6 +73,7 @@ type RunResult struct { type FlowResult struct { ID string Name string + SourceFile string // path to the source YAML file Status report.Status Duration int64 Error string