mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-30 05:25:35 +00:00
ae747a2e48
While investigating the fix in 7538f38671
,
I was curious why the testwrapper didn't fail. Turns out if the test
times out and there was no explicit failure, the only message we get
is that the overall pkg failed and no failure information about the
individual test. This resulted in a 0 exit code.
This fixes that by failing the explicit case of the pkg failing when
there is nothing to retry for that pkg.
Updates #8493
Signed-off-by: Maisem Ali <maisem@tailscale.com>
295 lines
7.4 KiB
Go
295 lines
7.4 KiB
Go
// 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"
|
|
|
|
xmaps "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)
|
|
}
|
|
defer r.Close()
|
|
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
|
|
}
|
|
|
|
// `go test -json` outputs invalid JSON when a build fails.
|
|
// In that case, discard the the output and start reading again.
|
|
// The build error will be printed to stderr.
|
|
// See: https://github.com/golang/go/issues/35169
|
|
if _, ok := err.(*json.SyntaxError); ok {
|
|
jd = json.NewDecoder(r)
|
|
continue
|
|
}
|
|
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 {
|
|
if tr.outcome == "fail" && len(toRetry[tr.name.pkg]) == 0 {
|
|
// If a package fails and we don't have any tests to
|
|
// retry, then we should fail. This typically happens
|
|
// when a package times out.
|
|
failed = true
|
|
}
|
|
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 := xmaps.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)
|
|
}
|
|
}
|