package gotest import ( "strings" "time" "github.com/jstemmer/go-junit-report/v2/gtr" ) // reportBuilder helps build a test Report from a collection of events. // // The reportBuilder keeps track of the active context whenever a test, // benchmark or build error is created. This is necessary because the test // parser do not contain any state themselves and simply just emit an event for // every line that is read. By tracking the active context, any output that is // appended to the reportBuilder gets attributed to the correct test, benchmark // or build error. type reportBuilder struct { packages []gtr.Package tests map[int]gtr.Test benchmarks map[int]gtr.Benchmark buildErrors map[int]gtr.Error runErrors map[int]gtr.Error // state nextID int // next free unused id lastID int // most recently created id output []string // output that does not belong to any test coverage float64 // coverage percentage parentIDs map[int]struct{} // set of test id's that contain subtests // options packageName string subtestMode SubtestMode timestampFunc func() time.Time } // newReportBuilder creates a new reportBuilder. func newReportBuilder() *reportBuilder { return &reportBuilder{ tests: make(map[int]gtr.Test), benchmarks: make(map[int]gtr.Benchmark), buildErrors: make(map[int]gtr.Error), runErrors: make(map[int]gtr.Error), nextID: 1, parentIDs: make(map[int]struct{}), timestampFunc: time.Now, } } // newID returns a new unique id and sets the active context this id. func (b *reportBuilder) newID() int { id := b.nextID b.lastID = id b.nextID += 1 return id } // flush creates a new package in this report containing any tests or // benchmarks we've collected so far. This is necessary when a test or // benchmark did not end with a summary. func (b *reportBuilder) flush() { if len(b.tests) > 0 || len(b.benchmarks) > 0 { b.CreatePackage(b.packageName, "", 0, "") } } // Build returns the new Report containing all the tests, benchmarks and output // created so far. func (b *reportBuilder) Build() gtr.Report { b.flush() return gtr.Report{Packages: b.packages} } // CreateTest adds a test with the given name to the report, and marks it as // active. func (b *reportBuilder) CreateTest(name string) { if parentID, ok := b.findTestParentID(name); ok { b.parentIDs[parentID] = struct{}{} } b.tests[b.newID()] = gtr.Test{Name: name} } // PauseTest marks the active context as no longer active. Any results or // output added to the report after calling PauseTest will no longer be assumed // to belong to this test. func (b *reportBuilder) PauseTest(name string) { b.lastID = 0 } // ContinueTest finds the test with the given name and marks it as active. If // more than one test exist with this name, the most recently created test will // be used. func (b *reportBuilder) ContinueTest(name string) { b.lastID, _ = b.findTest(name) } // EndTest finds the test with the given name, sets the result, duration and // level. If more than one test exists with this name, the most recently // created test will be used. If no test exists with this name, a new test is // created. func (b *reportBuilder) EndTest(name, result string, duration time.Duration, level int) { id, ok := b.findTest(name) if !ok { // test did not exist, create one // TODO: Likely reason is that the user ran go test without the -v // flag, should we report this somewhere? b.CreateTest(name) id = b.lastID } t := b.tests[id] t.Result = parseResult(result) t.Duration = duration t.Level = level b.tests[id] = t b.lastID = 0 } // End marks the active context as no longer active. func (b *reportBuilder) End() { b.lastID = 0 } // CreateBenchmark adds a benchmark with the given name to the report, and // marks it as active. If more than one benchmark exists with this name, the // most recently created benchmark will be updated. If no benchmark exists with // this name, a new benchmark is created. func (b *reportBuilder) CreateBenchmark(name string) { b.benchmarks[b.newID()] = gtr.Benchmark{ Name: name, } } // BenchmarkResult updates an existing or adds a new benchmark with the given // results and marks it as active. If an existing benchmark with this name // exists but without result, then that one is updated. Otherwise a new one is // added to the report. func (b *reportBuilder) BenchmarkResult(name string, iterations int64, nsPerOp, mbPerSec float64, bytesPerOp, allocsPerOp int64) { id, ok := b.findBenchmark(name) if !ok || b.benchmarks[id].Result != gtr.Unknown { b.CreateBenchmark(name) id = b.lastID } b.benchmarks[id] = gtr.Benchmark{ Name: name, Result: gtr.Pass, Iterations: iterations, NsPerOp: nsPerOp, MBPerSec: mbPerSec, BytesPerOp: bytesPerOp, AllocsPerOp: allocsPerOp, } } // EndBenchmark finds the benchmark with the given name and sets the result. If // more than one benchmark exists with this name, the most recently created // benchmark will be used. If no benchmark exists with this name, a new // benchmark is created. func (b *reportBuilder) EndBenchmark(name, result string) { id, ok := b.findBenchmark(name) if !ok { b.CreateBenchmark(name) id = b.lastID } bm := b.benchmarks[id] bm.Result = parseResult(result) b.benchmarks[id] = bm b.lastID = 0 } // CreateBuildError creates a new build error and marks it as active. func (b *reportBuilder) CreateBuildError(packageName string) { b.buildErrors[b.newID()] = gtr.Error{Name: packageName} } // CreatePackage adds a new package with the given name to the Report. This // package contains all the build errors, output, tests and benchmarks created // so far. Afterwards all state is reset. func (b *reportBuilder) CreatePackage(name, result string, duration time.Duration, data string) { pkg := gtr.Package{ Name: name, Duration: duration, } if b.timestampFunc != nil { pkg.Timestamp = b.timestampFunc() } // Build errors are treated somewhat differently. Rather than having a // single package with all build errors collected so far, we only care // about the build errors for this particular package. for id, buildErr := range b.buildErrors { if buildErr.Name == name { if len(b.tests) > 0 || len(b.benchmarks) > 0 { panic("unexpected tests and/or benchmarks found in build error package") } buildErr.Duration = duration buildErr.Cause = data pkg.BuildError = buildErr b.packages = append(b.packages, pkg) delete(b.buildErrors, id) // TODO: reset state // TODO: buildErrors shouldn't reset/use nextID/lastID, they're more like a global cache return } } // If we've collected output, but there were no tests or benchmarks then // either there were no tests, or there was some other non-build error. if len(b.output) > 0 && len(b.tests) == 0 && len(b.benchmarks) == 0 { if parseResult(result) == gtr.Fail { pkg.RunError = gtr.Error{ Name: name, Output: b.output, } } b.packages = append(b.packages, pkg) // TODO: reset state b.output = nil return } // If the summary result says we failed, but there were no failing tests // then something else must have failed. if parseResult(result) == gtr.Fail && (len(b.tests) > 0 || len(b.benchmarks) > 0) && !b.containsFailingTest() { pkg.RunError = gtr.Error{ Name: name, Output: b.output, } b.output = nil } // Collect tests and benchmarks for this package, maintaining insertion order. var tests []gtr.Test var benchmarks []gtr.Benchmark for id := 1; id < b.nextID; id++ { if t, ok := b.tests[id]; ok { if b.isParent(id) { if b.subtestMode == IgnoreParentResults { t.Result = gtr.Pass } else if b.subtestMode == ExcludeParents { continue } } tests = append(tests, t) continue } if bm, ok := b.benchmarks[id]; ok { benchmarks = append(benchmarks, bm) continue } } pkg.Coverage = b.coverage pkg.Output = b.output pkg.Tests = tests pkg.Benchmarks = groupBenchmarksByName(benchmarks) b.packages = append(b.packages, pkg) // reset state, except for nextID to ensure all id's are unique. b.lastID = 0 b.output = nil b.coverage = 0 b.tests = make(map[int]gtr.Test) b.benchmarks = make(map[int]gtr.Benchmark) b.parentIDs = make(map[int]struct{}) } // Coverage sets the code coverage percentage. func (b *reportBuilder) Coverage(pct float64, packages []string) { b.coverage = pct } // AppendOutput appends the given line to the currently active context. If no // active context exists, the output is assumed to belong to the package. func (b *reportBuilder) AppendOutput(line string) { if b.lastID <= 0 { b.output = append(b.output, line) return } if t, ok := b.tests[b.lastID]; ok { t.Output = append(t.Output, line) b.tests[b.lastID] = t } else if bm, ok := b.benchmarks[b.lastID]; ok { bm.Output = append(bm.Output, line) b.benchmarks[b.lastID] = bm } else if be, ok := b.buildErrors[b.lastID]; ok { be.Output = append(be.Output, line) b.buildErrors[b.lastID] = be } else { b.output = append(b.output, line) } } // findTest returns the id of the most recently created test with the given // name if it exists. func (b *reportBuilder) findTest(name string) (int, bool) { // check if this test was lastID if t, ok := b.tests[b.lastID]; ok && t.Name == name { return b.lastID, true } for i := b.nextID; i >= 0; i-- { if test, ok := b.tests[i]; ok && test.Name == name { return i, true } } return 0, false } func (b *reportBuilder) findTestParentID(name string) (int, bool) { parent := dropLastSegment(name) for parent != "" { if id, ok := b.findTest(parent); ok { return id, true } parent = dropLastSegment(parent) } return 0, false } func (b *reportBuilder) isParent(id int) bool { _, ok := b.parentIDs[id] return ok } func dropLastSegment(name string) string { if idx := strings.LastIndexByte(name, '/'); idx >= 0 { return name[:idx] } return "" } // findBenchmark returns the id of the most recently created benchmark with the // given name if it exists. func (b *reportBuilder) findBenchmark(name string) (int, bool) { // check if this benchmark was lastID if bm, ok := b.benchmarks[b.lastID]; ok && bm.Name == name { return b.lastID, true } for id := len(b.benchmarks); id >= 0; id-- { if b.benchmarks[id].Name == name { return id, true } } return 0, false } // containsFailingTest return true if the current list of tests contains at // least one failing test or an unknown result. func (b *reportBuilder) containsFailingTest() bool { for _, test := range b.tests { if test.Result == gtr.Fail || test.Result == gtr.Unknown { return true } } return false } // parseResult returns a Result for the given string r. func parseResult(r string) gtr.Result { switch r { case "PASS": return gtr.Pass case "FAIL": return gtr.Fail case "SKIP": return gtr.Skip case "BENCH": return gtr.Pass default: return gtr.Unknown } } func groupBenchmarksByName(benchmarks []gtr.Benchmark) []gtr.Benchmark { if len(benchmarks) == 0 { return nil } var grouped []gtr.Benchmark byName := make(map[string][]gtr.Benchmark) for _, bm := range benchmarks { if _, ok := byName[bm.Name]; !ok { grouped = append(grouped, gtr.Benchmark{Name: bm.Name}) } byName[bm.Name] = append(byName[bm.Name], bm) } for i, group := range grouped { count := 0 for _, bm := range byName[group.Name] { if bm.Result != gtr.Pass { continue } group.Iterations += bm.Iterations group.NsPerOp += bm.NsPerOp group.MBPerSec += bm.MBPerSec group.BytesPerOp += bm.BytesPerOp group.AllocsPerOp += bm.AllocsPerOp count++ } group.Result = groupResults(byName[group.Name]) if count > 0 { group.NsPerOp /= float64(count) group.MBPerSec /= float64(count) group.BytesPerOp /= int64(count) group.AllocsPerOp /= int64(count) } grouped[i] = group } return grouped } func groupResults(benchmarks []gtr.Benchmark) gtr.Result { var result gtr.Result for _, bm := range benchmarks { if bm.Result == gtr.Fail { return gtr.Fail } if result != gtr.Pass { result = bm.Result } } return result }