constable-rails 0.1.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 +7 -0
- data/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +515 -0
- data/exe/constable +7 -0
- data/lib/constable/case.rb +336 -0
- data/lib/constable/cli.rb +475 -0
- data/lib/constable/cold_case/minitest.rb +342 -0
- data/lib/constable/cold_case/rspec.rb +334 -0
- data/lib/constable/cold_case.rb +280 -0
- data/lib/constable/config.rb +125 -0
- data/lib/constable/coverage.rb +951 -0
- data/lib/constable/diff.rb +212 -0
- data/lib/constable/dsl.rb +833 -0
- data/lib/constable/identity.rb +121 -0
- data/lib/constable/importer/modernizer.rb +860 -0
- data/lib/constable/importer/reopener.rb +468 -0
- data/lib/constable/importer.rb +51 -0
- data/lib/constable/investigation.rb +67 -0
- data/lib/constable/isolation.rb +171 -0
- data/lib/constable/jail.rb +399 -0
- data/lib/constable/log_router.rb +197 -0
- data/lib/constable/matchers.rb +834 -0
- data/lib/constable/order_audit.rb +130 -0
- data/lib/constable/rails_support.rb +213 -0
- data/lib/constable/railtie.rb +36 -0
- data/lib/constable/registry.rb +57 -0
- data/lib/constable/reporter.rb +625 -0
- data/lib/constable/result.rb +149 -0
- data/lib/constable/runner.rb +697 -0
- data/lib/constable/selection.rb +205 -0
- data/lib/constable/storage/adapter.rb +91 -0
- data/lib/constable/storage/mysql_adapter.rb +125 -0
- data/lib/constable/storage/postgres_adapter.rb +125 -0
- data/lib/constable/storage/sqlite_adapter.rb +84 -0
- data/lib/constable/storage.rb +847 -0
- data/lib/constable/version.rb +5 -0
- data/lib/constable/warrants.rb +290 -0
- data/lib/constable-rails.rb +16 -0
- data/lib/constable.rb +151 -0
- data/lib/generators/constable/base.rb +99 -0
- data/lib/generators/constable/channel/channel_generator.rb +20 -0
- data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
- data/lib/generators/constable/controller/controller_generator.rb +25 -0
- data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
- data/lib/generators/constable/generator/generator_generator.rb +31 -0
- data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
- data/lib/generators/constable/helper/helper_generator.rb +23 -0
- data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
- data/lib/generators/constable/import_generator.rb +137 -0
- data/lib/generators/constable/install_generator.rb +188 -0
- data/lib/generators/constable/integration/integration_generator.rb +27 -0
- data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
- data/lib/generators/constable/job/job_generator.rb +20 -0
- data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
- data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
- data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
- data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
- data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
- data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
- data/lib/generators/constable/model/model_generator.rb +31 -0
- data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
- data/lib/generators/constable/resource/resource_generator.rb +27 -0
- data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
- data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
- data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
- data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
- data/lib/generators/constable/system/system_generator.rb +20 -0
- data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
- data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
- data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
- data/lib/generators/constable/templates/config.yml.tt +67 -0
- data/lib/generators/constable/templates/example_case.rb.tt +56 -0
- data/lib/generators/constable/templates/matchers.rb.tt +36 -0
- data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
- metadata +209 -0
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "coverage"
|
|
4
|
+
require "set"
|
|
5
|
+
require "cgi"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "constable/diff"
|
|
8
|
+
|
|
9
|
+
module Constable
|
|
10
|
+
# Coverage -- "the beat".
|
|
11
|
+
#
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# A NAMING TRAP, READ THIS FIRST
|
|
14
|
+
#
|
|
15
|
+
# This module is `Constable::Coverage`. Ruby's built-in line-coverage module is
|
|
16
|
+
# `::Coverage`. Inside `module Constable`, a bare `Coverage` resolves to *this* module,
|
|
17
|
+
# so `Coverage.start(lines: true)` written anywhere in this file would call a method on
|
|
18
|
+
# ourselves and fail in a way that reads like a bug in Ruby. **Every reference to the
|
|
19
|
+
# stdlib is spelled `::Coverage`, without exception.** Keep it that way.
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
#
|
|
22
|
+
# We wrap Ruby's own `Coverage` module rather than SimpleCov, and that is the whole
|
|
23
|
+
# trick behind coverage working uniformly here: **`::Coverage` measures the process, not
|
|
24
|
+
# the test framework.** It hooks line execution in the VM. It has no idea whether the
|
|
25
|
+
# line was reached by a native `investigate` block, by the real RSpec engine running a
|
|
26
|
+
# cold case, or by a `require` at boot. So native cases and cold cases contribute to one
|
|
27
|
+
# set of numbers automatically, with no per-engine instrumentation and nothing to keep
|
|
28
|
+
# in sync when a new engine adapter appears.
|
|
29
|
+
#
|
|
30
|
+
# What is *not* uniform is the gate. The enforced threshold is diff-based, not blanket:
|
|
31
|
+
# `coverage_threshold: 90` is checked only against lines changed versus the merge base,
|
|
32
|
+
# the same git-diff philosophy the Runner uses to decide what to run locally. Legacy
|
|
33
|
+
# gaps stay visible in the report without blocking the build; new code is held to the
|
|
34
|
+
# bar. And cold cases, which opt out of Constable's rules everywhere else, opt out here
|
|
35
|
+
# too: they contribute numbers, they are not held to the gate.
|
|
36
|
+
module Coverage
|
|
37
|
+
# Our own lib/, so the gem never measures itself.
|
|
38
|
+
GEM_LIB = File.expand_path("..", __dir__)
|
|
39
|
+
|
|
40
|
+
# Directory segments that are never application code.
|
|
41
|
+
EXCLUDED_DIRS = %w[
|
|
42
|
+
test spec features vendor tmp node_modules coverage log pkg
|
|
43
|
+
.git .constable .bundle
|
|
44
|
+
].to_set.freeze
|
|
45
|
+
|
|
46
|
+
EXCLUDED_BASENAMES = %w[
|
|
47
|
+
case_helper.rb test_helper.rb spec_helper.rb rails_helper.rb
|
|
48
|
+
].to_set.freeze
|
|
49
|
+
|
|
50
|
+
EXCLUDED_BASENAME_PATTERN = /_(?:test|spec)\.rb\z/
|
|
51
|
+
|
|
52
|
+
# Lines a heuristic scan treats as not executable, used only for files that never got
|
|
53
|
+
# loaded at all (see Report#synthesize_missing).
|
|
54
|
+
NON_EXECUTABLE = /\A(?:end|else|ensure|begin|rescue.*|\}|\)|\]|__END__)\z/
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
# Begins measuring, if the run asked for it. Returns true when measurement is
|
|
58
|
+
# active afterwards (whoever started it), false when coverage is switched off.
|
|
59
|
+
#
|
|
60
|
+
# Coverage.start!(config: Constable.config)
|
|
61
|
+
# Coverage.start!(config: config, force: true) # `constable beat`, ignoring the setting
|
|
62
|
+
#
|
|
63
|
+
# If something else -- SimpleCov in the app's own rails_helper, a CI wrapper -- has
|
|
64
|
+
# already started `::Coverage`, we attach to their measurement instead of starting a
|
|
65
|
+
# second one (there is only one, process-wide) and take care never to stop it out
|
|
66
|
+
# from under them.
|
|
67
|
+
def start!(config: Constable.config, force: false)
|
|
68
|
+
return false unless force || config.coverage?
|
|
69
|
+
return true if active?
|
|
70
|
+
|
|
71
|
+
if ::Coverage.running?
|
|
72
|
+
@external = true
|
|
73
|
+
else
|
|
74
|
+
::Coverage.start(lines: true)
|
|
75
|
+
@external = false
|
|
76
|
+
end
|
|
77
|
+
@active = true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Ends measurement and returns a Report, or nil if we were never measuring.
|
|
81
|
+
#
|
|
82
|
+
# report = Coverage.stop!(config: config)
|
|
83
|
+
# report = Coverage.stop!(config: config, gate: false) # cold-only run
|
|
84
|
+
# report = Coverage.stop!(config: config, exempt: cold_files) # per-file exemptions
|
|
85
|
+
def stop!(config: Constable.config, root: Constable.root, exempt: [], gate: true,
|
|
86
|
+
since: nil, changed_lines: :detect)
|
|
87
|
+
raw = harvest(stop: true)
|
|
88
|
+
return nil if raw.nil?
|
|
89
|
+
|
|
90
|
+
build_report(raw, config: config, root: root, exempt: exempt, gate: gate,
|
|
91
|
+
since: since, changed_lines: changed_lines)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A Report from the numbers so far, leaving measurement running. Useful for a
|
|
95
|
+
# mid-run snapshot; harmless to call when coverage is off (returns nil).
|
|
96
|
+
def peek(config: Constable.config, root: Constable.root, exempt: [], gate: true,
|
|
97
|
+
since: nil, changed_lines: :detect)
|
|
98
|
+
raw = harvest(stop: false)
|
|
99
|
+
return nil if raw.nil?
|
|
100
|
+
|
|
101
|
+
build_report(raw, config: config, root: root, exempt: exempt, gate: gate,
|
|
102
|
+
since: since, changed_lines: changed_lines)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Stops measurement without producing a report -- for teardown paths that just want
|
|
106
|
+
# the hook off again. Never stops a measurement somebody else started.
|
|
107
|
+
def abort!
|
|
108
|
+
::Coverage.result(stop: true, clear: true) if @active && !@external && ::Coverage.running?
|
|
109
|
+
true
|
|
110
|
+
ensure
|
|
111
|
+
@active = false
|
|
112
|
+
@external = false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def active? = @active == true
|
|
116
|
+
def external? = @external == true
|
|
117
|
+
|
|
118
|
+
# Builds a Report from a raw `::Coverage` result hash. Public because it is the
|
|
119
|
+
# seam every test and every alternative front-end goes through: hand it a synthetic
|
|
120
|
+
# `{ "path.rb" => [1, 0, nil] }` and you get the same Report the real thing produces.
|
|
121
|
+
#
|
|
122
|
+
# raw - `::Coverage` result: { path => [hits...] } or { path => { lines: [...] } }
|
|
123
|
+
# exempt - paths/globs excluded from the diff gate (cold-case files)
|
|
124
|
+
# gate - false disables the diff gate entirely (a cold-case-only run)
|
|
125
|
+
# changed_lines - :detect asks Diff; nil means "no diff info"; a Hash is used as given
|
|
126
|
+
def build_report(raw, config: Constable.config, root: Constable.root, exempt: [], gate: true,
|
|
127
|
+
since: nil, changed_lines: :detect)
|
|
128
|
+
root = resolve_root(root)
|
|
129
|
+
files = normalize(raw, config: config, root: root)
|
|
130
|
+
changed = resolve_changed_lines(changed_lines, root: root, since: since)
|
|
131
|
+
|
|
132
|
+
Report.new(files: files, root: root, config: config, changed_lines: changed,
|
|
133
|
+
exempt: exempt, gate: gate)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Persists one snapshot per run so `constable status` can plot a trend.
|
|
137
|
+
def record!(report, run_id, storage: Constable.storage)
|
|
138
|
+
return report if report.nil? || run_id.nil?
|
|
139
|
+
|
|
140
|
+
storage.record_coverage(run_id, percent: report.percent, files: report.files.map(&:to_h))
|
|
141
|
+
report
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def default_html_path(root: Constable.root)
|
|
145
|
+
File.join(root.to_s, ".constable", "coverage", "index.html")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Writes the browsable report and returns the path written.
|
|
149
|
+
def write_html(report, path: nil, root: Constable.root)
|
|
150
|
+
path ||= default_html_path(root: root)
|
|
151
|
+
require "fileutils"
|
|
152
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
153
|
+
File.write(path, report.to_html)
|
|
154
|
+
path
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Is this path application code we should be measuring? Excludes the gem itself,
|
|
158
|
+
# installed gems, the stdlib, and the suite's own test/spec files -- a test file's
|
|
159
|
+
# coverage of itself is noise, not information.
|
|
160
|
+
def application_file?(path, root: Constable.root, config: nil)
|
|
161
|
+
path = path.to_s
|
|
162
|
+
return false unless path.end_with?(".rb")
|
|
163
|
+
|
|
164
|
+
absolute = File.expand_path(path)
|
|
165
|
+
relative = relative_to(absolute, root)
|
|
166
|
+
return false if relative.nil?
|
|
167
|
+
return false if absolute.start_with?("#{GEM_LIB}/")
|
|
168
|
+
return false if absolute.include?("/gems/") || absolute.include?("/vendor/bundle/")
|
|
169
|
+
return false if stdlib?(absolute)
|
|
170
|
+
|
|
171
|
+
segments = relative.split("/")
|
|
172
|
+
basename = segments.pop
|
|
173
|
+
return false if segments.any? { |segment| EXCLUDED_DIRS.include?(segment) }
|
|
174
|
+
return false if EXCLUDED_BASENAMES.include?(basename) || EXCLUDED_BASENAME_PATTERN.match?(basename)
|
|
175
|
+
|
|
176
|
+
excluded = extra_excludes(config).any? do |glob|
|
|
177
|
+
File.fnmatch?(glob, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
178
|
+
end
|
|
179
|
+
return false if excluded
|
|
180
|
+
|
|
181
|
+
true
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# --- internals -----------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
# `::Coverage` reports the paths Ruby actually loaded, which are symlink-resolved.
|
|
187
|
+
# A root of /var/folders/... and a coverage path of /private/var/folders/... are the
|
|
188
|
+
# same directory on macOS and must not be treated as different ones.
|
|
189
|
+
def resolve_root(root)
|
|
190
|
+
File.realpath(root.to_s)
|
|
191
|
+
rescue SystemCallError
|
|
192
|
+
File.expand_path(root.to_s)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# A path's location under the root, or nil when it isn't under it at all. Tries
|
|
196
|
+
# both spellings of the root -- symlinked (/var/...) and resolved (/private/var/...)
|
|
197
|
+
# -- because Ruby hands out one and the caller usually holds the other.
|
|
198
|
+
def relative_to(path, root)
|
|
199
|
+
absolute = File.expand_path(path.to_s)
|
|
200
|
+
[File.expand_path(root.to_s), resolve_root(root)].uniq.each do |prefix|
|
|
201
|
+
return absolute.delete_prefix("#{prefix}/") if absolute.start_with?("#{prefix}/")
|
|
202
|
+
end
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The raw stdlib numbers as they stand, without ending the measurement. Workers use
|
|
207
|
+
# this to send their counts home: Ruby's Coverage is per-process, so a forked worker's
|
|
208
|
+
# hits exist only in that worker and would otherwise be thrown away when it exits.
|
|
209
|
+
def peek_raw
|
|
210
|
+
return nil unless ::Coverage.running?
|
|
211
|
+
|
|
212
|
+
::Coverage.peek_result
|
|
213
|
+
rescue StandardError
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Sums two raw coverage results. Line arrays are added element-wise; nil means the
|
|
218
|
+
# line isn't executable and stays nil, which is not the same as zero and must not
|
|
219
|
+
# become it -- a nil turned into a 0 invents an uncovered line that never existed.
|
|
220
|
+
def merge_raw(left, right)
|
|
221
|
+
merged = (left || {}).dup
|
|
222
|
+
|
|
223
|
+
(right || {}).each do |path, entry|
|
|
224
|
+
existing = merged[path]
|
|
225
|
+
merged[path] = existing.nil? ? entry : merge_entry(existing, entry)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
merged
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def merge_entry(left, right)
|
|
232
|
+
if left.is_a?(Hash) || right.is_a?(Hash)
|
|
233
|
+
lines = merge_lines(left.is_a?(Hash) ? left[:lines] : left,
|
|
234
|
+
right.is_a?(Hash) ? right[:lines] : right)
|
|
235
|
+
return (left.is_a?(Hash) ? left : right).merge(lines: lines)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
merge_lines(left, right)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def merge_lines(left, right)
|
|
242
|
+
return right if left.nil?
|
|
243
|
+
return left if right.nil?
|
|
244
|
+
|
|
245
|
+
[left.size, right.size].max.times.map do |i|
|
|
246
|
+
a = left[i]
|
|
247
|
+
b = right[i]
|
|
248
|
+
next nil if a.nil? && b.nil?
|
|
249
|
+
|
|
250
|
+
a.to_i + b.to_i
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Reads the numbers out of the stdlib. When someone else owns the measurement we
|
|
255
|
+
# peek rather than stop, so their own report still comes out right at exit.
|
|
256
|
+
def harvest(stop:)
|
|
257
|
+
return nil unless active?
|
|
258
|
+
return nil unless ::Coverage.running?
|
|
259
|
+
|
|
260
|
+
if external? || !stop
|
|
261
|
+
::Coverage.peek_result
|
|
262
|
+
else
|
|
263
|
+
::Coverage.result(stop: true, clear: true)
|
|
264
|
+
end
|
|
265
|
+
ensure
|
|
266
|
+
if stop
|
|
267
|
+
@active = false
|
|
268
|
+
@external = false
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def normalize(raw, config:, root:)
|
|
273
|
+
(raw || {}).filter_map do |path, entry|
|
|
274
|
+
path = real_path(path)
|
|
275
|
+
next unless application_file?(path, root: root, config: config)
|
|
276
|
+
|
|
277
|
+
lines = entry.is_a?(Hash) ? (entry[:lines] || entry["lines"]) : entry
|
|
278
|
+
next if lines.nil?
|
|
279
|
+
|
|
280
|
+
FileCoverage.new(path: path, lines: lines, root: root)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# `load "/var/tmp/x.rb"` is recorded by `::Coverage` under exactly the path it was
|
|
285
|
+
# given, symlinks and all. Resolving each measured file once keeps every later
|
|
286
|
+
# comparison -- against the root, against the diff -- an ordinary string compare.
|
|
287
|
+
def real_path(path)
|
|
288
|
+
File.realpath(path.to_s)
|
|
289
|
+
rescue SystemCallError
|
|
290
|
+
File.expand_path(path.to_s)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def resolve_changed_lines(changed_lines, root:, since:)
|
|
294
|
+
case changed_lines
|
|
295
|
+
when :detect then Diff.changed_lines(since: since, root: root, absolute: true)
|
|
296
|
+
when nil then nil
|
|
297
|
+
else normalize_changed_lines(changed_lines, root: root)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Accepts relative or absolute keys, Arrays or Sets of line numbers.
|
|
302
|
+
def normalize_changed_lines(hash, root:)
|
|
303
|
+
hash.each_with_object({}) do |(path, numbers), out|
|
|
304
|
+
out[File.expand_path(path.to_s, root)] = numbers.is_a?(Set) ? numbers : Set.new(Array(numbers))
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def stdlib?(absolute)
|
|
309
|
+
%w[rubylibdir libdir sitelibdir vendorlibdir archdir].any? do |key|
|
|
310
|
+
dir = RbConfig::CONFIG[key]
|
|
311
|
+
dir && !dir.empty? && absolute.start_with?("#{dir}/")
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Undocumented-but-supported escape valve: `coverage_exclude:` in config.yml takes
|
|
316
|
+
# a list of globs for generated or vendored code that lives inside the app tree.
|
|
317
|
+
def extra_excludes(config)
|
|
318
|
+
return [] unless config.respond_to?(:[])
|
|
319
|
+
|
|
320
|
+
Array(config["coverage_exclude"]).map(&:to_s)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# One file's line coverage. `lines` is `::Coverage`'s own array: an Integer hit count
|
|
325
|
+
# per executable line, nil for a line that can never execute (blank, comment, `end`).
|
|
326
|
+
class FileCoverage
|
|
327
|
+
attr_reader :path, :lines, :root
|
|
328
|
+
|
|
329
|
+
def initialize(path:, lines:, root: Constable.root)
|
|
330
|
+
@path = path.to_s
|
|
331
|
+
@lines = Array(lines)
|
|
332
|
+
@root = Coverage.resolve_root(root)
|
|
333
|
+
@synthesized = false
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# True when this entry was inferred rather than measured -- a changed file that was
|
|
337
|
+
# never loaded at all, so `::Coverage` had nothing to say about it.
|
|
338
|
+
def synthesized? = @synthesized
|
|
339
|
+
|
|
340
|
+
def synthesized! # :nodoc:
|
|
341
|
+
@synthesized = true
|
|
342
|
+
self
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def relative_path
|
|
346
|
+
@relative_path ||= Coverage.relative_to(@path, @root) || @path
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Number of executable lines.
|
|
350
|
+
def relevant = @lines.count { |hits| !hits.nil? }
|
|
351
|
+
|
|
352
|
+
# Number of executable lines that ran at least once.
|
|
353
|
+
def covered = @lines.count { |hits| !hits.nil? && hits.positive? }
|
|
354
|
+
|
|
355
|
+
def missed = relevant - covered
|
|
356
|
+
|
|
357
|
+
# 1-based line numbers, for the report and the gate.
|
|
358
|
+
def missed_lines = line_numbers { |hits| !hits.nil? && hits.zero? }
|
|
359
|
+
def covered_lines = line_numbers { |hits| !hits.nil? && hits.positive? }
|
|
360
|
+
def relevant_lines = line_numbers { |hits| !hits.nil? }
|
|
361
|
+
|
|
362
|
+
# 1-based line numbers whose hit count satisfies the block.
|
|
363
|
+
def line_numbers
|
|
364
|
+
numbers = []
|
|
365
|
+
@lines.each_with_index { |hits, index| numbers << (index + 1) if yield(hits) }
|
|
366
|
+
numbers
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def hits_for(number) = @lines[number - 1]
|
|
370
|
+
def executable?(number) = !@lines[number - 1].nil?
|
|
371
|
+
def covered?(number) = (@lines[number - 1] || 0).positive?
|
|
372
|
+
|
|
373
|
+
# A file with no executable lines at all is vacuously complete, not a gap.
|
|
374
|
+
def percent
|
|
375
|
+
return 100.0 if relevant.zero?
|
|
376
|
+
|
|
377
|
+
((covered.to_f / relevant) * 100).round(2)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# "Unpatrolled" -- zero executed lines. Named separately from a merely thin file
|
|
381
|
+
# because a 0% file is usually a file nobody remembered to test at all, and that is
|
|
382
|
+
# a different problem from a file whose edges are uncovered.
|
|
383
|
+
def unpatrolled? = relevant.positive? && covered.zero?
|
|
384
|
+
|
|
385
|
+
# The file's text, for the HTML line view. Missing/unreadable files render empty
|
|
386
|
+
# rather than blowing up a report someone is waiting on.
|
|
387
|
+
def source_lines
|
|
388
|
+
@source_lines ||= begin
|
|
389
|
+
File.readlines(@path, chomp: true).map { |line| line.dup.force_encoding(Encoding::UTF_8).scrub }
|
|
390
|
+
rescue SystemCallError, IOError
|
|
391
|
+
[]
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def to_h
|
|
396
|
+
{
|
|
397
|
+
path: relative_path, percent: percent, relevant: relevant,
|
|
398
|
+
covered: covered, missed: missed, unpatrolled: unpatrolled?,
|
|
399
|
+
missed_lines: missed_lines, synthesized: synthesized?
|
|
400
|
+
}
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# The full picture for one run: overall numbers, per-file breakdown, the unpatrolled
|
|
405
|
+
# list, and the diff-based gate.
|
|
406
|
+
class Report
|
|
407
|
+
attr_reader :files, :root, :config, :exempt
|
|
408
|
+
|
|
409
|
+
def initialize(files:, root: Constable.root, config: Constable.config,
|
|
410
|
+
changed_lines: nil, exempt: [], gate: true)
|
|
411
|
+
@root = Coverage.resolve_root(root)
|
|
412
|
+
@config = config
|
|
413
|
+
@changed_lines = changed_lines
|
|
414
|
+
@exempt = Array(exempt).map(&:to_s)
|
|
415
|
+
@gate = gate
|
|
416
|
+
@files = files.sort_by(&:relative_path)
|
|
417
|
+
synthesize_missing!
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# --- overall -------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
def empty? = @files.empty?
|
|
423
|
+
def file(path) = @files.find { |f| f.relative_path == path.to_s || f.path == File.expand_path(path.to_s, @root) }
|
|
424
|
+
|
|
425
|
+
def relevant = @files.sum(&:relevant)
|
|
426
|
+
def covered = @files.sum(&:covered)
|
|
427
|
+
def missed = relevant - covered
|
|
428
|
+
|
|
429
|
+
def percent
|
|
430
|
+
return 100.0 if relevant.zero?
|
|
431
|
+
|
|
432
|
+
((covered.to_f / relevant) * 100).round(2)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def unpatrolled = @files.select(&:unpatrolled?)
|
|
436
|
+
|
|
437
|
+
# --- the diff gate -------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
# False when git could not tell us what changed. The gate cannot be enforced then,
|
|
440
|
+
# and silently passing is the only honest answer -- we would otherwise fail builds
|
|
441
|
+
# on shallow clones and source tarballs.
|
|
442
|
+
def diff_available? = !@changed_lines.nil?
|
|
443
|
+
|
|
444
|
+
# Gating is switched off wholesale for a run that carried no native cases -- a
|
|
445
|
+
# `constable test --unsafe` run is all cold cases, and cold cases are exempt.
|
|
446
|
+
def gate? = @gate == true
|
|
447
|
+
|
|
448
|
+
# Files whose changed lines are held to the threshold: application files with
|
|
449
|
+
# changed lines, minus anything exempt (a cold-case file, or a path the Runner
|
|
450
|
+
# passed in as cold).
|
|
451
|
+
def gated_files
|
|
452
|
+
return [] unless diff_available?
|
|
453
|
+
|
|
454
|
+
@files.reject { |f| exempt?(f) }.select { |f| changed_numbers(f).any? }
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Changed, executable lines under the gate.
|
|
458
|
+
def diff_relevant
|
|
459
|
+
gated_files.sum { |f| changed_numbers(f).count { |n| f.executable?(n) } }
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def diff_covered
|
|
463
|
+
gated_files.sum { |f| changed_numbers(f).count { |n| f.executable?(n) && f.covered?(n) } }
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def diff_missed = diff_relevant - diff_covered
|
|
467
|
+
|
|
468
|
+
# Percentage of changed executable lines that ran. nil when there is no diff info;
|
|
469
|
+
# 100.0 when the diff touched no executable line at all (a README, a comment).
|
|
470
|
+
def diff_percent
|
|
471
|
+
return nil unless diff_available?
|
|
472
|
+
return 100.0 if diff_relevant.zero?
|
|
473
|
+
|
|
474
|
+
((diff_covered.to_f / diff_relevant) * 100).round(2)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# { "app/models/user.rb" => [12, 13, 40] } -- the changed lines that never ran.
|
|
478
|
+
# This is the actionable half of the gate: not "you are at 84%", but "these lines".
|
|
479
|
+
def uncovered_diff_lines
|
|
480
|
+
gated_files.each_with_object({}) do |file, out|
|
|
481
|
+
numbers = changed_numbers(file).select { |n| file.executable?(n) && !file.covered?(n) }.sort
|
|
482
|
+
out[file.relative_path] = numbers if numbers.any?
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def threshold = @config.respond_to?(:coverage_threshold) ? @config.coverage_threshold : 0
|
|
487
|
+
|
|
488
|
+
# The build gate. Passes when there is nothing to judge -- no git, no changed
|
|
489
|
+
# executable lines, or a run that was exempt from gating altogether.
|
|
490
|
+
def meets_threshold?(config = @config)
|
|
491
|
+
return true unless gate?
|
|
492
|
+
return true unless diff_available?
|
|
493
|
+
return true if diff_relevant.zero?
|
|
494
|
+
|
|
495
|
+
diff_percent >= (config.respond_to?(:coverage_threshold) ? config.coverage_threshold : 0)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# nil when the gate passes; otherwise the sentence the reporter should print.
|
|
499
|
+
def threshold_message(config = @config)
|
|
500
|
+
return nil if meets_threshold?(config)
|
|
501
|
+
|
|
502
|
+
limit = config.respond_to?(:coverage_threshold) ? config.coverage_threshold : 0
|
|
503
|
+
"diff coverage #{format_percent(diff_percent)} of #{diff_relevant} changed " \
|
|
504
|
+
"#{plural(diff_relevant, "line")} is below the #{limit}% threshold"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# --- output --------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
# The one line the run summary gains: "◐ 92% covered (3 files unpatrolled)".
|
|
510
|
+
def summary_line
|
|
511
|
+
line = "◐ #{percent.round}% covered"
|
|
512
|
+
count = unpatrolled.size
|
|
513
|
+
return line if count.zero?
|
|
514
|
+
|
|
515
|
+
"#{line} (#{count} #{plural(count, "file")} unpatrolled)"
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# `constable beat` -- the standalone full picture. Plain text; colouring is the
|
|
519
|
+
# reporter's business, not ours.
|
|
520
|
+
def beat_report
|
|
521
|
+
rule = "━" * 60
|
|
522
|
+
out = [rule, " THE BEAT#{" " * 12}#{headline}", rule, ""]
|
|
523
|
+
out.concat(breakdown_section)
|
|
524
|
+
out.concat(unpatrolled_section)
|
|
525
|
+
out.concat(diff_section)
|
|
526
|
+
out << rule
|
|
527
|
+
out.join("\n")
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def to_h
|
|
531
|
+
{
|
|
532
|
+
percent: percent, covered: covered, relevant: relevant,
|
|
533
|
+
unpatrolled: unpatrolled.map(&:relative_path),
|
|
534
|
+
diff_percent: diff_percent, diff_covered: diff_covered, diff_relevant: diff_relevant,
|
|
535
|
+
uncovered_diff_lines: uncovered_diff_lines,
|
|
536
|
+
meets_threshold: meets_threshold?,
|
|
537
|
+
files: @files.map(&:to_h)
|
|
538
|
+
}
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def to_html = Html.new(self).render
|
|
542
|
+
|
|
543
|
+
# --- internals -----------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
def changed_numbers(file)
|
|
546
|
+
return [] unless @changed_lines
|
|
547
|
+
|
|
548
|
+
Array(@changed_lines[file.path] ||
|
|
549
|
+
@changed_lines[File.expand_path(file.relative_path, @root)] ||
|
|
550
|
+
@changed_lines[file.relative_path])
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def exempt?(file)
|
|
554
|
+
relative = file.relative_path
|
|
555
|
+
return true if @config.respond_to?(:cold_case?) && @config.cold_case?(relative)
|
|
556
|
+
|
|
557
|
+
@exempt.any? do |pattern|
|
|
558
|
+
pattern == relative || pattern == file.path ||
|
|
559
|
+
File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
private
|
|
564
|
+
|
|
565
|
+
# `::Coverage` only knows about files that were *loaded*. Add a brand-new class that
|
|
566
|
+
# no test ever requires and it simply will not appear in the result -- and a diff
|
|
567
|
+
# gate that silently ignores the one file you just wrote is worse than no gate. So
|
|
568
|
+
# for changed application files with no measurement at all, we synthesize a
|
|
569
|
+
# zero-coverage entry, guessing executable lines from the source. The guess is
|
|
570
|
+
# crude on purpose; a file in this state is 0% covered whichever way you count it.
|
|
571
|
+
def synthesize_missing!
|
|
572
|
+
return unless @changed_lines
|
|
573
|
+
|
|
574
|
+
known = @files.to_set(&:path)
|
|
575
|
+
@changed_lines.each_key do |path|
|
|
576
|
+
next if known.include?(path)
|
|
577
|
+
next unless File.file?(path)
|
|
578
|
+
next unless Coverage.application_file?(path, root: @root, config: @config)
|
|
579
|
+
|
|
580
|
+
@files << FileCoverage.new(path: path, lines: guess_executable(path), root: @root).synthesized!
|
|
581
|
+
end
|
|
582
|
+
@files.sort_by!(&:relative_path)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def guess_executable(path)
|
|
586
|
+
File.readlines(path, chomp: true).map do |line|
|
|
587
|
+
stripped = line.strip
|
|
588
|
+
next nil if stripped.empty? || stripped.start_with?("#")
|
|
589
|
+
next nil if NON_EXECUTABLE.match?(stripped)
|
|
590
|
+
|
|
591
|
+
0
|
|
592
|
+
end
|
|
593
|
+
rescue SystemCallError, IOError
|
|
594
|
+
[]
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def headline
|
|
598
|
+
parts = ["#{format_percent(percent)} covered", "#{@files.size} #{plural(@files.size, "file")}"]
|
|
599
|
+
parts << "#{unpatrolled.size} unpatrolled" if unpatrolled.any?
|
|
600
|
+
parts << "diff #{format_percent(diff_percent)}" if diff_available? && diff_relevant.positive?
|
|
601
|
+
parts.join(" · ")
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def breakdown_section
|
|
605
|
+
return [" no application files measured", ""] if @files.empty?
|
|
606
|
+
|
|
607
|
+
width = @files.map { |f| f.relative_path.length }.max
|
|
608
|
+
rows = @files.sort_by { |f| [f.percent, f.relative_path] }.map do |f|
|
|
609
|
+
format(" %6s %-#{width}s %s/%s%s", format_percent(f.percent), f.relative_path,
|
|
610
|
+
f.covered, f.relevant, f.synthesized? ? " (never loaded)" : "")
|
|
611
|
+
end
|
|
612
|
+
[" COVERAGE", " #{"─" * 8}", *rows, ""]
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def unpatrolled_section
|
|
616
|
+
return [] if unpatrolled.empty?
|
|
617
|
+
|
|
618
|
+
rows = unpatrolled.map do |f|
|
|
619
|
+
" ○ #{f.relative_path} (0 of #{f.relevant} #{plural(f.relevant, "line")})"
|
|
620
|
+
end
|
|
621
|
+
[" UNPATROLLED", " #{"─" * 11}", *rows, ""]
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def diff_section
|
|
625
|
+
return [" DIFF COVERAGE", " #{"─" * 13}", " no diff information available", ""] unless diff_available?
|
|
626
|
+
return [] if diff_relevant.zero? && uncovered_diff_lines.empty?
|
|
627
|
+
|
|
628
|
+
verdict = meets_threshold? ? "meets" : "below"
|
|
629
|
+
rows = [" #{format_percent(diff_percent)} of #{diff_relevant} changed " \
|
|
630
|
+
"#{plural(diff_relevant, "line")} covered -- #{verdict} the #{threshold}% threshold"]
|
|
631
|
+
uncovered_diff_lines.each { |path, numbers| rows << " ✗ #{path}:#{numbers.join(",")}" }
|
|
632
|
+
[" DIFF COVERAGE", " #{"─" * 13}", *rows, ""]
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def format_percent(value)
|
|
636
|
+
return "n/a" if value.nil?
|
|
637
|
+
|
|
638
|
+
value == value.round ? "#{value.round}%" : "#{format("%.1f", value)}%"
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def plural(count, word) = count == 1 ? word : "#{word}s"
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# The browsable report: one self-contained HTML file, no CDN, no assets directory,
|
|
645
|
+
# nothing to serve. Open it from a file:// URL on a plane and it still works.
|
|
646
|
+
class Html
|
|
647
|
+
def initialize(report)
|
|
648
|
+
@report = report
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def render
|
|
652
|
+
<<~HTML
|
|
653
|
+
<!doctype html>
|
|
654
|
+
<html lang="en">
|
|
655
|
+
<head>
|
|
656
|
+
<meta charset="utf-8">
|
|
657
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
658
|
+
<title>Constable — the beat</title>
|
|
659
|
+
<style>#{CSS}</style>
|
|
660
|
+
</head>
|
|
661
|
+
<body>
|
|
662
|
+
<header class="beat">
|
|
663
|
+
#{header}
|
|
664
|
+
</header>
|
|
665
|
+
<main>
|
|
666
|
+
#{diff_panel}
|
|
667
|
+
#{unpatrolled_panel}
|
|
668
|
+
#{table}
|
|
669
|
+
</main>
|
|
670
|
+
<footer>Generated by Constable — click a filename for its line-by-line view.</footer>
|
|
671
|
+
<script>#{JS}</script>
|
|
672
|
+
</body>
|
|
673
|
+
</html>
|
|
674
|
+
HTML
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
private
|
|
678
|
+
|
|
679
|
+
def e(text) = CGI.escapeHTML(text.to_s)
|
|
680
|
+
|
|
681
|
+
def header
|
|
682
|
+
percent = @report.percent
|
|
683
|
+
<<~HTML
|
|
684
|
+
<div class="dial #{grade(percent)}">
|
|
685
|
+
<span class="pct">#{percent.round}%</span>
|
|
686
|
+
<span class="lbl">covered</span>
|
|
687
|
+
</div>
|
|
688
|
+
<div class="stats">
|
|
689
|
+
<h1>The beat</h1>
|
|
690
|
+
<p>
|
|
691
|
+
<strong>#{@report.covered}</strong> of <strong>#{@report.relevant}</strong> relevant lines
|
|
692
|
+
across <strong>#{@report.files.size}</strong> files.
|
|
693
|
+
<span class="chip #{@report.unpatrolled.empty? ? "ok" : "warn"}">#{@report.unpatrolled.size} unpatrolled</span>
|
|
694
|
+
#{diff_chip}
|
|
695
|
+
</p>
|
|
696
|
+
</div>
|
|
697
|
+
HTML
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def diff_chip
|
|
701
|
+
return "" unless @report.diff_available?
|
|
702
|
+
return %(<span class="chip ok">no changed lines</span>) if @report.diff_relevant.zero?
|
|
703
|
+
|
|
704
|
+
state = @report.meets_threshold? ? "ok" : "bad"
|
|
705
|
+
%(<span class="chip #{state}">diff #{@report.diff_percent.round}% of ) +
|
|
706
|
+
%(#{@report.diff_relevant} changed lines</span>)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
def diff_panel
|
|
710
|
+
return "" unless @report.diff_available?
|
|
711
|
+
|
|
712
|
+
uncovered = @report.uncovered_diff_lines
|
|
713
|
+
return "" if uncovered.empty?
|
|
714
|
+
|
|
715
|
+
rows = uncovered.map do |path, numbers|
|
|
716
|
+
links = numbers.map { |n| %(<a href="#" data-goto="#{e(path)}" data-line="#{n}">#{n}</a>) }.join(", ")
|
|
717
|
+
%(<li><code>#{e(path)}</code> <span class="lines">#{links}</span></li>)
|
|
718
|
+
end.join("\n")
|
|
719
|
+
|
|
720
|
+
<<~HTML
|
|
721
|
+
<section class="panel #{@report.meets_threshold? ? "ok" : "bad"}">
|
|
722
|
+
<h2>Changed lines not covered</h2>
|
|
723
|
+
<p class="sub">Only these lines are held to the #{@report.threshold}% threshold. Legacy gaps below are visible, not blocking.</p>
|
|
724
|
+
<ul class="uncovered">#{rows}</ul>
|
|
725
|
+
</section>
|
|
726
|
+
HTML
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def unpatrolled_panel
|
|
730
|
+
files = @report.unpatrolled
|
|
731
|
+
return "" if files.empty?
|
|
732
|
+
|
|
733
|
+
items = files.map do |f|
|
|
734
|
+
%(<li><a href="#" data-goto="#{e(f.relative_path)}">#{e(f.relative_path)}</a> ) +
|
|
735
|
+
%(<span class="sub">#{f.relevant} lines, none executed</span></li>)
|
|
736
|
+
end.join("\n")
|
|
737
|
+
|
|
738
|
+
<<~HTML
|
|
739
|
+
<section class="panel warn">
|
|
740
|
+
<h2>Unpatrolled</h2>
|
|
741
|
+
<p class="sub">Zero executed lines. Usually a file nobody tested at all, rather than a thin one.</p>
|
|
742
|
+
<ul class="uncovered">#{items}</ul>
|
|
743
|
+
</section>
|
|
744
|
+
HTML
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def table
|
|
748
|
+
rows = @report.files.each_with_index.map { |file, index| file_rows(file, index) }.join("\n")
|
|
749
|
+
<<~HTML
|
|
750
|
+
<table id="files">
|
|
751
|
+
<thead>
|
|
752
|
+
<tr>
|
|
753
|
+
<th data-sort="text" class="asc">File</th>
|
|
754
|
+
<th data-sort="num">Coverage</th>
|
|
755
|
+
<th data-sort="num">Covered</th>
|
|
756
|
+
<th data-sort="num">Relevant</th>
|
|
757
|
+
<th data-sort="num">Missed</th>
|
|
758
|
+
</tr>
|
|
759
|
+
</thead>
|
|
760
|
+
<tbody>#{rows}</tbody>
|
|
761
|
+
</table>
|
|
762
|
+
HTML
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def file_rows(file, index)
|
|
766
|
+
percent = file.percent
|
|
767
|
+
note = file.synthesized? ? %( <span class="chip warn">never loaded</span>) : ""
|
|
768
|
+
<<~HTML
|
|
769
|
+
<tr class="file #{grade(percent)}" data-file="#{e(file.relative_path)}" data-index="#{index}">
|
|
770
|
+
<td class="name"><button type="button" class="toggle" aria-expanded="false">#{e(file.relative_path)}</button>#{note}</td>
|
|
771
|
+
<td data-value="#{percent}"><span class="bar"><span style="width:#{percent}%"></span></span><span class="num">#{format("%.1f", percent)}%</span></td>
|
|
772
|
+
<td data-value="#{file.covered}" class="num">#{file.covered}</td>
|
|
773
|
+
<td data-value="#{file.relevant}" class="num">#{file.relevant}</td>
|
|
774
|
+
<td data-value="#{file.missed}" class="num">#{file.missed}</td>
|
|
775
|
+
</tr>
|
|
776
|
+
<tr class="source" hidden><td colspan="5">#{source_view(file)}</td></tr>
|
|
777
|
+
HTML
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
def source_view(file)
|
|
781
|
+
lines = file.source_lines
|
|
782
|
+
return %(<p class="sub">Source unavailable (#{e(file.path)}).</p>) if lines.empty?
|
|
783
|
+
|
|
784
|
+
body = lines.each_with_index.map do |text, index|
|
|
785
|
+
number = index + 1
|
|
786
|
+
hits = file.hits_for(number)
|
|
787
|
+
state = if hits.nil? then "na"
|
|
788
|
+
elsif hits.positive? then "hit"
|
|
789
|
+
else "miss"
|
|
790
|
+
end
|
|
791
|
+
count = hits.nil? ? "" : "#{hits}×"
|
|
792
|
+
%(<div class="line #{state}" id="L#{e(file.relative_path)}-#{number}">) +
|
|
793
|
+
%(<span class="ln">#{number}</span><span class="hits">#{count}</span><code>#{e(text)}</code></div>)
|
|
794
|
+
end.join
|
|
795
|
+
|
|
796
|
+
%(<div class="code">#{body}</div>)
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
def grade(percent)
|
|
800
|
+
return "good" if percent >= 90
|
|
801
|
+
return "ok" if percent >= 70
|
|
802
|
+
|
|
803
|
+
percent.zero? ? "none" : "bad"
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
CSS = <<~CSS
|
|
807
|
+
:root {
|
|
808
|
+
color-scheme: light dark;
|
|
809
|
+
--bg: #f6f5f2; --panel: #fffdf8; --ink: #1c1a17; --muted: #6c665c;
|
|
810
|
+
--line: #e3ded3; --good: #2e7d4f; --ok: #b07d12; --bad: #b3261e; --none: #7a2f2a;
|
|
811
|
+
--hit: rgba(46,125,79,.14); --miss: rgba(179,38,30,.16); --accent: #2d4f8a;
|
|
812
|
+
}
|
|
813
|
+
@media (prefers-color-scheme: dark) {
|
|
814
|
+
:root {
|
|
815
|
+
--bg: #16171a; --panel: #1e2024; --ink: #e8e6e1; --muted: #9a958c;
|
|
816
|
+
--line: #2e3137; --good: #6bbd8c; --ok: #d8a93e; --bad: #ef6b62; --none: #ef8b7f;
|
|
817
|
+
--hit: rgba(107,189,140,.16); --miss: rgba(239,107,98,.18); --accent: #7fa8f0;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
* { box-sizing: border-box; }
|
|
821
|
+
body { margin: 0; background: var(--bg); color: var(--ink);
|
|
822
|
+
font: 14px/1.5 ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
823
|
+
header.beat { display: flex; gap: 1.5rem; align-items: center; padding: 1.5rem 2rem;
|
|
824
|
+
border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
825
|
+
.dial { width: 96px; height: 96px; border-radius: 50%; display: flex; flex-direction: column;
|
|
826
|
+
align-items: center; justify-content: center; border: 4px solid var(--line); flex: none; }
|
|
827
|
+
.dial.good { border-color: var(--good); } .dial.ok { border-color: var(--ok); }
|
|
828
|
+
.dial.bad, .dial.none { border-color: var(--bad); }
|
|
829
|
+
.dial .pct { font-size: 1.6rem; font-weight: 700; }
|
|
830
|
+
.dial .lbl { font-size: .7rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
|
831
|
+
h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
|
|
832
|
+
h2 { font-size: 1rem; margin: 0 0 .25rem; }
|
|
833
|
+
p { margin: .25rem 0; }
|
|
834
|
+
.sub { color: var(--muted); font-size: .85rem; }
|
|
835
|
+
.chip { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .78rem;
|
|
836
|
+
border: 1px solid var(--line); }
|
|
837
|
+
.chip.ok { color: var(--good); border-color: var(--good); }
|
|
838
|
+
.chip.warn { color: var(--ok); border-color: var(--ok); }
|
|
839
|
+
.chip.bad { color: var(--bad); border-color: var(--bad); }
|
|
840
|
+
main { padding: 1.5rem 2rem 3rem; }
|
|
841
|
+
.panel { background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--muted);
|
|
842
|
+
border-radius: 6px; padding: 1rem 1.25rem; margin-bottom: 1.25rem; }
|
|
843
|
+
.panel.bad { border-left-color: var(--bad); }
|
|
844
|
+
.panel.warn { border-left-color: var(--ok); }
|
|
845
|
+
ul.uncovered { list-style: none; margin: .5rem 0 0; padding: 0; }
|
|
846
|
+
ul.uncovered li { padding: .2rem 0; border-bottom: 1px dotted var(--line); }
|
|
847
|
+
.lines a { color: var(--accent); text-decoration: none; margin-right: .25rem; }
|
|
848
|
+
table { width: 100%; border-collapse: collapse; background: var(--panel);
|
|
849
|
+
border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
|
850
|
+
th { text-align: left; font-size: .78rem; text-transform: uppercase; letter-spacing: .06em;
|
|
851
|
+
color: var(--muted); padding: .6rem .75rem; border-bottom: 1px solid var(--line);
|
|
852
|
+
cursor: pointer; user-select: none; white-space: nowrap; }
|
|
853
|
+
th::after { content: ""; }
|
|
854
|
+
th.asc::after { content: " ▲"; } th.desc::after { content: " ▼"; }
|
|
855
|
+
td { padding: .4rem .75rem; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
|
856
|
+
td.num, th:not(:first-child) { text-align: right; }
|
|
857
|
+
tr.file.good td:first-child { border-left: 3px solid var(--good); }
|
|
858
|
+
tr.file.ok td:first-child { border-left: 3px solid var(--ok); }
|
|
859
|
+
tr.file.bad td:first-child, tr.file.none td:first-child { border-left: 3px solid var(--bad); }
|
|
860
|
+
button.toggle { background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer;
|
|
861
|
+
font: inherit; text-align: left; }
|
|
862
|
+
.bar { display: inline-block; width: 90px; height: 6px; background: var(--line);
|
|
863
|
+
border-radius: 3px; overflow: hidden; margin-right: .5rem; vertical-align: middle; }
|
|
864
|
+
.bar > span { display: block; height: 100%; background: var(--good); }
|
|
865
|
+
tr.file.ok .bar > span { background: var(--ok); }
|
|
866
|
+
tr.file.bad .bar > span, tr.file.none .bar > span { background: var(--bad); }
|
|
867
|
+
tr.source > td { padding: 0; background: var(--bg); }
|
|
868
|
+
.code { max-height: 60vh; overflow: auto; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
869
|
+
.line { display: flex; white-space: pre; }
|
|
870
|
+
.line .ln { width: 4.5rem; flex: none; text-align: right; padding-right: .75rem; color: var(--muted); }
|
|
871
|
+
.line .hits { width: 4rem; flex: none; text-align: right; padding-right: 1rem; color: var(--muted); font-size: .9em; }
|
|
872
|
+
.line code { white-space: pre; }
|
|
873
|
+
.line.hit { background: var(--hit); }
|
|
874
|
+
.line.miss { background: var(--miss); }
|
|
875
|
+
.line.miss .ln { color: var(--bad); font-weight: 700; }
|
|
876
|
+
.line.na { opacity: .65; }
|
|
877
|
+
.line.flash { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
878
|
+
footer { padding: 1rem 2rem 2rem; color: var(--muted); font-size: .8rem; }
|
|
879
|
+
CSS
|
|
880
|
+
|
|
881
|
+
JS = <<~JS
|
|
882
|
+
(function () {
|
|
883
|
+
var table = document.getElementById("files");
|
|
884
|
+
if (!table) return;
|
|
885
|
+
var body = table.tBodies[0];
|
|
886
|
+
|
|
887
|
+
// Rows come in pairs: the file row and its (hidden) source row. Sorting moves
|
|
888
|
+
// the pair, never just the header of it.
|
|
889
|
+
function pairs() {
|
|
890
|
+
var out = [], rows = Array.prototype.slice.call(body.rows);
|
|
891
|
+
for (var i = 0; i < rows.length; i += 2) out.push([rows[i], rows[i + 1]]);
|
|
892
|
+
return out;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function toggle(row) {
|
|
896
|
+
var source = row.nextElementSibling;
|
|
897
|
+
var button = row.querySelector(".toggle");
|
|
898
|
+
var open = source.hidden;
|
|
899
|
+
source.hidden = !open;
|
|
900
|
+
if (button) button.setAttribute("aria-expanded", open ? "true" : "false");
|
|
901
|
+
return source;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
body.addEventListener("click", function (event) {
|
|
905
|
+
var button = event.target.closest(".toggle");
|
|
906
|
+
if (!button) return;
|
|
907
|
+
event.preventDefault();
|
|
908
|
+
toggle(button.closest("tr.file"));
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
Array.prototype.forEach.call(table.tHead.rows[0].cells, function (th, index) {
|
|
912
|
+
th.addEventListener("click", function () {
|
|
913
|
+
var descending = !th.classList.contains("desc");
|
|
914
|
+
Array.prototype.forEach.call(table.tHead.rows[0].cells, function (other) {
|
|
915
|
+
other.classList.remove("asc", "desc");
|
|
916
|
+
});
|
|
917
|
+
th.classList.add(descending ? "desc" : "asc");
|
|
918
|
+
var numeric = th.dataset.sort === "num";
|
|
919
|
+
var sorted = pairs().sort(function (a, b) {
|
|
920
|
+
var x = a[0].cells[index], y = b[0].cells[index];
|
|
921
|
+
if (numeric) {
|
|
922
|
+
return (parseFloat(y.dataset.value) - parseFloat(x.dataset.value)) * (descending ? 1 : -1);
|
|
923
|
+
}
|
|
924
|
+
return x.textContent.trim().localeCompare(y.textContent.trim()) * (descending ? -1 : 1);
|
|
925
|
+
});
|
|
926
|
+
sorted.forEach(function (pair) { body.appendChild(pair[0]); body.appendChild(pair[1]); });
|
|
927
|
+
});
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
document.addEventListener("click", function (event) {
|
|
931
|
+
var link = event.target.closest("[data-goto]");
|
|
932
|
+
if (!link) return;
|
|
933
|
+
event.preventDefault();
|
|
934
|
+
var row = body.querySelector('tr.file[data-file="' + link.dataset.goto + '"]');
|
|
935
|
+
if (!row) return;
|
|
936
|
+
if (row.nextElementSibling.hidden) toggle(row);
|
|
937
|
+
var target = link.dataset.line
|
|
938
|
+
? document.getElementById("L" + link.dataset.goto + "-" + link.dataset.line)
|
|
939
|
+
: row;
|
|
940
|
+
if (!target) target = row;
|
|
941
|
+
target.scrollIntoView({ block: "center" });
|
|
942
|
+
target.classList.add("flash");
|
|
943
|
+
setTimeout(function () { target.classList.remove("flash"); }, 1600);
|
|
944
|
+
});
|
|
945
|
+
})();
|
|
946
|
+
JS
|
|
947
|
+
|
|
948
|
+
private_constant :CSS, :JS
|
|
949
|
+
end
|
|
950
|
+
end
|
|
951
|
+
end
|