mirror of
https://github.com/jstemmer/go-junit-report.git
synced 2025-04-04 20:50:14 -05:00

The reportBuilder assumed we were always processing events for a single package at a time. This is not true however when running `go test -json -race` for example. In order to properly support processing events from different packages we now have packageBuilders per package name. Fixes #134
486 lines
14 KiB
Go
486 lines
14 KiB
Go
package gotest
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jstemmer/go-junit-report/v2/gtr"
|
|
"github.com/jstemmer/go-junit-report/v2/parser/gotest/internal/collector"
|
|
)
|
|
|
|
const (
|
|
globalID = 0
|
|
)
|
|
|
|
// reportBuilder helps build a test Report from a collection of events.
|
|
//
|
|
// The reportBuilder delegates to the packageBuilder for creating packages from
|
|
// basic test events, but keeps track of build errors itself. The reportBuilder
|
|
// is also responsible for generating unique test id's.
|
|
//
|
|
// Test output is collected by the output collector, which also keeps track of
|
|
// the currently active test so output is automatically associated with the
|
|
// correct test.
|
|
type reportBuilder struct {
|
|
packageBuilders map[string]*packageBuilder
|
|
buildErrors map[int]gtr.Error
|
|
|
|
nextID int // next free unused id
|
|
output *collector.Output // output collected for each id
|
|
packages []gtr.Package // completed packages
|
|
|
|
// options
|
|
packageName string
|
|
subtestMode SubtestMode
|
|
timestampFunc func() time.Time
|
|
}
|
|
|
|
// newReportBuilder creates a new reportBuilder.
|
|
func newReportBuilder() *reportBuilder {
|
|
return &reportBuilder{
|
|
packageBuilders: make(map[string]*packageBuilder),
|
|
buildErrors: make(map[int]gtr.Error),
|
|
nextID: 1,
|
|
output: collector.New(),
|
|
timestampFunc: time.Now,
|
|
}
|
|
}
|
|
|
|
// getPackageBuilder returns the packageBuilder for the given packageName. If
|
|
// no packageBuilder exists for the given package, a new one is created.
|
|
func (b *reportBuilder) getPackageBuilder(packageName string) *packageBuilder {
|
|
pb, ok := b.packageBuilders[packageName]
|
|
if !ok {
|
|
output := b.output
|
|
if packageName != "" {
|
|
output = collector.New()
|
|
}
|
|
pb = newPackageBuilder(b.generateID, output)
|
|
b.packageBuilders[packageName] = pb
|
|
}
|
|
return pb
|
|
}
|
|
|
|
// ProcessEvent takes a test event and adds it to the report.
|
|
func (b *reportBuilder) ProcessEvent(ev Event) {
|
|
switch ev.Type {
|
|
case "run_test":
|
|
b.getPackageBuilder(ev.Package).CreateTest(ev.Name)
|
|
case "pause_test":
|
|
b.getPackageBuilder(ev.Package).PauseTest(ev.Name)
|
|
case "cont_test":
|
|
b.getPackageBuilder(ev.Package).ContinueTest(ev.Name)
|
|
case "end_test":
|
|
b.getPackageBuilder(ev.Package).EndTest(ev.Name, ev.Result, ev.Duration, ev.Indent)
|
|
case "run_benchmark":
|
|
b.getPackageBuilder(ev.Package).CreateTest(ev.Name)
|
|
case "benchmark":
|
|
b.getPackageBuilder(ev.Package).BenchmarkResult(ev.Name, ev.Iterations, ev.NsPerOp, ev.MBPerSec, ev.BytesPerOp, ev.AllocsPerOp)
|
|
case "end_benchmark":
|
|
b.getPackageBuilder(ev.Package).EndTest(ev.Name, ev.Result, 0, 0)
|
|
case "status":
|
|
b.getPackageBuilder(ev.Package).End()
|
|
case "summary":
|
|
// The summary marks the end of a package. We can now create the actual
|
|
// package from all the events we've processed so far for this package.
|
|
b.packages = append(b.packages, b.CreatePackage(ev.Package, ev.Name, ev.Result, ev.Duration, ev.Data))
|
|
case "coverage":
|
|
b.getPackageBuilder(ev.Package).Coverage(ev.CovPct, ev.CovPackages)
|
|
case "build_output":
|
|
b.CreateBuildError(ev.Name)
|
|
case "output":
|
|
if ev.Package != "" {
|
|
b.getPackageBuilder(ev.Package).Output(ev.Data)
|
|
} else {
|
|
b.output.Append(ev.Data)
|
|
}
|
|
default:
|
|
// This shouldn't happen, but just in case print a warning and ignore
|
|
// this event.
|
|
fmt.Printf("reportBuilder: unhandled event type: %v\n", ev.Type)
|
|
}
|
|
}
|
|
|
|
// newID returns a new unique id.
|
|
func (b *reportBuilder) generateID() int {
|
|
id := b.nextID
|
|
b.nextID++
|
|
return id
|
|
}
|
|
|
|
// Build returns the new Report containing all the tests, build errors and
|
|
// their output created from the processed events.
|
|
func (b *reportBuilder) Build() gtr.Report {
|
|
// Create packages for any leftover package builders.
|
|
for name, pb := range b.packageBuilders {
|
|
if pb.IsEmpty() {
|
|
continue
|
|
}
|
|
b.packages = append(b.packages, b.CreatePackage(name, b.packageName, "", 0, ""))
|
|
}
|
|
return gtr.Report{Packages: b.packages}
|
|
}
|
|
|
|
// CreateBuildError creates a new build error and marks it as active.
|
|
func (b *reportBuilder) CreateBuildError(packageName string) {
|
|
id := b.generateID()
|
|
b.output.SetActiveID(id)
|
|
b.buildErrors[id] = gtr.Error{ID: id, Name: packageName}
|
|
}
|
|
|
|
// CreatePackage returns a new package containing all the build errors, output,
|
|
// tests and benchmarks created so far. The optional packageName is used to
|
|
// find the correct reportBuilder. The newPackageName is the actual package
|
|
// name that will be given to the returned package, which should be used in
|
|
// case the packageName was unknown until this point.
|
|
func (b *reportBuilder) CreatePackage(packageName, newPackageName, result string, duration time.Duration, data string) gtr.Package {
|
|
pkg := gtr.Package{
|
|
Name: newPackageName,
|
|
Duration: duration,
|
|
Timestamp: b.timestampFunc(),
|
|
}
|
|
|
|
// First check if this package contained a build error. If that's the case,
|
|
// we won't find any tests in this package.
|
|
for id, buildErr := range b.buildErrors {
|
|
if buildErr.Name == newPackageName {
|
|
pkg.BuildError = buildErr
|
|
pkg.BuildError.ID = id
|
|
pkg.BuildError.Duration = duration
|
|
pkg.BuildError.Cause = data
|
|
pkg.BuildError.Output = b.output.Get(id)
|
|
|
|
delete(b.buildErrors, id)
|
|
b.output.SetActiveID(0)
|
|
return pkg
|
|
}
|
|
}
|
|
|
|
// Get the packageBuilder for this package and make sure it's deleted, so
|
|
// future events for this package will use a new packageBuilder.
|
|
pb := b.getPackageBuilder(packageName)
|
|
delete(b.packageBuilders, packageName)
|
|
pb.output.SetActiveID(0)
|
|
|
|
if pb.IsEmpty() {
|
|
return pkg
|
|
}
|
|
|
|
// If we've collected output, but there were no tests, then this package
|
|
// had a runtime error or it simply didn't have any tests.
|
|
if pb.output.Contains(globalID) && len(pb.tests) == 0 {
|
|
if parseResult(result) == gtr.Fail {
|
|
pkg.RunError = gtr.Error{
|
|
Name: newPackageName,
|
|
Output: pb.output.Get(globalID),
|
|
}
|
|
} else {
|
|
pkg.Output = pb.output.Get(globalID)
|
|
}
|
|
pb.output.Clear(globalID)
|
|
return pkg
|
|
}
|
|
|
|
// 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(pb.tests) > 0 && !pb.containsFailures() {
|
|
pkg.RunError = gtr.Error{
|
|
Name: newPackageName,
|
|
Output: pb.output.Get(globalID),
|
|
}
|
|
pb.output.Clear(globalID)
|
|
}
|
|
|
|
// Collect tests for this package
|
|
var tests []gtr.Test
|
|
for id, t := range pb.tests {
|
|
if pb.isParent(id) {
|
|
if b.subtestMode == IgnoreParentResults {
|
|
t.Result = gtr.Pass
|
|
} else if b.subtestMode == ExcludeParents {
|
|
pb.output.Merge(id, globalID)
|
|
continue
|
|
}
|
|
}
|
|
t.Output = pb.output.Get(id)
|
|
tests = append(tests, t)
|
|
}
|
|
tests = groupBenchmarksByName(tests, b.output)
|
|
|
|
// Sort packages by id to ensure we maintain insertion order.
|
|
sort.Slice(tests, func(i, j int) bool {
|
|
return tests[i].ID < tests[j].ID
|
|
})
|
|
|
|
pkg.Tests = groupBenchmarksByName(tests, pb.output)
|
|
pkg.Coverage = pb.coverage
|
|
pkg.Output = pb.output.Get(globalID)
|
|
pb.output.Clear(globalID)
|
|
return pkg
|
|
}
|
|
|
|
// parseResult returns a gtr.Result for the given result 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
|
|
}
|
|
}
|
|
|
|
// groupBenchmarksByName groups tests with the Benchmark prefix if they have
|
|
// the same name and combines their output.
|
|
func groupBenchmarksByName(tests []gtr.Test, output *collector.Output) []gtr.Test {
|
|
if len(tests) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var grouped []gtr.Test
|
|
byName := make(map[string][]gtr.Test)
|
|
for _, test := range tests {
|
|
if !strings.HasPrefix(test.Name, "Benchmark") {
|
|
// If this test is not a benchmark, we won't group it by name but
|
|
// just add it to the final result.
|
|
grouped = append(grouped, test)
|
|
continue
|
|
}
|
|
if _, ok := byName[test.Name]; !ok {
|
|
grouped = append(grouped, gtr.NewTest(test.ID, test.Name))
|
|
}
|
|
byName[test.Name] = append(byName[test.Name], test)
|
|
}
|
|
|
|
for i, group := range grouped {
|
|
if !strings.HasPrefix(group.Name, "Benchmark") {
|
|
continue
|
|
}
|
|
var (
|
|
ids []int
|
|
total Benchmark
|
|
count int
|
|
)
|
|
for _, test := range byName[group.Name] {
|
|
ids = append(ids, test.ID)
|
|
if test.Result != gtr.Pass {
|
|
continue
|
|
}
|
|
|
|
if bench, ok := GetBenchmarkData(test); ok {
|
|
total.Iterations += bench.Iterations
|
|
total.NsPerOp += bench.NsPerOp
|
|
total.MBPerSec += bench.MBPerSec
|
|
total.BytesPerOp += bench.BytesPerOp
|
|
total.AllocsPerOp += bench.AllocsPerOp
|
|
count++
|
|
}
|
|
}
|
|
|
|
group.Duration = combinedDuration(byName[group.Name])
|
|
group.Result = groupResults(byName[group.Name])
|
|
group.Output = output.GetAll(ids...)
|
|
if count > 0 {
|
|
total.Iterations /= int64(count)
|
|
total.NsPerOp /= float64(count)
|
|
total.MBPerSec /= float64(count)
|
|
total.BytesPerOp /= int64(count)
|
|
total.AllocsPerOp /= int64(count)
|
|
SetBenchmarkData(&group, total)
|
|
}
|
|
grouped[i] = group
|
|
}
|
|
return grouped
|
|
}
|
|
|
|
// combinedDuration returns the sum of the durations of the given tests.
|
|
func combinedDuration(tests []gtr.Test) time.Duration {
|
|
var total time.Duration
|
|
for _, test := range tests {
|
|
total += test.Duration
|
|
}
|
|
return total
|
|
}
|
|
|
|
// groupResults returns the result we should use for a collection of tests.
|
|
func groupResults(tests []gtr.Test) gtr.Result {
|
|
var result gtr.Result
|
|
for _, test := range tests {
|
|
if test.Result == gtr.Fail {
|
|
return gtr.Fail
|
|
}
|
|
if result != gtr.Pass {
|
|
result = test.Result
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// packageBuilder helps build a gtr.Package from a collection of test events.
|
|
type packageBuilder struct {
|
|
generateID func() int
|
|
output *collector.Output
|
|
|
|
tests map[int]gtr.Test
|
|
parentIDs map[int]struct{} // set of test id's that contain subtests
|
|
coverage float64 // coverage percentage
|
|
}
|
|
|
|
// newPackageBuilder creates a new packageBuilder. New tests will be assigned
|
|
// an ID returned by the generateID function. The activeIDSetter is called to
|
|
// set or reset the active test id.
|
|
func newPackageBuilder(generateID func() int, output *collector.Output) *packageBuilder {
|
|
return &packageBuilder{
|
|
generateID: generateID,
|
|
output: output,
|
|
tests: make(map[int]gtr.Test),
|
|
parentIDs: make(map[int]struct{}),
|
|
}
|
|
}
|
|
|
|
// IsEmpty returns true if this package builder does not have any tests and has
|
|
// not collected any global output.
|
|
func (b packageBuilder) IsEmpty() bool {
|
|
return len(b.tests) == 0 && !b.output.Contains(0)
|
|
}
|
|
|
|
// CreateTest adds a test with the given name to the package, marks it as
|
|
// active and returns its generated id.
|
|
func (b *packageBuilder) CreateTest(name string) int {
|
|
if parentID, ok := b.findTestParentID(name); ok {
|
|
b.parentIDs[parentID] = struct{}{}
|
|
}
|
|
id := b.generateID()
|
|
b.output.SetActiveID(id)
|
|
b.tests[id] = gtr.NewTest(id, name)
|
|
return id
|
|
}
|
|
|
|
// PauseTest marks the test with the given name no longer active. Any results
|
|
// or output added to the package after calling PauseTest will no longer be
|
|
// associated with this test.
|
|
func (b *packageBuilder) PauseTest(name string) {
|
|
b.output.SetActiveID(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 *packageBuilder) ContinueTest(name string) {
|
|
id, _ := b.findTest(name)
|
|
b.output.SetActiveID(id)
|
|
}
|
|
|
|
// 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. The test is then marked as no longer active.
|
|
func (b *packageBuilder) 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?
|
|
id = b.CreateTest(name)
|
|
}
|
|
|
|
t := b.tests[id]
|
|
t.Result = parseResult(result)
|
|
t.Duration = duration
|
|
t.Level = level
|
|
b.tests[id] = t
|
|
b.output.SetActiveID(0)
|
|
}
|
|
|
|
// End resets the active test.
|
|
func (b *packageBuilder) End() {
|
|
b.output.SetActiveID(0)
|
|
}
|
|
|
|
// BenchmarkResult updates an existing or adds a new test with the given
|
|
// results and marks it as active. If an existing test with this name exists
|
|
// but without result, then that one is updated. Otherwise a new one is added
|
|
// to the report.
|
|
func (b *packageBuilder) BenchmarkResult(name string, iterations int64, nsPerOp, mbPerSec float64, bytesPerOp, allocsPerOp int64) {
|
|
id, ok := b.findTest(name)
|
|
if !ok || b.tests[id].Result != gtr.Unknown {
|
|
id = b.CreateTest(name)
|
|
}
|
|
b.output.SetActiveID(id)
|
|
|
|
benchmark := Benchmark{iterations, nsPerOp, mbPerSec, bytesPerOp, allocsPerOp}
|
|
test := gtr.NewTest(id, name)
|
|
test.Result = gtr.Pass
|
|
test.Duration = benchmark.ApproximateDuration()
|
|
SetBenchmarkData(&test, benchmark)
|
|
b.tests[id] = test
|
|
}
|
|
|
|
// Coverage sets the code coverage percentage.
|
|
func (b *packageBuilder) Coverage(pct float64, packages []string) {
|
|
b.coverage = pct
|
|
}
|
|
|
|
// Output appends data to the output of this package.
|
|
func (b *packageBuilder) Output(data string) {
|
|
b.output.Append(data)
|
|
}
|
|
|
|
// findTest returns the id of the most recently created test with the given
|
|
// name if it exists.
|
|
func (b *packageBuilder) findTest(name string) (int, bool) {
|
|
var maxid int
|
|
for id, test := range b.tests {
|
|
if maxid < id && test.Name == name {
|
|
maxid = id
|
|
}
|
|
}
|
|
return maxid, maxid > 0
|
|
}
|
|
|
|
// findTestParentID searches the existing tests in this package for a parent of
|
|
// the test with the given name, and returns its id if one is found.
|
|
func (b *packageBuilder) 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
|
|
}
|
|
|
|
// isParent returns true if the test with the given id has sub tests.
|
|
func (b *packageBuilder) isParent(id int) bool {
|
|
_, ok := b.parentIDs[id]
|
|
return ok
|
|
}
|
|
|
|
// dropLastSegment strips the last `/` and everything following it from the
|
|
// given name. If no `/` was found, the empty string is returned.
|
|
func dropLastSegment(name string) string {
|
|
if idx := strings.LastIndexByte(name, '/'); idx >= 0 {
|
|
return name[:idx]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// containsFailures return true if this package contains at least one failing
|
|
// test or a test with an unknown result.
|
|
func (b *packageBuilder) containsFailures() bool {
|
|
for _, test := range b.tests {
|
|
if test.Result == gtr.Fail || test.Result == gtr.Unknown {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|