Files
tasks/tools/testrunner/log.go
Stephan BehnkeandGitHub 16a4092254 Generate JSON test summary (#10381)
## What changed?

(1) Generate JSON test summary, too
(2) Tweak summary detail capture esp for `Await`

## Why?

Allow AI agents to easily/quickly/cheaply inspect CI results.

<img width="1582" height="707" alt="Screenshot 2026-05-25 at 6 46 13 PM"
src="https://github.com/user-attachments/assets/ca232f52-4f6f-45cb-9b52-450d32999d0c"
/>

<img width="2158" height="254" alt="Screenshot 2026-05-25 at 6 47 03 PM"
src="https://github.com/user-attachments/assets/447ccf28-bec7-424b-a961-630c44a02cb0"
/>
2026-05-27 02:14:19 +00:00

445 lines
13 KiB
Go

package testrunner
import (
"fmt"
"io"
"slices"
"strings"
"github.com/maruel/panicparse/v2/stack"
)
const (
// noFailureDetails is returned by parseFailureDetails when no recognisable
// failure block is found.
noFailureDetails = "(error details not found)"
goTestFailLinePrefix = "--- FAIL:"
)
// parseTestTimeouts parses the stdout of a test run and returns the stacktrace and names of tests that timed out.
func parseTestTimeouts(stdout string) (stacktrace string, timedoutTests []string) {
lines := strings.Split(strings.ReplaceAll(stdout, "\r\n", "\n"), "\n")
for i := 0; i < len(lines); i++ {
line := lines[i]
if strings.HasPrefix(line, "FAIL") {
// ignore
} else if strings.HasPrefix(line, "panic: test timed out after") {
// parse names of tests that timed out
for {
i++
line = strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "Test") {
timedoutTests = append(timedoutTests, strings.Split(line, " ")[0])
}
if line == "" {
break
}
}
} else if len(timedoutTests) > 0 {
// collect stracktrace
stacktrace += line + "\n"
}
}
stacktrace = fmt.Sprintf("%d timed out test(s):\n\t%v\n\n%v",
len(timedoutTests), strings.Join(timedoutTests, "\n\t"), testOnlyStacktrace(stacktrace))
return
}
// testOnlyStacktrace removes all but the test stacktraces from the full stacktrace.
func testOnlyStacktrace(stacktrace string) string {
var res string
snap, _, err := stack.ScanSnapshot(strings.NewReader(stacktrace), io.Discard, stack.DefaultOpts())
if err != nil && err != io.EOF {
return fmt.Sprintf("failed to parse stacktrace: %v", err)
}
if snap == nil {
return "failed to find a stacktrace"
}
res = "abridged stacktrace:\n"
for _, goroutine := range snap.Goroutines {
shouldPrint := slices.ContainsFunc(goroutine.Stack.Calls, func(call stack.Call) bool {
return strings.HasSuffix(call.RemoteSrcPath, "_test.go")
})
if shouldPrint {
res += fmt.Sprintf("\tgoroutine %d [%v]:\n", goroutine.ID, goroutine.State)
for _, call := range goroutine.Stack.Calls {
file := call.RemoteSrcPath
res += fmt.Sprintf("\t\t%s:%d\n", file, call.Line)
}
res += "\n"
}
}
return res
}
// alert captures a prominent issue detected from stdout/stderr of test runs.
type alert struct {
Type failureType
Summary string
Details string
Tests []string
}
// primaryTestName returns a single representative test name for an alert.
// Preference order:
// 1) Fully-qualified test name containing ".Test"
// 2) First detected test name
func primaryTestName(tests []string) string {
if len(tests) == 0 {
return ""
}
for _, t := range tests {
if strings.Contains(t, ".Test") {
return t
}
}
return tests[0]
}
// preferFullyQualifiedTestName returns the best display name for a test.
// If the primary name is not fully-qualified (e.g., "TestXxx"), but a
// fully-qualified variant exists in the list (e.g., "pkg/path.TestXxx"),
// this returns the fully-qualified variant.
func preferFullyQualifiedTestName(tests []string) string {
primary := primaryTestName(tests)
if primary == "" || strings.Contains(primary, ".Test") {
return primary
}
// Try to find an FQN that ends with "."+primary
suffix := "." + primary
for _, t := range tests {
if strings.HasSuffix(t, suffix) {
return t
}
}
return primary
}
// parseAlerts scans a gotestsum/go test stdout stream and extracts high-priority
// alerts such as data races and panics. It returns a slice of alerts in the
// order they were encountered.
func parseAlerts(stdout string) []alert {
lines := strings.Split(strings.ReplaceAll(stdout, "\r\n", "\n"), "\n")
var alerts []alert
for i := 0; i < len(lines); i++ {
line := lines[i]
if a, next, ok := tryParseDataRace(lines, i, line); ok {
alerts = append(alerts, a)
i = next
continue
}
if a, next, ok := tryParsePanic(lines, i, line); ok {
alerts = append(alerts, a)
i = next
continue
}
if a, next, ok := tryParseFatal(lines, i, line); ok {
alerts = append(alerts, a)
i = next
continue
}
}
return alerts
}
// extractTestNames tries to identify Go test function names from a log block.
// It looks for fully-qualified names like pkg.TestXxx(...) and Go test failure lines.
func extractTestNames(block string) []string {
var tests []string
seen := make(map[string]struct{})
for line := range strings.SplitSeq(block, "\n") {
l := strings.TrimSpace(line)
if l == "" {
continue
}
if name, ok := parseTripleDashTestName(l); ok {
addUniqueTest(&tests, seen, name)
continue
}
if name, ok := parseFullyQualifiedTestName(l); ok {
addUniqueTest(&tests, seen, name)
continue
}
if name, ok := parsePlainTestName(l); ok {
addUniqueTest(&tests, seen, name)
}
}
return tests
}
// addUniqueTest appends name to tests if not already seen.
func addUniqueTest(tests *[]string, seen map[string]struct{}, name string) {
if _, ok := seen[name]; ok {
return
}
seen[name] = struct{}{}
*tests = append(*tests, name)
}
// parseTripleDashTestName parses Go test failure lines and returns the test name if present.
func parseTripleDashTestName(line string) (string, bool) {
if !strings.HasPrefix(line, goTestFailLinePrefix) {
return "", false
}
name := strings.TrimSpace(strings.TrimPrefix(line, goTestFailLinePrefix))
name, _, _ = strings.Cut(name, " ")
if !strings.HasPrefix(name, "Test") {
return "", false
}
return name, true
}
// parseFullyQualifiedTestName extracts names like "pkg/path.TestName" from a line.
func parseFullyQualifiedTestName(line string) (string, bool) {
idx := strings.Index(line, ".Test")
if idx < 0 {
return "", false
}
// Include the package/path qualifier preceding ".Test"
start := 0
if sp := strings.LastIndex(line[:idx], " "); sp >= 0 {
start = sp + 1
}
if p := strings.Index(line[idx:], "("); p > 0 {
return line[start : idx+p], true
}
return "", false
}
// parsePlainTestName extracts a leading "TestName(" form.
func parsePlainTestName(line string) (string, bool) {
if !strings.HasPrefix(line, "Test") || !strings.Contains(line, "(") {
return "", false
}
name := line
if p := strings.Index(name, "("); p > 0 {
name = name[:p]
}
return name, true
}
// tryParseDataRace parses a data race alert at position i if present.
func tryParseDataRace(lines []string, i int, line string) (alert, int, bool) {
if !strings.HasPrefix(line, "WARNING: DATA RACE") {
return alert{}, i, false
}
start := findRaceBlockStart(lines, i)
// Merge contiguous race-report sections into a single alert. The Go race
// detector may emit multiple "WARNING: DATA RACE" blocks back-to-back,
// each wrapped by a line of ==================. Treat adjacent sections as
// a single logical alert until we either hit a test boundary or a race
// boundary that is not followed by another race section.
block, end := collectBlock(lines, start, func(curLine string, idx, start int) bool {
// Stop at PASS/FAIL boundaries always.
if isTestResultBoundary(curLine) {
return true
}
// If we hit a race boundary after we've started, only stop if the next
// non-current line does not continue the race report.
if idx > start && isRaceBoundary(curLine) {
if idx+1 < len(lines) {
next := strings.TrimSpace(lines[idx+1])
if isRaceBoundary(next) || strings.HasPrefix(next, "WARNING: DATA RACE") {
return false
}
}
return true
}
return false
})
return alert{
Type: failureTypeDataRace,
Summary: "Data race detected",
Details: block,
Tests: extractTestNames(block),
}, end, true
}
// tryParsePanic parses a non-timeout panic alert at position i if present.
func tryParsePanic(lines []string, i int, line string) (alert, int, bool) {
if !strings.HasPrefix(line, "panic: ") || strings.HasPrefix(line, "panic: test timed out after") {
return alert{}, i, false
}
block, end := collectBlock(lines, i, shouldStopOnTestBoundary)
return alert{
Type: failureTypePanic,
Summary: strings.TrimSpace(strings.TrimPrefix(line, "panic: ")),
Details: block,
Tests: extractTestNames(block),
}, end, true
}
// tryParseFatal parses a runtime fatal error alert at position i if present.
func tryParseFatal(lines []string, i int, line string) (alert, int, bool) {
if !strings.HasPrefix(line, "fatal error: ") {
return alert{}, i, false
}
block, end := collectBlock(lines, i, shouldStopOnTestBoundary)
return alert{
Type: failureTypeFatal,
Summary: strings.TrimSpace(strings.TrimPrefix(line, "fatal error: ")),
Details: block,
Tests: extractTestNames(block),
}, end, true
}
// findRaceBlockStart searches upward for the race report delimiter.
func findRaceBlockStart(lines []string, i int) int {
start := i
for j := i - 1; j >= 0; j-- {
if isRaceBoundary(lines[j]) {
start = j
break
}
}
return start
}
// collectBlock builds a block from start until the stop condition is met.
func collectBlock(lines []string, start int, stop func(line string, idx, start int) bool) (string, int) {
var b strings.Builder
for j := start; j < len(lines); j++ {
b.WriteString(lines[j])
b.WriteByte('\n')
if stop(lines[j], j, start) {
return b.String(), j
}
}
return b.String(), len(lines) - 1
}
func isRaceBoundary(line string) bool {
return strings.HasPrefix(strings.TrimSpace(line), "==================")
}
func isTestResultBoundary(line string) bool {
return strings.HasPrefix(line, "FAIL") || strings.HasPrefix(line, "PASS")
}
func shouldStopOnTestBoundary(line string, _ int, _ int) bool {
return isTestResultBoundary(line)
}
// parseFailedTestsFromOutput extracts failing test names from gotestsum stdout.
// It looks for Go test failure lines produced as tests complete, and is
// used when the test binary was killed externally before producing a JUnit XML.
func parseFailedTestsFromOutput(stdout string) []string {
var failed []string
seen := make(map[string]struct{})
for line := range strings.SplitSeq(strings.ReplaceAll(stdout, "\r\n", "\n"), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, goTestFailLinePrefix) {
continue
}
if name, ok := parseTripleDashTestName(line); ok {
addUniqueTest(&failed, seen, name)
}
}
return failed
}
// parseFailureDetails extracts the actionable part of a JUnit failure Data block.
func parseFailureDetails(data string) string {
lines := normalizedFailureLines(data)
// Prefer assertion blocks because they contain the useful testify failure
// detail and can be selected from the end while ignoring trailing logs.
if block, ok := findLastAssertionFailureBlock(lines); ok {
return block
}
// Some failures, such as gomock errors, do not include an Error Trace.
// Fall back to the last Go test output block in those cases.
if start, end, ok := findLastTestOutputFailureBlock(lines); ok {
return strings.Join(lines[start:end], "\n")
}
return noFailureDetails
}
func normalizedFailureLines(data string) []string {
lines := strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n")
for len(lines) > 0 {
t := strings.TrimSpace(lines[len(lines)-1])
if t != "" && t != "FAIL" {
break
}
lines = lines[:len(lines)-1]
}
return lines
}
func findLastAssertionFailureBlock(lines []string) (string, bool) {
var failLine string
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if failLine == "" && strings.HasPrefix(line, goTestFailLinePrefix) {
// Keep the final Go test failure line because it carries the test duration.
failLine = line
continue
}
if !strings.Contains(lines[i], "Error Trace:") {
continue
}
// Include the nearest preceding line when present. For testify this is
// the file header; for await failures this is the attempt marker.
start := i
for prev := i - 1; prev >= 0; prev-- {
if strings.TrimSpace(lines[prev]) != "" {
start = prev
break
}
}
out := append([]string{}, lines[start:endOfAssertionBlock(lines, i+1)]...)
if failLine != "" {
out = append(out, "", failLine)
}
return strings.Join(out, "\n"), true
}
return "", false
}
func endOfAssertionBlock(lines []string, start int) int {
sawTestLine := false
for i := start; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if line == "" || strings.HasPrefix(line, goTestFailLinePrefix) {
return i
}
if strings.HasPrefix(line, "Test:") {
sawTestLine = true
continue
}
// Logs written after the Test line are not part of the assertion block.
if sawTestLine && isTestOutputLine(lines[i]) {
return i
}
}
return len(lines)
}
func findLastTestOutputFailureBlock(lines []string) (start, end int, ok bool) {
for start := len(lines) - 1; start >= 0; start-- {
if !isTestOutputLine(lines[start]) {
continue
}
end := start + 1
for end < len(lines) && !isTestOutputLine(lines[end]) && lines[end] != "" {
end++
}
return start, end, true
}
return 0, 0, false
}
// isTestOutputLine reports whether line is a Go test-framework output line,
// i.e. " file.go:N: …" — exactly 4 spaces then a non-whitespace character.
// Testify assertion content is indented further (8+ spaces or tabs), so this
// distinguishes log entries from assertion block content.
func isTestOutputLine(line string) bool {
return len(line) > 4 && line[:4] == " " && line[4] != ' ' && line[4] != '\t'
}