diff --git a/.gitignore b/.gitignore index a960199..2a925db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Binary files +/bin + # IDE files /.vscode /.idea diff --git a/README.md b/README.md index 308c24f..0fa9364 100644 --- a/README.md +++ b/README.md @@ -16,93 +16,146 @@ ## About -Benchttp engine is a Go library providing a way to perform benchmarks and tests -on HTTP endpoints. +Benchttp is a command line tool for end-to-end performance testing of HTTP endpoints. + +![Benchttp demo](doc/demo.gif) + +You can define performance targets for an endpoint in a declarative way and run them from the command line. + +The test suite will exits with a status code 0 if successful or 1 if any test failed. This makes Benchttp very interoperable with a CI. +You can making sure your changes do not introduce any perfomance regressions. + +![Benchttp test suite](doc/test-suite.png) ## Installation -### Prerequisites +### Manual download + +Download the latest release from the [releases page](https://github.com/benchttp/engine/releases) that matches your operating system and architecture (format is `benchttp__`). + +Rename the binary to `benchttp` and move it to a directory in your `PATH`. + +```bash +mv benchttp__ benchttp + +export PATH=$PATH:/path/to/benchttp -Go1.17 environment or higher is required. +benchttp version +``` -Install. +## Command line usage -```txt -go get github.com/benchttp/engine +```bash +# Command syntax +benchttp run [options] ``` -## Usage +If no options are provided, benchttp will use a default configuration with minimal options. +Only the URL is always required. -### Basic usage +```bash +benchttp run -url https://example.com +``` -```go -package main +### Configuration -import ( - "context" - "fmt" +You can override the default configuration and fine tune the Benchttp runner by either providing a configuration file (YAML or JSON) with the `-configFile` flag, or by passing individual flags to the `run` command. - "github.com/benchttp/engine/benchttp" -) +Mixing configuration file and flags is possible, the flags will override the configuration file. -func main() { - report, _ := benchttp. - DefaultRunner(). // Default runner with safe configuration - WithNewRequest("GET", "http://localhost:3000", nil). // Attach request - Run(context.Background()) // Run benchmark, retrieve report +### Specification - fmt.Println(report.Metrics.ResponseTimes.Mean) -} -``` +Every option can be set either via command line flags or a configuration file, expect for command line only options (see below). Option names always match between the two. -### Usage with JSON config via `configio` +#### HTTP request options -```go -package main +| CLI flag | File option | Description | Usage example | +| --------- | --------------------- | ------------------------- | ----------------------------------------- | +| `-url` | `request.url` | Target URL (**Required**) | `-url http://localhost:8080/users?page=3` | +| `-method` | `request.method` | HTTP Method | `-method POST` | +| - | `request.queryParams` | Added query params to URL | - | +| `-header` | `request.header` | Request headers | `-header 'key0:val0' -header 'key1:val1'` | +| `-body` | `request.body` | Raw request body | `-body 'raw:{"id":"abc"}'` | -import ( - "context" - "fmt" +#### Runner options - "github.com/benchttp/engine/benchttp" - "github.com/benchttp/engine/configio" -) +| CLI flag | File option | Description | Usage example | +| ----------------- | ----------------------- | -------------------------------------------------------------------- | -------------------- | +| `-requests` | `runner.requests` | Number of requests to run (-1 means infinite, stop on globalTimeout) | `-requests 100` | +| `-concurrency` | `runner.concurrency` | Maximum concurrent requests | `-concurrency 10` | +| `-interval` | `runner.interval` | Minimum duration between two non-concurrent requests | `-interval 200ms` | +| `-requestTimeout` | `runner.requestTimeout` | Timeout for every single request | `-requestTimeout 5s` | +| `-globalTimeout` | `runner.globalTimeout` | Timeout for the whole benchmark | `-globalTimeout 30s` | -func main() { - // JSON configuration obtained via e.g. a file or HTTP call - jsonConfig := []byte(` -{ - "request": { - "url": "http://localhost:3000" - } -}`) +Note: the expected format for durations is ``, with `unit` being any of `ns`, `µs`, `ms`, `s`, `m`, `h`. - // Instantiate a base Runner (here the default with a safe configuration) - runner := benchttp.DefaultRunner() +#### Test suite options - // Parse the json configuration into the Runner - _ = configio.UnmarshalJSONRunner(jsonConfig, &runner) +Test suite options are only available via configuration file. +They must be declared in a configuration file. There is currently no way to set these via cli options. - // Run benchmark, retrieve report - report, _ := runner.Run(context.Background()) +Refer to [our Wiki](https://github.com/benchttp/engine/wiki/IO-Structures#yaml) for how to configure test suite. +You can define a test for every available [fields](https://github.com/benchttp/engine/wiki/Fields). - fmt.Println(report.Metrics.ResponseTimes.Mean) -} -``` +#### Command line only options + +| CLI flag | Description | Usage example | +| ------------- | ---------------------------- | ---------------------------------- | +| `-silent` | Remove convenience prints | `-silent` / `-silent=false` | +| `-configFile` | Path to benchttp config file | `-configFile=path/to/benchttp.yml` | + +## Use in CI + +Benchttp can aslo be used in CI. A GitHub action is available [here](https://github.com/benchttp/action). + +## How does the configuration work? -📄 Please refer to [our Wiki](https://github.com/benchttp/engine/wiki/IO-Structures) for exhaustive `Runner` and `Report` structures (and more!) +The runner uses a default configuration that can be overridden by a configuration file and/or flags. To determine the final configuration of a benchmark and which options take predecence over the others, the runner follows this flow: + +1. It starts with a [default configuration](./examples/config/default.yml) +2. Then it tries to find a configuration file and overrides the defaults with the values set in it + + - If flag `-configFile` is set, it resolves its value as a path + - Else, it tries to find a config file in the working directory, by priority order: + `.benchttp.yml` > `.benchttp.yaml` > `.benchttp.json` + + The configuration file is _optional_: if none is found, this step is ignored. + If a configuration file has an option `extends`, it resolves all files recursively until the root is reached and overrides the values from parent to child. + +3. Then it overrides the current config values with any value set via command line flags +4. Finally, it performs a validation on the resulting config (not before!). + This allows composed configurations for better granularity. ## Development -### Prerequisites +Requires Go version 1.17 or higher. + +Build for all platforms: + +```bash +./script/build +``` + +```bash +./script/build +``` + +Test: + +```bash +./script/test +``` -1. Go 1.17 or higher is required -1. Golangci-lint for linting files +Lint: -### Main commands +Requires [golangci-lint](https://golangci-lint.run/). -| Command | Description | -| --------------- | ------------------------------------------------- | -| `./script/lint` | Runs lint on the codebase | -| `./script/test` | Runs tests suites from all packages | -| `./script/doc` | Serves Go doc for this module at `localhost:9995` | +```bash +./script/lint +``` + +Serve Go doc: + +```bash +./script/doc +``` diff --git a/cli/configflag/bind.go b/cli/configflag/bind.go new file mode 100644 index 0000000..2cfb717 --- /dev/null +++ b/cli/configflag/bind.go @@ -0,0 +1,139 @@ +package configflag + +import ( + "bytes" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/benchttp/engine/configio" +) + +// Bind reads arguments provided to flagset as config fields +// and binds their value to the appropriate fields of dst. +// The provided *flag.Flagset must not have been parsed yet, otherwise +// bindings its values would fail. +func Bind(flagset *flag.FlagSet, dst *configio.Builder) { + for field, bind := range bindings { + flagset.Func(field, flagsUsage[field], bind(dst)) + } +} + +type setter = func(string) error + +var bindings = map[string]func(*configio.Builder) setter{ + flagMethod: func(b *configio.Builder) setter { + return func(in string) error { + b.SetRequestMethod(in) + return nil + } + }, + flagURL: func(b *configio.Builder) setter { + return func(in string) error { + u, err := url.ParseRequestURI(in) + if err != nil { + return err + } + b.SetRequestURL(u) + return nil + } + }, + flagHeader: func(b *configio.Builder) setter { + return func(in string) error { + keyval := strings.SplitN(in, ":", 2) + if len(keyval) != 2 { + return errors.New(`-header: expect format ":"`) + } + key, val := keyval[0], keyval[1] + b.SetRequestHeaderFunc(func(h http.Header) http.Header { + if h == nil { + h = http.Header{} + } + h[key] = append(h[key], val) + return h + }) + return nil + } + }, + flagBody: func(b *configio.Builder) setter { + return func(in string) error { + errFormat := fmt.Errorf(`expect format ":", got %q`, in) + if in == "" { + return errFormat + } + split := strings.SplitN(in, ":", 2) + if len(split) != 2 { + return errFormat + } + btype, bcontent := split[0], split[1] + if bcontent == "" { + return errFormat + } + switch btype { + case "raw": + b.SetRequestBody(io.NopCloser(bytes.NewBufferString(bcontent))) + // case "file": + // // TODO + default: + return fmt.Errorf(`unsupported type: %s (only "raw" accepted)`, btype) + } + return nil + } + }, + flagRequests: func(b *configio.Builder) setter { + return func(in string) error { + n, err := strconv.Atoi(in) + if err != nil { + return err + } + b.SetRequests(n) + return nil + } + }, + flagConcurrency: func(b *configio.Builder) setter { + return func(in string) error { + n, err := strconv.Atoi(in) + if err != nil { + return err + } + b.SetConcurrency(n) + return nil + } + }, + flagInterval: func(b *configio.Builder) setter { + return func(in string) error { + d, err := time.ParseDuration(in) + if err != nil { + return err + } + b.SetInterval(d) + return nil + } + }, + flagRequestTimeout: func(b *configio.Builder) setter { + return func(in string) error { + d, err := time.ParseDuration(in) + if err != nil { + return err + } + b.SetRequestTimeout(d) + return nil + } + }, + flagGlobalTimeout: func(b *configio.Builder) setter { + return func(in string) error { + d, err := time.ParseDuration(in) + if err != nil { + return err + } + b.SetGlobalTimeout(d) + return nil + } + }, +} diff --git a/cli/configflag/bind_test.go b/cli/configflag/bind_test.go new file mode 100644 index 0000000..b33c82a --- /dev/null +++ b/cli/configflag/bind_test.go @@ -0,0 +1,83 @@ +package configflag_test + +import ( + "bytes" + "flag" + "io" + "net/http" + "net/url" + "testing" + "time" + + "github.com/benchttp/engine/benchttp" + "github.com/benchttp/engine/benchttptest" + "github.com/benchttp/engine/configio" + + "github.com/benchttp/engine/cli/configflag" +) + +func TestBind(t *testing.T) { + t.Run("default to zero runner", func(t *testing.T) { + flagset := flag.NewFlagSet("", flag.ExitOnError) + args := []string{} // no args + + b := configio.Builder{} + configflag.Bind(flagset, &b) + if err := flagset.Parse(args); err != nil { + t.Fatal(err) // critical error, stop the test + } + + benchttptest.AssertEqualRunners(t, benchttp.Runner{}, b.Runner()) + }) + + t.Run("set config with flags values", func(t *testing.T) { + flagset := flag.NewFlagSet("", flag.ExitOnError) + args := []string{ + "-method", "POST", + "-url", "https://example.com?a=b", + "-header", "API_KEY:abc", + "-header", "Accept:text/html", + "-header", "Accept:application/json", + "-body", "raw:hello", + "-requests", "1", + "-concurrency", "2", + "-interval", "3s", + "-requestTimeout", "4s", + "-globalTimeout", "5s", + } + + b := configio.Builder{} + configflag.Bind(flagset, &b) + if err := flagset.Parse(args); err != nil { + t.Fatal(err) // critical error, stop the test + } + + benchttptest.AssertEqualRunners(t, + benchttp.Runner{ + Request: &http.Request{ + Method: "POST", + URL: mustParseURL("https://example.com?a=b"), + Header: http.Header{ + "API_KEY": []string{"abc"}, + "Accept": []string{"text/html", "application/json"}, + }, + Body: io.NopCloser(bytes.NewBufferString("hello")), + }, + Requests: 1, + Concurrency: 2, + Interval: 3 * time.Second, + RequestTimeout: 4 * time.Second, + GlobalTimeout: 5 * time.Second, + }, + b.Runner(), + ) + }) +} + +func mustParseURL(v string) *url.URL { + u, err := url.ParseRequestURI(v) + if err != nil { + panic("mustParseURL: " + err.Error()) + } + return u +} diff --git a/cli/configflag/configflag.go b/cli/configflag/configflag.go new file mode 100644 index 0000000..e841413 --- /dev/null +++ b/cli/configflag/configflag.go @@ -0,0 +1,28 @@ +package configflag + +const ( + flagMethod = "method" + flagURL = "url" + flagHeader = "header" + flagBody = "body" + flagRequests = "requests" + flagConcurrency = "concurrency" + flagInterval = "interval" + flagRequestTimeout = "requestTimeout" + flagGlobalTimeout = "globalTimeout" + flagTests = "tests" +) + +// flagsUsage is a record of all available config flags and their usage. +var flagsUsage = map[string]string{ + flagMethod: "HTTP request method", + flagURL: "HTTP request url", + flagHeader: "HTTP request header", + flagBody: "HTTP request body", + flagRequests: "Number of requests to run, use duration as exit condition if omitted", + flagConcurrency: "Number of connections to run concurrently", + flagInterval: "Minimum duration between two non concurrent requests", + flagRequestTimeout: "Timeout for each HTTP request", + flagGlobalTimeout: "Max duration of test", + flagTests: "Test suite", +} diff --git a/cli/errorutil/errorutil.go b/cli/errorutil/errorutil.go new file mode 100644 index 0000000..d733a93 --- /dev/null +++ b/cli/errorutil/errorutil.go @@ -0,0 +1,24 @@ +package errorutil + +import ( + "fmt" + "strings" +) + +// WithDetails returns an error wrapping err, appended with a string +// representation of details separated by ": ". +// +// Example +// +// var ErrNotFound = errors.New("not found") +// err := WithDetails(ErrNotFound, "abc.jpg", "deleted yesterday") +// +// errors.Is(err, ErrNotFound) == true +// err.Error() == "not found: abc.jpg: deleted yesterday" +func WithDetails(base error, details ...interface{}) error { + detailsStr := make([]string, len(details)) + for i := range details { + detailsStr[i] = fmt.Sprint(details[i]) + } + return fmt.Errorf("%w: %s", base, strings.Join(detailsStr, ": ")) +} diff --git a/cli/output/conditional.go b/cli/output/conditional.go new file mode 100644 index 0000000..ffe0f28 --- /dev/null +++ b/cli/output/conditional.go @@ -0,0 +1,38 @@ +package output + +import ( + "io" +) + +// ConditionalWriter is an io.Writer that wraps an input writer +// and exposes methods to condition its action. +type ConditionalWriter struct { + Writer io.Writer + ok bool +} + +// Write writes b only if ConditionalWriter.Mute is false, +// otherwise it is no-op. +func (w ConditionalWriter) Write(b []byte) (int, error) { + if !w.ok { + return 0, nil + } + return w.Writer.Write(b) +} + +// If sets the write condition to v. +func (w ConditionalWriter) If(v bool) ConditionalWriter { + return ConditionalWriter{ + Writer: w.Writer, + ok: v, + } +} + +// ElseIf either keeps the previous write condition if it is true, +// else it sets it to v. +func (w ConditionalWriter) ElseIf(v bool) ConditionalWriter { + return ConditionalWriter{ + Writer: w.Writer, + ok: w.ok || v, + } +} diff --git a/cli/render/ansi/style.go b/cli/render/ansi/style.go new file mode 100644 index 0000000..a7c9227 --- /dev/null +++ b/cli/render/ansi/style.go @@ -0,0 +1,70 @@ +package ansi + +import ( + "fmt" + "strings" +) + +type StyleFunc func(in string) string + +type style string + +const ( + reset style = "\033[0m" + + bold style = "\033[1m" + + grey style = "\033[1;30m" + red style = "\033[1;31m" + green style = "\033[1;32m" + yellow style = "\033[1;33m" + cyan style = "\033[1;36m" + + erase style = "\033[1A" +) + +func withStyle(in string, s style) string { + return fmt.Sprintf("%s%s%s", s, in, reset) +} + +// Bold returns the bold version of the input string. +func Bold(in string) string { + return withStyle(in, bold) +} + +// Green returns the green version of the input string. +func Green(in string) string { + return withStyle(in, green) +} + +// Yellow returns the yellow version of the input string. +func Yellow(in string) string { + return withStyle(in, yellow) +} + +// Cyan returns the cyan version of the input string. +func Cyan(in string) string { + return withStyle(in, cyan) +} + +// Red returns the red version of the input string. +func Red(in string) string { + return withStyle(in, red) +} + +// Grey returns the grey version of the input string. +func Grey(in string) string { + return withStyle(in, grey) +} + +// Erase returns a string that erases the previous line n times. +func Erase(n int) string { + if n < 1 { + return "" + } + var b strings.Builder + for i := 0; i < n; i++ { + b.Write([]byte(erase)) + } + return b.String() +} diff --git a/cli/render/progress.go b/cli/render/progress.go new file mode 100644 index 0000000..3e97a1f --- /dev/null +++ b/cli/render/progress.go @@ -0,0 +1,88 @@ +package render + +import ( + "fmt" + "io" + "strconv" + "strings" + + "github.com/benchttp/engine/benchttp" + + "github.com/benchttp/engine/cli/render/ansi" +) + +// Progress renders a fancy representation of a runner.RecordingProgress +// and writes the result to w. +func Progress(w io.Writer, p benchttp.RecordingProgress) (int, error) { + return fmt.Fprint(w, progressString(p)) +} + +// progressString returns a string representation of a runner.RecordingProgress +// for a fancy display in a CLI: +// +// RUNNING ◼︎◼︎◼︎◼︎◼︎◼︎◼︎◼︎◼︎◼︎ 50% | 50/100 requests | 27s timeout +func progressString(p benchttp.RecordingProgress) string { + var ( + countdown = p.Timeout - p.Elapsed + reqmax = strconv.Itoa(p.MaxCount) + pctdone = p.Percent() + timeline = renderTimeline(pctdone) + ) + + if reqmax == "-1" { + reqmax = "∞" + } + if countdown < 0 { + countdown = 0 + } + + return fmt.Sprintf( + "%s%s %s %d%% | %d/%s requests | %.0fs timeout \n", + ansi.Erase(1), // replace previous line + renderStatus(p.Status()), timeline, pctdone, // progress + p.DoneCount, reqmax, // requests + countdown.Seconds(), // timeout + ) +} + +var ( + tlBlock = "◼︎" + tlBlockGrey = ansi.Grey(tlBlock) + tlBlockGreen = ansi.Green(tlBlock) + tlLen = 10 +) + +// renderTimeline returns a colored representation of the progress as a string: +// +// ◼︎◼︎◼︎◼︎◼︎◼︎◼︎◼︎◼︎◼︎ +func renderTimeline(pctdone int) string { + tl := strings.Repeat(tlBlockGrey, tlLen) + for i := 0; i < tlLen; i++ { + if pctdone >= (tlLen * i) { + tl = strings.Replace(tl, tlBlockGrey, tlBlockGreen, 1) + } + } + return tl +} + +// renderStatus returns a string representing the status, +// depending on whether the run is done or not and the value +// of its context error. +func renderStatus(status benchttp.RecordingStatus) string { + styled := statusStyle(status) + return styled(string(status)) +} + +func statusStyle(status benchttp.RecordingStatus) ansi.StyleFunc { + switch status { + case benchttp.StatusRunning: + return ansi.Yellow + case benchttp.StatusDone: + return ansi.Green + case benchttp.StatusCanceled: + return ansi.Red + case benchttp.StatusTimeout: + return ansi.Cyan + } + return ansi.Grey // should not occur +} diff --git a/cli/render/report.go b/cli/render/report.go new file mode 100644 index 0000000..34fa48e --- /dev/null +++ b/cli/render/report.go @@ -0,0 +1,55 @@ +package render + +import ( + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/benchttp/engine/benchttp" + + "github.com/benchttp/engine/cli/render/ansi" +) + +func ReportSummary(w io.Writer, rep *benchttp.Report) (int, error) { + return w.Write([]byte(ReportSummaryString(rep))) +} + +// String returns a default summary of the Report as a string. +func ReportSummaryString(rep *benchttp.Report) string { + var b strings.Builder + + line := func(name string, value interface{}) string { + const template = "%-18s %v\n" + return fmt.Sprintf(template, name, value) + } + + msString := func(d time.Duration) string { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + + formatRequests := func(n, max int) string { + maxString := strconv.Itoa(max) + if maxString == "-1" { + maxString = "∞" + } + return fmt.Sprintf("%d/%s", n, maxString) + } + + m := rep.Metrics + r := rep.Metadata.Runner + + b.WriteString(ansi.Bold("→ Summary")) + b.WriteString("\n") + b.WriteString(line("Endpoint", r.Request.URL)) + b.WriteString(line("Requests", formatRequests(len(m.Records), r.Requests))) + b.WriteString(line("Errors", len(m.RequestFailures))) + b.WriteString(line("Min response time", msString(m.ResponseTimes.Min))) + b.WriteString(line("Max response time", msString(m.ResponseTimes.Max))) + b.WriteString(line("Mean response time", msString(m.ResponseTimes.Mean))) + b.WriteString(line("Total duration", msString(rep.Metadata.TotalDuration))) + b.WriteString("\n") + + return b.String() +} diff --git a/cli/render/report_test.go b/cli/render/report_test.go new file mode 100644 index 0000000..72ba406 --- /dev/null +++ b/cli/render/report_test.go @@ -0,0 +1,78 @@ +package render_test + +import ( + "net/http" + "testing" + "time" + + "github.com/benchttp/engine/benchttp" + + "github.com/benchttp/engine/cli/render" + "github.com/benchttp/engine/cli/render/ansi" +) + +func TestReport_String(t *testing.T) { + t.Run("returns metrics summary", func(t *testing.T) { + metrics, duration := metricsStub() + runner := runnerStub() + + rep := &benchttp.Report{ + Metrics: metrics, + Metadata: benchttp.Metadata{ + Runner: runner, + TotalDuration: duration, + }, + } + checkSummary(t, render.ReportSummaryString(rep)) + }) +} + +// helpers + +func metricsStub() (agg benchttp.MetricsAggregate, total time.Duration) { + return benchttp.MetricsAggregate{ + RequestFailures: make([]struct { + Reason string + }, 1), + Records: make([]struct{ ResponseTime time.Duration }, 3), + ResponseTimes: benchttp.MetricsTimeStats{ + Min: 4 * time.Second, + Max: 6 * time.Second, + Mean: 5 * time.Second, + }, + }, 15 * time.Second +} + +func runnerStub() benchttp.Runner { + runner := benchttp.Runner{} + runner.Request = mustMakeRequest("https://a.b.com") + runner.Requests = -1 + return runner +} + +func checkSummary(t *testing.T, summary string) { + t.Helper() + + expSummary := ansi.Bold("→ Summary") + ` +Endpoint https://a.b.com +Requests 3/∞ +Errors 1 +Min response time 4000ms +Max response time 6000ms +Mean response time 5000ms +Total duration 15000ms + +` + + if summary != expSummary { + t.Errorf("\nexp summary:\n%q\ngot summary:\n%q", expSummary, summary) + } +} + +func mustMakeRequest(uri string) *http.Request { + req, err := http.NewRequest("GET", uri, nil) + if err != nil { + panic(err) + } + return req +} diff --git a/cli/render/testsuite.go b/cli/render/testsuite.go new file mode 100644 index 0000000..63714fd --- /dev/null +++ b/cli/render/testsuite.go @@ -0,0 +1,63 @@ +package render + +import ( + "io" + "strings" + + "github.com/benchttp/engine/benchttp" + + "github.com/benchttp/engine/cli/render/ansi" +) + +func TestSuite(w io.Writer, suite benchttp.TestSuiteResults) (int, error) { + return w.Write([]byte(TestSuiteString(suite))) +} + +// String returns a default summary of the Report as a string. +func TestSuiteString(suite benchttp.TestSuiteResults) string { + if len(suite.Results) == 0 { + return "" + } + + var b strings.Builder + + b.WriteString(ansi.Bold("→ Test suite")) + b.WriteString("\n") + + writeResultString(&b, suite.Pass) + b.WriteString("\n") + + for _, tr := range suite.Results { + writeIndent(&b, 1) + writeResultString(&b, tr.Pass) + b.WriteString(" ") + b.WriteString(tr.Input.Name) + + if !tr.Pass { + b.WriteString("\n ") + writeIndent(&b, 3) + b.WriteString(ansi.Bold("→ ")) + b.WriteString(tr.Summary) + } + + b.WriteString("\n") + } + + return b.String() +} + +func writeResultString(w io.StringWriter, pass bool) { + if pass { + w.WriteString(ansi.Green("PASS")) + } else { + w.WriteString(ansi.Red("FAIL")) + } +} + +func writeIndent(w io.StringWriter, count int) { + if count <= 0 { + return + } + const baseIndent = " " + w.WriteString(strings.Repeat(baseIndent, count)) +} diff --git a/cli/signals/signals.go b/cli/signals/signals.go new file mode 100644 index 0000000..3425d45 --- /dev/null +++ b/cli/signals/signals.go @@ -0,0 +1,17 @@ +package signals + +import ( + "os" + "os/signal" + "syscall" +) + +// ListenOSInterrupt listens for OS interrupt signals and calls callback +// on receive. It should be called in a separate goroutine from the main +// program as it blocks the execution until a signal is received. +func ListenOSInterrupt(callback func()) { + sigC := make(chan os.Signal, 1) + signal.Notify(sigC, os.Interrupt, syscall.SIGTERM, syscall.SIGINT) + <-sigC + callback() +} diff --git a/cli/testutil/http.go b/cli/testutil/http.go new file mode 100644 index 0000000..34b660b --- /dev/null +++ b/cli/testutil/http.go @@ -0,0 +1,15 @@ +package testutil + +import ( + "bytes" + "net/http" +) + +func MustMakeRequest(method, uri string, header http.Header, body []byte) *http.Request { + req, err := http.NewRequest(method, uri, bytes.NewReader(body)) + if err != nil { + panic("testutil.MustMakeRequest: " + err.Error()) + } + req.Header = header + return req +} diff --git a/cmd/benchttp/main.go b/cmd/benchttp/main.go new file mode 100644 index 0000000..82e27c7 --- /dev/null +++ b/cmd/benchttp/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" +) + +// errUsage reports an incorrect usage of the benchttp command. +var errUsage = errors.New("usage") + +func main() { + if err := run(); err != nil { + fmt.Println(err) + if errors.Is(err, errUsage) { + flag.Usage() + } + os.Exit(1) + } +} + +func run() error { + commandName, options, err := shiftArgs(os.Args[1:]) + if err != nil { + return err + } + + cmd, err := commandOf(commandName) + if err != nil { + return err + } + + return cmd.execute(options) +} + +func shiftArgs(args []string) (commandName string, nextArgs []string, err error) { + if len(args) < 1 { + return "", []string{}, fmt.Errorf("%w: no command specified", errUsage) + } + return args[0], args[1:], nil +} + +// command is the interface that all benchttp subcommands must implement. +type command interface { + execute(args []string) error +} + +func commandOf(name string) (command, error) { + switch name { + case "run": + return &cmdRun{flagset: flag.NewFlagSet("run", flag.ExitOnError)}, nil + case "version": + return &cmdVersion{}, nil + default: + return nil, fmt.Errorf("%w: unknown command: %s", errUsage, name) + } +} diff --git a/cmd/benchttp/run.go b/cmd/benchttp/run.go new file mode 100644 index 0000000..ce84726 --- /dev/null +++ b/cmd/benchttp/run.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + + "github.com/benchttp/engine/benchttp" + "github.com/benchttp/engine/configio" + + "github.com/benchttp/engine/cli/configflag" + "github.com/benchttp/engine/cli/output" + "github.com/benchttp/engine/cli/render" + "github.com/benchttp/engine/cli/signals" +) + +// cmdRun handles subcommand "benchttp run [options]". +type cmdRun struct { + flagset *flag.FlagSet + + configFile string // parsed value for flag -configFile + silent bool // parsed value for flag -silent + + builder configio.Builder +} + +// execute runs the benchttp runner: it parses CLI flags, loads config +// from config file and parsed flags, then runs the benchmark and outputs +// it according to the config. +func (cmd *cmdRun) execute(args []string) error { + if err := cmd.parseArgs(args); err != nil { + return err + } + + config, err := buildConfig(cmd.builder, cmd.configFile) + if err != nil { + return err + } + + report, err := runBenchmark(config, cmd.silent) + if err != nil { + return err + } + + return renderReport(os.Stdout, report, cmd.silent) +} + +func (cmd *cmdRun) parseArgs(args []string) error { + cmd.flagset.StringVar(&cmd.configFile, "configFile", configio.FindFile(), "Config file path") + cmd.flagset.BoolVar(&cmd.silent, "silent", false, "Silent mode") + configflag.Bind(cmd.flagset, &cmd.builder) + return cmd.flagset.Parse(args) +} + +func buildConfig( + b configio.Builder, + filePath string, +) (benchttp.Runner, error) { + // use default runner as a base + runner := benchttp.DefaultRunner() + + // override with config file values + err := configio.UnmarshalFile(filePath, &runner) + if err != nil && !errors.Is(err, configio.ErrFileNotFound) { + // config file is not mandatory: discard ErrFileNotFound. + // other errors are critical + return runner, err + } + + // override with CLI flags values + b.Mutate(&runner) + + return runner, nil +} + +func runBenchmark(runner benchttp.Runner, silent bool) (*benchttp.Report, error) { + // Prepare graceful shutdown in case of os.Interrupt (Ctrl+C) + ctx, cancel := context.WithCancel(context.Background()) + go signals.ListenOSInterrupt(cancel) + + // Stream progress to stdout + runner.OnProgress = onRecordingProgress(silent) + + // Run the benchmark + report, err := runner.Run(ctx) + if err != nil { + return report, err + } + + return report, nil +} + +func onRecordingProgress(silent bool) func(benchttp.RecordingProgress) { + if silent { + return func(benchttp.RecordingProgress) {} + } + + // hack: write a blank line as render.Progress always + // erases the previous line + fmt.Println() + + return func(progress benchttp.RecordingProgress) { + render.Progress(os.Stdout, progress) //nolint: errcheck + } +} + +func renderReport(w io.Writer, report *benchttp.Report, silent bool) error { + writeIfNotSilent := output.ConditionalWriter{Writer: w}.If(!silent) + + if _, err := render.ReportSummary(writeIfNotSilent, report); err != nil { + return err + } + + if _, err := render.TestSuite( + writeIfNotSilent.ElseIf(!report.Tests.Pass), + report.Tests, + ); err != nil { + return err + } + + if !report.Tests.Pass { + return errors.New("test suite failed") + } + + return nil +} diff --git a/cmd/benchttp/version.go b/cmd/benchttp/version.go new file mode 100644 index 0000000..94d3527 --- /dev/null +++ b/cmd/benchttp/version.go @@ -0,0 +1,21 @@ +package main + +import "fmt" + +// benchttpVersion is the current version of benchttp +// as output by `benchttp version`. It is assumed to be set +// by `go build -ldflags "-X main.benchttpVersion="`, +// allowing us to set the value dynamically at build time +// using latest git tag. +// +// Its default value "development" is only used when the app +// is ran locally without a build (e.g. `go run ./cmd/benchttp`). +var benchttpVersion = "development" + +// cmdVersion handles subcommand "benchttp version". +type cmdVersion struct{} + +func (cmdVersion) execute([]string) error { + fmt.Println("benchttp", benchttpVersion) + return nil +} diff --git a/doc/demo.gif b/doc/demo.gif new file mode 100644 index 0000000..5bc2313 Binary files /dev/null and b/doc/demo.gif differ diff --git a/doc/test-suite.png b/doc/test-suite.png new file mode 100644 index 0000000..1c05793 Binary files /dev/null and b/doc/test-suite.png differ diff --git a/examples/config/test-suite.yml b/examples/config/test-suite.yml new file mode 100644 index 0000000..58bb3cd --- /dev/null +++ b/examples/config/test-suite.yml @@ -0,0 +1,20 @@ +request: + url: https://example.com + +runner: + requests: 10 + concurrency: 2 + +tests: + - name: minimum response time + field: ResponseTimes.Min + predicate: GT + target: 80ms + - name: maximum response time + field: ResponseTimes.Max + predicate: LTE + target: 120ms + - name: 100% availability + field: RequestFailureCount + predicate: EQ + target: 0 diff --git a/script/build b/script/build new file mode 100755 index 0000000..09dac45 --- /dev/null +++ b/script/build @@ -0,0 +1,35 @@ +#!/bin/bash + +platforms="darwin/amd64 darwin/arm64 linux/386 linux/amd64 windows/386 windows/amd64" + +version=$(git describe --tags --abbrev=0) +ldflags="-X main.benchttpVersion=$version" +tags="prod" + +cmddir="./cmd/benchttp" +bindir="./bin" + +# clear bin directory +rm -rf ./bin/* + +i=0 +for platform in ${platforms}; do + ((i++)) + + split=(${platform//// }) # split platform by sep "/" + goos="${split[0]}" + goarch="${split[1]}" + output="benchttp_${goos}_${goarch}" # e.g. benchttp_darwin_amd64 + + # add .exe to windows binaries + [[ "$goos" == "windows" ]] && output="$output.exe" + + output="$bindir/$output" + + # build binary + GOOS="$goos" GOARCH="$goarch" go build -tags "$tags" -ldflags "$ldflags" -o "$output" "$cmddir" + + echo "[$i/6] $output" +done + +echo -e "\033[1;32m✔︎\033[0m Build complete!" diff --git a/script/build-healthcheck b/script/build-healthcheck new file mode 100755 index 0000000..4f9b2e9 --- /dev/null +++ b/script/build-healthcheck @@ -0,0 +1,17 @@ +#!/bin/bash + +goos=$(go env GOOS) +goarch=$(go env GOARCH) +benchttp="./bin/benchttp_${goos}_${goarch}" + +expVersion="benchttp $(git describe --tags --abbrev=0)" +gotVersion=$(eval $benchttp version) + +if [[ "$gotVersion" != "$expVersion" ]]; then + echo -e "\033[1;31m✘\033[0m Error running ./bin/benchttp version" + echo " exp $expVersion" + echo " got $gotVersion" + exit 1 +fi + +echo -e "\033[1;32m✔︎\033[0m Build integrity OK!"