gotest.tools/gotestsum@v1.11.0/cmd/main.go (about)

     1  package cmd
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"os/exec"
    10  	"os/signal"
    11  	"strings"
    12  	"sync/atomic"
    13  	"syscall"
    14  
    15  	"github.com/dnephin/pflag"
    16  	"github.com/fatih/color"
    17  	"gotest.tools/gotestsum/internal/log"
    18  	"gotest.tools/gotestsum/testjson"
    19  )
    20  
    21  var version = "dev"
    22  
    23  func Run(name string, args []string) error {
    24  	flags, opts := setupFlags(name)
    25  	switch err := flags.Parse(args); {
    26  	case err == pflag.ErrHelp:
    27  		return nil
    28  	case err != nil:
    29  		usage(os.Stderr, name, flags)
    30  		return err
    31  	}
    32  	opts.args = flags.Args()
    33  	setupLogging(opts)
    34  
    35  	switch {
    36  	case opts.version:
    37  		fmt.Fprintf(os.Stdout, "gotestsum version %s\n", version)
    38  		return nil
    39  	case opts.watch:
    40  		return runWatcher(opts)
    41  	}
    42  	return run(opts)
    43  }
    44  
    45  func setupFlags(name string) (*pflag.FlagSet, *options) {
    46  	opts := &options{
    47  		hideSummary:                  newHideSummaryValue(),
    48  		junitTestCaseClassnameFormat: &junitFieldFormatValue{},
    49  		junitTestSuiteNameFormat:     &junitFieldFormatValue{},
    50  		postRunHookCmd:               &commandValue{},
    51  		stdout:                       color.Output,
    52  		stderr:                       color.Error,
    53  	}
    54  	flags := pflag.NewFlagSet(name, pflag.ContinueOnError)
    55  	flags.SetInterspersed(false)
    56  	flags.Usage = func() {
    57  		usage(os.Stdout, name, flags)
    58  	}
    59  
    60  	flags.StringVarP(&opts.format, "format", "f",
    61  		lookEnvWithDefault("GOTESTSUM_FORMAT", "pkgname"),
    62  		"print format of test input")
    63  	flags.BoolVar(&opts.formatOptions.HideEmptyPackages, "format-hide-empty-pkg",
    64  		false, "do not print empty packages in compact formats")
    65  	flags.BoolVar(&opts.formatOptions.UseHiVisibilityIcons, "format-hivis",
    66  		false, "use high visibility characters in some formats")
    67  	flags.BoolVar(&opts.rawCommand, "raw-command", false,
    68  		"don't prepend 'go test -json' to the 'go test' command")
    69  	flags.BoolVar(&opts.ignoreNonJSONOutputLines, "ignore-non-json-output-lines", false,
    70  		"write non-JSON 'go test' output lines to stderr instead of failing")
    71  	flags.Lookup("ignore-non-json-output-lines").Hidden = true
    72  	flags.StringVar(&opts.jsonFile, "jsonfile",
    73  		lookEnvWithDefault("GOTESTSUM_JSONFILE", ""),
    74  		"write all TestEvents to file")
    75  	flags.StringVar(&opts.jsonFileTimingEvents, "jsonfile-timing-events",
    76  		lookEnvWithDefault("GOTESTSUM_JSONFILE_TIMING_EVENTS", ""),
    77  		"write only the pass, skip, and fail TestEvents to the file")
    78  	flags.BoolVar(&opts.noColor, "no-color", defaultNoColor(), "disable color output")
    79  
    80  	flags.Var(opts.hideSummary, "no-summary",
    81  		"do not print summary of: "+testjson.SummarizeAll.String())
    82  	flags.Lookup("no-summary").Hidden = true
    83  	flags.Var(opts.hideSummary, "hide-summary",
    84  		"hide sections of the summary: "+testjson.SummarizeAll.String())
    85  	flags.Var(opts.postRunHookCmd, "post-run-command",
    86  		"command to run after the tests have completed")
    87  	flags.BoolVar(&opts.watch, "watch", false,
    88  		"watch go files, and run tests when a file is modified")
    89  	flags.BoolVar(&opts.watchChdir, "watch-chdir", false,
    90  		"in watch mode change the working directory to the directory with the modified file before running tests")
    91  	flags.IntVar(&opts.maxFails, "max-fails", 0,
    92  		"end the test run after this number of failures")
    93  
    94  	flags.StringVar(&opts.junitFile, "junitfile",
    95  		lookEnvWithDefault("GOTESTSUM_JUNITFILE", ""),
    96  		"write a JUnit XML file")
    97  	flags.Var(opts.junitTestSuiteNameFormat, "junitfile-testsuite-name",
    98  		"format the testsuite name field as: "+junitFieldFormatValues)
    99  	flags.Var(opts.junitTestCaseClassnameFormat, "junitfile-testcase-classname",
   100  		"format the testcase classname field as: "+junitFieldFormatValues)
   101  	flags.StringVar(&opts.junitProjectName, "junitfile-project-name",
   102  		lookEnvWithDefault("GOTESTSUM_JUNITFILE_PROJECT_NAME", ""),
   103  		"name of the project used in the junit.xml file")
   104  	flags.BoolVar(&opts.junitHideEmptyPackages, "junitfile-hide-empty-pkg",
   105  		truthyFlag(lookEnvWithDefault("GOTESTSUM_JUNIT_HIDE_EMPTY_PKG", "")),
   106  		"omit packages with no tests from the junit.xml file")
   107  
   108  	flags.IntVar(&opts.rerunFailsMaxAttempts, "rerun-fails", 0,
   109  		"rerun failed tests until they all pass, or attempts exceeds maximum. Defaults to max 2 reruns when enabled")
   110  	flags.Lookup("rerun-fails").NoOptDefVal = "2"
   111  	flags.IntVar(&opts.rerunFailsMaxInitialFailures, "rerun-fails-max-failures", 10,
   112  		"do not rerun any tests if the initial run has more than this number of failures")
   113  	flags.Var((*stringSlice)(&opts.packages), "packages",
   114  		"space separated list of package to test")
   115  	flags.StringVar(&opts.rerunFailsReportFile, "rerun-fails-report", "",
   116  		"write a report to the file, of the tests that were rerun")
   117  	flags.BoolVar(&opts.rerunFailsRunRootCases, "rerun-fails-run-root-test", false,
   118  		"rerun the entire root testcase when any of its subtests fail, instead of only the failed subtest")
   119  
   120  	flags.BoolVar(&opts.debug, "debug", false, "enabled debug logging")
   121  	flags.BoolVar(&opts.version, "version", false, "show version and exit")
   122  	return flags, opts
   123  }
   124  
   125  func usage(out io.Writer, name string, flags *pflag.FlagSet) {
   126  	fmt.Fprintf(out, `Usage:
   127      %[1]s [flags] [--] [go test flags]
   128      %[1]s [command]
   129  
   130  See https://pkg.go.dev/gotest.tools/gotestsum#section-readme for detailed documentation.
   131  
   132  Flags:
   133  `, name)
   134  	flags.SetOutput(out)
   135  	flags.PrintDefaults()
   136  	fmt.Fprintf(out, `
   137  Formats:
   138      dots                     print a character for each test
   139      dots-v2                  experimental dots format, one package per line
   140      pkgname                  print a line for each package
   141      pkgname-and-test-fails   print a line for each package and failed test output
   142      testname                 print a line for each test and package
   143      testdox                  print a sentence for each test using gotestdox
   144      github-actions           testname format with github actions log grouping
   145      standard-quiet           standard go test format
   146      standard-verbose         standard go test -v format
   147  
   148  Commands:
   149      %[1]s tool slowest   find or skip the slowest tests
   150      %[1]s help           print this help next
   151  `, name)
   152  }
   153  
   154  func lookEnvWithDefault(key, defValue string) string {
   155  	if value := os.Getenv(key); value != "" {
   156  		return value
   157  	}
   158  	return defValue
   159  }
   160  
   161  type options struct {
   162  	args                         []string
   163  	format                       string
   164  	formatOptions                testjson.FormatOptions
   165  	debug                        bool
   166  	rawCommand                   bool
   167  	ignoreNonJSONOutputLines     bool
   168  	jsonFile                     string
   169  	jsonFileTimingEvents         string
   170  	junitFile                    string
   171  	postRunHookCmd               *commandValue
   172  	noColor                      bool
   173  	hideSummary                  *hideSummaryValue
   174  	junitTestSuiteNameFormat     *junitFieldFormatValue
   175  	junitTestCaseClassnameFormat *junitFieldFormatValue
   176  	junitProjectName             string
   177  	junitHideEmptyPackages       bool
   178  	rerunFailsMaxAttempts        int
   179  	rerunFailsMaxInitialFailures int
   180  	rerunFailsReportFile         string
   181  	rerunFailsRunRootCases       bool
   182  	packages                     []string
   183  	watch                        bool
   184  	watchChdir                   bool
   185  	maxFails                     int
   186  	version                      bool
   187  
   188  	// shims for testing
   189  	stdout io.Writer
   190  	stderr io.Writer
   191  }
   192  
   193  func (o options) Validate() error {
   194  	if o.rerunFailsMaxAttempts > 0 && len(o.args) > 0 && !o.rawCommand && len(o.packages) == 0 {
   195  		return fmt.Errorf(
   196  			"when go test args are used with --rerun-fails " +
   197  				"the list of packages to test must be specified by the --packages flag")
   198  	}
   199  	if o.rerunFailsMaxAttempts > 0 && boolArgIndex("failfast", o.args) > -1 {
   200  		return fmt.Errorf("-failfast can not be used with --rerun-fails " +
   201  			"because not all test cases will run")
   202  	}
   203  	return nil
   204  }
   205  
   206  func defaultNoColor() bool {
   207  	// fatih/color will only output color when stdout is a terminal which is not
   208  	// true for many CI environments which support color output. So instead, we
   209  	// try to detect these CI environments via their environment variables.
   210  	// This code is based on https://github.com/jwalton/go-supportscolor
   211  	if _, exists := os.LookupEnv("CI"); exists {
   212  		var ciEnvNames = []string{
   213  			"APPVEYOR",
   214  			"BUILDKITE",
   215  			"CIRCLECI",
   216  			"DRONE",
   217  			"GITEA_ACTIONS",
   218  			"GITHUB_ACTIONS",
   219  			"GITLAB_CI",
   220  			"TRAVIS",
   221  		}
   222  		for _, ciEnvName := range ciEnvNames {
   223  			if _, exists := os.LookupEnv(ciEnvName); exists {
   224  				return false
   225  			}
   226  		}
   227  		if os.Getenv("CI_NAME") == "codeship" {
   228  			return false
   229  		}
   230  	}
   231  	if _, exists := os.LookupEnv("TEAMCITY_VERSION"); exists {
   232  		return false
   233  	}
   234  	return color.NoColor
   235  }
   236  
   237  func setupLogging(opts *options) {
   238  	if opts.debug {
   239  		log.SetLevel(log.DebugLevel)
   240  	}
   241  	color.NoColor = opts.noColor
   242  }
   243  
   244  func run(opts *options) error {
   245  	ctx, cancel := context.WithCancel(context.Background())
   246  	defer cancel()
   247  
   248  	if err := opts.Validate(); err != nil {
   249  		return err
   250  	}
   251  
   252  	goTestProc, err := startGoTestFn(ctx, "", goTestCmdArgs(opts, rerunOpts{}))
   253  	if err != nil {
   254  		return err
   255  	}
   256  
   257  	handler, err := newEventHandler(opts)
   258  	if err != nil {
   259  		return err
   260  	}
   261  	defer handler.Close() // nolint: errcheck
   262  	cfg := testjson.ScanConfig{
   263  		Stdout:                   goTestProc.stdout,
   264  		Stderr:                   goTestProc.stderr,
   265  		Handler:                  handler,
   266  		Stop:                     cancel,
   267  		IgnoreNonJSONOutputLines: opts.ignoreNonJSONOutputLines,
   268  	}
   269  	exec, err := testjson.ScanTestOutput(cfg)
   270  	handler.Flush()
   271  	if err != nil {
   272  		return finishRun(opts, exec, err)
   273  	}
   274  
   275  	exitErr := goTestProc.cmd.Wait()
   276  	if signum := atomic.LoadInt32(&goTestProc.signal); signum != 0 {
   277  		return finishRun(opts, exec, exitError{num: signalExitCode + int(signum)})
   278  	}
   279  	if exitErr == nil || opts.rerunFailsMaxAttempts == 0 {
   280  		return finishRun(opts, exec, exitErr)
   281  	}
   282  	if err := hasErrors(exitErr, exec); err != nil {
   283  		return finishRun(opts, exec, err)
   284  	}
   285  
   286  	failed := len(rerunFailsFilter(opts)(exec.Failed()))
   287  	if failed > opts.rerunFailsMaxInitialFailures {
   288  		err := fmt.Errorf(
   289  			"number of test failures (%d) exceeds maximum (%d) set by --rerun-fails-max-failures",
   290  			failed, opts.rerunFailsMaxInitialFailures)
   291  		return finishRun(opts, exec, err)
   292  	}
   293  
   294  	cfg = testjson.ScanConfig{Execution: exec, Handler: handler}
   295  	exitErr = rerunFailed(ctx, opts, cfg)
   296  	handler.Flush()
   297  	if err := writeRerunFailsReport(opts, exec); err != nil {
   298  		return err
   299  	}
   300  	return finishRun(opts, exec, exitErr)
   301  }
   302  
   303  func finishRun(opts *options, exec *testjson.Execution, exitErr error) error {
   304  	testjson.PrintSummary(opts.stdout, exec, opts.hideSummary.value)
   305  
   306  	if err := writeJUnitFile(opts, exec); err != nil {
   307  		return fmt.Errorf("failed to write junit file: %w", err)
   308  	}
   309  	if err := postRunHook(opts, exec); err != nil {
   310  		return fmt.Errorf("post run command failed: %w", err)
   311  	}
   312  	return exitErr
   313  }
   314  
   315  func goTestCmdArgs(opts *options, rerunOpts rerunOpts) []string {
   316  	if opts.rawCommand {
   317  		var result []string
   318  		result = append(result, opts.args...)
   319  		result = append(result, rerunOpts.Args()...)
   320  		return result
   321  	}
   322  
   323  	args := opts.args
   324  	result := []string{"go", "test"}
   325  
   326  	if len(args) == 0 {
   327  		result = append(result, "-json")
   328  		if rerunOpts.runFlag != "" {
   329  			result = append(result, rerunOpts.runFlag)
   330  		}
   331  		return append(result, cmdArgPackageList(opts, rerunOpts, "./...")...)
   332  	}
   333  
   334  	if boolArgIndex("json", args) < 0 {
   335  		result = append(result, "-json")
   336  	}
   337  
   338  	if rerunOpts.runFlag != "" {
   339  		// Remove any existing run arg, it needs to be replaced with our new one
   340  		// and duplicate args are not allowed by 'go test'.
   341  		runIndex, runIndexEnd := argIndex("run", args)
   342  		if runIndex >= 0 && runIndexEnd < len(args) {
   343  			args = append(args[:runIndex], args[runIndexEnd+1:]...)
   344  		}
   345  		result = append(result, rerunOpts.runFlag)
   346  	}
   347  
   348  	pkgArgIndex := findPkgArgPosition(args)
   349  	result = append(result, args[:pkgArgIndex]...)
   350  	result = append(result, cmdArgPackageList(opts, rerunOpts)...)
   351  	result = append(result, args[pkgArgIndex:]...)
   352  	return result
   353  }
   354  
   355  func cmdArgPackageList(opts *options, rerunOpts rerunOpts, defPkgList ...string) []string {
   356  	switch {
   357  	case rerunOpts.pkg != "":
   358  		return []string{rerunOpts.pkg}
   359  	case len(opts.packages) > 0:
   360  		return opts.packages
   361  	case os.Getenv("TEST_DIRECTORY") != "":
   362  		return []string{os.Getenv("TEST_DIRECTORY")}
   363  	default:
   364  		return defPkgList
   365  	}
   366  }
   367  
   368  func boolArgIndex(flag string, args []string) int {
   369  	for i, arg := range args {
   370  		if arg == "-"+flag || arg == "--"+flag {
   371  			return i
   372  		}
   373  	}
   374  	return -1
   375  }
   376  
   377  func argIndex(flag string, args []string) (start, end int) {
   378  	for i, arg := range args {
   379  		if arg == "-"+flag || arg == "--"+flag {
   380  			return i, i + 1
   381  		}
   382  		if strings.HasPrefix(arg, "-"+flag+"=") || strings.HasPrefix(arg, "--"+flag+"=") {
   383  			return i, i
   384  		}
   385  	}
   386  	return -1, -1
   387  }
   388  
   389  // The package list is before the -args flag, or at the end of the args list
   390  // if the -args flag is not in args.
   391  // The -args flag is a 'go test' flag that indicates that all subsequent
   392  // args should be passed to the test binary. It requires that the list of
   393  // packages comes before -args, so we re-use it as a placeholder in the case
   394  // where some args must be passed to the test binary.
   395  func findPkgArgPosition(args []string) int {
   396  	if i := boolArgIndex("args", args); i >= 0 {
   397  		return i
   398  	}
   399  	return len(args)
   400  }
   401  
   402  type proc struct {
   403  	cmd    waiter
   404  	stdout io.Reader
   405  	stderr io.Reader
   406  	// signal is atomically set to the signal value when a signal is received
   407  	// by newSignalHandler.
   408  	signal int32
   409  }
   410  
   411  type waiter interface {
   412  	Wait() error
   413  }
   414  
   415  func startGoTest(ctx context.Context, dir string, args []string) (*proc, error) {
   416  	if len(args) == 0 {
   417  		return nil, errors.New("missing command to run")
   418  	}
   419  
   420  	cmd := exec.CommandContext(ctx, args[0], args[1:]...)
   421  	cmd.Stdin = os.Stdin
   422  	cmd.Dir = dir
   423  
   424  	p := proc{cmd: cmd}
   425  	log.Debugf("exec: %s", cmd.Args)
   426  	var err error
   427  	p.stdout, err = cmd.StdoutPipe()
   428  	if err != nil {
   429  		return nil, err
   430  	}
   431  	p.stderr, err = cmd.StderrPipe()
   432  	if err != nil {
   433  		return nil, err
   434  	}
   435  	if err := cmd.Start(); err != nil {
   436  		return nil, fmt.Errorf("failed to run %s: %w", strings.Join(cmd.Args, " "), err)
   437  	}
   438  	log.Debugf("go test pid: %d", cmd.Process.Pid)
   439  
   440  	ctx, cancel := context.WithCancel(ctx)
   441  	newSignalHandler(ctx, cmd.Process.Pid, &p)
   442  	p.cmd = &cancelWaiter{cancel: cancel, wrapped: p.cmd}
   443  	return &p, nil
   444  }
   445  
   446  // ExitCodeWithDefault returns the ExitStatus of a process from the error returned by
   447  // exec.Run(). If the exit status is not available an error is returned.
   448  func ExitCodeWithDefault(err error) int {
   449  	if err == nil {
   450  		return 0
   451  	}
   452  	if exiterr, ok := err.(exitCoder); ok {
   453  		if code := exiterr.ExitCode(); code != -1 {
   454  			return code
   455  		}
   456  	}
   457  	return 127
   458  }
   459  
   460  type exitCoder interface {
   461  	ExitCode() int
   462  }
   463  
   464  func IsExitCoder(err error) bool {
   465  	_, ok := err.(exitCoder)
   466  	return ok
   467  }
   468  
   469  type exitError struct {
   470  	num int
   471  }
   472  
   473  func (e exitError) Error() string {
   474  	return fmt.Sprintf("exit code %d", e.num)
   475  }
   476  
   477  func (e exitError) ExitCode() int {
   478  	return e.num
   479  }
   480  
   481  // signalExitCode is the base value added to a signal number to produce the
   482  // exit code value. This matches the behaviour of bash.
   483  const signalExitCode = 128
   484  
   485  func newSignalHandler(ctx context.Context, pid int, p *proc) {
   486  	c := make(chan os.Signal, 1)
   487  	signal.Notify(c, os.Interrupt)
   488  
   489  	go func() {
   490  		defer signal.Stop(c)
   491  
   492  		select {
   493  		case <-ctx.Done():
   494  			return
   495  		case s := <-c:
   496  			atomic.StoreInt32(&p.signal, int32(s.(syscall.Signal)))
   497  
   498  			proc, err := os.FindProcess(pid)
   499  			if err != nil {
   500  				log.Errorf("failed to find pid of 'go test': %v", err)
   501  				return
   502  			}
   503  			if err := proc.Signal(s); err != nil {
   504  				log.Errorf("failed to interrupt 'go test': %v", err)
   505  				return
   506  			}
   507  		}
   508  	}()
   509  }
   510  
   511  // cancelWaiter wraps a waiter to cancel the context after the wrapped
   512  // Wait exits.
   513  type cancelWaiter struct {
   514  	cancel  func()
   515  	wrapped waiter
   516  }
   517  
   518  func (w *cancelWaiter) Wait() error {
   519  	err := w.wrapped.Wait()
   520  	w.cancel()
   521  	return err
   522  }