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  }