github.com/rainforestapp/rainforest-cli@v2.12.0+incompatible/runner.go (about) 1 package main 2 3 import ( 4 "errors" 5 "fmt" 6 "log" 7 "net/url" 8 "path/filepath" 9 "strconv" 10 "strings" 11 "time" 12 13 "github.com/rainforestapp/rainforest-cli/rainforest" 14 "github.com/urfave/cli" 15 ) 16 17 type runnerAPI interface { 18 CreateRun(params rainforest.RunParams) (*rainforest.RunStatus, error) 19 CreateTemporaryEnvironment(string) (*rainforest.Environment, error) 20 CheckRunStatus(int) (*rainforest.RunStatus, error) 21 rfmlAPI 22 } 23 24 type runner struct { 25 client runnerAPI 26 } 27 28 func startRun(c cliContext) error { 29 r := newRunner() 30 return r.startRun(c) 31 } 32 33 func newRunner() *runner { 34 return &runner{client: api} 35 } 36 37 // startRun starts a new Rainforest run & depending on passed flags monitors its execution 38 func (r *runner) startRun(c cliContext) error { 39 // First check if we even want to crate new run or just monitor the existing one. 40 if runIDStr := c.String("reattach"); runIDStr != "" { 41 runID, err := strconv.Atoi(runIDStr) 42 if err != nil { 43 return cli.NewExitError(err.Error(), 1) 44 } 45 return monitorRunStatus(c, runID) 46 } 47 48 var localTests []*rainforest.RFTest 49 var err error 50 if c.Bool("f") { 51 localTests, err = r.prepareLocalRun(c) 52 if err != nil { 53 return cli.NewExitError(err.Error(), 1) 54 } 55 } 56 57 params, err := r.makeRunParams(c, localTests) 58 if err != nil { 59 return cli.NewExitError(err.Error(), 1) 60 } 61 62 if c.Bool("git-trigger") { 63 var git gitTrigger 64 git, err = newGitTrigger() 65 if err != nil { 66 return cli.NewExitError(err.Error(), 1) 67 } 68 if !git.checkTrigger() { 69 log.Printf("Git trigger enabled, but %v was not found in latest commit. Exiting...", git.Trigger) 70 return nil 71 } 72 if tags := git.getTags(); len(tags) > 0 { 73 if len(params.Tags) == 0 { 74 log.Print("Found tag list in the commit message, overwriting argument.") 75 } else { 76 log.Print("Found tag list in the commit message.") 77 } 78 params.Tags = tags 79 } 80 } 81 82 err = preRunCSVUpload(c, api) 83 if err != nil { 84 return cli.NewExitError(err.Error(), 1) 85 } 86 87 runStatus, err := r.client.CreateRun(params) 88 if err != nil { 89 return cli.NewExitError(err.Error(), 1) 90 } 91 log.Printf("Run %v has been created.", runStatus.ID) 92 93 // if background flag is enabled we'll skip monitoring run status 94 if c.Bool("bg") { 95 return nil 96 } 97 98 return monitorRunStatus(c, runStatus.ID) 99 } 100 101 func (r *runner) prepareLocalRun(c cliContext) ([]*rainforest.RFTest, error) { 102 invalidFilters := []string{"folder", "feature", "run-group", "site"} 103 for _, filter := range invalidFilters { 104 if c.Int(filter) != 0 || (c.String(filter) != "" && c.String(filter) != "0") { 105 return nil, fmt.Errorf("%s cannot be specified with run -f", filter) 106 } 107 } 108 tags := getTags(c) 109 files := c.Args() 110 tests, err := readRFMLFiles(files) 111 if err != nil { 112 return nil, err 113 } 114 115 uploads, err := filterUploadTests(tests, tags) 116 if err != nil { 117 return nil, err 118 } 119 err = uploadRFMLFiles(uploads, true, r.client) 120 if err != nil { 121 return nil, err 122 } 123 124 forceExecute := map[string]bool{} 125 for _, path := range c.StringSlice("force-execute") { 126 abs, err := filepath.Abs(path) 127 if err != nil { 128 log.Printf("%v is not a valid path", path) 129 continue 130 } 131 forceExecute[abs] = true 132 } 133 134 forceSkip := map[string]bool{} 135 for _, path := range c.StringSlice("exclude") { 136 abs, err := filepath.Abs(path) 137 if err != nil { 138 log.Printf("%v is not a valid path", path) 139 continue 140 } 141 forceSkip[abs] = true 142 } 143 return filterExecuteTests(tests, tags, forceExecute, forceSkip), nil 144 } 145 146 // filterUploadTests pre-filters tests for upload. The rule is: upload anything 147 // with the tag *plus* anything that is depended on by a tagged test. 148 func filterUploadTests(tests []*rainforest.RFTest, tags []string) ([]*rainforest.RFTest, error) { 149 testsByID := map[string]*rainforest.RFTest{} 150 for _, test := range tests { 151 testsByID[test.RFMLID] = test 152 } 153 154 // DFS for filtered tests + embeds 155 includedTests := make(map[*rainforest.RFTest]bool) 156 var q []*rainforest.RFTest 157 158 // Start with tag-filtered tests 159 for _, test := range tests { 160 if tags == nil || anyMember(tags, test.Tags) { 161 q = append(q, test) 162 } 163 } 164 for len(q) > 0 { 165 t := q[len(q)-1] 166 q = q[:len(q)-1] 167 includedTests[t] = true 168 169 for _, step := range t.Steps { 170 if embed, ok := step.(rainforest.RFEmbeddedTest); ok { 171 embeddedTest, ok := testsByID[embed.RFMLID] 172 if !ok { 173 return nil, fmt.Errorf("Could not find embedded test %v", embed.RFMLID) 174 } 175 if _, ok := includedTests[embeddedTest]; !ok { 176 q = append(q, embeddedTest) 177 } 178 } 179 } 180 } 181 182 result := make([]*rainforest.RFTest, 0, len(includedTests)) 183 for t := range includedTests { 184 result = append(result, t) 185 } 186 187 return result, nil 188 } 189 190 // filterExecuteTests filters for tests that should execute. The rules are: it 191 // should execute if it's tagged properly *and* has Execute set to true (or is 192 // in forceExecute) *and* isn't in forceSkip. 193 func filterExecuteTests(tests []*rainforest.RFTest, tags []string, forceExecute, forceSkip map[string]bool) []*rainforest.RFTest { 194 var result []*rainforest.RFTest 195 for _, test := range tests { 196 path, err := filepath.Abs(test.RFMLPath) 197 if err != nil { 198 path = "" 199 } 200 if !forceSkip[path] && 201 (test.Execute || forceExecute[path]) && 202 (tags == nil || anyMember(tags, test.Tags)) { 203 204 result = append(result, test) 205 } 206 } 207 208 return result 209 } 210 211 func monitorRunStatus(c cliContext, runID int) error { 212 failed_attempts := 1 213 214 for { 215 status, msg, done, err := getRunStatus(c.Bool("fail-fast"), runID, api) 216 log.Print(msg) 217 218 if done { 219 if status.FrontendURL != "" { 220 log.Printf("The detailed results are available at %v\n", status.FrontendURL) 221 } 222 223 postRunJUnitReport(c, runID) 224 225 if status.Result != "passed" { 226 return cli.NewExitError("", 1) 227 } 228 229 return nil 230 } 231 232 // If we've had too many errors, give up 233 if failed_attempts >= 5 { 234 msg := fmt.Sprintf("Can not get run status after %d attempts, giving up", failed_attempts) 235 return cli.NewExitError(msg, 1) 236 } 237 238 // If we hit an error, record it 239 if err != nil { 240 failed_attempts++ 241 } else { 242 // Reset attempts 243 failed_attempts = 1 244 } 245 246 time.Sleep(runStatusPollInterval) 247 } 248 } 249 250 func getRunStatus(failFast bool, runID int, client runnerAPI) (*rainforest.RunStatus, string, bool, error) { 251 newStatus, err := client.CheckRunStatus(runID) 252 if err != nil { 253 msg := fmt.Sprintf("API error: %v\n", err) 254 return newStatus, msg, false, err 255 } 256 257 if newStatus.StateDetails.IsFinalState { 258 msg := fmt.Sprintf("Run %v is now %v and has %v\n", runID, newStatus.State, newStatus.Result) 259 return newStatus, msg, true, nil 260 } 261 262 msg := fmt.Sprintf("Run %v is %v and is %v%% complete\n", runID, newStatus.State, newStatus.CurrentProgress.Percent) 263 if newStatus.Result == "failed" && failFast { 264 return newStatus, msg, true, nil 265 } 266 return newStatus, msg, false, nil 267 } 268 269 // makeRunParams parses and validates command line arguments + options 270 // and makes RunParams struct out of them 271 func (r *runner) makeRunParams(c cliContext, localTests []*rainforest.RFTest) (rainforest.RunParams, error) { 272 var err error 273 localOnly := localTests != nil 274 275 var smartFolderID int 276 if s := c.String("folder"); !localOnly && s != "" { 277 smartFolderID, err = strconv.Atoi(c.String("folder")) 278 if err != nil { 279 return rainforest.RunParams{}, err 280 } 281 } 282 283 var siteID int 284 if s := c.String("site"); s != "" { 285 siteID, err = strconv.Atoi(c.String("site")) 286 if err != nil { 287 return rainforest.RunParams{}, err 288 } 289 } 290 291 var crowd string 292 if crowd = c.String("crowd"); crowd != "" && crowd != "default" && crowd != "on_premise_crowd" { 293 return rainforest.RunParams{}, errors.New("Invalid crowd option specified") 294 } 295 296 var conflict string 297 if conflict = c.String("conflict"); conflict != "" && conflict != "abort" && conflict != "abort-all" { 298 return rainforest.RunParams{}, errors.New("Invalid conflict option specified") 299 } 300 301 featureID := c.Int("feature") 302 runGroupID := c.Int("run-group") 303 304 browsers := c.StringSlice("browser") 305 expandedBrowsers := expandStringSlice(browsers) 306 307 description := c.String("description") 308 release := c.String("release") 309 310 var environmentID int 311 if s := c.String("custom-url"); s != "" { 312 var customURL *url.URL 313 customURL, err = url.Parse(s) 314 if err != nil { 315 return rainforest.RunParams{}, err 316 } 317 318 if (customURL.Scheme != "http") && (customURL.Scheme != "https") { 319 return rainforest.RunParams{}, errors.New("custom URL scheme must be http or https") 320 } 321 322 var environment *rainforest.Environment 323 environment, err = r.client.CreateTemporaryEnvironment(customURL.String()) 324 if err != nil { 325 return rainforest.RunParams{}, err 326 } 327 328 log.Printf("Created temporary environment with name %v", environment.Name) 329 environmentID = environment.ID 330 } else if s := c.String("environment-id"); s != "" { 331 environmentID, err = strconv.Atoi(c.String("environment-id")) 332 if err != nil { 333 return rainforest.RunParams{}, err 334 } 335 } 336 337 // Figure out test/RFML IDs 338 var testIDs interface{} 339 var rfmlIDs []string 340 testIDsArgs := c.Args() 341 342 if localOnly { 343 for _, t := range localTests { 344 rfmlIDs = append(rfmlIDs, t.RFMLID) 345 } 346 } else if testIDsArgs.First() != "all" && testIDsArgs.First() != "" { 347 testIDs = []int{} 348 for _, arg := range testIDsArgs { 349 nextTestIDs, err := stringToIntSlice(arg) 350 if err != nil { 351 return rainforest.RunParams{}, err 352 } 353 testIDs = append(testIDs.([]int), nextTestIDs...) 354 } 355 } else if testIDsArgs.First() == "all" { 356 testIDs = "all" 357 } 358 359 tags := getTags(c) 360 361 return rainforest.RunParams{ 362 Tests: testIDs, 363 RFMLIDs: rfmlIDs, 364 Tags: tags, 365 SmartFolderID: smartFolderID, 366 SiteID: siteID, 367 Crowd: crowd, 368 Conflict: conflict, 369 Browsers: expandedBrowsers, 370 Description: description, 371 Release: release, 372 EnvironmentID: environmentID, 373 FeatureID: featureID, 374 RunGroupID: runGroupID, 375 }, nil 376 } 377 378 // stringToIntSlice takes a string of comma separated integers and returns a slice of them 379 func stringToIntSlice(s string) ([]int, error) { 380 if s == "" { 381 return nil, nil 382 } 383 splitString := strings.Split(s, ",") 384 var slicedInt []int 385 for _, slice := range splitString { 386 newInt, err := strconv.Atoi(strings.TrimSpace(slice)) 387 if err != nil { 388 return slicedInt, err 389 } 390 slicedInt = append(slicedInt, newInt) 391 } 392 return slicedInt, nil 393 } 394 395 // getTags get tags from a CLI context. It supports expanding comma-separated 396 // sublists. 397 func getTags(c cliContext) []string { 398 tags := c.StringSlice("tag") 399 return expandStringSlice(tags) 400 } 401 402 // expandStringSlice takes a slice of strings and expands any comma separated sublists 403 // into one slice. This allows us to accept args like: -tag abc -tag qwe,xyz 404 func expandStringSlice(slice []string) []string { 405 var result []string 406 for _, element := range slice { 407 splitElement := strings.Split(element, ",") 408 for _, singleElement := range splitElement { 409 result = append(result, strings.TrimSpace(singleElement)) 410 } 411 } 412 return result 413 }