Skip to content

Commit 4a3a8fa

Browse files
committed
Add cloud provider abstraction for test result reporting
Generic Provider interface so adding new cloud providers (Sauce Labs, BrowserStack, LambdaTest, TestingBot, etc.) requires one file — zero changes to test.go. - pkg/cloud/provider.go: Provider interface, TestResult/FlowResult types, registry with Detect() and Register() - pkg/cloud/saucelabs.go: Sauce Labs implementation (RDC + VMs APIs, region detection, credential extraction) - pkg/cloud/example_provider.go: skeleton for new providers - pkg/driver/appium: expose SessionID() and SessionCaps() from client/driver - pkg/cli/test.go: wire up cloud detection in createAppiumDriver and result reporting in executeTest after report generation - docs/cloud-providers/: Sauce Labs guide and contributor README Sauce Labs logic based on #43
1 parent 30d3cff commit 4a3a8fa

12 files changed

Lines changed: 982 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ scripts/
4949
docs_local/
5050
research/
5151
examples/
52+
PLAN-cloud-provider.md

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ maestro-runner --driver appium --appium-url <HUB_URL> --caps caps.json test flow
140140
```
141141

142142
- **[TestingBot](docs/cloud-providers/testingbot.md)** — Setup guide for running on TestingBot's real device cloud
143+
- **[Sauce Labs](docs/cloud-providers/saucelabs.md)** — Setup guide for running on Sauce Labs Appium cloud
143144

144145
## Contributing
145146

docs/cloud-providers/README.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Cloud Provider Integration
2+
3+
maestro-runner automatically detects cloud Appium providers from the `--appium-url` and reports test pass/fail after the run completes.
4+
5+
## Supported providers
6+
7+
- [TestingBot](testingbot.md)
8+
- [Sauce Labs](saucelabs.md)
9+
10+
## How it works
11+
12+
1. **Detect** — after `--appium-url` is parsed, each registered provider checks if the URL matches (e.g., contains "saucelabs")
13+
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`
14+
3. **Report result** — after all flows and reports complete, the provider receives the full test result and reports pass/fail to the cloud API
15+
16+
No extra flags needed — detection and reporting happen automatically.
17+
18+
## Adding a new provider
19+
20+
All provider code lives in `pkg/cloud/`. To add a new provider:
21+
22+
### 1. Create the file
23+
24+
Copy `pkg/cloud/example_provider.go` to `pkg/cloud/<yourprovider>.go`.
25+
26+
### 2. Implement the Provider interface
27+
28+
```go
29+
package cloud
30+
31+
type Provider interface {
32+
// Name returns the human-readable provider name.
33+
Name() string
34+
35+
// ExtractMeta is called once after the Appium session is created.
36+
// Read what you need from sessionID and caps, write to meta.
37+
ExtractMeta(sessionID string, caps map[string]interface{}, meta map[string]string)
38+
39+
// ReportResult is called once after all flows and reports complete.
40+
// Use meta for provider-specific data, result for test outcome.
41+
ReportResult(appiumURL string, meta map[string]string, result *TestResult) error
42+
}
43+
```
44+
45+
### 3. Register via init()
46+
47+
The factory function checks the URL and returns a provider or `nil`:
48+
49+
```go
50+
func init() {
51+
Register(func(appiumURL string) Provider {
52+
if !strings.Contains(strings.ToLower(appiumURL), "yourprovider") {
53+
return nil
54+
}
55+
return &yourProvider{}
56+
})
57+
}
58+
```
59+
60+
### 4. Example skeleton
61+
62+
A complete skeleton is available at `pkg/cloud/example_provider.go`. Copy it, rename, and implement the TODOs.
63+
64+
### 5. Add tests
65+
66+
Create `pkg/cloud/<yourprovider>_test.go` with tests for:
67+
- URL detection (matches your provider, rejects others)
68+
- ExtractMeta (correct meta keys)
69+
- ReportResult (use `httptest.NewServer` to verify endpoint, auth, body)
70+
71+
### 6. Add documentation
72+
73+
Create `docs/cloud-providers/<yourprovider>.md` with:
74+
- Run command example
75+
- Example capabilities JSON
76+
- Any provider-specific notes
77+
78+
Add a link in the main `README.md` under **Cloud Providers** and in this file under **Supported providers**.
79+
80+
## TestResult fields
81+
82+
`ReportResult` receives the full test outcome. Use what your provider's API supports:
83+
84+
```go
85+
type TestResult struct {
86+
Passed bool // overall pass/fail
87+
Total int // total flow count
88+
PassedCount int // flows that passed
89+
FailedCount int // flows that failed
90+
Duration int64 // total duration in milliseconds
91+
OutputDir string // path to log, reports, screenshots
92+
Flows []FlowResult // per-flow details
93+
}
94+
95+
type FlowResult struct {
96+
Name string // flow name
97+
Passed bool // this flow passed
98+
Duration int64 // milliseconds
99+
Error string // error message (empty if passed)
100+
}
101+
```
102+
103+
- Most providers only need `result.Passed` for a simple pass/fail update
104+
- `result.Flows` is available for providers that support per-test annotations
105+
- `result.OutputDir` contains `maestro-runner.log`, `report.html`, `report.json`, `junit-report.xml`, and screenshots — providers can upload these if their API supports artifacts
106+
107+
## Meta map
108+
109+
The `meta map[string]string` is owned by the caller and passed through `ExtractMeta``ReportResult`. Each provider writes its own keys. Examples:
110+
111+
| Provider | Keys | Description |
112+
|----------|------|-------------|
113+
| Sauce Labs | `jobID`, `type` | `type` is "rdc" (real device) or "vms" (emulator/simulator) |
114+
| (new provider) | `jobID` | Typically the WebDriver session ID |
115+
116+
No naming conflicts since only one provider is active per session.
117+
118+
## Credentials
119+
120+
Each provider handles credentials internally in `ReportResult`. The common pattern is:
121+
122+
1. Extract from `--appium-url` userinfo (e.g., `https://USER:KEY@hub.example.com`)
123+
2. Fall back to provider-specific environment variables
124+
125+
This keeps credential logic out of the shared interface.
126+
127+
## Error handling
128+
129+
`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.

docs/cloud-providers/saucelabs.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Sauce Labs (Appium)
2+
3+
Use Appium driver mode with a Sauce Labs URL and provider capabilities.
4+
5+
## Run command
6+
7+
```bash
8+
maestro-runner \
9+
--driver appium \
10+
--appium-url "https://$SAUCE_USERNAME:$SAUCE_ACCESS_KEY@ondemand.us-west-1.saucelabs.com:443/wd/hub" \
11+
--caps provider-caps.json \
12+
test flows/
13+
```
14+
15+
- Default example uses `us-west-1`. Replace the Sauce Labs endpoints with your region as needed (for example `eu-central-1`, `us-east-4`).
16+
- The Appium URL should include Sauce credentials (`$SAUCE_USERNAME` and `$SAUCE_ACCESS_KEY`) or be provided via environment variables.
17+
18+
## Example capabilities
19+
20+
Example `provider-caps.json` for Android real device:
21+
22+
```json
23+
{
24+
"platformName": "Android",
25+
"appium:automationName": "UiAutomator2",
26+
"appium:deviceName": "Samsung.*",
27+
"appium:platformVersion": "^1[5-6].*",
28+
"appium:app": "storage:filename=mda-2.2.0-25.apk",
29+
"sauce:options": {
30+
"build": "Maestro Android Run",
31+
"appiumVersion": "latest"
32+
}
33+
}
34+
```
35+
36+
Example `provider-caps.json` for iOS real device:
37+
38+
```json
39+
{
40+
"platformName": "iOS",
41+
"appium:automationName": "XCUITest",
42+
"appium:deviceName": "iPhone.*",
43+
"appium:platformVersion": "^(18|26).*",
44+
"appium:app": "storage:filename=SauceLabs-Demo-App.ipa",
45+
"sauce:options": {
46+
"build": "Maestro iOS Run",
47+
"appiumVersion": "latest",
48+
"resigningEnabled": true
49+
}
50+
}
51+
```
52+
53+
Example `provider-caps.json` for Android emulator:
54+
55+
```json
56+
{
57+
"platformName": "Android",
58+
"appium:automationName": "UiAutomator2",
59+
"appium:deviceName": "Google Pixel 9 Emulator",
60+
"appium:platformVersion": "16.0",
61+
"appium:app": "storage:filename=mda-2.2.0-25.apk",
62+
"sauce:options": {
63+
"build": "Maestro Android Emulator Run",
64+
"appiumVersion": "2.11.0"
65+
}
66+
}
67+
```
68+
69+
Example `provider-caps.json` for iOS simulator:
70+
71+
```json
72+
{
73+
"platformName": "iOS",
74+
"appium:automationName": "XCUITest",
75+
"appium:deviceName": "iPhone Simulator",
76+
"appium:platformVersion": "17.0",
77+
"appium:app": "storage:filename=SauceLabs-Demo-App.Simulator.zip",
78+
"sauce:options": {
79+
"build": "Maestro iOS Simulator Run",
80+
"appiumVersion": "2.11.3"
81+
}
82+
}
83+
```
84+
85+
## References
86+
87+
- [Run Maestro Flows on Any Cloud Provider](https://devicelab.dev/blog/run-maestro-flows-any-cloud)
88+
- [Sauce Labs: Mobile Appium capabilities](https://docs.saucelabs.com/dev/test-configuration-options/#mobile-appium-capabilities)

pkg/cli/test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"syscall"
1616
"time"
1717

18+
"github.com/devicelab-dev/maestro-runner/pkg/cloud"
1819
"github.com/devicelab-dev/maestro-runner/pkg/config"
1920
"github.com/devicelab-dev/maestro-runner/pkg/core"
2021
"github.com/devicelab-dev/maestro-runner/pkg/device"
@@ -472,6 +473,10 @@ type RunConfig struct {
472473

473474
// Flutter
474475
NoFlutterFallback bool // Disable automatic Flutter VM Service fallback
476+
477+
// Cloud provider (detected from AppiumURL, nil if not a cloud provider)
478+
CloudProvider cloud.Provider
479+
CloudMeta map[string]string
475480
}
476481

477482
func hyperlink(url, text string) string {
@@ -844,6 +849,31 @@ func executeTest(cfg *RunConfig) error {
844849
fmt.Printf(" %s⚠%s Warning: failed to generate Allure report: %v\n", color(colorYellow), color(colorReset), err)
845850
}
846851

852+
// Report result to cloud provider (if detected)
853+
if cfg.CloudProvider != nil {
854+
cloudResult := &cloud.TestResult{
855+
Passed: result.Status == report.StatusPassed,
856+
Total: result.TotalFlows,
857+
PassedCount: result.PassedFlows,
858+
FailedCount: result.FailedFlows,
859+
Duration: result.Duration,
860+
OutputDir: cfg.OutputDir,
861+
}
862+
for _, f := range result.FlowResults {
863+
cloudResult.Flows = append(cloudResult.Flows, cloud.FlowResult{
864+
Name: f.Name,
865+
Passed: f.Status == report.StatusPassed,
866+
Duration: f.Duration,
867+
Error: f.Error,
868+
})
869+
}
870+
if err := cfg.CloudProvider.ReportResult(cfg.AppiumURL, cfg.CloudMeta, cloudResult); err != nil {
871+
logger.Warn("%s result reporting failed: %v", cfg.CloudProvider.Name(), err)
872+
} else {
873+
logger.Info("%s job updated: passed=%v", cfg.CloudProvider.Name(), cloudResult.Passed)
874+
}
875+
}
876+
847877
// Display reports section as a directory tree
848878
fmt.Printf(" %sReports:%s %s\n", color(colorBold), color(colorReset), cfg.OutputDir)
849879
fmt.Printf(" ├── report.json\n")
@@ -1647,6 +1677,15 @@ func createAppiumDriver(cfg *RunConfig) (core.Driver, func(), error) {
16471677
return nil, nil, fmt.Errorf("create Appium session: %w", err)
16481678
}
16491679
logger.Info("Appium session created successfully: %s", driver.GetPlatformInfo().DeviceID)
1680+
1681+
// Detect cloud provider and extract metadata
1682+
if p := cloud.Detect(cfg.AppiumURL); p != nil {
1683+
cfg.CloudProvider = p
1684+
cfg.CloudMeta = make(map[string]string)
1685+
p.ExtractMeta(driver.SessionID(), driver.SessionCaps(), cfg.CloudMeta)
1686+
logger.Info("Cloud provider detected: %s", p.Name())
1687+
}
1688+
16501689
printSetupSuccess("Appium session created")
16511690

16521691
// Cleanup function

pkg/cloud/example_provider.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// This file is a skeleton for adding a new cloud provider.
2+
// Copy this file, rename it, and implement the TODOs.
3+
//
4+
// Steps:
5+
// 1. Copy to <provider>.go (e.g., browserstack.go)
6+
// 2. Replace "example" with your provider name
7+
// 3. Implement the URL match in the factory
8+
// 4. Implement ExtractMeta and ReportResult
9+
// 5. Add tests in <provider>_test.go
10+
// 6. Add docs in docs/cloud-providers/<provider>.md
11+
12+
package cloud
13+
14+
/*
15+
import (
16+
"fmt"
17+
"strings"
18+
)
19+
20+
func init() {
21+
Register(newExampleProvider)
22+
}
23+
24+
func newExampleProvider(appiumURL string) Provider {
25+
// TODO: match your provider's Appium hub URL
26+
if !strings.Contains(strings.ToLower(appiumURL), "example") {
27+
return nil
28+
}
29+
return &exampleProvider{}
30+
}
31+
32+
type exampleProvider struct{}
33+
34+
func (p *exampleProvider) Name() string { return "Example" }
35+
36+
func (p *exampleProvider) ExtractMeta(sessionID string, caps map[string]interface{}, meta map[string]string) {
37+
// TODO: extract provider-specific data from session
38+
// Most providers just need the session ID as the job ID:
39+
meta["jobID"] = sessionID
40+
}
41+
42+
func (p *exampleProvider) ReportResult(appiumURL string, meta map[string]string, result *TestResult) error {
43+
jobID := meta["jobID"]
44+
if jobID == "" {
45+
return fmt.Errorf("no job ID")
46+
}
47+
48+
// TODO: extract credentials from appiumURL userinfo or env vars
49+
// TODO: PUT/PATCH pass/fail to your provider's REST API
50+
//
51+
// Available data:
52+
// result.Passed - overall pass/fail
53+
// result.Total - total flow count
54+
// result.PassedCount - flows passed
55+
// result.FailedCount - flows failed
56+
// result.Duration - total ms
57+
// result.OutputDir - path to log, reports, screenshots
58+
// result.Flows - per-flow name, pass/fail, duration, error
59+
60+
return nil
61+
}
62+
*/

0 commit comments

Comments
 (0)