| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "os" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // pipeFile is a real *os.File that is NOT a terminal — the shape every CI |
| 11 | // run, every `| less` and every e2e test's exec'd binary sees. |
| 12 | func pipeFile(t *testing.T) *os.File { |
| 13 | t.Helper() |
| 14 | r, w, err := os.Pipe() |
| 15 | if err != nil { |
| 16 | t.Fatal(err) |
| 17 | } |
| 18 | t.Cleanup(func() { r.Close(); w.Close() }) |
| 19 | return w |
| 20 | } |
| 21 | |
| 22 | func TestColorEnabledPrecedence(t *testing.T) { |
| 23 | f := pipeFile(t) |
| 24 | cases := []struct { |
| 25 | name string |
| 26 | env map[string]string |
| 27 | want bool |
| 28 | }{ |
| 29 | {"pipe with a terminal set", map[string]string{"TERM": "xterm-256color"}, false}, |
| 30 | {"CARLOS_COLOR=always beats the pipe", map[string]string{"TERM": "xterm", "CARLOS_COLOR": "always"}, true}, |
| 31 | {"CARLOS_COLOR=always beats NO_COLOR", map[string]string{"TERM": "xterm", "CARLOS_COLOR": "always", "NO_COLOR": "1"}, true}, |
| 32 | {"CARLOS_COLOR=never", map[string]string{"TERM": "xterm", "CARLOS_COLOR": "never"}, false}, |
| 33 | {"NO_COLOR", map[string]string{"TERM": "xterm", "NO_COLOR": "1"}, false}, |
| 34 | {"TERM=dumb", map[string]string{"TERM": "dumb", "CARLOS_COLOR": ""}, false}, |
| 35 | } |
| 36 | for _, tc := range cases { |
| 37 | t.Run(tc.name, func(t *testing.T) { |
| 38 | for _, k := range []string{"TERM", "NO_COLOR", "CARLOS_COLOR"} { |
| 39 | t.Setenv(k, tc.env[k]) |
| 40 | } |
| 41 | if got := colorEnabled(f); got != tc.want { |
| 42 | t.Fatalf("colorEnabled = %v, want %v", got, tc.want) |
| 43 | } |
| 44 | }) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | // A buffer is what the CLI's print seam holds under test. It must never be |
| 49 | // coloured, whatever the environment says — including CARLOS_COLOR=always, |
| 50 | // because an operator with that set in their shell would otherwise see every |
| 51 | // in-process test fail on escape codes. |
| 52 | func TestColorForNonFileIsAlwaysPlain(t *testing.T) { |
| 53 | t.Setenv("CARLOS_COLOR", "always") |
| 54 | t.Setenv("TERM", "xterm-256color") |
| 55 | var buf bytes.Buffer |
| 56 | if c := colorFor(&buf); c.on { |
| 57 | t.Fatal("a bytes.Buffer was given a colour palette") |
| 58 | } |
| 59 | mutedf(&buf, "using %s as %s", "https://console.example", "vicky@keymail.dev") |
| 60 | if got, want := buf.String(), "using https://console.example as vicky@keymail.dev\n"; got != want { |
| 61 | t.Fatalf("mutedf wrote %q, want %q", got, want) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | func TestPaletteWrapsOnlyWhenOn(t *testing.T) { |
| 66 | on := colors{on: true} |
| 67 | if got, want := on.ok("shipped"), ansiGreen+"shipped"+ansiReset; got != want { |
| 68 | t.Fatalf("ok = %q, want %q", got, want) |
| 69 | } |
| 70 | if got := on.muted(""); got != "" { |
| 71 | t.Fatalf("an empty string got wrapped: %q", got) |
| 72 | } |
| 73 | off := colors{} |
| 74 | for name, got := range map[string]string{ |
| 75 | "muted": off.muted("x"), "ok": off.ok("x"), "warn": off.warn("x"), |
| 76 | "bad": off.bad("x"), "head": off.head("x"), "link": off.link("x"), |
| 77 | } { |
| 78 | if got != "x" { |
| 79 | t.Fatalf("%s with colour off = %q, want %q", name, got, "x") |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | // The point of table's buffering: escape codes on the header must not be |
| 85 | // counted as column content. If they were, every row below would be padded |
| 86 | // to the header's inflated width. |
| 87 | func TestTableHeaderStylingDoesNotMoveColumns(t *testing.T) { |
| 88 | rows := func(w *table) { |
| 89 | w.Write([]byte("CHANNEL\tVERSION\tPROMOTED\n")) |
| 90 | w.Write([]byte("canary/keymail\t1.2.3\t2026-08-07\n")) |
| 91 | w.Write([]byte("stable\t1.0.0\t2026-08-01\n")) |
| 92 | } |
| 93 | |
| 94 | var plain bytes.Buffer |
| 95 | pt := newTable(&plain) |
| 96 | rows(pt) |
| 97 | if err := pt.Flush(); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | |
| 101 | var styled bytes.Buffer |
| 102 | st := newTable(&styled) |
| 103 | st.pal = colors{on: true} // a buffer never gets colour on its own |
| 104 | rows(st) |
| 105 | if err := st.Flush(); err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | |
| 109 | got := styled.String() |
| 110 | if !strings.HasPrefix(got, ansiBold+"CHANNEL") { |
| 111 | t.Fatalf("header not styled: %q", got[:40]) |
| 112 | } |
| 113 | // Strip the codes back out and the two renderings must be identical: |
| 114 | // the columns did not move. |
| 115 | stripped := strings.NewReplacer(ansiBold, "", ansiReset, "").Replace(got) |
| 116 | if stripped != plain.String() { |
| 117 | t.Fatalf("styling changed the layout:\n plain: %q\nstyled: %q", plain.String(), stripped) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // Colour off, the banner is byte-for-byte the line the API-mode tests pin. |
| 122 | func TestPrintUsingIsUnchangedWithoutColor(t *testing.T) { |
| 123 | var buf bytes.Buffer |
| 124 | printUsing(&buf, &apiSession{console: "https://console.example", address: "vicky@keymail.dev"}) |
| 125 | if got, want := buf.String(), "using https://console.example as vicky@keymail.dev\n"; got != want { |
| 126 | t.Fatalf("printUsing wrote %q, want %q", got, want) |
| 127 | } |
| 128 | } |
| 129 | |