Skip to content

Commit 38fe804

Browse files
committed
JUnit summary with failures
1 parent 956f6ec commit 38fe804

10 files changed

Lines changed: 872 additions & 71 deletions

File tree

.github/actions/get-job-id/action.yml

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,28 +30,44 @@ runs:
3030
# The script will first try to match by (name, runner_name), then fall back to name-only.
3131
run: |
3232
set -euo pipefail
33+
3334
job_url="https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/jobs?per_page=100"
34-
json=$(curl -sSL \
35-
-H "Accept: application/vnd.github+json" \
36-
-H "Authorization: Bearer $GITHUB_TOKEN" \
37-
"$job_url")
35+
max_retries=6
36+
job_id=""
37+
json=""
3838
39-
# Prefer matching both job name and the current runner name to disambiguate matrix jobs
40-
job_id=$(jq -r --arg name "$JOB_NAME" --arg runner "$RUNNER_NAME" '
41-
(.jobs // [])
42-
| map(select(.name == $name and (.runner_name // "") == $runner))
43-
| (.[0].id // empty)
44-
' <<< "$json" )
39+
for ((attempt = 1; attempt <= max_retries; attempt++)); do
40+
json=$(curl -sSL \
41+
-H "Accept: application/vnd.github+json" \
42+
-H "Authorization: Bearer $GITHUB_TOKEN" \
43+
"$job_url")
4544
46-
# Fallback: match by name only
47-
if [ -z "${job_id:-}" ]; then
48-
job_id=$(jq -r --arg name "$JOB_NAME" '
49-
(.jobs // []) | map(select(.name == $name)) | (.[0].id // empty)
45+
# Prefer matching both job name and the current runner name to disambiguate matrix jobs
46+
job_id=$(jq -r --arg name "$JOB_NAME" --arg runner "$RUNNER_NAME" '
47+
(.jobs // [])
48+
| map(select(.name == $name and (.runner_name // "") == $runner))
49+
| (.[0].id // empty)
5050
' <<< "$json" )
51-
fi
51+
52+
# Fallback: match by name only
53+
if [ -z "${job_id:-}" ]; then
54+
job_id=$(jq -r --arg name "$JOB_NAME" '
55+
(.jobs // []) | map(select(.name == $name)) | (.[0].id // empty)
56+
' <<< "$json" )
57+
fi
58+
59+
if [ -n "${job_id:-}" ] && [ "$job_id" != "null" ]; then
60+
break
61+
fi
62+
63+
if [ "$attempt" -lt "$max_retries" ]; then
64+
echo "::notice::Job ID for '$JOB_NAME' not visible yet (attempt $attempt/$max_retries); retrying in 5s"
65+
sleep 5
66+
fi
67+
done
5268
5369
if [ -z "${job_id:-}" ] || [ "$job_id" = "null" ]; then
54-
echo "::error::Failed to resolve job ID for name '$JOB_NAME' on runner '$RUNNER_NAME' in run '$RUN_ID'" >&2
70+
echo "::error::Failed to resolve job ID for name '$JOB_NAME' on runner '$RUNNER_NAME' in run '$RUN_ID' after retries" >&2
5571
exit 1
5672
fi
5773
echo "job_id=$job_id" >> "$GITHUB_OUTPUT"

.github/workflows/run-tests.yml

Lines changed: 32 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,13 @@ jobs:
314314
path: ~/.cache/go-build
315315
key: go-${{ runner.os }}${{ runner.arch }}-build-${{ env.COMMIT }}
316316

317+
- name: Get job ID
318+
id: get_job_id
319+
uses: ./.github/actions/get-job-id
320+
with:
321+
job_name: Unit test
322+
run_id: ${{ github.run_id }}
323+
317324
- name: Run unit tests
318325
timeout-minutes: 20
319326
run: TEST_TIMEOUT=15m ./develop/github/monitor_test.sh make unit-test-coverage
@@ -328,15 +335,13 @@ jobs:
328335
[ "$(find .testoutput -maxdepth 1 -name 'junit.*.xml' | wc -l)" -lt "$MAX_TEST_ATTEMPTS" ] &&
329336
CRASH_REPORT_NAME="$GITHUB_JOB" make report-test-crash
330337
331-
- name: Generate test summary
332-
uses: mikepenz/action-junit-report@v6
338+
- name: Write test summary
333339
if: ${{ !cancelled() }}
334-
with:
335-
report_paths: ./.testoutput/junit.*.xml
336-
detailed_summary: true
337-
check_annotations: false
338-
annotate_only: true
339-
skip_annotations: true
340+
run: |
341+
summary="$(make -s write-test-summary)"
342+
if [ -n "$summary" ]; then
343+
printf '%s\n' "$summary" > "$GITHUB_STEP_SUMMARY"
344+
fi
340345
341346
- name: Upload code coverage to Codecov
342347
uses: codecov/codecov-action@v5
@@ -354,13 +359,6 @@ jobs:
354359
flags: unit-test
355360
report_type: test_results
356361

357-
- name: Get job ID
358-
id: get_job_id
359-
uses: ./.github/actions/get-job-id
360-
with:
361-
job_name: Unit test
362-
run_id: ${{ github.run_id }}
363-
364362
- name: Upload test results to GitHub
365363
# Can't pin to major because the action linter doesn't recognize the include-hidden-files flag.
366364
uses: actions/upload-artifact@v6
@@ -414,6 +412,13 @@ jobs:
414412
# shellcheck disable=SC2046
415413
docker compose -f ${{ env.DOCKER_COMPOSE_FILE }} up --wait $(docker compose -f ${{ env.DOCKER_COMPOSE_FILE }} ps --services)
416414
415+
- name: Get job ID
416+
id: get_job_id
417+
uses: ./.github/actions/get-job-id
418+
with:
419+
job_name: Integration test
420+
run_id: ${{ github.run_id }}
421+
417422
- name: Run integration test
418423
timeout-minutes: 15
419424
run: ./develop/github/monitor_test.sh make integration-test-coverage
@@ -428,15 +433,13 @@ jobs:
428433
[ "$(find .testoutput -maxdepth 1 -name 'junit.*.xml' | wc -l)" -lt "$MAX_TEST_ATTEMPTS" ] &&
429434
CRASH_REPORT_NAME="$GITHUB_JOB" make report-test-crash
430435
431-
- name: Generate test summary
432-
uses: mikepenz/action-junit-report@v6
436+
- name: Write test summary
433437
if: ${{ !cancelled() }}
434-
with:
435-
report_paths: ./.testoutput/junit.*.xml
436-
detailed_summary: true
437-
check_annotations: false
438-
annotate_only: true
439-
skip_annotations: true
438+
run: |
439+
summary="$(make -s write-test-summary)"
440+
if [ -n "$summary" ]; then
441+
printf '%s\n' "$summary" > "$GITHUB_STEP_SUMMARY"
442+
fi
440443
441444
- name: Upload code coverage to Codecov
442445
uses: codecov/codecov-action@v5
@@ -454,13 +457,6 @@ jobs:
454457
flags: integration-test
455458
report_type: test_results
456459

457-
- name: Get job ID
458-
id: get_job_id
459-
uses: ./.github/actions/get-job-id
460-
with:
461-
job_name: Integration test
462-
run_id: ${{ github.run_id }}
463-
464460
- name: Upload test results to GitHub
465461
# Can't pin to major because the action linter doesn't recognize the include-hidden-files flag.
466462
uses: actions/upload-artifact@v6
@@ -567,15 +563,13 @@ jobs:
567563
[ "$(find .testoutput -maxdepth 1 -name 'junit.*.xml' | wc -l)" -lt "$MAX_TEST_ATTEMPTS" ] &&
568564
CRASH_REPORT_NAME="$GITHUB_JOB" make report-test-crash
569565
570-
- name: Generate test summary
571-
uses: mikepenz/action-junit-report@v6
566+
- name: Write test summary
572567
if: ${{ !cancelled() }}
573-
with:
574-
report_paths: ./.testoutput/junit.*.xml
575-
detailed_summary: true
576-
check_annotations: false
577-
annotate_only: true
578-
skip_annotations: true
568+
run: |
569+
summary="$(make -s write-test-summary)"
570+
if [ -n "$summary" ]; then
571+
printf '%s\n' "$summary" > "$GITHUB_STEP_SUMMARY"
572+
fi
579573
580574
- name: Upload code coverage to Codecov
581575
uses: codecov/codecov-action@v5

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,10 @@ report-test-crash: $(TEST_OUTPUT_ROOT)
557557
--junitfile=$(TEST_OUTPUT_ROOT)/junit.crash.xml \
558558
--crashreportname=$(CRASH_REPORT_NAME)
559559

560+
write-test-summary: $(TEST_OUTPUT_ROOT)
561+
@go run ./cmd/tools/test-runner write-summary \
562+
--junit-glob=$(TEST_OUTPUT_ROOT)/junit.*.xml
563+
560564
##### Schema #####
561565
install-schema-cass-es: temporal-cassandra-tool install-schema-es
562566
@printf $(COLOR) "Install Cassandra schema..."

tools/testrunner/junit.go

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import (
1313
"github.com/jstemmer/go-junit-report/v2/junit"
1414
)
1515

16+
// alertsSuiteName is the JUnit suite name used for structural alerts (data
17+
// races, panics, fatal errors). It is also used by collectSummaryRows to
18+
// distinguish alert entries from regular test failures.
19+
const alertsSuiteName = "ALERTS"
20+
const junitAlertDetailsMaxBytes = 64 * 1024
21+
1622
type junitReport struct {
1723
junit.Testsuites
1824
path string
@@ -83,19 +89,22 @@ func (j *junitReport) appendAlertsSuite(alerts []alert) {
8389
if p := primaryTestName(a.Tests); p != "" {
8490
name = fmt.Sprintf("%s — in %s", name, p)
8591
}
86-
// Include only test names for context, not the full log details to avoid XML malformation
87-
var details string
92+
var sb strings.Builder
93+
if a.Details != "" {
94+
sb.WriteString(truncateAlertDetails(sanitizeXML(a.Details)))
95+
sb.WriteByte('\n')
96+
}
8897
if len(a.Tests) > 0 {
89-
details = fmt.Sprintf("Detected in tests:\n\t%s", strings.Join(a.Tests, "\n\t"))
98+
fmt.Fprintf(&sb, "Detected in tests:\n\t%s", strings.Join(a.Tests, "\n\t"))
9099
}
91-
r := &junit.Result{Message: string(a.Kind), Data: details}
100+
r := &junit.Result{Message: string(a.Kind), Data: strings.TrimRight(sb.String(), "\n")}
92101
cases = append(cases, junit.Testcase{
93102
Name: name,
94103
Failure: r,
95104
})
96105
}
97106
suite := junit.Testsuite{
98-
Name: "ALERTS",
107+
Name: alertsSuiteName,
99108
Failures: len(cases),
100109
Tests: len(cases),
101110
Testcases: cases,
@@ -105,6 +114,35 @@ func (j *junitReport) appendAlertsSuite(alerts []alert) {
105114
j.Tests += suite.Tests
106115
}
107116

117+
// sanitizeXML removes characters that are invalid in XML 1.0. Go's xml.Encoder
118+
// escapes <, >, & etc., but control characters other than \t, \n, \r are not
119+
// legal XML and cause parsers to reject the document.
120+
func sanitizeXML(s string) string {
121+
return strings.Map(func(r rune) rune {
122+
if r == '\t' || r == '\n' || r == '\r' {
123+
return r
124+
}
125+
if r < 0x20 || r == 0xFFFE || r == 0xFFFF {
126+
return -1
127+
}
128+
return r
129+
}, s)
130+
}
131+
132+
func truncateAlertDetails(s string) string {
133+
if len(s) <= junitAlertDetailsMaxBytes {
134+
return s
135+
}
136+
const marker = "\n... (truncated) ...\n"
137+
if junitAlertDetailsMaxBytes <= len(marker) {
138+
return s[:junitAlertDetailsMaxBytes]
139+
}
140+
remaining := junitAlertDetailsMaxBytes - len(marker)
141+
head := remaining / 2
142+
tail := remaining - head
143+
return s[:head] + marker + s[len(s)-tail:]
144+
}
145+
108146
// dedupeAlerts removes duplicate alerts (e.g., repeated across retries) based
109147
// on kind and details while preserving the first-seen order.
110148
func dedupeAlerts(alerts []alert) []alert {
@@ -224,6 +262,11 @@ func mergeReports(reports []*junitReport) (*junitReport, error) {
224262
// Discard test case parents since they provide no value.
225263
continue
226264
}
265+
if testCase.Failure != nil && testCase.Failure.Data != "" {
266+
if details := parseFailureDetails(testCase.Failure.Data); details != noFailureDetails {
267+
testCase.Failure.Data = details
268+
}
269+
}
227270
testCase.Name += suffix
228271
newSuite.Testcases = append(newSuite.Testcases, testCase)
229272
}

tools/testrunner/junit_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"os"
77
"slices"
8+
"strings"
89
"testing"
910

1011
"github.com/jstemmer/go-junit-report/v2/junit"
@@ -73,6 +74,19 @@ func TestMergeReports_SingleReport(t *testing.T) {
7374
require.Len(t, testNames, 5)
7475
require.NotContains(t, testNames, "TestCallbacksSuite")
7576
require.NotContains(t, testNames, "TestCallbacksSuite/TestWorkflowNexusCallbacks_CarriedOver")
77+
var failureData string
78+
for _, tc := range suites[0].Testcases {
79+
if tc.Name == "TestCallbacksSuite/TestWorkflowCallbacks_InvalidArgument" {
80+
require.NotNil(t, tc.Failure)
81+
failureData = tc.Failure.Data
82+
break
83+
}
84+
}
85+
require.NotEmpty(t, failureData)
86+
require.Contains(t, failureData, "Error Trace:")
87+
require.Contains(t, failureData, "expected: 1")
88+
require.NotContains(t, failureData, "=== RUN")
89+
require.NotContains(t, failureData, "--- FAIL:")
7690
}
7791

7892
func TestMergeReports_MultipleReports(t *testing.T) {
@@ -164,6 +178,56 @@ func TestAppendAlertsSuite(t *testing.T) {
164178
requireReportEquals(t, "testdata/junit-alerts-output.xml", out.Name())
165179
}
166180

181+
func TestAppendAlertsSuite_TruncatesLargeDetails(t *testing.T) {
182+
j := &junitReport{}
183+
details := "panic: large panic\n" + strings.Repeat("x", junitAlertDetailsMaxBytes) + "\ntrailing sentinel"
184+
j.appendAlertsSuite([]alert{{
185+
Kind: alertKindPanic,
186+
Summary: "large panic",
187+
Details: details,
188+
Tests: []string{"TestLargePanic"},
189+
}})
190+
191+
require.Len(t, j.Suites, 1)
192+
require.Len(t, j.Suites[0].Testcases, 1)
193+
got := j.Suites[0].Testcases[0].Failure.Data
194+
require.Contains(t, got, "... (truncated) ...")
195+
require.Contains(t, got, "panic: large panic")
196+
require.Contains(t, got, "trailing sentinel")
197+
require.LessOrEqual(t, len(got), junitAlertDetailsMaxBytes+len("\nDetected in tests:\n\tTestLargePanic"))
198+
}
199+
200+
func TestMergeReports_PreservesOriginalFailureDataWhenExtractionFindsNothing(t *testing.T) {
201+
report := &junitReport{
202+
Testsuites: junit.Testsuites{
203+
Suites: []junit.Testsuite{
204+
{
205+
Name: "suite",
206+
Testcases: []junit.Testcase{
207+
{
208+
Name: "TestStandalone",
209+
Failure: &junit.Result{
210+
Message: "Failed",
211+
Data: "plain failure output with no recognizable block",
212+
},
213+
},
214+
},
215+
Failures: 1,
216+
Tests: 1,
217+
},
218+
},
219+
Failures: 1,
220+
Tests: 1,
221+
},
222+
}
223+
224+
merged, err := mergeReports([]*junitReport{report})
225+
require.NoError(t, err)
226+
require.Len(t, merged.Suites, 1)
227+
require.Len(t, merged.Suites[0].Testcases, 1)
228+
require.Equal(t, "plain failure output with no recognizable block", merged.Suites[0].Testcases[0].Failure.Data)
229+
}
230+
167231
func collectTestNames(suites []junit.Testsuite) []string {
168232
var testNames []string
169233
for _, suite := range suites {

0 commit comments

Comments
 (0)