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 }