github.com/saucelabs/saucectl@v0.175.1/internal/http/rdcservice.go (about) 1 package http 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "net/http" 11 "os" 12 "path/filepath" 13 "strings" 14 "time" 15 16 "github.com/saucelabs/saucectl/internal/slice" 17 18 "github.com/hashicorp/go-retryablehttp" 19 "github.com/rs/zerolog/log" 20 21 "github.com/saucelabs/saucectl/internal/config" 22 "github.com/saucelabs/saucectl/internal/devices" 23 "github.com/saucelabs/saucectl/internal/espresso" 24 "github.com/saucelabs/saucectl/internal/fpath" 25 "github.com/saucelabs/saucectl/internal/job" 26 "github.com/saucelabs/saucectl/internal/xcuitest" 27 ) 28 29 // RDCService http client. 30 type RDCService struct { 31 Client *retryablehttp.Client 32 URL string 33 Username string 34 AccessKey string 35 ArtifactConfig config.ArtifactDownload 36 } 37 38 type rdcJob struct { 39 ID string `json:"id"` 40 Name string `json:"name"` 41 AutomationBackend string `json:"automation_backend,omitempty"` 42 FrameworkLogURL string `json:"framework_log_url,omitempty"` 43 DeviceLogURL string `json:"device_log_url,omitempty"` 44 TestCasesURL string `json:"test_cases_url,omitempty"` 45 VideoURL string `json:"video_url,omitempty"` 46 Screenshots []struct { 47 ID string 48 } `json:"screenshots,omitempty"` 49 Status string `json:"status,omitempty"` 50 Passed bool `json:"passed,omitempty"` 51 ConsolidatedStatus string `json:"consolidated_status,omitempty"` 52 Error string `json:"error,omitempty"` 53 OS string `json:"os,omitempty"` 54 OSVersion string `json:"os_version,omitempty"` 55 DeviceName string `json:"device_name,omitempty"` 56 } 57 58 // RDCSessionRequest represents the RDC session request. 59 type RDCSessionRequest struct { 60 TestFramework string `json:"test_framework,omitempty"` 61 AppID string `json:"app_id,omitempty"` 62 TestAppID string `json:"test_app_id,omitempty"` 63 OtherApps []string `json:"other_apps,omitempty"` 64 DeviceQuery DeviceQuery `json:"device_query,omitempty"` 65 TestOptions map[string]string `json:"test_options,omitempty"` 66 TestsToRun []string `json:"tests_to_run,omitempty"` 67 TestsToSkip []string `json:"tests_to_skip,omitempty"` 68 TestName string `json:"test_name,omitempty"` 69 TunnelName string `json:"tunnel_name,omitempty"` 70 TunnelOwner string `json:"tunnel_owner,omitempty"` 71 UseTestOrchestrator bool `json:"use_test_orchestrator,omitempty"` 72 Tags []string `json:"tags,omitempty"` 73 Build string `json:"build,omitempty"` 74 AppSettings job.AppSettings `json:"settings_overwrite,omitempty"` 75 RealDeviceKind string `json:"kind,omitempty"` 76 } 77 78 // DeviceQuery represents the device selection query for RDC. 79 type DeviceQuery struct { 80 Type string `json:"type"` 81 DeviceDescriptorID string `json:"device_descriptor_id,omitempty"` 82 PrivateDevicesOnly bool `json:"private_devices_only,omitempty"` 83 CarrierConnectivityRequested bool `json:"carrier_connectivity_requested,omitempty"` 84 RequestedDeviceType string `json:"requested_device_type,omitempty"` 85 DeviceName string `json:"device_name,omitempty"` 86 PlatformVersion string `json:"platform_version,omitempty"` 87 } 88 89 // NewRDCService creates a new client. 90 func NewRDCService(url, username, accessKey string, timeout time.Duration, artifactConfig config.ArtifactDownload) RDCService { 91 return RDCService{ 92 Client: NewRetryableClient(timeout), 93 URL: url, 94 Username: username, 95 AccessKey: accessKey, 96 ArtifactConfig: artifactConfig, 97 } 98 } 99 100 // StartJob creates a new job in Sauce Labs. 101 func (c *RDCService) StartJob(ctx context.Context, opts job.StartOptions) (jobID string, isRDC bool, err error) { 102 url := fmt.Sprintf("%s/v1/rdc/native-composer/tests", c.URL) 103 104 var frameworkName string 105 switch opts.Framework { 106 case "espresso": 107 frameworkName = "ANDROID_INSTRUMENTATION" 108 case "xcuitest": 109 frameworkName = "XCUITEST" 110 } 111 112 useTestOrchestrator := false 113 if v, ok := opts.TestOptions["useTestOrchestrator"]; ok { 114 useTestOrchestrator = fmt.Sprintf("%v", v) == "true" 115 } 116 117 jobReq := RDCSessionRequest{ 118 TestName: opts.Name, 119 AppID: opts.App, 120 TestAppID: opts.Suite, 121 OtherApps: opts.OtherApps, 122 TestOptions: c.formatEspressoArgs(opts.TestOptions), 123 TestsToRun: opts.TestsToRun, 124 TestsToSkip: opts.TestsToSkip, 125 DeviceQuery: c.deviceQuery(opts), 126 TestFramework: frameworkName, 127 TunnelName: opts.Tunnel.ID, 128 TunnelOwner: opts.Tunnel.Parent, 129 UseTestOrchestrator: useTestOrchestrator, 130 Tags: opts.Tags, 131 Build: opts.Build, 132 RealDeviceKind: opts.RealDeviceKind, 133 AppSettings: opts.AppSettings, 134 } 135 136 var b bytes.Buffer 137 err = json.NewEncoder(&b).Encode(jobReq) 138 if err != nil { 139 return 140 } 141 142 req, err := NewRequestWithContext(ctx, http.MethodPost, url, &b) 143 if err != nil { 144 return 145 } 146 req.Header.Set("Content-Type", "application/json") 147 req.SetBasicAuth(c.Username, c.AccessKey) 148 149 resp, err := c.Client.HTTPClient.Do(req) 150 if err != nil { 151 return 152 } 153 defer resp.Body.Close() 154 body, err := io.ReadAll(resp.Body) 155 if err != nil { 156 return 157 } 158 159 if resp.StatusCode >= 300 { 160 err = fmt.Errorf("job start failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, strings.TrimSpace(string(body))) 161 return "", true, err 162 } 163 164 var sessionStart struct { 165 TestReport struct { 166 ID string 167 } `json:"test_report"` 168 } 169 if err = json.Unmarshal(body, &sessionStart); err != nil { 170 return "", true, fmt.Errorf("job start status unknown: unable to parse server response: %w", err) 171 } 172 173 return sessionStart.TestReport.ID, true, nil 174 } 175 176 func (c *RDCService) StopJob(ctx context.Context, id string, realDevice bool) (job.Job, error) { 177 if !realDevice { 178 return job.Job{}, errors.New("the RDC client does not support virtual device jobs") 179 } 180 181 req, err := NewRequestWithContext(ctx, http.MethodPut, 182 fmt.Sprintf("%s/v1/rdc/jobs/%s/stop", c.URL, id), nil) 183 if err != nil { 184 return job.Job{}, err 185 } 186 req.SetBasicAuth(c.Username, c.AccessKey) 187 188 r, err := retryablehttp.FromRequest(req) 189 if err != nil { 190 return job.Job{}, err 191 } 192 193 resp, err := c.Client.Do(r) 194 if err != nil { 195 return job.Job{}, err 196 } 197 defer resp.Body.Close() 198 199 if resp.StatusCode >= http.StatusInternalServerError { 200 return job.Job{}, ErrServerError 201 } 202 203 if resp.StatusCode == http.StatusNotFound { 204 return job.Job{}, ErrJobNotFound 205 } 206 207 if resp.StatusCode != http.StatusOK { 208 body, _ := io.ReadAll(resp.Body) 209 err := fmt.Errorf("unable to stop job: %d - %s", resp.StatusCode, string(body)) 210 return job.Job{}, err 211 } 212 213 // RDC does not return any job details in the response. 214 return job.Job{}, nil 215 } 216 217 // ReadJob returns the job details. 218 func (c *RDCService) ReadJob(ctx context.Context, id string, realDevice bool) (job.Job, error) { 219 if !realDevice { 220 return job.Job{}, errors.New("the RDC client does not support virtual device jobs") 221 } 222 223 req, err := NewRequestWithContext(ctx, http.MethodGet, 224 fmt.Sprintf("%s/v1/rdc/jobs/%s", c.URL, id), nil) 225 if err != nil { 226 return job.Job{}, err 227 } 228 req.SetBasicAuth(c.Username, c.AccessKey) 229 230 r, err := retryablehttp.FromRequest(req) 231 if err != nil { 232 return job.Job{}, err 233 } 234 235 resp, err := c.Client.Do(r) 236 if err != nil { 237 return job.Job{}, err 238 } 239 defer resp.Body.Close() 240 241 if resp.StatusCode >= http.StatusInternalServerError { 242 return job.Job{}, ErrServerError 243 } 244 245 if resp.StatusCode == http.StatusNotFound { 246 return job.Job{}, ErrJobNotFound 247 } 248 249 if resp.StatusCode != 200 { 250 return job.Job{}, fmt.Errorf("unexpected statusCode: %v", resp.StatusCode) 251 } 252 253 return c.parseJob(resp.Body) 254 } 255 256 // PollJob polls job details at an interval, until timeout has been reached or until the job has ended, whether successfully or due to an error. 257 func (c *RDCService) PollJob(ctx context.Context, id string, interval, timeout time.Duration, realDevice bool) (job.Job, error) { 258 if !realDevice { 259 return job.Job{}, errors.New("the RDC client does not support virtual device jobs") 260 } 261 262 ticker := time.NewTicker(interval) 263 defer ticker.Stop() 264 265 if timeout <= 0 { 266 timeout = 24 * time.Hour 267 } 268 deathclock := time.NewTimer(timeout) 269 defer deathclock.Stop() 270 271 for { 272 select { 273 case <-ticker.C: 274 j, err := c.ReadJob(ctx, id, realDevice) 275 if err != nil { 276 return job.Job{}, err 277 } 278 279 if job.Done(j.Status) { 280 j.IsRDC = true 281 return j, nil 282 } 283 case <-deathclock.C: 284 j, err := c.ReadJob(ctx, id, realDevice) 285 if err != nil { 286 return job.Job{}, err 287 } 288 j.TimedOut = true 289 return j, nil 290 } 291 } 292 } 293 294 // GetJobAssetFileNames returns all assets files available. 295 func (c *RDCService) GetJobAssetFileNames(ctx context.Context, jobID string, realDevice bool) ([]string, error) { 296 if !realDevice { 297 return nil, errors.New("the RDC client does not support virtual device jobs") 298 } 299 300 req, err := NewRequestWithContext(ctx, http.MethodGet, 301 fmt.Sprintf("%s/v1/rdc/jobs/%s", c.URL, jobID), nil) 302 if err != nil { 303 return []string{}, err 304 } 305 req.SetBasicAuth(c.Username, c.AccessKey) 306 307 r, err := retryablehttp.FromRequest(req) 308 if err != nil { 309 return []string{}, err 310 } 311 312 resp, err := c.Client.Do(r) 313 if err != nil { 314 return []string{}, err 315 } 316 defer resp.Body.Close() 317 318 if resp.StatusCode != 200 { 319 return []string{}, fmt.Errorf("unexpected statusCode: %v", resp.StatusCode) 320 } 321 322 var jr rdcJob 323 if err := json.NewDecoder(resp.Body).Decode(&jr); err != nil { 324 return []string{}, err 325 } 326 327 var files []string 328 329 if strings.HasSuffix(jr.DeviceLogURL, "/deviceLogs") { 330 files = append(files, "device.log") 331 } 332 if strings.HasSuffix(jr.VideoURL, "/video.mp4") { 333 files = append(files, "video.mp4") 334 } 335 if len(jr.Screenshots) > 0 { 336 files = append(files, "screenshots.zip") 337 } 338 339 // xcuitest.log is available for espresso according to API, but will always be empty, 340 // => hiding it until API is fixed. 341 if jr.AutomationBackend == xcuitest.Kind && strings.HasSuffix(jr.FrameworkLogURL, "/xcuitestLogs") { 342 files = append(files, "xcuitest.log") 343 } 344 // junit.xml is available only for native frameworks. 345 if jr.AutomationBackend == xcuitest.Kind || jr.AutomationBackend == espresso.Kind { 346 files = append(files, "junit.xml") 347 } 348 return files, nil 349 } 350 351 // GetJobAssetFileContent returns the job asset file content. 352 func (c *RDCService) GetJobAssetFileContent(ctx context.Context, jobID, fileName string, realDevice bool) ([]byte, error) { 353 if !realDevice { 354 return nil, errors.New("the RDC client does not support virtual device jobs") 355 } 356 357 // jobURIMappings contains the assets that don't get accessed by their filename. 358 // Those items also requires to send "Accept: text/plain" header to get raw content instead of json. 359 var jobURIMappings = map[string]string{ 360 "device.log": "deviceLogs", 361 "xcuitest.log": "xcuitestLogs", 362 } 363 364 acceptHeader := "" 365 URIFileName := fileName 366 if _, ok := jobURIMappings[fileName]; ok { 367 URIFileName = jobURIMappings[fileName] 368 acceptHeader = "text/plain" 369 } 370 371 req, err := NewRequestWithContext(ctx, http.MethodGet, 372 fmt.Sprintf("%s/v1/rdc/jobs/%s/%s", c.URL, jobID, URIFileName), nil) 373 if err != nil { 374 return nil, err 375 } 376 377 req.SetBasicAuth(c.Username, c.AccessKey) 378 if acceptHeader != "" { 379 req.Header.Set("Accept", acceptHeader) 380 } 381 382 rreq, err := retryablehttp.FromRequest(req) 383 if err != nil { 384 return nil, err 385 } 386 resp, err := c.Client.Do(rreq) 387 if err != nil { 388 return nil, err 389 } 390 defer resp.Body.Close() 391 392 if resp.StatusCode >= http.StatusInternalServerError { 393 return nil, ErrServerError 394 } 395 396 if resp.StatusCode == http.StatusNotFound { 397 return nil, ErrAssetNotFound 398 } 399 400 if resp.StatusCode != http.StatusOK { 401 body, _ := io.ReadAll(resp.Body) 402 err := fmt.Errorf("job status request failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, string(body)) 403 return nil, err 404 } 405 406 return io.ReadAll(resp.Body) 407 } 408 409 // DownloadArtifact downloads artifacts and returns a list of downloaded files. 410 func (c *RDCService) DownloadArtifact(jobID, suiteName string, realDevice bool) []string { 411 targetDir, err := config.GetSuiteArtifactFolder(suiteName, c.ArtifactConfig) 412 if err != nil { 413 log.Error().Msgf("Unable to create artifacts folder (%v)", err) 414 return []string{} 415 } 416 417 files, err := c.GetJobAssetFileNames(context.Background(), jobID, realDevice) 418 if err != nil { 419 log.Error().Msgf("Unable to fetch artifacts list (%v)", err) 420 return []string{} 421 } 422 423 filepaths := fpath.MatchFiles(files, c.ArtifactConfig.Match) 424 var artifacts []string 425 for _, f := range filepaths { 426 targetFile, err := c.downloadArtifact(targetDir, jobID, f, realDevice) 427 if err != nil { 428 log.Err(err).Msg("Unable to download artifacts") 429 return artifacts 430 } 431 artifacts = append(artifacts, targetFile) 432 } 433 434 return artifacts 435 } 436 437 func (c *RDCService) downloadArtifact(targetDir, jobID, fileName string, realDevice bool) (string, error) { 438 content, err := c.GetJobAssetFileContent(context.Background(), jobID, fileName, realDevice) 439 if err != nil { 440 return "", err 441 } 442 targetFile := filepath.Join(targetDir, fileName) 443 return targetFile, os.WriteFile(targetFile, content, 0644) 444 } 445 446 // GetDevices returns the list of available devices using a specific operating system. 447 func (c *RDCService) GetDevices(ctx context.Context, OS string) ([]devices.Device, error) { 448 req, err := NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/v1/rdc/devices/filtered", c.URL), nil) 449 if err != nil { 450 return nil, err 451 } 452 453 q := req.URL.Query() 454 q.Add("os", OS) 455 req.URL.RawQuery = q.Encode() 456 req.SetBasicAuth(c.Username, c.AccessKey) 457 458 r, err := retryablehttp.FromRequest(req) 459 if err != nil { 460 return nil, err 461 } 462 463 res, err := c.Client.Do(r) 464 if err != nil { 465 return []devices.Device{}, err 466 } 467 468 var resp struct { 469 Entities []struct { 470 Name string 471 OS string 472 } 473 } 474 if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { 475 return []devices.Device{}, err 476 } 477 478 var dev []devices.Device 479 for _, d := range resp.Entities { 480 dev = append(dev, devices.Device{ 481 Name: d.Name, 482 OS: d.OS, 483 }) 484 } 485 return dev, nil 486 } 487 488 // formatEspressoArgs adapts option shape to match RDC expectations 489 func (c *RDCService) formatEspressoArgs(options map[string]interface{}) map[string]string { 490 mappedOptions := map[string]string{} 491 for k, v := range options { 492 if v == nil { 493 continue 494 } 495 // We let the user set 'useTestOrchestrator' inside TestOptions, but RDC has a dedicated setting for it. 496 if k == "useTestOrchestrator" { 497 continue 498 } 499 500 value := fmt.Sprintf("%v", v) 501 502 // class/notClass need special treatment, because we accept these as slices, but the backend wants 503 // a comma separated string. 504 if k == "class" || k == "notClass" { 505 value = slice.Join(v, ",") 506 } 507 508 if value == "" { 509 continue 510 } 511 mappedOptions[k] = value 512 } 513 return mappedOptions 514 } 515 516 // deviceQuery creates a DeviceQuery from opts. 517 func (c *RDCService) deviceQuery(opts job.StartOptions) DeviceQuery { 518 if opts.DeviceID != "" { 519 return DeviceQuery{ 520 Type: "HardcodedDeviceQuery", 521 DeviceDescriptorID: opts.DeviceID, 522 } 523 } 524 return DeviceQuery{ 525 Type: "DynamicDeviceQuery", 526 CarrierConnectivityRequested: opts.DeviceHasCarrier, 527 DeviceName: opts.DeviceName, 528 PlatformVersion: opts.PlatformVersion, 529 PrivateDevicesOnly: opts.DevicePrivateOnly, 530 RequestedDeviceType: opts.DeviceType, 531 } 532 } 533 534 // parseJob parses the body into rdcJob and converts it to job.Job. 535 func (c *RDCService) parseJob(body io.ReadCloser) (job.Job, error) { 536 var j rdcJob 537 err := json.NewDecoder(body).Decode(&j) 538 return job.Job{ 539 ID: j.ID, 540 Name: j.Name, 541 Error: j.Error, 542 Status: j.Status, 543 Passed: j.Status == job.StatePassed, 544 DeviceName: j.DeviceName, 545 Framework: j.AutomationBackend, 546 OS: j.OS, 547 OSVersion: j.OSVersion, 548 IsRDC: true, 549 }, err 550 }