testwrapper.go 11 KB

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