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  }