github.com/saucelabs/saucectl@v0.175.1/internal/playwright/config.go (about) 1 package playwright 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/fpath" 15 "github.com/saucelabs/saucectl/internal/insights" 16 "github.com/saucelabs/saucectl/internal/msg" 17 "github.com/saucelabs/saucectl/internal/playwright/grep" 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 = "playwright" 27 28 // APIVersion represents the supported config version. 29 APIVersion = "v1alpha" 30 ) 31 32 var supportedBrowsers = []string{"chromium", "firefox", "webkit", "chrome"} 33 34 // Project represents the playwright project configuration. 35 type Project struct { 36 config.TypeDef `yaml:",inline" mapstructure:",squash"` 37 ShowConsoleLog bool `yaml:"showConsoleLog" json:"-"` 38 DryRun bool `yaml:"-" json:"-"` 39 ConfigFilePath string `yaml:"-" json:"-"` 40 CLIFlags map[string]interface{} `yaml:"-" json:"-"` 41 Sauce config.SauceConfig `yaml:"sauce,omitempty" json:"sauce"` 42 Playwright Playwright `yaml:"playwright,omitempty" json:"playwright"` 43 // Suite is only used as a workaround to parse adhoc suites that are created via CLI args. 44 Suite Suite `yaml:"suite,omitempty" json:"-"` 45 Suites []Suite `yaml:"suites,omitempty" json:"suites"` 46 BeforeExec []string `yaml:"beforeExec,omitempty" json:"beforeExec"` 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 // Playwright represents crucial playwright configuration that is required for setting up a project. 59 type Playwright struct { 60 Version string `yaml:"version,omitempty" json:"version,omitempty"` 61 ConfigFile string `yaml:"configFile,omitempty" json:"configFile,omitempty"` 62 } 63 64 // Suite represents the playwright test suite configuration. 65 type Suite struct { 66 Name string `yaml:"name,omitempty" json:"name"` 67 Mode string `yaml:"mode,omitempty" json:"-"` 68 Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout"` 69 PlaywrightVersion string `yaml:"playwrightVersion,omitempty" json:"playwrightVersion,omitempty"` 70 TestMatch []string `yaml:"testMatch,omitempty" json:"testMatch,omitempty"` 71 ExcludedTestFiles []string `yaml:"excludedTestFiles,omitempty" json:"testIgnore"` 72 PlatformName string `yaml:"platformName,omitempty" json:"platformName,omitempty"` 73 Params SuiteConfig `yaml:"params,omitempty" json:"param,omitempty"` 74 ScreenResolution string `yaml:"screenResolution,omitempty" json:"screenResolution,omitempty"` 75 Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"` 76 NumShards int `yaml:"numShards,omitempty" json:"-"` 77 Shard string `yaml:"shard,omitempty" json:"-"` 78 PreExec []string `yaml:"preExec,omitempty" json:"preExec"` 79 TimeZone string `yaml:"timeZone,omitempty" json:"timeZone"` 80 PassThreshold int `yaml:"passThreshold,omitempty" json:"-"` 81 SmartRetry config.SmartRetry `yaml:"smartRetry,omitempty" json:"-"` 82 ShardGrepEnabled bool `yaml:"shardGrepEnabled,omitempty" json:"-"` 83 } 84 85 // SuiteConfig represents the configuration specific to a suite 86 type SuiteConfig struct { 87 BrowserName string `yaml:"browserName,omitempty" json:"browserName,omitempty"` 88 // BrowserVersion for playwright is not specified by the user, but determined by Test-Composer 89 BrowserVersion string `yaml:"-" json:"-"` 90 91 // Fields appeared in v1.12+ 92 Headless bool `yaml:"headless,omitempty" json:"headless,omitempty"` 93 GlobalTimeout int `yaml:"globalTimeout,omitempty" json:"globalTimeout,omitempty"` 94 Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` 95 Grep string `yaml:"grep,omitempty" json:"grep,omitempty"` 96 GrepInvert string `yaml:"grepInvert,omitempty" json:"grepInvert,omitempty"` 97 RepeatEach int `yaml:"repeatEach,omitempty" json:"repeatEach,omitempty"` 98 Retries int `yaml:"retries,omitempty" json:"retries,omitempty"` 99 MaxFailures int `yaml:"maxFailures,omitempty" json:"maxFailures,omitempty"` 100 Project string `yaml:"project" json:"project,omitempty"` 101 UpdateSnapshots bool `yaml:"updateSnapshots,omitempty" json:"updateSnapshots"` 102 Workers int `yaml:"workers,omitempty" json:"workers,omitempty"` 103 104 // Shard is set by saucectl (not user) based on Suite.NumShards. 105 Shard string `yaml:"-" json:"shard,omitempty"` 106 } 107 108 // FromFile creates a new playwright Project based on the filepath cfgPath. 109 func FromFile(cfgPath string) (Project, error) { 110 var p Project 111 112 if err := config.Unmarshal(cfgPath, &p); err != nil { 113 return p, err 114 } 115 116 p.ConfigFilePath = cfgPath 117 118 return p, nil 119 } 120 121 // SetDefaults applies config defaults in case the user has left them blank. 122 func SetDefaults(p *Project) { 123 if p.Kind == "" { 124 p.Kind = Kind 125 } 126 127 if p.APIVersion == "" { 128 p.APIVersion = APIVersion 129 } 130 131 if p.Sauce.Concurrency < 1 { 132 p.Sauce.Concurrency = 2 133 } 134 135 // Default rootDir to . 136 if p.RootDir == "" { 137 p.RootDir = "." 138 msg.LogRootDirWarning() 139 } 140 141 if p.Defaults.Timeout < 0 { 142 p.Defaults.Timeout = 0 143 } 144 145 p.Sauce.Tunnel.SetDefaults() 146 p.Sauce.Metadata.SetDefaultBuild() 147 p.Npm.SetDefaults(p.Kind, p.Playwright.Version) 148 149 for k := range p.Suites { 150 s := &p.Suites[k] 151 if s.PlatformName == "" { 152 s.PlatformName = "Windows 10" 153 log.Info().Msgf(msg.InfoUsingDefaultPlatform, s.PlatformName, s.Name) 154 } 155 156 if s.Timeout <= 0 { 157 s.Timeout = p.Defaults.Timeout 158 } 159 160 if s.Params.Workers <= 0 { 161 s.Params.Workers = 1 162 } 163 if s.PassThreshold < 1 { 164 s.PassThreshold = 1 165 } 166 } 167 168 // Apply global env vars onto every suite. 169 // Precedence: --env flag > root-level env vars > suite-level env vars. 170 for _, env := range []map[string]string{p.EnvFlag, p.Env} { 171 for k, v := range env { 172 for ks := range p.Suites { 173 s := &p.Suites[ks] 174 if s.Env == nil { 175 s.Env = map[string]string{} 176 } 177 s.Env[k] = v 178 } 179 } 180 } 181 } 182 183 // ShardSuites applies sharding by NumShards or by Shard (based on pattern) 184 func ShardSuites(p *Project) error { 185 if err := checkShards(p); err != nil { 186 return err 187 } 188 189 // either sharding by NumShards or by Shard will be applied 190 p.Suites = shardSuitesByNumShards(p.Suites) 191 shardedSuites, err := shardInSuites(p.RootDir, p.Suites, p.Sauce.Concurrency, p.Sauce.Sauceignore) 192 if err != nil { 193 return err 194 } 195 p.Suites = shardedSuites 196 197 if len(p.Suites) == 0 { 198 return errors.New(msg.EmptySuite) 199 } 200 return nil 201 } 202 203 func checkShards(p *Project) error { 204 errMsg := "suite name: %s numShards and shard can't be used at the same time" 205 for _, suite := range p.Suites { 206 if suite.NumShards >= 2 && suite.Shard != "" { 207 return fmt.Errorf(errMsg, suite.Name) 208 } 209 } 210 211 return nil 212 } 213 214 // shardInSuites divides suites into shards based on the pattern. 215 func shardInSuites(rootDir string, suites []Suite, ccy int, sauceignoreFile string) ([]Suite, error) { 216 var shardedSuites []Suite 217 218 for _, s := range suites { 219 if s.Shard != "spec" && s.Shard != "concurrency" { 220 shardedSuites = append(shardedSuites, s) 221 continue 222 } 223 files, err := fpath.FindFiles(rootDir, s.TestMatch, fpath.FindByRegex) 224 if err != nil { 225 return []Suite{}, err 226 } 227 if len(files) == 0 { 228 msg.SuiteSplitNoMatch(s.Name, rootDir, s.TestMatch) 229 return []Suite{}, fmt.Errorf("suite '%s' patterns have no matching files", s.Name) 230 } 231 excludedFiles, err := fpath.FindFiles(rootDir, s.ExcludedTestFiles, fpath.FindByRegex) 232 if err != nil { 233 return []Suite{}, err 234 } 235 236 files = sauceignore.ExcludeSauceIgnorePatterns(files, sauceignoreFile) 237 testFiles := fpath.ExcludeFiles(files, excludedFiles) 238 239 if s.ShardGrepEnabled && (s.Params.Grep != "" || s.Params.GrepInvert != "") { 240 var unmatched []string 241 testFiles, unmatched = grep.MatchFiles(os.DirFS(rootDir), testFiles, s.Params.Grep, s.Params.GrepInvert) 242 if len(testFiles) == 0 { 243 log.Error().Str("suiteName", s.Name).Str("grep", s.Params.Grep).Msg("No files match the configured grep expressions") 244 return []Suite{}, errors.New(msg.ShardingConfigurationNoMatchingTests) 245 } else if len(unmatched) > 0 { 246 log.Info().Str("suiteName", s.Name).Str("grep", s.Params.Grep).Msgf("Files filtered out by grep: %q", unmatched) 247 } 248 } 249 250 if s.Shard == "spec" { 251 for _, f := range testFiles { 252 replica := s 253 replica.Name = fmt.Sprintf("%s - %s", s.Name, f) 254 replica.TestMatch = []string{f} 255 shardedSuites = append(shardedSuites, replica) 256 } 257 } 258 if s.Shard == "concurrency" { 259 groups := concurrency.BinPack(testFiles, ccy) 260 for i, group := range groups { 261 replica := s 262 replica.Name = fmt.Sprintf("%s - %d/%d", s.Name, i+1, len(groups)) 263 replica.TestMatch = group 264 shardedSuites = append(shardedSuites, replica) 265 } 266 } 267 } 268 return shardedSuites, nil 269 } 270 271 // shardSuitesByNumShards applies sharding by replacing the original suites with the appropriate number of replicas according to 272 // the numShards setting on each suite. A suite is only sharded if numShards > 1. 273 func shardSuitesByNumShards(suites []Suite) []Suite { 274 var shardedSuites []Suite 275 for _, s := range suites { 276 // Use the original suite if there is nothing to shard. 277 if s.NumShards <= 1 { 278 shardedSuites = append(shardedSuites, s) 279 continue 280 } 281 282 for i := 1; i <= s.NumShards; i++ { 283 replica := s 284 replica.Params.Shard = fmt.Sprintf("%d/%d", i, s.NumShards) 285 replica.Name = fmt.Sprintf("%s (shard %s)", replica.Name, replica.Params.Shard) 286 shardedSuites = append(shardedSuites, replica) 287 } 288 } 289 return shardedSuites 290 } 291 292 // Validate validates basic configuration of the project and returns an error if any of the settings contain illegal 293 // values. This is not an exhaustive operation and further validation should be performed both in the client and/or 294 // server side depending on the workflow that is executed. 295 func Validate(p *Project) error { 296 p.Playwright.Version = config.StandardizeVersionFormat(p.Playwright.Version) 297 if p.Playwright.Version == "" { 298 return errors.New(msg.MissingFrameworkVersionConfig) 299 } 300 301 // Check rootDir exists. 302 if p.RootDir != "" { 303 if _, err := os.Stat(p.RootDir); err != nil { 304 return fmt.Errorf(msg.UnableToLocateRootDir, p.RootDir) 305 } 306 } 307 308 if err := checkSupportedBrowsers(p); err != nil { 309 return err 310 } 311 312 regio := region.FromString(p.Sauce.Region) 313 if regio == region.None { 314 return errors.New(msg.MissingRegion) 315 } 316 317 if ok := config.ValidateVisibility(p.Sauce.Visibility); !ok { 318 return fmt.Errorf(msg.InvalidVisibility, p.Sauce.Visibility, strings.Join(config.ValidVisibilityValues, ",")) 319 } 320 321 err := config.ValidateRegistries(p.Npm.Registries) 322 if err != nil { 323 return err 324 } 325 326 if err := config.ValidateArtifacts(p.Artifacts); err != nil { 327 return err 328 } 329 330 if p.Sauce.LaunchOrder != "" && p.Sauce.LaunchOrder != config.LaunchOrderFailRate { 331 return fmt.Errorf(msg.InvalidLaunchingOption, p.Sauce.LaunchOrder, string(config.LaunchOrderFailRate)) 332 } 333 334 suiteNames := make(map[string]bool) 335 for idx, s := range p.Suites { 336 if len(s.Name) == 0 { 337 return fmt.Errorf(msg.MissingSuiteName, idx) 338 } 339 340 if _, seen := suiteNames[s.Name]; seen { 341 return fmt.Errorf(msg.DuplicateSuiteName, s.Name) 342 } 343 suiteNames[s.Name] = true 344 345 for _, c := range s.Name { 346 if unicode.IsSymbol(c) { 347 return fmt.Errorf(msg.IllegalSymbol, c, s.Name) 348 } 349 } 350 if p.Sauce.Retries < s.PassThreshold-1 { 351 return fmt.Errorf(msg.InvalidPassThreshold) 352 } 353 } 354 355 if p.Sauce.Retries < 0 { 356 log.Warn().Int("retries", p.Sauce.Retries).Msg(msg.InvalidReries) 357 } 358 359 return nil 360 } 361 362 func checkSupportedBrowsers(p *Project) error { 363 for _, suite := range p.Suites { 364 if suite.Params.BrowserName == "" || !isSupportedBrowser(suite.Params.BrowserName) { 365 return fmt.Errorf(msg.UnsupportedBrowser, suite.Params.BrowserName, strings.Join(supportedBrowsers, ", ")) 366 } 367 } 368 369 return nil 370 } 371 372 func isSupportedBrowser(browser string) bool { 373 for _, supportedBr := range supportedBrowsers { 374 if supportedBr == browser { 375 return true 376 } 377 } 378 379 return false 380 } 381 382 // FilterSuites filters out suites in the project that don't match the given suite name. 383 func FilterSuites(p *Project, suiteName string) error { 384 for _, s := range p.Suites { 385 if s.Name == suiteName { 386 p.Suites = []Suite{s} 387 return nil 388 } 389 } 390 return fmt.Errorf(msg.SuiteNameNotFound, suiteName) 391 } 392 393 func IsSharded(suites []Suite) bool { 394 for _, s := range suites { 395 if s.NumShards > 1 || s.Shard != "" { 396 return true 397 } 398 } 399 return false 400 } 401 402 // SortByHistory sorts the suites in the order of job history 403 func SortByHistory(suites []Suite, history insights.JobHistory) []Suite { 404 hash := map[string]Suite{} 405 for _, s := range suites { 406 hash[s.Name] = s 407 } 408 var res []Suite 409 for _, s := range history.TestCases { 410 if v, ok := hash[s.Name]; ok { 411 res = append(res, v) 412 delete(hash, s.Name) 413 } 414 } 415 for _, v := range suites { 416 if _, ok := hash[v.Name]; ok { 417 res = append(res, v) 418 } 419 } 420 return res 421 } 422 423 // FilterFailedTests takes the failed tests in the report and sets them as a test filter in the suite. 424 // The test filter remains unchanged if the report does not contain any failed tests. 425 func (p *Project) FilterFailedTests(suiteName string, report saucereport.SauceReport) error { 426 failedTests := saucereport.GetFailedTests(report) 427 if len(failedTests) == 0 { 428 return nil 429 } 430 431 var found bool 432 for i, s := range p.Suites { 433 if s.Name != suiteName { 434 continue 435 } 436 found = true 437 p.Suites[i].Params.Grep = strings.Join(failedTests, "|") 438 } 439 if !found { 440 return fmt.Errorf("suite(%s) not found", suiteName) 441 } 442 return nil 443 } 444 445 // IsSmartRetried checks if the suites contain a smartRetried suite 446 func (p *Project) IsSmartRetried() bool { 447 for _, s := range p.Suites { 448 if s.SmartRetry.IsRetryFailedOnly() { 449 return true 450 } 451 } 452 return false 453 }