testament-probe-ruby 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/LICENSE.txt +21 -0
- data/README.md +28 -0
- data/lib/testament/probe/version.rb +7 -0
- data/lib/testament/probe.rb +248 -0
- metadata +50 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 337fe8994c7ab64268d451d7b9f91e671251036eade4ed01905f42a10dd366f8
|
|
4
|
+
data.tar.gz: 5faf2fb1f40a1df3cbed28907b6871940ec05d498a284d955fa1b06fc2f78c18
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 55f1e5ff0529789880754d15acd90be7ee2a58d4104983800d5c592175f74de4a97597e3e49f1009dcc0c6f2114ada4ca4c1687ff00e1730ab841cdacdd6763c
|
|
7
|
+
data.tar.gz: 4c6d77433a6e09c18b446672af24937812a71c7a49c0cdd31213d2a89b544c5ca9634b576806d74027a0107db3328dc5b6b68d705442d35fc8b0102d2b3d05d9
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# testament-probe-ruby
|
|
2
|
+
|
|
3
|
+
Small Ruby-side companion for collecting per-test line coverage and assertion
|
|
4
|
+
trace evidence consumed by testament.
|
|
5
|
+
|
|
6
|
+
## Usage
|
|
7
|
+
|
|
8
|
+
Add the probe to the test process before tests run:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
require "testament/probe"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
By default the probe writes `.testament/per-test-coverage.json` and
|
|
15
|
+
`.testament/trace.json`. Set `TESTAMENT_PROBE_OUTPUT` or
|
|
16
|
+
`TESTAMENT_TRACE_OUTPUT` to write another path.
|
|
17
|
+
|
|
18
|
+
The trace output records executed project lines, lines executed while assertion
|
|
19
|
+
methods are active, and recent executed lines observed when assertions begin.
|
|
20
|
+
`TESTAMENT_PROJECT_ROOT` controls the project-root filter, and
|
|
21
|
+
`TESTAMENT_TRACE_WINDOW` controls how many recent lines are attributed to an
|
|
22
|
+
assertion.
|
|
23
|
+
|
|
24
|
+
Line/call tracing adds noticeable overhead to the test run. Set
|
|
25
|
+
`TESTAMENT_TRACE=0` to collect per-test line coverage without trace evidence.
|
|
26
|
+
|
|
27
|
+
The probe installs hooks for RSpec and Minitest when those constants are loaded.
|
|
28
|
+
Load the probe after the test framework is required.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
require "coverage"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "probe/version"
|
|
5
|
+
|
|
6
|
+
module Testament
|
|
7
|
+
module Probe
|
|
8
|
+
DEFAULT_OUTPUT = ".testament/per-test-coverage.json"
|
|
9
|
+
DEFAULT_TRACE_OUTPUT = ".testament/trace.json"
|
|
10
|
+
DEFAULT_TRACE_WINDOW = 200
|
|
11
|
+
ASSERTION_METHODS = %i[
|
|
12
|
+
expect should should_not to not_to to_not
|
|
13
|
+
assert assert_equal assert_same assert_nil assert_not_nil assert_empty
|
|
14
|
+
assert_includes assert_match assert_operator assert_predicate
|
|
15
|
+
assert_instance_of assert_kind_of assert_raises assert_throws
|
|
16
|
+
assert_output assert_silent
|
|
17
|
+
refute refute_equal refute_same refute_nil refute_empty
|
|
18
|
+
refute_includes refute_match refute_operator refute_predicate
|
|
19
|
+
refute_instance_of refute_kind_of
|
|
20
|
+
must_be must_equal must_include must_match must_raise must_be_nil
|
|
21
|
+
wont_be wont_equal wont_include wont_match wont_be_nil
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def install!(
|
|
26
|
+
output: ENV.fetch("TESTAMENT_PROBE_OUTPUT", DEFAULT_OUTPUT),
|
|
27
|
+
trace_output: ENV.fetch("TESTAMENT_TRACE_OUTPUT", DEFAULT_TRACE_OUTPUT)
|
|
28
|
+
)
|
|
29
|
+
@output = output
|
|
30
|
+
@trace_output = trace_output
|
|
31
|
+
@root = File.expand_path(ENV.fetch("TESTAMENT_PROJECT_ROOT", Dir.pwd))
|
|
32
|
+
@probe_file = File.expand_path(__FILE__)
|
|
33
|
+
@trace_window = ENV.fetch("TESTAMENT_TRACE_WINDOW", DEFAULT_TRACE_WINDOW).to_i
|
|
34
|
+
@trace_enabled = ENV.fetch("TESTAMENT_TRACE", "1") != "0"
|
|
35
|
+
start_coverage
|
|
36
|
+
capture_coverage
|
|
37
|
+
install_rspec if defined?(RSpec)
|
|
38
|
+
install_minitest if defined?(Minitest::Test)
|
|
39
|
+
at_exit { write! }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def record(case_id)
|
|
43
|
+
start_coverage
|
|
44
|
+
start_trace
|
|
45
|
+
previous_case = @current_case
|
|
46
|
+
previous_recent_lines = @recent_lines
|
|
47
|
+
previous_assertion_depth = @assertion_depth
|
|
48
|
+
@recent_lines = []
|
|
49
|
+
@assertion_depth = 0
|
|
50
|
+
@current_case = case_id
|
|
51
|
+
trace_cases[case_id]
|
|
52
|
+
yield
|
|
53
|
+
ensure
|
|
54
|
+
merge_case(case_id, capture_coverage) if case_id
|
|
55
|
+
@recent_lines = previous_recent_lines || []
|
|
56
|
+
@assertion_depth = previous_assertion_depth || 0
|
|
57
|
+
@current_case = previous_case
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def write!
|
|
61
|
+
output = @output || DEFAULT_OUTPUT
|
|
62
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
63
|
+
File.write(output, JSON.pretty_generate("cases" => cases))
|
|
64
|
+
|
|
65
|
+
trace_output = @trace_output || DEFAULT_TRACE_OUTPUT
|
|
66
|
+
FileUtils.mkdir_p(File.dirname(trace_output))
|
|
67
|
+
File.write(trace_output, JSON.pretty_generate("cases" => trace_cases))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def cases
|
|
73
|
+
@cases ||= {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def trace_cases
|
|
77
|
+
@trace_cases ||= Hash.new do |cases, case_id|
|
|
78
|
+
cases[case_id] = { "executed" => {}, "checked" => {} }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def start_coverage
|
|
83
|
+
return false if coverage_running?
|
|
84
|
+
|
|
85
|
+
Coverage.start(lines: true)
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def start_trace
|
|
90
|
+
return unless @trace_enabled
|
|
91
|
+
|
|
92
|
+
@tracepoint ||= TracePoint.new(:line, :call, :c_call, :return, :c_return) do |event|
|
|
93
|
+
case event.event
|
|
94
|
+
when :line
|
|
95
|
+
record_trace_line(event.path, event.lineno)
|
|
96
|
+
when :call, :c_call
|
|
97
|
+
enter_assertion if assertion_method?(event.method_id, event.defined_class)
|
|
98
|
+
when :return, :c_return
|
|
99
|
+
leave_assertion if assertion_method?(event.method_id, event.defined_class)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
@tracepoint.enable unless @tracepoint.enabled?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def coverage_running?
|
|
106
|
+
Coverage.respond_to?(:running?) && Coverage.running?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def capture_coverage
|
|
110
|
+
Coverage.result(stop: false, clear: true)
|
|
111
|
+
rescue ArgumentError
|
|
112
|
+
Coverage.result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def merge_case(case_id, coverage)
|
|
116
|
+
normalized = normalize_coverage(coverage)
|
|
117
|
+
return if normalized.empty?
|
|
118
|
+
|
|
119
|
+
existing = cases.fetch(case_id, {})
|
|
120
|
+
normalized.each do |path, lines|
|
|
121
|
+
merged = (existing.fetch(path, []) + lines).uniq.sort
|
|
122
|
+
existing[path] = merged
|
|
123
|
+
end
|
|
124
|
+
cases[case_id] = existing
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def record_trace_line(path, line)
|
|
128
|
+
return unless @current_case
|
|
129
|
+
|
|
130
|
+
path = normalize_trace_path(path)
|
|
131
|
+
return unless path
|
|
132
|
+
|
|
133
|
+
trace = trace_cases[@current_case]
|
|
134
|
+
append_trace_line(trace["executed"], path, line)
|
|
135
|
+
append_trace_line(trace["checked"], path, line) if assertion_active?
|
|
136
|
+
@recent_lines ||= []
|
|
137
|
+
@recent_lines << [path, line]
|
|
138
|
+
@recent_lines.shift while @recent_lines.length > trace_window
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def enter_assertion
|
|
142
|
+
mark_recent_lines_checked
|
|
143
|
+
@assertion_depth = assertion_depth + 1
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def leave_assertion
|
|
147
|
+
@assertion_depth = [assertion_depth - 1, 0].max
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def assertion_active?
|
|
151
|
+
assertion_depth.positive?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def assertion_depth
|
|
155
|
+
@assertion_depth ||= 0
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def mark_recent_lines_checked
|
|
159
|
+
return unless @current_case
|
|
160
|
+
|
|
161
|
+
checked = trace_cases[@current_case]["checked"]
|
|
162
|
+
@recent_lines.each do |path, line|
|
|
163
|
+
append_trace_line(checked, path, line)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def append_trace_line(files, path, line)
|
|
168
|
+
lines = files.fetch(path, [])
|
|
169
|
+
lines << line
|
|
170
|
+
files[path] = lines.uniq.sort
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def normalize_trace_path(path)
|
|
174
|
+
return if path.nil? || path.empty?
|
|
175
|
+
return if path.start_with?("<")
|
|
176
|
+
|
|
177
|
+
expanded = File.expand_path(path)
|
|
178
|
+
root = @root || Dir.pwd
|
|
179
|
+
return if expanded == @probe_file
|
|
180
|
+
return unless expanded == root || expanded.start_with?("#{root}/")
|
|
181
|
+
|
|
182
|
+
expanded.delete_prefix("#{root}/")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def assertion_method?(method_id, defined_class)
|
|
186
|
+
return false unless ASSERTION_METHODS.include?(method_id)
|
|
187
|
+
|
|
188
|
+
owner = defined_class.to_s
|
|
189
|
+
owner.include?("RSpec") || owner.include?("Minitest") || owner.include?("Test::Unit")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def trace_window
|
|
193
|
+
[@trace_window || DEFAULT_TRACE_WINDOW, 1].max
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def normalize_coverage(coverage)
|
|
197
|
+
coverage.each_with_object({}) do |(path, value), result|
|
|
198
|
+
lines = line_hits(value)
|
|
199
|
+
covered = lines.each_with_index.filter_map do |hits, index|
|
|
200
|
+
index + 1 if hits && hits.positive?
|
|
201
|
+
end
|
|
202
|
+
result[path] = covered unless covered.empty?
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def line_hits(value)
|
|
207
|
+
if value.is_a?(Hash)
|
|
208
|
+
unless value.key?(:lines) || value.key?("lines")
|
|
209
|
+
warn_missing_line_coverage
|
|
210
|
+
return []
|
|
211
|
+
end
|
|
212
|
+
value.fetch(:lines, value.fetch("lines", []))
|
|
213
|
+
else
|
|
214
|
+
value || []
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def warn_missing_line_coverage
|
|
219
|
+
return if @warned_missing_line_coverage
|
|
220
|
+
|
|
221
|
+
warn "testament probe: Coverage is active without lines mode; per-test coverage will be empty"
|
|
222
|
+
@warned_missing_line_coverage = true
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def install_rspec
|
|
226
|
+
RSpec.configure do |config|
|
|
227
|
+
config.around(:each) do |example|
|
|
228
|
+
Testament::Probe.record(example.full_description) { example.run }
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def install_minitest
|
|
234
|
+
return if Minitest::Test < MinitestIntegration
|
|
235
|
+
|
|
236
|
+
Minitest::Test.prepend(MinitestIntegration)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
module MinitestIntegration
|
|
241
|
+
def run
|
|
242
|
+
Testament::Probe.record("#{self.class}##{name}") { super }
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
Testament::Probe.install!
|
metadata
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: testament-probe-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Collects per-test line coverage and assertion trace evidence for the
|
|
13
|
+
testament test-quality analyzer.
|
|
14
|
+
executables: []
|
|
15
|
+
extensions: []
|
|
16
|
+
extra_rdoc_files:
|
|
17
|
+
- LICENSE.txt
|
|
18
|
+
- README.md
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE.txt
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/testament/probe.rb
|
|
23
|
+
- lib/testament/probe/version.rb
|
|
24
|
+
homepage: https://github.com/ydah/testament
|
|
25
|
+
licenses:
|
|
26
|
+
- MIT
|
|
27
|
+
metadata:
|
|
28
|
+
source_code_uri: https://github.com/ydah/testament
|
|
29
|
+
homepage_uri: https://github.com/ydah/testament
|
|
30
|
+
bug_tracker_uri: https://github.com/ydah/testament/issues
|
|
31
|
+
allowed_push_host: https://rubygems.org
|
|
32
|
+
rubygems_mfa_required: 'true'
|
|
33
|
+
rdoc_options: []
|
|
34
|
+
require_paths:
|
|
35
|
+
- lib
|
|
36
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.1'
|
|
41
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '0'
|
|
46
|
+
requirements: []
|
|
47
|
+
rubygems_version: 4.0.6
|
|
48
|
+
specification_version: 4
|
|
49
|
+
summary: Per-test Ruby coverage and trace probe for testament
|
|
50
|
+
test_files: []
|