Joël Stemmer 2b3d79c572 junit: Specify unicode replacement character as \uFFFD
This is just to make the exact character we want to return explicit,
otherwise it could be confused by any unsupported character in your text
editor.
2022-09-17 22:33:04 +01:00

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 '\uFFFD'
}, 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.
// From: 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
}