mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
## 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" />
445 lines
13 KiB
Go
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'
|
|
}
|