testwrapper.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  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. "unicode"
  24. "github.com/dave/courtney/scanner"
  25. "github.com/dave/courtney/shared"
  26. "github.com/dave/courtney/tester"
  27. "github.com/dave/patsy"
  28. "github.com/dave/patsy/vos"
  29. "tailscale.com/cmd/testwrapper/flakytest"
  30. "tailscale.com/util/slicesx"
  31. )
  32. const (
  33. maxAttempts = 3
  34. )
  35. type testAttempt struct {
  36. pkg string // "tailscale.com/types/key"
  37. testName string // "TestFoo"
  38. outcome string // "pass", "fail", "skip"
  39. logs bytes.Buffer
  40. start, end time.Time
  41. isMarkedFlaky bool // set if the test is marked as flaky
  42. issueURL string // set if the test is marked as flaky
  43. pkgFinished bool
  44. }
  45. // packageTests describes what to run.
  46. // It's also JSON-marshalled to output for analysys tools to parse
  47. // so the fields are all exported.
  48. // TODO(bradfitz): move this type to its own types package?
  49. type packageTests struct {
  50. // Pattern is the package Pattern to run.
  51. // Must be a single Pattern, not a list of patterns.
  52. Pattern string // "./...", "./types/key"
  53. // Tests is a list of Tests to run. If empty, all Tests in the package are
  54. // run.
  55. Tests []string // ["TestFoo", "TestBar"]
  56. // IssueURLs maps from a test name to a URL tracking its flake.
  57. IssueURLs map[string]string // "TestFoo" => "https://github.com/foo/bar/issue/123"
  58. }
  59. type goTestOutput struct {
  60. Time time.Time
  61. Action string
  62. Package string
  63. Test string
  64. Output string
  65. }
  66. var debug = os.Getenv("TS_TESTWRAPPER_DEBUG") != ""
  67. // runTests runs the tests in pt and sends the results on ch. It sends a
  68. // testAttempt for each test and a final testAttempt per pkg with pkgFinished
  69. // set to true. Package build errors will not emit a testAttempt (as no valid
  70. // JSON is produced) but the [os/exec.ExitError] will be returned.
  71. // It calls close(ch) when it's done.
  72. func runTests(ctx context.Context, attempt int, pt *packageTests, goTestArgs, testArgs []string, ch chan<- *testAttempt) error {
  73. defer close(ch)
  74. args := []string{"test"}
  75. args = append(args, goTestArgs...)
  76. args = append(args, pt.Pattern)
  77. if len(pt.Tests) > 0 {
  78. runArg := strings.Join(pt.Tests, "|")
  79. args = append(args, "--run", runArg)
  80. }
  81. args = append(args, testArgs...)
  82. args = append(args, "-json")
  83. if debug {
  84. fmt.Println("running", strings.Join(args, " "))
  85. }
  86. cmd := exec.CommandContext(ctx, "go", args...)
  87. if len(pt.Tests) > 0 {
  88. cmd.Env = append(os.Environ(), "TS_TEST_SHARD=") // clear test shard; run all tests we say to run
  89. }
  90. r, err := cmd.StdoutPipe()
  91. if err != nil {
  92. log.Printf("error creating stdout pipe: %v", err)
  93. }
  94. defer r.Close()
  95. cmd.Stderr = os.Stderr
  96. cmd.Env = os.Environ()
  97. cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%d", flakytest.FlakeAttemptEnv, attempt))
  98. if err := cmd.Start(); err != nil {
  99. log.Printf("error starting test: %v", err)
  100. os.Exit(1)
  101. }
  102. s := bufio.NewScanner(r)
  103. resultMap := make(map[string]map[string]*testAttempt) // pkg -> test -> testAttempt
  104. for s.Scan() {
  105. var goOutput goTestOutput
  106. if err := json.Unmarshal(s.Bytes(), &goOutput); err != nil {
  107. if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) {
  108. break
  109. }
  110. // `go test -json` outputs invalid JSON when a build fails.
  111. // In that case, discard the the output and start reading again.
  112. // The build error will be printed to stderr.
  113. // See: https://github.com/golang/go/issues/35169
  114. if _, ok := err.(*json.SyntaxError); ok {
  115. fmt.Println(s.Text())
  116. continue
  117. }
  118. panic(err)
  119. }
  120. pkg := goOutput.Package
  121. pkgTests := resultMap[pkg]
  122. if pkgTests == nil {
  123. pkgTests = make(map[string]*testAttempt)
  124. resultMap[pkg] = pkgTests
  125. }
  126. if goOutput.Test == "" {
  127. switch goOutput.Action {
  128. case "start":
  129. pkgTests[""] = &testAttempt{start: goOutput.Time}
  130. case "fail", "pass", "skip":
  131. for _, test := range pkgTests {
  132. if test.testName != "" && test.outcome == "" {
  133. test.outcome = "fail"
  134. ch <- test
  135. }
  136. }
  137. ch <- &testAttempt{
  138. pkg: goOutput.Package,
  139. outcome: goOutput.Action,
  140. start: pkgTests[""].start,
  141. end: goOutput.Time,
  142. pkgFinished: true,
  143. }
  144. }
  145. continue
  146. }
  147. testName := goOutput.Test
  148. if test, _, isSubtest := strings.Cut(goOutput.Test, "/"); isSubtest {
  149. testName = test
  150. if goOutput.Action == "output" {
  151. resultMap[pkg][testName].logs.WriteString(goOutput.Output)
  152. }
  153. continue
  154. }
  155. switch goOutput.Action {
  156. case "start":
  157. // ignore
  158. case "run":
  159. pkgTests[testName] = &testAttempt{
  160. pkg: pkg,
  161. testName: testName,
  162. start: goOutput.Time,
  163. }
  164. case "skip", "pass", "fail":
  165. pkgTests[testName].end = goOutput.Time
  166. pkgTests[testName].outcome = goOutput.Action
  167. ch <- pkgTests[testName]
  168. case "output":
  169. if suffix, ok := strings.CutPrefix(strings.TrimSpace(goOutput.Output), flakytest.FlakyTestLogMessage); ok {
  170. pkgTests[testName].isMarkedFlaky = true
  171. pkgTests[testName].issueURL = strings.TrimPrefix(suffix, ": ")
  172. } else {
  173. pkgTests[testName].logs.WriteString(goOutput.Output)
  174. }
  175. }
  176. }
  177. if err := cmd.Wait(); err != nil {
  178. return err
  179. }
  180. if err := s.Err(); err != nil {
  181. return fmt.Errorf("reading go test stdout: %w", err)
  182. }
  183. return nil
  184. }
  185. func main() {
  186. goTestArgs, packages, testArgs, err := splitArgs(os.Args[1:])
  187. if err != nil {
  188. log.Fatal(err)
  189. return
  190. }
  191. if len(packages) == 0 {
  192. fmt.Println("testwrapper: no packages specified")
  193. return
  194. }
  195. ctx := context.Background()
  196. type nextRun struct {
  197. tests []*packageTests
  198. attempt int // starting at 1
  199. }
  200. firstRun := &nextRun{
  201. attempt: 1,
  202. }
  203. for _, pkg := range packages {
  204. firstRun.tests = append(firstRun.tests, &packageTests{Pattern: pkg})
  205. }
  206. toRun := []*nextRun{firstRun}
  207. printPkgOutcome := func(pkg, outcome string, attempt int, runtime time.Duration) {
  208. if outcome == "skip" {
  209. fmt.Printf("?\t%s [skipped/no tests] \n", pkg)
  210. return
  211. }
  212. if outcome == "pass" {
  213. outcome = "ok"
  214. }
  215. if outcome == "fail" {
  216. outcome = "FAIL"
  217. }
  218. if attempt > 1 {
  219. fmt.Printf("%s\t%s\t%.3fs\t[attempt=%d]\n", outcome, pkg, runtime.Seconds(), attempt)
  220. return
  221. }
  222. fmt.Printf("%s\t%s\t%.3fs\n", outcome, pkg, runtime.Seconds())
  223. }
  224. // Check for -coverprofile argument and filter it out
  225. combinedCoverageFilename := ""
  226. filteredGoTestArgs := make([]string, 0, len(goTestArgs))
  227. preceededByCoverProfile := false
  228. for _, arg := range goTestArgs {
  229. if arg == "-coverprofile" {
  230. preceededByCoverProfile = true
  231. } else if preceededByCoverProfile {
  232. combinedCoverageFilename = strings.TrimSpace(arg)
  233. preceededByCoverProfile = false
  234. } else {
  235. filteredGoTestArgs = append(filteredGoTestArgs, arg)
  236. }
  237. }
  238. goTestArgs = filteredGoTestArgs
  239. runningWithCoverage := combinedCoverageFilename != ""
  240. if runningWithCoverage {
  241. fmt.Printf("Will log coverage to %v\n", combinedCoverageFilename)
  242. }
  243. // Keep track of all test coverage files. With each retry, we'll end up
  244. // with additional coverage files that will be combined when we finish.
  245. coverageFiles := make([]string, 0)
  246. for len(toRun) > 0 {
  247. var thisRun *nextRun
  248. thisRun, toRun = toRun[0], toRun[1:]
  249. if thisRun.attempt > maxAttempts {
  250. fmt.Println("max attempts reached")
  251. os.Exit(1)
  252. }
  253. if thisRun.attempt > 1 {
  254. j, _ := json.Marshal(thisRun.tests)
  255. fmt.Printf("\n\nAttempt #%d: Retrying flaky tests:\n\nflakytest failures JSON: %s\n\n", thisRun.attempt, j)
  256. }
  257. goTestArgsWithCoverage := testArgs
  258. if runningWithCoverage {
  259. coverageFile := fmt.Sprintf("/tmp/coverage_%d.out", thisRun.attempt)
  260. coverageFiles = append(coverageFiles, coverageFile)
  261. goTestArgsWithCoverage = make([]string, len(goTestArgs), len(goTestArgs)+2)
  262. copy(goTestArgsWithCoverage, goTestArgs)
  263. goTestArgsWithCoverage = append(
  264. goTestArgsWithCoverage,
  265. fmt.Sprintf("-coverprofile=%v", coverageFile),
  266. "-covermode=set",
  267. "-coverpkg=./...",
  268. )
  269. }
  270. toRetry := make(map[string][]*testAttempt) // pkg -> tests to retry
  271. for _, pt := range thisRun.tests {
  272. ch := make(chan *testAttempt)
  273. runErr := make(chan error, 1)
  274. go func() {
  275. defer close(runErr)
  276. runErr <- runTests(ctx, thisRun.attempt, pt, goTestArgsWithCoverage, testArgs, ch)
  277. }()
  278. var failed bool
  279. for tr := range ch {
  280. // Go assigns the package name "command-line-arguments" when you
  281. // `go test FILE` rather than `go test PKG`. It's more
  282. // convenient for us to to specify files in tests, so fix tr.pkg
  283. // so that subsequent testwrapper attempts run correctly.
  284. if tr.pkg == "command-line-arguments" {
  285. tr.pkg = packages[0]
  286. }
  287. if tr.pkgFinished {
  288. if tr.outcome == "fail" && len(toRetry[tr.pkg]) == 0 {
  289. // If a package fails and we don't have any tests to
  290. // retry, then we should fail. This typically happens
  291. // when a package times out.
  292. failed = true
  293. }
  294. printPkgOutcome(tr.pkg, tr.outcome, thisRun.attempt, tr.end.Sub(tr.start))
  295. continue
  296. }
  297. if testingVerbose || tr.outcome == "fail" {
  298. io.Copy(os.Stdout, &tr.logs)
  299. }
  300. if tr.outcome != "fail" {
  301. continue
  302. }
  303. if tr.isMarkedFlaky {
  304. toRetry[tr.pkg] = append(toRetry[tr.pkg], tr)
  305. } else {
  306. failed = true
  307. }
  308. }
  309. if failed {
  310. fmt.Println("\n\nNot retrying flaky tests because non-flaky tests failed.")
  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. if runningWithCoverage {
  352. intermediateCoverageFilename := "/tmp/coverage.out_intermediate"
  353. if err := combineCoverageFiles(intermediateCoverageFilename, coverageFiles); err != nil {
  354. fmt.Printf("error combining coverage files: %v\n", err)
  355. os.Exit(2)
  356. }
  357. if err := processCoverageWithCourtney(intermediateCoverageFilename, combinedCoverageFilename, testArgs); err != nil {
  358. fmt.Printf("error processing coverage with courtney: %v\n", err)
  359. os.Exit(3)
  360. }
  361. fmt.Printf("Wrote combined coverage to %v\n", combinedCoverageFilename)
  362. }
  363. }
  364. func combineCoverageFiles(intermediateCoverageFilename string, coverageFiles []string) error {
  365. combinedCoverageFile, err := os.OpenFile(intermediateCoverageFilename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
  366. if err != nil {
  367. return fmt.Errorf("create /tmp/coverage.out: %w", err)
  368. }
  369. defer combinedCoverageFile.Close()
  370. w := bufio.NewWriter(combinedCoverageFile)
  371. defer w.Flush()
  372. for fileNumber, coverageFile := range coverageFiles {
  373. f, err := os.Open(coverageFile)
  374. if err != nil {
  375. return fmt.Errorf("open %v: %w", coverageFile, err)
  376. }
  377. defer f.Close()
  378. in := bufio.NewReader(f)
  379. line := 0
  380. for {
  381. r, _, err := in.ReadRune()
  382. if err != nil {
  383. if err != io.EOF {
  384. return fmt.Errorf("read %v: %w", coverageFile, err)
  385. }
  386. break
  387. }
  388. // On all but the first coverage file, skip the coverage file header
  389. if fileNumber > 0 && line == 0 {
  390. continue
  391. }
  392. if r == '\n' {
  393. line++
  394. }
  395. // filter for only printable characters because coverage file sometimes includes junk on 2nd line
  396. if unicode.IsPrint(r) || r == '\n' {
  397. if _, err := w.WriteRune(r); err != nil {
  398. return fmt.Errorf("write %v: %w", combinedCoverageFile.Name(), err)
  399. }
  400. }
  401. }
  402. }
  403. return nil
  404. }
  405. // processCoverageWithCourtney post-processes code coverage to exclude less
  406. // meaningful sections like 'if err != nil { return err}', as well as
  407. // anything marked with a '// notest' comment.
  408. //
  409. // instead of running the courtney as a separate program, this embeds
  410. // courtney for easier integration.
  411. func processCoverageWithCourtney(intermediateCoverageFilename, combinedCoverageFilename string, testArgs []string) error {
  412. env := vos.Os()
  413. setup := &shared.Setup{
  414. Env: vos.Os(),
  415. Paths: patsy.NewCache(env),
  416. TestArgs: testArgs,
  417. Load: intermediateCoverageFilename,
  418. Output: combinedCoverageFilename,
  419. }
  420. if err := setup.Parse(testArgs); err != nil {
  421. return fmt.Errorf("parse args: %w", err)
  422. }
  423. s := scanner.New(setup)
  424. if err := s.LoadProgram(); err != nil {
  425. return fmt.Errorf("load program: %w", err)
  426. }
  427. if err := s.ScanPackages(); err != nil {
  428. return fmt.Errorf("scan packages: %w", err)
  429. }
  430. t := tester.New(setup)
  431. if err := t.Load(); err != nil {
  432. return fmt.Errorf("load: %w", err)
  433. }
  434. if err := t.ProcessExcludes(s.Excludes); err != nil {
  435. return fmt.Errorf("process excludes: %w", err)
  436. }
  437. if err := t.Save(); err != nil {
  438. return fmt.Errorf("save: %w", err)
  439. }
  440. return nil
  441. }