github.com/saucelabs/saucectl@v0.175.1/internal/espresso/config.go (about) 1 package espresso 2 3 import ( 4 "errors" 5 "fmt" 6 "strconv" 7 "strings" 8 "time" 9 10 "github.com/rs/zerolog/log" 11 "github.com/saucelabs/saucectl/internal/apps" 12 "github.com/saucelabs/saucectl/internal/config" 13 "github.com/saucelabs/saucectl/internal/insights" 14 "github.com/saucelabs/saucectl/internal/msg" 15 "github.com/saucelabs/saucectl/internal/region" 16 ) 17 18 // Config descriptors. 19 var ( 20 // Kind represents the type definition of this config. 21 Kind = "espresso" 22 23 // APIVersion represents the supported config version. 24 APIVersion = "v1alpha" 25 ) 26 27 // Project represents the espresso project configuration. 28 type Project struct { 29 config.TypeDef `yaml:",inline" mapstructure:",squash"` 30 Defaults config.Defaults `yaml:"defaults" json:"defaults"` 31 ShowConsoleLog bool `yaml:"showConsoleLog" json:"-"` 32 DryRun bool `yaml:"-" json:"-"` 33 ConfigFilePath string `yaml:"-" json:"-"` 34 CLIFlags map[string]interface{} `yaml:"-" json:"-"` 35 Sauce config.SauceConfig `yaml:"sauce,omitempty" json:"sauce"` 36 Espresso Espresso `yaml:"espresso,omitempty" json:"espresso"` 37 // Suite is only used as a workaround to parse adhoc suites that are created via CLI args. 38 Suite Suite `yaml:"suite,omitempty" json:"-"` 39 Suites []Suite `yaml:"suites,omitempty" json:"suites"` 40 Artifacts config.Artifacts `yaml:"artifacts,omitempty" json:"artifacts"` 41 Reporters config.Reporters `yaml:"reporters,omitempty" json:"-"` 42 Notifications config.Notifications `yaml:"notifications,omitempty" json:"-"` 43 } 44 45 // Espresso represents espresso apps configuration. 46 type Espresso struct { 47 App string `yaml:"app,omitempty" json:"app"` 48 AppDescription string `yaml:"appDescription,omitempty" json:"appDescription"` 49 TestApp string `yaml:"testApp,omitempty" json:"testApp"` 50 TestAppDescription string `yaml:"testAppDescription,omitempty" json:"testAppDescription"` 51 OtherApps []string `yaml:"otherApps,omitempty" json:"otherApps"` 52 } 53 54 // Suite represents the espresso test suite configuration. 55 type Suite struct { 56 Name string `yaml:"name,omitempty" json:"name"` 57 TestApp string `yaml:"testApp,omitempty" json:"testApp"` 58 TestAppDescription string `yaml:"testAppDescription,omitempty" json:"testAppDescription"` 59 Devices []config.Device `yaml:"devices,omitempty" json:"devices"` 60 Emulators []config.Emulator `yaml:"emulators,omitempty" json:"emulators"` 61 TestOptions map[string]interface{} `yaml:"testOptions,omitempty" json:"testOptions"` 62 Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout"` 63 AppSettings config.AppSettings `yaml:"appSettings,omitempty" json:"appSettings"` 64 PassThreshold int `yaml:"passThreshold,omitempty" json:"-"` 65 SmartRetry config.SmartRetry `yaml:"smartRetry,omitempty" json:"-"` 66 } 67 68 // Android constant 69 const Android = "Android" 70 71 // FromFile creates a new cypress Project based on the filepath cfgPath. 72 func FromFile(cfgPath string) (Project, error) { 73 var p Project 74 75 if err := config.Unmarshal(cfgPath, &p); err != nil { 76 return p, err 77 } 78 p.ConfigFilePath = cfgPath 79 80 return p, nil 81 } 82 83 // SetDefaults applies config defaults in case the user has left them blank. 84 func SetDefaults(p *Project) { 85 if p.Kind == "" { 86 p.Kind = Kind 87 } 88 89 if p.APIVersion == "" { 90 p.APIVersion = APIVersion 91 } 92 93 if p.Sauce.Concurrency < 1 { 94 p.Sauce.Concurrency = 2 95 } 96 97 if p.Defaults.Timeout < 0 { 98 p.Defaults.Timeout = 0 99 } 100 101 p.Sauce.Tunnel.SetDefaults() 102 p.Sauce.Metadata.SetDefaultBuild() 103 104 for i := range p.Suites { 105 suite := &p.Suites[i] 106 107 for j := range suite.Devices { 108 // Android is the only choice. 109 suite.Devices[j].PlatformName = Android 110 suite.Devices[j].Options.DeviceType = strings.ToUpper(p.Suites[i].Devices[j].Options.DeviceType) 111 } 112 for j := range suite.Emulators { 113 suite.Emulators[j].PlatformName = Android 114 } 115 116 if suite.Timeout <= 0 { 117 suite.Timeout = p.Defaults.Timeout 118 } 119 120 if suite.TestApp == "" { 121 suite.TestApp = p.Espresso.TestApp 122 suite.TestAppDescription = p.Espresso.TestAppDescription 123 } 124 if suite.PassThreshold < 1 { 125 suite.PassThreshold = 1 126 } 127 } 128 } 129 130 // Validate validates basic configuration of the project and returns an error if any of the settings contain illegal 131 // values. This is not an exhaustive operation and further validation should be performed both in the client and/or 132 // server side depending on the workflow that is executed. 133 func Validate(p Project) error { 134 regio := region.FromString(p.Sauce.Region) 135 if regio == region.None { 136 return errors.New(msg.MissingRegion) 137 } 138 139 if regio == region.USEast4 && p.Sauce.Tunnel.Name != "" { 140 return errors.New(msg.NoTunnelSupport) 141 } 142 143 if ok := config.ValidateVisibility(p.Sauce.Visibility); !ok { 144 return fmt.Errorf(msg.InvalidVisibility, p.Sauce.Visibility, strings.Join(config.ValidVisibilityValues, ",")) 145 } 146 147 if p.Espresso.App == "" { 148 return errors.New(msg.MissingAppPath) 149 } 150 if err := apps.Validate("application", p.Espresso.App, []string{".apk", ".aab"}); err != nil { 151 return err 152 } 153 154 if p.Espresso.TestApp == "" { 155 return errors.New(msg.MissingTestAppPath) 156 } 157 if err := apps.Validate("test application", p.Espresso.TestApp, []string{".apk", ".aab"}); err != nil { 158 return err 159 } 160 161 for _, app := range p.Espresso.OtherApps { 162 if err := apps.Validate("other application", app, []string{".apk", ".aab"}); err != nil { 163 return err 164 } 165 } 166 167 if p.Sauce.LaunchOrder != "" && p.Sauce.LaunchOrder != config.LaunchOrderFailRate { 168 return fmt.Errorf(msg.InvalidLaunchingOption, p.Sauce.LaunchOrder, string(config.LaunchOrderFailRate)) 169 } 170 171 if len(p.Suites) == 0 { 172 return errors.New(msg.EmptySuite) 173 } 174 175 for _, suite := range p.Suites { 176 if len(suite.Devices) == 0 && len(suite.Emulators) == 0 { 177 return fmt.Errorf(msg.MissingDevicesOrEmulatorConfig, suite.Name) 178 } 179 if err := validateDevices(suite.Name, suite.Devices); err != nil { 180 return err 181 } 182 if err := validateEmulators(suite.Name, suite.Emulators); err != nil { 183 return err 184 } 185 if regio == region.USEast4 && len(suite.Emulators) > 0 { 186 return errors.New(msg.NoEmulatorSupport) 187 } 188 if p.Sauce.Retries < suite.PassThreshold-1 { 189 return fmt.Errorf(msg.InvalidPassThreshold) 190 } 191 config.ValidateSmartRetry(suite.SmartRetry) 192 } 193 if p.Sauce.Retries < 0 { 194 log.Warn().Int("retries", p.Sauce.Retries).Msg(msg.InvalidReries) 195 } 196 197 return nil 198 } 199 200 func validateDevices(suiteName string, devices []config.Device) error { 201 for didx, device := range devices { 202 if device.Name == "" && device.ID == "" { 203 return fmt.Errorf(msg.MissingDeviceConfig, suiteName, didx) 204 } 205 if device.Options.DeviceType != "" && !config.IsSupportedDeviceType(device.Options.DeviceType) { 206 return fmt.Errorf(msg.InvalidDeviceType, 207 device.Options.DeviceType, suiteName, didx, strings.Join(config.SupportedDeviceTypes, ",")) 208 } 209 } 210 return nil 211 } 212 213 func validateEmulators(suiteName string, emulators []config.Emulator) error { 214 for eidx, emulator := range emulators { 215 if emulator.Name == "" { 216 return fmt.Errorf(msg.MissingEmulatorName, suiteName, eidx) 217 } 218 if !strings.Contains(strings.ToLower(emulator.Name), "emulator") { 219 return fmt.Errorf(msg.InvalidEmulatorName, emulator.Name, suiteName, eidx) 220 } 221 if len(emulator.PlatformVersions) == 0 { 222 return fmt.Errorf(msg.MissingEmulatorPlatformVersion, emulator.Name, suiteName, eidx) 223 } 224 } 225 return nil 226 } 227 228 // FilterSuites filters out suites in the project that don't match the given suite name. 229 func FilterSuites(p *Project, suiteName string) error { 230 for _, s := range p.Suites { 231 if s.Name == suiteName { 232 p.Suites = []Suite{s} 233 return nil 234 } 235 } 236 return fmt.Errorf(msg.SuiteNameNotFound, suiteName) 237 } 238 239 func IsSharded(suites []Suite) bool { 240 for _, suite := range suites { 241 if v, ok := suite.TestOptions["numShards"]; ok { 242 val, err := strconv.Atoi(fmt.Sprintf("%v", v)) 243 return err == nil && val > 0 244 } 245 } 246 return false 247 } 248 249 // SortByHistory sorts the suites in the order of job history 250 func SortByHistory(suites []Suite, history insights.JobHistory) []Suite { 251 hash := map[string]Suite{} 252 for _, s := range suites { 253 hash[s.Name] = s 254 } 255 var res []Suite 256 for _, s := range history.TestCases { 257 if v, ok := hash[s.Name]; ok { 258 res = append(res, v) 259 delete(hash, s.Name) 260 } 261 } 262 for _, v := range suites { 263 if _, ok := hash[v.Name]; ok { 264 res = append(res, v) 265 } 266 } 267 return res 268 } 269 270 // IsSmartRetried checks if the suites contain a smartRetried suite 271 func (p *Project) IsSmartRetried() bool { 272 for _, s := range p.Suites { 273 if s.SmartRetry.IsRetryFailedOnly() { 274 return true 275 } 276 } 277 return false 278 }