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 }