github.com/saucelabs/saucectl@v0.175.1/internal/report/table/table.go (about) 1 package table 2 3 import ( 4 "fmt" 5 "io" 6 "sync" 7 "time" 8 9 "github.com/fatih/color" 10 "github.com/jedib0t/go-pretty/v6/table" 11 "github.com/jedib0t/go-pretty/v6/text" 12 "github.com/saucelabs/saucectl/internal/imagerunner" 13 "github.com/saucelabs/saucectl/internal/job" 14 "github.com/saucelabs/saucectl/internal/report" 15 ) 16 17 var defaultTableStyle = table.Style{ 18 Name: "saucy", 19 Box: table.BoxStyle{ 20 BottomLeft: "└", 21 BottomRight: "┘", 22 BottomSeparator: "", 23 EmptySeparator: text.RepeatAndTrim(" ", text.RuneCount("+")), 24 Left: "│", 25 LeftSeparator: "", 26 MiddleHorizontal: "─", 27 MiddleSeparator: "", 28 MiddleVertical: "", 29 PaddingLeft: " ", 30 PaddingRight: " ", 31 PageSeparator: "\n", 32 Right: "│", 33 RightSeparator: "", 34 TopLeft: "┌", 35 TopRight: "┐", 36 TopSeparator: "", 37 UnfinishedRow: " ...", 38 }, 39 Color: table.ColorOptionsDefault, 40 Format: table.FormatOptions{ 41 Footer: text.FormatDefault, 42 Header: text.FormatDefault, 43 Row: text.FormatDefault, 44 }, 45 HTML: table.DefaultHTMLOptions, 46 Options: table.Options{ 47 DrawBorder: false, 48 SeparateColumns: false, 49 SeparateFooter: true, 50 SeparateHeader: true, 51 SeparateRows: false, 52 }, 53 Title: table.TitleOptionsDefault, 54 } 55 56 // Reporter is a table writer implementation for report.Reporter. 57 type Reporter struct { 58 TestResults []report.TestResult 59 Dst io.Writer 60 lock sync.Mutex 61 } 62 63 // Add adds the test result to the summary table. 64 func (r *Reporter) Add(t report.TestResult) { 65 r.lock.Lock() 66 defer r.lock.Unlock() 67 r.TestResults = append(r.TestResults, t) 68 } 69 70 // Render renders out a test summary table to the destination of Reporter.Dst. 71 func (r *Reporter) Render() { 72 r.lock.Lock() 73 defer r.lock.Unlock() 74 75 t := table.NewWriter() 76 t.SetOutputMirror(r.Dst) 77 t.SetStyle(defaultTableStyle) 78 t.SuppressEmptyColumns() 79 80 t.AppendHeader(table.Row{"", "Name", "Duration", "Status", "Browser", "Platform", "Device", "Attempts"}) 81 t.SetColumnConfigs([]table.ColumnConfig{ 82 { 83 Number: 0, // it's the first nameless column that contains the passed/fail icon 84 WidthMax: 1, 85 }, 86 { 87 Name: "Name", 88 WidthMin: 30, 89 }, 90 { 91 Name: "Duration", 92 Align: text.AlignRight, 93 AlignFooter: text.AlignRight, 94 }, 95 }) 96 97 var ( 98 errors int 99 inProgress int 100 totalDur time.Duration 101 ) 102 for _, ts := range r.TestResults { 103 if !job.Done(ts.Status) && !imagerunner.Done(ts.Status) && !ts.TimedOut { 104 inProgress++ 105 } 106 if ts.Status == job.StateFailed || ts.Status == imagerunner.StateFailed || 107 ts.Status == imagerunner.StateCancelled || ts.Status == imagerunner.StateTerminated { 108 errors++ 109 } 110 if ts.TimedOut { 111 errors++ 112 ts.Status = job.StateUnknown 113 } 114 115 totalDur += ts.Duration 116 117 // the order of values must match the order of the header 118 t.AppendRow(table.Row{statusSymbol(ts.Status), ts.Name, ts.Duration.Truncate(1 * time.Second), 119 statusText(ts.Status), ts.Browser, ts.Platform, ts.DeviceName, len(ts.Attempts)}) 120 } 121 122 t.AppendFooter(footer(errors, inProgress, len(r.TestResults), calDuration(r.TestResults))) 123 124 _, _ = fmt.Fprintln(r.Dst) 125 t.Render() 126 } 127 128 // Reset resets the reporter to its initial state. This action will delete all test results. 129 func (r *Reporter) Reset() { 130 r.lock.Lock() 131 defer r.lock.Unlock() 132 r.TestResults = make([]report.TestResult, 0) 133 } 134 135 // ArtifactRequirements returns a list of artifact types are this reporter requires to create a proper report. 136 func (r *Reporter) ArtifactRequirements() []report.ArtifactType { 137 return nil 138 } 139 140 func footer(errors, inProgress, tests int, dur time.Duration) table.Row { 141 if errors != 0 { 142 relative := float64(errors) / float64(tests) * 100 143 return table.Row{statusSymbol(job.StateError), fmt.Sprintf("%d of %d suites have failed (%.0f%%)", errors, tests, relative), dur.Truncate(1 * time.Second)} 144 } 145 if inProgress != 0 { 146 return table.Row{statusSymbol(job.StateInProgress), "All suites have launched", dur.Truncate(1 * time.Second)} 147 } 148 return table.Row{statusSymbol(job.StatePassed), "All suites have passed", dur.Truncate(1 * time.Second)} 149 } 150 151 func statusText(status string) string { 152 switch status { 153 case job.StatePassed, imagerunner.StateSucceeded: 154 return color.GreenString(status) 155 case job.StateInProgress, job.StateQueued, job.StateNew, imagerunner.StateRunning, imagerunner.StatePending, 156 imagerunner.StateUploading: 157 return color.BlueString(status) 158 default: 159 return color.RedString(status) 160 } 161 } 162 163 func statusSymbol(status string) string { 164 switch status { 165 case job.StatePassed, imagerunner.StateSucceeded: 166 return color.GreenString("✔") 167 case job.StateInProgress, job.StateQueued, job.StateNew, imagerunner.StateRunning, imagerunner.StatePending, 168 imagerunner.StateUploading: 169 return color.BlueString("*") 170 default: 171 return color.RedString("✖") 172 } 173 } 174 175 func calDuration(results []report.TestResult) time.Duration { 176 start := time.Now() 177 end := start 178 for _, r := range results { 179 if r.StartTime.Before(start) && !r.StartTime.IsZero() { 180 start = r.StartTime 181 } 182 if r.EndTime.After(end) && !r.StartTime.IsZero() { 183 end = r.EndTime 184 } 185 } 186 187 return end.Sub(start) 188 }