diff --git a/api/next/71287.txt b/api/next/71287.txt new file mode 100644 index 0000000000..c1e09a1f52 --- /dev/null +++ b/api/next/71287.txt @@ -0,0 +1,4 @@ +pkg testing, method (*B) ArtifactDir() string #71287 +pkg testing, method (*F) ArtifactDir() string #71287 +pkg testing, method (*T) ArtifactDir() string #71287 +pkg testing, type TB interface, ArtifactDir() string #71287 diff --git a/doc/next/6-stdlib/99-minor/testing/71287.md b/doc/next/6-stdlib/99-minor/testing/71287.md new file mode 100644 index 0000000000..82cac63810 --- /dev/null +++ b/doc/next/6-stdlib/99-minor/testing/71287.md @@ -0,0 +1,18 @@ +The new methods [T.ArtifactDir], [B.ArtifactDir], and [F.ArtifactDir] +return a directory in which to write test output files (artifacts). + +When the `-artifacts` flag is provided to `go test`, +this directory will be located under the output directory +(specified with `-outputdir`, or the current directory by default). +Otherwise, artifacts are stored in a temporary directory +which is removed after the test completes. + +The first call to `ArtifactDir` when `-artifacts` is provided +writes the location of the directory to the test log. + +For example, in a test named `TestArtifacts`, +`t.ArtifactDir()` emits: + +``` +=== ARTIFACTS Test /path/to/artifact/dir +``` diff --git a/src/cmd/go/alldocs.go b/src/cmd/go/alldocs.go index 19b48f0579..51f2223283 100644 --- a/src/cmd/go/alldocs.go +++ b/src/cmd/go/alldocs.go @@ -3244,6 +3244,10 @@ // The following flags are recognized by the 'go test' command and // control the execution of any test: // +// -artifacts +// Save test artifacts in the directory specified by -outputdir. +// See 'go doc testing.T.ArtifactDir'. +// // -bench regexp // Run only those benchmarks matching a regular expression. // By default, no benchmarks are run. @@ -3338,6 +3342,10 @@ // This will only list top-level tests. No subtest or subbenchmarks will be // shown. // +// -outputdir directory +// Place output files from profiling and test artifacts in the +// specified directory, by default the directory in which "go test" is running. +// // -parallel n // Allow parallel execution of test functions that call t.Parallel, and // fuzz targets that call t.Parallel when running the seed corpus. @@ -3449,10 +3457,6 @@ // Sample 1 in n stack traces of goroutines holding a // contended mutex. // -// -outputdir directory -// Place output files from profiling in the specified directory, -// by default the directory in which "go test" is running. -// // -trace trace.out // Write an execution trace to the specified file before exiting. // diff --git a/src/cmd/go/internal/load/test.go b/src/cmd/go/internal/load/test.go index f895e3a246..9849ee138a 100644 --- a/src/cmd/go/internal/load/test.go +++ b/src/cmd/go/internal/load/test.go @@ -649,6 +649,14 @@ func (t *testFuncs) ImportPath() string { return pkg } +func (t *testFuncs) ModulePath() string { + m := t.Package.Module + if m == nil { + return "" + } + return m.Path +} + // Covered returns a string describing which packages are being tested for coverage. // If the covered package is the same as the tested package, it returns the empty string. // Otherwise it is a comma-separated human-readable list of packages beginning with @@ -836,6 +844,7 @@ func init() { testdeps.CoverMarkProfileEmittedFunc = cfile.MarkProfileEmitted {{end}} + testdeps.ModulePath = {{.ModulePath | printf "%q"}} testdeps.ImportPath = {{.ImportPath | printf "%q"}} } diff --git a/src/cmd/go/internal/test/flagdefs.go b/src/cmd/go/internal/test/flagdefs.go index 8aa0bfc2bf..b8b4bf649e 100644 --- a/src/cmd/go/internal/test/flagdefs.go +++ b/src/cmd/go/internal/test/flagdefs.go @@ -9,6 +9,7 @@ package test // passFlagToTest contains the flags that should be forwarded to // the test binary with the prefix "test.". var passFlagToTest = map[string]bool{ + "artifacts": true, "bench": true, "benchmem": true, "benchtime": true, diff --git a/src/cmd/go/internal/test/test.go b/src/cmd/go/internal/test/test.go index 7a2963ff29..15ffc618c6 100644 --- a/src/cmd/go/internal/test/test.go +++ b/src/cmd/go/internal/test/test.go @@ -192,6 +192,10 @@ and -show_bytes options of pprof control how the information is presented. The following flags are recognized by the 'go test' command and control the execution of any test: + -artifacts + Save test artifacts in the directory specified by -outputdir. + See 'go doc testing.T.ArtifactDir'. + -bench regexp Run only those benchmarks matching a regular expression. By default, no benchmarks are run. @@ -286,6 +290,10 @@ control the execution of any test: This will only list top-level tests. No subtest or subbenchmarks will be shown. + -outputdir directory + Place output files from profiling and test artifacts in the + specified directory, by default the directory in which "go test" is running. + -parallel n Allow parallel execution of test functions that call t.Parallel, and fuzz targets that call t.Parallel when running the seed corpus. @@ -397,10 +405,6 @@ profile the tests during execution: Sample 1 in n stack traces of goroutines holding a contended mutex. - -outputdir directory - Place output files from profiling in the specified directory, - by default the directory in which "go test" is running. - -trace trace.out Write an execution trace to the specified file before exiting. @@ -540,6 +544,7 @@ See the documentation of the testing package for more information. } var ( + testArtifacts bool // -artifacts flag testBench string // -bench flag testC bool // -c flag testCoverPkgs []*load.Package // -coverpkg flag diff --git a/src/cmd/go/internal/test/testflag.go b/src/cmd/go/internal/test/testflag.go index 983e8f56e9..fc2b22cb56 100644 --- a/src/cmd/go/internal/test/testflag.go +++ b/src/cmd/go/internal/test/testflag.go @@ -44,6 +44,7 @@ func init() { // some of them so that cmd/go knows what to do with the test output, or knows // to build the test in a way that supports the use of the flag. + cf.BoolVar(&testArtifacts, "artifacts", false, "") cf.StringVar(&testBench, "bench", "", "") cf.Bool("benchmem", false, "") cf.String("benchtime", "", "") @@ -392,7 +393,8 @@ func testFlags(args []string) (packageNames, passToTest []string) { // directory, but 'go test' defaults it to the working directory of the 'go' // command. Set it explicitly if it is needed due to some other flag that // requests output. - if testProfile() != "" && !outputDirSet { + needOutputDir := testProfile() != "" || testArtifacts + if needOutputDir && !outputDirSet { injectedFlags = append(injectedFlags, "-test.outputdir="+testOutputDir.getAbs()) } diff --git a/src/cmd/internal/test2json/test2json.go b/src/cmd/internal/test2json/test2json.go index d08ef389f8..f28051e177 100644 --- a/src/cmd/internal/test2json/test2json.go +++ b/src/cmd/internal/test2json/test2json.go @@ -38,6 +38,7 @@ type event struct { FailedBuild string `json:",omitempty"` Key string `json:",omitempty"` Value string `json:",omitempty"` + Path string `json:",omitempty"` } // textBytes is a hack to get JSON to emit a []byte as a string @@ -180,6 +181,7 @@ var ( []byte("=== FAIL "), []byte("=== SKIP "), []byte("=== ATTR "), + []byte("=== ARTIFACTS "), } reports = [][]byte{ @@ -251,7 +253,6 @@ func (c *Converter) handleInputLine(line []byte) { // "=== RUN " // "=== PAUSE " // "=== CONT " - actionColon := false origLine := line ok := false indent := 0 @@ -273,7 +274,6 @@ func (c *Converter) handleInputLine(line []byte) { } for _, magic := range reports { if bytes.HasPrefix(line, magic) { - actionColon = true ok = true break } @@ -296,16 +296,11 @@ func (c *Converter) handleInputLine(line []byte) { return } - // Parse out action and test name. - i := 0 - if actionColon { - i = bytes.IndexByte(line, ':') + 1 - } - if i == 0 { - i = len(updates[0]) - } - action := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(string(line[4:i])), ":")) - name := strings.TrimSpace(string(line[i:])) + // Parse out action and test name from "=== ACTION: Name". + action, name, _ := strings.Cut(string(line[len("=== "):]), " ") + action = strings.TrimSuffix(action, ":") + action = strings.ToLower(action) + name = strings.TrimSpace(name) e := &event{Action: action} if line[0] == '-' { // PASS or FAIL report @@ -336,7 +331,10 @@ func (c *Converter) handleInputLine(line []byte) { c.output.write(origLine) return } - if action == "attr" { + switch action { + case "artifacts": + name, e.Path, _ = strings.Cut(name, " ") + case "attr": var rest string name, rest, _ = strings.Cut(name, " ") e.Key, e.Value, _ = strings.Cut(rest, " ") diff --git a/src/testing/internal/testdeps/deps.go b/src/testing/internal/testdeps/deps.go index 6f42d4722c..5ab377daeb 100644 --- a/src/testing/internal/testdeps/deps.go +++ b/src/testing/internal/testdeps/deps.go @@ -66,6 +66,12 @@ func (TestDeps) ImportPath() string { return ImportPath } +var ModulePath string + +func (TestDeps) ModulePath() string { + return ModulePath +} + // testLog implements testlog.Interface, logging actions by package os. type testLog struct { mu sync.Mutex diff --git a/src/testing/testing.go b/src/testing/testing.go index 3f76446549..0d1d08ca89 100644 --- a/src/testing/testing.go +++ b/src/testing/testing.go @@ -420,7 +420,6 @@ import ( "sync/atomic" "time" "unicode" - "unicode/utf8" _ "unsafe" // for linkname ) @@ -456,6 +455,7 @@ func Init() { // this flag lets "go test" tell the binary to write the files in the directory where // the "go test" command is run. outputDir = flag.String("test.outputdir", "", "write profiles to `dir`") + artifacts = flag.Bool("test.artifacts", false, "store test artifacts in test.,outputdir") // Report as tests are run; default is silent for success. flag.Var(&chatty, "test.v", "verbose: print additional output") count = flag.Uint("test.count", 1, "run tests and benchmarks `n` times") @@ -489,6 +489,7 @@ var ( short *bool failFast *bool outputDir *string + artifacts *bool chatty chattyFlag count *uint coverProfile *string @@ -516,6 +517,7 @@ var ( cpuList []int testlogFile *os.File + artifactDir string numFailed atomic.Uint32 // number of test failures @@ -653,15 +655,17 @@ type common struct { runner string // Function name of tRunner running the test. isParallel bool // Whether the test is parallel. - parent *common - level int // Nesting depth of test or benchmark. - creator []uintptr // If level > 0, the stack trace at the point where the parent called t.Run. - name string // Name of test or benchmark. - start highPrecisionTime // Time test or benchmark started - duration time.Duration - barrier chan bool // To signal parallel subtests they may start. Nil when T.Parallel is not present (B) or not usable (when fuzzing). - signal chan bool // To signal a test is done. - sub []*T // Queue of subtests to be run in parallel. + parent *common + level int // Nesting depth of test or benchmark. + creator []uintptr // If level > 0, the stack trace at the point where the parent called t.Run. + modulePath string + importPath string + name string // Name of test or benchmark. + start highPrecisionTime // Time test or benchmark started + duration time.Duration + barrier chan bool // To signal parallel subtests they may start. Nil when T.Parallel is not present (B) or not usable (when fuzzing). + signal chan bool // To signal a test is done. + sub []*T // Queue of subtests to be run in parallel. lastRaceErrors atomic.Int64 // Max value of race.Errors seen during the test or its subtests. raceErrorLogged atomic.Bool @@ -671,6 +675,10 @@ type common struct { tempDirErr error tempDirSeq int32 + artifactDirOnce sync.Once + artifactDir string + artifactDirErr error + ctx context.Context cancelCtx context.CancelFunc } @@ -879,6 +887,7 @@ func fmtDuration(d time.Duration) string { // TB is the interface common to [T], [B], and [F]. type TB interface { + ArtifactDir() string Attr(key, value string) Cleanup(func()) Error(args ...any) @@ -1313,6 +1322,96 @@ func (c *common) Cleanup(f func()) { c.cleanups = append(c.cleanups, fn) } +// ArtifactDir returns a directory in which the test should store output files. +// When the -artifacts flag is provided, this directory is located +// under the output directory. Otherwise, ArtifactDir returns a temporary directory +// that is removed after the test completes. +// +// Each test or subtest within each test package has a unique artifact directory. +// Repeated calls to ArtifactDir in the same test or subtest return the same directory. +// Subtest outputs are not located under the parent test's output directory. +func (c *common) ArtifactDir() string { + c.checkFuzzFn("ArtifactDir") + c.artifactDirOnce.Do(func() { + c.artifactDir, c.artifactDirErr = c.makeArtifactDir() + }) + if c.artifactDirErr != nil { + c.Fatalf("ArtifactDir: %v", c.artifactDirErr) + } + return c.artifactDir +} + +func hashString(s string) (h uint64) { + // FNV, used here to avoid a dependency on maphash. + for i := 0; i < len(s); i++ { + h ^= uint64(s[i]) + h *= 1099511628211 + } + return +} + +// makeArtifactDir creates the artifact directory for a test. +// The artifact directory is: +// +// /_artifacts/// +// +// The test package is the package import path with the module name prefix removed. +// The test name is truncated if too long. +// Special characters are removed from the path. +func (c *common) makeArtifactDir() (string, error) { + if !*artifacts { + return c.makeTempDir() + } + + // If the test name is longer than maxNameSize, truncate it and replace the last + // hashSize bytes with a hash of the full name. + const maxNameSize = 64 + name := strings.ReplaceAll(c.name, "/", "__") + if len(name) > maxNameSize { + h := fmt.Sprintf("%0x", hashString(name)) + name = name[:maxNameSize-len(h)] + h + } + + // Remove the module path prefix from the import path. + pkg := strings.TrimPrefix(c.importPath, c.modulePath+"/") + + // Join with /, not filepath.Join: the import path is /-separated, + // and we don't want removeSymbolsExcept to strip \ separators on Windows. + base := "/" + pkg + "/" + name + base = removeSymbolsExcept(base, "!#$%&()+,-.=@^_{}~ /") + base, err := filepath.Localize(base) + if err != nil { + // This name can't be safely converted into a local filepath. + // Drop it and just use _artifacts/. + base = "" + } + + artifactBase := filepath.Join(artifactDir, base) + if err := os.MkdirAll(artifactBase, 0o777); err != nil { + return "", err + } + dir, err := os.MkdirTemp(artifactBase, "") + if err != nil { + return "", err + } + if c.chatty != nil { + c.chatty.Updatef(c.name, "=== ARTIFACTS %s %v\n", c.name, dir) + } + return dir, nil +} + +func removeSymbolsExcept(s, allowed string) string { + mapper := func(r rune) rune { + if unicode.IsLetter(r) || + unicode.IsNumber(r) || + strings.ContainsRune(allowed, r) { + return r + } + return -1 // disallowed symbol + } + return strings.Map(mapper, s) +} + // TempDir returns a temporary directory for the test to use. // The directory is automatically removed when the test and // all its subtests complete. @@ -1322,6 +1421,14 @@ func (c *common) Cleanup(f func()) { // be created somewhere beneath it. func (c *common) TempDir() string { c.checkFuzzFn("TempDir") + dir, err := c.makeTempDir() + if err != nil { + c.Fatalf("TempDir: %v", err) + } + return dir +} + +func (c *common) makeTempDir() (string, error) { // Use a single parent directory for all the temporary directories // created by a test, each numbered sequentially. c.tempDirMu.Lock() @@ -1332,7 +1439,7 @@ func (c *common) TempDir() string { _, err := os.Stat(c.tempDir) nonExistent = os.IsNotExist(err) if err != nil && !nonExistent { - c.Fatalf("TempDir: %v", err) + return "", err } } @@ -1347,23 +1454,9 @@ func (c *common) TempDir() string { // Drop unusual characters (such as path separators or // characters interacting with globs) from the directory name to // avoid surprising os.MkdirTemp behavior. - mapper := func(r rune) rune { - if r < utf8.RuneSelf { - const allowed = "!#$%&()+,-.=@^_{}~ " - if '0' <= r && r <= '9' || - 'a' <= r && r <= 'z' || - 'A' <= r && r <= 'Z' { - return r - } - if strings.ContainsRune(allowed, r) { - return r - } - } else if unicode.IsLetter(r) || unicode.IsNumber(r) { - return r - } - return -1 - } - pattern = strings.Map(mapper, pattern) + const allowed = "!#$%&()+,-.=@^_{}~ " + pattern = removeSymbolsExcept(pattern, allowed) + c.tempDir, c.tempDirErr = os.MkdirTemp(os.Getenv("GOTMPDIR"), pattern) if c.tempDirErr == nil { c.Cleanup(func() { @@ -1381,14 +1474,14 @@ func (c *common) TempDir() string { c.tempDirMu.Unlock() if c.tempDirErr != nil { - c.Fatalf("TempDir: %v", c.tempDirErr) + return "", c.tempDirErr } dir := fmt.Sprintf("%s%c%03d", c.tempDir, os.PathSeparator, seq) if err := os.Mkdir(dir, 0o777); err != nil { - c.Fatalf("TempDir: %v", err) + return "", err } - return dir + return dir, nil } // removeAll is like os.RemoveAll, but retries Windows "Access is denied." @@ -1971,15 +2064,17 @@ func (t *T) Run(name string, f func(t *T)) bool { ctx, cancelCtx := context.WithCancel(context.Background()) t = &T{ common: common{ - barrier: make(chan bool), - signal: make(chan bool, 1), - name: testName, - parent: &t.common, - level: t.level + 1, - creator: pc[:n], - chatty: t.chatty, - ctx: ctx, - cancelCtx: cancelCtx, + barrier: make(chan bool), + signal: make(chan bool, 1), + name: testName, + modulePath: t.modulePath, + importPath: t.importPath, + parent: &t.common, + level: t.level + 1, + creator: pc[:n], + chatty: t.chatty, + ctx: ctx, + cancelCtx: cancelCtx, }, tstate: t.tstate, } @@ -2140,6 +2235,7 @@ func (f matchStringOnly) MatchString(pat, str string) (bool, error) { return f func (f matchStringOnly) StartCPUProfile(w io.Writer) error { return errMain } func (f matchStringOnly) StopCPUProfile() {} func (f matchStringOnly) WriteProfileTo(string, io.Writer, int) error { return errMain } +func (f matchStringOnly) ModulePath() string { return "" } func (f matchStringOnly) ImportPath() string { return "" } func (f matchStringOnly) StartTestLog(io.Writer) {} func (f matchStringOnly) StopTestLog() error { return errMain } @@ -2193,6 +2289,7 @@ type M struct { // testing/internal/testdeps's TestDeps. type testDeps interface { ImportPath() string + ModulePath() string MatchString(pat, str string) (bool, error) SetPanicOnExit0(bool) StartCPUProfile(io.Writer) error @@ -2336,7 +2433,7 @@ func (m *M) Run() (code int) { if !*isFuzzWorker { deadline := m.startAlarm() haveExamples = len(m.examples) > 0 - testRan, testOk := runTests(m.deps.MatchString, m.tests, deadline) + testRan, testOk := runTests(m.deps.ModulePath(), m.deps.ImportPath(), m.deps.MatchString, m.tests, deadline) fuzzTargetsRan, fuzzTargetsOk := runFuzzTests(m.deps, m.fuzzTargets, deadline) exampleRan, exampleOk := runExamples(m.deps.MatchString, m.examples) m.stopAlarm() @@ -2437,14 +2534,14 @@ func RunTests(matchString func(pat, str string) (bool, error), tests []InternalT if *timeout > 0 { deadline = time.Now().Add(*timeout) } - ran, ok := runTests(matchString, tests, deadline) + ran, ok := runTests("", "", matchString, tests, deadline) if !ran && !haveExamples { fmt.Fprintln(os.Stderr, "testing: warning: no tests to run") } return ok } -func runTests(matchString func(pat, str string) (bool, error), tests []InternalTest, deadline time.Time) (ran, ok bool) { +func runTests(modulePath, importPath string, matchString func(pat, str string) (bool, error), tests []InternalTest, deadline time.Time) (ran, ok bool) { ok = true for _, procs := range cpuList { runtime.GOMAXPROCS(procs) @@ -2463,11 +2560,13 @@ func runTests(matchString func(pat, str string) (bool, error), tests []InternalT tstate.deadline = deadline t := &T{ common: common{ - signal: make(chan bool, 1), - barrier: make(chan bool), - w: os.Stdout, - ctx: ctx, - cancelCtx: cancelCtx, + signal: make(chan bool, 1), + barrier: make(chan bool), + w: os.Stdout, + ctx: ctx, + cancelCtx: cancelCtx, + modulePath: modulePath, + importPath: importPath, }, tstate: tstate, } @@ -2536,6 +2635,18 @@ func (m *M) before() { fmt.Fprintf(os.Stderr, "testing: cannot use -test.gocoverdir because test binary was not built with coverage enabled\n") os.Exit(2) } + if *artifacts { + var err error + artifactDir, err = filepath.Abs(toOutputDir("_artifacts")) + if err != nil { + fmt.Fprintf(os.Stderr, "testing: cannot make -test.outputdir absolute: %v\n", err) + os.Exit(2) + } + if err := os.Mkdir(artifactDir, 0o777); err != nil && !errors.Is(err, os.ErrExist) { + fmt.Fprintf(os.Stderr, "testing: %v\n", err) + os.Exit(2) + } + } if *testlog != "" { // Note: Not using toOutputDir. // This file is for use by cmd/go, not users. diff --git a/src/testing/testing_test.go b/src/testing/testing_test.go index cc89e4144e..167f4a0b45 100644 --- a/src/testing/testing_test.go +++ b/src/testing/testing_test.go @@ -469,7 +469,7 @@ func TestTesting(t *testing.T) { // runTest runs a helper test with -test.v, ignoring its exit status. // runTest both logs and returns the test output. -func runTest(t *testing.T, test string) []byte { +func runTest(t *testing.T, test string, args ...string) []byte { t.Helper() testenv.MustHaveExec(t) @@ -477,6 +477,7 @@ func runTest(t *testing.T, test string) []byte { cmd := testenv.Command(t, testenv.Executable(t), "-test.run=^"+test+"$", "-test.bench="+test, "-test.v", "-test.parallel=2", "-test.benchtime=2x") cmd = testenv.CleanCmdEnv(cmd) cmd.Env = append(cmd.Env, "GO_WANT_HELPER_PROCESS=1") + cmd.Args = append(cmd.Args, args...) out, err := cmd.CombinedOutput() t.Logf("%v: %v\n%s", cmd, err, out) @@ -1055,6 +1056,105 @@ func TestAttrInvalid(t *testing.T) { } } +const artifactContent = "It belongs in a museum.\n" + +func TestArtifactDirExample(t *testing.T) { + os.WriteFile(filepath.Join(t.ArtifactDir(), "artifact"), []byte(artifactContent), 0o666) +} + +func TestArtifactDirDefault(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + out := runTest(t, "TestArtifactDirExample", "-test.artifacts") + checkArtifactDir(t, out, "TestArtifactDirExample", tempDir) +} + +func TestArtifactDirSpecified(t *testing.T) { + tempDir := t.TempDir() + out := runTest(t, "TestArtifactDirExample", "-test.artifacts", "-test.outputdir="+tempDir) + checkArtifactDir(t, out, "TestArtifactDirExample", tempDir) +} + +func TestArtifactDirNoArtifacts(t *testing.T) { + t.Chdir(t.TempDir()) + out := string(runTest(t, "TestArtifactDirExample")) + if strings.Contains(out, "=== ARTIFACTS") { + t.Errorf("expected output with no === ARTIFACTS, got\n%q", out) + } + ents, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, e := range ents { + t.Errorf("unexpected file in current directory after test: %v", e.Name()) + } +} + +func TestArtifactDirSubtestExample(t *testing.T) { + t.Run("Subtest", func(t *testing.T) { + os.WriteFile(filepath.Join(t.ArtifactDir(), "artifact"), []byte(artifactContent), 0o666) + }) +} + +func TestArtifactDirInSubtest(t *testing.T) { + tempDir := t.TempDir() + out := runTest(t, "TestArtifactDirSubtestExample/Subtest", "-test.artifacts", "-test.outputdir="+tempDir) + checkArtifactDir(t, out, "TestArtifactDirSubtestExample/Subtest", tempDir) +} + +func TestArtifactDirLongTestNameExample(t *testing.T) { + name := strings.Repeat("x", 256) + t.Run(name, func(t *testing.T) { + os.WriteFile(filepath.Join(t.ArtifactDir(), "artifact"), []byte(artifactContent), 0o666) + }) +} + +func TestArtifactDirWithLongTestName(t *testing.T) { + tempDir := t.TempDir() + out := runTest(t, "TestArtifactDirLongTestNameExample", "-test.artifacts", "-test.outputdir="+tempDir) + checkArtifactDir(t, out, `TestArtifactDirLongTestNameExample/\w+`, tempDir) +} + +func TestArtifactDirConsistent(t *testing.T) { + a := t.ArtifactDir() + b := t.ArtifactDir() + if a != b { + t.Errorf("t.ArtifactDir is not consistent between calls: %q, %q", a, b) + } +} + +func checkArtifactDir(t *testing.T, out []byte, testName, outputDir string) { + t.Helper() + + re := regexp.MustCompile(`=== ARTIFACTS ` + testName + ` ([^\n]+)`) + match := re.FindSubmatch(out) + if match == nil { + t.Fatalf("expected output matching %q, got\n%q", re, out) + } + artifactDir := string(match[1]) + + // Verify that the artifact directory is contained in the expected output directory. + relDir, err := filepath.Rel(outputDir, artifactDir) + if err != nil { + t.Fatal(err) + } + if !filepath.IsLocal(relDir) { + t.Fatalf("want artifact directory contained in %q, got %q", outputDir, artifactDir) + } + + for _, part := range strings.Split(relDir, string(os.PathSeparator)) { + const maxSize = 64 + if len(part) > maxSize { + t.Errorf("artifact directory %q contains component >%v characters long: %q", relDir, maxSize, part) + } + } + + got, err := os.ReadFile(filepath.Join(artifactDir, "artifact")) + if err != nil || string(got) != artifactContent { + t.Errorf("reading artifact in %q: got %q, %v; want %q", artifactDir, got, err, artifactContent) + } +} + func TestBenchmarkBLoopIterationCorrect(t *testing.T) { out := runTest(t, "BenchmarkBLoopPrint") c := bytes.Count(out, []byte("Printing from BenchmarkBLoopPrint")) @@ -1110,3 +1210,7 @@ func BenchmarkBNPrint(b *testing.B) { b.Logf("Printing from BenchmarkBNPrint") } } + +func TestArtifactDir(t *testing.T) { + t.Log(t.ArtifactDir()) +}