github.com/saucelabs/saucectl@v0.175.1/internal/http/rdcservice_test.go (about) 1 package http 2 3 import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "net/http" 9 "net/http/httptest" 10 "os" 11 "path/filepath" 12 "reflect" 13 "sort" 14 "testing" 15 "time" 16 17 "github.com/hashicorp/go-retryablehttp" 18 "github.com/saucelabs/saucectl/internal/config" 19 "github.com/saucelabs/saucectl/internal/devices" 20 "github.com/saucelabs/saucectl/internal/job" 21 "github.com/stretchr/testify/assert" 22 ) 23 24 func TestRDCService_ReadJob(t *testing.T) { 25 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 var err error 27 switch r.URL.Path { 28 case "/v1/rdc/jobs/test1": 29 w.WriteHeader(http.StatusOK) 30 _, err = w.Write([]byte(`{"id": "test1", "error": null, "status": "passed", "consolidated_status": "passed"}`)) 31 case "/v1/rdc/jobs/test2": 32 w.WriteHeader(http.StatusOK) 33 _, err = w.Write([]byte(`{"id": "test2", "error": "no-device-found", "status": "failed", "consolidated_status": "failed"}`)) 34 case "/v1/rdc/jobs/test3": 35 w.WriteHeader(http.StatusOK) 36 _, err = w.Write([]byte(`{"id": "test3", "error": null, "status": "in progress", "consolidated_status": "in progress"}`)) 37 case "/v1/rdc/jobs/test4": 38 w.WriteHeader(http.StatusNotFound) 39 default: 40 w.WriteHeader(http.StatusInternalServerError) 41 } 42 if err != nil { 43 t.Errorf("failed to respond: %v", err) 44 } 45 })) 46 defer ts.Close() 47 timeout := 3 * time.Second 48 client := NewRDCService(ts.URL, "test-user", "test-key", timeout, config.ArtifactDownload{}) 49 50 testCases := []struct { 51 name string 52 jobID string 53 want job.Job 54 wantErr error 55 }{ 56 { 57 name: "passed job", 58 jobID: "test1", 59 want: job.Job{ID: "test1", Error: "", Status: "passed", Passed: true, IsRDC: true}, 60 wantErr: nil, 61 }, 62 { 63 name: "failed job", 64 jobID: "test2", 65 want: job.Job{ID: "test2", Error: "no-device-found", Status: "failed", Passed: false, IsRDC: true}, 66 wantErr: nil, 67 }, 68 { 69 name: "in progress job", 70 jobID: "test3", 71 want: job.Job{ID: "test3", Error: "", Status: "in progress", Passed: false, IsRDC: true}, 72 wantErr: nil, 73 }, 74 { 75 name: "non-existent job", 76 jobID: "test4", 77 want: job.Job{ID: "test4", Error: "", Status: "", Passed: false}, 78 wantErr: ErrJobNotFound, 79 }, 80 } 81 82 for _, tt := range testCases { 83 j, err := client.ReadJob(context.Background(), tt.jobID, true) 84 assert.Equal(t, err, tt.wantErr) 85 if err == nil { 86 assert.Equal(t, tt.want, j) 87 } 88 } 89 } 90 91 func TestRDCService_PollJob(t *testing.T) { 92 var retryCount int 93 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 94 var err error 95 switch r.URL.Path { 96 case "/v1/rdc/jobs/1": 97 _ = json.NewEncoder(w).Encode(rdcJob{ 98 ID: "1", 99 Status: job.StateComplete, 100 }) 101 case "/v1/rdc/jobs/2": 102 _ = json.NewEncoder(w).Encode(rdcJob{ 103 ID: "2", 104 Passed: false, 105 Status: job.StateError, 106 Error: "User Abandoned Test -- User terminated", 107 }) 108 case "/v1/rdc/jobs/3": 109 w.WriteHeader(http.StatusNotFound) 110 case "/v1/rdc/jobs/4": 111 w.WriteHeader(http.StatusUnauthorized) 112 case "/v1/rdc/jobs/5": 113 if retryCount < 2 { 114 w.WriteHeader(http.StatusInternalServerError) 115 retryCount++ 116 return 117 } 118 119 _ = json.NewEncoder(w).Encode(rdcJob{ 120 ID: "5", 121 Status: job.StatePassed, 122 Passed: true, 123 Error: "", 124 }) 125 default: 126 w.WriteHeader(http.StatusInternalServerError) 127 } 128 129 if err != nil { 130 t.Errorf("failed to respond: %v", err) 131 } 132 })) 133 defer ts.Close() 134 timeout := 3 * time.Second 135 136 testCases := []struct { 137 name string 138 client RDCService 139 jobID string 140 expectedResp job.Job 141 expectedErr error 142 }{ 143 { 144 name: "get job details with ID 1 and status 'complete'", 145 client: NewRDCService(ts.URL, "test", "123", timeout, config.ArtifactDownload{}), 146 jobID: "1", 147 expectedResp: job.Job{ 148 ID: "1", 149 Passed: false, 150 Status: "complete", 151 Error: "", 152 IsRDC: true, 153 }, 154 expectedErr: nil, 155 }, 156 { 157 name: "get job details with ID 2 and status 'error'", 158 client: NewRDCService(ts.URL, "test", "123", timeout, config.ArtifactDownload{}), 159 jobID: "2", 160 expectedResp: job.Job{ 161 ID: "2", 162 Passed: false, 163 Status: "error", 164 Error: "User Abandoned Test -- User terminated", 165 IsRDC: true, 166 }, 167 expectedErr: nil, 168 }, 169 { 170 name: "job not found error from external API", 171 client: NewRDCService(ts.URL, "test", "123", timeout, config.ArtifactDownload{}), 172 jobID: "3", 173 expectedResp: job.Job{}, 174 expectedErr: ErrJobNotFound, 175 }, 176 { 177 name: "http status is not 200, but 401 from external API", 178 client: NewRDCService(ts.URL, "test", "123", timeout, config.ArtifactDownload{}), 179 jobID: "4", 180 expectedResp: job.Job{}, 181 expectedErr: errors.New("unexpected statusCode: 401"), 182 }, 183 { 184 name: "unexpected status code from external API", 185 client: NewRDCService(ts.URL, "test", "123", timeout, config.ArtifactDownload{}), 186 jobID: "333", 187 expectedResp: job.Job{}, 188 expectedErr: errors.New("internal server error"), 189 }, 190 { 191 name: "get job details with ID 5. retry 2 times and succeed", 192 client: NewRDCService(ts.URL, "test", "123", timeout, config.ArtifactDownload{}), 193 jobID: "5", 194 expectedResp: job.Job{ 195 ID: "5", 196 Passed: true, 197 Status: job.StatePassed, 198 Error: "", 199 IsRDC: true, 200 }, 201 expectedErr: nil, 202 }, 203 } 204 205 for _, tc := range testCases { 206 t.Run(tc.name, func(t *testing.T) { 207 tc.client.Client.RetryWaitMax = 1 * time.Millisecond 208 got, err := tc.client.PollJob(context.Background(), tc.jobID, 10*time.Millisecond, 0, true) 209 assert.Equal(t, tc.expectedResp, got) 210 if err != nil { 211 assert.Equal(t, tc.expectedErr, err) 212 } 213 }) 214 } 215 } 216 217 func TestRDCService_GetJobAssetFileNames(t *testing.T) { 218 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 219 var err error 220 switch r.URL.Path { 221 case "/v1/rdc/jobs/1": 222 w.WriteHeader(http.StatusOK) 223 _, err = w.Write([]byte(`{"automation_backend":"xcuitest","framework_log_url":"https://dummy/xcuitestLogs","device_log_url":"https://dummy/deviceLogs","video_url":"https://dummy/video.mp4"}`)) 224 case "/v1/rdc/jobs/2": 225 w.WriteHeader(http.StatusOK) 226 _, err = w.Write([]byte(`{"automation_backend":"xcuitest","framework_log_url":"https://dummy/xcuitestLogs","screenshots":[{"id":"sc1"}],"video_url":"https://dummy/video.mp4"}`)) 227 case "/v1/rdc/jobs/3": 228 w.WriteHeader(http.StatusOK) 229 // The discrepancy between automation_backend and framework_log_url is wanted, as this is how the backend is currently responding. 230 _, err = w.Write([]byte(`{"automation_backend":"espresso","framework_log_url":"https://dummy/xcuitestLogs","video_url":"https://dummy/video.mp4"}`)) 231 case "/v1/rdc/jobs/4": 232 w.WriteHeader(http.StatusOK) 233 // The discrepancy between automation_backend and framework_log_url is wanted, as this is how the backend is currently responding. 234 _, err = w.Write([]byte(`{"automation_backend":"espresso","framework_log_url":"https://dummy/xcuitestLogs","device_log_url":"https://dummy/deviceLogs","screenshots":[{"id":"sc1"}],"video_url":"https://dummy/video.mp4"}`)) 235 default: 236 w.WriteHeader(http.StatusNotFound) 237 } 238 239 if err != nil { 240 t.Errorf("failed to respond: %v", err) 241 } 242 })) 243 defer ts.Close() 244 client := NewRDCService(ts.URL, "test-user", "test-password", 1*time.Second, config.ArtifactDownload{}) 245 246 testCases := []struct { 247 name string 248 jobID string 249 expected []string 250 wantErr error 251 }{ 252 { 253 name: "XCUITest w/o screenshots", 254 jobID: "1", 255 expected: []string{"device.log", "junit.xml", "video.mp4", "xcuitest.log"}, 256 wantErr: nil, 257 }, 258 { 259 name: "XCUITest w/ screenshots w/o deviceLogs", 260 jobID: "2", 261 expected: []string{"junit.xml", "screenshots.zip", "video.mp4", "xcuitest.log"}, 262 wantErr: nil, 263 }, 264 { 265 name: "espresso w/o screenshots", 266 jobID: "3", 267 expected: []string{"junit.xml", "video.mp4"}, 268 wantErr: nil, 269 }, 270 { 271 name: "espresso w/ screenshots w/o deviceLogs", 272 jobID: "4", 273 expected: []string{"device.log", "junit.xml", "screenshots.zip", "video.mp4"}, 274 wantErr: nil, 275 }, 276 } 277 for _, tt := range testCases { 278 t.Run(tt.name, func(t *testing.T) { 279 files, err := client.GetJobAssetFileNames(context.Background(), tt.jobID, true) 280 if err != nil { 281 if !reflect.DeepEqual(err, tt.wantErr) { 282 t.Errorf("GetJobAssetFileNames(): got: %v, want: %v", err, tt.wantErr) 283 } 284 return 285 } 286 if tt.wantErr != nil { 287 t.Errorf("GetJobAssetFileNames(): got: %v, want: %v", err, tt.wantErr) 288 } 289 sort.Strings(files) 290 sort.Strings(tt.expected) 291 if !reflect.DeepEqual(files, tt.expected) { 292 t.Errorf("GetJobAssetFileNames(): got: %v, want: %v", files, tt.expected) 293 } 294 }) 295 } 296 } 297 298 func TestRDCService_GetJobAssetFileContent(t *testing.T) { 299 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 300 var err error 301 switch r.URL.Path { 302 case "/v1/rdc/jobs/jobID/deviceLogs": 303 w.WriteHeader(http.StatusOK) 304 _, err = w.Write([]byte("INFO 15:10:16 1 Icing : Usage reports ok 0, Failed Usage reports 0, indexed 0, rejected 0\nINFO 15:10:16 2 GmsCoreXrpcWrapper : Returning a channel provider with trafficStatsTag=12803\nINFO 15:10:16 3 Icing : Usage reports ok 0, Failed Usage reports 0, indexed 0, rejected 0\n")) 305 case "/v1/rdc/jobs/jobID/junit.xml": 306 w.WriteHeader(http.StatusOK) 307 _, err = w.Write([]byte("<xml>junit.xml</xml>")) 308 default: 309 w.WriteHeader(http.StatusNotFound) 310 } 311 312 if err != nil { 313 t.Errorf("failed to respond: %v", err) 314 } 315 })) 316 defer ts.Close() 317 client := NewRDCService(ts.URL, "test-user", "test-password", 1*time.Second, config.ArtifactDownload{}) 318 319 testCases := []struct { 320 name string 321 jobID string 322 fileName string 323 want []byte 324 wantErr error 325 }{ 326 { 327 name: "Download deviceLogs asset", 328 jobID: "jobID", 329 fileName: "deviceLogs", 330 want: []byte("INFO 15:10:16 1 Icing : Usage reports ok 0, Failed Usage reports 0, indexed 0, rejected 0\nINFO 15:10:16 2 GmsCoreXrpcWrapper : Returning a channel provider with trafficStatsTag=12803\nINFO 15:10:16 3 Icing : Usage reports ok 0, Failed Usage reports 0, indexed 0, rejected 0\n"), 331 wantErr: nil, 332 }, 333 { 334 name: "Download junit.xml asset", 335 jobID: "jobID", 336 fileName: "junit.xml", 337 want: []byte("<xml>junit.xml</xml>"), 338 wantErr: nil, 339 }, 340 { 341 name: "Download invalid filename", 342 jobID: "jobID", 343 fileName: "buggy-file.txt", 344 wantErr: errors.New("asset not found"), 345 }, 346 } 347 for _, tt := range testCases { 348 data, err := client.GetJobAssetFileContent(context.Background(), tt.jobID, tt.fileName, true) 349 assert.Equal(t, err, tt.wantErr) 350 if err == nil { 351 assert.Equal(t, tt.want, data) 352 } 353 } 354 } 355 356 func TestRDCService_DownloadArtifact(t *testing.T) { 357 fileContent := "<xml>junit.xml</xml>" 358 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 359 var err error 360 switch r.URL.Path { 361 case "/v1/rdc/jobs/test-123": 362 _, err = w.Write([]byte(`{"automation_backend":"espresso"}`)) 363 case "/v1/rdc/jobs/test-123/junit.xml": 364 _, err = w.Write([]byte(fileContent)) 365 default: 366 w.WriteHeader(http.StatusNotFound) 367 } 368 369 if err != nil { 370 t.Errorf("failed to respond: %v", err) 371 } 372 })) 373 defer ts.Close() 374 375 tempDir, err := os.MkdirTemp("", "saucectl-download-artifact") 376 if err != nil { 377 t.Errorf("Failed to create temp dir: %v", err) 378 } 379 defer func() { 380 _ = os.RemoveAll(tempDir) 381 }() 382 383 rc := NewRDCService(ts.URL, "dummy-user", "dummy-key", 10*time.Second, config.ArtifactDownload{ 384 Directory: tempDir, 385 Match: []string{"junit.xml"}, 386 }) 387 rc.DownloadArtifact("test-123", "suite name", true) 388 389 fileName := filepath.Join(tempDir, "suite_name", "junit.xml") 390 d, err := os.ReadFile(fileName) 391 if err != nil { 392 t.Errorf("file '%s' not found: %v", fileName, err) 393 } 394 395 if string(d) != fileContent { 396 t.Errorf("file content mismatch: got '%v', expects: '%v'", d, fileContent) 397 } 398 } 399 400 func TestRDCService_GetDevices(t *testing.T) { 401 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 402 var err error 403 completeQuery := fmt.Sprintf("%s?%s", r.URL.Path, r.URL.RawQuery) 404 switch completeQuery { 405 case "/v1/rdc/devices/filtered?os=ANDROID": 406 _, err = w.Write([]byte(`{"entities":[{"name": "OnePlus 5T"},{"name": "OnePlus 6"},{"name": "OnePlus 6T"}]}`)) 407 case "/v1/rdc/devices/filtered?os=IOS": 408 _, err = w.Write([]byte(`{"entities":[{"name": "iPhone XR"},{"name": "iPhone XS"},{"name": "iPhone X"}]}`)) 409 default: 410 w.WriteHeader(http.StatusNotFound) 411 } 412 413 if err != nil { 414 t.Errorf("failed to respond: %v", err) 415 } 416 })) 417 defer ts.Close() 418 client := retryablehttp.NewClient() 419 client.HTTPClient = &http.Client{Timeout: 1 * time.Second} 420 421 cl := RDCService{ 422 Client: client, 423 URL: ts.URL, 424 Username: "dummy-user", 425 AccessKey: "dummy-key", 426 } 427 type args struct { 428 ctx context.Context 429 OS string 430 } 431 tests := []struct { 432 name string 433 args args 434 want []devices.Device 435 wantErr bool 436 }{ 437 { 438 name: "Android devices", 439 args: args{ 440 ctx: context.Background(), 441 OS: "ANDROID", 442 }, 443 want: []devices.Device{ 444 {Name: "OnePlus 5T"}, 445 {Name: "OnePlus 6"}, 446 {Name: "OnePlus 6T"}, 447 }, 448 wantErr: false, 449 }, 450 { 451 name: "iOS devices", 452 args: args{ 453 ctx: context.Background(), 454 OS: "IOS", 455 }, 456 want: []devices.Device{ 457 {Name: "iPhone XR"}, 458 {Name: "iPhone XS"}, 459 {Name: "iPhone X"}, 460 }, 461 wantErr: false, 462 }, 463 } 464 for _, tt := range tests { 465 t.Run(tt.name, func(t *testing.T) { 466 got, err := cl.GetDevices(tt.args.ctx, tt.args.OS) 467 if (err != nil) != tt.wantErr { 468 t.Errorf("GetDevices() error = %v, wantErr %v", err, tt.wantErr) 469 return 470 } 471 if !reflect.DeepEqual(got, tt.want) { 472 t.Errorf("GetDevices() got = %v, want %v", got, tt.want) 473 } 474 }) 475 } 476 } 477 478 func TestRDCService_StartJob(t *testing.T) { 479 type args struct { 480 ctx context.Context 481 jobStarterPayload job.StartOptions 482 } 483 type fields struct { 484 HTTPClient *http.Client 485 URL string 486 } 487 tests := []struct { 488 name string 489 fields fields 490 args args 491 want string 492 wantErr error 493 serverFunc func(w http.ResponseWriter, r *http.Request) // what shall the mock server respond with 494 }{ 495 { 496 name: "Happy path", 497 args: args{ 498 ctx: context.TODO(), 499 jobStarterPayload: job.StartOptions{ 500 User: "fake-user", 501 AccessKey: "fake-access-key", 502 BrowserName: "fake-browser-name", 503 Name: "fake-test-name", 504 Framework: "fake-framework", 505 Build: "fake-buildname", 506 Tags: nil, 507 }, 508 }, 509 want: "fake-job-id", 510 wantErr: nil, 511 serverFunc: func(w http.ResponseWriter, r *http.Request) { 512 w.WriteHeader(201) 513 _, _ = w.Write([]byte(`{ "test_report": { "id": "fake-job-id" }}`)) 514 }, 515 }, 516 { 517 name: "Non 2xx status code", 518 args: args{ 519 ctx: context.TODO(), 520 jobStarterPayload: job.StartOptions{}, 521 }, 522 want: "", 523 wantErr: fmt.Errorf("job start failed; unexpected response code:'300', msg:''"), 524 serverFunc: func(w http.ResponseWriter, r *http.Request) { 525 w.WriteHeader(300) 526 }, 527 }, 528 { 529 name: "Unknown error", 530 args: args{ 531 ctx: context.TODO(), 532 jobStarterPayload: job.StartOptions{}, 533 }, 534 want: "", 535 wantErr: fmt.Errorf("job start failed; unexpected response code:'500', msg:'Internal server error'"), 536 serverFunc: func(w http.ResponseWriter, r *http.Request) { 537 w.WriteHeader(500) 538 _, err := w.Write([]byte("Internal server error")) 539 if err != nil { 540 t.Errorf("failed to write response: %v", err) 541 } 542 }, 543 }, 544 } 545 for _, tt := range tests { 546 t.Run(tt.name, func(t *testing.T) { 547 server := httptest.NewServer(http.HandlerFunc(tt.serverFunc)) 548 defer server.Close() 549 550 c := &RDCService{ 551 Client: &retryablehttp.Client{HTTPClient: server.Client()}, 552 URL: server.URL, 553 } 554 555 got, _, err := c.StartJob(tt.args.ctx, tt.args.jobStarterPayload) 556 if (err != nil) && !reflect.DeepEqual(err, tt.wantErr) { 557 t.Errorf("StartJob() error = %v, wantErr %v", err, tt.wantErr) 558 return 559 } 560 if !reflect.DeepEqual(got, tt.want) { 561 t.Errorf("StartJob() got = %v, want %v", got, tt.want) 562 } 563 }) 564 } 565 }