mirror of
https://github.com/jstemmer/go-junit-report.git
synced 2025-04-04 20:50:14 -05:00
263 lines
6.9 KiB
Go
263 lines
6.9 KiB
Go
// Package junit defines a JUnit XML report and includes convenience methods
|
||
// for working with these reports.
|
||
package junit
|
||
|
||
import (
|
||
"encoding/xml"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jstemmer/go-junit-report/v2/gtr"
|
||
)
|
||
|
||
// Testsuites is a collection of JUnit testsuites.
|
||
type Testsuites struct {
|
||
XMLName xml.Name `xml:"testsuites"`
|
||
|
||
Name string `xml:"name,attr,omitempty"`
|
||
Time string `xml:"time,attr,omitempty"` // total duration in seconds
|
||
Tests int `xml:"tests,attr,omitempty"`
|
||
Errors int `xml:"errors,attr,omitempty"`
|
||
Failures int `xml:"failures,attr,omitempty"`
|
||
Skipped int `xml:"skipped,attr,omitempty"`
|
||
Disabled int `xml:"disabled,attr,omitempty"`
|
||
|
||
Suites []Testsuite `xml:"testsuite,omitempty"`
|
||
}
|
||
|
||
// AddSuite adds a Testsuite and updates this testssuites' totals.
|
||
func (t *Testsuites) AddSuite(ts Testsuite) {
|
||
t.Suites = append(t.Suites, ts)
|
||
t.Tests += ts.Tests
|
||
t.Errors += ts.Errors
|
||
t.Failures += ts.Failures
|
||
t.Skipped += ts.Skipped
|
||
t.Disabled += ts.Disabled
|
||
}
|
||
|
||
// Testsuite is a single JUnit testsuite containing testcases.
|
||
type Testsuite struct {
|
||
// required attributes
|
||
Name string `xml:"name,attr"`
|
||
Tests int `xml:"tests,attr"`
|
||
Failures int `xml:"failures,attr"`
|
||
Errors int `xml:"errors,attr"`
|
||
ID int `xml:"id,attr"`
|
||
|
||
// optional attributes
|
||
Disabled int `xml:"disabled,attr,omitempty"`
|
||
Hostname string `xml:"hostname,attr,omitempty"`
|
||
Package string `xml:"package,attr,omitempty"`
|
||
Skipped int `xml:"skipped,attr,omitempty"`
|
||
Time string `xml:"time,attr"` // duration in seconds
|
||
Timestamp string `xml:"timestamp,attr,omitempty"` // date and time in ISO8601
|
||
File string `xml:"file,attr,omitempty"`
|
||
|
||
Properties *[]Property `xml:"properties>property,omitempty"`
|
||
Testcases []Testcase `xml:"testcase,omitempty"`
|
||
SystemOut *Output `xml:"system-out,omitempty"`
|
||
SystemErr *Output `xml:"system-err,omitempty"`
|
||
}
|
||
|
||
// AddProperty adds a property with the given name and value to this Testsuite.
|
||
func (t *Testsuite) AddProperty(name, value string) {
|
||
prop := Property{Name: name, Value: value}
|
||
if t.Properties == nil {
|
||
t.Properties = &[]Property{prop}
|
||
return
|
||
}
|
||
props := append(*t.Properties, prop)
|
||
t.Properties = &props
|
||
}
|
||
|
||
// AddTestcase adds Testcase tc to this Testsuite.
|
||
func (t *Testsuite) AddTestcase(tc Testcase) {
|
||
t.Testcases = append(t.Testcases, tc)
|
||
t.Tests++
|
||
|
||
if tc.Error != nil {
|
||
t.Errors++
|
||
}
|
||
|
||
if tc.Failure != nil {
|
||
t.Failures++
|
||
}
|
||
|
||
if tc.Skipped != nil {
|
||
t.Skipped++
|
||
}
|
||
}
|
||
|
||
// SetTimestamp sets the timestamp in this Testsuite.
|
||
func (t *Testsuite) SetTimestamp(timestamp time.Time) {
|
||
t.Timestamp = timestamp.Format(time.RFC3339)
|
||
}
|
||
|
||
// Testcase represents a single test with its results.
|
||
type Testcase struct {
|
||
// required attributes
|
||
Name string `xml:"name,attr"`
|
||
Classname string `xml:"classname,attr"`
|
||
|
||
// optional attributes
|
||
Time string `xml:"time,attr,omitempty"` // duration in seconds
|
||
Status string `xml:"status,attr,omitempty"`
|
||
|
||
Skipped *Result `xml:"skipped,omitempty"`
|
||
Error *Result `xml:"error,omitempty"`
|
||
Failure *Result `xml:"failure,omitempty"`
|
||
SystemOut *Output `xml:"system-out,omitempty"`
|
||
SystemErr *Output `xml:"system-err,omitempty"`
|
||
}
|
||
|
||
// Property represents a key/value pair.
|
||
type Property struct {
|
||
Name string `xml:"name,attr"`
|
||
Value string `xml:"value,attr"`
|
||
}
|
||
|
||
// Result represents the result of a single test.
|
||
type Result struct {
|
||
Message string `xml:"message,attr"`
|
||
Type string `xml:"type,attr,omitempty"`
|
||
Data string `xml:",cdata"`
|
||
}
|
||
|
||
// Output represents output written to stdout or sderr.
|
||
type Output struct {
|
||
Data string `xml:",cdata"`
|
||
}
|
||
|
||
// CreateFromReport creates a JUnit representation of the given gtr.Report.
|
||
func CreateFromReport(report gtr.Report, hostname string) Testsuites {
|
||
var suites Testsuites
|
||
for _, pkg := range report.Packages {
|
||
var duration time.Duration
|
||
suite := Testsuite{
|
||
Name: pkg.Name,
|
||
Hostname: hostname,
|
||
ID: len(suites.Suites),
|
||
}
|
||
|
||
if !pkg.Timestamp.IsZero() {
|
||
suite.SetTimestamp(pkg.Timestamp)
|
||
}
|
||
|
||
for _, p := range pkg.Properties {
|
||
suite.AddProperty(p.Name, p.Value)
|
||
}
|
||
|
||
if len(pkg.Output) > 0 {
|
||
suite.SystemOut = &Output{Data: formatOutput(pkg.Output)}
|
||
}
|
||
|
||
if pkg.Coverage > 0 {
|
||
suite.AddProperty("coverage.statements.pct", fmt.Sprintf("%.2f", pkg.Coverage))
|
||
}
|
||
|
||
for _, test := range pkg.Tests {
|
||
duration += test.Duration
|
||
suite.AddTestcase(createTestcaseForTest(pkg.Name, test))
|
||
}
|
||
|
||
// JUnit doesn't have a good way of dealing with build or runtime
|
||
// errors that happen before a test has started, so we create a single
|
||
// failing test that contains the build error details.
|
||
if pkg.BuildError.Name != "" {
|
||
tc := Testcase{
|
||
Classname: pkg.BuildError.Name,
|
||
Name: pkg.BuildError.Cause,
|
||
Time: formatDuration(0),
|
||
Error: &Result{
|
||
Message: "Build error",
|
||
Data: strings.Join(pkg.BuildError.Output, "\n"),
|
||
},
|
||
}
|
||
suite.AddTestcase(tc)
|
||
}
|
||
|
||
if pkg.RunError.Name != "" {
|
||
tc := Testcase{
|
||
Classname: pkg.RunError.Name,
|
||
Name: "Failure",
|
||
Time: formatDuration(0),
|
||
Error: &Result{
|
||
Message: "Runtime error",
|
||
Data: strings.Join(pkg.RunError.Output, "\n"),
|
||
},
|
||
}
|
||
suite.AddTestcase(tc)
|
||
}
|
||
|
||
if (pkg.Duration) == 0 {
|
||
suite.Time = formatDuration(duration)
|
||
} else {
|
||
suite.Time = formatDuration(pkg.Duration)
|
||
}
|
||
suites.AddSuite(suite)
|
||
}
|
||
return suites
|
||
}
|
||
|
||
func createTestcaseForTest(pkgName string, test gtr.Test) Testcase {
|
||
tc := Testcase{
|
||
Classname: pkgName,
|
||
Name: test.Name,
|
||
Time: formatDuration(test.Duration),
|
||
}
|
||
|
||
if test.Result == gtr.Fail {
|
||
tc.Failure = &Result{
|
||
Message: "Failed",
|
||
Data: formatOutput(test.Output),
|
||
}
|
||
} else if test.Result == gtr.Skip {
|
||
tc.Skipped = &Result{
|
||
Message: "Skipped",
|
||
Data: formatOutput(test.Output),
|
||
}
|
||
} else if test.Result == gtr.Unknown {
|
||
tc.Error = &Result{
|
||
Message: "No test result found",
|
||
Data: formatOutput(test.Output),
|
||
}
|
||
} else if len(test.Output) > 0 {
|
||
tc.SystemOut = &Output{Data: formatOutput(test.Output)}
|
||
}
|
||
return tc
|
||
}
|
||
|
||
// formatDuration returns the JUnit string representation of the given
|
||
// duration.
|
||
func formatDuration(d time.Duration) string {
|
||
return fmt.Sprintf("%.3f", d.Seconds())
|
||
}
|
||
|
||
// formatOutput combines the lines from the given output into a single string.
|
||
func formatOutput(output []string) string {
|
||
return escapeIllegalChars(strings.Join(output, "\n"))
|
||
}
|
||
|
||
func escapeIllegalChars(str string) string {
|
||
return strings.Map(func(r rune) rune {
|
||
if isInCharacterRange(r) {
|
||
return r
|
||
}
|
||
return '<27>'
|
||
}, str)
|
||
}
|
||
|
||
// Decide whether the given rune is in the XML Character Range, per
|
||
// the Char production of https://www.xml.com/axml/testaxml.htm,
|
||
// Section 2.2 Characters.
|
||
// Form: encoding/xml/xml.go
|
||
func isInCharacterRange(r rune) (inrange bool) {
|
||
return r == 0x09 ||
|
||
r == 0x0A ||
|
||
r == 0x0D ||
|
||
r >= 0x20 && r <= 0xD7FF ||
|
||
r >= 0xE000 && r <= 0xFFFD ||
|
||
r >= 0x10000 && r <= 0x10FFFF
|
||
}
|