github.com/rainforestapp/rainforest-cli@v2.12.0+incompatible/reporter.go (about) 1 package main 2 3 import ( 4 "encoding/xml" 5 "fmt" 6 "log" 7 "os" 8 "path/filepath" 9 "strconv" 10 11 "errors" 12 13 "github.com/rainforestapp/rainforest-cli/rainforest" 14 "github.com/urfave/cli" 15 ) 16 17 // Maximum concurrency for multithreaded HTTP requests 18 const reporterConcurrency = 4 19 20 // resourceAPI is part of the API connected to available resources 21 type reporterAPI interface { 22 GetRunTestDetails(int, int) (*rainforest.RunTestDetails, error) 23 GetRunDetails(int) (*rainforest.RunDetails, error) 24 } 25 26 type reporter struct { 27 getRunDetails func(int, reporterAPI) (*rainforest.RunDetails, error) 28 createOutputFile func(string) (*os.File, error) 29 createJUnitReportSchema func(*rainforest.RunDetails, reporterAPI) (*jUnitReportSchema, error) 30 writeJUnitReport func(*jUnitReportSchema, *os.File) error 31 } 32 33 func createReport(c cliContext) error { 34 r := newReporter() 35 return r.createReport(c) 36 } 37 38 func postRunJUnitReport(c cliContext, runID int) error { 39 // Get the csv file path either and skip uploading if it's not present 40 fileName := c.String("junit-file") 41 if fileName == "" { 42 return nil 43 } 44 45 r := newReporter() 46 return r.createJUnitReport(runID, fileName) 47 } 48 49 func newReporter() *reporter { 50 return &reporter{ 51 getRunDetails: getRunDetails, 52 createOutputFile: os.Create, 53 createJUnitReportSchema: createJUnitReportSchema, 54 writeJUnitReport: writeJUnitReport, 55 } 56 } 57 58 func (r *reporter) createReport(c cliContext) error { 59 var runID int 60 var err error 61 62 if runIDArg := c.Args().Get(0); runIDArg != "" { 63 runID, err = strconv.Atoi(runIDArg) 64 if err != nil { 65 return cli.NewExitError(err.Error(), 1) 66 } 67 } else if deprecatedRunIDArg := c.String("run-id"); deprecatedRunIDArg != "" { 68 runID, err = strconv.Atoi(deprecatedRunIDArg) 69 if err != nil { 70 return cli.NewExitError(err.Error(), 1) 71 } 72 73 log.Println("Warning: `run-id` flag is deprecated. Please provide Run ID as an argument.") 74 } else { 75 return cli.NewExitError("No run ID argument found.", 1) 76 } 77 78 if junitFile := c.String("junit-file"); junitFile != "" { 79 err = r.createJUnitReport(runID, junitFile) 80 if err != nil { 81 return cli.NewExitError(err.Error(), 1) 82 } 83 } else { 84 return cli.NewExitError("Output file not specified", 1) 85 } 86 87 return nil 88 } 89 90 func (r *reporter) createJUnitReport(runID int, junitFile string) error { 91 log.Print("Creating JUnit report for run #" + strconv.Itoa(runID) + ": " + junitFile) 92 93 if filepath.Ext(junitFile) != ".xml" { 94 return errors.New("JUnit file extension must be .xml") 95 } 96 97 filepath, err := filepath.Abs(junitFile) 98 if err != nil { 99 return err 100 } 101 102 var runDetails *rainforest.RunDetails 103 runDetails, err = r.getRunDetails(runID, api) 104 if err != nil { 105 return err 106 } 107 108 var outputFile *os.File 109 outputFile, err = r.createOutputFile(filepath) 110 defer outputFile.Close() 111 if err != nil { 112 return err 113 } 114 115 var reportSchema *jUnitReportSchema 116 reportSchema, err = r.createJUnitReportSchema(runDetails, api) 117 if err != nil { 118 return err 119 } 120 121 err = r.writeJUnitReport(reportSchema, outputFile) 122 if err != nil { 123 return err 124 } 125 126 return nil 127 } 128 129 func getRunDetails(runID int, client reporterAPI) (*rainforest.RunDetails, error) { 130 var runDetails *rainforest.RunDetails 131 var err error 132 133 log.Printf("Fetching details for run #" + strconv.Itoa(runID)) 134 if runDetails, err = client.GetRunDetails(runID); err != nil { 135 return runDetails, err 136 } 137 138 if !runDetails.StateDetails.IsFinalState { 139 err = errors.New("Report cannot be created for an incomplete run") 140 } 141 142 return runDetails, err 143 } 144 145 type jUnitTestReportFailure struct { 146 XMLName xml.Name `xml:"failure"` 147 Type string `xml:"type,attr"` 148 Message string `xml:"message,attr"` 149 } 150 151 type jUnitTestReportSchema struct { 152 XMLName xml.Name `xml:"testcase"` 153 Name string `xml:"name,attr"` 154 Time float64 `xml:"time,attr"` 155 Failures []jUnitTestReportFailure 156 } 157 158 type jUnitReportSchema struct { 159 XMLName xml.Name `xml:"testsuite"` 160 Name string `xml:"name,attr"` 161 Tests int `xml:"tests,attr"` 162 Errors int `xml:"errors,attr"` 163 Failures int `xml:"failures,attr"` 164 Time float64 `xml:"time,attr"` 165 TestCases []jUnitTestReportSchema 166 } 167 168 func createJUnitReportSchema(runDetails *rainforest.RunDetails, api reporterAPI) (*jUnitReportSchema, error) { 169 type processedTestCase struct { 170 TestCase jUnitTestReportSchema 171 Error error 172 } 173 174 // Create channels for work to be done and results 175 testCount := len(runDetails.Tests) 176 processedTestChan := make(chan processedTestCase, testCount) 177 testsChan := make(chan rainforest.RunTestDetails, testCount) 178 179 processTestWorker := func(testsChan <-chan rainforest.RunTestDetails) { 180 for test := range testsChan { 181 testCase := jUnitTestReportSchema{} 182 183 testDuration := test.UpdatedAt.Sub(test.CreatedAt).Seconds() 184 185 testCase.Name = test.Title 186 testCase.Time = testDuration 187 188 if test.Result == "failed" { 189 log.Printf("Fetching information for failed test #" + strconv.Itoa(test.ID)) 190 testDetails, err := api.GetRunTestDetails(runDetails.ID, test.ID) 191 192 if err != nil { 193 processedTestChan <- processedTestCase{TestCase: jUnitTestReportSchema{}, Error: err} 194 return 195 } 196 197 for _, step := range testDetails.Steps { 198 for _, browser := range step.Browsers { 199 for _, feedback := range browser.Feedback { 200 if feedback.Result != "failed" || feedback.JobState != "approved" { 201 continue 202 } 203 204 if feedback.FailureNote != "" { 205 reportFailure := jUnitTestReportFailure{Type: browser.Name, Message: feedback.FailureNote} 206 testCase.Failures = append(testCase.Failures, reportFailure) 207 } else if feedback.CommentReason != "" { 208 // The step failed due to a special comment type being selected 209 message := feedback.CommentReason 210 211 if feedback.Comment != "" { 212 message += ": " + feedback.Comment 213 } 214 reportFailure := jUnitTestReportFailure{Type: browser.Name, Message: message} 215 testCase.Failures = append(testCase.Failures, reportFailure) 216 } 217 } 218 } 219 } 220 } 221 222 processedTestChan <- processedTestCase{TestCase: testCase} 223 } 224 } 225 226 // spawn workers 227 for i := 0; i < reporterConcurrency; i++ { 228 go processTestWorker(testsChan) 229 } 230 231 // give them work 232 for _, test := range runDetails.Tests { 233 testsChan <- test 234 } 235 close(testsChan) 236 237 // and collect the results 238 testCases := make([]jUnitTestReportSchema, testCount) 239 for i := 0; i < testCount; i++ { 240 processed := <-processedTestChan 241 242 if processed.Error != nil { 243 return &jUnitReportSchema{}, processed.Error 244 } 245 246 testCases[i] = processed.TestCase 247 } 248 249 finalStateName := runDetails.StateDetails.Name 250 runDuration := runDetails.Timestamps[finalStateName].Sub(runDetails.Timestamps["created_at"]).Seconds() 251 report := &jUnitReportSchema{ 252 Errors: runDetails.TotalNoResultTests, 253 Failures: runDetails.TotalFailedTests, 254 Tests: runDetails.TotalTests, 255 TestCases: testCases, 256 Time: runDuration, 257 } 258 259 if runDesc := runDetails.Description; runDesc != "" { 260 report.Name = runDesc 261 } else { 262 report.Name = fmt.Sprintf("Run #%v", runDetails.ID) 263 } 264 265 return report, nil 266 } 267 268 func writeJUnitReport(reportSchema *jUnitReportSchema, file *os.File) error { 269 enc := xml.NewEncoder(file) 270 271 file.Write([]byte(xml.Header)) 272 273 enc.Indent("", " ") 274 err := enc.Encode(reportSchema) 275 if err != nil { 276 return err 277 } 278 279 log.Printf("JUnit report successfully written to %v", file.Name()) 280 return nil 281 }