github.com/fnando/bolt@v0.0.4-0.20231107225351-5241e4d187b8/internal/commands/run.go (about)

     1  package commands
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"errors"
     7  	"flag"
     8  	"fmt"
     9  	"os"
    10  	"os/exec"
    11  	"strings"
    12  	"time"
    13  
    14  	c "github.com/fnando/bolt/common"
    15  	"github.com/fnando/bolt/internal/reporters"
    16  	"github.com/joho/godotenv"
    17  )
    18  
    19  type RunArgs struct {
    20  	Compat            bool
    21  	CoverageCount     int
    22  	CoverageThreshold float64
    23  	Debug             bool
    24  	Dotenv            string
    25  	HideCoverage      bool
    26  	HideSlowest       bool
    27  	HomeDir           string
    28  	NoColor           bool
    29  	Raw               bool
    30  	Replay            string
    31  	Reporter          string
    32  	SlowestCount      int
    33  	SlowestThreshold  string
    34  	WorkingDir        string
    35  	PostRunCommand    string
    36  }
    37  
    38  var usage string = `
    39  Run tests by wrapping "go tests".
    40  
    41    Usage: bolt [options] [packages...] -- [additional "go test" arguments]
    42  
    43    Options:
    44  %s
    45  
    46    Available reporters:
    47      progress
    48        Print a character for each test, with a test summary and list of
    49        failed/skipped tests.
    50  
    51      json
    52        Print a JSON representation of the bolt state.
    53  
    54  
    55    How it works:
    56      This is what bolt runs if you execute "bolt ./...":
    57  
    58      $ go test ./... -cover -json -fullpath
    59  
    60      You can pass additional arguments to the "go test" command like this:
    61  
    62      $ bolt ./... -- -run TestExample
    63  
    64      These arguments will be appended to the default arguments used by bolt.
    65      The example above would be executed like this:
    66  
    67      $ go test -cover -json -fullpath -run TestExample ./...
    68  
    69      To execute a raw "go test" command, use the switch --raw. This will avoid
    70      default arguments from being added to the final execution. In practice, it
    71      means you'll need to run the whole command:
    72  
    73      $ bolt --raw -- ./some_module -run TestExample
    74  
    75      Note: -fullpath was introduced on go 1.21. If you're using an older
    76      version, you can use --compat or manually set arguments by using --raw.
    77  
    78  
    79    Env files:
    80      bolt will load .env.test by default. You can also set it to a
    81      different file by using --env. If you want to disable env files
    82      completely, use --env=false.
    83  
    84  
    85    Color:
    86      bolt will output colored text based on ANSI colors. By default, the
    87      following env vars will be used and you can override any of them to set
    88      a custom color:
    89  
    90      export BOLT_TEXT_COLOR="30"
    91      export BOLT_FAIL_COLOR="31"
    92      export BOLT_PASS_COLOR="32"
    93      export BOLT_SKIP_COLOR="33"
    94      export BOLT_DETAIL_COLOR="34"
    95  
    96      To disable colored output you can use "--no-color" or
    97      set the env var NO_COLOR=1.
    98  
    99  
   100    Progress reporter:
   101      You can override the default progress symbols by setting env vars. The
   102      following example shows how to use emojis instead:
   103  
   104      export BOLT_FAIL_SYMBOL=❌
   105      export BOLT_PASS_SYMBOL=⚡️
   106      export BOLT_SKIP_SYMBOL=😴
   107  
   108  
   109    Post run command:
   110      You can run any commands after the runner is done by using
   111      --post-run-command. The command will receive the following environment
   112      variables.
   113  
   114      BOLT_SUMMARY
   115        a text summarizing the tests
   116      BOLT_TITLE
   117        a text that can be used as the title (e.g. Passed!)
   118      BOLT_TEST_COUNT
   119        a number representing the total number of tests
   120      BOLT_FAIL_COUNT
   121        a number representing the total number of failed tests
   122      BOLT_PASS_COUNT
   123        a number representing the total number of passing tests
   124      BOLT_SKIP_COUNT
   125        a number representing the total number of skipped tests
   126      BOLT_BENCHMARK_COUNT
   127        a number representing the total number of benchmarks
   128      BOLT_ELAPSED
   129        a string representing the duration (e.g. 1m20s)
   130      BOLT_ELAPSED_NANOSECONDS
   131        an integer string representing the duration in nanoseconds
   132  
   133  `
   134  
   135  func Run(args []string, options RunArgs, output *c.Output) int {
   136  	flags := flag.NewFlagSet("bolt", flag.ContinueOnError)
   137  	flags.Usage = func() {}
   138  
   139  	flags.BoolVar(
   140  		&options.NoColor,
   141  		"no-color",
   142  		false,
   143  		"Disable colored output. When unset, respects the NO_COLOR=1 env var",
   144  	)
   145  
   146  	flags.BoolVar(&options.Raw, "raw", false, "Don't append arguments to `go test`")
   147  	flags.BoolVar(&options.Compat, "compat", false, "Don't append -fullpath, available on go 1.21 or new")
   148  	flags.BoolVar(&options.HideCoverage, "hide-coverage", false, "Don't display the coverage section")
   149  	flags.BoolVar(&options.HideSlowest, "hide-slowest", false, "Don't display the slowest tests section")
   150  	flags.StringVar(&options.Dotenv, "env", ".env.test", "Load env file")
   151  	flags.IntVar(&options.CoverageCount, "coverage-count", 10, "Number of coverate items to show")
   152  	flags.Float64Var(&options.CoverageThreshold, "coverage-threshold", 100.0, "Anything below this threshold will be listed")
   153  	flags.StringVar(&options.SlowestThreshold, "slowest-threshold", "1s", "Anything above this threshold will be listed. Must be a valid duration string")
   154  	flags.IntVar(&options.SlowestCount, "slowest-count", 10, "Number of slowest tests to show")
   155  	flags.StringVar(&options.PostRunCommand, "post-run-command", "", "Run a command after runner is done")
   156  
   157  	flags.BoolVar(&options.Debug, "debug", false, "")
   158  	flags.StringVar(&options.Replay, "replay", "", "")
   159  	flags.StringVar(&options.Reporter, "reporter", "progress", "")
   160  
   161  	flags.SetOutput(bufio.NewWriter(&bytes.Buffer{}))
   162  	err := flags.Parse(args)
   163  
   164  	if options.Dotenv != "false" {
   165  		dotenvErr := godotenv.Load(options.Dotenv)
   166  
   167  		if dotenvErr != nil {
   168  			ignore := strings.Contains(dotenvErr.Error(), "no such file") &&
   169  				options.Dotenv == ".env.test"
   170  
   171  			if !ignore {
   172  				err = dotenvErr
   173  			}
   174  		}
   175  	}
   176  
   177  	if options.Debug {
   178  		fmt.Fprintln(output.Stdout, c.Color.Detail("⚡️")+" version:", c.Version)
   179  		fmt.Fprintln(output.Stdout, c.Color.Detail("⚡️")+" arch:", c.Arch)
   180  		fmt.Fprintln(output.Stdout, c.Color.Detail("⚡️")+" commit:", c.Commit)
   181  		fmt.Fprintln(output.Stdout, c.Color.Detail("⚡️")+" working dir:", options.WorkingDir)
   182  		fmt.Fprintln(output.Stdout, c.Color.Detail("⚡️")+" home dir:", options.HomeDir)
   183  		fmt.Fprintln(output.Stdout, c.Color.Detail("⚡️")+" reporter:", options.Reporter)
   184  		fmt.Fprintln(output.Stdout, c.Color.Detail("⚡️")+" env file:", options.Dotenv)
   185  		fmt.Fprintln(output.Stdout, c.Color.Detail("⚡️")+" compat:", options.Compat)
   186  
   187  		if options.Replay != "" {
   188  			fmt.Fprintln(output.Stdout, c.Color.Detail("⚡️")+" replay file:", options.Replay)
   189  		}
   190  	}
   191  
   192  	if err == flag.ErrHelp {
   193  		fmt.Fprintf(output.Stdout, usage, getFlagsUsage(flags))
   194  		return 0
   195  	} else if err != nil {
   196  		fmt.Fprintf(output.Stderr, "%s %v\n", c.Color.Fail("ERROR:"), err)
   197  		return 1
   198  	}
   199  
   200  	slowestThreshold, err := time.ParseDuration(options.SlowestThreshold)
   201  
   202  	if err != nil {
   203  		fmt.Fprintf(output.Stderr, "%s %v\n", c.Color.Fail("ERROR:"), err)
   204  		return 1
   205  	}
   206  
   207  	exitcode := 1
   208  	consumer := c.StreamConsumer{
   209  		Aggregation: &c.Aggregation{
   210  			TestsMap:          map[string]*c.Test{},
   211  			CoverageMap:       map[string]*c.Coverage{},
   212  			BenchmarksMap:     map[string]*c.Benchmark{},
   213  			CoverageThreshold: options.CoverageThreshold,
   214  			CoverageCount:     options.CoverageCount,
   215  			SlowestThreshold:  slowestThreshold,
   216  			SlowestCount:      options.SlowestCount,
   217  		},
   218  	}
   219  
   220  	reporterList := []reporters.Reporter{
   221  		reporters.PostRunCommandReporter{Output: output, Command: options.PostRunCommand},
   222  	}
   223  
   224  	if options.Reporter == "progress" {
   225  		reporterList = append(reporterList, reporters.ProgressReporter{Output: output})
   226  	} else if options.Reporter == "standard" {
   227  		reporterList = append(reporterList, reporters.StandardReporter{Output: output})
   228  	} else if options.Reporter == "json" {
   229  		reporterList = append(reporterList, reporters.JSONReporter{Output: output})
   230  	} else {
   231  		fmt.Fprintf(output.Stderr, "%s %s\n", c.Color.Fail("ERROR:"), "Invalid reporter")
   232  		return 1
   233  	}
   234  
   235  	consumer.OnData = func(line string) {
   236  		for _, reporter := range reporterList {
   237  			reporter.OnData(line)
   238  		}
   239  	}
   240  
   241  	consumer.OnProgress = func(test c.Test) {
   242  		for _, reporter := range reporterList {
   243  			reporter.OnProgress(test)
   244  		}
   245  	}
   246  
   247  	consumer.OnFinished = func(aggregation *c.Aggregation) {
   248  		reporterOptions := reporters.ReporterFinishedOptions{
   249  			Aggregation:  aggregation,
   250  			HideCoverage: options.HideCoverage,
   251  			HideSlowest:  options.HideSlowest,
   252  			Debug:        options.Debug,
   253  		}
   254  
   255  		for _, reporter := range reporterList {
   256  			reporter.OnFinished(reporterOptions)
   257  		}
   258  	}
   259  
   260  	if options.Replay == "" {
   261  		execArgs := append([]string{"-json", "-cover"})
   262  
   263  		if !options.Compat {
   264  			execArgs = append(execArgs, "-fullpath")
   265  		}
   266  
   267  		extraArgs := []string{}
   268  
   269  		for _, arg := range flags.Args() {
   270  			if arg != "--" {
   271  				extraArgs = append(extraArgs, arg)
   272  			}
   273  		}
   274  
   275  		execArgs = append(execArgs, extraArgs...)
   276  
   277  		if options.Raw {
   278  			execArgs = flags.Args()
   279  		}
   280  
   281  		if options.Debug {
   282  			fmt.Fprintln(
   283  				output.Stdout,
   284  				c.Color.Detail("⚡️"),
   285  				"command:",
   286  				"go test",
   287  				strings.Join(execArgs, " "),
   288  			)
   289  		}
   290  
   291  		exitcode, err = Exec(&consumer, output, execArgs)
   292  	} else {
   293  		exitcode, err = Replay(&consumer, &options)
   294  	}
   295  
   296  	if err != nil {
   297  		fmt.Fprintf(output.Stderr, "%s %v\n", c.Color.Fail("ERROR:"), err)
   298  		return 1
   299  	}
   300  
   301  	return exitcode
   302  }
   303  
   304  func Replay(consumer *c.StreamConsumer, options *RunArgs) (int, error) {
   305  	stat, err := os.Stat(options.Replay)
   306  
   307  	if os.IsNotExist(err) {
   308  		return 1, errors.New("replay file doesn't exist")
   309  	}
   310  
   311  	if stat.IsDir() {
   312  		return 1, errors.New("can't read directory (" + options.Replay + ")")
   313  	}
   314  
   315  	file, err := os.Open(options.Replay)
   316  
   317  	if err != nil {
   318  		return 1, err
   319  	}
   320  
   321  	defer file.Close()
   322  	scanner := bufio.NewScanner(file)
   323  	scanner.Split(bufio.ScanLines)
   324  
   325  	consumer.Ingest(scanner)
   326  
   327  	return consumer.Aggregation.CountBy("fail"), nil
   328  }
   329  
   330  func Exec(consumer *c.StreamConsumer, output *c.Output, args []string) (int, error) {
   331  	args = append([]string{"test"}, args...)
   332  	cmd := exec.Command("go", args...)
   333  	cmd.Stderr = cmd.Stdout
   334  	out, _ := cmd.StdoutPipe()
   335  	scanner := bufio.NewScanner(out)
   336  	scanner.Split(bufio.ScanLines)
   337  
   338  	err := cmd.Start()
   339  
   340  	if err != nil {
   341  		return 1, err
   342  	}
   343  
   344  	consumer.Ingest(scanner)
   345  
   346  	err = cmd.Wait()
   347  
   348  	if err != nil {
   349  		return 1, err
   350  	}
   351  
   352  	exitcode := 0
   353  
   354  	if exiterr, ok := err.(*exec.ExitError); ok {
   355  		exitcode = exiterr.ExitCode()
   356  		return exitcode, nil
   357  	}
   358  
   359  	return exitcode, err
   360  }