mirror of
https://github.com/tailscale/tailscale.git
synced 2026-09-22 11:35:12 -04:00
testwrapper ran a separate, sequential "go test" invocation for each package pattern on its command line. That is fine for a single "./..." argument but not for callers that pass an explicit package list: CI jobs in the corp repo passing ~200 packages ran ~200 serial go test processes with no cross-package parallelism and a fixed set of never-cacheable lookups per process, and spent several times longer on process startup, package loading, cache lookups, and serial test binary links than on running tests. See tailscale/corp#48453 for the details. Locally, on 203 packages with a fully warm build and test cache, so measuring only the per-invocation overhead: old (203 go test processes): 26.4s new (1 go test process): 3.6s (7.3x faster) Our own Windows CI job hits the same path: its "sharded:N/M" mode expands to an explicit list of that shard's packages via listpkgs, so each shard ran one go test process per package, and Windows process startup is slower still. Each shard now runs as one invocation. Fixes tailscale/corp#48453 Updates tailscale/corp#47035 Change-Id: I3f796ff1724af40f93be9f918a7ddfde3bb45a91 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
210 lines
6.4 KiB
Go
210 lines
6.4 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package main_test
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// multiPkgDir creates a temporary directory for test packages under
|
|
// testdata, so the packages are inside this module but ignored by
|
|
// "./..." patterns if a test ever leaks them.
|
|
func multiPkgDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("testdata", "multipkg-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
|
return dir
|
|
}
|
|
|
|
// writePkg writes a test-only package named name under dir with the
|
|
// given test source and returns its relative package pattern.
|
|
func writePkg(t *testing.T, dir, name, src string) string {
|
|
t.Helper()
|
|
pkgDir := filepath.Join(dir, name)
|
|
if err := os.MkdirAll(pkgDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(pkgDir, name+"_test.go"), []byte(src), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return "./" + filepath.ToSlash(pkgDir)
|
|
}
|
|
|
|
// importPath returns the import path of a package written by writePkg.
|
|
func importPath(pattern string) string {
|
|
return "tailscale.com/cmd/testwrapper/" + strings.TrimPrefix(pattern, "./")
|
|
}
|
|
|
|
// firstPassRuns returns the debug "running ..." lines for first-pass
|
|
// go test invocations, which unlike retries have no --run flag.
|
|
func firstPassRuns(out []byte) []string {
|
|
var runs []string
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
if strings.HasPrefix(line, "running test") && !strings.Contains(line, "--run") {
|
|
runs = append(runs, line)
|
|
}
|
|
}
|
|
return runs
|
|
}
|
|
|
|
const passSrc = `package pass_test
|
|
|
|
import "testing"
|
|
|
|
func TestPass(t *testing.T) {}
|
|
`
|
|
|
|
const flakySrc = `package flaky_test
|
|
|
|
import (
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
func TestFlaky(t *testing.T) {
|
|
if os.Getenv("TS_TESTWRAPPER_ATTEMPT") == "1" {
|
|
t.Fatal("failing on the first attempt so the wrapper retries")
|
|
}
|
|
}
|
|
`
|
|
|
|
const failSrc = `package fail_test
|
|
|
|
import "testing"
|
|
|
|
func TestAlwaysFails(t *testing.T) { t.Fatal("nope") }
|
|
`
|
|
|
|
// TestMultiPackage covers several package patterns on one command line:
|
|
// the first pass must be a single go test invocation naming all of
|
|
// them, and results, flake retries, and permanent failures must still be
|
|
// attributed to the right package.
|
|
func TestMultiPackage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := multiPkgDir(t)
|
|
passPkg := writePkg(t, dir, "pass", passSrc)
|
|
flakyPkg := writePkg(t, dir, "flaky", flakySrc)
|
|
failPkg := writePkg(t, dir, "fail", failSrc)
|
|
|
|
cmd := cmdTestwrapper(t, passPkg, flakyPkg, failPkg)
|
|
cmd.Env = append(cmd.Env, "TS_TESTWRAPPER_DEBUG=1")
|
|
out, err := cmd.CombinedOutput()
|
|
if code, ok := errExitCode(err); !ok || code != 1 {
|
|
t.Fatalf("got exit code %d, want 1 (err: %v) with output:\n%s", code, err, out)
|
|
}
|
|
|
|
runs := firstPassRuns(out)
|
|
if len(runs) != 1 {
|
|
t.Errorf("got %d first-pass go test invocations, want 1:\n%s\noutput:\n%s", len(runs), strings.Join(runs, "\n"), out)
|
|
} else {
|
|
for _, p := range []string{passPkg, flakyPkg, failPkg} {
|
|
if !strings.Contains(runs[0], " "+p+" ") {
|
|
t.Errorf("first-pass invocation %q lacks package %q", runs[0], p)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, want := range []string{
|
|
"ok\t" + importPath(passPkg),
|
|
"FAIL\t" + importPath(flakyPkg), // first pass fails; the retry is per test
|
|
"FAIL\t" + importPath(failPkg),
|
|
"flakytest failures JSON:",
|
|
"permanent test failures JSON:",
|
|
} {
|
|
if !bytes.Contains(out, []byte(want)) {
|
|
t.Errorf("output lacks %q:\n%s", want, out)
|
|
}
|
|
}
|
|
flakyLine, permLine := jsonLines(out)
|
|
if !strings.Contains(flakyLine, importPath(flakyPkg)) || !strings.Contains(flakyLine, "TestFlaky") {
|
|
t.Errorf("flaky failures JSON %q should name %s TestFlaky", flakyLine, importPath(flakyPkg))
|
|
}
|
|
if !strings.Contains(permLine, importPath(failPkg)) || !strings.Contains(permLine, "TestAlwaysFails") {
|
|
t.Errorf("permanent failures JSON %q should name %s TestAlwaysFails", permLine, importPath(failPkg))
|
|
}
|
|
if strings.Contains(permLine, "TestFlaky") || strings.Contains(flakyLine, "TestAlwaysFails") {
|
|
t.Errorf("failures attributed to the wrong list:\nflaky: %s\npermanent: %s", flakyLine, permLine)
|
|
}
|
|
}
|
|
|
|
// jsonLines returns the flakytest and permanent failures JSON lines
|
|
// from testwrapper output, or "" for each that is absent.
|
|
func jsonLines(out []byte) (flaky, permanent string) {
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
if strings.HasPrefix(line, "flakytest failures JSON:") {
|
|
flaky = line
|
|
}
|
|
if strings.HasPrefix(line, "permanent test failures JSON:") {
|
|
permanent = line
|
|
}
|
|
}
|
|
return flaky, permanent
|
|
}
|
|
|
|
// TestMultiPackageBuildError covers a build error in one package of a
|
|
// batch: the other packages still run and report, and the wrapper
|
|
// exits with go test's exit code.
|
|
func TestMultiPackageBuildError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := multiPkgDir(t)
|
|
passPkg := writePkg(t, dir, "pass", passSrc)
|
|
brokenPkg := writePkg(t, dir, "broken", "package broken_test\n\nderp\n")
|
|
|
|
out, err := cmdTestwrapper(t, passPkg, brokenPkg).CombinedOutput()
|
|
if code, ok := errExitCode(err); !ok || code != 1 {
|
|
t.Fatalf("got exit code %d, want 1 (err: %v) with output:\n%s", code, err, out)
|
|
}
|
|
for _, want := range []string{
|
|
"ok\t" + importPath(passPkg),
|
|
"expected declaration, found derp",
|
|
} {
|
|
if !bytes.Contains(out, []byte(want)) {
|
|
t.Errorf("output lacks %q:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMultiFile covers several .go file arguments: each is its own
|
|
// "command-line-arguments" package, so each gets its own first-pass
|
|
// invocation and retries target the right file.
|
|
func TestMultiFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
fileA := filepath.Join(dir, "a_test.go")
|
|
fileB := filepath.Join(dir, "b_test.go")
|
|
if err := os.WriteFile(fileA, []byte(passSrc), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(fileB, []byte(flakySrc), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cmd := cmdTestwrapper(t, fileA, fileB)
|
|
cmd.Env = append(cmd.Env, "TS_TESTWRAPPER_DEBUG=1")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("testwrapper: %v with output:\n%s", err, out)
|
|
}
|
|
if runs := firstPassRuns(out); len(runs) != 2 {
|
|
t.Errorf("got %d first-pass go test invocations, want 2 (one per file):\n%s\noutput:\n%s", len(runs), strings.Join(runs, "\n"), out)
|
|
}
|
|
wantRetry := fileB + " --run ^(TestFlaky)$"
|
|
if !bytes.Contains(out, []byte(wantRetry)) {
|
|
t.Errorf("output lacks retry of %q:\n%s", wantRetry, out)
|
|
}
|
|
if bytes.Contains(out, []byte("permanent test failures JSON:")) {
|
|
t.Errorf("unexpected permanent failures:\n%s", out)
|
|
}
|
|
}
|