Skip to content

Commit 84ad76d

Browse files
committed
Add visual progress bar for TTY output
Replace periodic text-based progress output with a progress bar that updates in place when running in a TTY. It looks something like this: ``` RUNNING 45/100 (45.0%) ████████████░░░░░░░░░░░░ alive: 12 23.4s 1.89/s ```
1 parent 595eabb commit 84ad76d

File tree

8 files changed

+311
-22
lines changed

8 files changed

+311
-22
lines changed

ruby/lib/mutant.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ module Mutant
237237
require 'mutant/reporter/null'
238238
require 'mutant/reporter/sequence'
239239
require 'mutant/reporter/cli'
240+
require 'mutant/reporter/cli/progress_bar'
240241
require 'mutant/reporter/cli/printer'
241242
require 'mutant/reporter/cli/printer/config'
242243
require 'mutant/reporter/cli/printer/coverage_result'

ruby/lib/mutant/reporter/cli.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def warn(message)
8080
#
8181
# @return [self]
8282
def report(env)
83+
finish_progress_bar
8384
Printer::EnvResult.call(output:, object: env)
8485
self
8586
end
@@ -90,6 +91,7 @@ def report(env)
9091
#
9192
# @return [self]
9293
def test_report(env)
94+
finish_progress_bar
9395
Printer::Test::EnvResult.call(output:, object: env)
9496
self
9597
end
@@ -100,6 +102,13 @@ def write(frame)
100102
output.write(frame)
101103
end
102104

105+
# Output newline to finish the progress bar line
106+
#
107+
# @return [undefined]
108+
def finish_progress_bar
109+
output.puts if format.tty
110+
end
111+
103112
end # CLI
104113
end # Reporter
105114
end # Mutant

ruby/lib/mutant/reporter/cli/format.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ class Progressive < self
6565
REPORT_FREQUENCY = 1.0
6666
REPORT_DELAY = 1 / REPORT_FREQUENCY
6767

68+
# ANSI escape sequence to clear from cursor to end of line
69+
CLEAR_LINE = "\e[K"
70+
6871
# Start representation
6972
#
7073
# @return [String]
@@ -83,14 +86,14 @@ def test_start(env)
8386
#
8487
# @return [String]
8588
def progress(status)
86-
format(Printer::StatusProgressive, status)
89+
wrap_progress { format(Printer::StatusProgressive, status) }
8790
end
8891

8992
# Progress representation
9093
#
9194
# @return [String]
9295
def test_progress(status)
93-
format(Printer::Test::StatusProgressive, status)
96+
wrap_progress { format(Printer::Test::StatusProgressive, status) }
9497
end
9598

9699
private
@@ -99,6 +102,21 @@ def new_buffer
99102
StringIO.new
100103
end
101104

105+
# Wrap progress output with TTY-specific line handling
106+
#
107+
# In TTY mode: use carriage return and clear line for in-place updates
108+
# In non-TTY mode: use regular newline-terminated output
109+
#
110+
# @return [String]
111+
def wrap_progress
112+
content = yield
113+
if tty
114+
"\r#{CLEAR_LINE}#{content}"
115+
else
116+
content
117+
end
118+
end
119+
102120
end # Progressive
103121
end # Format
104122
end # CLI

ruby/lib/mutant/reporter/cli/printer/status_progressive.rb

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ class CLI
66
class Printer
77
# Reporter for progressive output format on scheduler Status objects
88
class StatusProgressive < self
9-
FORMAT = 'progress: %02d/%02d alive: %d runtime: %0.02fs killtime: %0.02fs mutations/s: %0.02f'
9+
FALLBACK_FORMAT = 'progress: %02d/%02d alive: %d runtime: %0.02fs killtime: %0.02fs mutations/s: %0.02f'
10+
11+
BAR_WIDTH = 24
1012

