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 }