github.com/saucelabs/saucectl@v0.175.1/internal/testcafe/config.go (about) 1 package testcafe 2 3 import ( 4 "errors" 5 "fmt" 6 "regexp" 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/fpath" 15 "github.com/saucelabs/saucectl/internal/insights" 16 "github.com/saucelabs/saucectl/internal/msg" 17 "github.com/saucelabs/saucectl/internal/region" 18 "github.com/saucelabs/saucectl/internal/sauceignore" 19 "github.com/saucelabs/saucectl/internal/saucereport" 20 ) 21 22 // Config descriptors. 23 var ( 24 // Kind represents the type definition of this config. 25 Kind = "testcafe" 26 27 // APIVersion represents the supported config version. 28 APIVersion = "v1alpha" 29 ) 30 31 // appleDeviceRegex is a device name matching regex for apple devices (mainly ipad/iphone). 32 var appleDeviceRegex = regexp.MustCompile(`(?i)(iP)(hone|ad)[\w\s\d]*(Simulator)?`) 33 34 // Project represents the testcafe project configuration. 35 type Project struct { 36 config.TypeDef `yaml:",inline" mapstructure:",squash"` 37 DryRun bool `yaml:"-" json:"-"` 38 ShowConsoleLog bool `yaml:"showConsoleLog" json:"-"` 39 ConfigFilePath string `yaml:"-" json:"-"` 40 CLIFlags map[string]interface{} `yaml:"-" json:"-"` 41 Sauce config.SauceConfig `yaml:"sauce,omitempty" json:"sauce"` 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 Testcafe Testcafe `yaml:"testcafe,omitempty" json:"testcafe"` 47 Npm config.Npm `yaml:"npm,omitempty" json:"npm"` 48 RootDir string `yaml:"rootDir,omitempty" json:"rootDir"` 49 RunnerVersion string `yaml:"runnerVersion,omitempty" json:"runnerVersion"` 50 Artifacts config.Artifacts `yaml:"artifacts,omitempty" json:"artifacts"` 51 Reporters config.Reporters `yaml:"reporters,omitempty" json:"-"` 52 Defaults config.Defaults `yaml:"defaults,omitempty" json:"defaults"` 53 Env map[string]string `yaml:"env,omitempty" json:"env"` 54 EnvFlag map[string]string `yaml:"-" json:"-"` 55 Notifications config.Notifications `yaml:"notifications,omitempty" json:"-"` 56 } 57 58 // Filter represents the testcafe filters configuration 59 type Filter struct { 60 Test string `yaml:"test,omitempty" json:"test,omitempty"` 61 TestGrep string `yaml:"testGrep,omitempty" json:"testGrep,omitempty"` 62 Fixture string `yaml:"fixture,omitempty" json:"fixture,omitempty"` 63 FixtureGrep string `yaml:"fixtureGrep,omitempty" json:"fixtureGrep,omitempty"` 64 TestMeta map[string]string `yaml:"testMeta,omitempty" json:"testMeta,omitempty"` 65 FixtureMeta map[string]string `yaml:"fixtureMeta,omitempty" json:"fixtureMeta,omitempty"` 66 } 67 68 // CompilerOptions represents the compiler options. 69 type CompilerOptions struct { 70 TypeScript TypescriptCompilerOptions `yaml:"typescript,omitempty" json:"typescript,omitempty"` 71 } 72 73 // TypescriptCompilerOptions represents the typescript compiler options. 74 type TypescriptCompilerOptions struct { 75 ConfigPath string `yaml:"configPath,omitempty" json:"configPath,omitempty"` 76 CustomCompilerModulePath string `yaml:"customCompilerModulePath,omitempty" json:"customCompilerModulePath,omitempty"` 77 Options map[string]string `yaml:"options,omitempty" json:"options,omitempty"` 78 } 79 80 // Suite represents the testcafe test suite configuration. 81 type Suite struct { 82 Name string `yaml:"name,omitempty" json:"name"` 83 BrowserName string `yaml:"browserName,omitempty" json:"browserName"` 84 BrowserVersion string `yaml:"browserVersion,omitempty" json:"browserVersion"` 85 BrowserArgs []string `yaml:"browserArgs,omitempty" json:"browserArgs"` 86 Src []string `yaml:"src,omitempty" json:"src"` 87 Screenshots Screenshots `yaml:"screenshots,omitempty" json:"screenshots"` 88 PlatformName string `yaml:"platformName,omitempty" json:"platformName"` 89 ScreenResolution string `yaml:"screenResolution,omitempty" json:"screenResolution"` 90 Env map[string]string `yaml:"env,omitempty" json:"env"` 91 Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout"` 92 PreExec []string `yaml:"preExec,omitempty" json:"preExec"` 93 ExcludedTestFiles []string `yaml:"excludedTestFiles,omitempty" json:"-"` 94 // Deprecated as of TestCafe v1.10.0 https://testcafe.io/documentation/402638/reference/configuration-file#tsconfigpath 95 TsConfigPath string `yaml:"tsConfigPath,omitempty" json:"tsConfigPath"` 96 ClientScripts []string `yaml:"clientScripts,omitempty" json:"clientScripts,omitempty"` 97 SkipJsErrors bool `yaml:"skipJsErrors,omitempty" json:"skipJsErrors"` 98 QuarantineMode map[string]interface{} `yaml:"quarantineMode,omitempty" json:"quarantineMode,omitempty"` 99 SkipUncaughtErrors bool `yaml:"skipUncaughtErrors,omitempty" json:"skipUncaughtErrors"` 100 SelectorTimeout int `yaml:"selectorTimeout,omitempty" json:"selectorTimeout"` 101 AssertionTimeout int `yaml:"assertionTimeout,omitempty" json:"assertionTimeout"` 102 PageLoadTimeout int `yaml:"pageLoadTimeout,omitempty" json:"pageLoadTimeout"` 103 AjaxRequestTimeout int `yaml:"ajaxRequestTimeout,omitempty" json:"ajaxRequestTimeout"` 104 PageRequestTimeout int `yaml:"pageRequestTimeout,omitempty" json:"pageRequestTimeout"` 105 BrowserInitTimeout int `yaml:"browserInitTimeout,omitempty" json:"browserInitTimeout"` 106 TestExecutionTimeout int `yaml:"testExecutionTimeout,omitempty" json:"testExecutionTimeout"` 107 RunExecutionTimeout int `yaml:"runExecutionTimeout,omitempty" json:"runExecutionTimeout"` 108 Speed float64 `yaml:"speed,omitempty" json:"speed"` 109 StopOnFirstFail bool `yaml:"stopOnFirstFail,omitempty" json:"stopOnFirstFail"` 110 DisablePageCaching bool `yaml:"disablePageCaching,omitempty" json:"disablePageCaching"` 111 DisableScreenshots bool `yaml:"disableScreenshots,omitempty" json:"disableScreenshots"` 112 Filter Filter `yaml:"filter,omitempty" json:"filter,omitempty"` 113 DisableVideo bool `yaml:"disableVideo,omitempty" json:"disableVideo"` // This field is for sauce, not for native testcafe config. 114 Mode string `yaml:"mode,omitempty" json:"-"` 115 Shard string `yaml:"shard,omitempty" json:"-"` 116 Headless bool `yaml:"headless,omitempty" json:"headless"` 117 TimeZone string `yaml:"timeZone,omitempty" json:"timeZone"` 118 PassThreshold int `yaml:"passThreshold,omitempty" json:"-"` 119 SmartRetry config.SmartRetry `yaml:"smartRetry,omitempty" json:"-"` 120 // TypeScript compiling options 121 CompilerOptions CompilerOptions `yaml:"compilerOptions,omitempty" json:"compilerOptions"` 122 // Deprecated. Reserved for future use for actual devices. 123 Devices []config.Simulator `yaml:"devices,omitempty" json:"devices"` 124 Simulators []config.Simulator `yaml:"simulators,omitempty" json:"simulators"` 125 } 126 127 // Screenshots represents screenshots configuration. 128 type Screenshots struct { 129 TakeOnFails bool `yaml:"takeOnFails,omitempty" json:"takeOnFails"` 130 FullPage bool `yaml:"fullPage,omitempty" json:"fullPage"` 131 } 132 133 // Testcafe represents the configuration for testcafe. 134 type Testcafe struct { 135 // Version represents the testcafe framework version. 136 Version string `yaml:"version,omitempty" json:"version"` 137 // ConfigFile represents the testcafe config file 138 ConfigFile string `yaml:"configFile,omitempty" json:"configFile"` 139 } 140 141 // FromFile creates a new testcafe project based on the filepath. 142 func FromFile(cfgPath string) (Project, error) { 143 var p Project 144 145 if err := config.Unmarshal(cfgPath, &p); err != nil { 146 return p, err 147 } 148 149 p.ConfigFilePath = cfgPath 150 151 return p, nil 152 } 153 154 // SetDefaults applies config defaults in case the user has left them blank. 155 func SetDefaults(p *Project) { 156 if p.Kind == "" { 157 p.Kind = Kind 158 } 159 160 if p.APIVersion == "" { 161 p.APIVersion = APIVersion 162 } 163 164 if p.Sauce.Concurrency < 1 { 165 // Default concurrency is 2 166 p.Sauce.Concurrency = 2 167 } 168 169 if p.Defaults.Timeout < 0 { 170 p.Defaults.Timeout = 0 171 } 172 173 // Default rootDir to . 174 if p.RootDir == "" { 175 p.RootDir = "." 176 msg.LogRootDirWarning() 177 } 178 179 p.Sauce.Tunnel.SetDefaults() 180 p.Sauce.Metadata.SetDefaultBuild() 181 p.Npm.SetDefaults(p.Kind, p.Testcafe.Version) 182 183 for k := range p.Suites { 184 suite := &p.Suites[k] 185 // If value is 0, it's assumed that the value has not been filled. 186 // So we define it to the default value: 1 (full speed). 187 // Expected values for TestCafe are between .01 and 1. 188 if suite.Speed < .01 || suite.Speed > 1 { 189 suite.Speed = 1 190 } 191 // Set default timeout. ref: https://devexpress.github.io/testcafe/documentation/reference/configuration-file.html#selectortimeout 192 if suite.SelectorTimeout <= 0 { 193 suite.SelectorTimeout = 10000 194 } 195 if suite.AssertionTimeout <= 0 { 196 suite.AssertionTimeout = 3000 197 } 198 if suite.PageLoadTimeout <= 0 { 199 suite.PageLoadTimeout = 3000 200 } 201 202 if suite.Timeout <= 0 { 203 suite.Timeout = p.Defaults.Timeout 204 } 205 if suite.PassThreshold < 1 { 206 suite.PassThreshold = 1 207 } 208 209 // If this suite is targeting devices, then the platformName on the device takes precedence and we can skip the 210 // defaults on the suite level. 211 if suite.PlatformName == "" && len(suite.Simulators) == 0 { 212 suite.PlatformName = "Windows 10" 213 if strings.ToLower(suite.BrowserName) == "safari" { 214 suite.PlatformName = "macOS 11.00" 215 } 216 log.Info().Msgf(msg.InfoUsingDefaultPlatform, suite.PlatformName, suite.Name) 217 } 218 219 for j := range suite.Simulators { 220 sim := &suite.Simulators[j] 221 if sim.PlatformName == "" && appleDeviceRegex.MatchString(sim.Name) { 222 sim.PlatformName = "iOS" 223 } 224 } 225 } 226 227 // Apply global env vars onto every suite. 228 // Precedence: --env flag > root-level env vars > suite-level env vars. 229 for _, env := range []map[string]string{p.Env, p.EnvFlag} { 230 for k, v := range env { 231 for ks := range p.Suites { 232 s := &p.Suites[ks] 233 if s.Env == nil { 234 s.Env = map[string]string{} 235 } 236 s.Env[k] = v 237 } 238 } 239 } 240 } 241 242 // Validate validates basic configuration of the project and returns an error if any of the settings contain illegal 243 // values. This is not an exhaustive operation and further validation should be performed both in the client and/or 244 // server side depending on the workflow that is executed. 245 func Validate(p *Project) error { 246 regio := region.FromString(p.Sauce.Region) 247 if regio == region.None { 248 return errors.New(msg.MissingRegion) 249 } 250 251 if ok := config.ValidateVisibility(p.Sauce.Visibility); !ok { 252 return fmt.Errorf(msg.InvalidVisibility, p.Sauce.Visibility, strings.Join(config.ValidVisibilityValues, ",")) 253 } 254 255 err := config.ValidateRegistries(p.Npm.Registries) 256 if err != nil { 257 return err 258 } 259 260 if err := config.ValidateArtifacts(p.Artifacts); err != nil { 261 return err 262 } 263 264 p.Testcafe.Version = config.StandardizeVersionFormat(p.Testcafe.Version) 265 if p.Testcafe.Version == "" { 266 return errors.New(msg.MissingFrameworkVersionConfig) 267 } 268 269 if p.Sauce.LaunchOrder != "" && p.Sauce.LaunchOrder != config.LaunchOrderFailRate { 270 return fmt.Errorf(msg.InvalidLaunchingOption, p.Sauce.LaunchOrder, string(config.LaunchOrderFailRate)) 271 } 272 273 if len(p.Suites) == 0 { 274 return errors.New(msg.EmptySuite) 275 } 276 suiteNames := make(map[string]bool) 277 for i, v := range p.Suites { 278 if _, seen := suiteNames[v.Name]; seen { 279 return fmt.Errorf(msg.DuplicateSuiteName, v.Name) 280 } 281 suiteNames[v.Name] = true 282 283 if len(v.Name) == 0 { 284 return fmt.Errorf(msg.MissingSuiteName, i) 285 } 286 287 for _, c := range v.Name { 288 if unicode.IsSymbol(c) { 289 return fmt.Errorf(msg.IllegalSymbol, c, v.Name) 290 } 291 } 292 293 // Force the user to migrate. 294 if len(v.Devices) != 0 { 295 return errors.New(msg.InvalidTestCafeDeviceSetting) 296 } 297 if len(v.ExcludedTestFiles) != 0 { 298 files, err := fpath.FindFiles(p.RootDir, v.Src, fpath.FindByShellPattern) 299 if err != nil { 300 return err 301 } 302 if len(files) == 0 { 303 msg.SuiteSplitNoMatch(v.Name, p.RootDir, v.Src) 304 return fmt.Errorf("suite '%s' test patterns have no matching files", v.Name) 305 } 306 excludedFiles, err := fpath.FindFiles(p.RootDir, v.ExcludedTestFiles, fpath.FindByShellPattern) 307 if err != nil { 308 return err 309 } 310 311 p.Suites[i].Src = fpath.ExcludeFiles(files, excludedFiles) 312 } 313 314 if len(v.Simulators) == 0 && v.BrowserName == "" { 315 return fmt.Errorf(msg.MissingBrowserInSuite, v.Name) 316 } 317 if p.Sauce.Retries < v.PassThreshold-1 { 318 return fmt.Errorf(msg.InvalidPassThreshold) 319 } 320 } 321 if p.Sauce.Retries < 0 { 322 log.Warn().Int("retries", p.Sauce.Retries).Msg(msg.InvalidReries) 323 } 324 325 p.Suites, err = shardSuites(p.RootDir, p.Suites, p.Sauce.Concurrency, p.Sauce.Sauceignore) 326 327 return err 328 } 329 330 // shardSuites divides suites into shards based on the pattern. 331 func shardSuites(rootDir string, suites []Suite, ccy int, sauceignoreFile string) ([]Suite, error) { 332 var shardedSuites []Suite 333 334 for _, s := range suites { 335 if s.Shard != "spec" && s.Shard != "concurrency" { 336 shardedSuites = append(shardedSuites, s) 337 continue 338 } 339 files, err := fpath.FindFiles(rootDir, s.Src, fpath.FindByShellPattern) 340 if err != nil { 341 return []Suite{}, err 342 } 343 if len(files) == 0 { 344 msg.SuiteSplitNoMatch(s.Name, rootDir, s.Src) 345 return []Suite{}, fmt.Errorf("suite '%s' patterns have no matching files", s.Name) 346 } 347 excludedFiles, err := fpath.FindFiles(rootDir, s.ExcludedTestFiles, fpath.FindByShellPattern) 348 if err != nil { 349 return []Suite{}, err 350 } 351 352 files = sauceignore.ExcludeSauceIgnorePatterns(files, sauceignoreFile) 353 testFiles := fpath.ExcludeFiles(files, excludedFiles) 354 355 if s.Shard == "spec" { 356 for _, f := range testFiles { 357 replica := s 358 replica.Name = fmt.Sprintf("%s - %s", s.Name, f) 359 replica.Src = []string{f} 360 shardedSuites = append(shardedSuites, replica) 361 } 362 } 363 if s.Shard == "concurrency" { 364 groups := concurrency.BinPack(testFiles, ccy) 365 for i, group := range groups { 366 replica := s 367 replica.Name = fmt.Sprintf("%s - %d/%d", s.Name, i+1, len(groups)) 368 replica.Src = group 369 shardedSuites = append(shardedSuites, replica) 370 } 371 } 372 } 373 374 return shardedSuites, nil 375 } 376 377 // FilterSuites filters out suites in the project that don't match the given suite name. 378 func FilterSuites(p *Project, suiteName string) error { 379 for _, s := range p.Suites { 380 if s.Name == suiteName { 381 p.Suites = []Suite{s} 382 return nil 383 } 384 } 385 return fmt.Errorf("no suite named '%s' found", suiteName) 386 } 387 388 func IsSharded(suites []Suite) bool { 389 for _, s := range suites { 390 if s.Shard != "" { 391 return true 392 } 393 } 394 return false 395 } 396 397 // SortByHistory sorts the suites in the order of job history 398 func SortByHistory(suites []Suite, history insights.JobHistory) []Suite { 399 hash := map[string]Suite{} 400 for _, s := range suites { 401 hash[s.Name] = s 402 } 403 var res []Suite 404 for _, s := range history.TestCases { 405 if v, ok := hash[s.Name]; ok { 406 res = append(res, v) 407 delete(hash, s.Name) 408 } 409 } 410 for _, v := range suites { 411 if _, ok := hash[v.Name]; ok { 412 res = append(res, v) 413 } 414 } 415 return res 416 } 417 418 // FilterFailedTests takes the failed tests in the report and sets them as a test filter in the suite. 419 // The test filter remains unchanged if the report does not contain any failed tests. 420 func (p *Project) FilterFailedTests(suiteName string, report saucereport.SauceReport) error { 421 failedTests := saucereport.GetFailedTests(report) 422 // if no failed tests found, just keep the original settings 423 if len(failedTests) == 0 { 424 return nil 425 } 426 var found bool 427 for i, s := range p.Suites { 428 if s.Name != suiteName { 429 continue 430 } 431 found = true 432 p.Suites[i].Filter.TestGrep = strings.Join(failedTests, "|") 433 } 434 if !found { 435 return fmt.Errorf("suite(%s) not found", suiteName) 436 } 437 return nil 438 } 439 440 // IsSmartRetried checks if the suites contain a smartRetried suite 441 func (p *Project) IsSmartRetried() bool { 442 for _, s := range p.Suites { 443 if s.SmartRetry.IsRetryFailedOnly() { 444 return true 445 } 446 } 447 return false 448 }