package main import ( "bytes" "os" "strings" "testing" ) // pipeFile is a real *os.File that is NOT a terminal — the shape every CI // run, every `| less` and every e2e test's exec'd binary sees. func pipeFile(t *testing.T) *os.File { t.Helper() r, w, err := os.Pipe() if err != nil { t.Fatal(err) } t.Cleanup(func() { r.Close(); w.Close() }) return w } func TestColorEnabledPrecedence(t *testing.T) { f := pipeFile(t) cases := []struct { name string env map[string]string want bool }{ {"pipe with a terminal set", map[string]string{"TERM": "xterm-256color"}, false}, {"CARLOS_COLOR=always beats the pipe", map[string]string{"TERM": "xterm", "CARLOS_COLOR": "always"}, true}, {"CARLOS_COLOR=always beats NO_COLOR", map[string]string{"TERM": "xterm", "CARLOS_COLOR": "always", "NO_COLOR": "1"}, true}, {"CARLOS_COLOR=never", map[string]string{"TERM": "xterm", "CARLOS_COLOR": "never"}, false}, {"NO_COLOR", map[string]string{"TERM": "xterm", "NO_COLOR": "1"}, false}, {"TERM=dumb", map[string]string{"TERM": "dumb", "CARLOS_COLOR": ""}, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { for _, k := range []string{"TERM", "NO_COLOR", "CARLOS_COLOR"} { t.Setenv(k, tc.env[k]) } if got := colorEnabled(f); got != tc.want { t.Fatalf("colorEnabled = %v, want %v", got, tc.want) } }) } } // A buffer is what the CLI's print seam holds under test. It must never be // coloured, whatever the environment says — including CARLOS_COLOR=always, // because an operator with that set in their shell would otherwise see every // in-process test fail on escape codes. func TestColorForNonFileIsAlwaysPlain(t *testing.T) { t.Setenv("CARLOS_COLOR", "always") t.Setenv("TERM", "xterm-256color") var buf bytes.Buffer if c := colorFor(&buf); c.on { t.Fatal("a bytes.Buffer was given a colour palette") } mutedf(&buf, "using %s as %s", "https://console.example", "vicky@keymail.dev") if got, want := buf.String(), "using https://console.example as vicky@keymail.dev\n"; got != want { t.Fatalf("mutedf wrote %q, want %q", got, want) } } func TestPaletteWrapsOnlyWhenOn(t *testing.T) { on := colors{on: true} if got, want := on.ok("shipped"), ansiGreen+"shipped"+ansiReset; got != want { t.Fatalf("ok = %q, want %q", got, want) } if got := on.muted(""); got != "" { t.Fatalf("an empty string got wrapped: %q", got) } off := colors{} for name, got := range map[string]string{ "muted": off.muted("x"), "ok": off.ok("x"), "warn": off.warn("x"), "bad": off.bad("x"), "head": off.head("x"), "link": off.link("x"), } { if got != "x" { t.Fatalf("%s with colour off = %q, want %q", name, got, "x") } } } // The point of table's buffering: escape codes on the header must not be // counted as column content. If they were, every row below would be padded // to the header's inflated width. func TestTableHeaderStylingDoesNotMoveColumns(t *testing.T) { rows := func(w *table) { w.Write([]byte("CHANNEL\tVERSION\tPROMOTED\n")) w.Write([]byte("canary/keymail\t1.2.3\t2026-08-07\n")) w.Write([]byte("stable\t1.0.0\t2026-08-01\n")) } var plain bytes.Buffer pt := newTable(&plain) rows(pt) if err := pt.Flush(); err != nil { t.Fatal(err) } var styled bytes.Buffer st := newTable(&styled) st.pal = colors{on: true} // a buffer never gets colour on its own rows(st) if err := st.Flush(); err != nil { t.Fatal(err) } got := styled.String() if !strings.HasPrefix(got, ansiBold+"CHANNEL") { t.Fatalf("header not styled: %q", got[:40]) } // Strip the codes back out and the two renderings must be identical: // the columns did not move. stripped := strings.NewReplacer(ansiBold, "", ansiReset, "").Replace(got) if stripped != plain.String() { t.Fatalf("styling changed the layout:\n plain: %q\nstyled: %q", plain.String(), stripped) } } // Colour off, the banner is byte-for-byte the line the API-mode tests pin. func TestPrintUsingIsUnchangedWithoutColor(t *testing.T) { var buf bytes.Buffer printUsing(&buf, &apiSession{console: "https://console.example", address: "vicky@keymail.dev"}) if got, want := buf.String(), "using https://console.example as vicky@keymail.dev\n"; got != want { t.Fatalf("printUsing wrote %q, want %q", got, want) } }