github.com/saucelabs/saucectl@v0.175.1/internal/xcuitest/config.go (about)

     1  package xcuitest
     2  
     3  import (
     4  	"bufio"
     5  	"errors"
     6  	"fmt"
     7  	"os"
     8  	"reflect"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/rs/zerolog/log"
    13  	"github.com/saucelabs/saucectl/internal/apps"
    14  	"github.com/saucelabs/saucectl/internal/concurrency"
    15  	"github.com/saucelabs/saucectl/internal/config"
    16  	"github.com/saucelabs/saucectl/internal/insights"
    17  	"github.com/saucelabs/saucectl/internal/msg"
    18  	"github.com/saucelabs/saucectl/internal/region"
    19  )
    20  
    21  // Config descriptors.
    22  var (
    23  	// Kind represents the type definition of this config.
    24  	Kind = "xcuitest"
    25  
    26  	// APIVersion represents the supported config version.
    27  	APIVersion = "v1alpha"
    28  )
    29  
    30  // Project represents the xcuitest project configuration.
    31  type Project struct {
    32  	config.TypeDef `yaml:",inline" mapstructure:",squash"`
    33  	Defaults       config.Defaults        `yaml:"defaults,omitempty" json:"defaults"`
    34  	ConfigFilePath string                 `yaml:"-" json:"-"`
    35  	ShowConsoleLog bool                   `yaml:"showConsoleLog" json:"-"`
    36  	DryRun         bool                   `yaml:"-" json:"-"`
    37  	CLIFlags       map[string]interface{} `yaml:"-" json:"-"`
    38  	Sauce          config.SauceConfig     `yaml:"sauce,omitempty" json:"sauce"`
    39  	Xcuitest       Xcuitest               `yaml:"xcuitest,omitempty" json:"xcuitest"`
    40  	// Suite is only used as a workaround to parse adhoc suites that are created via CLI args.
    41  	Suite         Suite                `yaml:"suite,omitempty" json:"-"`
    42  	Suites        []Suite              `yaml:"suites,omitempty" json:"suites"`
    43  	Artifacts     config.Artifacts     `yaml:"artifacts,omitempty" json:"artifacts"`
    44  	Reporters     config.Reporters     `yaml:"reporters,omitempty" json:"-"`
    45  	Notifications config.Notifications `yaml:"notifications,omitempty" json:"-"`
    46  	Env           map[string]string    `yaml:"env,omitempty" json:"-"`
    47  	EnvFlag       map[string]string    `yaml:"-" json:"-"`
    48  }
    49  
    50  // Xcuitest represents xcuitest apps configuration.
    51  type Xcuitest struct {
    52  	App                string   `yaml:"app,omitempty" json:"app"`
    53  	AppDescription     string   `yaml:"appDescription,omitempty" json:"appDescription"`
    54  	TestApp            string   `yaml:"testApp,omitempty" json:"testApp"`
    55  	TestAppDescription string   `yaml:"testAppDescription,omitempty" json:"testAppDescription"`
    56  	OtherApps          []string `yaml:"otherApps,omitempty" json:"otherApps"`
    57  }
    58  
    59  // TestOptions represents the xcuitest test filter options configuration.
    60  type TestOptions struct {
    61  	NotClass                          []string `yaml:"notClass,omitempty" json:"notClass"`
    62  	Class                             []string `yaml:"class,omitempty" json:"class"`
    63  	TestLanguage                      string   `yaml:"testLanguage,omitempty" json:"testLanguage"`
    64  	TestRegion                        string   `yaml:"testRegion,omitempty" json:"testRegion"`
    65  	TestTimeoutsEnabled               string   `yaml:"testTimeoutsEnabled,omitempty" json:"testTimeoutsEnabled"`
    66  	MaximumTestExecutionTimeAllowance int      `yaml:"maximumTestExecutionTimeAllowance,omitempty" json:"maximumTestExecutionTimeAllowance"`
    67  	DefaultTestExecutionTimeAllowance int      `yaml:"defaultTestExecutionTimeAllowance,omitempty" json:"defaultTestExecutionTimeAllowance"`
    68  	StatusBarOverrideTime             string   `yaml:"statusBarOverrideTime,omitempty" json:"statusBarOverrideTime"`
    69  }
    70  
    71  // ToMap converts the TestOptions to a map where the keys are derived from json struct tags.
    72  func (t TestOptions) ToMap() map[string]interface{} {
    73  	m := make(map[string]interface{})
    74  	v := reflect.ValueOf(t)
    75  	tt := v.Type()
    76  
    77  	count := v.NumField()
    78  	for i := 0; i < count; i++ {
    79  		if v.Field(i).CanInterface() {
    80  			tag := tt.Field(i).Tag
    81  			tname, ok := tag.Lookup("json")
    82  			if ok && tname != "-" {
    83  				fv := v.Field(i).Interface()
    84  				ft := v.Field(i).Type()
    85  				switch ft.Kind() {
    86  				// Convert int to string to match chef expectation that all test option values are strings
    87  				case reflect.Int:
    88  					// Conventionally, test options with value "" will be ignored.
    89  					if fv.(int) == 0 {
    90  						m[tname] = ""
    91  					} else {
    92  						m[tname] = fmt.Sprintf("%v", fv)
    93  					}
    94  				default:
    95  					m[tname] = fv
    96  				}
    97  			}
    98  		}
    99  	}
   100  	return m
   101  }
   102  
   103  // Suite represents the xcuitest test suite configuration.
   104  type Suite struct {
   105  	Name               string             `yaml:"name,omitempty" json:"name"`
   106  	App                string             `yaml:"app,omitempty" json:"app"`
   107  	AppDescription     string             `yaml:"appDescription,omitempty" json:"appDescription"`
   108  	TestApp            string             `yaml:"testApp,omitempty" json:"testApp"`
   109  	TestAppDescription string             `yaml:"testAppDescription,omitempty" json:"testAppDescription"`
   110  	OtherApps          []string           `yaml:"otherApps,omitempty" json:"otherApps"`
   111  	Timeout            time.Duration      `yaml:"timeout,omitempty" json:"timeout"`
   112  	Devices            []config.Device    `yaml:"devices,omitempty" json:"devices"`
   113  	Simulators         []config.Simulator `yaml:"simulators,omitempty" json:"simulators"`
   114  	TestOptions        TestOptions        `yaml:"testOptions,omitempty" json:"testOptions"`
   115  	AppSettings        config.AppSettings `yaml:"appSettings,omitempty" json:"appSettings"`
   116  	PassThreshold      int                `yaml:"passThreshold,omitempty" json:"-"`
   117  	SmartRetry         config.SmartRetry  `yaml:"smartRetry,omitempty" json:"-"`
   118  	Shard              string             `yaml:"shard,omitempty" json:"-"`
   119  	TestListFile       string             `yaml:"testListFile,omitempty" json:"-"`
   120  	Env                map[string]string  `yaml:"env,omitempty" json:"-"`
   121  }
   122  
   123  // IOS constant
   124  const IOS = "iOS"
   125  
   126  // FromFile creates a new xcuitest Project based on the filepath cfgPath.
   127  func FromFile(cfgPath string) (Project, error) {
   128  	var p Project
   129  
   130  	if err := config.Unmarshal(cfgPath, &p); err != nil {
   131  		return p, err
   132  	}
   133  
   134  	p.ConfigFilePath = cfgPath
   135  
   136  	return p, nil
   137  }
   138  
   139  // SetDefaults applies config defaults in case the user has left them blank.
   140  func SetDefaults(p *Project) {
   141  	if p.Kind == "" {
   142  		p.Kind = Kind
   143  	}
   144  
   145  	if p.APIVersion == "" {
   146  		p.APIVersion = APIVersion
   147  	}
   148  
   149  	if p.Sauce.Concurrency < 1 {
   150  		p.Sauce.Concurrency = 2
   151  	}
   152  
   153  	if p.Defaults.Timeout < 0 {
   154  		p.Defaults.Timeout = 0
   155  	}
   156  
   157  	p.Sauce.Tunnel.SetDefaults()
   158  	p.Sauce.Metadata.SetDefaultBuild()
   159  
   160  	for i := range p.Suites {
   161  		suite := &p.Suites[i]
   162  
   163  		for id := range suite.Devices {
   164  			suite.Devices[id].PlatformName = "iOS"
   165  
   166  			// device type only supports uppercase values
   167  			suite.Devices[id].Options.DeviceType = strings.ToUpper(suite.Devices[id].Options.DeviceType)
   168  		}
   169  		for id := range suite.Simulators {
   170  			suite.Simulators[id].PlatformName = "iOS"
   171  		}
   172  
   173  		if suite.Timeout <= 0 {
   174  			suite.Timeout = p.Defaults.Timeout
   175  		}
   176  
   177  		if suite.TestApp == "" {
   178  			suite.TestApp = p.Xcuitest.TestApp
   179  			suite.TestAppDescription = p.Xcuitest.TestAppDescription
   180  		}
   181  		if suite.App == "" {
   182  			suite.App = p.Xcuitest.App
   183  			suite.AppDescription = p.Xcuitest.AppDescription
   184  		}
   185  		if len(suite.OtherApps) == 0 {
   186  			suite.OtherApps = append(suite.OtherApps, p.Xcuitest.OtherApps...)
   187  		}
   188  		if suite.PassThreshold < 1 {
   189  			suite.PassThreshold = 1
   190  		}
   191  
   192  		// Precedence: --env flag > root-level env vars > suite-level env vars.
   193  		for _, env := range []map[string]string{p.Env, p.EnvFlag} {
   194  			for k, v := range env {
   195  				if suite.Env == nil {
   196  					suite.Env = map[string]string{}
   197  				}
   198  				suite.Env[k] = v
   199  			}
   200  		}
   201  	}
   202  }
   203  
   204  // Validate validates basic configuration of the project and returns an error if any of the settings contain illegal
   205  // values. This is not an exhaustive operation and further validation should be performed both in the client and/or
   206  // server side depending on the workflow that is executed.
   207  func Validate(p Project) error {
   208  	regio := region.FromString(p.Sauce.Region)
   209  	if regio == region.None {
   210  		return errors.New(msg.MissingRegion)
   211  	}
   212  
   213  	if regio == region.USEast4 && p.Sauce.Tunnel.Name != "" {
   214  		return errors.New(msg.NoTunnelSupport)
   215  	}
   216  
   217  	if p.Sauce.LaunchOrder != "" && p.Sauce.LaunchOrder != config.LaunchOrderFailRate {
   218  		return fmt.Errorf(msg.InvalidLaunchingOption, p.Sauce.LaunchOrder, string(config.LaunchOrderFailRate))
   219  	}
   220  
   221  	if len(p.Suites) == 0 {
   222  		return errors.New(msg.EmptySuite)
   223  	}
   224  
   225  	for _, suite := range p.Suites {
   226  		if len(suite.Devices) == 0 && len(suite.Simulators) == 0 {
   227  			return fmt.Errorf(msg.MissingXcuitestDeviceConfig, suite.Name)
   228  		}
   229  		if len(suite.Devices) > 0 && len(suite.Simulators) > 0 {
   230  			return fmt.Errorf("suite cannot have both simulators and devices")
   231  		}
   232  
   233  		validAppExt := []string{".app"}
   234  		if len(suite.Devices) > 0 {
   235  			validAppExt = append(validAppExt, ".ipa")
   236  		} else if len(suite.Simulators) > 0 {
   237  			validAppExt = append(validAppExt, ".zip")
   238  		}
   239  		if suite.App == "" {
   240  			return errors.New(msg.MissingXcuitestAppPath)
   241  		}
   242  		if err := apps.Validate("application", suite.App, validAppExt); err != nil {
   243  			return err
   244  		}
   245  
   246  		if suite.TestApp == "" {
   247  			return errors.New(msg.MissingXcuitestTestAppPath)
   248  		}
   249  		if err := apps.Validate("test application", suite.TestApp, validAppExt); err != nil {
   250  			return err
   251  		}
   252  
   253  		for _, app := range suite.OtherApps {
   254  			if err := apps.Validate("other application", app, validAppExt); err != nil {
   255  				return err
   256  			}
   257  		}
   258  
   259  		for didx, device := range suite.Devices {
   260  			if device.ID == "" && device.Name == "" {
   261  				return fmt.Errorf(msg.MissingDeviceConfig, suite.Name, didx)
   262  			}
   263  
   264  			if device.Options.DeviceType != "" && !config.IsSupportedDeviceType(device.Options.DeviceType) {
   265  				return fmt.Errorf(msg.InvalidDeviceType,
   266  					device.Options.DeviceType, suite.Name, didx, strings.Join(config.SupportedDeviceTypes, ","))
   267  			}
   268  		}
   269  		if p.Sauce.Retries < suite.PassThreshold-1 {
   270  			return fmt.Errorf(msg.InvalidPassThreshold)
   271  		}
   272  		config.ValidateSmartRetry(suite.SmartRetry)
   273  	}
   274  	if p.Sauce.Retries < 0 {
   275  		log.Warn().Int("retries", p.Sauce.Retries).Msg(msg.InvalidReries)
   276  	}
   277  
   278  	return nil
   279  }
   280  
   281  // FilterSuites filters out suites in the project that don't match the given suite name.
   282  func FilterSuites(p *Project, suiteName string) error {
   283  	for _, s := range p.Suites {
   284  		if s.Name == suiteName {
   285  			p.Suites = []Suite{s}
   286  			return nil
   287  		}
   288  	}
   289  	return fmt.Errorf(msg.SuiteNameNotFound, suiteName)
   290  }
   291  
   292  // SortByHistory sorts the suites in the order of job history
   293  func SortByHistory(suites []Suite, history insights.JobHistory) []Suite {
   294  	hash := map[string]Suite{}
   295  	for _, s := range suites {
   296  		hash[s.Name] = s
   297  	}
   298  	var res []Suite
   299  	for _, s := range history.TestCases {
   300  		if v, ok := hash[s.Name]; ok {
   301  			res = append(res, v)
   302  			delete(hash, s.Name)
   303  		}
   304  	}
   305  	for _, v := range suites {
   306  		if _, ok := hash[v.Name]; ok {
   307  			res = append(res, v)
   308  		}
   309  	}
   310  	return res
   311  }
   312  
   313  // ShardSuites applies sharding by provided testListFile.
   314  func ShardSuites(p *Project) error {
   315  	var suites []Suite
   316  	for _, s := range p.Suites {
   317  		if s.Shard != "concurrency" {
   318  			suites = append(suites, s)
   319  			continue
   320  		}
   321  		shardedSuites, err := getShardedSuites(s, p.Sauce.Concurrency)
   322  		if err != nil {
   323  			return fmt.Errorf("failed to get tests from testListFile(%q): %v", s.TestListFile, err)
   324  		}
   325  		suites = append(suites, shardedSuites...)
   326  	}
   327  	p.Suites = suites
   328  
   329  	return nil
   330  }
   331  
   332  func getShardedSuites(suite Suite, ccy int) ([]Suite, error) {
   333  	readFile, err := os.Open(suite.TestListFile)
   334  	if err != nil {
   335  		return nil, err
   336  	}
   337  	defer readFile.Close()
   338  
   339  	fileScanner := bufio.NewScanner(readFile)
   340  	fileScanner.Split(bufio.ScanLines)
   341  	var tests []string
   342  	for fileScanner.Scan() {
   343  		text := strings.TrimSpace(fileScanner.Text())
   344  		if text == "" {
   345  			continue
   346  		}
   347  		tests = append(tests, text)
   348  	}
   349  	if len(tests) == 0 {
   350  		return nil, errors.New("empty file")
   351  	}
   352  
   353  	buckets := concurrency.BinPack(tests, ccy)
   354  	var suites []Suite
   355  	for i, b := range buckets {
   356  		currSuite := suite
   357  		currSuite.Name = fmt.Sprintf("%s - %d/%d", suite.Name, i+1, len(buckets))
   358  		currSuite.TestOptions.Class = b
   359  		suites = append(suites, currSuite)
   360  	}
   361  	return suites, nil
   362  }
   363  
   364  func IsSharded(suites []Suite) bool {
   365  	for _, s := range suites {
   366  		if s.Shard != "" {
   367  			return true
   368  		}
   369  	}
   370  	return false
   371  }
   372  
   373  // IsSmartRetried checks if the suites contain a smartRetried suite
   374  func (p *Project) IsSmartRetried() bool {
   375  	for _, s := range p.Suites {
   376  		if s.SmartRetry.IsRetryFailedOnly() {
   377  			return true
   378  		}
   379  	}
   380  	return false
   381  }