testwrapper.go 8.7 KB

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