├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── chart.go ├── docs ├── img1.png ├── img2.png ├── img3.png ├── img4.png └── logo.svg ├── go.mod ├── go.sum ├── html.go ├── main.go ├── node_modules ├── .package-lock.json └── plotly.js-dist │ ├── LICENSE │ ├── README.md │ ├── package.json │ └── plotly.js ├── package-lock.json ├── package.json ├── parser.go ├── parser_test.go ├── server.go └── testdata ├── golden.html ├── golden.json └── test.json /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | 10 | test: 11 | name: Run Tests 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: '1.23' 22 | 23 | - name: Get dependencies 24 | run: go mod download 25 | 26 | - name: Run tests 27 | run: go test -v ./... 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roblaszczak/vgt/766bbdec893fc7f84b2c56c8e5d66c130f5a7635/.gitignore -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Robert Laszczak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vgt - Visualise Go Tests 2 | 3 |
4 | 5 |
6 | 7 | --- 8 | 9 | `vgt` is a tool for visualising Go test results in a browser. 10 | 11 | It's helpful with understanding parallelism of tests and identifying slow tests. 12 | More information can be found in our [blog post about optimising Go tests parallelism](https://threedots.tech/post/go-test-parallelism/). 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
Screenshot 3Screenshot 4
Screenshot 1Screenshot 2
24 | 25 | ## Installation 26 | 27 | ```bash 28 | go install github.com/roblaszczak/vgt@latest 29 | ``` 30 | 31 | You can also run without installing by running `go run github.com/roblaszczak/vgt@latest`. 32 | 33 | ## Usage 34 | 35 | For visualising test results, run `go test` with the `-json` flag and pipe the output to `vgt`. 36 | 37 | ```bash 38 | go test -json ./... | vgt 39 | ``` 40 | 41 | or with `go run`: 42 | 43 | ```bash 44 | go test -json ./... | go run github.com/roblaszczak/vgt@latest 45 | ``` 46 | 47 | > [!WARNING] 48 | > When you are piping go tests output to `vgt`, `vgt` will exit with 1 when tests failed. 49 | 50 | or just `vgt` with a custom flags after `--` to run tests and visualise them: 51 | 52 | ```bash 53 | $ vgt -- ./... -count=1 -short 54 | 10:26PM INF Running go test command="[go test -json ./... -count=1 -short]" 55 | ``` 56 | 57 | After tests were executed, a browser window will open with the visualisation. 58 | 59 | If you want to preserve the output, you can pipe test logs to file and later pass it to `vgt`: 60 | 61 | ```bash 62 | go test -json ./... > test.json 63 | cat test.json | vgt 64 | ``` 65 | 66 | ### Additional flags 67 | 68 | ```bash 69 | Usage of vgt: 70 | -debug 71 | enable debug mode 72 | -dont-pass-output 73 | don't print output received to stdin 74 | -duration-cutoff string 75 | threshold for test duration cutoff, under which tests are not shown in the chart (default "100µs") 76 | -from-file string 77 | read input from file instead of stdin 78 | -keep-running 79 | keep browser running after page was opened 80 | -print-html 81 | print html to stdout instead of opening browser 82 | ``` 83 | 84 | ## Development 85 | 86 | If you have an idea for a feature or found a bug, feel free to open an issue or a pull request. 87 | 88 | Before making a big change, it's a good idea to open an issue to discuss it first. 89 | 90 | ### Running tests 91 | 92 | Tests are not really sophisticated, and are based on checking changes in golden files and checking in browser if 93 | it looks good. 94 | 95 | ### Updating golden files 96 | 97 | If you made a change and want to update golden files, you can run: 98 | 99 | ```bash 100 | go test . -update-golden 101 | ``` 102 | -------------------------------------------------------------------------------- /chart.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | func generateCharts(pr ParseResult) []PlotlyChart { 11 | var charts []PlotlyChart 12 | 13 | testNames := pr.TestNamesOrderedByStart() 14 | 15 | for _, tn := range testNames { 16 | ch := PlotlyChart{ 17 | Type: "bar", 18 | Orientation: "h", 19 | Hoverinfo: "text", 20 | Textposition: "inside", 21 | } 22 | 23 | pause, hasPause := pr.TestPauses.ByTestName(tn) 24 | run, hasRun := pr.TestRuns.ByTestName(tn) 25 | 26 | if !hasRun { 27 | slog.Debug("Test was not executed", "test", tn) 28 | continue 29 | } 30 | 31 | packageNameParts := strings.Split(tn.Package, "/") 32 | 33 | var packageName string 34 | if len(packageNameParts) != 0 { 35 | packageName = packageNameParts[len(packageNameParts)-1] 36 | } else { 37 | slog.Warn("Package name is empty", "test", tn.Package) 38 | } 39 | 40 | packageNameFull := fmt.Sprintf("%s.%s", packageName, tn.TestName) 41 | y := packageNameFull 42 | 43 | if !run.Passed { 44 | y += " (failed)" 45 | } 46 | 47 | if hasPause { 48 | startAfter := pause.Start.Sub(pr.Start) 49 | duration := pause.Duration() 50 | 51 | slog.Debug("Test was paused", "startAfter", startAfter, "duration", duration, "test", tn) 52 | 53 | ch.Add( 54 | fmt.Sprintf("%s PAUSE (%s)", packageNameFull, duration.Round(time.Millisecond).String()), 55 | y, 56 | startAfter, 57 | duration, 58 | "rgba(108,122,137,1)", 59 | ) 60 | } 61 | 62 | { 63 | startAfter := run.Start.Sub(pr.Start) 64 | duration := run.Duration() 65 | 66 | slog.Debug("Test was executed", "startAfter", startAfter, "duration", duration, "test", tn) 67 | 68 | ch.Add( 69 | fmt.Sprintf("%s RUN (%s)", packageNameFull, duration.Round(time.Millisecond)), 70 | y, 71 | startAfter, 72 | duration, 73 | durationToRgb(run, pr.MaxDuration), 74 | ) 75 | } 76 | 77 | slog.Debug("PlotlyChart", "chart", ch) 78 | 79 | charts = append(charts, ch) 80 | } 81 | 82 | return charts 83 | } 84 | -------------------------------------------------------------------------------- /docs/img1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roblaszczak/vgt/766bbdec893fc7f84b2c56c8e5d66c130f5a7635/docs/img1.png -------------------------------------------------------------------------------- /docs/img2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roblaszczak/vgt/766bbdec893fc7f84b2c56c8e5d66c130f5a7635/docs/img2.png -------------------------------------------------------------------------------- /docs/img3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roblaszczak/vgt/766bbdec893fc7f84b2c56c8e5d66c130f5a7635/docs/img3.png -------------------------------------------------------------------------------- /docs/img4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roblaszczak/vgt/766bbdec893fc7f84b2c56c8e5d66c130f5a7635/docs/img4.png -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/roblaszczak/vgt 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/lmittmann/tint v1.0.5 7 | github.com/muesli/cancelreader v0.2.2 8 | github.com/stretchr/testify v1.9.0 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw= 4 | github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 5 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 6 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 10 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 11 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a h1:ppl5mZgokTT8uPkmYOyEUmPTr3ypaKkg5eFOGrAmxxE= 12 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 15 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 16 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /html.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "html/template" 7 | "log/slog" 8 | "math" 9 | "slices" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | type PlotlyChart struct { 15 | Type string `json:"type"` 16 | Y []string `json:"y"` 17 | X []float64 `json:"x"` 18 | Orientation string `json:"orientation"` 19 | Base []float64 `json:"base"` 20 | Text []string `json:"text"` 21 | Textposition string `json:"textposition"` 22 | Width []float64 `json:"width"` 23 | Marker struct { 24 | Color []string `json:"color"` 25 | } `json:"marker"` 26 | Hoverinfo string `json:"hoverinfo"` 27 | } 28 | 29 | func (c *PlotlyChart) Add( 30 | label, y string, 31 | start, duration time.Duration, 32 | color string, 33 | ) { 34 | c.Y = append(c.Y, y) 35 | 36 | c.X = append(c.X, duration.Round(time.Millisecond*10).Seconds()) 37 | c.Base = append(c.Base, start.Round(time.Millisecond*10).Seconds()) 38 | c.Text = append(c.Text, label) 39 | c.Width = append(c.Width, 0.9) 40 | c.Marker.Color = append(c.Marker.Color, color) 41 | } 42 | 43 | func render(pr ParseResult, charts []PlotlyChart, callOnLoad bool) (string, error) { 44 | settings := map[string]any{ 45 | "showlegend": false, 46 | "yaxis": map[string]any{ 47 | "visible": false, 48 | }, 49 | "xaxis": map[string]any{ 50 | "ticksuffix": "s", 51 | }, 52 | } 53 | 54 | slices.Reverse(charts) 55 | 56 | chartsJSON, err := json.MarshalIndent(charts, "", " ") 57 | if err != nil { 58 | return "", fmt.Errorf("error marshalling charts: %w", err) 59 | } 60 | 61 | settingsJSON, err := json.MarshalIndent(settings, "", " ") 62 | if err != nil { 63 | return "", fmt.Errorf("error marshalling settings: %w", err) 64 | } 65 | 66 | slog.Debug("Generated HTML with charts", "charts", string(chartsJSON)) 67 | 68 | html := ` 69 | 70 | 71 | 72 | 73 | Test Results ({{.duration}} {{.passed}} passed, {{.failed}} failed) 74 | 75 | 76 |
77 |
78 |
79 | × 80 |

