polyrun 1.4.1 → 1.5.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/CHANGELOG.md +21 -0
- data/lib/polyrun/cli/ci_shard_hooks.rb +12 -4
- data/lib/polyrun/cli/ci_shard_run_command.rb +3 -1
- data/lib/polyrun/cli/help.rb +3 -0
- data/lib/polyrun/cli/helpers.rb +22 -0
- data/lib/polyrun/cli/run_shards_parallel_children.rb +26 -34
- data/lib/polyrun/cli/run_shards_parallel_wait.rb +267 -0
- data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +34 -1
- data/lib/polyrun/cli/run_shards_plan_options.rb +6 -2
- data/lib/polyrun/cli/run_shards_run.rb +7 -33
- data/lib/polyrun/cli/run_shards_worker_interrupt.rb +75 -0
- data/lib/polyrun/coverage/collector_finish.rb +3 -2
- data/lib/polyrun/coverage/formatter.rb +2 -1
- data/lib/polyrun/coverage/merge/formatters_html.rb +191 -43
- data/lib/polyrun/coverage/merge/html/_file_list.html.erb +21 -0
- data/lib/polyrun/coverage/merge/html/_file_section.html.erb +26 -0
- data/lib/polyrun/coverage/merge/html/_groups_table.html.erb +18 -0
- data/lib/polyrun/coverage/merge/html/_overview.html.erb +47 -0
- data/lib/polyrun/coverage/merge/html/report.css +147 -0
- data/lib/polyrun/coverage/merge/html/report.js +48 -0
- data/lib/polyrun/coverage/merge/html/template.html.erb +30 -0
- data/lib/polyrun/coverage/track_files.rb +9 -0
- data/lib/polyrun/hooks.rb +9 -1
- data/lib/polyrun/log.rb +16 -0
- data/lib/polyrun/minitest.rb +34 -0
- data/lib/polyrun/quick/example_runner.rb +11 -0
- data/lib/polyrun/rspec.rb +18 -0
- data/lib/polyrun/version.rb +1 -1
- data/lib/polyrun/worker_ping.rb +74 -0
- data/sig/polyrun/minitest.rbs +2 -0
- data/sig/polyrun/rspec.rbs +4 -0
- data/sig/polyrun/worker_ping.rbs +10 -0
- metadata +12 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
class CLI
|
|
3
|
+
# SIGINT/SIGTERM handling and non-blocking reap for parallel worker PIDs (used by run-shards / ci-shard fan-out).
|
|
4
|
+
module RunShardsWorkerInterrupt
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def run_shards_log_interrupt_workers(pids, _ctx)
|
|
8
|
+
parts = pids.map { |h| "shard=#{h[:shard]} pid=#{h[:pid]}" }
|
|
9
|
+
Polyrun::Log.orchestration_warn "polyrun run-shards: SIGINT/SIGTERM while waiting on workers — stopping: #{parts.join(", ")}"
|
|
10
|
+
Polyrun::Log.warn "polyrun run-shards: search this log for each shard's started … pid= line and RSpec output; repeat SIGINT during cleanup escalates to SIGKILL"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Best-effort worker teardown then exit. Does not return.
|
|
14
|
+
def run_shards_shutdown_on_signal!(pids, code)
|
|
15
|
+
run_shards_log_interrupt_workers(pids, nil)
|
|
16
|
+
run_shards_terminate_children!(pids)
|
|
17
|
+
exit(code)
|
|
18
|
+
rescue Interrupt
|
|
19
|
+
run_shards_signal_workers_kill(pids)
|
|
20
|
+
run_shards_reap_worker_pids_interruptible(pids.map { |h| h[:pid] })
|
|
21
|
+
exit(code)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Send SIGTERM to each worker PID and wait so Ctrl+C / SIGTERM does not leave orphans.
|
|
25
|
+
def run_shards_terminate_children!(pids)
|
|
26
|
+
run_shards_signal_workers_term(pids)
|
|
27
|
+
run_shards_reap_worker_pids_interruptible(pids.map { |h| h[:pid] })
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run_shards_signal_workers_term(pids)
|
|
31
|
+
pids.each do |h|
|
|
32
|
+
Process.kill(:TERM, h[:pid])
|
|
33
|
+
rescue Errno::ESRCH
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run_shards_signal_workers_kill(pids)
|
|
38
|
+
pids.each do |h|
|
|
39
|
+
Process.kill(:KILL, h[:pid])
|
|
40
|
+
rescue Errno::ESRCH
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Reap child PIDs without blocking uninterruptibly on one stuck zombie (avoids noisy stacks on repeat Ctrl+C).
|
|
45
|
+
def run_shards_reap_worker_pids_interruptible(pids)
|
|
46
|
+
pending = pids.compact.uniq
|
|
47
|
+
force_note = false
|
|
48
|
+
until pending.empty?
|
|
49
|
+
pending.reject! do |pid|
|
|
50
|
+
w = Process.wait(pid, Process::WNOHANG)
|
|
51
|
+
next true if w == pid
|
|
52
|
+
|
|
53
|
+
false
|
|
54
|
+
rescue Errno::ECHILD
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
break if pending.empty?
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
sleep(0.05)
|
|
61
|
+
rescue Interrupt
|
|
62
|
+
unless force_note
|
|
63
|
+
force_note = true
|
|
64
|
+
Polyrun::Log.orchestration_warn "polyrun run-shards: repeated SIGINT during worker cleanup — SIGKILL to #{pending.size} process(es)"
|
|
65
|
+
end
|
|
66
|
+
pending.each do |pid|
|
|
67
|
+
Process.kill(:KILL, pid)
|
|
68
|
+
rescue Errno::ESRCH
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -41,9 +41,10 @@ module Polyrun
|
|
|
41
41
|
def self.track_blob_for_finish(cfg, blob)
|
|
42
42
|
sharded = ENV["POLYRUN_SHARD_TOTAL"].to_i > 1
|
|
43
43
|
if cfg[:track_files]
|
|
44
|
-
|
|
44
|
+
filtered = TrackFiles.keep_tracked_files(blob, cfg[:root], cfg[:track_files])
|
|
45
|
+
return filtered if sharded
|
|
45
46
|
|
|
46
|
-
TrackFiles.merge_untracked_into_blob(
|
|
47
|
+
TrackFiles.merge_untracked_into_blob(filtered, cfg[:root], cfg[:track_files])
|
|
47
48
|
else
|
|
48
49
|
Collector.keep_under_root(blob, cfg[:root], cfg[:track_under])
|
|
49
50
|
end
|
|
@@ -106,7 +106,8 @@ module Polyrun
|
|
|
106
106
|
def write_files(result, output_dir, basename)
|
|
107
107
|
path = File.join(output_dir, "#{basename}.html")
|
|
108
108
|
title = (result.meta && result.meta["title"]) || (result.meta && result.meta[:title]) || "Polyrun coverage"
|
|
109
|
-
|
|
109
|
+
root = result.meta && (result.meta["polyrun_coverage_root"] || result.meta[:polyrun_coverage_root])
|
|
110
|
+
File.write(path, Merge.emit_html(result.coverage_blob, title: title, root: root, groups: result.groups))
|
|
110
111
|
{html: path}
|
|
111
112
|
end
|
|
112
113
|
end
|
|
@@ -1,55 +1,203 @@
|
|
|
1
|
+
# rubocop:disable Polyrun/FileLength -- HTML merge formatter + helpers in one file
|
|
1
2
|
require "cgi"
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
require "erb"
|
|
5
|
+
require "pathname"
|
|
2
6
|
|
|
3
7
|
module Polyrun
|
|
4
8
|
module Coverage
|
|
5
9
|
module Merge
|
|
6
10
|
module_function
|
|
7
11
|
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
# Standalone HTML report with summary, file table, and per-file source details.
|
|
13
|
+
# rubocop:disable Metrics/AbcSize -- linear assembly of overview, file table, sections, asset reads
|
|
14
|
+
def emit_html(coverage_blob, title: "Polyrun coverage", root: nil, groups: nil, generated_at: Time.now)
|
|
15
|
+
files = coverage_blob.keys.sort.map { |path| html_file_payload(path, coverage_blob[path], root) }
|
|
16
|
+
summary = html_summary(files)
|
|
17
|
+
groups_html = render_html_partial("groups_table", group_rows_html: html_group_rows(groups).join("\n"))
|
|
18
|
+
overview_html = render_html_partial(
|
|
19
|
+
"overview",
|
|
20
|
+
summary: summary,
|
|
21
|
+
summary_badge_class: html_badge_class(summary[:line_percent]),
|
|
22
|
+
groups_html: groups_html
|
|
23
|
+
)
|
|
24
|
+
file_list_html = render_html_partial("file_list", file_rows_html: files.map { |file| html_file_list_row(file) }.join("\n"))
|
|
25
|
+
file_sections_html = files.map { |file| render_html_partial("file_section", file: file) }.join("\n")
|
|
26
|
+
ERB.new(File.read(html_template_path), trim_mode: "-").result_with_hash(
|
|
27
|
+
title: CGI.escapeHTML(title.to_s),
|
|
28
|
+
generated_label: html_generated_label(generated_at),
|
|
29
|
+
overview_html: overview_html,
|
|
30
|
+
file_list_html: file_list_html,
|
|
31
|
+
file_sections_html: file_sections_html,
|
|
32
|
+
stylesheet: File.read(html_stylesheet_path),
|
|
33
|
+
javascript: File.read(html_javascript_path)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
# rubocop:enable Metrics/AbcSize
|
|
37
|
+
|
|
38
|
+
def html_asset_dir
|
|
39
|
+
File.join(__dir__, "html")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def html_template_path
|
|
43
|
+
File.join(html_asset_dir, "template.html.erb")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def html_stylesheet_path
|
|
47
|
+
File.join(html_asset_dir, "report.css")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def html_javascript_path
|
|
51
|
+
File.join(html_asset_dir, "report.js")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def html_partial_path(name)
|
|
55
|
+
File.join(html_asset_dir, "_#{name}.html.erb")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render_html_partial(name, locals = {})
|
|
59
|
+
ERB.new(File.read(html_partial_path(name)), trim_mode: "-").result_with_hash(locals)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def html_file_payload(path, file, root)
|
|
63
|
+
line_arr = line_array_from_file_entry(file) || []
|
|
64
|
+
source_lines = html_source_lines(path, line_arr.length)
|
|
65
|
+
counts = line_counts(file)
|
|
66
|
+
relevant = counts[:relevant]
|
|
67
|
+
covered = counts[:covered]
|
|
68
|
+
total_hits = line_arr.sum { |hit| html_numeric_hit(hit) || 0 }
|
|
69
|
+
pct = relevant.positive? ? (100.0 * covered / relevant) : 0.0
|
|
70
|
+
{
|
|
71
|
+
id: "file-#{Digest::SHA1.hexdigest(path.to_s)}",
|
|
72
|
+
path: path.to_s,
|
|
73
|
+
display_path: html_display_path(path, root),
|
|
74
|
+
badge_class: html_badge_class(pct),
|
|
75
|
+
total_lines: [source_lines.length, line_arr.length].max,
|
|
76
|
+
relevant: relevant,
|
|
77
|
+
covered: covered,
|
|
78
|
+
missed: relevant - covered,
|
|
79
|
+
avg_hits: relevant.positive? ? (total_hits.to_f / relevant) : 0.0,
|
|
80
|
+
line_percent: pct,
|
|
81
|
+
source_rows: html_source_rows(source_lines, line_arr)
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def html_summary(files)
|
|
86
|
+
relevant = files.sum { |file| file[:relevant] }
|
|
87
|
+
covered = files.sum { |file| file[:covered] }
|
|
88
|
+
total_hits = files.sum { |file| file[:avg_hits] * file[:relevant] }
|
|
89
|
+
{
|
|
90
|
+
files: files.length,
|
|
91
|
+
lines_relevant: relevant,
|
|
92
|
+
lines_covered: covered,
|
|
93
|
+
lines_missed: relevant - covered,
|
|
94
|
+
line_percent: relevant.positive? ? (100.0 * covered / relevant) : 0.0,
|
|
95
|
+
avg_hits: relevant.positive? ? (total_hits.to_f / relevant) : 0.0
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def html_group_rows(groups)
|
|
100
|
+
return [] unless groups.is_a?(Hash) && !groups.empty?
|
|
101
|
+
|
|
102
|
+
groups.map do |name, data|
|
|
103
|
+
pct = html_group_percent(data)
|
|
104
|
+
<<~ROW.strip
|
|
105
|
+
<tr>
|
|
106
|
+
<td>#{CGI.escapeHTML(name.to_s)}</td>
|
|
107
|
+
<td class="cell--number"><span class="badge #{html_badge_class(pct)}">#{format("%.2f", pct)}%</span></td>
|
|
108
|
+
</tr>
|
|
109
|
+
ROW
|
|
17
110
|
end
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
</
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def html_group_percent(data)
|
|
114
|
+
return 0.0 unless data.is_a?(Hash)
|
|
115
|
+
|
|
116
|
+
lines = data["lines"] || data[:lines]
|
|
117
|
+
pct = lines.is_a?(Hash) ? (lines["covered_percent"] || lines[:covered_percent]) : nil
|
|
118
|
+
pct.to_f
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def html_file_list_row(file)
|
|
122
|
+
<<~ROW.strip
|
|
123
|
+
<tr class="t-file">
|
|
124
|
+
<td class="strong t-file__name"><a href="##{file[:id]}" class="src_link" title="#{CGI.escapeHTML(file[:display_path])}">#{CGI.escapeHTML(file[:display_path])}</a></td>
|
|
125
|
+
<td class="cell--number"><span class="badge #{html_badge_class(file[:line_percent])}">#{format("%.2f", file[:line_percent])}%</span></td>
|
|
126
|
+
<td class="cell--number">#{file[:total_lines]}</td>
|
|
127
|
+
<td class="cell--number">#{file[:relevant]}</td>
|
|
128
|
+
<td class="cell--number">#{file[:covered]}</td>
|
|
129
|
+
<td class="cell--number">#{file[:missed]}</td>
|
|
130
|
+
<td class="cell--number">#{format("%.2f", file[:avg_hits])}</td>
|
|
131
|
+
</tr>
|
|
132
|
+
ROW
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def html_source_rows(source_lines, line_arr)
|
|
136
|
+
max_len = [source_lines.length, line_arr.length].max
|
|
137
|
+
Array.new(max_len) do |idx|
|
|
138
|
+
source = source_lines[idx] || ""
|
|
139
|
+
hit = line_arr[idx]
|
|
140
|
+
css = html_line_class(hit)
|
|
141
|
+
hits_label = html_hit_label(hit)
|
|
142
|
+
<<~ROW.strip
|
|
143
|
+
<tr class="#{css}">
|
|
144
|
+
<td class="line-num">#{idx + 1}</td>
|
|
145
|
+
<td class="line-hits">#{hits_label}</td>
|
|
146
|
+
<td class="line-source"><code>#{source.empty? ? " " : CGI.escapeHTML(source)}</code></td>
|
|
147
|
+
</tr>
|
|
148
|
+
ROW
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def html_source_lines(path, fallback_length)
|
|
153
|
+
return Array.new(fallback_length, "") unless File.file?(path.to_s)
|
|
154
|
+
|
|
155
|
+
File.readlines(path.to_s, chomp: true)
|
|
156
|
+
rescue Errno::ENOENT, Errno::EACCES, ArgumentError
|
|
157
|
+
Array.new(fallback_length, "")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def html_display_path(path, root)
|
|
161
|
+
p = File.expand_path(path.to_s)
|
|
162
|
+
return p if root.nil? || root.to_s.empty?
|
|
163
|
+
|
|
164
|
+
Pathname.new(p).relative_path_from(Pathname.new(File.expand_path(root.to_s))).to_s
|
|
165
|
+
rescue ArgumentError
|
|
166
|
+
p
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def html_generated_label(generated_at)
|
|
170
|
+
t = generated_at.is_a?(Time) ? generated_at : Time.now
|
|
171
|
+
t.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def html_badge_class(percent)
|
|
175
|
+
return "green" if percent >= 90.0
|
|
176
|
+
return "yellow" if percent >= 80.0
|
|
177
|
+
|
|
178
|
+
"red"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def html_numeric_hit(hit)
|
|
182
|
+
return nil if hit.nil? || hit == "ignored"
|
|
183
|
+
|
|
184
|
+
hit.to_i
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def html_hit_label(hit)
|
|
188
|
+
return "" if hit.nil?
|
|
189
|
+
return "ignored" if hit == "ignored"
|
|
190
|
+
|
|
191
|
+
hit.to_i.to_s
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def html_line_class(hit)
|
|
195
|
+
return "line-none" if hit.nil?
|
|
196
|
+
return "line-ignored" if hit == "ignored"
|
|
197
|
+
|
|
198
|
+
hit.to_i.positive? ? "line-covered" : "line-missed"
|
|
52
199
|
end
|
|
53
200
|
end
|
|
54
201
|
end
|
|
55
202
|
end
|
|
203
|
+
# rubocop:enable Polyrun/FileLength
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<div class="panel file_list_container" id="file-list">
|
|
2
|
+
<h2>Files</h2>
|
|
3
|
+
<div class="file_list--responsive">
|
|
4
|
+
<table class="file_list" data-sortable="true">
|
|
5
|
+
<thead>
|
|
6
|
+
<tr>
|
|
7
|
+
<th>File</th>
|
|
8
|
+
<th class="cell--number">% covered</th>
|
|
9
|
+
<th class="cell--number">Lines</th>
|
|
10
|
+
<th class="cell--number">Relevant Lines</th>
|
|
11
|
+
<th class="cell--number">Lines covered</th>
|
|
12
|
+
<th class="cell--number">Lines missed</th>
|
|
13
|
+
<th class="cell--number">Avg. Hits / Line</th>
|
|
14
|
+
</tr>
|
|
15
|
+
</thead>
|
|
16
|
+
<tbody>
|
|
17
|
+
<%= file_rows_html %>
|
|
18
|
+
</tbody>
|
|
19
|
+
</table>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<div class="panel file-section source_file" id="<%= file[:id] %>">
|
|
2
|
+
<div class="file-heading">
|
|
3
|
+
<h2><%= CGI.escapeHTML(file[:display_path]) %></h2>
|
|
4
|
+
<a href="#overview">Back to summary</a>
|
|
5
|
+
</div>
|
|
6
|
+
<div class="file-meta">
|
|
7
|
+
<span class="badge <%= file[:badge_class] %>"><%= format("%.2f", file[:line_percent]) %>%</span>
|
|
8
|
+
<%= file[:covered] %> / <%= file[:relevant] %> relevant lines covered,
|
|
9
|
+
<%= file[:missed] %> missed,
|
|
10
|
+
<%= format("%.2f", file[:avg_hits]) %> avg. hits / line
|
|
11
|
+
</div>
|
|
12
|
+
<div class="file_list--responsive">
|
|
13
|
+
<table class="source_table">
|
|
14
|
+
<thead>
|
|
15
|
+
<tr>
|
|
16
|
+
<th class="line-num">Line</th>
|
|
17
|
+
<th class="line-hits">Hits</th>
|
|
18
|
+
<th class="line-source">Source</th>
|
|
19
|
+
</tr>
|
|
20
|
+
</thead>
|
|
21
|
+
<tbody>
|
|
22
|
+
<%= file[:source_rows].join("\n") %>
|
|
23
|
+
</tbody>
|
|
24
|
+
</table>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<% unless group_rows_html.empty? %>
|
|
2
|
+
<div class="group-summary">
|
|
3
|
+
<h3>Groups</h3>
|
|
4
|
+
<div class="file_list--responsive">
|
|
5
|
+
<table class="group-table" data-sortable="true">
|
|
6
|
+
<thead>
|
|
7
|
+
<tr>
|
|
8
|
+
<th>Group</th>
|
|
9
|
+
<th class="cell--number">% covered</th>
|
|
10
|
+
</tr>
|
|
11
|
+
</thead>
|
|
12
|
+
<tbody>
|
|
13
|
+
<%= group_rows_html %>
|
|
14
|
+
</tbody>
|
|
15
|
+
</table>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<div class="panel file_list_container" id="overview">
|
|
2
|
+
<h2>
|
|
3
|
+
<span class="group_name">All Files</span>
|
|
4
|
+
(<span class="covered_percent"><span class="<%= summary_badge_class %>"><%= format("%.2f", summary[:line_percent]) %>%</span></span>
|
|
5
|
+
covered at
|
|
6
|
+
<span class="covered_strength"><span class="<%= summary_badge_class %>"><%= format("%.2f", summary[:avg_hits]) %></span></span>
|
|
7
|
+
hits/line)
|
|
8
|
+
</h2>
|
|
9
|
+
|
|
10
|
+
<div><b><%= summary[:files] %></b> files in total.</div>
|
|
11
|
+
<div class="t-line-summary">
|
|
12
|
+
<b><%= summary[:lines_relevant] %></b> relevant lines,
|
|
13
|
+
<span class="green"><b><%= summary[:lines_covered] %></b> lines covered</span> and
|
|
14
|
+
<span class="red"><b><%= summary[:lines_missed] %></b> lines missed</span>
|
|
15
|
+
(<span class="<%= summary_badge_class %>"><%= format("%.2f", summary[:line_percent]) %>%</span>)
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="metrics">
|
|
19
|
+
<div class="metric">
|
|
20
|
+
<div class="metric-label">Coverage</div>
|
|
21
|
+
<div class="metric-value"><%= format("%.2f", summary[:line_percent]) %>%</div>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="metric">
|
|
24
|
+
<div class="metric-label">Relevant Lines</div>
|
|
25
|
+
<div class="metric-value"><%= summary[:lines_relevant] %></div>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="metric">
|
|
28
|
+
<div class="metric-label">Lines Covered</div>
|
|
29
|
+
<div class="metric-value"><%= summary[:lines_covered] %></div>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="metric">
|
|
32
|
+
<div class="metric-label">Lines Missed</div>
|
|
33
|
+
<div class="metric-value"><%= summary[:lines_missed] %></div>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="metric">
|
|
36
|
+
<div class="metric-label">Avg. Hits / Line</div>
|
|
37
|
+
<div class="metric-value"><%= format("%.2f", summary[:avg_hits]) %></div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<%= groups_html %>
|
|
42
|
+
|
|
43
|
+
<div class="nav-links">
|
|
44
|
+
<a href="#file-list">File List</a>
|
|
45
|
+
<a href="#source-files">Source Files</a>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
--bg: #f5f7fb;
|
|
4
|
+
--surface: #ffffff;
|
|
5
|
+
--border: #d7ddea;
|
|
6
|
+
--text: #1f2937;
|
|
7
|
+
--muted: #6b7280;
|
|
8
|
+
--green: #0f766e;
|
|
9
|
+
--green-bg: #ecfdf5;
|
|
10
|
+
--yellow: #b45309;
|
|
11
|
+
--yellow-bg: #fffbeb;
|
|
12
|
+
--red: #b91c1c;
|
|
13
|
+
--red-bg: #fef2f2;
|
|
14
|
+
--gray-bg: #f8fafc;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
|
|
19
|
+
html {
|
|
20
|
+
scroll-behavior: smooth;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
body {
|
|
24
|
+
margin: 0;
|
|
25
|
+
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
26
|
+
background: var(--bg);
|
|
27
|
+
color: var(--text);
|
|
28
|
+
line-height: 1.45;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
a { color: #1d4ed8; text-decoration: none; }
|
|
32
|
+
a:hover { text-decoration: underline; }
|
|
33
|
+
|
|
34
|
+
#wrapper { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
|
35
|
+
|
|
36
|
+
.topbar {
|
|
37
|
+
display: flex;
|
|
38
|
+
justify-content: space-between;
|
|
39
|
+
gap: 1rem;
|
|
40
|
+
align-items: baseline;
|
|
41
|
+
margin-bottom: 1rem;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.timestamp { color: var(--muted); font-size: 0.9rem; }
|
|
45
|
+
|
|
46
|
+
.panel {
|
|
47
|
+
background: var(--surface);
|
|
48
|
+
border: 1px solid var(--border);
|
|
49
|
+
border-radius: 12px;
|
|
50
|
+
padding: 1rem 1.25rem;
|
|
51
|
+
margin-bottom: 1rem;
|
|
52
|
+
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.metrics {
|
|
56
|
+
display: grid;
|
|
57
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
58
|
+
gap: 0.75rem;
|
|
59
|
+
margin-top: 1rem;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.metric {
|
|
63
|
+
border: 1px solid var(--border);
|
|
64
|
+
border-radius: 10px;
|
|
65
|
+
padding: 0.85rem 1rem;
|
|
66
|
+
background: var(--gray-bg);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.metric-label { color: var(--muted); font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
|
70
|
+
.metric-value { font-size: 1.4rem; font-weight: 700; margin-top: 0.2rem; }
|
|
71
|
+
.summary-line, .t-line-summary { margin-top: 0.75rem; color: var(--muted); }
|
|
72
|
+
.group-summary { margin-top: 1rem; }
|
|
73
|
+
|
|
74
|
+
table { width: 100%; border-collapse: collapse; }
|
|
75
|
+
|
|
76
|
+
th, td { padding: 0.55rem 0.7rem; border-top: 1px solid var(--border); vertical-align: top; }
|
|
77
|
+
|
|
78
|
+
thead th {
|
|
79
|
+
background: var(--gray-bg);
|
|
80
|
+
color: var(--muted);
|
|
81
|
+
font-size: 0.82rem;
|
|
82
|
+
text-transform: uppercase;
|
|
83
|
+
letter-spacing: 0.03em;
|
|
84
|
+
border-top: none;
|
|
85
|
+
position: sticky;
|
|
86
|
+
top: 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
th[data-sortable-column="true"] {
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
user-select: none;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
th[data-sort-direction="asc"]::after { content: " ↑"; }
|
|
95
|
+
th[data-sort-direction="desc"]::after { content: " ↓"; }
|
|
96
|
+
|
|
97
|
+
.cell--number { text-align: right; white-space: nowrap; }
|
|
98
|
+
|
|
99
|
+
.badge {
|
|
100
|
+
display: inline-block;
|
|
101
|
+
min-width: 72px;
|
|
102
|
+
text-align: center;
|
|
103
|
+
border-radius: 999px;
|
|
104
|
+
padding: 0.15rem 0.55rem;
|
|
105
|
+
font-weight: 700;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.green { color: var(--green); }
|
|
109
|
+
.yellow { color: var(--yellow); }
|
|
110
|
+
.red { color: var(--red); }
|
|
111
|
+
.badge.green { background: var(--green-bg); }
|
|
112
|
+
.badge.yellow { background: var(--yellow-bg); }
|
|
113
|
+
.badge.red { background: var(--red-bg); }
|
|
114
|
+
|
|
115
|
+
.file_list--responsive { overflow-x: auto; }
|
|
116
|
+
.file-section { margin-top: 1rem; }
|
|
117
|
+
|
|
118
|
+
.file-heading {
|
|
119
|
+
display: flex;
|
|
120
|
+
justify-content: space-between;
|
|
121
|
+
gap: 1rem;
|
|
122
|
+
align-items: baseline;
|
|
123
|
+
flex-wrap: wrap;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.file-meta { color: var(--muted); font-size: 0.95rem; }
|
|
127
|
+
|
|
128
|
+
.source_table td {
|
|
129
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
130
|
+
font-size: 0.83rem;
|
|
131
|
+
white-space: pre-wrap;
|
|
132
|
+
word-break: break-word;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.line-num, .line-hits {
|
|
136
|
+
width: 5rem;
|
|
137
|
+
color: var(--muted);
|
|
138
|
+
text-align: right;
|
|
139
|
+
white-space: nowrap;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.line-source { width: auto; }
|
|
143
|
+
.line-covered { background: rgba(16, 185, 129, 0.10); }
|
|
144
|
+
.line-missed { background: rgba(239, 68, 68, 0.10); }
|
|
145
|
+
.line-ignored, .line-none { background: transparent; }
|
|
146
|
+
.nav-links { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.75rem; }
|
|
147
|
+
.group-table td:first-child, .file_list td:first-child { width: 100%; }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
function cellValue(row, index) {
|
|
3
|
+
const cell = row.children[index];
|
|
4
|
+
return cell ? cell.textContent.trim() : "";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function sortableValue(text) {
|
|
8
|
+
const numeric = Number.parseFloat(text.replace(/[% ,]/g, ""));
|
|
9
|
+
return Number.isNaN(numeric) ? text.toLowerCase() : numeric;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sortTable(table, index, direction) {
|
|
13
|
+
const body = table.tBodies[0];
|
|
14
|
+
if (!body) return;
|
|
15
|
+
|
|
16
|
+
const rows = Array.from(body.rows);
|
|
17
|
+
rows.sort((left, right) => {
|
|
18
|
+
const a = sortableValue(cellValue(left, index));
|
|
19
|
+
const b = sortableValue(cellValue(right, index));
|
|
20
|
+
if (a < b) return direction === "asc" ? -1 : 1;
|
|
21
|
+
if (a > b) return direction === "asc" ? 1 : -1;
|
|
22
|
+
return 0;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
rows.forEach((row) => body.appendChild(row));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function installSortableTables() {
|
|
29
|
+
document.querySelectorAll("table[data-sortable='true']").forEach((table) => {
|
|
30
|
+
const headers = Array.from(table.tHead ? table.tHead.rows[0].cells : []);
|
|
31
|
+
headers.forEach((header, index) => {
|
|
32
|
+
header.dataset.sortableColumn = "true";
|
|
33
|
+
header.addEventListener("click", () => {
|
|
34
|
+
const nextDirection = header.dataset.sortDirection === "asc" ? "desc" : "asc";
|
|
35
|
+
headers.forEach((cell) => delete cell.dataset.sortDirection);
|
|
36
|
+
header.dataset.sortDirection = nextDirection;
|
|
37
|
+
sortTable(table, index, nextDirection);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (document.readyState === "loading") {
|
|
44
|
+
document.addEventListener("DOMContentLoaded", installSortableTables);
|
|
45
|
+
} else {
|
|
46
|
+
installSortableTables();
|
|
47
|
+
}
|
|
48
|
+
})();
|