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 }