Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
107 changes: 107 additions & 0 deletions color_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
98 changes: 98 additions & 0 deletions e2e_color_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
Loading
Loading