// 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() }