Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
109 changes: 109 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,86 @@ 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 {
stackFile := filepath.Base(matches[1])
stackLine := matches[2]
isTestFile := strings.HasSuffix(stackFile, "_test.go")

if file == "" || isTestFile {
file = stackFile
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 _, outputLine := range outputLines {
if matches := patterns.fileLine.FindStringSubmatch(outputLine); len(matches) == 3 {
file := matches[1]
line := matches[2]

parts := strings.SplitN(outputLine, ":", 3)
var message string
if len(parts) >= 3 {
message = strings.TrimSpace(parts[2])
}
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))
}
}
}
}
65 changes: 65 additions & 0 deletions testjson/github_actions_format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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",
)
}
10 changes: 10 additions & 0 deletions testjson/testdata/format/github-actions.out
Original file line number Diff line number Diff line change
Expand Up @@ -74,35 +74,42 @@ this is a Print
this is stderr

::endgroup::
::error file=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=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=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=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=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=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=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

Expand All @@ -126,6 +133,7 @@ this is a Print
fails_test.go:30: the skip message

::endgroup::
::error file=fails_test.go,line=34,title=TestFailed::this failed
::group::FAIL testjson/internal/withfails.TestFailed (0.00s)
fails_test.go:34: this failed

Expand All @@ -134,6 +142,7 @@ this is a Print
this is stderr

::endgroup::
::error file=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
Expand All @@ -155,6 +164,7 @@ this is stderr
--- PASS: TestNestedWithFailure/b (0.00s)

::endgroup::
::error file=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)
Expand Down