-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathcucumber.go
More file actions
266 lines (221 loc) · 8.36 KB
/
cucumber.go
File metadata and controls
266 lines (221 loc) · 8.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
package runner
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"slices"
"strings"
"github.com/buildkite/test-engine-client/internal/debug"
"github.com/buildkite/test-engine-client/internal/plan"
"github.com/kballard/go-shellquote"
)
// Cucumber implements TestRunner for Cucumber (Ruby implementation).
// It follows very similar behaviour to the RSpec runner. We rely on the JSON formatter
// so users MUST include `--format json --out {{resultPath}}` in their custom commands.
//
// We treat every Scenario as an individual test case. A scenario is considered failed
// if any step in it failed or has undefined status. "pending" and "skipped" are
// mapped to TestStatusSkipped.
type Cucumber struct {
RunnerConfig
}
func NewCucumber(c RunnerConfig) Cucumber {
if c.TestCommand == "" {
// The pretty formatter gives a nice progress bar in the console, the JSON formatter is required for bktec.
c.TestCommand = "cucumber --format pretty --format json --out {{resultPath}} {{testExamples}}"
}
if c.TestFilePattern == "" {
c.TestFilePattern = "features/**/*.feature"
}
if c.RetryTestCommand == "" {
c.RetryTestCommand = c.TestCommand
}
return Cucumber{
RunnerConfig: c,
}
}
func (c Cucumber) SupportedFeatures() SupportedFeatures {
return SupportedFeatures{
SplitByFile: true,
SplitByExample: true,
FilterTestFiles: true,
AutoRetry: true,
Mute: true,
Skip: true,
}
}
func (c Cucumber) Name() string {
return "Cucumber"
}
// GetFiles returns the list of feature files based on include / exclude pattern.
func (c Cucumber) GetFiles() ([]string, error) {
debug.Println("Discovering test files with include pattern:", c.TestFilePattern, "exclude pattern:", c.TestFileExcludePattern)
files, err := discoverTestFiles(c.TestFilePattern, c.TestFileExcludePattern)
debug.Println("Discovered", len(files), "files")
if err != nil {
return nil, err
}
if len(files) == 0 {
return nil, fmt.Errorf("no files found with pattern %q and exclude pattern %q", c.TestFilePattern, c.TestFileExcludePattern)
}
return files, nil
}
// Run executes the Cucumber command and records results.
func (c Cucumber) Run(result *RunResult, testCases []plan.TestCase, retry bool) error {
command := c.TestCommand
if retry {
command = c.RetryTestCommand
}
testPaths := make([]string, len(testCases))
for i, tc := range testCases {
testPaths[i] = tc.Path
}
commandName, commandArgs, err := c.commandNameAndArgs(command, testPaths)
if err != nil {
return fmt.Errorf("failed to build command: %w", err)
}
cmd := exec.Command(commandName, commandArgs...)
err = runAndForwardSignal(cmd)
if ProcessSignaledError := new(ProcessSignaledError); errors.As(err, &ProcessSignaledError) {
return err
}
report, parseErr := c.ParseReport(c.ResultPath)
if parseErr != nil {
fmt.Printf("Buildkite Test Engine Client: Failed to read Cucumber JSON output, tests will not be retried: %v", parseErr)
return err
}
// Iterate scenarios.
for _, feature := range report {
for _, scenario := range feature.Elements {
if scenario.Type != "scenario" {
continue
}
status := scenario.AggregatedStatus()
var testStatus TestStatus
switch status {
case "failed", "undefined", "errored":
testStatus = TestStatusFailed
case "passed":
testStatus = TestStatusPassed
case "pending", "skipped" /* cucumber-js uses skipped */ :
testStatus = TestStatusSkipped
default:
testStatus = TestStatusSkipped
}
fileLinePath := fmt.Sprintf("%s:%d", feature.URI, scenario.Line)
testCaseForResult := plan.TestCase{
Identifier: fileLinePath, // Use file:line as the primary identifier
Name: scenario.Name,
Scope: feature.Name,
Path: fileLinePath,
}
result.RecordTestResult(testCaseForResult, testStatus)
}
}
// Determine if there were any errors outside of scenarios. Cucumber does not
// provide such count – we rely on process exit status already handled above.
return nil
}
// CucumberFeature and CucumberElement structs would be defined, likely in a separate parser file.
// For brevity, they are assumed here.
// mapScenarioToTestCase maps a Cucumber scenario (element) to a plan.TestCase
func mapScenarioToTestCase(featureName string, featureURI string, scenario CucumberElement) plan.TestCase {
// Cucumber scenarios are identified by file_path:line_number
identifier := fmt.Sprintf("%s:%d", featureURI, scenario.Line)
return plan.TestCase{
Path: identifier,
Scope: featureName,
Name: scenario.Name,
Identifier: identifier, // Or scenario.ID if it's more suitable and consistently available
}
}
// GetExamples returns an array of test scenarios within the given feature files.
func (c Cucumber) GetExamples(files []string) ([]plan.TestCase, error) {
if len(files) == 0 {
return []plan.TestCase{}, nil
}
// Create a temporary file to store the JSON output of the cucumber dry run.
f, err := os.CreateTemp("", "cucumber-dry-run-*.json")
if err != nil {
return nil, fmt.Errorf("failed to create temporary file for cucumber dry run: %w", err)
}
debug.Printf("Created temp file for cucumber dry run: %s", f.Name())
defer func() {
closeErr := f.Close()
if closeErr != nil {
debug.Printf("Error closing temp file %s: %v", f.Name(), closeErr)
}
removeErr := os.Remove(f.Name())
if removeErr != nil {
debug.Printf("Error removing temp file %s: %v", f.Name(), removeErr)
}
}()
cmdName, _, err := c.commandNameAndArgs(c.TestCommand, files)
if err != nil {
return nil, err
}
dryRunArgs := append(
[]string{"--dry-run", "--format", "json", "--out", f.Name(), "--format", "progress"},
files...,
)
debug.Printf("Running `%s %s` for dry run", cmdName, strings.Join(dryRunArgs, " "))
output, err := exec.Command(cmdName, dryRunArgs...).CombinedOutput()
if err != nil {
return []plan.TestCase{}, fmt.Errorf("failed to run Cucumber dry run: %s", output)
}
dryRunReport, parseErr := parseCucumberDryRunJSONOutput(f.Name()) // Use parser from cucumber_result_parser.go
if parseErr != nil {
return nil, fmt.Errorf("failed to parse cucumber dry run JSON report from %s: %w", f.Name(), parseErr)
}
var testCases []plan.TestCase
for _, feature := range dryRunReport {
for _, scenario := range feature.Elements {
if scenario.Type == "scenario" { // Only include scenarios, not scenario outlines directly (examples are handled differently)
testCases = append(testCases, mapScenarioToTestCase(feature.Name, feature.URI, scenario))
} else if scenario.Type == "scenario_outline" && scenario.Keyword == "Scenario Outline" {
// Scenario outlines themselves aren't runnable directly by path:line of the outline.
// Cucumber expands them into concrete scenarios based on their Examples tables.
// The JSON from a dry run might already include these expanded examples as individual 'scenario' type elements.
// If not, we'd need to parse scenario.Examples and generate test cases for each example row.
// For now, we assume the JSON includes expanded examples as type: "scenario".
// If the dry run JSON for outlines is different, this part needs adjustment.
// Let's log if we encounter an outline to see its structure.
debug.Printf("Encountered Scenario Outline: %s:%d. Its examples might be listed as separate scenarios.", feature.URI, scenario.Line)
}
}
}
return testCases, nil
}
// commandNameAndArgs replaces placeholders and returns command + args.
func (c Cucumber) commandNameAndArgs(cmd string, testCases []string) (string, []string, error) {
words, err := shellquote.Split(cmd)
if err != nil {
return "", []string{}, err
}
idx := slices.Index(words, "{{testExamples}}")
if idx < 0 {
words = append(words, testCases...)
} else {
words = slices.Replace(words, idx, idx+1, testCases...)
}
idx = slices.Index(words, "{{resultPath}}")
if idx >= 0 {
words = slices.Replace(words, idx, idx+1, c.ResultPath)
}
return words[0], words[1:], nil
}
// ---------------- Report parsing -------------------
// ParseReport now uses CucumberFeature from cucumber_result_parser.go
func (c Cucumber) ParseReport(path string) ([]CucumberFeature, error) {
var report []CucumberFeature
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read cucumber output: %v", err)
}
if err := json.Unmarshal(data, &report); err != nil {
return nil, fmt.Errorf("failed to parse cucumber output: %s", err)
}
return report, nil
}