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  }