Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions testjson/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"

Expand Down Expand Up @@ -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)

Expand All @@ -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}

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
142 changes: 142 additions & 0 deletions testjson/github_actions_format_test.go
Original file line number Diff line number Diff line change
@@ -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 <int>: 0",
"\t\tto equal",
"\t\t <int>: 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 <int>: 0 to equal <int>: 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",
)
}
Loading