// Command testreport turns `go test -json` output into a markdown report. It
// prints the report to stdout and, when GITHUB_STEP_SUMMARY is set, appends it
// to the CI step summary so the pipeline shows what ran.
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
)
type event struct {
Action string `json:"Action"`
Package string `json:"Package"`
Test string `json:"Test"`
Elapsed float64 `json:"Elapsed"`
Output string `json:"Output"`
}
type testKey struct {
pkg string
name string
}
type testResult struct {
pkg string
name string
status string
dur time.Duration
output strings.Builder
}
func main() {
in := io.Reader(os.Stdin)
if len(os.Args) > 1 {
f, err := os.Open(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, "testreport:", err)
os.Exit(1)
}
defer f.Close()
in = f
}
events, err := decode(in)
if err != nil {
fmt.Fprintln(os.Stderr, "testreport:", err)
os.Exit(1)
}
out := summarize(events)
fmt.Print(out)
if path := os.Getenv("GITHUB_STEP_SUMMARY"); path != "" {
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
fmt.Fprintln(os.Stderr, "testreport:", err)
return
}
defer f.Close()
if _, err := f.WriteString(out); err != nil {
fmt.Fprintln(os.Stderr, "testreport:", err)
}
}
}
func decode(r io.Reader) ([]event, error) {
var events []event
dec := json.NewDecoder(bufio.NewReader(r))
for {
var e event
err := dec.Decode(&e)
if err == io.EOF {
return events, nil
}
if err != nil {
return nil, err
}
events = append(events, e)
}
}
func summarize(events []event) string {
tests := map[testKey]*testResult{}
var testOrder []testKey
pkgOrder := []string{}
pkgSeen := map[string]bool{}
pkgFailed := map[string]bool{}
pkgOutput := map[string]strings.Builder{}
for _, e := range events {
if e.Package == "" {
continue
}
if !pkgSeen[e.Package] {
pkgSeen[e.Package] = true
pkgOrder = append(pkgOrder, e.Package)
}
if e.Test == "" {
switch e.Action {
case "output":
b := pkgOutput[e.Package]
b.WriteString(e.Output)
pkgOutput[e.Package] = b
case "fail":
pkgFailed[e.Package] = true
}
continue
}
key := testKey{e.Package, e.Test}
res, ok := tests[key]
if !ok {
res = &testResult{pkg: e.Package, name: e.Test}
tests[key] = res
testOrder = append(testOrder, key)
}
switch e.Action {
case "output":
res.output.WriteString(e.Output)
case "pass", "fail", "skip":
res.status = e.Action
res.dur = time.Duration(e.Elapsed * float64(time.Second))
}
}
sort.Slice(testOrder, func(i, j int) bool {
if testOrder[i].pkg != testOrder[j].pkg {
return testOrder[i].pkg < testOrder[j].pkg
}
return testOrder[i].name < testOrder[j].name
})
var passed, failed, skipped int
for _, res := range tests {
switch res.status {
case "pass":
passed++
case "fail":
failed++
case "skip":
skipped++
}
}
var b strings.Builder
b.WriteString("## Go test report\n\n")
fmt.Fprintf(&b, "**%d passed, %d failed, %d skipped**\n\n", passed, failed+len(pkgFailed), skipped)
if len(testOrder) > 0 {
b.WriteString("| Package | Test | Status | Duration |\n")
b.WriteString("| --- | --- | --- | --- |\n")
for _, key := range testOrder {
res := tests[key]
status := res.status
if status == "" {
status = "unknown"
}
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", res.pkg, res.name, status, res.dur.Round(time.Millisecond))
}
}
var failures []string
for _, key := range testOrder {
if tests[key].status == "fail" {
failures = append(failures, fmt.Sprintf("%s.%s", tests[key].pkg, tests[key].name))
}
}
if len(failures) > 0 || len(pkgFailed) > 0 {
b.WriteString("\n### Failures\n\n")
for _, key := range testOrder {
res := tests[key]
if res.status != "fail" {
continue
}
fmt.Fprintf(&b, "%s.%s
\n\n```\n%s```\n\n \n\n", res.pkg, res.name, res.output.String())
}
failedPkgs := make([]string, 0, len(pkgFailed))
for pkg := range pkgFailed {
failedPkgs = append(failedPkgs, pkg)
}
sort.Strings(failedPkgs)
for _, pkg := range failedPkgs {
output := pkgOutput[pkg]
fmt.Fprintf(&b, "%s
\n\n```\n%s```\n\n \n\n", pkg, output.String())
}
}
return b.String()
}