Skip to content

Commit d49cb9a

Browse files
committed
Merge branch 'main' of github.com:smartcontractkit/testrig into traces
2 parents 0e00af9 + 09d68a1 commit d49cb9a

10 files changed

Lines changed: 598 additions & 130 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ replace github.com/smartcontractkit/testrig/tools/test => ./tools/test
1111
require (
1212
charm.land/fang/v2 v2.0.1
1313
charm.land/lipgloss/v2 v2.0.3
14+
github.com/charmbracelet/x/ansi v0.11.7
1415
github.com/charmbracelet/x/term v0.2.2
1516
github.com/spf13/cobra v1.10.2
1617
github.com/spf13/pflag v1.0.10
@@ -23,7 +24,6 @@ require (
2324
github.com/bitfield/gotestdox v0.2.2 // indirect
2425
github.com/charmbracelet/colorprofile v0.4.3 // indirect
2526
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f // indirect
26-
github.com/charmbracelet/x/ansi v0.11.7 // indirect
2727
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a // indirect
2828
github.com/charmbracelet/x/termios v0.1.1 // indirect
2929
github.com/charmbracelet/x/windows v0.2.2 // indirect

internal/output/inline.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package output
2+
3+
import (
4+
"fmt"
5+
"io"
6+
7+
"github.com/charmbracelet/x/ansi"
8+
"github.com/charmbracelet/x/term"
9+
)
10+
11+
// eraseInlineLines clears n physical terminal lines ending at the cursor (no trailing newline).
12+
// The cursor is assumed to be at the end of the bottom line of the block.
13+
func eraseInlineLines(w io.Writer, lines int) {
14+
if lines <= 0 {
15+
return
16+
}
17+
if lines > 1 {
18+
_, _ = fmt.Fprintf(w, "\033[%dA", lines-1)
19+
}
20+
for i := range lines {
21+
_, _ = fmt.Fprint(w, "\r\033[2K")
22+
if i < lines-1 {
23+
_, _ = fmt.Fprint(w, "\033[1B")
24+
}
25+
}
26+
}
27+
28+
func inlineVisualLines(line string, cols int) int {
29+
if cols < 1 {
30+
cols = 80
31+
}
32+
w := ansi.StringWidth(line)
33+
if w == 0 {
34+
return 0
35+
}
36+
return (w + cols - 1) / cols
37+
}
38+
39+
func (p *Printer) termColumns() int {
40+
if p.testTermColumns > 0 {
41+
return p.testTermColumns
42+
}
43+
if p.stderrFD == 0 {
44+
return 80
45+
}
46+
cols, _, err := term.GetSize(p.stderrFD)
47+
if err != nil || cols < 1 {
48+
return 80
49+
}
50+
return cols
51+
}
52+
53+
// inlineEraseLineCount is how many physical rows to clear before redraw.
54+
// Uses max(tracked lines, lines at the last draw width, and lines at the current
55+
// width) so a terminal resize between ticks still clears the reflowed layout.
56+
func (p *Printer) inlineEraseLineCount() int {
57+
n := p.inlineLastLines
58+
if p.inlineLastLine == "" || p.inlineLastCols <= 0 {
59+
return n
60+
}
61+
if atLast := inlineVisualLines(p.inlineLastLine, p.inlineLastCols); atLast > n {
62+
n = atLast
63+
}
64+
cols := p.termColumns()
65+
if cols != p.inlineLastCols {
66+
if atCurrent := inlineVisualLines(p.inlineLastLine, cols); atCurrent > n {
67+
n = atCurrent
68+
}
69+
}
70+
return n
71+
}
72+
73+
func (p *Printer) clearInlineBeforeDraw() {
74+
eraseInlineLines(p.stderr, p.inlineEraseLineCount())
75+
}
76+
77+
// TermColumns returns stderr width for progress fitting (defaults to 80 when unknown).
78+
func (p *Printer) TermColumns() int {
79+
return p.termColumns()
80+
}
81+
82+
// RedrawInline replaces the live progress block on stderr when live inline mode is active.
83+
// The line should already be fitted to the terminal width by the caller.
84+
func (p *Printer) RedrawInline(line string) {
85+
if !p.liveInline {
86+
return
87+
}
88+
cols := p.termColumns()
89+
p.clearInlineBeforeDraw()
90+
_, _ = fmt.Fprint(p.stderr, "\r\033[2K", line)
91+
p.inlineLastLine = line
92+
p.inlineLastCols = cols
93+
p.inlineLastLines = inlineVisualLines(line, cols)
94+
}

internal/output/inline_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package output
2+
3+
import (
4+
"io"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestInlineVisualLines(t *testing.T) {
12+
t.Parallel()
13+
tests := []struct {
14+
name string
15+
line string
16+
cols int
17+
want int
18+
}{
19+
{name: "empty", line: "", cols: 80, want: 0},
20+
{name: "zero cols defaults", line: "hello", cols: 0, want: 1},
21+
{name: "single row", line: "12345", cols: 10, want: 1},
22+
{name: "exact width", line: "1234567890", cols: 10, want: 1},
23+
{name: "wraps second row", line: "12345678901", cols: 10, want: 2},
24+
{name: "wide rune", line: "日本語", cols: 4, want: 2},
25+
{name: "ansi codes width", line: "\x1b[1mhello\x1b[0m", cols: 3, want: 2},
26+
}
27+
for _, tt := range tests {
28+
t.Run(tt.name, func(t *testing.T) {
29+
t.Parallel()
30+
require.Equal(t, tt.want, inlineVisualLines(tt.line, tt.cols))
31+
})
32+
}
33+
}
34+
35+
func TestEraseInlineLines_singleLine(t *testing.T) {
36+
t.Parallel()
37+
var b strings.Builder
38+
eraseInlineLines(&b, 1)
39+
require.Equal(t, "\r\x1b[2K", b.String())
40+
}
41+
42+
func TestEraseInlineLines_multipleLines(t *testing.T) {
43+
t.Parallel()
44+
var b strings.Builder
45+
eraseInlineLines(&b, 3)
46+
got := b.String()
47+
require.Contains(t, got, "\x1b[2A")
48+
require.Equal(t, 3, strings.Count(got, "\r\x1b[2K"))
49+
}
50+
51+
func TestRedrawInline_erasesPreviousLines(t *testing.T) {
52+
t.Parallel()
53+
var stderr strings.Builder
54+
p := NewForTest(false, io.Discard, &stderr, true)
55+
p.SetInlineLastLinesForTest(2)
56+
p.RedrawInline("next")
57+
got := stderr.String()
58+
require.Contains(t, got, "\x1b[1A")
59+
require.Contains(t, got, "\r\x1b[2Knext")
60+
require.Equal(t, 1, p.InlineLastLinesForTest())
61+
}
62+
63+
func TestRedrawInline_secondRedrawNoExtraNewlines(t *testing.T) {
64+
t.Parallel()
65+
var stderr strings.Builder
66+
p := NewForTest(false, io.Discard, &stderr, true)
67+
p.RedrawInline("a")
68+
p.RedrawInline("b")
69+
require.NotContains(t, stderr.String(), "\n")
70+
}
71+
72+
func TestRedrawInline_wrapsAtTestColumns(t *testing.T) {
73+
t.Parallel()
74+
var stderr strings.Builder
75+
p := NewForTest(false, io.Discard, &stderr, true)
76+
p.SetTermColumnsForTest(40)
77+
long := strings.Repeat("x", 100)
78+
p.RedrawInline(long)
79+
require.GreaterOrEqual(t, p.InlineLastLinesForTest(), 2)
80+
got := stderr.String()
81+
require.GreaterOrEqual(t, strings.Count(got, "\r\x1b[2K"), 1)
82+
}
83+
84+
func TestRedrawInline_terminalResize_erasesOldWrap(t *testing.T) {
85+
t.Parallel()
86+
var stderr strings.Builder
87+
p := NewForTest(false, io.Discard, &stderr, true)
88+
p.SetTermColumnsForTest(40)
89+
p.RedrawInline(strings.Repeat("x", 100))
90+
require.GreaterOrEqual(t, p.InlineLastLinesForTest(), 2)
91+
92+
p.SetTermColumnsForTest(80)
93+
p.RedrawInline(strings.Repeat("y", 50))
94+
require.Contains(t, stderr.String(), "\x1b[2A")
95+
require.Contains(t, stderr.String(), "y")
96+
require.NotContains(t, stderr.String(), "\n")
97+
}
98+
99+
func TestClearInline_clearsMultipleTrackedLines(t *testing.T) {
100+
t.Parallel()
101+
var stderr strings.Builder
102+
p := NewForTest(false, io.Discard, &stderr, true)
103+
p.SetInlineLastLinesForTest(2)
104+
p.ClearInline()
105+
got := stderr.String()
106+
require.Contains(t, got, "\x1b[1A")
107+
require.Equal(t, 0, p.InlineLastLinesForTest())
108+
}
109+
110+
func TestInlineEraseLineCount_usesLastDrawWidth(t *testing.T) {
111+
t.Parallel()
112+
p := NewForTest(false, io.Discard, io.Discard, true)
113+
p.SetTermColumnsForTest(40)
114+
p.RedrawInline(strings.Repeat("a", 80))
115+
p.SetTermColumnsForTest(80)
116+
p.inlineLastLines = 1 // stale count after resize before next redraw
117+
require.GreaterOrEqual(t, p.inlineEraseLineCount(), 2)
118+
}
119+
120+
func TestInlineEraseLineCount_usesCurrentWidthAfterShrink(t *testing.T) {
121+
t.Parallel()
122+
p := NewForTest(false, io.Discard, io.Discard, true)
123+
p.SetTermColumnsForTest(80)
124+
p.RedrawInline(strings.Repeat("a", 120))
125+
p.SetTermColumnsForTest(40)
126+
p.inlineLastLines = 1 // stale after terminal reflow
127+
require.GreaterOrEqual(t, p.inlineEraseLineCount(), 3)
128+
}
129+
130+
func TestClearInline_afterShrink_clearsReflowRows(t *testing.T) {
131+
t.Parallel()
132+
var stderr strings.Builder
133+
p := NewForTest(false, io.Discard, &stderr, true)
134+
p.SetTermColumnsForTest(80)
135+
p.RedrawInline(strings.Repeat("a", 120))
136+
p.SetTermColumnsForTest(40)
137+
p.ClearInline()
138+
p.HumanStderr(" 4 pass")
139+
got := stderr.String()
140+
require.NotContains(t, got, "aaaa 4")
141+
require.Contains(t, got, " 4 pass\n")
142+
}

internal/output/output.go

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,31 @@ import (
1515
// Printer writes CLI messages for tools/test. Child processes (go test) still
1616
// attach os.Stdout/os.Stderr directly where passthrough is intended.
1717
type Printer struct {
18-
aiOutput bool
19-
stdout io.Writer
20-
stderr io.Writer
21-
liveInline bool // human mode and stderr is a TTY (safe for \r progress)
18+
aiOutput bool
19+
stdout io.Writer
20+
stderr io.Writer
21+
stderrFD uintptr
22+
liveInline bool // human mode and stderr is a TTY (safe for \r progress)
23+
inlineLastLines int
24+
inlineLastCols int // terminal width when inlineLastLine was drawn
25+
inlineLastLine string // last RedrawInline payload (for resize-aware erase)
26+
testTermColumns int // when >0, overrides termColumns (tests only)
2227
}
2328

2429
// New builds a production Printer. liveInline is enabled when stderrFD points
2530
// at a real terminal and ai-output is off. Tests should use NewForTest.
2631
func New(aiOutput bool, stdout, stderr io.Writer, stderrFD uintptr) *Printer {
2732
live := !aiOutput && term.IsTerminal(stderrFD)
28-
return newPrinter(aiOutput, stdout, stderr, live)
33+
return newPrinter(aiOutput, stdout, stderr, stderrFD, live)
2934
}
3035

3136
// NewForTest returns a Printer with explicit live-inline behavior. liveInline
3237
// is ignored when aiOutput is true.
3338
func NewForTest(aiOutput bool, stdout, stderr io.Writer, liveInline bool) *Printer {
34-
return newPrinter(aiOutput, stdout, stderr, liveInline && !aiOutput)
39+
return newPrinter(aiOutput, stdout, stderr, 0, liveInline && !aiOutput)
3540
}
3641

37-
func newPrinter(aiOutput bool, stdout, stderr io.Writer, liveInline bool) *Printer {
42+
func newPrinter(aiOutput bool, stdout, stderr io.Writer, stderrFD uintptr, liveInline bool) *Printer {
3843
if stdout == nil {
3944
stdout = io.Discard
4045
}
@@ -45,6 +50,7 @@ func newPrinter(aiOutput bool, stdout, stderr io.Writer, liveInline bool) *Print
4550
aiOutput: aiOutput,
4651
stdout: stdout,
4752
stderr: stderr,
53+
stderrFD: stderrFD,
4854
liveInline: liveInline,
4955
}
5056
}
@@ -99,10 +105,32 @@ func (p *Printer) Stdoutln(a ...any) {
99105
_, _ = fmt.Fprintln(p.stdout, a...)
100106
}
101107

102-
// ClearInline clears the current stderr line when live inline progress is active.
108+
// SetTermColumnsForTest pins TermColumns/RedrawInline width (0 restores default).
109+
func (p *Printer) SetTermColumnsForTest(cols int) {
110+
p.testTermColumns = cols
111+
}
112+
113+
// SetInlineLastLinesForTest sets tracked wrapped line count without a prior draw.
114+
func (p *Printer) SetInlineLastLinesForTest(n int) {
115+
p.inlineLastLines = n
116+
}
117+
118+
// InlineLastLinesForTest returns the tracked wrapped line count after RedrawInline.
119+
func (p *Printer) InlineLastLinesForTest() int {
120+
return p.inlineLastLines
121+
}
122+
123+
func (p *Printer) resetInlineState() {
124+
p.inlineLastLines = 0
125+
p.inlineLastCols = 0
126+
p.inlineLastLine = ""
127+
}
128+
129+
// ClearInline clears live inline progress lines on stderr when active.
103130
func (p *Printer) ClearInline() {
104131
if !p.liveInline {
105132
return
106133
}
107-
_, _ = fmt.Fprint(p.stderr, "\r\033[K")
134+
p.clearInlineBeforeDraw()
135+
p.resetInlineState()
108136
}

internal/output/output_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ func TestNewForTest_liveInline(t *testing.T) {
2727
var stderr strings.Builder
2828
p := NewForTest(false, io.Discard, &stderr, true)
2929
require.True(t, p.LiveInlineProgress())
30+
p.SetInlineLastLinesForTest(1)
3031
p.ClearInline()
31-
require.Equal(t, "\r\x1b[K", stderr.String())
32+
require.Equal(t, "\r\x1b[2K", stderr.String())
3233

3334
var err2 strings.Builder
3435
pAI := NewForTest(true, io.Discard, &err2, true)

0 commit comments

Comments
 (0)