github.com/saucelabs/saucectl@v0.175.1/internal/saucecloud/cloud.go (about) 1 package saucecloud 2 3 import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "io" 9 "os" 10 "os/signal" 11 "path" 12 "path/filepath" 13 "strings" 14 "time" 15 16 "golang.org/x/text/cases" 17 "golang.org/x/text/language" 18 19 "github.com/fatih/color" 20 ptable "github.com/jedib0t/go-pretty/v6/table" 21 "github.com/rs/zerolog/log" 22 "github.com/saucelabs/saucectl/internal/apps" 23 "github.com/saucelabs/saucectl/internal/build" 24 "github.com/saucelabs/saucectl/internal/config" 25 "github.com/saucelabs/saucectl/internal/espresso" 26 "github.com/saucelabs/saucectl/internal/framework" 27 "github.com/saucelabs/saucectl/internal/hashio" 28 "github.com/saucelabs/saucectl/internal/iam" 29 "github.com/saucelabs/saucectl/internal/insights" 30 "github.com/saucelabs/saucectl/internal/job" 31 "github.com/saucelabs/saucectl/internal/junit" 32 "github.com/saucelabs/saucectl/internal/msg" 33 "github.com/saucelabs/saucectl/internal/progress" 34 "github.com/saucelabs/saucectl/internal/region" 35 "github.com/saucelabs/saucectl/internal/report" 36 "github.com/saucelabs/saucectl/internal/saucecloud/retry" 37 "github.com/saucelabs/saucectl/internal/saucecloud/zip" 38 "github.com/saucelabs/saucectl/internal/sauceignore" 39 "github.com/saucelabs/saucectl/internal/saucereport" 40 "github.com/saucelabs/saucectl/internal/storage" 41 "github.com/saucelabs/saucectl/internal/tunnel" 42 ) 43 44 // CloudRunner represents the cloud runner for the Sauce Labs cloud. 45 type CloudRunner struct { 46 ProjectUploader storage.AppService 47 JobService job.Service 48 TunnelService tunnel.Service 49 Region region.Region 50 MetadataService framework.MetadataService 51 ShowConsoleLog bool 52 Framework framework.Framework 53 MetadataSearchStrategy framework.MetadataSearchStrategy 54 InsightsService insights.Service 55 UserService iam.UserService 56 BuildService build.Reader 57 Retrier retry.Retrier 58 59 Reporters []report.Reporter 60 61 Async bool 62 FailFast bool 63 64 NPMDependencies []string 65 66 interrupted bool 67 Cache Cache 68 } 69 70 type Cache struct { 71 VDCBuildURL string 72 RDCBuildURL string 73 } 74 75 type result struct { 76 name string 77 browser string 78 job job.Job 79 skipped bool 80 err error 81 duration time.Duration 82 startTime time.Time 83 endTime time.Time 84 retries int 85 attempts []report.Attempt 86 87 details insights.Details 88 } 89 90 // ConsoleLogAsset represents job asset log file name. 91 const ConsoleLogAsset = "console.log" 92 93 func (r *CloudRunner) createWorkerPool(ccy int, maxRetries int) (chan job.StartOptions, chan result, error) { 94 jobOpts := make(chan job.StartOptions, maxRetries+1) 95 results := make(chan result, ccy) 96 97 log.Info().Int("concurrency", ccy).Msg("Launching workers.") 98 for i := 0; i < ccy; i++ { 99 go r.runJobs(jobOpts, results) 100 } 101 102 return jobOpts, results, nil 103 } 104 105 func (r *CloudRunner) collectResults(artifactCfg config.ArtifactDownload, results chan result, expected int) bool { 106 // TODO find a better way to get the expected 107 completed := 0 108 inProgress := expected 109 passed := true 110 111 done := make(chan interface{}) 112 go func(r *CloudRunner) { 113 t := time.NewTicker(10 * time.Second) 114 defer t.Stop() 115 for { 116 select { 117 case <-done: 118 return 119 case <-t.C: 120 if !r.interrupted { 121 log.Info().Msgf("Suites in progress: %d", inProgress) 122 } 123 } 124 } 125 }(r) 126 127 for i := 0; i < expected; i++ { 128 res := <-results 129 // in case one of test suites not passed 130 // ignore jobs that are still in progress (i.e. async execution or client timeout) 131 // since their status is unknown 132 if job.Done(res.job.Status) && !res.job.Passed { 133 passed = false 134 } 135 completed++ 136 inProgress-- 137 138 if !res.skipped { 139 platform := res.job.OS 140 if res.job.OSVersion != "" { 141 platform = fmt.Sprintf("%s %s", platform, res.job.OSVersion) 142 } 143 144 browser := res.browser 145 // browser is empty for mobile tests 146 if browser != "" { 147 browser = fmt.Sprintf("%s %s", browser, res.job.BrowserVersion) 148 } 149 150 var artifacts []report.Artifact 151 files := r.downloadArtifacts(res.name, res.job, artifactCfg.When) 152 for _, f := range files { 153 artifacts = append(artifacts, report.Artifact{ 154 FilePath: f, 155 }) 156 } 157 158 r.FetchJUnitReports(&res, artifacts) 159 160 var url string 161 if res.job.ID != "" { 162 url = fmt.Sprintf("%s/tests/%s", r.Region.AppBaseURL(), res.job.ID) 163 } 164 buildURL := r.getBuildURL(res.job.ID, res.job.IsRDC) 165 tr := report.TestResult{ 166 Name: res.name, 167 Duration: res.duration, 168 StartTime: res.startTime, 169 EndTime: res.endTime, 170 Status: res.job.TotalStatus(), 171 Browser: browser, 172 Platform: platform, 173 DeviceName: res.job.DeviceName, 174 URL: url, 175 Artifacts: artifacts, 176 Origin: "sauce", 177 RDC: res.job.IsRDC, 178 TimedOut: res.job.TimedOut, 179 Attempts: res.attempts, 180 BuildURL: buildURL, 181 } 182 for _, rep := range r.Reporters { 183 rep.Add(tr) 184 } 185 } 186 r.logSuite(res) 187 188 // NOTE: Jobs must be finished in order to be reported to Insights. 189 // * Async jobs have an unknown status by definition, so should always be excluded from reporting. 190 // * Timed out jobs will be requested to stop, but stopping a job 191 // is either not possible (rdc) or async (vdc) so its actual status is not known now. 192 // Skip reporting to be safe. 193 isFinished := !r.Async && !res.job.TimedOut 194 if isFinished { 195 r.reportSuiteToInsights(res) 196 } 197 } 198 close(done) 199 200 if !r.interrupted { 201 for _, rep := range r.Reporters { 202 rep.Render() 203 } 204 } 205 206 return passed 207 } 208 209 func (r *CloudRunner) getBuildURL(jobID string, isRDC bool) string { 210 var buildSource build.Source 211 if !isRDC { 212 if r.Cache.VDCBuildURL != "" { 213 return r.Cache.VDCBuildURL 214 } 215 buildSource = build.VDC 216 } else { 217 if r.Cache.RDCBuildURL != "" { 218 return r.Cache.RDCBuildURL 219 } 220 buildSource = build.RDC 221 } 222 223 bID, err := r.BuildService.GetBuildID(context.Background(), jobID, buildSource) 224 if err != nil { 225 log.Warn().Err(err).Msgf("Failed to retrieve build id for job (%s)", jobID) 226 return "" 227 } 228 229 bURL := fmt.Sprintf("%s/builds/%s/%s", r.Region.AppBaseURL(), buildSource, bID) 230 if !isRDC { 231 r.Cache.VDCBuildURL = bURL 232 } else { 233 r.Cache.RDCBuildURL = bURL 234 } 235 return bURL 236 } 237 238 func (r *CloudRunner) runJob(opts job.StartOptions) (j job.Job, skipped bool, err error) { 239 log.Info(). 240 Str("suite", opts.DisplayName). 241 Str("region", r.Region.String()). 242 Str("tunnel", opts.Tunnel.ID). 243 Msg("Starting suite.") 244 245 id, _, err := r.JobService.StartJob(context.Background(), opts) 246 if err != nil { 247 return job.Job{Status: job.StateError}, false, err 248 } 249 250 sigChan := r.registerInterruptOnSignal(id, opts.RealDevice, opts.DisplayName) 251 defer unregisterSignalCapture(sigChan) 252 253 r.uploadSauceConfig(id, opts.RealDevice, opts.ConfigFilePath) 254 r.uploadCLIFlags(id, opts.RealDevice, opts.CLIFlags) 255 256 // os.Interrupt can arrive before the signal.Notify() is registered. In that case, 257 // if a soft exit is requested during startContainer phase, it gently exits. 258 if r.interrupted { 259 r.stopSuiteExecution(id, opts.RealDevice, opts.DisplayName) 260 j, err = r.JobService.PollJob(context.Background(), id, 15*time.Second, opts.Timeout, opts.RealDevice) 261 return j, true, err 262 } 263 264 jobDetailsPage := fmt.Sprintf("%s/tests/%s", r.Region.AppBaseURL(), id) 265 l := log.Info().Str("url", jobDetailsPage).Str("suite", opts.DisplayName).Str("platform", opts.PlatformName) 266 267 if opts.RealDevice { 268 l.Str("deviceName", opts.DeviceName).Str("platformVersion", opts.PlatformVersion).Str("deviceId", opts.DeviceID) 269 l.Bool("private", opts.DevicePrivateOnly) 270 } else { 271 l.Str("browser", opts.BrowserName) 272 } 273 274 l.Msg("Suite started.") 275 276 // Async mode. Mark the job as started without waiting for the result. 277 if r.Async { 278 return job.Job{ID: id, IsRDC: opts.RealDevice, Status: job.StateInProgress}, false, nil 279 } 280 281 // High interval poll to not oversaturate the job reader with requests 282 j, err = r.JobService.PollJob(context.Background(), id, 15*time.Second, opts.Timeout, opts.RealDevice) 283 if err != nil { 284 return job.Job{}, r.interrupted, fmt.Errorf("failed to retrieve job status for suite %s: %s", opts.DisplayName, err.Error()) 285 } 286 287 // Enrich RDC data 288 if opts.RealDevice { 289 enrichRDCReport(&j, opts) 290 } 291 292 // Check timeout 293 if j.TimedOut { 294 log.Error(). 295 Str("suite", opts.DisplayName). 296 Str("timeout", opts.Timeout.String()). 297 Msg("Suite timed out.") 298 299 r.stopSuiteExecution(id, opts.RealDevice, opts.DisplayName) 300 301 j.Passed = false 302 j.TimedOut = true 303 304 return j, false, fmt.Errorf("suite %q has timed out", opts.DisplayName) 305 } 306 307 if !j.Passed { 308 // We may need to differentiate when a job has crashed vs. when there is errors. 309 return j, r.interrupted, fmt.Errorf("suite %q has test failures", opts.DisplayName) 310 } 311 312 return j, false, nil 313 } 314 315 // enrichRDCReport added the fields from the opts as the API does not provides it. 316 func enrichRDCReport(j *job.Job, opts job.StartOptions) { 317 switch opts.Framework { 318 case "espresso": 319 j.OS = espresso.Android 320 } 321 322 if opts.DeviceID != "" { 323 j.DeviceName = opts.DeviceID 324 } else { 325 j.DeviceName = opts.DeviceName 326 j.OSVersion = opts.PlatformVersion 327 } 328 } 329 330 func (r *CloudRunner) runJobs(jobOpts chan job.StartOptions, results chan<- result) { 331 for opts := range jobOpts { 332 start := time.Now() 333 334 details := insights.Details{ 335 Framework: opts.Framework, 336 Browser: opts.BrowserName, 337 Tags: opts.Tags, 338 BuildName: opts.Build, 339 } 340 341 if r.interrupted { 342 results <- result{ 343 name: opts.DisplayName, 344 browser: opts.BrowserName, 345 skipped: true, 346 err: nil, 347 attempts: opts.PrevAttempts, 348 retries: opts.Retries, 349 details: details, 350 } 351 continue 352 } 353 354 if opts.Attempt == 0 { 355 opts.StartTime = start 356 } 357 358 jobData, skipped, err := r.runJob(opts) 359 360 if jobData.Passed { 361 opts.CurrentPassCount++ 362 } 363 364 if opts.Attempt < opts.Retries && ((!jobData.Passed && !skipped) || (opts.CurrentPassCount < opts.PassThreshold)) { 365 if !jobData.Passed { 366 log.Warn().Err(err).Msg("Suite errored.") 367 } 368 369 opts.Attempt++ 370 opts.PrevAttempts = append(opts.PrevAttempts, report.Attempt{ 371 ID: jobData.ID, 372 Duration: time.Since(start), 373 StartTime: start, 374 EndTime: time.Now(), 375 Status: jobData.Status, 376 TestSuites: junit.TestSuites{}, 377 }) 378 go r.Retrier.Retry(jobOpts, opts, jobData) 379 continue 380 } 381 382 if r.FailFast && !jobData.Passed { 383 log.Warn().Err(err).Msg("FailFast mode enabled. Skipping upcoming suites.") 384 r.interrupted = true 385 } 386 387 if !r.Async { 388 if opts.CurrentPassCount < opts.PassThreshold { 389 log.Error().Str("suite", opts.DisplayName).Msg("Failed to pass threshold") 390 jobData.Status = job.StateFailed 391 jobData.Passed = false 392 } else { 393 log.Info().Str("suite", opts.DisplayName).Msg("Passed threshold") 394 jobData.Status = job.StatePassed 395 jobData.Passed = true 396 } 397 } 398 399 results <- result{ 400 name: opts.DisplayName, 401 browser: opts.BrowserName, 402 job: jobData, 403 skipped: skipped, 404 err: err, 405 startTime: opts.StartTime, 406 endTime: time.Now(), 407 duration: time.Since(start), 408 retries: opts.Retries, 409 details: details, 410 attempts: append(opts.PrevAttempts, report.Attempt{ 411 ID: jobData.ID, 412 Duration: time.Since(opts.StartTime), 413 StartTime: opts.StartTime, 414 EndTime: time.Now(), 415 Status: jobData.Status, 416 }), 417 } 418 } 419 } 420 421 // remoteArchiveProject archives the contents of the folder and uploads to remote storage. 422 // It returns app uri as the uploaded project, otherApps as the collection of runner config and node_modules bundle. 423 func (r *CloudRunner) remoteArchiveProject(project interface{}, folder string, sauceignoreFile string, dryRun bool) (app string, otherApps []string, err error) { 424 tempDir, err := os.MkdirTemp(os.TempDir(), "saucectl-app-payload-") 425 if err != nil { 426 return 427 } 428 if !dryRun { 429 defer os.RemoveAll(tempDir) 430 } 431 432 var files []string 433 434 contents, err := os.ReadDir(folder) 435 if err != nil { 436 return 437 } 438 439 for _, file := range contents { 440 // we never want mode_modules as part of the app payload 441 if file.Name() == "node_modules" { 442 continue 443 } 444 files = append(files, filepath.Join(folder, file.Name())) 445 } 446 447 archives := make(map[uploadType]string) 448 449 matcher, err := sauceignore.NewMatcherFromFile(sauceignoreFile) 450 if err != nil { 451 return 452 } 453 454 appZip, err := zip.ArchiveFiles("app", tempDir, folder, files, matcher) 455 if err != nil { 456 return 457 } 458 archives[projectUpload] = appZip 459 460 modZip, err := zip.ArchiveNodeModules(tempDir, folder, matcher, r.NPMDependencies) 461 if err != nil { 462 return 463 } 464 if modZip != "" { 465 archives[nodeModulesUpload] = modZip 466 } 467 468 configZip, err := zip.ArchiveRunnerConfig(project, tempDir) 469 if err != nil { 470 return 471 } 472 archives[runnerConfigUpload] = configZip 473 474 var uris = map[uploadType]string{} 475 for k, v := range archives { 476 uri, err := r.uploadProject(v, "", k, dryRun) 477 if err != nil { 478 return "", []string{}, err 479 } 480 uris[k] = uri 481 } 482 483 app = uris[projectUpload] 484 for _, item := range []uploadType{runnerConfigUpload, nodeModulesUpload, otherAppsUpload} { 485 if val, ok := uris[item]; ok { 486 otherApps = append(otherApps, val) 487 } 488 } 489 490 return 491 } 492 493 // remoteArchiveFiles archives the files to a remote storage. 494 func (r *CloudRunner) remoteArchiveFiles(project interface{}, files []string, sauceignoreFile string, dryRun bool) (string, error) { 495 tempDir, err := os.MkdirTemp(os.TempDir(), "saucectl-app-payload-") 496 if err != nil { 497 return "", err 498 } 499 if !dryRun { 500 defer os.RemoveAll(tempDir) 501 } 502 503 archives := make(map[uploadType]string) 504 505 matcher, err := sauceignore.NewMatcherFromFile(sauceignoreFile) 506 if err != nil { 507 return "", err 508 } 509 510 zipName, err := zip.ArchiveFiles("app", tempDir, ".", files, matcher) 511 if err != nil { 512 return "", err 513 } 514 archives[projectUpload] = zipName 515 516 configZip, err := zip.ArchiveRunnerConfig(project, tempDir) 517 if err != nil { 518 return "", err 519 } 520 archives[runnerConfigUpload] = configZip 521 522 var uris []string 523 for k, v := range archives { 524 uri, err := r.uploadProject(v, "", k, dryRun) 525 if err != nil { 526 return "", err 527 } 528 uris = append(uris, uri) 529 530 } 531 532 return strings.Join(uris, ","), nil 533 } 534 535 // FetchJUnitReports retrieves junit reports for the given result and all of its 536 // attempts. Can use the given artifacts to avoid unnecessary API calls. 537 func (r *CloudRunner) FetchJUnitReports(res *result, artifacts []report.Artifact) { 538 if !report.IsArtifactRequired(r.Reporters, report.JUnitArtifact) { 539 return 540 } 541 542 var junitArtifact *report.Artifact 543 for _, artifact := range artifacts { 544 if strings.HasSuffix(artifact.FilePath, junit.FileName) { 545 junitArtifact = &artifact 546 break 547 } 548 } 549 550 for i := range res.attempts { 551 attempt := &res.attempts[i] 552 553 var content []byte 554 var err error 555 556 // If this is the last attempt, we can use the given junit artifact to 557 // avoid unnecessary API calls. 558 if i == len(res.attempts)-1 && junitArtifact != nil { 559 content, err = os.ReadFile(junitArtifact.FilePath) 560 log.Debug().Msg("Using cached JUnit report") 561 } else { 562 content, err = r.JobService.GetJobAssetFileContent( 563 context.Background(), 564 attempt.ID, 565 junit.FileName, 566 res.job.IsRDC, 567 ) 568 } 569 570 if err != nil { 571 log.Warn().Err(err).Str("jobID", attempt.ID).Msg("Unable to retrieve JUnit report") 572 continue 573 } 574 575 attempt.TestSuites, err = junit.Parse(content) 576 if err != nil { 577 log.Warn().Err(err).Str("jobID", attempt.ID).Msg("Unable to parse JUnit report") 578 continue 579 } 580 } 581 } 582 583 type uploadType string 584 585 var ( 586 testAppUpload uploadType = "test application" 587 appUpload uploadType = "application" 588 projectUpload uploadType = "project" 589 runnerConfigUpload uploadType = "runner config" 590 nodeModulesUpload uploadType = "node modules" 591 otherAppsUpload uploadType = "other applications" 592 ) 593 594 func (r *CloudRunner) uploadProjects(filenames []string, pType uploadType, dryRun bool) ([]string, error) { 595 var IDs []string 596 for _, f := range filenames { 597 ID, err := r.uploadProject(f, "", pType, dryRun) 598 if err != nil { 599 return []string{}, err 600 } 601 IDs = append(IDs, ID) 602 } 603 604 return IDs, nil 605 } 606 607 func (r *CloudRunner) uploadProject(filename, description string, pType uploadType, dryRun bool) (string, error) { 608 if dryRun { 609 log.Info().Str("file", filename).Msgf("Skipping upload in dry run.") 610 return "", nil 611 } 612 613 if apps.IsStorageReference(filename) { 614 return apps.NormalizeStorageReference(filename), nil 615 } 616 617 if apps.IsRemote(filename) { 618 log.Info().Msgf("Downloading from remote: %s", filename) 619 620 progress.Show("Downloading %s", filename) 621 dest, err := r.download(filename) 622 progress.Stop() 623 if err != nil { 624 return "", fmt.Errorf("unable to download app from %s: %w", filename, err) 625 } 626 627 if err != nil { 628 return "", err 629 } 630 defer os.RemoveAll(dest) 631 632 filename = dest 633 } 634 635 log.Info().Msgf("Checking if %s has already been uploaded previously", filename) 636 if storageID, _ := r.isFileStored(filename); storageID != "" { 637 log.Info().Msgf("Skipping upload, using storage:%s", storageID) 638 return fmt.Sprintf("storage:%s", storageID), nil 639 } 640 641 filename, err := filepath.Abs(filename) 642 if err != nil { 643 return "", nil 644 } 645 file, err := os.Open(filename) 646 if err != nil { 647 return "", fmt.Errorf("project upload: %w", err) 648 } 649 defer file.Close() 650 651 progress.Show("Uploading %s %s", pType, filename) 652 start := time.Now() 653 resp, err := r.ProjectUploader.UploadStream(filepath.Base(filename), description, file) 654 progress.Stop() 655 if err != nil { 656 return "", err 657 } 658 log.Info().Dur("durationMs", time.Since(start)).Str("storageId", resp.ID). 659 Msgf("%s uploaded.", cases.Title(language.English).String(string(pType))) 660 return fmt.Sprintf("storage:%s", resp.ID), nil 661 } 662 663 // isFileStored calculates the checksum of the given file and looks up its existence in the Sauce Labs app storage. 664 // Returns an empty string if no file was found. 665 func (r *CloudRunner) isFileStored(filename string) (storageID string, err error) { 666 hash, err := hashio.SHA256(filename) 667 if err != nil { 668 return "", err 669 } 670 671 log.Info().Msgf("Checksum: %s", hash) 672 673 l, err := r.ProjectUploader.List(storage.ListOptions{ 674 SHA256: hash, 675 MaxResults: 1, 676 }) 677 if err != nil { 678 return "", err 679 } 680 if len(l.Items) == 0 { 681 return "", nil 682 } 683 684 return l.Items[0].ID, nil 685 } 686 687 // logSuite display the result of a suite 688 func (r *CloudRunner) logSuite(res result) { 689 // Job isn't done, hence nothing more to log about it. 690 if !job.Done(res.job.Status) || r.Async { 691 return 692 } 693 694 if res.skipped { 695 log.Error().Err(res.err).Str("suite", res.name).Msg("Suite skipped.") 696 return 697 } 698 if res.job.ID == "" { 699 log.Error().Err(res.err).Str("suite", res.name).Msg("Failed to start suite.") 700 return 701 } 702 703 jobDetailsPage := fmt.Sprintf("%s/tests/%s", r.Region.AppBaseURL(), res.job.ID) 704 705 if res.job.TimedOut { 706 log.Error().Str("suite", res.name).Str("url", jobDetailsPage).Msg("Suite timed out.") 707 return 708 } 709 710 msg := "Suite finished." 711 if res.job.Passed { 712 log.Info().Str("suite", res.name).Bool("passed", res.job.Passed).Str("url", jobDetailsPage). 713 Msg(msg) 714 } else { 715 l := log.Error().Str("suite", res.name).Bool("passed", res.job.Passed).Str("url", jobDetailsPage) 716 if res.job.Error != "" { 717 l.Str("error", res.job.Error) 718 msg = "Suite finished with error." 719 } 720 l.Msg(msg) 721 } 722 r.logSuiteConsole(res) 723 } 724 725 // logSuiteError display the console output when tests from a suite are failing 726 func (r *CloudRunner) logSuiteConsole(res result) { 727 // To avoid clutter, we don't show the console on job passes. 728 if res.job.Passed && !r.ShowConsoleLog { 729 return 730 } 731 732 // If a job errored (not to be confused with tests failing), there are likely no assets available anyway. 733 if res.job.Error != "" { 734 return 735 } 736 737 var assetContent []byte 738 var err error 739 740 // Display log only when at least it has started 741 if assetContent, err = r.JobService.GetJobAssetFileContent(context.Background(), res.job.ID, ConsoleLogAsset, res.job.IsRDC); err == nil { 742 log.Info().Str("suite", res.name).Msgf("console.log output: \n%s", assetContent) 743 return 744 } 745 746 // Some frameworks produce a junit.xml instead, check for that file if there's no console.log 747 assetContent, err = r.JobService.GetJobAssetFileContent(context.Background(), res.job.ID, junit.FileName, res.job.IsRDC) 748 if err != nil { 749 log.Warn().Err(err).Str("suite", res.name).Msg("Failed to retrieve the console output.") 750 return 751 } 752 753 var testsuites junit.TestSuites 754 if testsuites, err = junit.Parse(assetContent); err != nil { 755 log.Warn().Str("suite", res.name).Msg("Failed to parse junit") 756 return 757 } 758 759 // Print summary of failures from junit.xml 760 headerColor := color.New(color.FgRed).Add(color.Bold).Add(color.Underline) 761 if !res.job.Passed { 762 headerColor.Print("\nErrors:\n\n") 763 } 764 bodyColor := color.New(color.FgHiRed) 765 errCount := 1 766 failCount := 1 767 for _, ts := range testsuites.TestSuites { 768 for _, tc := range ts.TestCases { 769 if tc.Error != nil { 770 fmt.Printf("\n\t%d) %s.%s\n\n", errCount, tc.ClassName, tc.Name) 771 headerColor.Println("\tError was:") 772 bodyColor.Printf("\t%s\n", tc.Error) 773 errCount++ 774 } else if tc.Failure != nil { 775 fmt.Printf("\n\t%d) %s.%s\n\n", failCount, tc.ClassName, tc.Name) 776 headerColor.Println("\tFailure was:") 777 bodyColor.Printf("\t%s\n", tc.Failure) 778 failCount++ 779 } 780 } 781 } 782 783 fmt.Println() 784 t := ptable.NewWriter() 785 t.SetOutputMirror(os.Stdout) 786 t.AppendHeader(ptable.Row{fmt.Sprintf("%s testsuite", r.Framework.Name), "tests", "pass", "fail", "error"}) 787 for _, ts := range testsuites.TestSuites { 788 passed := ts.Tests - ts.Errors - ts.Failures 789 t.AppendRow(ptable.Row{ts.Package, ts.Tests, passed, ts.Failures, ts.Errors}) 790 } 791 t.Render() 792 fmt.Println() 793 } 794 795 func (r *CloudRunner) validateTunnel(name, owner string, dryRun bool, timeout time.Duration) error { 796 return tunnel.Validate(r.TunnelService, name, owner, tunnel.NoneFilter, dryRun, timeout) 797 } 798 799 // stopSuiteExecution stops the current execution on Sauce Cloud 800 func (r *CloudRunner) stopSuiteExecution(jobID string, realDevice bool, suiteName string) { 801 log.Info().Str("suite", suiteName).Msg("Attempting to stop job...") 802 803 // Ignore errors when stopping a job, as it may have already ended or is in 804 // a state where it cannot be stopped. Either way, there's nothing we can do. 805 _, _ = r.JobService.StopJob(context.Background(), jobID, realDevice) 806 } 807 808 // registerInterruptOnSignal stops execution on Sauce Cloud when a SIGINT is captured. 809 func (r *CloudRunner) registerInterruptOnSignal(jobID string, realDevice bool, suiteName string) chan os.Signal { 810 sigChan := make(chan os.Signal, 1) 811 signal.Notify(sigChan, os.Interrupt) 812 813 go func(c <-chan os.Signal, jobID, suiteName string) { 814 sig := <-c 815 if sig == nil { 816 return 817 } 818 r.stopSuiteExecution(jobID, realDevice, suiteName) 819 }(sigChan, jobID, suiteName) 820 return sigChan 821 } 822 823 // registerSkipSuitesOnSignal prevent new suites from being executed when a SIGINT is captured. 824 func (r *CloudRunner) registerSkipSuitesOnSignal() chan os.Signal { 825 sigChan := make(chan os.Signal, 1) 826 signal.Notify(sigChan, os.Interrupt) 827 828 go func(c <-chan os.Signal, cr *CloudRunner) { 829 for { 830 sig := <-c 831 if sig == nil { 832 return 833 } 834 if cr.interrupted { 835 os.Exit(1) 836 } 837 println("\nStopping run. Waiting for all in progress tests to be stopped... (press Ctrl-c again to exit without waiting)\n") 838 cr.interrupted = true 839 } 840 }(sigChan, r) 841 return sigChan 842 } 843 844 // unregisterSignalCapture remove the signal hook associated to the chan c. 845 func unregisterSignalCapture(c chan os.Signal) { 846 signal.Stop(c) 847 close(c) 848 } 849 850 // uploadSauceConfig adds job configuration as an asset. 851 func (r *CloudRunner) uploadSauceConfig(jobID string, realDevice bool, cfgFile string) { 852 // A config file is optional. 853 if cfgFile == "" { 854 return 855 } 856 857 f, err := os.Open(cfgFile) 858 if err != nil { 859 log.Warn().Msgf("failed to open configuration: %v", err) 860 return 861 } 862 content, err := io.ReadAll(f) 863 if err != nil { 864 log.Warn().Msgf("failed to read configuration: %v", err) 865 return 866 } 867 if err := r.JobService.UploadAsset(jobID, realDevice, filepath.Base(cfgFile), "text/plain", content); err != nil { 868 log.Warn().Msgf("failed to attach configuration: %v", err) 869 } 870 } 871 872 // uploadCLIFlags adds commandline parameters as an asset. 873 func (r *CloudRunner) uploadCLIFlags(jobID string, realDevice bool, content interface{}) { 874 encoded, err := json.Marshal(content) 875 if err != nil { 876 log.Warn().Msgf("Failed to encode CLI flags: %v", err) 877 return 878 } 879 if err := r.JobService.UploadAsset(jobID, realDevice, "flags.json", "text/plain", encoded); err != nil { 880 log.Warn().Msgf("Failed to report CLI flags: %v", err) 881 } 882 } 883 884 func (r *CloudRunner) deprecationMessage(frameworkName string, frameworkVersion string, removalDate time.Time) string { 885 formattedDate := removalDate.Format("Jan 02, 2006") 886 887 return fmt.Sprintf( 888 "%s%s%s%s%s", 889 color.RedString(fmt.Sprintf("\n\n%s\n", msg.WarningLine)), 890 color.RedString(fmt.Sprintf("\nVersion %s for %s is deprecated and will be removed on %s!\n", frameworkVersion, frameworkName, formattedDate)), 891 fmt.Sprintf("You should update your version of %s to a more recent one.\n", frameworkName), 892 color.RedString(fmt.Sprintf("\n%s\n\n", msg.WarningLine)), 893 r.getAvailableVersionsMessage(frameworkName), 894 ) 895 } 896 897 func (r *CloudRunner) flaggedForRemovalMessage(frameworkName string, frameworkVersion string) string { 898 return fmt.Sprintf( 899 "%s%s%s%s%s", 900 color.RedString(fmt.Sprintf("\n\n%s\n", msg.WarningLine)), 901 color.RedString(fmt.Sprintf("\nVersion %s for %s is UNSUPPORTED and can be removed at anytime !\n", frameworkVersion, frameworkName)), 902 color.RedString(fmt.Sprintf("You MUST update your version of %s to a more recent one.\n", frameworkName)), 903 color.RedString(fmt.Sprintf("\n%s\n\n", msg.WarningLine)), 904 r.getAvailableVersionsMessage(frameworkName), 905 ) 906 } 907 908 func (r *CloudRunner) logFrameworkError(err error) { 909 var unavailableErr *framework.UnavailableError 910 if errors.As(err, &unavailableErr) { 911 color.Red(fmt.Sprintf("\n%s\n\n", err.Error())) 912 fmt.Print(r.getAvailableVersionsMessage(unavailableErr.Name)) 913 } 914 } 915 916 // logAvailableVersions displays the available cloud version for the framework. 917 func (r *CloudRunner) getAvailableVersionsMessage(frameworkName string) string { 918 versions, err := r.MetadataService.Versions(context.Background(), frameworkName) 919 if err != nil { 920 return "" 921 } 922 m := fmt.Sprintf("Available versions of %s are:\n", frameworkName) 923 for _, v := range versions { 924 if !v.IsDeprecated() && !v.IsFlaggedForRemoval() { 925 m += fmt.Sprintf(" - %s\n", v.FrameworkVersion) 926 } 927 } 928 m += "\n" 929 return m 930 } 931 932 func (r *CloudRunner) getHistory(launchOrder config.LaunchOrder) (insights.JobHistory, error) { 933 user, err := r.UserService.User(context.Background()) 934 if err != nil { 935 return insights.JobHistory{}, err 936 } 937 938 // The config uses spaces, but the API requires underscores. 939 sortBy := strings.ReplaceAll(string(launchOrder), " ", "_") 940 941 return r.InsightsService.GetHistory(context.Background(), user, sortBy) 942 } 943 944 func getSource(isRDC bool) build.Source { 945 if isRDC { 946 return build.RDC 947 } 948 return build.VDC 949 } 950 951 func (r *CloudRunner) reportSuiteToInsights(res result) { 952 // Skip reporting if job is not completed 953 if !job.Done(res.job.Status) || res.skipped || res.job.ID == "" { 954 return 955 } 956 957 if res.details.BuildID == "" { 958 buildID, err := r.BuildService.GetBuildID(context.Background(), res.job.ID, getSource(res.job.IsRDC)) 959 if err != nil { 960 // leave BuildID empty when it failed to get build info 961 log.Warn().Err(err).Str("action", "getBuild").Str("jobID", res.job.ID).Msg(msg.EmptyBuildID) 962 } 963 res.details.BuildID = buildID 964 } 965 966 assets, err := r.JobService.GetJobAssetFileNames(context.Background(), res.job.ID, res.job.IsRDC) 967 if err != nil { 968 log.Warn().Err(err).Str("action", "loadAssets").Str("jobID", res.job.ID).Msg(msg.InsightsReportError) 969 return 970 } 971 972 // read job from insights to get accurate platform and device name 973 j, err := r.InsightsService.ReadJob(context.Background(), res.job.ID) 974 if err != nil { 975 log.Warn().Err(err).Str("action", "readJob").Str("jobID", res.job.ID).Msg(msg.InsightsReportError) 976 return 977 } 978 res.details.Platform = strings.TrimSpace(fmt.Sprintf("%s %s", j.OS, j.OSVersion)) 979 res.details.Device = j.DeviceName 980 981 var testRuns []insights.TestRun 982 if arrayContains(assets, saucereport.SauceReportFileName) { 983 report, err := r.loadSauceTestReport(res.job.ID, res.job.IsRDC) 984 if err != nil { 985 log.Warn().Err(err).Str("action", "parsingJSON").Str("jobID", res.job.ID).Msg(msg.InsightsReportError) 986 return 987 } 988 testRuns = insights.FromSauceReport(report, res.job.ID, res.name, res.details, res.job.IsRDC) 989 } else if arrayContains(assets, junit.FileName) { 990 report, err := r.loadJUnitReport(res.job.ID, res.job.IsRDC) 991 if err != nil { 992 log.Warn().Err(err).Str("action", "parsingXML").Str("jobID", res.job.ID).Msg(msg.InsightsReportError) 993 return 994 } 995 testRuns = insights.FromJUnit(report, res.job.ID, res.name, res.details, res.job.IsRDC) 996 } 997 998 if len(testRuns) > 0 { 999 if err := r.InsightsService.PostTestRun(context.Background(), testRuns); err != nil { 1000 log.Warn().Err(err).Str("action", "posting").Str("jobID", res.job.ID).Msg(msg.InsightsReportError) 1001 } 1002 } 1003 } 1004 1005 func (r *CloudRunner) loadSauceTestReport(jobID string, isRDC bool) (saucereport.SauceReport, error) { 1006 fileContent, err := r.JobService.GetJobAssetFileContent(context.Background(), jobID, saucereport.SauceReportFileName, isRDC) 1007 if err != nil { 1008 log.Warn().Err(err).Str("action", "loading-json-report").Msg(msg.InsightsReportError) 1009 return saucereport.SauceReport{}, err 1010 } 1011 return saucereport.Parse(fileContent) 1012 } 1013 1014 func (r *CloudRunner) loadJUnitReport(jobID string, isRDC bool) (junit.TestSuites, error) { 1015 fileContent, err := r.JobService.GetJobAssetFileContent(context.Background(), jobID, junit.FileName, isRDC) 1016 if err != nil { 1017 log.Warn().Err(err).Str("action", "loading-xml-report").Msg(msg.InsightsReportError) 1018 return junit.TestSuites{}, err 1019 } 1020 return junit.Parse(fileContent) 1021 } 1022 1023 func (r *CloudRunner) downloadArtifacts(suiteName string, job job.Job, when config.When) []string { 1024 if job.ID == "" || job.TimedOut || r.Async || !when.IsNow(job.Passed) { 1025 return []string{} 1026 } 1027 1028 return r.JobService.DownloadArtifact(job.ID, suiteName, job.IsRDC) 1029 } 1030 1031 func arrayContains(list []string, want string) bool { 1032 for _, item := range list { 1033 if item == want { 1034 return true 1035 } 1036 } 1037 return false 1038 } 1039 1040 // download downloads the resource the URL points to and returns its local path. 1041 func (r *CloudRunner) download(url string) (string, error) { 1042 reader, _, err := r.ProjectUploader.DownloadURL(url) 1043 if err != nil { 1044 return "", err 1045 } 1046 defer reader.Close() 1047 1048 dir, err := os.MkdirTemp("", "tmp-app") 1049 if err != nil { 1050 return "", err 1051 } 1052 1053 tmpFilePath := path.Join(dir, path.Base(url)) 1054 1055 f, err := os.Create(tmpFilePath) 1056 if err != nil { 1057 return "", err 1058 } 1059 defer f.Close() 1060 1061 _, err = io.Copy(f, reader) 1062 1063 return tmpFilePath, err 1064 } 1065 1066 func printDryRunSuiteNames(suites []string) { 1067 fmt.Println("\nThe following test suites would have run:") 1068 for _, s := range suites { 1069 fmt.Printf(" - %s\n", s) 1070 } 1071 fmt.Println() 1072 }