Skip to content

Commit 7004151

Browse files
INT-7: Add support for retry attempts in test results reporting (#1072)
* Add support for retry attempts in test results reporting - Updated GitHub summary to include attempts in the report header. - Enhanced JUnit reporter to add properties for retry attempts. - Added tests for rendering reports with retry attempts in both JSON and GitHub formats. - Modified TestResult struct to include attempts in the JSON output. * updated time import position in code * show retries in summary only when there was retry attempt made. * fix whitespace linter issues * refactor: replace custom string search function with strings.Contains * remove white space
1 parent 5e39163 commit 7004151

File tree

6 files changed

+258
-9
lines changed

6 files changed

+258
-9
lines changed

internal/report/github/summary.go

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,25 +44,35 @@ func hasDevice(results []report.TestResult) bool {
4444
return false
4545
}
4646

47+
func hasRetries(results []report.TestResult) bool {
48+
for _, t := range results {
49+
if len(t.Attempts) > 1 {
50+
return true
51+
}
52+
}
53+
return false
54+
}
55+
4756
func (r *Reporter) Render() {
4857
if !r.isActive() {
4958
return
5059
}
5160

5261
endTime := time.Now()
5362
hasDevices := hasDevice(r.results)
63+
showRetries := hasRetries(r.results)
5464
errors := 0
5565
inProgress := 0
5666

57-
content := renderHeader(hasDevices)
67+
content := renderHeader(hasDevices, showRetries)
5868
for _, result := range r.results {
5969
if result.Status == job.StateInProgress || result.Status == job.StateNew {
6070
inProgress++
6171
}
6272
if result.Status == job.StateFailed || result.Status == job.StateError {
6373
errors++
6474
}
65-
content += renderTestResult(result, hasDevices)
75+
content += renderTestResult(result, hasDevices, showRetries)
6676
}
6777
content += renderFooter(errors, inProgress, len(r.results), endTime.Sub(r.startTime))
6878

@@ -82,15 +92,21 @@ func (r *Reporter) ArtifactRequirements() []report.ArtifactType {
8292
return []report.ArtifactType{}
8393
}
8494

85-
func renderHeader(hasDevices bool) string {
95+
func renderHeader(hasDevices bool, showRetries bool) string {
8696
deviceTitle := ""
8797
deviceSeparator := ""
8898
if hasDevices {
8999
deviceTitle = " Device |"
90100
deviceSeparator = " --- |"
91101
}
92-
content := fmt.Sprintf("| | Name | Duration | Status | Browser | Platform |%s\n", deviceTitle)
93-
content += fmt.Sprintf("| --- | --- | --- | --- | --- | --- |%s\n", deviceSeparator)
102+
retriesTitle := ""
103+
retriesSeparator := ""
104+
if showRetries {
105+
retriesTitle = " Attempts |"
106+
retriesSeparator = " --- |"
107+
}
108+
content := fmt.Sprintf("| | Name | Duration | Status | Browser | Platform |%s%s\n", deviceTitle, retriesTitle)
109+
content += fmt.Sprintf("| --- | --- | --- | --- | --- | --- |%s%s\n", deviceSeparator, retriesSeparator)
94110
return content
95111
}
96112

@@ -109,17 +125,21 @@ func statusToEmoji(status string) string {
109125
}
110126
}
111127

112-
func renderTestResult(t report.TestResult, hasDevices bool) string {
128+
func renderTestResult(t report.TestResult, hasDevices bool, showRetries bool) string {
113129
content := ""
114130

115131
mark := statusToEmoji(t.Status)
116132
deviceValue := ""
117133
if hasDevices {
118134
deviceValue = fmt.Sprintf(" %s |", t.DeviceName)
119135
}
136+
retriesValue := ""
137+
if showRetries {
138+
retriesValue = fmt.Sprintf(" %d |", len(t.Attempts))
139+
}
120140

121-
content += fmt.Sprintf("| %s | [%s](%s) | %.0fs | %s | %s | %s |%s\n",
122-
mark, t.Name, t.URL, t.Duration.Seconds(), t.Status, t.Browser, t.Platform, deviceValue)
141+
content += fmt.Sprintf("| %s | [%s](%s) | %.0fs | %s | %s | %s |%s%s\n",
142+
mark, t.Name, t.URL, t.Duration.Seconds(), t.Status, t.Browser, t.Platform, deviceValue, retriesValue)
123143
return content
124144
}
125145

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package github
2+
3+
import (
4+
"os"
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"github.com/saucelabs/saucectl/internal/job"
10+
"github.com/saucelabs/saucectl/internal/report"
11+
)
12+
13+
func TestReporter_RenderWithAttempts(t *testing.T) {
14+
f, err := os.CreateTemp("", "github-step-summary")
15+
if err != nil {
16+
t.Fatalf("Failed to create temp file: %s", err)
17+
}
18+
defer os.Remove(f.Name())
19+
f.Close()
20+
21+
r := &Reporter{
22+
startTime: time.Now(),
23+
stepSummaryFile: f.Name(),
24+
}
25+
26+
r.Add(report.TestResult{
27+
Name: "Login Tests",
28+
Duration: 90 * time.Second,
29+
Status: job.StatePassed,
30+
Browser: "Chrome 120",
31+
Platform: "Windows 11",
32+
URL: "https://app.saucelabs.com/tests/job-3",
33+
Attempts: []report.Attempt{
34+
{ID: "job-1", Status: job.StateFailed},
35+
{ID: "job-2", Status: job.StateFailed},
36+
{ID: "job-3", Status: job.StatePassed},
37+
},
38+
})
39+
r.Add(report.TestResult{
40+
Name: "Checkout Tests",
41+
Duration: 45 * time.Second,
42+
Status: job.StatePassed,
43+
Browser: "Firefox 121",
44+
Platform: "macOS 13",
45+
URL: "https://app.saucelabs.com/tests/job-4",
46+
Attempts: []report.Attempt{
47+
{ID: "job-4", Status: job.StatePassed},
48+
},
49+
})
50+
51+
r.Render()
52+
53+
data, err := os.ReadFile(f.Name())
54+
if err != nil {
55+
t.Fatalf("Failed to read output file: %s", err)
56+
}
57+
58+
content := string(data)
59+
60+
// Verify header has Attempts column
61+
if !strings.Contains(content, "Attempts |") {
62+
t.Errorf("Expected 'Attempts' column in header, got:\n%s", content)
63+
}
64+
// Verify Login Tests row shows 3 attempts
65+
if !strings.Contains(content, "| 3 |") {
66+
t.Errorf("Expected '| 3 |' for Login Tests attempts, got:\n%s", content)
67+
}
68+
// Verify Checkout Tests row shows 1 attempt
69+
if !strings.Contains(content, "| 1 |") {
70+
t.Errorf("Expected '| 1 |' for Checkout Tests attempts, got:\n%s", content)
71+
}
72+
}

internal/report/json/json_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package json
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
"github.com/saucelabs/saucectl/internal/job"
11+
"github.com/saucelabs/saucectl/internal/report"
12+
)
13+
14+
func TestReporter_RenderWithAttempts(t *testing.T) {
15+
f, err := os.CreateTemp("", "saucectl-report.json")
16+
if err != nil {
17+
t.Fatalf("Failed to create temp file: %s", err)
18+
}
19+
defer os.Remove(f.Name())
20+
f.Close()
21+
22+
r := &Reporter{
23+
Filename: f.Name(),
24+
Results: []report.TestResult{
25+
{
26+
Name: "Login Tests",
27+
Duration: 90 * time.Second,
28+
Status: job.StatePassed,
29+
Browser: "Chrome 120",
30+
Platform: "Windows 11",
31+
Attempts: []report.Attempt{
32+
{ID: "job-1", Status: job.StateFailed, Duration: 30 * time.Second},
33+
{ID: "job-2", Status: job.StateFailed, Duration: 28 * time.Second},
34+
{ID: "job-3", Status: job.StatePassed, Duration: 25 * time.Second},
35+
},
36+
},
37+
},
38+
}
39+
r.Render()
40+
41+
data, err := os.ReadFile(f.Name())
42+
if err != nil {
43+
t.Fatalf("Failed to read output file: %s", err)
44+
}
45+
46+
// Parse back to verify structure
47+
var results []report.TestResult
48+
if err := json.Unmarshal(data, &results); err != nil {
49+
t.Fatalf("Failed to parse JSON output: %s", err)
50+
}
51+
52+
if len(results) != 1 {
53+
t.Fatalf("Expected 1 result, got %d", len(results))
54+
}
55+
if len(results[0].Attempts) != 3 {
56+
t.Fatalf("Expected 3 attempts, got %d", len(results[0].Attempts))
57+
}
58+
if results[0].Attempts[0].Status != job.StateFailed {
59+
t.Errorf("Expected attempt 0 status %q, got %q", job.StateFailed, results[0].Attempts[0].Status)
60+
}
61+
if results[0].Attempts[0].ID != "job-1" {
62+
t.Errorf("Expected attempt 0 ID %q, got %q", "job-1", results[0].Attempts[0].ID)
63+
}
64+
if results[0].Attempts[2].Status != job.StatePassed {
65+
t.Errorf("Expected attempt 2 status %q, got %q", job.StatePassed, results[0].Attempts[2].Status)
66+
}
67+
}
68+
69+
func TestReporter_RenderNoAttempts(t *testing.T) {
70+
f, err := os.CreateTemp("", "saucectl-report.json")
71+
if err != nil {
72+
t.Fatalf("Failed to create temp file: %s", err)
73+
}
74+
defer os.Remove(f.Name())
75+
f.Close()
76+
77+
r := &Reporter{
78+
Filename: f.Name(),
79+
Results: []report.TestResult{
80+
{
81+
Name: "Simple Test",
82+
Duration: 10 * time.Second,
83+
Status: job.StatePassed,
84+
},
85+
},
86+
}
87+
r.Render()
88+
89+
data, err := os.ReadFile(f.Name())
90+
if err != nil {
91+
t.Fatalf("Failed to read output file: %s", err)
92+
}
93+
94+
// With omitempty, the "attempts" key should not appear when slice is empty
95+
raw := string(data)
96+
if strings.Contains(raw, `"attempts"`) {
97+
t.Errorf("Expected no 'attempts' key in JSON when Attempts is empty, got:\n%s", raw)
98+
}
99+
}

internal/report/junit/junit.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"strconv"
88
"sync"
9+
"time"
910

1011
"github.com/rs/zerolog/log"
1112
"github.com/saucelabs/saucectl/internal/junit"
@@ -94,6 +95,22 @@ func extractProperties(r report.TestResult) []junit.Property {
9495
},
9596
}
9697

98+
// Add retry attempt properties when more than one attempt was made.
99+
if len(r.Attempts) > 1 {
100+
props = append(props,
101+
junit.Property{Name: "retried", Value: "true"},
102+
junit.Property{Name: "retries", Value: strconv.Itoa(len(r.Attempts))},
103+
)
104+
for i, a := range r.Attempts {
105+
prefix := fmt.Sprintf("attempt.%d.", i)
106+
props = append(props,
107+
junit.Property{Name: prefix + "id", Value: a.ID},
108+
junit.Property{Name: prefix + "status", Value: a.Status},
109+
junit.Property{Name: prefix + "duration", Value: fmt.Sprintf("%.0f", a.Duration.Truncate(time.Second).Seconds())},
110+
)
111+
}
112+
}
113+
97114
var filtered []junit.Property
98115
for _, p := range props {
99116
// we don't want to display properties with empty values

internal/report/junit/junit_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,47 @@ func TestReporter_Render(t *testing.T) {
9292
</properties>
9393
</testsuite>
9494
</testsuites>
95+
`,
96+
},
97+
{
98+
name: "with retries",
99+
fields: fields{
100+
TestResults: []report.TestResult{
101+
{
102+
Name: "Chrome",
103+
Duration: 90 * time.Second,
104+
Status: job.StatePassed,
105+
Browser: "Chrome",
106+
Platform: "Windows 11",
107+
URL: "https://app.saucelabs.com/tests/job-3",
108+
Attempts: []report.Attempt{
109+
{ID: "job-1", Status: job.StateFailed, Duration: 30 * time.Second},
110+
{ID: "job-2", Status: job.StateFailed, Duration: 28 * time.Second},
111+
{ID: "job-3", Status: job.StatePassed, Duration: 25 * time.Second},
112+
},
113+
},
114+
},
115+
},
116+
want: `<testsuites>
117+
<testsuite name="Chrome" tests="0" time="90">
118+
<properties>
119+
<property name="url" value="https://app.saucelabs.com/tests/job-3"></property>
120+
<property name="browser" value="Chrome"></property>
121+
<property name="platform" value="Windows 11"></property>
122+
<property name="retried" value="true"></property>
123+
<property name="retries" value="3"></property>
124+
<property name="attempt.0.id" value="job-1"></property>
125+
<property name="attempt.0.status" value="failed"></property>
126+
<property name="attempt.0.duration" value="30"></property>
127+
<property name="attempt.1.id" value="job-2"></property>
128+
<property name="attempt.1.status" value="failed"></property>
129+
<property name="attempt.1.duration" value="28"></property>
130+
<property name="attempt.2.id" value="job-3"></property>
131+
<property name="attempt.2.status" value="passed"></property>
132+
<property name="attempt.2.duration" value="25"></property>
133+
</properties>
134+
</testsuite>
135+
</testsuites>
95136
`,
96137
},
97138
}

internal/report/report.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ type TestResult struct {
3939
RDC bool `json:"-"`
4040
TimedOut bool `json:"-"`
4141
PassThreshold bool `json:"-"`
42-
Attempts []Attempt `json:"-"`
42+
Attempts []Attempt `json:"attempts,omitempty"`
4343
}
4444

4545
// ArtifactType represents the type of assets (e.g. a junit report). Semantically similar to Content-Type.

0 commit comments

Comments
 (0)