1113
delegate(
1214
:amount_mutation_results,
@@ -21,8 +23,26 @@ class StatusProgressive < self
2123
#
2224
# @return [undefined]
2325
def run
26+
if tty?
27+
render_progress_bar
28+
else
29+
render_fallback
30+
end
31+
end
32+
33+
private
34+
35+
def object
36+
super.payload
37+
end
38+
39+
def mutations_per_second
40+
amount_mutation_results / runtime
41+
end
42+
43+
def render_fallback
2444
status(
25-
FORMAT,
45+
FALLBACK_FORMAT,
2646
amount_mutation_results,
2747
amount_mutations,
2848
amount_mutations_alive,
@@ -32,14 +52,37 @@ def run
3252
)
3353
end
3454

35-
private
55+
def render_progress_bar
56+
bar = ProgressBar.build(
57+
current: amount_mutation_results,
58+
total: amount_mutations,
59+
width: BAR_WIDTH
60+
)
3661

37-
def object
38-
super.payload
62+
line = format_progress_line(bar)
63+
output.write(colorize(status_color, line))
3964
end
4065

41-
def mutations_per_second
42-
amount_mutation_results / runtime
66+
def format_progress_line(bar)
67+
format(
68+
'%s %d/%d (%5.1f%%) %s alive: %d %0.1fs %0.2f/s',
69+
progress_prefix,
70+
amount_mutation_results,
71+
amount_mutations,
72+
bar.percentage,
73+
bar.render,
74+
amount_mutations_alive,
75+
runtime,
76+
mutations_per_second
77+
)
78+
end
79+
80+
def progress_prefix
81+
if amount_mutations_alive.positive?
82+
'RUNNING'
83+
else
84+
'RUNNING'
85+
end
4386
end
4487
end # StatusProgressive
4588
end # Printer

ruby/lib/mutant/reporter/cli/printer/test.rb

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ def run
117117

118118
# Reporter for progressive output format on scheduler Status objects
119119
class StatusProgressive < self
120-
FORMAT = 'progress: %02d/%02d failed: %d runtime: %0.02fs testtime: %0.02fs tests/s: %0.02f'
120+
FALLBACK_FORMAT = 'progress: %02d/%02d failed: %d runtime: %0.02fs testtime: %0.02fs tests/s: %0.02f'
121+
122+
BAR_WIDTH = 24
121123

122124
delegate(
123125
:amount_test_results,
@@ -131,8 +133,26 @@ class StatusProgressive < self
131133
#
132134
# @return [undefined]
133135
def run
136+
if tty?
137+
render_progress_bar
138+
else
139+
render_fallback
140+
end
141+
end
142+
143+
private
144+
145+
def object
146+
super.payload
147+
end
148+
149+
def tests_per_second
150+
amount_test_results / runtime
151+
end
152+
153+
def render_fallback
134154
status(
135-
FORMAT,
155+
FALLBACK_FORMAT,
136156
amount_test_results,
137157
amount_tests,
138158
amount_tests_failed,
@@ -142,14 +162,37 @@ def run
142162
)
143163
end
144164

145-
private
165+
def render_progress_bar
166+
bar = ProgressBar.build(
167+
current: amount_test_results,
168+
total: amount_tests,
169+
width: BAR_WIDTH
170+
)
146171

147-
def object
148-
super.payload
172+
line = format_progress_line(bar)
173+
output.write(colorize(status_color, line))
149174
end
150175

151-
def tests_per_second
152-
amount_test_results / runtime
176+
def format_progress_line(bar)
177+
format(
178+
'%s %d/%d (%5.1f%%) %s failed: %d %0.1fs %0.2f/s',
179+
progress_prefix,
180+
amount_test_results,
181+
amount_tests,
182+
bar.percentage,
183+
bar.render,
184+
amount_tests_failed,
185+
runtime,
186+
tests_per_second
187+
)
188+
end
189+
190+
def progress_prefix
191+
if amount_tests_failed.positive?
192+
'TESTING'
193+
else
194+
'TESTING'
195+
end
153196
end
154197
end # StatusProgressive
155198
end # Test
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# frozen_string_literal: true
2+
3+
module Mutant
4+
class Reporter
5+
class CLI
6+
# Visual progress bar renderer
7+
#
8+
# Renders nextest-style progress bars like:
9+
# 45/100 (45.0%) ████████████░░░░░░░░ alive: 12 23.45s
10+
class ProgressBar
11+
include Anima.new(
12+
:current,
13+
:total,
14+
:width,
15+
:filled_char,
16+
:empty_char
17+
)
18+
19+
FILLED_CHAR = "\u2588" # █
20+
EMPTY_CHAR = "\u2591" # ░
21+
22+
DEFAULT_WIDTH = 30
23+
24+
# Render the progress bar string
25+
#
26+
# @return [String]
27+
def render
28+
"#{filled}#{empty}"
29+
end
30+
31+
# Calculate percentage completion
32+
#
33+
# @return [Float]
34+
def percentage
35+
return 0.0 if total.zero?
36+
37+
(current.to_f / total * 100)
38+
end
39+
40+
# Build a progress bar with defaults
41+
#
42+
# @param current [Integer] current progress value
43+
# @param total [Integer] total value
44+
# @param width [Integer] bar width in characters
45+
#
46+
# @return [ProgressBar]
47+
def self.build(current:, total:, width: DEFAULT_WIDTH)
48+
new(
49+
current:,
50+
total:,
51+
width:,
52+
filled_char: FILLED_CHAR,
53+
empty_char: EMPTY_CHAR
54+
)
55+
end
56+
57+
private
58+
59+
def filled_width
60+
return 0 if total.zero?
61+
62+
[((current.to_f / total) * width).round, width].min
63+
end
64+
65+
def empty_width
66+
[width - filled_width, 0].max
67+
end
68+
69+
def filled
70+
filled_char * filled_width
71+
end
72+
73+
def empty
74+
empty_char * empty_width
75+
end
76+
end # ProgressBar
77+
end # CLI
78+
end # Reporter
79+
end # Mutant

0 commit comments

Comments
 (0)