├── .github └── workflows │ └── go.yml ├── .gitignore ├── .golangci.json ├── LICENSE ├── Makefile ├── README.md ├── benchart.go ├── benchart_test.go ├── go.mod ├── go.sum ├── template.html └── testdata ├── etalon.html └── input.csv /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v3 18 | with: 19 | go-version: 1.18 20 | 21 | - name: Lint 22 | run: make lint 23 | 24 | - name: Test 25 | run: make test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dump/ 3 | coverage.out 4 | result.html 5 | -------------------------------------------------------------------------------- /.golangci.json: -------------------------------------------------------------------------------- 1 | { 2 | "linters-settings": { 3 | "govet": { 4 | "check-shadowing": true 5 | }, 6 | "nakedret": { 7 | "lines": 1 8 | }, 9 | "godox": { 10 | "keywords": [ 11 | "NOCOMMIT", 12 | "NOCOMIT" 13 | ] 14 | } 15 | }, 16 | "issues": { 17 | "exclude-rules": [ 18 | { 19 | "path": "_test.go", 20 | "linters": [ 21 | "dupl", 22 | "goconst", 23 | "gomnd", 24 | "structcheck", 25 | "unused", 26 | "gochecknoglobals", 27 | "gosec", 28 | "gocognit" 29 | ] 30 | } 31 | ] 32 | }, 33 | "linters": { 34 | "enable": [ 35 | "asciicheck", 36 | "bidichk", 37 | "bodyclose", 38 | "bodyclose", 39 | "deadcode", 40 | "decorder", 41 | "depguard", 42 | "dogsled", 43 | "dupl", 44 | "durationcheck", 45 | "errcheck", 46 | "errchkjson", 47 | "errname", 48 | "errorlint", 49 | "execinquery", 50 | "exhaustive", 51 | "exhaustruct", 52 | "exportloopref", 53 | "exportloopref", 54 | "forcetypeassert", 55 | "gci", 56 | "gochecknoglobals", 57 | "gochecknoinits", 58 | "gocognit", 59 | "goconst", 60 | "gocritic", 61 | "gocyclo", 62 | "godot", 63 | "godox", 64 | "goerr113", 65 | "gofmt", 66 | "gofumpt", 67 | "goheader", 68 | "goimports", 69 | "gomnd", 70 | "gomoddirectives", 71 | "gomodguard", 72 | "goprintffuncname", 73 | "gosec", 74 | "gosimple", 75 | "govet", 76 | "grouper", 77 | "ineffassign", 78 | "ireturn", 79 | "lll", 80 | "misspell", 81 | "nakedret", 82 | "nestif", 83 | "nilerr", 84 | "noctx", 85 | "nolintlint", 86 | "nonamedreturns", 87 | "prealloc", 88 | "revive", 89 | "rowserrcheck", 90 | "staticcheck", 91 | "structcheck", 92 | "stylecheck", 93 | "tagliatelle", 94 | "tenv", 95 | "thelper", 96 | "tparallel", 97 | "typecheck", 98 | "unconvert", 99 | "unparam", 100 | "unused", 101 | "varcheck", 102 | "whitespace", 103 | "wsl" 104 | ] 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Bohdan Storozhuk 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. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | coverage: test 2 | go tool cover -html=coverage.out 3 | 4 | test: clean format 5 | go test -race -coverprofile coverage.out ./... 6 | 7 | lint: clean 8 | go run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.46.2 run . 9 | 10 | clean: 11 | @go clean 12 | @rm -f profile.out 13 | @rm -f coverage.out 14 | @rm -f result.html 15 | 16 | format: 17 | go run mvdan.cc/gofumpt@v0.3.1 -l -w . 18 | 19 | help: 20 | @awk '$$1 ~ /^.*:/ {print substr($$1, 0, length($$1)-1)}' Makefile 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # benchart [![Build Status](https://github.com/storozhukBM/benchart/workflows/Go/badge.svg)](https://github.com/storozhukBM/benchart/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/storozhukBM/benchart)](https://goreportcard.com/report/github.com/storozhukBM/benchart) [![PkgGoDev](https://pkg.go.dev/badge/github.com/storozhukBM/benchart)](https://pkg.go.dev/github.com/storozhukBM/benchart) 2 | 3 | benchart is a tool that takes [benchstat -csv](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat#section-readme) 4 | output as an input and plots results of your benchmark in a html file. 5 | All you need to do is format your benchmark's name properly. 6 | 7 | Chart 1 8 | 9 | 10 | All measurement attributes that benchart parses should be split by `;` and every attribute consists of key and value 11 | separated by `:`. Every measurement should have at least 2 attributes: one it `type` that will be used to label your 12 | chart and one additional attribute that will act as `x-axis` by default the last attribute will be interpreted 13 | as `x-axis`. 14 | 15 | ``` 16 | name,time/op (ns/op),± 17 | Hash/type:crc32;bytes:8-8,4.47967E+00,0% 18 | |________| |_____| 19 | mandatory x-axis 20 | attribute 21 | ``` 22 | 23 | You can also provide additional chart option sets. Every chart option set should have chart name at the beginning and then set 24 | of options separated by `;`. Every option in a set should have key and value separated by `=`. 25 | Supported options: 26 | - `title` - string start will be used as a graph title instead of name from csv file 27 | - `xAxisName` - overrides x-axis name on graph 28 | - `xAxisType` - changes type of x-axis, by default we use linear scale, but you can specify `log` scale 29 | - `yAxisType` - changes type of y-axis, by default we use linear scale, but you can specify `log` scale 30 | 31 | Example of a command with options specified 32 | 33 | > benchart 'Hash;title=Benchmark of hash functions;xAxisName=bytes size;xAxisType=log' input.csv result.html 34 | 35 | Other examples of benchart usage are in the testdata directory. 36 | 37 | Chart 2 38 | 39 | Chart 3 40 | 41 | 42 | -------------------------------------------------------------------------------- /benchart.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "crypto/sha256" 7 | _ "embed" 8 | "encoding/hex" 9 | "encoding/json" 10 | "fmt" 11 | "os" 12 | "strconv" 13 | "strings" 14 | ) 15 | 16 | //go:embed "template.html" 17 | var template []byte 18 | 19 | type BenchartError string 20 | 21 | func (b BenchartError) Error() string { 22 | return string(b) 23 | } 24 | 25 | const ( 26 | ErrCantCloseOutputFile BenchartError = "can't close output file" 27 | ErrCantFlushOutputFile BenchartError = "can't flush output file" 28 | ErrCantMarshalChartBenchmarkResults BenchartError = "can't marshal chart benchmarkResults" 29 | ErrCantOpenInputFile BenchartError = "can't open input file" 30 | ErrCantOpenOrCreateOutputFile BenchartError = "can't open or create output file" 31 | ErrCantParseBenchmarkName BenchartError = "can't parse benchmark name" 32 | ErrCantParseChartOptions BenchartError = "can't parse chart options" 33 | ErrCantParseErrorRate BenchartError = "can't parse error rate" 34 | ErrCantParseMeasurementAttributes BenchartError = "can't parse measurement attributes" 35 | ErrCantParseOption BenchartError = "can't parse option" 36 | ErrCantParseYValue BenchartError = "can't parse y value" 37 | ErrCantWriteOutputFile BenchartError = "can't write output file" 38 | ErrMeasurementLineHasNoTypeAttribute BenchartError = "measurement line has no 'type' attribute" 39 | ErrNotEnoughColumns BenchartError = "not enough columns" 40 | ErrNotEnoughInputArguments BenchartError = "not enough input arguments" 41 | ErrOptionChartNameNotFound BenchartError = "option chart name not found" 42 | ErrOptionIsNotSupported BenchartError = "option is not supported" 43 | ErrOptionTypeIsWrong BenchartError = "option type is wrong" 44 | ) 45 | 46 | type ( 47 | ChartName string 48 | ChartOption string 49 | ChartOptionType string 50 | ChartOptionTypeSet map[ChartOptionType]struct{} 51 | ) 52 | 53 | type Point struct { 54 | X string 55 | Y string 56 | Error string 57 | } 58 | 59 | type BenchmarkResults struct { 60 | ID string 61 | Name ChartName 62 | YAxisLabel string 63 | Cases map[string][]Point 64 | Options map[ChartOption]string 65 | } 66 | 67 | func main() { 68 | errOutput := RunCommand(os.Args) 69 | if errOutput != nil { 70 | fmt.Println("Command finished with error: " + errOutput.Error()) 71 | os.Exit(-1) 72 | } 73 | } 74 | 75 | //nolint:nonamedreturns // here named return required to propagate error from defer 76 | func RunCommand(arguments []string) (errResponse error) { 77 | inputFilePath, outputFilePath, options, errOptionsParsing := parseOptions(arguments) 78 | if errOptionsParsing != nil { 79 | return errOptionsParsing 80 | } 81 | 82 | inputFile, errFileOpen := os.Open(inputFilePath) 83 | if errFileOpen != nil { 84 | return fmt.Errorf("%w: %v: %v", ErrCantOpenInputFile, inputFilePath, errFileOpen) 85 | } 86 | 87 | benchmarkResults, errParsingResults := parseBenchmarkResultsFromInputFile(inputFile, options) 88 | if errParsingResults != nil { 89 | return errParsingResults 90 | } 91 | 92 | f, errOutputFileCreation := os.Create(outputFilePath) 93 | if errOutputFileCreation != nil { 94 | return fmt.Errorf("%w: %v: %v", ErrCantOpenOrCreateOutputFile, outputFilePath, errOutputFileCreation) 95 | } 96 | 97 | defer func(f *os.File) { 98 | errCloseOutputFile := f.Close() 99 | if errCloseOutputFile != nil { 100 | errResponse = fmt.Errorf("%w: %v: %v", ErrCantCloseOutputFile, outputFilePath, errCloseOutputFile) 101 | return 102 | } 103 | }(f) 104 | 105 | benchmarkResultsJSON, errMarshalData := json.MarshalIndent(&benchmarkResults, "", " ") 106 | if errMarshalData != nil { 107 | return fmt.Errorf("%w: %v", ErrCantMarshalChartBenchmarkResults, errMarshalData) 108 | } 109 | 110 | outputWriter := bufio.NewWriter(f) 111 | defer func(outputWriter *bufio.Writer) { 112 | errFileFlush := outputWriter.Flush() 113 | if errFileFlush != nil { 114 | errResponse = fmt.Errorf("%w: %v", ErrCantFlushOutputFile, errFileFlush) 115 | return 116 | } 117 | }(outputWriter) 118 | 119 | _, errWriteFile := outputWriter.Write(bytes.Replace(template, []byte("goCLIInput"), benchmarkResultsJSON, 1)) 120 | if errWriteFile != nil { 121 | return fmt.Errorf("%w: %v", ErrCantWriteOutputFile, errWriteFile) 122 | } 123 | 124 | return nil 125 | } 126 | 127 | func parseBenchmarkResultsFromInputFile( 128 | inputFile *os.File, options map[ChartName]map[ChartOption]string, 129 | ) ([]*BenchmarkResults, error) { 130 | inputScanner := bufio.NewScanner(inputFile) 131 | inputScanner.Split(bufio.ScanLines) 132 | 133 | benchmarkToCases := make(map[ChartName]*BenchmarkResults) 134 | benchmarkNamesOrder := make([]ChartName, 0) 135 | firstLine := "" 136 | yAxisLabel := "" 137 | 138 | lineCount := 0 139 | for inputScanner.Scan() { 140 | lineCount++ 141 | 142 | line := strings.TrimSpace(inputScanner.Text()) 143 | if len(line) == 0 { 144 | continue 145 | } 146 | 147 | if strings.HasPrefix(line, "name") && firstLine != "" { 148 | break // TODO support separate graphs for allocations 149 | } 150 | 151 | const minNumberOfCellsInOneRow = 3 152 | 153 | if firstLine == "" { 154 | firstLine = line 155 | 156 | cells := strings.Split(line, ",") 157 | if len(cells) < minNumberOfCellsInOneRow { 158 | return nil, fmt.Errorf("%w: on line [%v]: `%v`", ErrNotEnoughColumns, lineCount, line) 159 | } 160 | 161 | yAxisLabel = cells[1] 162 | 163 | continue 164 | } 165 | 166 | cells := strings.Split(line, ",") 167 | if len(cells) < minNumberOfCellsInOneRow { 168 | return nil, fmt.Errorf("%w: on line [%v]: `%v`", ErrNotEnoughColumns, lineCount, line) 169 | } 170 | 171 | chartName, caseName, xValueStr, xAxisName, errParsingAttributes := parseAttributes(cells[0], options) 172 | if errParsingAttributes != nil { 173 | return nil, fmt.Errorf("%w: on line [%v]: `%v`", errParsingAttributes, lineCount, line) 174 | } 175 | 176 | yValueStr, errorValueStr := cells[1], cells[2] 177 | 178 | point, errPointParsing := parsePoint(xValueStr, yValueStr, errorValueStr) 179 | if errPointParsing != nil { 180 | return nil, fmt.Errorf("%w: on line [%v]: `%v`", errPointParsing, lineCount, line) 181 | } 182 | 183 | cases, ok := benchmarkToCases[chartName] 184 | if !ok { 185 | benchmarkNamesOrder = append(benchmarkNamesOrder, chartName) 186 | cases = initBenchResult(chartName, xAxisName, yAxisLabel) 187 | } 188 | 189 | cases.Cases[caseName] = append(cases.Cases[caseName], point) 190 | benchmarkToCases[chartName] = cases 191 | } 192 | 193 | errApplyingOption := applyOptionsToResults(options, benchmarkToCases) 194 | if errApplyingOption != nil { 195 | return nil, errApplyingOption 196 | } 197 | 198 | benchmarkResults := make([]*BenchmarkResults, 0, len(benchmarkToCases)) 199 | for _, benchmarkName := range benchmarkNamesOrder { 200 | benchmarkResults = append(benchmarkResults, benchmarkToCases[benchmarkName]) 201 | } 202 | 203 | return benchmarkResults, nil 204 | } 205 | 206 | func applyOptionsToResults( 207 | options map[ChartName]map[ChartOption]string, benchmarkToCases map[ChartName]*BenchmarkResults, 208 | ) error { 209 | for optionsBenchName, options := range options { 210 | optionApplied := false 211 | 212 | for benchName, cases := range benchmarkToCases { 213 | if strings.HasPrefix(string(benchName), string(optionsBenchName)) { 214 | optionApplied = true 215 | 216 | for optionKey, optionValue := range options { 217 | cases.Options[optionKey] = optionValue 218 | } 219 | } 220 | } 221 | 222 | if !optionApplied { 223 | return fmt.Errorf( 224 | "%w: %v: `%v`, %v", 225 | ErrOptionChartNameNotFound, "you've passed options for chart with name", 226 | optionsBenchName, "but we didn't find such benchmark within input file", 227 | ) 228 | } 229 | } 230 | 231 | return nil 232 | } 233 | 234 | func initBenchResult(chartName ChartName, xAxisName string, yAxisLabel string) *BenchmarkResults { 235 | nameHash := sha256.Sum256([]byte(chartName)) 236 | 237 | return &BenchmarkResults{ 238 | ID: hex.EncodeToString(nameHash[:]), 239 | Name: chartName, 240 | Options: map[ChartOption]string{"xAxisName": xAxisName}, 241 | YAxisLabel: yAxisLabel, 242 | Cases: make(map[string][]Point), 243 | } 244 | } 245 | 246 | func parsePoint(xValueStr string, yValueStr string, errorValueStr string) (Point, error) { 247 | const percents float64 = 100 248 | 249 | yValue, errParsingYValue := parseYValue(yValueStr) 250 | if errParsingYValue != nil { 251 | return Point{}, errParsingYValue 252 | } 253 | 254 | errorRate, errParsingErrorRate := parseErrorRate(errorValueStr) 255 | if errParsingErrorRate != nil { 256 | return Point{}, errParsingErrorRate 257 | } 258 | 259 | point := Point{ 260 | X: xValueStr, Y: yValueStr, Error: fmt.Sprintf("%v", yValue*(errorRate/percents)), 261 | } 262 | 263 | return point, nil 264 | } 265 | 266 | func parseAttributes( 267 | firstCell string, options map[ChartName]map[ChartOption]string, 268 | ) (ChartName, string, string, string, error) { 269 | benchmarkName, restOfTheCell, ok := strings.Cut(firstCell, "/") 270 | if !ok { 271 | return "", "", "", "", fmt.Errorf("%w: `%v`", ErrCantParseBenchmarkName, firstCell) 272 | } 273 | 274 | measurementAttributesString, _, ok := strings.Cut(restOfTheCell, "-") 275 | if !ok { 276 | return "", "", "", "", fmt.Errorf("%w: `%v`", ErrCantParseMeasurementAttributes, restOfTheCell) 277 | } 278 | 279 | caseName := "" 280 | xValue := "" 281 | xAxisName, xAxisNameDefined := options[ChartName(benchmarkName)]["xAxisName"] 282 | benchmarkAttributes := []string{benchmarkName} 283 | measurementAttributes := strings.Split(measurementAttributesString, ";") 284 | 285 | for i, attribute := range measurementAttributes { 286 | attributeKeyAndValue := strings.Split(attribute, ":") 287 | if xAxisNameDefined && xAxisName == attributeKeyAndValue[0] { 288 | xValue = attributeKeyAndValue[1] 289 | continue 290 | } 291 | 292 | if i == len(measurementAttributes)-1 { 293 | xAxisName, xValue = attributeKeyAndValue[0], attributeKeyAndValue[1] 294 | continue 295 | } 296 | 297 | if attributeKeyAndValue[0] == "type" { 298 | caseName = attributeKeyAndValue[1] 299 | continue 300 | } 301 | 302 | benchmarkAttributes = append(benchmarkAttributes, attributeKeyAndValue[0]+"="+attributeKeyAndValue[1]) 303 | } 304 | 305 | if caseName == "" { 306 | return "", "", "", "", fmt.Errorf( 307 | "%w: `%v`", ErrMeasurementLineHasNoTypeAttribute, measurementAttributesString, 308 | ) 309 | } 310 | 311 | chartName := ChartName(strings.Join(benchmarkAttributes, " ")) 312 | 313 | return chartName, caseName, xValue, xAxisName, nil 314 | } 315 | 316 | func parseYValue(secondCell string) (float64, error) { 317 | bitSize := 64 318 | 319 | yValue, errYValue := strconv.ParseFloat(secondCell, bitSize) 320 | if errYValue != nil { 321 | return 0, fmt.Errorf("%w: `%v`", ErrCantParseYValue, secondCell) 322 | } 323 | 324 | return yValue, nil 325 | } 326 | 327 | func parseErrorRate(thirdCell string) (float64, error) { 328 | bitSize := 64 329 | 330 | if len(thirdCell) < 2 || thirdCell[len(thirdCell)-1] != '%' { 331 | return 0, fmt.Errorf("%w: error rate cell should end with percent symbol `%v`", ErrCantParseErrorRate, thirdCell) 332 | } 333 | 334 | errorRateString := thirdCell[:len(thirdCell)-1] 335 | 336 | errorRate, errParsingErrorRate := strconv.ParseFloat(errorRateString, bitSize) 337 | if errParsingErrorRate != nil { 338 | return 0, fmt.Errorf("%w: `%v`", ErrCantParseErrorRate, thirdCell) 339 | } 340 | 341 | return errorRate, nil 342 | } 343 | 344 | const helpMessage = ` 345 | Not enough arguments. specify input file path first and output file path second 346 | example: 347 | > benchart input.csv result.html 348 | 349 | you can also specify some options for charts with name of the chart at the beginning: 350 | > benchart 'PoolOverhead;title=Overhead;xAxisName=Number of tasks;xAxisType=log;yAxisType=log' input.csv result.html 351 | 352 | list of supported chart options: ` 353 | 354 | func parseOptions(cliArguments []string) (string, string, map[ChartName]map[ChartOption]string, error) { 355 | supportedChartOptions := map[ChartOption]ChartOptionTypeSet{ 356 | "title": {"string": struct{}{}}, 357 | "xAxisName": {"string": struct{}{}}, 358 | "xAxisType": {"log": struct{}{}}, 359 | "yAxisType": {"log": struct{}{}}, 360 | } 361 | 362 | minArgumentsCount := 3 363 | if len(cliArguments) < minArgumentsCount { 364 | return "", "", nil, fmt.Errorf("%w: %v%v", ErrNotEnoughInputArguments, helpMessage, supportedChartOptions) 365 | } 366 | 367 | inputFilePath := cliArguments[len(cliArguments)-2] 368 | outputFilePath := cliArguments[len(cliArguments)-1] 369 | options := make(map[ChartName]map[ChartOption]string) 370 | 371 | if len(cliArguments) == minArgumentsCount { 372 | return inputFilePath, outputFilePath, options, nil 373 | } 374 | 375 | optionArguments := cliArguments[1 : len(cliArguments)-2] 376 | for _, optionsString := range optionArguments { 377 | chartName, otherOptions, ok := strings.Cut(optionsString, ";") 378 | if !ok { 379 | return "", "", nil, fmt.Errorf("%w: %+v", ErrCantParseChartOptions, optionsString) 380 | } 381 | 382 | chartOptions, ok := options[ChartName(chartName)] 383 | if !ok { 384 | chartOptions = make(map[ChartOption]string) 385 | } 386 | 387 | for _, optionString := range strings.Split(otherOptions, ";") { 388 | optionSlice := strings.Split(optionString, "=") 389 | optionPartsCount := 2 390 | 391 | if len(optionSlice) != optionPartsCount { 392 | return "", "", nil, fmt.Errorf("%w: %+v", ErrCantParseOption, optionString) 393 | } 394 | 395 | chartOption := ChartOption(optionSlice[0]) 396 | 397 | optionTypeSet, ok := supportedChartOptions[chartOption] 398 | if !ok { 399 | return "", "", nil, fmt.Errorf( 400 | "%w: %+v; List of suported options: %v", 401 | ErrOptionIsNotSupported, optionString, supportedChartOptions, 402 | ) 403 | } 404 | 405 | chartOptionValue := optionSlice[1] 406 | _, stringAllowed := optionTypeSet["string"] 407 | _, thisOptionAllowed := optionTypeSet[ChartOptionType(chartOptionValue)] 408 | 409 | if stringAllowed || thisOptionAllowed { 410 | chartOptions[chartOption] = chartOptionValue 411 | continue 412 | } else { 413 | return "", "", nil, fmt.Errorf( 414 | "%w: option %v allows only %v", 415 | ErrOptionTypeIsWrong, chartOption, optionTypeSet, 416 | ) 417 | } 418 | } 419 | 420 | options[ChartName(chartName)] = chartOptions 421 | } 422 | 423 | return inputFilePath, outputFilePath, options, nil 424 | } 425 | -------------------------------------------------------------------------------- /benchart_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "hash/crc64" 8 | "io" 9 | "os" 10 | "testing" 11 | ) 12 | 13 | var filesToClean []string 14 | 15 | func TestMain(m *testing.M) { 16 | exitVal := m.Run() 17 | 18 | for _, filePath := range filesToClean { 19 | errFileDeletion := os.Remove(filePath) 20 | if errFileDeletion != nil { 21 | fmt.Printf("Can't delete test file: %v: %v\n", filePath, errFileDeletion) 22 | } 23 | } 24 | 25 | os.Exit(exitVal) 26 | } 27 | 28 | func Test_RunCommand(t *testing.T) { 29 | errCommand := RunCommand([]string{ 30 | "benchart", 31 | "Hash;xAxisName=bytes size;title=Benchmark of hash functions", 32 | "PoolOverhead;xAxisType=log;yAxisType=log", 33 | "RateLimiter;xAxisType=log;xAxisName=goroutines", 34 | "testdata/input.csv", "result.html", 35 | }) 36 | if errCommand != nil { 37 | t.Fatal(errCommand) 38 | } 39 | 40 | resultChecksum := checksumFromFile(t, "result.html") 41 | expectedChecksum := checksumFromFile(t, "testdata/etalon.html") 42 | 43 | if resultChecksum != expectedChecksum { 44 | t.Fatal("testdata/etalon.html is different from result.html") 45 | } 46 | } 47 | 48 | func checksumFromFile(t *testing.T, filepath string) uint64 { 49 | t.Helper() 50 | 51 | file, errOpenResultFile := os.Open(filepath) 52 | if errOpenResultFile != nil { 53 | t.Fatal(errOpenResultFile) 54 | } 55 | 56 | defer func(resultFile *os.File) { 57 | errClose := resultFile.Close() 58 | if errClose != nil { 59 | t.Fatal(errClose) 60 | } 61 | }(file) 62 | 63 | hashCrc64 := crc64.New(crc64.MakeTable(crc64.ISO)) 64 | 65 | _, errCopyResultFile := io.Copy(hashCrc64, file) 66 | if errCopyResultFile != nil { 67 | t.Fatal(errCopyResultFile) 68 | } 69 | 70 | return hashCrc64.Sum64() 71 | } 72 | 73 | func Test_RunCommand_Negative(t *testing.T) { 74 | type args struct { 75 | arguments []string 76 | } 77 | 78 | input := func(input ...string) args { 79 | return args{append([]string{"benchart"}, input...)} 80 | } 81 | 82 | tests := []struct { 83 | name string 84 | args args 85 | expectedError error 86 | }{ 87 | { 88 | "ErrCantOpenInputFile", 89 | input("nonExistentInputFile", "someOutput"), 90 | ErrCantOpenInputFile, 91 | }, 92 | { 93 | "ErrCantOpenOrCreateOutputFile", 94 | input("testdata/input.csv", "/nonExistentOutputPathFile/nonExistentOutputFile.html"), 95 | ErrCantOpenOrCreateOutputFile, 96 | }, 97 | { 98 | "ErrNotEnoughInputArguments", 99 | input("notEnoughInputArguments"), 100 | ErrNotEnoughInputArguments, 101 | }, 102 | { 103 | "ErrCantParseChartOptions", 104 | input("", "testdata/input.csv", "etalon.html"), 105 | ErrCantParseChartOptions, 106 | }, 107 | { 108 | "ErrCantParseOption", 109 | input(";", "testdata/input.csv", "etalon.html"), 110 | ErrCantParseOption, 111 | }, 112 | { 113 | "ErrOptionIsNotSupported", 114 | input(";someOption=", "testdata/input.csv", "etalon.html"), 115 | ErrOptionIsNotSupported, 116 | }, 117 | { 118 | "ErrOptionTypeIsWrong", 119 | input(";xAxisType=", "testdata/input.csv", "etalon.html"), 120 | ErrOptionTypeIsWrong, 121 | }, 122 | { 123 | "ErrOptionTypeIsWrong", 124 | input(";xAxisType=exp", "testdata/input.csv", "etalon.html"), 125 | ErrOptionTypeIsWrong, 126 | }, 127 | { 128 | "ErrNotEnoughColumns", 129 | input( 130 | ";xAxisType=log", 131 | caseFile(t, ` 132 | name,time/op (ns/op)± 133 | Hash/type:crc32;bytes:4-8,4.13067E+00,1% 134 | `), 135 | "etalon.html", 136 | ), 137 | ErrNotEnoughColumns, 138 | }, 139 | { 140 | "ErrNotEnoughColumns", 141 | input( 142 | ";xAxisType=log", 143 | caseFile(t, ` 144 | name,time/op (ns/op),± 145 | Hash/type:crc32;bytes:4-8,4.13067E+00|1% 146 | `), 147 | "etalon.html", 148 | ), 149 | ErrNotEnoughColumns, 150 | }, 151 | { 152 | "ErrCantParseBenchmarkName", 153 | input( 154 | ";xAxisType=log", 155 | caseFile(t, ` 156 | name,time/op (ns/op),± 157 | Hash\type:crc32;bytes:4-8,4.13067E+00,1% 158 | `), 159 | "etalon.html", 160 | ), 161 | ErrCantParseBenchmarkName, 162 | }, 163 | { 164 | "ErrCantParseMeasurementAttributes", 165 | input( 166 | ";xAxisType=log", 167 | caseFile(t, ` 168 | name,time/op (ns/op),± 169 | Hash/type:crc32;bytes:4|8,4.13067E+00,1% 170 | `), 171 | "etalon.html", 172 | ), 173 | ErrCantParseMeasurementAttributes, 174 | }, 175 | { 176 | "ErrMeasurementLineHasNoTypeAttribute", 177 | input( 178 | ";xAxisType=log", 179 | caseFile(t, ` 180 | name,time/op (ns/op),± 181 | Hash/types:crc32;bytes:4-8,4.13067E+00,1% 182 | `), 183 | "etalon.html", 184 | ), 185 | ErrMeasurementLineHasNoTypeAttribute, 186 | }, 187 | { 188 | "ErrCantParseYValue", 189 | input( 190 | ";xAxisType=log", 191 | caseFile(t, ` 192 | name,time/op (ns/op),± 193 | Hash/type:crc32;bytes:4-8,4.GG13067E+00,1% 194 | `), 195 | "etalon.html", 196 | ), 197 | ErrCantParseYValue, 198 | }, 199 | { 200 | "ErrCantParseErrorRate", 201 | input( 202 | ";xAxisType=log", 203 | caseFile(t, ` 204 | name,time/op (ns/op),± 205 | Hash/type:crc32;bytes:4-8,4.13067E+00,1 206 | `), 207 | "etalon.html", 208 | ), 209 | ErrCantParseErrorRate, 210 | }, 211 | { 212 | "ErrCantParseErrorRate", 213 | input( 214 | ";xAxisType=log", 215 | caseFile(t, ` 216 | name,time/op (ns/op),± 217 | Hash/type:crc32;bytes:4-8,4.13067E+00,1GG% 218 | `), 219 | "etalon.html", 220 | ), 221 | ErrCantParseErrorRate, 222 | }, 223 | { 224 | "ErrOptionChartNameNotFound", 225 | input( 226 | "HashesBench;xAxisType=log", 227 | caseFile(t, ` 228 | name,time/op (ns/op),± 229 | Hash/type:crc32;bytes:4-8,4.13067E+00,1% 230 | `), 231 | "etalon.html", 232 | ), 233 | ErrOptionChartNameNotFound, 234 | }, 235 | } 236 | 237 | for _, tt := range tests { 238 | t.Run(tt.name, func(t *testing.T) { 239 | errCommand := RunCommand(tt.args.arguments) 240 | if tt.expectedError != nil && !errors.Is(errCommand, tt.expectedError) { 241 | t.Fatalf("\nExp: `%T(%+v)`\nAct: `%T(%+v)`\n", tt.expectedError, tt.expectedError, errCommand, errCommand) 242 | } 243 | }) 244 | } 245 | } 246 | 247 | func caseFile(t *testing.T, body string) string { 248 | t.Helper() 249 | 250 | file, errFileCreation := os.CreateTemp("", "input-*.csv") 251 | if errFileCreation != nil { 252 | t.Fatalf(errFileCreation.Error()) 253 | } 254 | 255 | filesToClean = append(filesToClean, file.Name()) 256 | 257 | writer := bufio.NewWriter(file) 258 | defer func(writer *bufio.Writer) { 259 | errFlushWrite := writer.Flush() 260 | if errFlushWrite != nil { 261 | t.Fatalf(errFlushWrite.Error()) 262 | } 263 | }(writer) 264 | 265 | _, errWriteFile := writer.Write([]byte(body)) 266 | if errWriteFile != nil { 267 | t.Fatalf(errWriteFile.Error()) 268 | } 269 | 270 | return file.Name() 271 | } 272 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/storozhukBM/benchart 2 | 3 | go 1.18 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storozhukBM/benchart/7fe4adfb58af1d2ad3d9244322096c2837aac7f8/go.sum -------------------------------------------------------------------------------- /template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Charts 8 | 9 | 10 | 79 | 80 | 81 | --------------------------------------------------------------------------------