testwrapper.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. // Copyright (c) Tailscale Inc & AUTHORS
  2. // SPDX-License-Identifier: BSD-3-Clause
  3. // testwrapper is a wrapper for retrying flaky tests. It is an alternative to
  4. // `go test` and re-runs failed marked flaky tests (using the flakytest pkg). It
  5. // takes different arguments than go test and requires the first positional
  6. // argument to be the pattern to test.
  7. package main
  8. import (
  9. "bufio"
  10. "bytes"
  11. "context"
  12. "encoding/json"
  13. "errors"
  14. "flag"
  15. "fmt"
  16. "io"
  17. "log"
  18. "os"
  19. "os/exec"
  20. "slices"
  21. "sort"
  22. "strings"
  23. "time"
  24. xmaps "golang.org/x/exp/maps"
  25. "tailscale.com/cmd/testwrapper/flakytest"
  26. )
  27. const maxAttempts = 3
  28. type testAttempt struct {
  29. pkg string // "tailscale.com/types/key"
  30. testName string // "TestFoo"
  31. outcome string // "pass", "fail", "skip"
  32. logs bytes.Buffer
  33. isMarkedFlaky bool // set if the test is marked as flaky
  34. issueURL string // set if the test is marked as flaky
  35. pkgFinished bool
  36. }
  37. // packageTests describes what to run.
  38. // It's also JSON-marshalled to output for analysys tools to parse
  39. // so the fields are all exported.
  40. // TODO(bradfitz): move this type to its own types package?
  41. type packageTests struct {
  42. // Pattern is the package Pattern to run.
  43. // Must be a single Pattern, not a list of patterns.
  44. Pattern string // "./...", "./types/key"
  45. // Tests is a list of Tests to run. If empty, all Tests in the package are
  46. // run.
  47. Tests []string // ["TestFoo", "TestBar"]
  48. // IssueURLs maps from a test name to a URL tracking its flake.
  49. IssueURLs map[string]string // "TestFoo" => "https://github.com/foo/bar/issue/123"
  50. }
  51. type goTestOutput struct {
  52. Time time.Time
  53. Action string
  54. Package string
  55. Test string
  56. Output string
  57. }
  58. var debug = os.Getenv("TS_TESTWRAPPER_DEBUG") != ""
  59. // runTests runs the tests in pt and sends the results on ch. It sends a
  60. // testAttempt for each test and a final testAttempt per pkg with pkgFinished
  61. // set to true. Package build errors will not emit a testAttempt (as no valid
  62. // JSON is produced) but the [os/exec.ExitError] will be returned.
  63. // It calls close(ch) when it's done.
  64. func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []string, ch chan<- *testAttempt) error {
  65. defer close(ch)
  66. args := []string{"test", "--json"}
  67. if *flagSudo {
  68. args = append(args, "--exec", "sudo -E")
  69. }
  70. args = append(args, pt.Pattern)
  71. args = append(args, otherArgs...)
  72. if len(pt.Tests) > 0 {
  73. runArg := strings.Join(pt.Tests, "|")
  74. args = append(args, "--run", runArg)
  75. }
  76. if debug {
  77. fmt.Println("running", strings.Join(args, " "))
  78. }
  79. cmd := exec.CommandContext(ctx, "go", args...)
  80. r, err := cmd.StdoutPipe()
  81. if err != nil {
  82. log.Printf("error creating stdout pipe: %v", err)
  83. }
  84. defer r.Close()
  85. cmd.Stderr = os.Stderr
  86. cmd.Env = os.Environ()
  87. cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%d", flakytest.FlakeAttemptEnv, attempt))
  88. if err := cmd.Start(); err != nil {
  89. log.Printf("error starting test: %v", err)
  90. os.Exit(1)
  91. }
  92. s := bufio.NewScanner(r)
  93. resultMap := make(map[string]map[string]*testAttempt) // pkg -> test -> testAttempt
  94. for s.Scan() {
  95. var goOutput goTestOutput
  96. if err := json.Unmarshal(s.Bytes(), &goOutput); err != nil {
  97. if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) {
  98. break
  99. }
  100. // `go test -json` outputs invalid JSON when a build fails.
  101. // In that case, discard the the output and start reading again.
  102. // The build error will be printed to stderr.
  103. // See: https://github.com/golang/go/issues/35169
  104. if _, ok := err.(*json.SyntaxError); ok {
  105. fmt.Println(s.Text())
  106. continue
  107. }
  108. panic(err)
  109. }
  110. pkg := goOutput.Package
  111. pkgTests := resultMap[pkg]
  112. if goOutput.Test == "" {
  113. switch goOutput.Action {
  114. case "fail", "pass", "skip":
  115. for _, test := range pkgTests {
  116. if test.outcome == "" {
  117. test.outcome = "fail"
  118. ch <- test
  119. }
  120. }
  121. ch <- &testAttempt{
  122. pkg: goOutput.Package,
  123. outcome: goOutput.Action,
  124. pkgFinished: true,
  125. }
  126. }
  127. continue
  128. }
  129. if pkgTests == nil {
  130. pkgTests = make(map[string]*testAttempt)
  131. resultMap[pkg] = pkgTests
  132. }
  133. testName := goOutput.Test
  134. if test, _, isSubtest := strings.Cut(goOutput.Test, "/"); isSubtest {
  135. testName = test
  136. if goOutput.Action == "output" {
  137. resultMap[pkg][testName].logs.WriteString(goOutput.Output)
  138. }
  139. continue
  140. }
  141. switch goOutput.Action {
  142. case "start":
  143. // ignore
  144. case "run":
  145. pkgTests[testName] = &testAttempt{
  146. pkg: pkg,
  147. testName: testName,
  148. }
  149. case "skip", "pass", "fail":
  150. pkgTests[testName].outcome = goOutput.Action
  151. ch <- pkgTests[testName]
  152. case "output":
  153. if suffix, ok := strings.CutPrefix(strings.TrimSpace(goOutput.Output), flakytest.FlakyTestLogMessage); ok {
  154. pkgTests[testName].isMarkedFlaky = true
  155. pkgTests[testName].issueURL = strings.TrimPrefix(suffix, ": ")
  156. } else {
  157. pkgTests[testName].logs.WriteString(goOutput.Output)
  158. }
  159. }
  160. }
  161. if err := cmd.Wait(); err != nil {
  162. return err
  163. }
  164. if err := s.Err(); err != nil {
  165. return fmt.Errorf("reading go test stdout: %w", err)
  166. }
  167. return nil
  168. }
  169. var (
  170. flagVerbose = flag.Bool("v", false, "verbose")
  171. flagSudo = flag.Bool("sudo", false, "run tests with -exec=sudo")
  172. )
  173. func main() {
  174. ctx := context.Background()
  175. // We only need to parse the -v flag to figure out whether to print the logs
  176. // for a test. We don't need to parse any other flags, so we just use the
  177. // flag package to parse the -v flag and then pass the rest of the args
  178. // through to 'go test'.
  179. // We run `go test -json` which returns the same information as `go test -v`,
  180. // but in a machine-readable format. So this flag is only for testwrapper's
  181. // output.
  182. flag.Usage = func() {
  183. fmt.Println("usage: testwrapper [testwrapper-flags] [pattern] [build/test flags & test binary flags]")
  184. fmt.Println()
  185. fmt.Println("testwrapper-flags:")
  186. flag.CommandLine.PrintDefaults()
  187. fmt.Println()
  188. fmt.Println("examples:")
  189. fmt.Println("\ttestwrapper -v ./... -count=1")
  190. fmt.Println("\ttestwrapper ./pkg/foo -run TestBar -count=1")
  191. fmt.Println()
  192. fmt.Println("Unlike 'go test', testwrapper requires a package pattern as the first positional argument and only supports a single pattern.")
  193. }
  194. flag.Parse()
  195. args := flag.Args()
  196. if len(args) < 1 || strings.HasPrefix(args[0], "-") {
  197. fmt.Println("no pattern specified")
  198. flag.Usage()
  199. os.Exit(1)
  200. } else if len(args) > 1 && !strings.HasPrefix(args[1], "-") {
  201. fmt.Println("expected single pattern")
  202. flag.Usage()
  203. os.Exit(1)
  204. }
  205. pattern, otherArgs := args[0], args[1:]
  206. type nextRun struct {
  207. tests []*packageTests
  208. attempt int // starting at 1
  209. }
  210. toRun := []*nextRun{
  211. {
  212. tests: []*packageTests{{Pattern: pattern}},
  213. attempt: 1,
  214. },
  215. }
  216. printPkgOutcome := func(pkg, outcome string, attempt int) {
  217. if outcome == "skip" {
  218. fmt.Printf("?\t%s [skipped/no tests] \n", pkg)
  219. return
  220. }
  221. if outcome == "pass" {
  222. outcome = "ok"
  223. }
  224. if outcome == "fail" {
  225. outcome = "FAIL"
  226. }
  227. if attempt > 1 {
  228. fmt.Printf("%s\t%s [attempt=%d]\n", outcome, pkg, attempt)
  229. return
  230. }
  231. fmt.Printf("%s\t%s\n", outcome, pkg)
  232. }
  233. for len(toRun) > 0 {
  234. var thisRun *nextRun
  235. thisRun, toRun = toRun[0], toRun[1:]
  236. if thisRun.attempt > maxAttempts {
  237. fmt.Println("max attempts reached")
  238. os.Exit(1)
  239. }
  240. if thisRun.attempt > 1 {
  241. j, _ := json.Marshal(thisRun.tests)
  242. fmt.Printf("\n\nAttempt #%d: Retrying flaky tests:\n\nflakytest failures JSON: %s\n\n", thisRun.attempt, j)
  243. }
  244. toRetry := make(map[string][]*testAttempt) // pkg -> tests to retry
  245. for _, pt := range thisRun.tests {
  246. ch := make(chan *testAttempt)
  247. runErr := make(chan error, 1)
  248. go func() {
  249. defer close(runErr)
  250. runErr <- runTests(ctx, thisRun.attempt, pt, otherArgs, ch)
  251. }()
  252. var failed bool
  253. for tr := range ch {
  254. // Go assigns the package name "command-line-arguments" when you
  255. // `go test FILE` rather than `go test PKG`. It's more
  256. // convenient for us to to specify files in tests, so fix tr.pkg
  257. // so that subsequent testwrapper attempts run correctly.
  258. if tr.pkg == "command-line-arguments" {
  259. tr.pkg = pattern
  260. }
  261. if tr.pkgFinished {
  262. if tr.outcome == "fail" && len(toRetry[tr.pkg]) == 0 {
  263. // If a package fails and we don't have any tests to
  264. // retry, then we should fail. This typically happens
  265. // when a package times out.
  266. failed = true
  267. }
  268. printPkgOutcome(tr.pkg, tr.outcome, thisRun.attempt)
  269. continue
  270. }
  271. if *flagVerbose || tr.outcome == "fail" {
  272. io.Copy(os.Stdout, &tr.logs)
  273. }
  274. if tr.outcome != "fail" {
  275. continue
  276. }
  277. if tr.isMarkedFlaky {
  278. toRetry[tr.pkg] = append(toRetry[tr.pkg], tr)
  279. } else {
  280. failed = true
  281. }
  282. }
  283. if failed {
  284. fmt.Println("\n\nNot retrying flaky tests because non-flaky tests failed.")
  285. os.Exit(1)
  286. }
  287. // If there's nothing to retry and no non-retryable tests have
  288. // failed then we've probably hit a build error.
  289. if err := <-runErr; len(toRetry) == 0 && err != nil {
  290. var exit *exec.ExitError
  291. if errors.As(err, &exit) {
  292. if code := exit.ExitCode(); code > -1 {
  293. os.Exit(exit.ExitCode())
  294. }
  295. }
  296. log.Printf("testwrapper: %s", err)
  297. os.Exit(1)
  298. }
  299. }
  300. if len(toRetry) == 0 {
  301. continue
  302. }
  303. pkgs := xmaps.Keys(toRetry)
  304. sort.Strings(pkgs)
  305. nextRun := &nextRun{
  306. attempt: thisRun.attempt + 1,
  307. }
  308. for _, pkg := range pkgs {
  309. tests := toRetry[pkg]
  310. slices.SortFunc(tests, func(a, b *testAttempt) int { return strings.Compare(a.testName, b.testName) })
  311. issueURLs := map[string]string{} // test name => URL
  312. var testNames []string
  313. for _, ta := range tests {
  314. issueURLs[ta.testName] = ta.issueURL
  315. testNames = append(testNames, ta.testName)
  316. }
  317. nextRun.tests = append(nextRun.tests, &packageTests{
  318. Pattern: pkg,
  319. Tests: testNames,
  320. IssueURLs: issueURLs,
  321. })
  322. }
  323. toRun = append(toRun, nextRun)
  324. }
  325. }