github.com/saucelabs/saucectl@v0.175.1/internal/cypress/v1alpha/config.go (about)

     1  package v1alpha
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"strings"
     9  	"time"
    10  	"unicode"
    11  
    12  	"github.com/rs/zerolog/log"
    13  	"github.com/saucelabs/saucectl/internal/concurrency"
    14  	"github.com/saucelabs/saucectl/internal/config"
    15  	"github.com/saucelabs/saucectl/internal/cypress/suite"
    16  	"github.com/saucelabs/saucectl/internal/fpath"
    17  	"github.com/saucelabs/saucectl/internal/msg"
    18  	"github.com/saucelabs/saucectl/internal/region"
    19  	"github.com/saucelabs/saucectl/internal/sauceignore"
    20  	"github.com/saucelabs/saucectl/internal/saucereport"
    21  )
    22  
    23  // Config descriptors.
    24  var (
    25  	// Kind represents the type definition of this config.
    26  	Kind = "cypress"
    27  
    28  	// APIVersion represents the supported config version.
    29  	APIVersion = "v1alpha"
    30  )
    31  
    32  // Project represents the cypress project configuration.
    33  type Project struct {
    34  	config.TypeDef `yaml:",inline" mapstructure:",squash"`
    35  	Defaults       config.Defaults        `yaml:"defaults" json:"defaults"`
    36  	DryRun         bool                   `yaml:"-" json:"-"`
    37  	ShowConsoleLog bool                   `yaml:"showConsoleLog" json:"-"`
    38  	ConfigFilePath string                 `yaml:"-" json:"-"`
    39  	CLIFlags       map[string]interface{} `yaml:"-" json:"-"`
    40  	Sauce          config.SauceConfig     `yaml:"sauce,omitempty" json:"sauce"`
    41  	Cypress        Cypress                `yaml:"cypress,omitempty" json:"cypress"`
    42  	// Suite is only used as a workaround to parse adhoc suites that are created via CLI args.
    43  	Suite         Suite                `yaml:"suite,omitempty" json:"-"`
    44  	Suites        []Suite              `yaml:"suites,omitempty" json:"suites"`
    45  	BeforeExec    []string             `yaml:"beforeExec,omitempty" json:"beforeExec"`
    46  	Npm           config.Npm           `yaml:"npm,omitempty" json:"npm"`
    47  	RootDir       string               `yaml:"rootDir,omitempty" json:"rootDir"`
    48  	RunnerVersion string               `yaml:"runnerVersion,omitempty" json:"runnerVersion"`
    49  	Artifacts     config.Artifacts     `yaml:"artifacts,omitempty" json:"artifacts"`
    50  	Reporters     config.Reporters     `yaml:"reporters,omitempty" json:"-"`
    51  	Env           map[string]string    `yaml:"env,omitempty" json:"env"`
    52  	EnvFlag       map[string]string    `yaml:"-" json:"-"`
    53  	Notifications config.Notifications `yaml:"notifications,omitempty" json:"-"`
    54  }
    55  
    56  // Suite represents the cypress test suite configuration.
    57  type Suite struct {
    58  	Name             string            `yaml:"name,omitempty" json:"name"`
    59  	Browser          string            `yaml:"browser,omitempty" json:"browser"`
    60  	BrowserVersion   string            `yaml:"browserVersion,omitempty" json:"browserVersion"`
    61  	PlatformName     string            `yaml:"platformName,omitempty" json:"platformName"`
    62  	Config           SuiteConfig       `yaml:"config,omitempty" json:"config"`
    63  	ScreenResolution string            `yaml:"screenResolution,omitempty" json:"screenResolution"`
    64  	Timeout          time.Duration     `yaml:"timeout,omitempty" json:"timeout"`
    65  	Shard            string            `yaml:"shard,omitempty" json:"-"`
    66  	Headless         bool              `yaml:"headless,omitempty" json:"headless"`
    67  	PreExec          []string          `yaml:"preExec,omitempty" json:"preExec"`
    68  	TimeZone         string            `yaml:"timeZone,omitempty" json:"timeZone"`
    69  	PassThreshold    int               `yaml:"passThreshold,omitempty" json:"-"`
    70  	SmartRetry       config.SmartRetry `yaml:"smartRetry,omitempty" json:"-"`
    71  }
    72  
    73  // SuiteConfig represents the cypress config overrides.
    74  type SuiteConfig struct {
    75  	TestFiles         []string          `yaml:"testFiles,omitempty" json:"testFiles"`
    76  	ExcludedTestFiles []string          `yaml:"excludedTestFiles,omitempty" json:"ignoreTestFiles,omitempty"`
    77  	Env               map[string]string `yaml:"env,omitempty" json:"env"`
    78  }
    79  
    80  // Reporter represents a cypress report configuration.
    81  type Reporter struct {
    82  	Name    string                 `yaml:"name" json:"name"`
    83  	Options map[string]interface{} `yaml:"options" json:"options"`
    84  }
    85  
    86  // Cypress represents crucial cypress configuration that is required for setting up a project.
    87  type Cypress struct {
    88  	// ConfigFile is the path to "cypress.json".
    89  	ConfigFile string `yaml:"configFile,omitempty" json:"configFile"`
    90  
    91  	// Version represents the cypress framework version.
    92  	Version string `yaml:"version" json:"version"`
    93  
    94  	// Record represents the cypress framework record flag.
    95  	Record bool `yaml:"record" json:"record"`
    96  
    97  	// Key represents the cypress framework key flag.
    98  	Key string `yaml:"key" json:"key"`
    99  
   100  	// Reporters represents the customer reporters.
   101  	Reporters []Reporter `yaml:"reporters" json:"reporters"`
   102  }
   103  
   104  // FromFile creates a new cypress Project based on the filepath cfgPath.
   105  func FromFile(cfgPath string) (*Project, error) {
   106  	var p *Project
   107  
   108  	if err := config.Unmarshal(cfgPath, &p); err != nil {
   109  		return p, err
   110  	}
   111  
   112  	p.ConfigFilePath = cfgPath
   113  
   114  	return p, nil
   115  }
   116  
   117  // SetDefaults applies config defaults in case the user has left them blank.
   118  func (p *Project) SetDefaults() {
   119  	if p.Kind == "" {
   120  		p.Kind = Kind
   121  	}
   122  
   123  	if p.APIVersion == "" {
   124  		p.APIVersion = APIVersion
   125  	}
   126  
   127  	if p.Sauce.Concurrency < 1 {
   128  		p.Sauce.Concurrency = 2
   129  	}
   130  
   131  	// Default rootDir to .
   132  	if p.RootDir == "" {
   133  		p.RootDir = "."
   134  		msg.LogRootDirWarning()
   135  	}
   136  
   137  	if p.Defaults.Timeout < 0 {
   138  		p.Defaults.Timeout = 0
   139  	}
   140  
   141  	p.Sauce.Tunnel.SetDefaults()
   142  	p.Sauce.Metadata.SetDefaultBuild()
   143  	p.Npm.SetDefaults(p.Kind, p.Cypress.Version)
   144  
   145  	for k := range p.Suites {
   146  		s := &p.Suites[k]
   147  		if s.PlatformName == "" {
   148  			s.PlatformName = "Windows 10"
   149  			log.Info().Msgf(msg.InfoUsingDefaultPlatform, s.PlatformName, s.Name)
   150  		}
   151  
   152  		if s.Timeout <= 0 {
   153  			s.Timeout = p.Defaults.Timeout
   154  		}
   155  
   156  		if s.Config.Env == nil {
   157  			s.Config.Env = map[string]string{}
   158  		}
   159  
   160  		// Apply global env vars onto suite.
   161  		// Precedence: --env flag > root-level env vars > suite-level env vars.
   162  		for _, env := range []map[string]string{p.Env, p.EnvFlag} {
   163  			for k, v := range env {
   164  				s.Config.Env[k] = v
   165  			}
   166  		}
   167  
   168  		if s.PassThreshold < 1 {
   169  			s.PassThreshold = 1
   170  		}
   171  
   172  		// Update cypress related env vars.
   173  		for envK := range s.Config.Env {
   174  			// Add an entry without CYPRESS_ prefix as we directly pass it in Cypress.
   175  			if strings.HasPrefix(envK, "CYPRESS_") {
   176  				newKey := strings.TrimPrefix(envK, "CYPRESS_")
   177  				s.Config.Env[newKey] = s.Config.Env[envK]
   178  			}
   179  		}
   180  	}
   181  }
   182  
   183  func checkAvailability(path string, mustBeDirectory bool) error {
   184  	st, err := os.Stat(path)
   185  	if err != nil {
   186  		return err
   187  	}
   188  	if mustBeDirectory && !st.IsDir() {
   189  		return fmt.Errorf("%s: not a folder", path)
   190  	}
   191  	return nil
   192  }
   193  
   194  // loadCypressConfiguration reads the cypress.json file and performs basic validation.
   195  func loadCypressConfiguration(rootDir string, cypressCfgFile, sauceIgnoreFile string) (Config, error) {
   196  	isIgnored, err := isCypressCfgIgnored(sauceIgnoreFile, cypressCfgFile)
   197  	if err != nil {
   198  		return Config{}, err
   199  	}
   200  	if isIgnored {
   201  		return Config{}, fmt.Errorf("your .sauceignore configuration seems to include statements that match crucial cypress configuration files (e.g. cypress.json). In order to run your test successfully, please adjust your .sauceignore configuration")
   202  	}
   203  
   204  	cypressCfgPath := filepath.Join(rootDir, cypressCfgFile)
   205  	cfg, err := configFromFile(cypressCfgPath)
   206  	if err != nil {
   207  		return Config{}, err
   208  	}
   209  
   210  	if cfg.IntegrationFolder == "" {
   211  		cfg.IntegrationFolder = "cypress/integration"
   212  	}
   213  
   214  	configDir := filepath.Dir(cypressCfgPath)
   215  	if err = checkAvailability(filepath.Join(configDir, cfg.IntegrationFolder), true); err != nil {
   216  		return Config{}, err
   217  	}
   218  
   219  	// FixturesFolder sets the path to folder containing fixture files (Pass false to disable)
   220  	// ref:  https://docs.cypress.io/guides/references/configuration#Folders-Files
   221  	if f, ok := cfg.FixturesFolder.(string); ok && f != "" {
   222  		if err = checkAvailability(filepath.Join(configDir, f), true); err != nil {
   223  			return Config{}, err
   224  		}
   225  	}
   226  
   227  	if cfg.SupportFile != "" {
   228  		if err = checkAvailability(filepath.Join(configDir, cfg.SupportFile), false); err != nil {
   229  			return Config{}, err
   230  		}
   231  	}
   232  
   233  	if cfg.PluginsFile != "" {
   234  		if err = checkAvailability(filepath.Join(configDir, cfg.PluginsFile), false); err != nil {
   235  			return Config{}, err
   236  		}
   237  	}
   238  
   239  	return cfg, nil
   240  }
   241  
   242  func isCypressCfgIgnored(sauceIgnoreFile, cypressCfgFile string) (bool, error) {
   243  	if _, err := os.Stat(sauceIgnoreFile); err != nil {
   244  		return false, nil
   245  	}
   246  	matcher, err := sauceignore.NewMatcherFromFile(sauceIgnoreFile)
   247  	if err != nil {
   248  		return false, err
   249  	}
   250  
   251  	return matcher.Match([]string{cypressCfgFile}, false), nil
   252  }
   253  
   254  // Validate validates basic configuration of the project and returns an error if any of the settings contain illegal
   255  // values. This is not an exhaustive operation and further validation should be performed both in the client and/or
   256  // server side depending on the workflow that is executed.
   257  func (p *Project) Validate() error {
   258  	p.Cypress.Version = config.StandardizeVersionFormat(p.Cypress.Version)
   259  
   260  	if p.Cypress.Version == "" {
   261  		return errors.New(msg.MissingCypressVersion)
   262  	}
   263  
   264  	// Check rootDir exists.
   265  	if p.RootDir != "" {
   266  		if _, err := os.Stat(p.RootDir); err != nil {
   267  			return fmt.Errorf(msg.UnableToLocateRootDir, p.RootDir)
   268  		}
   269  	}
   270  
   271  	regio := region.FromString(p.Sauce.Region)
   272  	if regio == region.None {
   273  		return errors.New(msg.MissingRegion)
   274  	}
   275  
   276  	if ok := config.ValidateVisibility(p.Sauce.Visibility); !ok {
   277  		return fmt.Errorf(msg.InvalidVisibility, p.Sauce.Visibility, strings.Join(config.ValidVisibilityValues, ","))
   278  	}
   279  
   280  	err := config.ValidateRegistries(p.Npm.Registries)
   281  	if err != nil {
   282  		return err
   283  	}
   284  
   285  	if p.Sauce.LaunchOrder != "" && p.Sauce.LaunchOrder != config.LaunchOrderFailRate {
   286  		return fmt.Errorf(msg.InvalidLaunchingOption, p.Sauce.LaunchOrder, string(config.LaunchOrderFailRate))
   287  	}
   288  
   289  	// Validate suites.
   290  	if len(p.Suites) == 0 {
   291  		return errors.New(msg.EmptySuite)
   292  	}
   293  	suiteNames := make(map[string]bool)
   294  	for idx, s := range p.Suites {
   295  		if _, seen := suiteNames[s.Name]; seen {
   296  			return fmt.Errorf(msg.DuplicateSuiteName, s.Name)
   297  		}
   298  		suiteNames[s.Name] = true
   299  
   300  		if len(s.Name) == 0 {
   301  			return fmt.Errorf(msg.MissingSuiteName, idx)
   302  		}
   303  
   304  		for _, c := range s.Name {
   305  			if unicode.IsSymbol(c) {
   306  				return fmt.Errorf(msg.IllegalSymbol, c, s.Name)
   307  			}
   308  		}
   309  
   310  		if s.Browser == "" {
   311  			return fmt.Errorf(msg.MissingBrowserInSuite, s.Name)
   312  		}
   313  
   314  		if s.PlatformName == "" {
   315  			return fmt.Errorf(msg.MissingPlatformName)
   316  		}
   317  
   318  		if len(s.Config.TestFiles) == 0 {
   319  			return fmt.Errorf(msg.MissingTestFiles, s.Name)
   320  		}
   321  		if p.Sauce.Retries < s.PassThreshold-1 {
   322  			return fmt.Errorf(msg.InvalidPassThreshold)
   323  		}
   324  	}
   325  	if p.Sauce.Retries < 0 {
   326  		log.Warn().Int("retries", p.Sauce.Retries).Msg(msg.InvalidReries)
   327  	}
   328  
   329  	cfg, err := loadCypressConfiguration(p.RootDir, p.Cypress.ConfigFile, p.Sauce.Sauceignore)
   330  	if err != nil {
   331  		return err
   332  	}
   333  
   334  	if p.Suites, err = shardSuites(cfg, p.Suites, p.Sauce.Concurrency, p.Sauce.Sauceignore); err != nil {
   335  		return err
   336  	}
   337  	if len(p.Suites) == 0 {
   338  		return errors.New(msg.EmptySuite)
   339  	}
   340  	return nil
   341  }
   342  
   343  func shardSuites(cfg Config, suites []Suite, ccy int, sauceignoreFile string) ([]Suite, error) {
   344  	var shardedSuites []Suite
   345  	for _, s := range suites {
   346  		// Use the original suite if there is nothing to shard.
   347  		if s.Shard != "spec" && s.Shard != "concurrency" {
   348  			shardedSuites = append(shardedSuites, s)
   349  			continue
   350  		}
   351  		files, err := fpath.FindFiles(cfg.AbsIntegrationFolder(), s.Config.TestFiles, fpath.FindByShellPattern)
   352  		if err != nil {
   353  			return shardedSuites, err
   354  		}
   355  		if len(files) == 0 {
   356  			msg.SuiteSplitNoMatch(s.Name, cfg.AbsIntegrationFolder(), s.Config.TestFiles)
   357  			return []Suite{}, fmt.Errorf("suite '%s' patterns have no matching files", s.Name)
   358  		}
   359  		excludedFiles, err := fpath.FindFiles(cfg.AbsIntegrationFolder(), s.Config.ExcludedTestFiles, fpath.FindByShellPattern)
   360  		if err != nil {
   361  			return shardedSuites, err
   362  		}
   363  
   364  		files = sauceignore.ExcludeSauceIgnorePatterns(files, sauceignoreFile)
   365  		testFiles := fpath.ExcludeFiles(files, excludedFiles)
   366  
   367  		if s.Shard == "spec" {
   368  			for _, f := range testFiles {
   369  				replica := s
   370  				replica.Name = fmt.Sprintf("%s - %s", s.Name, f)
   371  				replica.Config.TestFiles = []string{f}
   372  				shardedSuites = append(shardedSuites, replica)
   373  			}
   374  		}
   375  		if s.Shard == "concurrency" {
   376  			fileGroups := concurrency.BinPack(testFiles, ccy)
   377  			for i, group := range fileGroups {
   378  				replica := s
   379  				replica.Name = fmt.Sprintf("%s - %d/%d", s.Name, i+1, len(fileGroups))
   380  				replica.Config.TestFiles = group
   381  				shardedSuites = append(shardedSuites, replica)
   382  			}
   383  		}
   384  	}
   385  
   386  	return shardedSuites, nil
   387  }
   388  
   389  // FilterSuites filters out suites in the project that don't match the given suite name.
   390  func (p *Project) FilterSuites(suiteName string) error {
   391  	for _, s := range p.Suites {
   392  		if s.Name == suiteName {
   393  			p.Suites = []Suite{s}
   394  			return nil
   395  		}
   396  	}
   397  	return fmt.Errorf(msg.SuiteNameNotFound, suiteName)
   398  }
   399  
   400  // IsSharded returns is it's sharded
   401  func (p *Project) IsSharded() bool {
   402  	for _, s := range p.Suites {
   403  		if s.Shard != "" {
   404  			return true
   405  		}
   406  	}
   407  	return false
   408  }
   409  
   410  // CleanPackages removes cypress from npm packages
   411  func (p *Project) CleanPackages() {
   412  	// Don't allow framework installation, it is provided by the runner
   413  	version, hasFramework := p.Npm.Packages["cypress"]
   414  	if hasFramework {
   415  		log.Warn().Msg(msg.IgnoredNpmPackagesMsg("cypress", p.Cypress.Version, []string{fmt.Sprintf("cypress@%s", version)}))
   416  		p.Npm.Packages = config.CleanNpmPackages(p.Npm.Packages, []string{"cypress"})
   417  	}
   418  }
   419  
   420  // GetSuiteCount returns the amount of suites
   421  func (p *Project) GetSuiteCount() int {
   422  	if p == nil {
   423  		return 0
   424  	}
   425  	return len(p.Suites)
   426  }
   427  
   428  // GetVersion returns cypress version
   429  func (p *Project) GetVersion() string {
   430  	return p.Cypress.Version
   431  }
   432  
   433  // GetRunnerVersion returns RunnerVersion
   434  func (p *Project) GetRunnerVersion() string {
   435  	return p.RunnerVersion
   436  }
   437  
   438  // SetVersion sets cypress version
   439  func (p *Project) SetVersion(version string) {
   440  	p.Cypress.Version = version
   441  }
   442  
   443  // SetRunnerVersion sets runner version
   444  func (p *Project) SetRunnerVersion(version string) {
   445  	p.RunnerVersion = version
   446  }
   447  
   448  // GetSauceCfg returns sauce related config
   449  func (p *Project) GetSauceCfg() config.SauceConfig {
   450  	return p.Sauce
   451  }
   452  
   453  // IsDryRun returns DryRun
   454  func (p *Project) IsDryRun() bool {
   455  	return p.DryRun
   456  }
   457  
   458  // GetRootDir returns RootDir
   459  func (p *Project) GetRootDir() string {
   460  	return p.RootDir
   461  }
   462  
   463  // GetSuiteNames returns combined suite names
   464  func (p *Project) GetSuiteNames() string {
   465  	var names []string
   466  	for _, s := range p.Suites {
   467  		names = append(names, s.Name)
   468  	}
   469  	return strings.Join(names, ", ")
   470  }
   471  
   472  // GetCfgPath returns ConfigFilePath
   473  func (p *Project) GetCfgPath() string {
   474  	return p.ConfigFilePath
   475  }
   476  
   477  // GetCLIFlags returns CLIFlags
   478  func (p *Project) GetCLIFlags() map[string]interface{} {
   479  	return p.CLIFlags
   480  }
   481  
   482  // GetArtifactsCfg returns config.Artifacts
   483  func (p *Project) GetArtifactsCfg() config.Artifacts {
   484  	return p.Artifacts
   485  }
   486  
   487  // IsShowConsoleLog returns ShowConsoleLog
   488  func (p *Project) IsShowConsoleLog() bool {
   489  	return p.ShowConsoleLog
   490  }
   491  
   492  // GetBeforeExec returns BeforeExec
   493  func (p *Project) GetBeforeExec() []string {
   494  	return p.BeforeExec
   495  }
   496  
   497  // GetReporter returns config.Reporters
   498  func (p *Project) GetReporters() config.Reporters {
   499  	return p.Reporters
   500  }
   501  
   502  // GetNotifications returns config.Notifications
   503  func (p *Project) GetNotifications() config.Notifications {
   504  	return p.Notifications
   505  }
   506  
   507  // GetNpm returns config.Npm
   508  func (p *Project) GetNpm() config.Npm {
   509  	return p.Npm
   510  }
   511  
   512  // SetCLIFlags sets cli flags
   513  func (p *Project) SetCLIFlags(flags map[string]interface{}) {
   514  	p.CLIFlags = flags
   515  }
   516  
   517  // GetSuites returns suites
   518  func (p *Project) GetSuites() []suite.Suite {
   519  	suites := []suite.Suite{}
   520  	for _, s := range p.Suites {
   521  		suites = append(suites, suite.Suite{
   522  			Name:             s.Name,
   523  			Browser:          s.Browser,
   524  			BrowserVersion:   s.BrowserVersion,
   525  			PlatformName:     s.PlatformName,
   526  			ScreenResolution: s.ScreenResolution,
   527  			Timeout:          s.Timeout,
   528  			Shard:            s.Shard,
   529  			Headless:         s.Headless,
   530  			PreExec:          s.PreExec,
   531  			TimeZone:         s.TimeZone,
   532  			Env:              s.Config.Env,
   533  			PassThreshold:    s.PassThreshold,
   534  		})
   535  	}
   536  	return suites
   537  }
   538  
   539  // GetKind returns Kind
   540  func (p *Project) GetKind() string {
   541  	return p.Kind
   542  }
   543  
   544  // GetSuite returns suite
   545  func (p *Project) GetSuite() suite.Suite {
   546  	s := p.Suite
   547  	return suite.Suite{
   548  		Name:             s.Name,
   549  		Browser:          s.Browser,
   550  		BrowserVersion:   s.BrowserVersion,
   551  		PlatformName:     s.PlatformName,
   552  		ScreenResolution: s.ScreenResolution,
   553  		Timeout:          s.Timeout,
   554  		Shard:            s.Shard,
   555  		Headless:         s.Headless,
   556  		PreExec:          s.PreExec,
   557  		TimeZone:         s.TimeZone,
   558  		Env:              s.Config.Env,
   559  		PassThreshold:    s.PassThreshold,
   560  	}
   561  }
   562  
   563  // ApplyFlags applys cli flags on cypress project
   564  func (p *Project) ApplyFlags(selectedSuite string) error {
   565  	if selectedSuite != "" {
   566  		if err := p.FilterSuites(selectedSuite); err != nil {
   567  			return err
   568  		}
   569  	}
   570  
   571  	// Create an adhoc suite if "--name" is provided
   572  	if p.Suite.Name != "" {
   573  		p.Suites = []Suite{p.Suite}
   574  	}
   575  
   576  	return nil
   577  }
   578  
   579  // AppendTags adds tags
   580  func (p *Project) AppendTags(tags []string) {
   581  	p.Sauce.Metadata.Tags = append(p.Sauce.Metadata.Tags, tags...)
   582  }
   583  
   584  // GetAPIVersion returns APIVersion
   585  func (p *Project) GetAPIVersion() string {
   586  	return p.APIVersion
   587  }
   588  
   589  // GetSmartRetry returns smartRetry config
   590  func (p *Project) GetSmartRetry(suiteName string) config.SmartRetry {
   591  	for _, s := range p.Suites {
   592  		if s.Name == suiteName {
   593  			return s.SmartRetry
   594  		}
   595  	}
   596  	return config.SmartRetry{}
   597  }
   598  
   599  // FilterFailedTests takes the failed tests in the report and sets them as a test filter in the suite.
   600  // The test filter remains unchanged if the report does not contain any failed tests.
   601  func (p *Project) FilterFailedTests(suiteName string, report saucereport.SauceReport) error {
   602  	failedTests := saucereport.GetFailedTests(report)
   603  	if len(failedTests) == 0 {
   604  		return nil
   605  	}
   606  
   607  	var found bool
   608  	for i, s := range p.Suites {
   609  		if s.Name != suiteName {
   610  			continue
   611  		}
   612  		found = true
   613  		if p.Suites[i].Config.Env == nil {
   614  			p.Suites[i].Config.Env = map[string]string{}
   615  		}
   616  		p.Suites[i].Config.Env["grep"] = strings.Join(failedTests, ";")
   617  
   618  	}
   619  	if !found {
   620  		return fmt.Errorf("suite(%s) not found", suiteName)
   621  	}
   622  	return nil
   623  }
   624  
   625  // IsSmartRetried checks if the suites contain a smartRetried suite
   626  func (p *Project) IsSmartRetried() bool {
   627  	for _, s := range p.Suites {
   628  		if s.SmartRetry.IsRetryFailedOnly() {
   629  			return true
   630  		}
   631  	}
   632  	return false
   633  }