testprune 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +9 -0
- data/lib/testprune/cli.rb +58 -10
- data/lib/testprune/report.rb +2 -21
- data/lib/testprune/runner.rb +72 -7
- data/lib/testprune/ui/error_toggle.rb +65 -0
- data/lib/testprune/ui/progress.rb +68 -0
- data/lib/testprune/ui/report_renderer.rb +148 -0
- data/lib/testprune/ui/styles.rb +60 -0
- data/lib/testprune/ui.rb +21 -0
- data/lib/testprune/version.rb +1 -1
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5df7245c9484fac5c375f5bc3275e2af2ffb4a48e1090a15cfff56188a24047f
|
|
4
|
+
data.tar.gz: 23edfd9d58322bdc03dacc04d5986772dfe57690ad5a6481da6bcf21dc604715
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cab54aebaf7b972b7efa70c617bc769109ce74d3aee567717ddd33339d164410a78c4cb6e98ac6277f28828e26179fff8b4cf267d511a1fdbe22ea6b0e5781ca
|
|
7
|
+
data.tar.gz: 5a9ef2a87d2ce1ddd88ace0cc19dc6ba3259ba7a500db3abc9f88cb95805ef5a42736c29d7489eb4cdf992d7fec2361bf7b584fbd7532ca88044e8b6add3dde9
|
data/README.md
CHANGED
|
@@ -82,8 +82,15 @@ testprune scan -- bundle exec rails test test/controllers/
|
|
|
82
82
|
|
|
83
83
|
# Restrict which source files are analyzed (-s is repeatable)
|
|
84
84
|
testprune scan -s app -s lib -s packs
|
|
85
|
+
|
|
86
|
+
# Show raw test output (disables the progress display)
|
|
87
|
+
testprune scan --verbose # or -V
|
|
85
88
|
```
|
|
86
89
|
|
|
90
|
+
By default, `testprune scan` suppresses raw test runner output and shows a live progress display (spinner + test counter + elapsed time). If test errors occur they are flagged non-blocking — the scan completes and you're prompted to expand the error detail before continuing.
|
|
91
|
+
|
|
92
|
+
Pass `--verbose` / `-V` to stream raw output directly instead (the pre-0.4.0 behavior).
|
|
93
|
+
|
|
87
94
|
When run with no arguments, `testprune scan` checks your `test/` directory and flags any subdirectories whose names contain `selenium`, `request`, `piper`, or `integration` — folders that tend to be slow, browser-driven, or external and are generally poor candidates for coverage analysis. You'll be prompted to exclude them before the run starts:
|
|
88
95
|
|
|
89
96
|
```
|
|
@@ -494,6 +501,7 @@ testprune report -s app -s lib || true
|
|
|
494
501
|
| `-o, --output DIR` | `tmp/.testprune` | all | Where `run.json` and `removal.patch` are written. |
|
|
495
502
|
| `--baseline FRAC` | `0.5` | report, apply | Strip units in ≥ FRAC of tests as shared-setup noise before detection. `0` disables. |
|
|
496
503
|
| `--json` | off | report | Emit machine-readable JSON instead of human text. |
|
|
504
|
+
| `-V, --verbose` | off | scan | Stream raw test output directly (disables progress display). |
|
|
497
505
|
| `-h, --help` | | all | Show help. |
|
|
498
506
|
| `-v, --version` | | | Print version. |
|
|
499
507
|
|
|
@@ -506,6 +514,7 @@ testprune report -s app -s lib || true
|
|
|
506
514
|
| `TESTPRUNE_OUTPUT_DIR` | Output directory. Set automatically by `testprune scan`. |
|
|
507
515
|
| `TESTPRUNE_DEBUG` | Print adapter-load diagnostics (`[testprune-debug] autostart loaded in pid …`). Useful when capture produces no `run.json`. |
|
|
508
516
|
| `DISABLE_SPRING` | Disable Spring preloader so the test process inherits testprune's instrumentation. |
|
|
517
|
+
| `NO_COLOR` | Set to any value to disable all ANSI color output ([no-color.org](https://no-color.org/)). Styled output is also disabled automatically when stdout/stderr is not a TTY (e.g. piped output, CI without color support). |
|
|
509
518
|
|
|
510
519
|
</details>
|
|
511
520
|
|
data/lib/testprune/cli.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'optparse'
|
|
4
4
|
require_relative '../testprune'
|
|
5
|
+
require_relative 'ui'
|
|
5
6
|
|
|
6
7
|
module Testprune
|
|
7
8
|
# Command-line front end. Three real commands:
|
|
@@ -32,6 +33,7 @@ module Testprune
|
|
|
32
33
|
--baseline FRAC Treat units run by >= FRAC of tests as shared-setup
|
|
33
34
|
noise and subtract them (0..1; default 0.5; 0 to disable)
|
|
34
35
|
--json Emit machine-readable JSON (report only)
|
|
36
|
+
-V, --verbose Show raw test output during scan (disables progress display)
|
|
35
37
|
-h, --help Show this help
|
|
36
38
|
-v, --version Show version
|
|
37
39
|
TXT
|
|
@@ -78,6 +80,7 @@ module Testprune
|
|
|
78
80
|
o.on('-o', '--output DIR') { |v| opts[:output] = v }
|
|
79
81
|
o.on('--baseline FRAC', Float) { |v| opts[:baseline] = v }
|
|
80
82
|
o.on('--json') { opts[:json] = true }
|
|
83
|
+
o.on('-V', '--verbose') { opts[:verbose] = true }
|
|
81
84
|
o.on('-h', '--help') { puts(BANNER); exit(0) }
|
|
82
85
|
end
|
|
83
86
|
rest = parser.parse(argv)
|
|
@@ -104,7 +107,7 @@ module Testprune
|
|
|
104
107
|
elsif test_command.nil?
|
|
105
108
|
test_command = runner.command_for_paths(paths)
|
|
106
109
|
end
|
|
107
|
-
runner.call(test_command)
|
|
110
|
+
runner.call(test_command, verbose: opts[:verbose])
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
def prompt_noisy_exclusions(runner)
|
|
@@ -131,8 +134,29 @@ module Testprune
|
|
|
131
134
|
end
|
|
132
135
|
|
|
133
136
|
def cmd_prune(argv)
|
|
137
|
+
if UI.tty?($stderr)
|
|
138
|
+
$stderr.puts UI::Styles::HEADER_BOX.render("✂️ testprune prune — scan + apply in one step")
|
|
139
|
+
$stderr.puts
|
|
140
|
+
end
|
|
134
141
|
cmd_scan(argv)
|
|
135
|
-
|
|
142
|
+
if UI.tty?($stderr)
|
|
143
|
+
sep = UI::Styles::PURPLE_TEXT.render(' ' + '━' * 58)
|
|
144
|
+
$stderr.puts
|
|
145
|
+
$stderr.puts sep
|
|
146
|
+
$stderr.puts " #{UI::Styles::PURPLE_TEXT.render('✂️')} Moving to apply…"
|
|
147
|
+
$stderr.puts sep
|
|
148
|
+
$stderr.puts
|
|
149
|
+
end
|
|
150
|
+
patch_path = cmd_apply([])
|
|
151
|
+
# T018: done summary
|
|
152
|
+
if UI.tty?($stdout)
|
|
153
|
+
summary = case patch_path
|
|
154
|
+
when String then " ✂️ Done — patch written\n #{UI::Styles::PURPLE_TEXT.render("git apply #{patch_path}")}"
|
|
155
|
+
when false then " ✂️ Done — aborted, no patch written"
|
|
156
|
+
else " ✂️ Done — nothing to prune"
|
|
157
|
+
end
|
|
158
|
+
puts UI::Styles::SUCCESS_BOX.render(summary)
|
|
159
|
+
end
|
|
136
160
|
end
|
|
137
161
|
|
|
138
162
|
def cmd_report(argv)
|
|
@@ -154,22 +178,46 @@ module Testprune
|
|
|
154
178
|
|
|
155
179
|
approved = result.approved_removals
|
|
156
180
|
if approved.empty?
|
|
157
|
-
|
|
158
|
-
|
|
181
|
+
msg = " Nothing safe to remove. No patch written."
|
|
182
|
+
puts(UI.tty?($stdout) ? UI::Styles::SUCCESS_BOX.render(msg) : "\n#{msg.strip}")
|
|
183
|
+
return nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Styled confirmation prompt
|
|
187
|
+
if UI.tty?($stdout)
|
|
188
|
+
puts
|
|
189
|
+
puts " Apply #{UI::Styles::GREEN_TEXT.render(approved.size.to_s)} HIGH-confidence removal(s) as a patch?"
|
|
190
|
+
puts " #{UI::Styles::DIM_TEXT.render('(MEDIUM/LOW review-only candidates are NOT patched.)')}"
|
|
191
|
+
print " #{UI::Styles::PURPLE_TEXT.render('[y/N]')} > "
|
|
192
|
+
else
|
|
193
|
+
print("\nApply #{approved.size} HIGH-confidence, safety-verified removal(s) as a patch?\n" \
|
|
194
|
+
"(MEDIUM/LOW review-only candidates are NOT patched automatically.) [y/N] ")
|
|
159
195
|
end
|
|
160
196
|
|
|
161
|
-
print("\nApply #{approved.size} HIGH-confidence, safety-verified removal(s) as a patch?\n" \
|
|
162
|
-
"(MEDIUM/LOW review-only candidates are NOT patched automatically.) [y/N] ")
|
|
163
197
|
answer = $stdin.gets&.strip&.downcase
|
|
164
198
|
unless %w[y yes].include?(answer)
|
|
165
|
-
|
|
166
|
-
|
|
199
|
+
msg = " Aborted — no patch written."
|
|
200
|
+
puts(UI.tty?($stdout) ? UI::Styles::DIM_TEXT.render(msg) : 'Aborted. No patch written.')
|
|
201
|
+
return false
|
|
167
202
|
end
|
|
168
203
|
|
|
169
204
|
require_relative 'patch_writer'
|
|
170
205
|
path = PatchWriter.new(Testprune.config).write(approved)
|
|
171
|
-
|
|
172
|
-
|
|
206
|
+
|
|
207
|
+
if UI.tty?($stdout)
|
|
208
|
+
box = [
|
|
209
|
+
" #{UI::Styles::GREEN_TEXT.render('✓')} Patch written",
|
|
210
|
+
" #{path}",
|
|
211
|
+
" #{UI::Styles::DIM_TEXT.render('Apply with:')} " \
|
|
212
|
+
"#{UI::Styles::PURPLE_TEXT.render("git apply #{path}")}"
|
|
213
|
+
].join("\n")
|
|
214
|
+
puts UI::Styles::SUCCESS_BOX.render(box)
|
|
215
|
+
else
|
|
216
|
+
puts("Wrote #{path}")
|
|
217
|
+
puts("Review it, then apply with: git apply #{path}")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
path
|
|
173
221
|
end
|
|
174
222
|
end
|
|
175
223
|
end
|
data/lib/testprune/report.rb
CHANGED
|
@@ -26,27 +26,8 @@ module Testprune
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
28
|
def render_text
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
lines << "Suite: #{test_count} test(s), framework=#{@result.run['framework']}"
|
|
32
|
-
if @result.ambient_units.positive?
|
|
33
|
-
lines << "Baseline: subtracted #{@result.ambient_units} shared-setup unit(s); " \
|
|
34
|
-
"#{@result.setup_only} test(s) had no distinctive coverage and were set aside."
|
|
35
|
-
end
|
|
36
|
-
lines << ''
|
|
37
|
-
|
|
38
|
-
lines.concat(section('HIGH confidence — safe to remove', high_candidates))
|
|
39
|
-
lines.concat(section('MEDIUM confidence — review (structural duplicates)', medium_candidates))
|
|
40
|
-
lines.concat(section('LOW confidence — review (overlapping coverage)', low_candidates))
|
|
41
|
-
|
|
42
|
-
lines.concat(savings_section)
|
|
43
|
-
lines << ''
|
|
44
|
-
lines << if @result.approved_removals.empty?
|
|
45
|
-
'No auto-removable candidates. Nothing to apply.'
|
|
46
|
-
else
|
|
47
|
-
'Run `testprune apply` to review and emit a removal patch.'
|
|
48
|
-
end
|
|
49
|
-
lines.join("\n")
|
|
29
|
+
require_relative 'ui/report_renderer'
|
|
30
|
+
UI::ReportRenderer.new(@result).render
|
|
50
31
|
end
|
|
51
32
|
|
|
52
33
|
def section(title, candidates)
|
data/lib/testprune/runner.rb
CHANGED
|
@@ -2,27 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'open3'
|
|
5
6
|
require_relative '../testprune'
|
|
7
|
+
require_relative 'ui'
|
|
6
8
|
|
|
7
9
|
module Testprune
|
|
8
10
|
# Boots the target project's suite in a subprocess, instrumented so the adapters
|
|
9
11
|
# capture per-test coverage. Reuses the gem's lib via RUBYOPT (-I) so the target
|
|
10
12
|
# project does not need testprune in its Gemfile.
|
|
11
13
|
class Runner
|
|
14
|
+
# Progress-line patterns: match single-char Minitest/RSpec progress indicators.
|
|
15
|
+
TEST_PROGRESS_RE = /\A[.FES*P]+\z/
|
|
16
|
+
# Lines that indicate a test error or failure.
|
|
17
|
+
ERROR_RE = /Error:|FAILED|Failure:|error:/
|
|
18
|
+
|
|
12
19
|
def initialize(config)
|
|
13
20
|
@config = config
|
|
14
21
|
end
|
|
15
22
|
|
|
16
|
-
# explicit_command: array form of a user-supplied test command (after `--`),
|
|
17
|
-
#
|
|
18
|
-
def call(explicit_command = nil)
|
|
23
|
+
# explicit_command: array form of a user-supplied test command (after `--`), or nil.
|
|
24
|
+
# verbose: when true, stream raw output directly (no capture, no spinner).
|
|
25
|
+
def call(explicit_command = nil, verbose: false)
|
|
19
26
|
framework, command = resolve(explicit_command)
|
|
20
27
|
|
|
21
28
|
FileUtils.mkdir_p(@config.output_dir)
|
|
22
29
|
File.delete(@config.run_file) if File.exist?(@config.run_file)
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
if verbose
|
|
32
|
+
warn("testprune: framework=#{framework} running: #{command.join(' ')}")
|
|
33
|
+
ok = system(env, *command, chdir: @config.root)
|
|
34
|
+
else
|
|
35
|
+
ok = run_captured(framework, command)
|
|
36
|
+
end
|
|
26
37
|
|
|
27
38
|
unless File.exist?(@config.run_file)
|
|
28
39
|
raise Error, 'suite finished but no run.json was captured — the adapter may ' \
|
|
@@ -34,8 +45,11 @@ module Testprune
|
|
|
34
45
|
rescue JSON::ParserError
|
|
35
46
|
'(unreadable — suite may have been interrupted mid-write)'
|
|
36
47
|
end
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
|
|
49
|
+
unless verbose
|
|
50
|
+
warn("testprune: captured #{count} test(s) -> #{@config.run_file}")
|
|
51
|
+
end
|
|
52
|
+
|
|
39
53
|
ok
|
|
40
54
|
end
|
|
41
55
|
|
|
@@ -53,6 +67,57 @@ module Testprune
|
|
|
53
67
|
|
|
54
68
|
private
|
|
55
69
|
|
|
70
|
+
def run_captured(framework, command)
|
|
71
|
+
progress = UI::Progress.new(io: $stderr)
|
|
72
|
+
output = []
|
|
73
|
+
error_lines = []
|
|
74
|
+
|
|
75
|
+
progress.start
|
|
76
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
77
|
+
|
|
78
|
+
ok = Open3.popen2e(env, *command, chdir: @config.root) do |_stdin, oe, wait_thr|
|
|
79
|
+
oe.each_line do |line|
|
|
80
|
+
output << line
|
|
81
|
+
# Count progress chars
|
|
82
|
+
progress.increment if line.match?(TEST_PROGRESS_RE)
|
|
83
|
+
error_lines << line.chomp if line.match?(ERROR_RE)
|
|
84
|
+
end
|
|
85
|
+
wait_thr.value.success?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
89
|
+
test_count = begin
|
|
90
|
+
File.exist?(@config.run_file) ? JSON.parse(File.read(@config.run_file)).fetch('tests', []).size : 0
|
|
91
|
+
rescue StandardError
|
|
92
|
+
0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
progress.stop(test_count: test_count, elapsed: elapsed)
|
|
96
|
+
print_scan_summary(test_count, elapsed, error_lines)
|
|
97
|
+
|
|
98
|
+
ok
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def print_scan_summary(test_count, elapsed, error_lines)
|
|
102
|
+
mins = (elapsed / 60).to_i
|
|
103
|
+
secs = (elapsed % 60).to_i
|
|
104
|
+
time_s = format('%02d:%02d', mins, secs)
|
|
105
|
+
|
|
106
|
+
if UI.tty?($stderr)
|
|
107
|
+
lines = []
|
|
108
|
+
lines << " #{UI::Styles::GREEN_TEXT.render('✓')} Scan complete"
|
|
109
|
+
lines << " #{test_count} tests · #{time_s} elapsed"
|
|
110
|
+
lines << " Data written to #{@config.output_dir}/"
|
|
111
|
+
$stderr.puts UI::Styles::SUCCESS_BOX.render(lines.join("\n"))
|
|
112
|
+
else
|
|
113
|
+
$stderr.puts("testprune: scan complete — #{test_count} tests in #{time_s}")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
return if error_lines.empty?
|
|
117
|
+
|
|
118
|
+
UI::ErrorToggle.new(errors: error_lines, io: $stderr).run
|
|
119
|
+
end
|
|
120
|
+
|
|
56
121
|
def env
|
|
57
122
|
gem_lib = File.expand_path('..', __dir__)
|
|
58
123
|
rubyopt = ["-I#{gem_lib}", '-rtestprune/autostart', ENV['RUBYOPT']].compact.reject(&:empty?).join(' ')
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'testprune/ui/styles'
|
|
4
|
+
|
|
5
|
+
module Testprune
|
|
6
|
+
module UI
|
|
7
|
+
# Post-scan interactive prompt. When test errors occurred (non-blocking),
|
|
8
|
+
# shows a count indicator and lets the user toggle the error detail on/off
|
|
9
|
+
# before continuing the workflow.
|
|
10
|
+
class ErrorToggle
|
|
11
|
+
def initialize(errors:, io: $stderr, stdin: $stdin)
|
|
12
|
+
@errors = errors
|
|
13
|
+
@io = io
|
|
14
|
+
@stdin = stdin
|
|
15
|
+
@expanded = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
return if @errors.empty?
|
|
20
|
+
|
|
21
|
+
print_indicator
|
|
22
|
+
loop do
|
|
23
|
+
@io.print(" [e + Enter] #{styled('[e]', Styles::PURPLE_TEXT)} show/hide errors" \
|
|
24
|
+
" [Enter] skip > ")
|
|
25
|
+
@io.flush
|
|
26
|
+
input = @stdin.gets&.strip&.downcase
|
|
27
|
+
break if input.nil? || input.empty?
|
|
28
|
+
if input == 'e'
|
|
29
|
+
@expanded = !@expanded
|
|
30
|
+
@expanded ? print_errors : clear_errors
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def tty?
|
|
38
|
+
return false if ENV['NO_COLOR']
|
|
39
|
+
@io.respond_to?(:isatty) && @io.isatty
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def styled(text, style)
|
|
43
|
+
tty? ? style.render(text) : text
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def print_indicator
|
|
47
|
+
label = styled("⚠ #{@errors.size} test error(s) detected", Styles::AMBER_TEXT)
|
|
48
|
+
note = styled(' (non-blocking — scan completed)', Styles::META_TEXT)
|
|
49
|
+
@io.puts("#{label}#{note}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def print_errors
|
|
53
|
+
sep = styled(' ' + '─' * 62, Styles::DIM_TEXT)
|
|
54
|
+
@io.puts(sep)
|
|
55
|
+
@errors.each { |line| @io.puts(" #{styled(line, Styles::ERROR_TEXT)}") }
|
|
56
|
+
@io.puts(sep)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def clear_errors
|
|
60
|
+
# Errors are already scrolled — just note they're hidden
|
|
61
|
+
@io.puts(styled(' (errors hidden)', Styles::DIM_TEXT))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'testprune/ui/styles'
|
|
4
|
+
|
|
5
|
+
module Testprune
|
|
6
|
+
module UI
|
|
7
|
+
# Live scan progress display. Shows a Braille spinner, running test counter,
|
|
8
|
+
# and elapsed time. All output goes to io (default $stderr) and is suppressed
|
|
9
|
+
# when not a TTY or when NO_COLOR is set.
|
|
10
|
+
class Progress
|
|
11
|
+
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(io: $stderr)
|
|
14
|
+
@io = io
|
|
15
|
+
@counter = 0
|
|
16
|
+
@frame = 0
|
|
17
|
+
@thread = nil
|
|
18
|
+
@mu = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def tty?
|
|
22
|
+
return false if ENV['NO_COLOR']
|
|
23
|
+
@io.respond_to?(:isatty) && @io.isatty
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def start
|
|
27
|
+
return unless tty?
|
|
28
|
+
@start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
29
|
+
@thread = Thread.new do
|
|
30
|
+
loop do
|
|
31
|
+
draw
|
|
32
|
+
sleep 0.1
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def increment(count: 1)
|
|
38
|
+
@mu.synchronize { @counter += count }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Stops the spinner thread and clears the line. Returns stats hash.
|
|
42
|
+
def stop(test_count:, elapsed:)
|
|
43
|
+
@thread&.kill
|
|
44
|
+
@thread&.join(0.2)
|
|
45
|
+
@thread = nil
|
|
46
|
+
@io.print("\r\e[K") if tty?
|
|
47
|
+
{ test_count: test_count, elapsed: elapsed }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def draw
|
|
53
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start
|
|
54
|
+
count = @mu.synchronize { @counter }
|
|
55
|
+
frame = FRAMES[@frame % FRAMES.size]
|
|
56
|
+
@frame += 1
|
|
57
|
+
mins = (elapsed / 60).to_i
|
|
58
|
+
secs = (elapsed % 60).to_i
|
|
59
|
+
time_s = format('%02d:%02d', mins, secs)
|
|
60
|
+
line = "#{Styles::PURPLE_TEXT.render(frame)} Running suite… " \
|
|
61
|
+
"#{Styles::GREEN_TEXT.render(count.to_s)} tests " \
|
|
62
|
+
"#{Styles::META_TEXT.render(time_s)} elapsed"
|
|
63
|
+
@io.print("\r\e[K#{line}")
|
|
64
|
+
@io.flush
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'testprune/ui/styles'
|
|
4
|
+
|
|
5
|
+
module Testprune
|
|
6
|
+
module UI
|
|
7
|
+
# Styled replacement for Report#render_text. Renders the analysis result as a
|
|
8
|
+
# rich, color-coded terminal report using lipgloss. Degrades to plain text when
|
|
9
|
+
# NO_COLOR=1 is set or when output is not a TTY (lipgloss handles this automatically).
|
|
10
|
+
class ReportRenderer
|
|
11
|
+
GROUP_LABELS = {
|
|
12
|
+
identical: 'identical',
|
|
13
|
+
subset: 'subset',
|
|
14
|
+
structural: 'structural',
|
|
15
|
+
overlap: 'overlap'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(result)
|
|
19
|
+
@result = result
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render
|
|
23
|
+
parts = []
|
|
24
|
+
parts << header_section
|
|
25
|
+
parts << ''
|
|
26
|
+
|
|
27
|
+
if @result.candidates.empty?
|
|
28
|
+
parts << nothing_found_section
|
|
29
|
+
else
|
|
30
|
+
parts << confidence_section(:high, 'HIGH confidence — safe to remove', Styles::HIGH_BADGE, '●')
|
|
31
|
+
parts << confidence_section(:medium, 'MEDIUM confidence — review', Styles::MEDIUM_BADGE, '●')
|
|
32
|
+
parts << confidence_section(:low, 'LOW confidence — review', Styles::LOW_BADGE, '●')
|
|
33
|
+
parts << savings_section unless @result.approved_removals.empty?
|
|
34
|
+
parts << ''
|
|
35
|
+
parts << cta_line
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
parts.compact.join("\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def tty?
|
|
44
|
+
return false if ENV['NO_COLOR']
|
|
45
|
+
|
|
46
|
+
$stdout.respond_to?(:isatty) && $stdout.isatty
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def styled(text, style)
|
|
50
|
+
tty? ? style.render(text) : text
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# ── Sections ─────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def header_section
|
|
56
|
+
framework = @result.run['framework'] || 'unknown'
|
|
57
|
+
test_count = (@result.run['tests'] || []).size
|
|
58
|
+
baseline_info = @result.ambient_units.positive? ?
|
|
59
|
+
" baseline: subtracted #{@result.ambient_units} shared-setup unit(s)" : ''
|
|
60
|
+
|
|
61
|
+
lines = [
|
|
62
|
+
" testprune — coverage redundancy report",
|
|
63
|
+
" #{test_count} tests · #{framework}#{baseline_info}"
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
if tty?
|
|
67
|
+
Styles::REPORT_BOX.render(lines.join("\n"))
|
|
68
|
+
else
|
|
69
|
+
lines.join("\n") + "\n" + ('─' * 64)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def confidence_section(tier, title, badge_style, bullet)
|
|
74
|
+
candidates = @result.candidates.select { |c| c.confidence == tier }
|
|
75
|
+
return nil if candidates.empty?
|
|
76
|
+
|
|
77
|
+
lines = []
|
|
78
|
+
badge = styled(" #{bullet} ", badge_style)
|
|
79
|
+
title_text = styled(title, badge_style)
|
|
80
|
+
count_text = styled(" (#{candidates.size})", Styles::META_TEXT)
|
|
81
|
+
lines << "#{badge}#{title_text}#{count_text}"
|
|
82
|
+
lines << styled(' ' + '─' * 62, Styles::DIM_TEXT)
|
|
83
|
+
lines << ''
|
|
84
|
+
candidates.each do |c|
|
|
85
|
+
lines.concat(candidate_lines(c))
|
|
86
|
+
lines << ''
|
|
87
|
+
end
|
|
88
|
+
lines.join("\n")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def candidate_lines(candidate)
|
|
92
|
+
fp = candidate.footprint
|
|
93
|
+
lines = []
|
|
94
|
+
|
|
95
|
+
group_badge = styled("[#{GROUP_LABELS[candidate.group] || candidate.group}]", Styles::PURPLE_TEXT)
|
|
96
|
+
lines << " #{group_badge} #{fp.id}"
|
|
97
|
+
lines << " #{styled('at: ', Styles::DIM_TEXT)}#{styled("#{fp.file}:#{fp.line}", Styles::META_TEXT)}" if fp.file
|
|
98
|
+
lines << " #{styled('reason: ', Styles::DIM_TEXT)}#{styled(candidate.reason, Styles::META_TEXT)}"
|
|
99
|
+
|
|
100
|
+
unless candidate.kept_by.empty?
|
|
101
|
+
lines << " #{styled('kept by: ', Styles::DIM_TEXT)}#{styled(candidate.kept_by.join(', '), Styles::DIM_TEXT)}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
covers = fp.units.map { |id| @result.label_for(id) }.sort
|
|
105
|
+
covers_text = covers.size <= 4 ? covers.join(' · ') : "#{covers.first(4).join(' · ')} (+#{covers.size - 4} more)"
|
|
106
|
+
lines << " #{styled('covers: ', Styles::DIM_TEXT)}#{styled(covers_text, Styles::DIM_TEXT)}"
|
|
107
|
+
|
|
108
|
+
safety = case candidate.safe
|
|
109
|
+
when true then styled(' ✓ safe — every covered unit is retained by another test', Styles::SAFE_LINE)
|
|
110
|
+
when false then styled(" ✗ NOT safe — #{candidate.safety_note}", Styles::UNSAFE_LINE)
|
|
111
|
+
else styled(' · review-only — not auto-applied', Styles::DIM_TEXT)
|
|
112
|
+
end
|
|
113
|
+
lines << safety
|
|
114
|
+
lines
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def savings_section
|
|
118
|
+
s = @result.savings
|
|
119
|
+
lines = [
|
|
120
|
+
' Estimated CI savings',
|
|
121
|
+
" #{styled("#{s.approved_count} test(s)", Styles::GREEN_TEXT)}" \
|
|
122
|
+
"#{styled(' · ', Styles::META_TEXT)}" \
|
|
123
|
+
"#{styled(format('%.4fs saved', s.approved_time), Styles::GREEN_TEXT)}" \
|
|
124
|
+
"#{styled(' · ', Styles::META_TEXT)}" \
|
|
125
|
+
"#{styled(format('~%.1f%% of suite', s.percent_of_test_time), Styles::GREEN_TEXT)}",
|
|
126
|
+
styled(' Note: wall-clock savings lower on parallel CI runners', Styles::META_TEXT)
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
if tty?
|
|
130
|
+
Styles::REPORT_BOX.render(lines.join("\n"))
|
|
131
|
+
else
|
|
132
|
+
lines.join("\n")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def nothing_found_section
|
|
137
|
+
msg = ' Nothing redundant found — suite looks clean.'
|
|
138
|
+
tty? ? Styles::SUCCESS_BOX.render(msg) : msg
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def cta_line
|
|
142
|
+
" #{styled('Run ', Styles::META_TEXT)}" \
|
|
143
|
+
"#{styled('testprune apply', Styles::PURPLE_TEXT)}" \
|
|
144
|
+
"#{styled(' to review and emit a removal patch.', Styles::META_TEXT)}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'lipgloss'
|
|
4
|
+
|
|
5
|
+
module Testprune
|
|
6
|
+
module UI
|
|
7
|
+
# Centralized style palette. All UI components pull from here so the visual
|
|
8
|
+
# identity stays consistent and editable in one place.
|
|
9
|
+
#
|
|
10
|
+
# Colors degrade automatically when NO_COLOR=1 is set (lipgloss strips ANSI).
|
|
11
|
+
module Styles
|
|
12
|
+
# ── Color hex constants ──────────────────────────────────────────────────
|
|
13
|
+
PURPLE = '#7D56F4' # brand / borders / interactive
|
|
14
|
+
GREEN = '#22C55E' # HIGH confidence / success / safe
|
|
15
|
+
AMBER = '#F59E0B' # MEDIUM confidence / warnings
|
|
16
|
+
GRAY = '#6B7280' # LOW confidence
|
|
17
|
+
RED = '#EF4444' # errors / unsafe removals
|
|
18
|
+
EMERALD = '#10B981' # scan complete / patch written
|
|
19
|
+
META = '#9CA3AF' # subdued meta-text (kept by, covers, timestamps)
|
|
20
|
+
DIM = '#3D3D5C' # decorators / separators
|
|
21
|
+
TEXT = '#E2E8F0' # default body text
|
|
22
|
+
|
|
23
|
+
# ── Pre-built reusable styles ────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
# Rounded box with purple border — used for command headers.
|
|
26
|
+
HEADER_BOX = Lipgloss::Style.new
|
|
27
|
+
.border(:rounded)
|
|
28
|
+
.border_foreground(PURPLE)
|
|
29
|
+
.padding(0, 1)
|
|
30
|
+
|
|
31
|
+
# Rounded box with emerald border — used for success summaries.
|
|
32
|
+
SUCCESS_BOX = Lipgloss::Style.new
|
|
33
|
+
.border(:rounded)
|
|
34
|
+
.border_foreground(EMERALD)
|
|
35
|
+
.padding(0, 1)
|
|
36
|
+
|
|
37
|
+
# Rounded box with purple border — used for report sections and savings.
|
|
38
|
+
REPORT_BOX = Lipgloss::Style.new
|
|
39
|
+
.border(:rounded)
|
|
40
|
+
.border_foreground(PURPLE)
|
|
41
|
+
.padding(0, 1)
|
|
42
|
+
|
|
43
|
+
# Confidence badges
|
|
44
|
+
HIGH_BADGE = Lipgloss::Style.new.foreground(GREEN).bold(true)
|
|
45
|
+
MEDIUM_BADGE = Lipgloss::Style.new.foreground(AMBER).bold(true)
|
|
46
|
+
LOW_BADGE = Lipgloss::Style.new.foreground(GRAY)
|
|
47
|
+
|
|
48
|
+
# Inline text styles
|
|
49
|
+
SAFE_LINE = Lipgloss::Style.new.foreground(GREEN)
|
|
50
|
+
UNSAFE_LINE = Lipgloss::Style.new.foreground(RED).bold(true)
|
|
51
|
+
META_TEXT = Lipgloss::Style.new.foreground(META)
|
|
52
|
+
DIM_TEXT = Lipgloss::Style.new.foreground(DIM)
|
|
53
|
+
PURPLE_TEXT = Lipgloss::Style.new.foreground(PURPLE)
|
|
54
|
+
GREEN_TEXT = Lipgloss::Style.new.foreground(GREEN)
|
|
55
|
+
AMBER_TEXT = Lipgloss::Style.new.foreground(AMBER)
|
|
56
|
+
ERROR_TEXT = Lipgloss::Style.new.foreground(RED).bold(true)
|
|
57
|
+
EMERALD_TEXT = Lipgloss::Style.new.foreground(EMERALD)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/testprune/ui.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'lipgloss'
|
|
4
|
+
require_relative 'ui/styles'
|
|
5
|
+
require_relative 'ui/progress'
|
|
6
|
+
require_relative 'ui/error_toggle'
|
|
7
|
+
require_relative 'ui/report_renderer'
|
|
8
|
+
|
|
9
|
+
module Testprune
|
|
10
|
+
# Terminal UI components built on lipgloss-ruby.
|
|
11
|
+
# All components degrade gracefully when NO_COLOR=1 is set or output is not a TTY.
|
|
12
|
+
module UI
|
|
13
|
+
# Returns true when styled output is appropriate for the given IO.
|
|
14
|
+
# Respects NO_COLOR (https://no-color.org/) and non-TTY output streams.
|
|
15
|
+
def self.tty?(io = $stdout)
|
|
16
|
+
return false if ENV['NO_COLOR']
|
|
17
|
+
|
|
18
|
+
io.respond_to?(:isatty) && io.isatty
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/testprune/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: testprune
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Seth MacPherson
|
|
@@ -29,6 +29,20 @@ dependencies:
|
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '3'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: lipgloss
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '0.1'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '0.1'
|
|
32
46
|
description: Combines Ruby's native Coverage execution counts with Prism AST analysis
|
|
33
47
|
to map per-test coverage onto semantic units (methods, branches, conditions), find
|
|
34
48
|
redundant tests grouped by duplication type with confidence levels, and emit a removal
|
|
@@ -63,6 +77,11 @@ files:
|
|
|
63
77
|
- lib/testprune/savings_estimator.rb
|
|
64
78
|
- lib/testprune/semantic_map.rb
|
|
65
79
|
- lib/testprune/test_body.rb
|
|
80
|
+
- lib/testprune/ui.rb
|
|
81
|
+
- lib/testprune/ui/error_toggle.rb
|
|
82
|
+
- lib/testprune/ui/progress.rb
|
|
83
|
+
- lib/testprune/ui/report_renderer.rb
|
|
84
|
+
- lib/testprune/ui/styles.rb
|
|
66
85
|
- lib/testprune/version.rb
|
|
67
86
|
homepage: https://github.com/seth-macpherson/testprune
|
|
68
87
|
licenses:
|