diff --git a/README.md b/README.md index 71f2379..14fa74b 100644 --- a/README.md +++ b/README.md @@ -407,10 +407,21 @@ way. Reading those bytes as plain text would be its own silent corruption. | `--verbose` | `-v` | Enable verbose logging (overrides `--log-level`) | | `--silent` | `-s` | Only log errors (overrides `-v`) | | `--log-level` | | Set log level (debug, info, warn, error) | +| `--color` | | Colour the verdict: `auto` (default), `always`, `never` | **Everything the tool prints goes to stderr; stdout is always empty.** Use `2>&1` when capturing output in a file or a pipe. +`auto` colours only when stderr is a terminal, so a pipe or a CI log stays +plain without being asked for. The verdict is green or red and the `[.]` `[:]` +`[>]` trace lines are dimmed. `[~]` is yellow — a retry is the one line that +reports trouble without being the verdict, and a check that passed on the +fourth attempt is not the same news as one that passed on the first. The +failure list itself stays plain so it can be copied out of a terminal +unchanged. `NO_COLOR` is honoured — any non-empty +value turns `auto` off — and `--color=always` overrides it, on the grounds that +the variable says what to do absent an instruction and the flag is one. + `warn` is accepted but currently logs exactly what `error` does; nothing in the tool logs at the warn level. diff --git a/color_test.go b/color_test.go new file mode 100644 index 0000000..ce8623d --- /dev/null +++ b/color_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "strings" + "testing" +) + +// Test_shouldColor is the whole decision, which is why it takes its three +// inputs as arguments rather than reading a terminal and an environment. +func Test_shouldColor(t *testing.T) { + t.Parallel() + + tests := []struct { + Name string + Mode string + NoColor string + IsTTY bool + Want bool + WantErr bool + }{ + {Name: "auto on a terminal", Mode: "auto", IsTTY: true, Want: true}, + {Name: "auto in a pipe", Mode: "auto", IsTTY: false, Want: false}, + // The case the default exists for: a CI log is not a terminal, so it + // stays plain without anyone having to ask. + {Name: "auto with NO_COLOR on a terminal", Mode: "auto", NoColor: "1", IsTTY: true, Want: false}, + {Name: "auto with NO_COLOR set to anything", Mode: "auto", NoColor: "0", IsTTY: true, Want: false}, + // NO_COLOR's own wording: empty counts as unset. + {Name: "auto with an empty NO_COLOR", Mode: "auto", NoColor: "", IsTTY: true, Want: true}, + + {Name: "always in a pipe", Mode: "always", IsTTY: false, Want: true}, + // A variable says what to do absent an instruction; the flag is one. + {Name: "always beats NO_COLOR", Mode: "always", NoColor: "1", IsTTY: false, Want: true}, + + {Name: "never on a terminal", Mode: "never", IsTTY: true, Want: false}, + {Name: "never with NO_COLOR unset", Mode: "never", IsTTY: true, Want: false}, + + {Name: "an unknown mode is rejected", Mode: "purple", WantErr: true}, + {Name: "an empty mode is rejected", Mode: "", WantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.Name, func(t *testing.T) { + got, err := shouldColor(tc.Mode, tc.NoColor, tc.IsTTY) + if tc.WantErr { + if err == nil { + t.Fatalf("expected an error for %q, got nil", tc.Mode) + } + if !strings.Contains(err.Error(), "auto, always, never") { + t.Errorf("error = %q, want it to list the values", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got != tc.Want { + t.Errorf("shouldColor(%q, %q, %v) = %v, want %v", + tc.Mode, tc.NoColor, tc.IsTTY, got, tc.Want) + } + }) + } +} + +// Test_paletteLine pins which lines are coloured and how. The sigil decides, +// so a new sigil that nobody adds here is left plain rather than mis-coloured. +func Test_paletteLine(t *testing.T) { + t.Parallel() + + on := palette{on: true} + + tests := []struct { + Name string + In string + Want string + }{ + {"the passing verdict is green", "[+] PASSED 1ms\n", ansiGreen + "[+] PASSED 1ms" + ansiReset + "\n"}, + {"the failing verdict is red", "[-] FAILED 1ms\n", ansiRed + "[-] FAILED 1ms" + ansiReset + "\n"}, + {"the request line is dimmed", "[.] GET /\n", ansiDim + "[.] GET /" + ansiReset + "\n"}, + {"the response line is dimmed", "[:] 200 OK\n", ansiDim + "[:] 200 OK" + ansiReset + "\n"}, + {"the redirect line is dimmed", "[>] /next\n", ansiDim + "[>] /next" + ansiReset + "\n"}, + {"the retry line is yellow", "[~] retry 1/3 in 1s\n", ansiYellow + "[~] retry 1/3 in 1s" + ansiReset + "\n"}, + {"an unsigilled line is left alone", "plain text\n", "plain text\n"}, + // The reset belongs before the blank line, or a terminal paints it. + {"trailing newlines stay outside the sequence", "[+] PASSED\n\n", ansiGreen + "[+] PASSED" + ansiReset + "\n\n"}, + } + + for _, tc := range tests { + t.Run(tc.Name, func(t *testing.T) { + if got := on.line(tc.In); got != tc.Want { + t.Errorf("line(%q) = %q, want %q", tc.In, got, tc.Want) + } + }) + } + + // The zero value is the one every path before flag parsing uses. + t.Run("the zero palette writes no colour", func(t *testing.T) { + var off palette + for _, in := range []string{"[+] PASSED\n", "[-] FAILED\n", "[.] GET /\n"} { + if got := off.line(in); got != in { + t.Errorf("line(%q) = %q, want it unchanged", in, got) + } + } + if got := off.wrap(ansiRed, "Error:"); got != "Error:" { + t.Errorf("wrap = %q, want it unchanged", got) + } + }) +} diff --git a/e2e_color_test.go b/e2e_color_test.go new file mode 100644 index 0000000..4d0dc36 --- /dev/null +++ b/e2e_color_test.go @@ -0,0 +1,98 @@ +package main_test + +import ( + "strings" + "testing" +) + +const esc = "\033[" + +// TestE2EColor covers what a caller can observe about colour (#98). +// +// The end-to-end suite captures output through a pipe, which is exactly the +// condition --color=auto exists to detect -- so the default staying plain here +// is the CI-log guarantee being tested, not an accident of the harness. +func TestE2EColor(t *testing.T) { + t.Run("the default is plain when output is not a terminal", func(t *testing.T) { + for _, u := range []string{url("/ok"), url("/500")} { + r := run(t, nil, "--assert-ok", u) + if strings.Contains(r.Output(), esc) { + t.Errorf("piped output carries ANSI: %q", r.Output()) + } + } + }) + + t.Run("--color=never is plain", func(t *testing.T) { + r := run(t, nil, "--color=never", "--assert-ok", url("/500")) + assertExit(t, r, exitAssertFail) + if strings.Contains(r.Output(), esc) { + t.Errorf("--color=never carries ANSI: %q", r.Output()) + } + }) + + t.Run("--color=always colours the verdict even in a pipe", func(t *testing.T) { + pass := run(t, nil, "--color=always", "--assert-ok", url("/ok")) + assertExit(t, pass, exitOK) + assertContains(t, pass, "\033[32m[+] PASSED") + + fail := run(t, nil, "--color=always", "--assert-ok", url("/500")) + assertExit(t, fail, exitAssertFail) + assertContains(t, fail, "\033[31m[-] FAILED") + assertContains(t, fail, "\033[31mError:\033[0m") + }) + + t.Run("--color=always dims the trace lines", func(t *testing.T) { + r := run(t, nil, "--color=always", "--assert-ok", url("/ok")) + assertContains(t, r, "\033[2m[.] ") + assertContains(t, r, "\033[2m[:] ") + }) + + // The failure list is copied out of terminals; escapes in it would travel. + t.Run("the assertion lines stay plain even with colour on", func(t *testing.T) { + r := run(t, nil, "--color=always", "--assert-status", "200", url("/500")) + assertExit(t, r, exitAssertFail) + assertContains(t, r, "\n- status: expected 200, got 500") + }) + + // NO_COLOR only changes the answer when stderr is a terminal, and this + // harness pipes -- so "auto plus NO_COLOR is plain" would pass here even + // if NO_COLOR were ignored entirely. That branch is covered by + // Test_shouldColor, which takes the terminal as an argument instead. + // + // This one is observable: if NO_COLOR wrongly won, the output would be + // plain. A variable says what to do absent an instruction; the flag is one. + t.Run("--color=always overrides NO_COLOR", func(t *testing.T) { + r := run(t, map[string]string{"NO_COLOR": "1"}, "--color=always", "--assert-ok", url("/ok")) + assertContains(t, r, "\033[32m[+] PASSED") + }) + + // A retry is the one trace line reporting trouble that is not the verdict, + // so it is neither dimmed with the rest of the trace nor red like a + // failure: a run that passed on the fourth attempt is not the same news as + // one that passed on the first. + t.Run("--color=always makes the retry line yellow", func(t *testing.T) { + r := run(t, nil, "--color=always", "--retry", "2", "--retry-delay", "10ms", + "--assert-ok", url("/500")) + assertExit(t, r, exitAssertFail) + assertContains(t, r, "\033[33m[~] retry 1/2") + assertNotContains(t, r, "\033[2m[~]") + + // The verdict it leads to is still red, and still distinguishable. + assertContains(t, r, "\033[31m[-] FAILED") + }) + + t.Run("a retry that recovers still colours the retry yellow", func(t *testing.T) { + r := run(t, nil, "--color=always", "--retry", "3", "--retry-delay", "10ms", + "--assert-ok", flaky(t, "/flaky", 1)) + assertExit(t, r, exitOK) + assertContains(t, r, "\033[33m[~] retry 1/3") + assertContains(t, r, "\033[32m[+] PASSED") + }) + + t.Run("an unknown value is rejected", func(t *testing.T) { + r := run(t, nil, "--color=purple", "--assert-ok", url("/ok")) + assertExit(t, r, exitBadInvocation) + assertContains(t, r, "Invalid value for --color flag") + assertContains(t, r, "auto, always, never") + }) +} diff --git a/main.go b/main.go index f12cda3..304f508 100644 --- a/main.go +++ b/main.go @@ -203,7 +203,21 @@ Compression: Nothing is advertised in Accept-Encoding unless -H says so, and the response headers are reported exactly as they arrived -- so a body can be asserted on - and its Content-Encoding at the same time.`, + and its Content-Encoding at the same time. + +Colour: + --color decides whether the sigil lines and Error: carry ANSI colour. auto, + the default, colours only when stderr is a terminal, so a pipe or a CI log + stays plain without being asked. always and never say so outright. + + NO_COLOR is honoured: any non-empty value turns auto off. --color=always + still wins over it, on the grounds that a variable says what to do in the + absence of an instruction and the flag is one. + + The verdict is green or red and the [.] [:] [>] trace lines are dimmed. [~] is + yellow: a retry is the one line that reports trouble without being the + verdict. Nothing else is coloured -- the failure list stays plain so it can be + copied out of a terminal unchanged.`, Example: ` # A health check: any non-error status passes http-assert --assert-ok https://example.com/health @@ -227,6 +241,11 @@ Compression: SilenceErrors: true, SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { + // Resolved first, so every message after this point -- including + // the ones dief writes about other flags -- is coloured the way + // the caller asked. + mustSetPalette(cmd) + insecure, _ := cmd.Flags().GetBool("insecure") maxTime, _ := cmd.Flags().GetInt("max-time") maphost, _ := cmd.Flags().GetStringArray("maphost") @@ -237,6 +256,7 @@ Compression: retryMaxTime, _ := cmd.Flags().GetDuration("retry-max-time") c := Client{ LogLevel: mustParseLogLevel(cmd), + Palette: errPalette, SkipSslChecks: insecure, Timeout: time.Duration(maxTime) * time.Second, HostMappings: mustParseHostMappings(maphost), @@ -310,6 +330,8 @@ Compression: "Be silent; log error messages only (same as --log-level error; overrides -v)") cmd.PersistentFlags().String("log-level", "", "Set log level; possible values: debug, info (default), warn, error") + cmd.PersistentFlags().String("color", "auto", + "Colour the verdict; possible values: auto (default), always, never") cmd.PersistentFlags().BoolP("insecure", "k", false, "Disable checking SSL certificates") cmd.PersistentFlags().IntP("max-time", "m", 20, "Maximum time in seconds that you allow each request to take") @@ -484,11 +506,124 @@ type exitError struct { func (e *exitError) Error() string { return e.msg } // dief formats a message to stderr and terminates the process with rc. +// ANSI colours, kept to the three the verdict needs. A 16-colour palette works +// on everything that renders escapes at all, so there is no capability to +// probe beyond "is anyone watching". +const ( + ansiReset = "\033[0m" + ansiRed = "\033[31m" + ansiGreen = "\033[32m" + ansiYellow = "\033[33m" + ansiDim = "\033[2m" +) + +// palette decides whether a line is written with colour. The zero value writes +// none, so any path that runs before the flags are parsed stays plain. +type palette struct{ on bool } + +// errPalette colours the one line dief writes. It is a package variable +// because dief is reachable from flag parsing, before there is a Client to +// hang it on; until the flags resolve it is the zero value, so an error raised +// on the way there is plain rather than half-coloured. +var errPalette palette + +func (p palette) wrap(code, s string) string { + if !p.on || s == "" { + return s + } + + return code + s + ansiReset +} + +// line colours a log line by its sigil. +// +// The sigil vocabulary already says what each line is; colour only makes the +// distinction survive a scroll through a CI log, which is the whole complaint +// (#98). The verdict is green or red and the trace lines are dimmed, so the +// verdict is what the eye lands on. +// +// [~] is the exception: a retry is the one trace line that reports something +// went wrong without being the verdict, and a run that passed on the fourth +// attempt is not the same news as one that passed on the first. Yellow says +// that without claiming the run failed. +func (p palette) line(s string) string { + if !p.on { + return s + } + + // The trailing newlines are outside the sequence: a reset after a blank + // line leaves the colour spanning it, which some terminals paint. + body := strings.TrimRight(s, "\n") + tail := s[len(body):] + + switch { + case strings.HasPrefix(body, "[+]"): + body = p.wrap(ansiGreen, body) + case strings.HasPrefix(body, "[-]"): + body = p.wrap(ansiRed, body) + case strings.HasPrefix(body, "[~]"): + body = p.wrap(ansiYellow, body) + case strings.HasPrefix(body, "[.]"), strings.HasPrefix(body, "[:]"), + strings.HasPrefix(body, "[>]"): + body = p.wrap(ansiDim, body) + } + + return body + tail +} + +// isTerminal reports whether anything is watching f. +// +// A character device is the stdlib's answer to the question, and it is the +// whole of the platform handling here: a terminal that cannot render escapes +// is rarer than the dependency needed to detect one, and --color=never and +// NO_COLOR both exist for it. +func isTerminal(f *os.File) bool { + st, err := f.Stat() + + return err == nil && st.Mode()&os.ModeCharDevice != 0 +} + +// shouldColor resolves --color against NO_COLOR and the terminal. +// +// Taking all three as arguments keeps the decision testable without a terminal +// or a mutated environment, which is what made it worth separating from the +// wiring at all. +// +// --color=always wins over NO_COLOR: the variable says what to do absent an +// instruction, and the flag is an instruction. NO_COLOR beats a bare terminal, +// which is the case it exists for. +func shouldColor(mode, noColor string, isTTY bool) (bool, error) { + switch mode { + case "never": + return false, nil + case "always": + return true, nil + case "auto": + return noColor == "" && isTTY, nil + } + + return false, fmt.Errorf("possible values: auto, always, never") +} + +// mustSetPalette resolves --color once and hands the answer to both writers. +// +// stderr is the subject because that is where every line this colours goes; +// asking about stdout would answer a question nobody is writing to. +func mustSetPalette(cmd *cobra.Command) { + mode, _ := cmd.Flags().GetString("color") + on, err := shouldColor(mode, os.Getenv("NO_COLOR"), isTerminal(os.Stderr)) + if err != nil { + dief(exitBadInvocation, "Invalid value for --color flag: %q: %s", mode, err) + } + + errPalette = palette{on: on} +} + func dief(rc int, format string, args ...interface{}) { if !strings.HasSuffix(format, "\n") { format += "\n" } - fmt.Fprintf(os.Stderr, "\nError: "+format, args...) + fmt.Fprintf(os.Stderr, "\n%s "+format, append([]interface{}{errPalette.wrap(ansiRed, "Error:")}, args...)...) os.Exit(rc) } @@ -847,7 +982,9 @@ func parseHeaderAssertions(vs []string, exactMatch bool) []Assertion { } type Client struct { - LogLevel LogLevel + LogLevel LogLevel + // Palette colours the sigil lines. The zero value writes none. + Palette palette SkipSslChecks bool Timeout time.Duration HostMappings []hostMapping @@ -1187,7 +1324,7 @@ func (c Client) log(l LogLevel, format string, args ...interface{}) { if !strings.HasSuffix(format, "\n") { format += "\n" } - fmt.Fprintf(os.Stderr, format, args...) + fmt.Fprint(os.Stderr, c.Palette.line(fmt.Sprintf(format, args...))) } type httpResponse struct {