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  }