diff --git a/internal/tty/tty.go b/internal/tty/tty.go index 9e0d9281..51f4f804 100644 --- a/internal/tty/tty.go +++ b/internal/tty/tty.go @@ -13,3 +13,26 @@ import ( func IsStderrTerminal() bool { return term.IsTerminal(int(os.Stderr.Fd())) } + +// IsStdoutTerminal returns true if stdout is connected to a terminal. +func IsStdoutTerminal() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + +// StderrTerminalWidth returns the width of the terminal connected to stderr +// and true if successful. Returns 0 and false if stderr is not a terminal or +// the width cannot be determined. +func StderrTerminalWidth() (int, bool) { + width, _, err := term.GetSize(int(os.Stderr.Fd())) + if err != nil || width <= 0 { + return 0, false + } + return width, true +} + +// IsInteractiveTerminal returns true if the process is running in an +// interactive terminal context: stderr is a terminal and the process is not +// running inside a container. +func IsInteractiveTerminal() bool { + return IsStderrTerminal() && !IsRunningInContainer() +} diff --git a/internal/tty/tty_test.go b/internal/tty/tty_test.go index 3066807b..aa7bc783 100644 --- a/internal/tty/tty_test.go +++ b/internal/tty/tty_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "golang.org/x/term" ) @@ -33,3 +34,65 @@ func TestIsStderrTerminal_NotATerminalInCI(t *testing.T) { } assert.False(t, IsStderrTerminal(), "stderr should not be a terminal in CI") } + +// TestIsStdoutTerminal verifies the function agrees with the underlying +// term.IsTerminal check for os.Stdout. +func TestIsStdoutTerminal(t *testing.T) { + expected := term.IsTerminal(int(os.Stdout.Fd())) + result := IsStdoutTerminal() + assert.Equal(t, expected, result, "IsStdoutTerminal should match term.IsTerminal(stdout)") +} + +// TestTermIsTerminal_PipeIsNotTerminal verifies that the underlying +// term.IsTerminal correctly identifies a pipe as not a terminal. This +// documents the invariant that IsStdoutTerminal and IsStderrTerminal rely on. +func TestTermIsTerminal_PipeIsNotTerminal(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + defer w.Close() + assert.False(t, term.IsTerminal(int(r.Fd())), "pipe file descriptor should not be a terminal") + assert.False(t, term.IsTerminal(int(w.Fd())), "pipe write-end should not be a terminal") +} + +// TestStderrTerminalWidth verifies that StderrTerminalWidth returns consistent +// results and only reports success when stderr is a terminal. +func TestStderrTerminalWidth(t *testing.T) { + width, ok := StderrTerminalWidth() + isTerminal := term.IsTerminal(int(os.Stderr.Fd())) + if isTerminal { + assert.True(t, ok, "should succeed when stderr is a terminal") + assert.Greater(t, width, 0, "terminal width should be positive") + } else { + assert.False(t, ok, "should fail when stderr is not a terminal") + assert.Equal(t, 0, width, "width should be 0 when not a terminal") + } +} + +// TestStderrTerminalWidth_NotATerminalInCI verifies that width detection +// returns false in CI where stderr is not a terminal. +func TestStderrTerminalWidth_NotATerminalInCI(t *testing.T) { + if os.Getenv("CI") == "" && os.Getenv("GITHUB_ACTIONS") == "" { + t.Skip("Skipping CI-specific assertion: not running in a CI environment") + } + width, ok := StderrTerminalWidth() + assert.False(t, ok, "should not detect terminal width in CI") + assert.Equal(t, 0, width, "width should be 0 in CI") +} + +// TestIsInteractiveTerminal verifies that IsInteractiveTerminal returns true +// only when both stderr is a terminal and the process is not in a container. +func TestIsInteractiveTerminal(t *testing.T) { + expected := IsStderrTerminal() && !IsRunningInContainer() + assert.Equal(t, expected, IsInteractiveTerminal(), + "IsInteractiveTerminal should be IsStderrTerminal() && !IsRunningInContainer()") +} + +// TestIsInteractiveTerminal_NotInteractiveInCI verifies the expected false +// result in CI where stderr is a pipe, not a terminal. +func TestIsInteractiveTerminal_NotInteractiveInCI(t *testing.T) { + if os.Getenv("CI") == "" && os.Getenv("GITHUB_ACTIONS") == "" { + t.Skip("Skipping CI-specific assertion: not running in a CI environment") + } + assert.False(t, IsInteractiveTerminal(), "should not be interactive in CI") +}