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

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