| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278 |
- // Copyright (c) Tailscale Inc & AUTHORS
- // SPDX-License-Identifier: BSD-3-Clause
- // testwrapper is a wrapper for retrying flaky tests. It is an alternative to
- // `go test` and re-runs failed marked flaky tests (using the flakytest pkg). It
- // takes different arguments than go test and requires the first positional
- // argument to be the pattern to test.
- package main
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "flag"
- "fmt"
- "io"
- "log"
- "os"
- "os/exec"
- "sort"
- "strings"
- "time"
- "golang.org/x/exp/maps"
- "tailscale.com/cmd/testwrapper/flakytest"
- )
- const maxAttempts = 3
- type testAttempt struct {
- name testName
- outcome string // "pass", "fail", "skip"
- logs bytes.Buffer
- isMarkedFlaky bool // set if the test is marked as flaky
- pkgFinished bool
- }
- type testName struct {
- pkg string // "tailscale.com/types/key"
- name string // "TestFoo"
- }
- type packageTests struct {
- // pattern is the package pattern to run.
- // Must be a single pattern, not a list of patterns.
- pattern string // "./...", "./types/key"
- // tests is a list of tests to run. If empty, all tests in the package are
- // run.
- tests []string // ["TestFoo", "TestBar"]
- }
- type goTestOutput struct {
- Time time.Time
- Action string
- Package string
- Test string
- Output string
- }
- var debug = os.Getenv("TS_TESTWRAPPER_DEBUG") != ""
- // runTests runs the tests in pt and sends the results on ch. It sends a
- // testAttempt for each test and a final testAttempt per pkg with pkgFinished
- // set to true.
- // It calls close(ch) when it's done.
- func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []string, ch chan<- *testAttempt) {
- defer close(ch)
- args := []string{"test", "-json", pt.pattern}
- args = append(args, otherArgs...)
- if len(pt.tests) > 0 {
- runArg := strings.Join(pt.tests, "|")
- args = append(args, "-run", runArg)
- }
- if debug {
- fmt.Println("running", strings.Join(args, " "))
- }
- cmd := exec.CommandContext(ctx, "go", args...)
- r, err := cmd.StdoutPipe()
- if err != nil {
- log.Printf("error creating stdout pipe: %v", err)
- }
- cmd.Stderr = os.Stderr
- cmd.Env = os.Environ()
- cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%d", flakytest.FlakeAttemptEnv, attempt))
- if err := cmd.Start(); err != nil {
- log.Printf("error starting test: %v", err)
- os.Exit(1)
- }
- done := make(chan struct{})
- go func() {
- defer close(done)
- cmd.Wait()
- }()
- jd := json.NewDecoder(r)
- resultMap := make(map[testName]*testAttempt)
- for {
- var goOutput goTestOutput
- if err := jd.Decode(&goOutput); err != nil {
- if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) {
- break
- }
- panic(err)
- }
- if goOutput.Test == "" {
- switch goOutput.Action {
- case "fail", "pass", "skip":
- ch <- &testAttempt{
- name: testName{
- pkg: goOutput.Package,
- },
- outcome: goOutput.Action,
- pkgFinished: true,
- }
- }
- continue
- }
- name := testName{
- pkg: goOutput.Package,
- name: goOutput.Test,
- }
- if test, _, isSubtest := strings.Cut(goOutput.Test, "/"); isSubtest {
- name.name = test
- if goOutput.Action == "output" {
- resultMap[name].logs.WriteString(goOutput.Output)
- }
- continue
- }
- switch goOutput.Action {
- case "start":
- // ignore
- case "run":
- resultMap[name] = &testAttempt{
- name: name,
- }
- case "skip", "pass", "fail":
- resultMap[name].outcome = goOutput.Action
- ch <- resultMap[name]
- case "output":
- if strings.TrimSpace(goOutput.Output) == flakytest.FlakyTestLogMessage {
- resultMap[name].isMarkedFlaky = true
- } else {
- resultMap[name].logs.WriteString(goOutput.Output)
- }
- }
- }
- <-done
- }
- func main() {
- ctx := context.Background()
- // We only need to parse the -v flag to figure out whether to print the logs
- // for a test. We don't need to parse any other flags, so we just use the
- // flag package to parse the -v flag and then pass the rest of the args
- // through to 'go test'.
- // We run `go test -json` which returns the same information as `go test -v`,
- // but in a machine-readable format. So this flag is only for testwrapper's
- // output.
- v := flag.Bool("v", false, "verbose")
- flag.Usage = func() {
- fmt.Println("usage: testwrapper [testwrapper-flags] [pattern] [build/test flags & test binary flags]")
- fmt.Println()
- fmt.Println("testwrapper-flags:")
- flag.CommandLine.PrintDefaults()
- fmt.Println()
- fmt.Println("examples:")
- fmt.Println("\ttestwrapper -v ./... -count=1")
- fmt.Println("\ttestwrapper ./pkg/foo -run TestBar -count=1")
- fmt.Println()
- fmt.Println("Unlike 'go test', testwrapper requires a package pattern as the first positional argument and only supports a single pattern.")
- }
- flag.Parse()
- args := flag.Args()
- if len(args) < 1 || strings.HasPrefix(args[0], "-") {
- fmt.Println("no pattern specified")
- flag.Usage()
- os.Exit(1)
- } else if len(args) > 1 && !strings.HasPrefix(args[1], "-") {
- fmt.Println("expected single pattern")
- flag.Usage()
- os.Exit(1)
- }
- pattern, otherArgs := args[0], args[1:]
- type nextRun struct {
- tests []*packageTests
- attempt int
- }
- toRun := []*nextRun{
- {
- tests: []*packageTests{{pattern: pattern}},
- attempt: 1,
- },
- }
- printPkgOutcome := func(pkg, outcome string, attempt int) {
- if outcome == "skip" {
- fmt.Printf("?\t%s [skipped/no tests] \n", pkg)
- return
- }
- if outcome == "pass" {
- outcome = "ok"
- }
- if outcome == "fail" {
- outcome = "FAIL"
- }
- if attempt > 1 {
- fmt.Printf("%s\t%s [attempt=%d]\n", outcome, pkg, attempt)
- return
- }
- fmt.Printf("%s\t%s\n", outcome, pkg)
- }
- for len(toRun) > 0 {
- var thisRun *nextRun
- thisRun, toRun = toRun[0], toRun[1:]
- if thisRun.attempt >= maxAttempts {
- fmt.Println("max attempts reached")
- os.Exit(1)
- }
- if thisRun.attempt > 1 {
- fmt.Printf("\n\nAttempt #%d: Retrying flaky tests:\n\n", thisRun.attempt)
- }
- failed := false
- toRetry := make(map[string][]string) // pkg -> tests to retry
- for _, pt := range thisRun.tests {
- ch := make(chan *testAttempt)
- go runTests(ctx, thisRun.attempt, pt, otherArgs, ch)
- for tr := range ch {
- if tr.pkgFinished {
- printPkgOutcome(tr.name.pkg, tr.outcome, thisRun.attempt)
- continue
- }
- if *v || tr.outcome == "fail" {
- io.Copy(os.Stdout, &tr.logs)
- }
- if tr.outcome != "fail" {
- continue
- }
- if tr.isMarkedFlaky {
- toRetry[tr.name.pkg] = append(toRetry[tr.name.pkg], tr.name.name)
- } else {
- failed = true
- }
- }
- }
- if failed {
- fmt.Println("\n\nNot retrying flaky tests because non-flaky tests failed.")
- os.Exit(1)
- }
- if len(toRetry) == 0 {
- continue
- }
- pkgs := maps.Keys(toRetry)
- sort.Strings(pkgs)
- nextRun := &nextRun{
- attempt: thisRun.attempt + 1,
- }
- for _, pkg := range pkgs {
- tests := toRetry[pkg]
- sort.Strings(tests)
- nextRun.tests = append(nextRun.tests, &packageTests{
- pattern: pkg,
- tests: tests,
- })
- }
- toRun = append(toRun, nextRun)
- }
- }
|