-
Notifications
You must be signed in to change notification settings - Fork 301
Centralize ANSI escape sequences in console package #14340
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package console | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
|
|
||
| "github.com/github/gh-aw/pkg/tty" | ||
| ) | ||
|
|
||
| // ANSI escape sequences for terminal control | ||
| const ( | ||
| // ansiClearScreen clears the screen and moves cursor to home position | ||
| ansiClearScreen = "\033[H\033[2J" | ||
|
|
||
| // ansiClearLine clears from cursor to end of line | ||
| ansiClearLine = "\033[K" | ||
|
|
||
| // ansiCarriageReturn moves cursor to start of current line | ||
| ansiCarriageReturn = "\r" | ||
| ) | ||
|
|
||
| // MoveCursorUp moves cursor up n lines if stderr is a TTY. | ||
| // Uses ANSI escape code: \033[nA where n is the number of lines. | ||
| func MoveCursorUp(n int) { | ||
| if tty.IsStderrTerminal() { | ||
| fmt.Fprintf(os.Stderr, "\033[%dA", n) | ||
| } | ||
| } | ||
|
|
||
| // MoveCursorDown moves cursor down n lines if stderr is a TTY. | ||
| // Uses ANSI escape code: \033[nB where n is the number of lines. | ||
| func MoveCursorDown(n int) { | ||
| if tty.IsStderrTerminal() { | ||
| fmt.Fprintf(os.Stderr, "\033[%dB", n) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| //go:build !integration | ||
|
|
||
| package console | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "io" | ||
| "os" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| // captureStderr captures stderr output during function execution | ||
| func captureStderr(t *testing.T, fn func()) string { | ||
| t.Helper() | ||
|
|
||
| // Save original stderr | ||
| oldStderr := os.Stderr | ||
|
|
||
| // Create a pipe to capture stderr | ||
| r, w, err := os.Pipe() | ||
| if err != nil { | ||
| t.Fatalf("Failed to create pipe: %v", err) | ||
| } | ||
|
|
||
| // Replace stderr with the write end of the pipe | ||
| os.Stderr = w | ||
|
|
||
| // Create a channel to receive the captured output | ||
| outputChan := make(chan string, 1) | ||
|
|
||
| // Read from the pipe in a goroutine | ||
| go func() { | ||
| var buf bytes.Buffer | ||
| io.Copy(&buf, r) | ||
| outputChan <- buf.String() | ||
| }() | ||
|
|
||
| // Execute the function | ||
| fn() | ||
|
|
||
| // Close the write end and restore stderr | ||
| w.Close() | ||
| os.Stderr = oldStderr | ||
|
|
||
| // Get the captured output | ||
| output := <-outputChan | ||
| r.Close() | ||
|
|
||
|
Comment on lines
+18
to
+50
|
||
| return output | ||
| } | ||
|
|
||
| func TestMoveCursorUp(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| lines int | ||
| }{ | ||
| { | ||
| name: "move up 1 line", | ||
| lines: 1, | ||
| }, | ||
| { | ||
| name: "move up 5 lines", | ||
| lines: 5, | ||
| }, | ||
| { | ||
| name: "move up 0 lines", | ||
| lines: 0, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| output := captureStderr(t, func() { | ||
| MoveCursorUp(tt.lines) | ||
| }) | ||
|
|
||
| // In non-TTY environments, output should be empty | ||
| // We just ensure no panic occurs | ||
| assert.NotNil(t, output, "MoveCursorUp should not panic") | ||
| }) | ||
|
Comment on lines
+73
to
+82
|
||
| } | ||
| } | ||
|
|
||
| func TestMoveCursorDown(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| lines int | ||
| }{ | ||
| { | ||
| name: "move down 1 line", | ||
| lines: 1, | ||
| }, | ||
| { | ||
| name: "move down 5 lines", | ||
| lines: 5, | ||
| }, | ||
| { | ||
| name: "move down 0 lines", | ||
| lines: 0, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| output := captureStderr(t, func() { | ||
| MoveCursorDown(tt.lines) | ||
| }) | ||
|
|
||
| // In non-TTY environments, output should be empty | ||
| // We just ensure no panic occurs | ||
| assert.NotNil(t, output, "MoveCursorDown should not panic") | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestTerminalCursorFunctionsNoTTY(t *testing.T) { | ||
| // This test verifies that in non-TTY environments (like CI/tests), | ||
| // no ANSI codes are emitted for cursor movement functions | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| fn func() | ||
| }{ | ||
| { | ||
| name: "MoveCursorUp", | ||
| fn: func() { MoveCursorUp(5) }, | ||
| }, | ||
| { | ||
| name: "MoveCursorDown", | ||
| fn: func() { MoveCursorDown(3) }, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| output := captureStderr(t, tt.fn) | ||
|
|
||
| // Since tests typically run in non-TTY, verify output is empty | ||
| // This ensures we properly respect TTY detection | ||
| if os.Getenv("CI") != "" || !isRealTerminal() { | ||
| assert.Empty(t, output, "%s should not output ANSI codes in non-TTY", tt.name) | ||
| } | ||
| }) | ||
|
Comment on lines
+136
to
+145
|
||
| } | ||
| } | ||
|
|
||
| // isRealTerminal checks if we're actually running in a terminal | ||
| // This is a helper to distinguish between test environments and real terminals | ||
| func isRealTerminal() bool { | ||
| // In test environments, stderr is typically redirected | ||
| fileInfo, err := os.Stderr.Stat() | ||
| if err != nil { | ||
| return false | ||
| } | ||
| // Check if stderr is a character device (terminal) | ||
| return (fileInfo.Mode() & os.ModeCharDevice) != 0 | ||
| } | ||
|
|
||
| func TestTerminalCursorFunctionsDoNotPanic(t *testing.T) { | ||
| // Ensure all cursor movement functions can be called safely without panicking | ||
| // even in edge cases | ||
|
|
||
| t.Run("all cursor functions", func(t *testing.T) { | ||
| assert.NotPanics(t, func() { | ||
| MoveCursorUp(0) | ||
| MoveCursorUp(100) | ||
| MoveCursorDown(0) | ||
| MoveCursorDown(100) | ||
| }, "Cursor movement functions should never panic") | ||
| }) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MoveCursorUp/MoveCursorDown will emit escape sequences for n==0 and will format negative values into the ANSI sequence (e.g., "\033[-1A"), which is not meaningful and may behave inconsistently across terminals. Consider validating n > 0 and returning early otherwise (or clamping at 0) to make the API safer.