You can zoom chart with controls or by clicking and selecting area to zoom.

81 |
82 |
83 | 84 | 85 | 138 | 139 | 142 | 143 | 152 | 153 | {{ if .callOnLoad }} 154 | 162 | {{ end }} 163 | 196 | 197 | ` 198 | t, err := template.New("template").Parse(html) 199 | if err != nil { 200 | return "", fmt.Errorf("error parsing template: %w", err) 201 | } 202 | 203 | t = t.Option("missingkey=error") 204 | 205 | passed := 0 206 | failed := 0 207 | 208 | for _, execution := range pr.TestRuns { 209 | if execution.Passed { 210 | passed++ 211 | } else { 212 | failed++ 213 | } 214 | } 215 | 216 | duration := pr.End.Sub(pr.Start) 217 | 218 | buf := new(strings.Builder) 219 | err = t.Execute(buf, map[string]any{ 220 | "plotly": template.JS(plotly), 221 | "chartsJSON": template.JS(chartsJSON), 222 | "settingsJSON": template.JS(settingsJSON), 223 | "callOnLoad": callOnLoad, 224 | "passed": passed, 225 | "failed": failed, 226 | "duration": duration.Round(time.Millisecond).String(), 227 | }) 228 | if err != nil { 229 | return "", fmt.Errorf("error executing template: %w", err) 230 | } 231 | 232 | return buf.String(), nil 233 | } 234 | 235 | func floatToColor(value float64) string { 236 | value = math.Max(0, math.Min(1, value)) 237 | 238 | r := uint8(math.Round(60 * value)) // Reduced red component 239 | g := uint8(math.Round(180 * (1 - value))) 240 | b := uint8(math.Round(200 + 30*value)) 241 | 242 | return fmt.Sprintf("rgba(%d, %d, %d, 100)", r, g, b) 243 | } 244 | 245 | func durationToRgb(d TestExecution, maxDuration time.Duration) string { 246 | if !d.Passed { 247 | return "rgba(255, 0, 0, 100)" 248 | } 249 | 250 | position := float64(d.Duration()) / float64(maxDuration) 251 | 252 | slog.Debug("Duration to RGB", "duration", d, "maxDuration", maxDuration, "position", position) 253 | 254 | return floatToColor(position) 255 | } 256 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | _ "embed" 8 | "errors" 9 | "flag" 10 | "fmt" 11 | "io" 12 | "log/slog" 13 | "os" 14 | "os/exec" 15 | "os/signal" 16 | "syscall" 17 | "time" 18 | 19 | "github.com/lmittmann/tint" 20 | "github.com/muesli/cancelreader" 21 | ) 22 | 23 | //go:embed node_modules/plotly.js-dist/plotly.js 24 | var plotly string 25 | 26 | var debug bool 27 | 28 | var dontPassOutput bool 29 | var testDurationCutoff string 30 | var testDurationCutoffDuration time.Duration 31 | var printHTML bool 32 | var keepRunning bool 33 | var fromFile string 34 | 35 | func main() { 36 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 37 | defer stop() 38 | 39 | flag.BoolVar(&debug, "debug", false, "enable debug mode") 40 | flag.BoolVar(&dontPassOutput, "dont-pass-output", false, "don't print output received to stdin") 41 | flag.BoolVar(&keepRunning, "keep-running", false, "keep browser running after page was opened") 42 | flag.BoolVar(&printHTML, "print-html", false, "print html to stdout instead of opening browser") 43 | flag.StringVar(&fromFile, "from-file", "", "read input from file instead of stdin") 44 | 45 | flag.StringVar( 46 | &testDurationCutoff, 47 | "duration-cutoff", 48 | "100µs", 49 | "threshold for test duration cutoff, under which tests are not shown in the chart", 50 | ) 51 | flag.Parse() 52 | 53 | logLevel := slog.LevelInfo 54 | if debug { 55 | logLevel = slog.LevelDebug 56 | } 57 | 58 | var err error 59 | testDurationCutoffDuration, err = time.ParseDuration(testDurationCutoff) 60 | if err != nil { 61 | panic(err) 62 | } 63 | 64 | slog.SetDefault(slog.New( 65 | tint.NewHandler(os.Stderr, &tint.Options{ 66 | Level: logLevel, 67 | TimeFormat: time.Kitchen, 68 | }), 69 | )) 70 | 71 | r, cleanup, exitCode, done := newReader(ctx) 72 | if !done { 73 | return 74 | } 75 | defer cleanup() 76 | 77 | scanner := bufio.NewScanner(r) 78 | 79 | result := Parse(scanner) 80 | 81 | if checkClosing(ctx) { 82 | return 83 | } 84 | 85 | if printHTML { 86 | charts := generateCharts(result) 87 | html, err := render(result, charts, false) 88 | if err != nil { 89 | slog.Error("Error rendering html", "err", err) 90 | return 91 | } 92 | _, _ = os.Stdout.Write([]byte(html)) 93 | } else { 94 | serveHTML(ctx, result) 95 | } 96 | 97 | if exitCode != 0 { 98 | os.Exit(exitCode) 99 | } 100 | if result.Failed { 101 | os.Exit(1) 102 | } 103 | } 104 | 105 | func newReader(ctx context.Context) (io.Reader, func(), int, bool) { 106 | fi, err := os.Stdin.Stat() 107 | if err != nil { 108 | slog.Error("Error getting stdin stat", "err", err) 109 | return nil, nil, 0, false 110 | } 111 | 112 | isPipe := (fi.Mode() & os.ModeCharDevice) == 0 113 | readFromFile := fromFile != "" 114 | 115 | if isPipe && readFromFile { 116 | slog.Error("Can't read from file and stdin at the same time") 117 | return nil, nil, 0, false 118 | } 119 | 120 | if readFromFile { 121 | f, err := os.Open(fromFile) 122 | if err != nil { 123 | slog.Error("Error opening file", "err", err) 124 | return nil, nil, 0, false 125 | } 126 | 127 | return f, func() { 128 | _ = f.Close() 129 | }, 0, true 130 | } 131 | 132 | if isPipe { 133 | sr, err := cancelreader.NewReader(os.Stdin) 134 | if err != nil { 135 | slog.Error("Error creating cancel reader", "err", err) 136 | return nil, nil, 0, false 137 | } 138 | 139 | go func() { 140 | <-ctx.Done() 141 | sr.Cancel() 142 | }() 143 | 144 | return sr, func() {}, 0, true 145 | } 146 | 147 | r := bytes.NewBuffer([]byte{}) 148 | 149 | command := append([]string{"go", "test", "-json"}, flag.Args()...) 150 | 151 | slog.Info("Running go test", "command", command) 152 | 153 | cmd := exec.Command(command[0], command[1:]...) 154 | cmd.Stdin = os.Stdin 155 | cmd.Stdout = io.MultiWriter(r, os.Stdout) 156 | cmd.Stderr = os.Stderr 157 | 158 | var exitCode int 159 | 160 | err = cmd.Run() 161 | var exitErr *exec.ExitError 162 | if err != nil { 163 | if errors.As(err, &exitErr) { 164 | // this is expected - tests failed 165 | slog.Info("Error running go test", "err", err) 166 | exitCode = exitErr.ExitCode() 167 | } else { 168 | slog.Error("Error running go test", "err", err) 169 | return nil, nil, 0, false 170 | } 171 | } 172 | 173 | go func() { 174 | <-ctx.Done() 175 | _ = cmd.Process.Kill() 176 | }() 177 | 178 | return r, func() { 179 | _, _ = cmd.Process.Wait() 180 | }, exitCode, true 181 | } 182 | 183 | func checkClosing(ctx context.Context) bool { 184 | select { 185 | case <-ctx.Done(): 186 | fmt.Println( 187 | `Process closed without input: you should pipe the output of your test command into this program. 188 | For example: go test -json ./... | vgt`, 189 | ) 190 | return true 191 | default: 192 | return false 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /node_modules/.package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gtv", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "node_modules/plotly.js-dist": { 7 | "version": "2.33.0", 8 | "resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-2.33.0.tgz", 9 | "integrity": "sha512-Q995s9+9coO+5EStfCtlSW7HQXR14L3i/jTNXTZap7lt1v7r2QPuokAUkOHqMwmE1RsJJjtqnGRQH9JlEq8qPQ==" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /node_modules/plotly.js-dist/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Plotly, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /node_modules/plotly.js-dist/README.md: -------------------------------------------------------------------------------- 1 | # plotly.js-dist 2 | 3 | Ready-to-use plotly.js distributed bundle. 4 | 5 | Contains trace modules `bar`, `barpolar`, `box`, `candlestick`, `carpet`, `choropleth`, `choroplethmapbox`, `cone`, `contour`, `contourcarpet`, `densitymapbox`, `funnel`, `funnelarea`, `heatmap`, `heatmapgl`, `histogram`, `histogram2d`, `histogram2dcontour`, `icicle`, `image`, `indicator`, `isosurface`, `mesh3d`, `ohlc`, `parcats`, `parcoords`, `pie`, `pointcloud`, `sankey`, `scatter`, `scatter3d`, `scattercarpet`, `scattergeo`, `scattergl`, `scattermapbox`, `scatterpolar`, `scatterpolargl`, `scattersmith`, `scatterternary`, `splom`, `streamtube`, `sunburst`, `surface`, `table`, `treemap`, `violin`, `volume` and `waterfall`. 6 | 7 | For more info on plotly.js, go to https://github.com/plotly/plotly.js#readme 8 | 9 | ## Installation 10 | 11 | ``` 12 | npm install plotly.js-dist 13 | ``` 14 | ## Usage 15 | 16 | ```js 17 | // ES6 module 18 | import Plotly from 'plotly.js-dist' 19 | 20 | // CommonJS 21 | var Plotly = require('plotly.js-dist') 22 | ``` 23 | 24 | ## Copyright and license 25 | 26 | Code and documentation copyright 2024 Plotly, Inc. 27 | 28 | Code released under the [MIT license](https://github.com/plotly/plotly.js/blob/master/LICENSE). 29 | 30 | Docs released under the [Creative Commons license](https://github.com/plotly/documentation/blob/source/LICENSE). 31 | 32 | Please visit [complete list of dependencies](https://www.npmjs.com/package/plotly.js/v/2.33.0?activeTab=dependencies). -------------------------------------------------------------------------------- /node_modules/plotly.js-dist/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plotly.js-dist", 3 | "version": "2.33.0", 4 | "description": "Ready-to-use plotly.js distributed bundle.", 5 | "license": "MIT", 6 | "main": "plotly.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/plotly/plotly.js.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/plotly/plotly.js/issues" 13 | }, 14 | "author": "Plotly, Inc.", 15 | "keywords": [ 16 | "graphing", 17 | "plotting", 18 | "data", 19 | "visualization", 20 | "plotly" 21 | ], 22 | "files": [ 23 | "LICENSE", 24 | "README.md", 25 | "plotly.js" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gtv", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "plotly.js-dist": "^2.33.0" 9 | } 10 | }, 11 | "node_modules/plotly.js-dist": { 12 | "version": "2.33.0", 13 | "resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-2.33.0.tgz", 14 | "integrity": "sha512-Q995s9+9coO+5EStfCtlSW7HQXR14L3i/jTNXTZap7lt1v7r2QPuokAUkOHqMwmE1RsJJjtqnGRQH9JlEq8qPQ==" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "plotly.js-dist": "^2.33.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "sort" 10 | "time" 11 | ) 12 | 13 | type testOutput struct { 14 | Time time.Time `json:"Time"` 15 | Action action `json:"Action"` 16 | Package string `json:"Package"` 17 | Test string `json:"Test"` 18 | } 19 | 20 | type action string 21 | 22 | const ( 23 | actionRun action = "run" 24 | actionPause action = "pause" 25 | 26 | actionPass action = "pass" 27 | actionCont action = "cont" 28 | actionFail action = "fail" 29 | actionSkip action = "skip" 30 | ) 31 | 32 | func (t testOutput) IsZero() bool { 33 | return t == testOutput{} 34 | } 35 | 36 | type TestName struct { 37 | Package string 38 | TestName string 39 | } 40 | 41 | func (t TestName) String() string { 42 | return fmt.Sprintf("%s/%s", t.Package, t.TestName) 43 | } 44 | 45 | type TestExecution struct { 46 | Test TestName 47 | 48 | Start time.Time 49 | End time.Time 50 | 51 | Passed bool 52 | } 53 | 54 | func (t TestExecution) Duration() time.Duration { 55 | if t.Start.IsZero() || t.End.IsZero() { 56 | return 0 57 | } 58 | 59 | return t.End.Sub(t.Start) 60 | } 61 | 62 | type TestExecutions map[TestName]TestExecution 63 | 64 | func (t TestExecutions) MarshalJSON() ([]byte, error) { 65 | m := map[string]TestExecution{} 66 | 67 | for k, v := range t { 68 | m[k.String()] = v 69 | } 70 | 71 | return json.Marshal(m) 72 | } 73 | 74 | func (t TestExecutions) Update(testName TestName, updateFn func(TestExecution) TestExecution) { 75 | if _, ok := t[testName]; !ok { 76 | t[testName] = TestExecution{ 77 | Test: testName, 78 | } 79 | } 80 | t[testName] = updateFn(t[testName]) 81 | } 82 | 83 | func (t TestExecutions) ByTestName(testName TestName) (TestExecution, bool) { 84 | te, ok := t[testName] 85 | return te, ok 86 | } 87 | 88 | func (t TestExecutions) AsSlice() []TestExecution { 89 | slice := make([]TestExecution, 0, len(t)) 90 | 91 | for _, execution := range t { 92 | slice = append(slice, execution) 93 | } 94 | return slice 95 | } 96 | 97 | type ParseResult struct { 98 | TestPauses TestExecutions 99 | TestRuns TestExecutions 100 | 101 | Start time.Time 102 | End time.Time 103 | 104 | MaxDuration time.Duration 105 | 106 | Failed bool 107 | } 108 | 109 | func (p ParseResult) TestNamesOrderedByStart() []TestName { 110 | allExecutions := append(p.TestPauses.AsSlice(), p.TestRuns.AsSlice()...) 111 | 112 | sort.Slice(allExecutions, func(i, j int) bool { 113 | return allExecutions[i].Start.Before(allExecutions[j].Start) 114 | }) 115 | 116 | testNames := make([]TestName, 0, len(p.TestRuns)) 117 | uniqTestNames := make(map[TestName]struct{}, len(p.TestRuns)) 118 | 119 | for _, execution := range allExecutions { 120 | if _, ok := uniqTestNames[execution.Test]; !ok { 121 | testNames = append(testNames, execution.Test) 122 | uniqTestNames[execution.Test] = struct{}{} 123 | } 124 | } 125 | 126 | return testNames 127 | } 128 | 129 | func Parse(scanner *bufio.Scanner) ParseResult { 130 | testRuns := make(TestExecutions) 131 | testPauses := make(TestExecutions) 132 | 133 | start := time.Time{} 134 | end := time.Time{} 135 | 136 | maxDuration := time.Duration(0) 137 | 138 | failed := false 139 | 140 | i := 0 141 | 142 | for scanner.Scan() { 143 | s := scanner.Text() 144 | i++ 145 | 146 | if !dontPassOutput { 147 | _, _ = fmt.Fprintln(os.Stderr, s) 148 | } 149 | 150 | if s == "" { 151 | continue 152 | } 153 | 154 | var out testOutput 155 | if err := json.Unmarshal([]byte(s), &out); err != nil { 156 | slog.Debug("failed to unmarshal", "line", i, "error", err) 157 | continue 158 | } 159 | if out.IsZero() { 160 | slog.Debug("zero value", "line", i) 161 | continue 162 | } 163 | 164 | if out.Action == actionFail { 165 | failed = true 166 | } 167 | 168 | if !out.Time.IsZero() { 169 | if start.IsZero() || out.Time.Before(start) { 170 | start = out.Time 171 | } 172 | if end.IsZero() || out.Time.After(end) { 173 | end = out.Time 174 | } 175 | } 176 | 177 | tn := TestName{ 178 | Package: out.Package, 179 | TestName: out.Test, 180 | } 181 | 182 | switch out.Action { 183 | case actionPause: 184 | testPauses.Update(tn, func(te TestExecution) TestExecution { 185 | te.Start = out.Time 186 | return te 187 | }) 188 | case actionCont: 189 | testPauses.Update(tn, func(te TestExecution) TestExecution { 190 | te.End = out.Time 191 | return te 192 | }) 193 | testRuns.Update(tn, func(te TestExecution) TestExecution { 194 | te.Start = out.Time 195 | return te 196 | }) 197 | 198 | case actionRun: 199 | testRuns.Update(tn, func(te TestExecution) TestExecution { 200 | te.Start = out.Time 201 | return te 202 | }) 203 | case actionPass, actionFail, actionSkip: 204 | testRuns.Update(tn, func(te TestExecution) TestExecution { 205 | te.End = out.Time 206 | te.Passed = out.Action == actionPass 207 | return te 208 | }) 209 | } 210 | } 211 | 212 | for test, execution := range testPauses { 213 | if execution.Duration() == 0 { 214 | delete(testPauses, test) 215 | slog.Debug("removed invalid test pause", "test", test) 216 | continue 217 | } 218 | if execution.Duration() <= testDurationCutoffDuration { 219 | delete(testPauses, test) 220 | slog.Debug("removed test pause below threshold", "test", test, "duration", execution.Duration()) 221 | continue 222 | } 223 | if execution.Test == (TestName{}) { 224 | delete(testPauses, test) 225 | slog.Debug("removed test pause with empty test name", "test", test) 226 | continue 227 | } 228 | 229 | slog.Debug( 230 | "parsed test pause", 231 | "test", execution.Test, 232 | "paused_from", execution.Start, 233 | "to", execution.End, 234 | "for", execution.Duration(), 235 | ) 236 | } 237 | 238 | for test, execution := range testRuns { 239 | if execution.Duration() == 0 { 240 | delete(testRuns, test) 241 | slog.Debug("removed invalid test run", "test", test) 242 | continue 243 | } 244 | 245 | if execution.Duration() <= testDurationCutoffDuration { 246 | delete(testRuns, test) 247 | slog.Debug("removed test run below threshold", "test", test, "duration", execution.Duration()) 248 | continue 249 | } 250 | 251 | if execution.Test == (TestName{}) { 252 | delete(testRuns, test) 253 | slog.Debug("removed test run with empty test name", "test", test) 254 | continue 255 | } 256 | 257 | slog.Debug( 258 | "parsed test", 259 | "test", execution.Test, 260 | "ran_from", execution.Start, 261 | "to", execution.End, 262 | "for", execution.Duration(), 263 | "passed", execution.Passed, 264 | ) 265 | 266 | if execution.Duration() > maxDuration { 267 | maxDuration = execution.Duration() 268 | } 269 | } 270 | 271 | slog.Debug("parsed", "start", start, "end", end) 272 | 273 | return ParseResult{ 274 | TestPauses: testPauses, 275 | TestRuns: testRuns, 276 | Start: start, 277 | End: end, 278 | MaxDuration: maxDuration, 279 | Failed: failed, 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /parser_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "flag" 8 | "os" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | var updateGolden = false 15 | 16 | func init() { 17 | flag.BoolVar(&updateGolden, "update-golden", false, "update golden files") 18 | } 19 | 20 | func TestParse(t *testing.T) { 21 | testOutput, err := os.ReadFile("testdata/test.json") 22 | require.NoError(t, err) 23 | 24 | scanner := bufio.NewScanner(bytes.NewBuffer(testOutput)) 25 | 26 | parseResult := Parse(scanner) 27 | 28 | marshaled, err := json.MarshalIndent(parseResult, "", " ") 29 | require.NoError(t, err) 30 | 31 | if updateGolden { 32 | err = os.WriteFile("testdata/golden.json", marshaled, 0644) 33 | require.NoError(t, err) 34 | } else { 35 | golden, err := os.ReadFile("testdata/golden.json") 36 | require.NoError(t, err) 37 | 38 | require.JSONEq(t, string(golden), string(marshaled)) 39 | } 40 | 41 | charts := generateCharts(parseResult) 42 | 43 | html, err := render(parseResult, charts, false) 44 | require.NoError(t, err) 45 | 46 | if updateGolden { 47 | err = os.WriteFile("testdata/golden.html", []byte(html), 0644) 48 | require.NoError(t, err) 49 | } else { 50 | golden, err := os.ReadFile("testdata/golden.html") 51 | require.NoError(t, err) 52 | 53 | require.Equal(t, string(golden), html) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "net/http" 10 | "os/exec" 11 | "runtime" 12 | "time" 13 | ) 14 | 15 | func serveHTML(ctx context.Context, pr ParseResult) { 16 | loaded := make(chan struct{}) 17 | 18 | charts := generateCharts(pr) 19 | 20 | http.HandleFunc("GET /", func(writer http.ResponseWriter, request *http.Request) { 21 | rendered, err := render(pr, charts, true) 22 | if err != nil { 23 | writer.WriteHeader(http.StatusInternalServerError) 24 | _, _ = writer.Write([]byte(fmt.Sprintf("Error rendering HTML: %s", err))) 25 | slog.Error("Error rendering HTML", "err", err) 26 | return 27 | } 28 | 29 | _, _ = writer.Write([]byte(rendered)) 30 | }) 31 | http.HandleFunc("GET /loaded", func(writer http.ResponseWriter, request *http.Request) { 32 | loadedHandler(writer, request, loaded) 33 | }) 34 | 35 | listener, err := net.Listen("tcp", "localhost:0") 36 | if err != nil { 37 | slog.Error("Error creating listener", "err", err) 38 | return 39 | } 40 | port := listener.Addr().(*net.TCPAddr).Port 41 | 42 | ctx, cancel := context.WithCancel(ctx) 43 | defer cancel() 44 | 45 | server := &http.Server{} 46 | go func() { 47 | err := server.Serve(listener) 48 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 49 | slog.Error("Error serving", "err", err) 50 | } 51 | }() 52 | 53 | url := fmt.Sprintf("http://localhost:%d", port) 54 | slog.Debug("Opening %s in your browser", "url", url) 55 | 56 | err = openBrowser(url) 57 | if err != nil { 58 | slog.Error("Error opening browser", "err", err) 59 | return 60 | } 61 | 62 | if !keepRunning { 63 | select { 64 | case <-loaded: 65 | slog.Debug("Browser successfully loaded the page.") 66 | case <-time.After(10 * time.Second): 67 | slog.Error("Timeout: Browser did not load the page within 10 seconds.") 68 | case <-ctx.Done(): 69 | slog.Debug("Context was canceled.") 70 | } 71 | } else { 72 | select { 73 | case <-ctx.Done(): 74 | slog.Debug("Context was canceled.") 75 | } 76 | } 77 | 78 | slog.Debug("Shutting down the server...") 79 | if err := server.Shutdown(ctx); err != nil { 80 | slog.Error("Error shutting down server", "err", err) 81 | } 82 | } 83 | 84 | func loadedHandler(w http.ResponseWriter, r *http.Request, loaded chan struct{}) { 85 | // Signal that the page has been loaded 86 | select { 87 | case loaded <- struct{}{}: 88 | default: 89 | // Channel already signaled, do nothing 90 | } 91 | } 92 | 93 | func openBrowser(url string) error { 94 | var cmd *exec.Cmd 95 | 96 | switch runtime.GOOS { 97 | case "linux": 98 | cmd = exec.Command("xdg-open", url) 99 | case "windows": 100 | cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 101 | case "darwin": 102 | cmd = exec.Command("open", url) 103 | default: 104 | return fmt.Errorf("unsupported platform") 105 | } 106 | 107 | return cmd.Start() 108 | } 109 | --------------------------------------------------------------------------------