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  }