diff --git a/testjson/format.go b/testjson/format.go index 64b2f261..087e49e7 100644 --- a/testjson/format.go +++ b/testjson/format.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "os" + "path/filepath" + "regexp" "sort" "strings" @@ -454,6 +456,23 @@ func NewEventFormatter(out io.Writer, format string, formatOpts FormatOptions) E } } +type githubActionsErrorPatterns struct { + fileLine *regexp.Regexp + panicStack *regexp.Regexp + panicLine *regexp.Regexp +} + +func newGitHubActionsErrorPatterns() githubActionsErrorPatterns { + return githubActionsErrorPatterns{ + // Matches " filename.go:123:" style lines emitted by go test failures + fileLine: regexp.MustCompile(`^\s+([a-zA-Z0-9_\-./]+\.go):(\d+):`), + // Matches stack frames emitted in Go panic traces + panicStack: regexp.MustCompile(`^\t(.+\.go):(\d+) \+0x`), + // Matches canonical panic lines such as "panic: runtime error: ..." + panicLine: regexp.MustCompile(`^panic:\s*`), + } +} + func githubActionsFormat(out io.Writer) EventFormatter { buf := bufio.NewWriter(out) @@ -463,6 +482,8 @@ func githubActionsFormat(out io.Writer) EventFormatter { } output := map[name][]string{} + patterns := newGitHubActionsErrorPatterns() + return eventFormatterFunc(func(event TestEvent, exec *Execution) error { key := name{Package: event.Package, Test: event.Test} @@ -476,6 +497,11 @@ func githubActionsFormat(out io.Writer) EventFormatter { // test case end event if event.Test != "" && event.Action.IsTerminal() { + // Emit error annotation for failed tests + if event.Action == ActionFail { + writeGitHubActionsError(buf, event, output[key], patterns) + } + if len(output[key]) > 0 { buf.WriteString("::group::") } else { @@ -513,3 +539,150 @@ func githubActionsFormat(out io.Writer) EventFormatter { return buf.Flush() }) } + +// writeGitHubActionsError parses test output and emits GitHub Actions error annotations +func writeGitHubActionsError( + buf *bufio.Writer, event TestEvent, outputLines []string, patterns githubActionsErrorPatterns, +) { + sanitize := func(s string) string { + // Percent must be escaped first + s = strings.ReplaceAll(s, "%", "%25") + // Escape newlines and carriage returns + s = strings.ReplaceAll(s, "\r", "%0D") + s = strings.ReplaceAll(s, "\n", "%0A") + return s + } + + // Check if this is a panic by looking for panic: in the output + var isPanic bool + var panicMessage strings.Builder + for _, outputLine := range outputLines { + trimmed := strings.TrimSpace(outputLine) + if patterns.panicLine.MatchString(trimmed) { + isPanic = true + panicMessage.WriteString(trimmed) + panicMessage.WriteString(" ") + } + } + + if isPanic { + // For panics, emit a single annotation with the panic location + var file string + var line string + + // Look for the test file in the stack trace + // Prefer _test.go files over other files (like testing.go or runtime files) + for _, outputLine := range outputLines { + if matches := patterns.panicStack.FindStringSubmatch(outputLine); len(matches) == 3 { + stackPath := filepath.ToSlash(matches[1]) + stackFile := filepath.Base(stackPath) + stackLine := matches[2] + isTestFile := strings.HasSuffix(stackFile, "_test.go") + repoRelative := repoRelativeFile(event, stackPath) + + if (file == "" && repoRelative != "") || isTestFile { + file = repoRelative + line = stackLine + + if isTestFile { + break + } + } + } + } + + message := strings.TrimSpace(panicMessage.String()) + if message == "" { + message = "Test panicked" + } + + if file != "" && line != "" { + fmt.Fprintf(buf, "::error file=%s,line=%s,title=%s::%s\n", + sanitize(file), line, sanitize(event.Test), sanitize(message)) + } else { + fmt.Fprintf(buf, "::error title=%s::%s\n", sanitize(event.Test), sanitize(message)) + } + } else { + // For regular test failures, emit one annotation per error line + for idx, outputLine := range outputLines { + if matches := patterns.fileLine.FindStringSubmatch(outputLine); len(matches) == 3 { + rawFile := matches[1] + file := repoRelativeFile(event, rawFile) + line := matches[2] + + // Ignore logs or helper output from non-test files; these are often + // informational (for example, telemetry logs) and shouldn't surface as + // GitHub Actions annotations. + if !strings.HasSuffix(file, "_test.go") { + continue + } + + parts := strings.SplitN(outputLine, ":", 3) + var message string + if len(parts) >= 3 { + message = strings.TrimSpace(parts[2]) + } + if message == "" { + message = collectAdditionalMessage(outputLines[idx+1:], patterns) + } + if message == "" { + message = "Test failed" + } + + fmt.Fprintf(buf, "::error file=%s,line=%s,title=%s::%s\n", + sanitize(file), line, sanitize(event.Test), sanitize(message)) + } + } + } +} + +func collectAdditionalMessage(lines []string, patterns githubActionsErrorPatterns) string { + shouldStop := func(line string, trimmed string) bool { + if trimmed == "" { + return true + } + if patterns.fileLine.MatchString(line) || patterns.panicStack.MatchString(line) { + return true + } + if strings.HasPrefix(trimmed, "PASS ") || strings.HasPrefix(trimmed, "FAIL ") || + strings.HasPrefix(trimmed, "SKIP ") || strings.HasPrefix(trimmed, "=== ") || + strings.HasPrefix(trimmed, "--- ") || strings.HasPrefix(trimmed, "::") { + return true + } + return false + } + + parts := make([]string, 0, len(lines)) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if shouldStop(line, trimmed) { + break + } + parts = append(parts, trimmed) + } + + return strings.Join(parts, " ") +} + +func repoRelativeFile(event TestEvent, file string) string { + clean := filepath.ToSlash(file) + clean = strings.TrimPrefix(clean, "./") + if clean == "" { + return "" + } + pkgPath := RelativePackagePath(event.Package) + pkgPath = strings.TrimPrefix(pkgPath, "./") + if pkgPath == "" || pkgPath == "." { + if strings.HasPrefix(clean, "/") || strings.Contains(clean, ":") { + return filepath.Base(clean) + } + return clean + } + if idx := strings.Index(clean, pkgPath+"/"); idx >= 0 { + return clean[idx:] + } + if !strings.Contains(clean, "/") { + return pkgPath + "/" + clean + } + return clean +} diff --git a/testjson/github_actions_format_test.go b/testjson/github_actions_format_test.go new file mode 100644 index 00000000..b920bf1f --- /dev/null +++ b/testjson/github_actions_format_test.go @@ -0,0 +1,142 @@ +package testjson + +import ( + "bufio" + "bytes" + "testing" + + "gotest.tools/v3/assert" +) + +func flushGitHubActionsBuffer(t *testing.T, buf *bufio.Writer, out *bytes.Buffer) string { + t.Helper() + assert.NilError(t, buf.Flush()) + return out.String() +} + +func TestWriteGitHubActionsError_FailureAnnotations(t *testing.T) { + out := new(bytes.Buffer) + writer := bufio.NewWriter(out) + + event := TestEvent{Test: "pkg.TestFailure"} + lines := []string{"\tfailure_test.go:42: something went wrong"} + + writeGitHubActionsError(writer, event, lines, newGitHubActionsErrorPatterns()) + + assert.Equal(t, + flushGitHubActionsBuffer(t, writer, out), + "::error file=failure_test.go,line=42,title=pkg.TestFailure::something went wrong\n", + ) +} + +func TestWriteGitHubActionsError_PanicPrefersTestFile(t *testing.T) { + out := new(bytes.Buffer) + writer := bufio.NewWriter(out) + + event := TestEvent{Test: "pkg.TestPanics"} + lines := []string{ + "panic: runtime error: index out of range", + "\t/usr/local/go/src/runtime/panic.go:88 +0x123", + "\t/home/user/project/example_test.go:45 +0x456", + "\t/home/user/project/example.go:12 +0x222", + } + + writeGitHubActionsError(writer, event, lines, newGitHubActionsErrorPatterns()) + + assert.Equal(t, + flushGitHubActionsBuffer(t, writer, out), + "::error file=example_test.go,line=45,title=pkg.TestPanics::panic: runtime error: index out of range\n", + ) +} + +func TestWriteGitHubActionsError_PanicRequiresStrictMatch(t *testing.T) { + out := new(bytes.Buffer) + writer := bufio.NewWriter(out) + + event := TestEvent{Test: "pkg.TestLogsPanicWord"} + lines := []string{"\tfailure_test.go:12: panic: not a real panic"} + + writeGitHubActionsError(writer, event, lines, newGitHubActionsErrorPatterns()) + + assert.Equal(t, + flushGitHubActionsBuffer(t, writer, out), + "::error file=failure_test.go,line=12,title=pkg.TestLogsPanicWord::panic: not a real panic\n", + ) +} + +func TestWriteGitHubActionsError_IgnoresNonTestLogs(t *testing.T) { + out := new(bytes.Buffer) + writer := bufio.NewWriter(out) + + event := TestEvent{Test: "pkg.TestWithTelemetry"} + lines := []string{ + "\texample.go:140: [request-handler.log] 2025-12-04T17:55:24Z INFO Worker [RequestHandler] finished", + } + + writeGitHubActionsError(writer, event, lines, newGitHubActionsErrorPatterns()) + + assert.Equal(t, flushGitHubActionsBuffer(t, writer, out), "") +} + +func TestWriteGitHubActionsError_UsesAdditionalLinesForMessage(t *testing.T) { + out := new(bytes.Buffer) + writer := bufio.NewWriter(out) + + event := TestEvent{Test: "pkg.TestHasDiff"} + lines := []string{ + "\tmy_integration_test.go:42:", + "\t\tExpected", + "\t\t : 0", + "\t\tto equal", + "\t\t : 1", + "", + } + + writeGitHubActionsError(writer, event, lines, newGitHubActionsErrorPatterns()) + + assert.Equal(t, + flushGitHubActionsBuffer(t, writer, out), + "::error file=my_integration_test.go,line=42,title=pkg.TestHasDiff::Expected : 0 to equal : 1\n", + ) +} + +func TestWriteGitHubActionsError_IncludesRepoRelativeFile(t *testing.T) { + patchPkgPathPrefix(t, "github.com/example/project") + out := new(bytes.Buffer) + writer := bufio.NewWriter(out) + + event := TestEvent{ + Test: "pkg.TestHasFailure", + Package: "github.com/example/project/internal/foo", + } + lines := []string{"\tfoo_test.go:12: boom"} + + writeGitHubActionsError(writer, event, lines, newGitHubActionsErrorPatterns()) + + assert.Equal(t, + flushGitHubActionsBuffer(t, writer, out), + "::error file=internal/foo/foo_test.go,line=12,title=pkg.TestHasFailure::boom\n", + ) +} + +func TestWriteGitHubActionsError_PanicUsesRepoRelativeFile(t *testing.T) { + patchPkgPathPrefix(t, "github.com/example/project") + out := new(bytes.Buffer) + writer := bufio.NewWriter(out) + + event := TestEvent{ + Test: "pkg.TestPanicsHard", + Package: "github.com/example/project/pkg/bar", + } + lines := []string{ + "panic: oh no", + "\t/home/runner/work/project/pkg/bar/bar_test.go:55 +0x123", + } + + writeGitHubActionsError(writer, event, lines, newGitHubActionsErrorPatterns()) + + assert.Equal(t, + flushGitHubActionsBuffer(t, writer, out), + "::error file=pkg/bar/bar_test.go,line=55,title=pkg.TestPanicsHard::panic: oh no\n", + ) +} diff --git a/testjson/testdata/format/github-actions.out b/testjson/testdata/format/github-actions.out index 0e6f3250..b1770941 100644 --- a/testjson/testdata/format/github-actions.out +++ b/testjson/testdata/format/github-actions.out @@ -74,35 +74,42 @@ this is a Print this is stderr ::endgroup:: +::error file=testjson/internal/parallelfails/fails_test.go,line=50,title=TestNestedParallelFailures/a::failed sub a ::group::FAIL testjson/internal/parallelfails.TestNestedParallelFailures/a (0.00s) fails_test.go:50: failed sub a --- FAIL: TestNestedParallelFailures/a (0.00s) ::endgroup:: +::error file=testjson/internal/parallelfails/fails_test.go,line=50,title=TestNestedParallelFailures/d::failed sub d ::group::FAIL testjson/internal/parallelfails.TestNestedParallelFailures/d (0.00s) fails_test.go:50: failed sub d --- FAIL: TestNestedParallelFailures/d (0.00s) ::endgroup:: +::error file=testjson/internal/parallelfails/fails_test.go,line=50,title=TestNestedParallelFailures/c::failed sub c ::group::FAIL testjson/internal/parallelfails.TestNestedParallelFailures/c (0.00s) fails_test.go:50: failed sub c --- FAIL: TestNestedParallelFailures/c (0.00s) ::endgroup:: +::error file=testjson/internal/parallelfails/fails_test.go,line=50,title=TestNestedParallelFailures/b::failed sub b ::group::FAIL testjson/internal/parallelfails.TestNestedParallelFailures/b (0.00s) fails_test.go:50: failed sub b --- FAIL: TestNestedParallelFailures/b (0.00s) ::endgroup:: FAIL testjson/internal/parallelfails.TestNestedParallelFailures (0.00s) +::error file=testjson/internal/parallelfails/fails_test.go,line=29,title=TestParallelTheFirst::failed the first ::group::FAIL testjson/internal/parallelfails.TestParallelTheFirst (0.01s) fails_test.go:29: failed the first ::endgroup:: +::error file=testjson/internal/parallelfails/fails_test.go,line=41,title=TestParallelTheThird::failed the third ::group::FAIL testjson/internal/parallelfails.TestParallelTheThird (0.00s) fails_test.go:41: failed the third ::endgroup:: +::error file=testjson/internal/parallelfails/fails_test.go,line=35,title=TestParallelTheSecond::failed the second ::group::FAIL testjson/internal/parallelfails.TestParallelTheSecond (0.01s) fails_test.go:35: failed the second @@ -126,6 +133,7 @@ this is a Print fails_test.go:30: the skip message ::endgroup:: +::error file=testjson/internal/withfails/fails_test.go,line=34,title=TestFailed::this failed ::group::FAIL testjson/internal/withfails.TestFailed (0.00s) fails_test.go:34: this failed @@ -134,6 +142,7 @@ this is a Print this is stderr ::endgroup:: +::error file=testjson/internal/withfails/fails_test.go,line=43,title=TestFailedWithStderr::also failed ::group::FAIL testjson/internal/withfails.TestFailedWithStderr (0.00s) this is stderr fails_test.go:43: also failed @@ -155,6 +164,7 @@ this is stderr --- PASS: TestNestedWithFailure/b (0.00s) ::endgroup:: +::error file=testjson/internal/withfails/fails_test.go,line=65,title=TestNestedWithFailure/c::failed ::group::FAIL testjson/internal/withfails.TestNestedWithFailure/c (0.00s) fails_test.go:65: failed --- FAIL: TestNestedWithFailure/c (0.00s)