verity 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 +21 -0
- data/README.md +227 -0
- data/bin/benchmark +99 -0
- data/bin/test_all +43 -0
- data/bin/verity +82 -0
- data/lib/verity/assertions.rb +316 -0
- data/lib/verity/configuration.rb +129 -0
- data/lib/verity/fingerprint.rb +155 -0
- data/lib/verity/manifest.rb +462 -0
- data/lib/verity/reporter.rb +54 -0
- data/lib/verity/reporters/colored_dots.rb +59 -0
- data/lib/verity/reporters/composite_reporter.rb +44 -0
- data/lib/verity/reporters/documentation_reporter.rb +121 -0
- data/lib/verity/reporters/dots_reporter.rb +48 -0
- data/lib/verity/reporters/null_reporter.rb +11 -0
- data/lib/verity/reporters/parallel_summary_reporter.rb +51 -0
- data/lib/verity/reporters/test_reporter.rb +48 -0
- data/lib/verity/runner.rb +210 -0
- data/lib/verity/version.rb +5 -0
- data/lib/verity.rb +614 -0
- metadata +139 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pp"
|
|
4
|
+
|
|
5
|
+
module Verity
|
|
6
|
+
# Public: Raised when an assertion fails. Distinct from unexpected exceptions
|
|
7
|
+
# so the runner can differentiate assertion failures from errors.
|
|
8
|
+
class AssertionError < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Public: Raised when a test body runs longer than its `timeout:` (see DSL).
|
|
11
|
+
class TestTimeoutError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Public: Assertion methods mixed into the DSL. Every assertion has a
|
|
14
|
+
# corresponding `refute_*` negation. All accept an optional `message:`
|
|
15
|
+
# keyword that overrides the default failure text.
|
|
16
|
+
module Assertions
|
|
17
|
+
# Public: Assert that a value is truthy.
|
|
18
|
+
#
|
|
19
|
+
# check - The value to test.
|
|
20
|
+
# message - Optional String or Proc failure message.
|
|
21
|
+
#
|
|
22
|
+
# Raises AssertionError if check is falsy.
|
|
23
|
+
def assert(check, message: nil)
|
|
24
|
+
return if check
|
|
25
|
+
fail_assertion(message) { "Expected truthy but got #{check.inspect}" }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Public: Assert that a value is falsy.
|
|
29
|
+
#
|
|
30
|
+
# check - The value to test.
|
|
31
|
+
# message - Optional String or Proc failure message.
|
|
32
|
+
#
|
|
33
|
+
# Raises AssertionError if check is truthy.
|
|
34
|
+
def refute(check, message: nil)
|
|
35
|
+
return unless check
|
|
36
|
+
fail_assertion(message) { "Expected falsy but got #{check.inspect}" }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Public: Assert that a value is nil.
|
|
40
|
+
#
|
|
41
|
+
# actual - The value to test.
|
|
42
|
+
# message - Optional String or Proc failure message.
|
|
43
|
+
#
|
|
44
|
+
# Raises AssertionError when actual is not nil.
|
|
45
|
+
def assert_nil(actual, message: nil)
|
|
46
|
+
return if actual.nil?
|
|
47
|
+
fail_assertion(message) { "Expected nil but got #{actual.inspect}" }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Public: Assert that a value is not nil.
|
|
51
|
+
#
|
|
52
|
+
# actual - The value to test.
|
|
53
|
+
# message - Optional String or Proc failure message.
|
|
54
|
+
#
|
|
55
|
+
# Raises AssertionError when actual is nil.
|
|
56
|
+
def refute_nil(actual, message: nil)
|
|
57
|
+
return unless actual.nil?
|
|
58
|
+
fail_assertion(message) { "Expected non-nil but got nil" }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Public: Assert that two values are equal using `==`.
|
|
62
|
+
#
|
|
63
|
+
# actual - The value produced by the code under test.
|
|
64
|
+
# expected - The reference value.
|
|
65
|
+
# message - Optional String or Proc failure message.
|
|
66
|
+
#
|
|
67
|
+
# Raises AssertionError when actual != expected.
|
|
68
|
+
def assert_equal(actual:, expected:, message: nil)
|
|
69
|
+
return if actual == expected
|
|
70
|
+
fail_assertion(message) { "Expected values to be equal\n#{format_diff(actual, expected)}" }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Public: Assert that two values are NOT equal using `==`.
|
|
74
|
+
#
|
|
75
|
+
# actual - The value produced by the code under test.
|
|
76
|
+
# expected - The reference value that should differ.
|
|
77
|
+
# message - Optional String or Proc failure message.
|
|
78
|
+
#
|
|
79
|
+
# Raises AssertionError when actual == expected.
|
|
80
|
+
def refute_equal(actual:, expected:, message: nil)
|
|
81
|
+
return unless actual == expected
|
|
82
|
+
fail_assertion(message) { "Expected values to differ, but both were #{actual.inspect}" }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Public: Assert that two references point to the same object (identity
|
|
86
|
+
# via `equal?`).
|
|
87
|
+
#
|
|
88
|
+
# actual - The object produced by the code under test.
|
|
89
|
+
# expected - The exact object expected.
|
|
90
|
+
# message - Optional String or Proc failure message.
|
|
91
|
+
#
|
|
92
|
+
# Raises AssertionError when the objects are not identical.
|
|
93
|
+
def assert_same(actual:, expected:, message: nil)
|
|
94
|
+
return if actual.equal?(expected)
|
|
95
|
+
fail_assertion(message) do
|
|
96
|
+
"Expected same object\n" \
|
|
97
|
+
" actual: #{actual.inspect} (object_id: #{actual.object_id})\n" \
|
|
98
|
+
" expected: #{expected.inspect} (object_id: #{expected.object_id})"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Public: Assert that two references are NOT the same object.
|
|
103
|
+
#
|
|
104
|
+
# actual - The object produced by the code under test.
|
|
105
|
+
# expected - The object that should be a different instance.
|
|
106
|
+
# message - Optional String or Proc failure message.
|
|
107
|
+
#
|
|
108
|
+
# Raises AssertionError when the objects are identical.
|
|
109
|
+
def refute_same(actual:, expected:, message: nil)
|
|
110
|
+
return unless actual.equal?(expected)
|
|
111
|
+
fail_assertion(message) do
|
|
112
|
+
"Expected different objects, both were #{actual.inspect} (object_id: #{actual.object_id})"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Public: Assert that the block raises one of the given exception classes.
|
|
117
|
+
# Optionally verify the error message matches a pattern.
|
|
118
|
+
#
|
|
119
|
+
# error_classes - One or more Exception subclasses expected.
|
|
120
|
+
# match - String or Regexp matched against the error message (default nil).
|
|
121
|
+
# message - Optional String or Proc failure message.
|
|
122
|
+
# block - Block expected to raise.
|
|
123
|
+
#
|
|
124
|
+
# Examples
|
|
125
|
+
#
|
|
126
|
+
# assert_raises(ArgumentError) { Integer("nope") }
|
|
127
|
+
# # => #<ArgumentError: ...>
|
|
128
|
+
#
|
|
129
|
+
# Returns the caught exception on success.
|
|
130
|
+
# Raises ArgumentError if no error classes are given.
|
|
131
|
+
# Raises AssertionError if the block does not raise as expected.
|
|
132
|
+
def assert_raises(*error_classes, match: nil, message: nil, &block)
|
|
133
|
+
raise ArgumentError, "assert_raises requires at least one error class" if error_classes.empty?
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
block.call
|
|
137
|
+
rescue *error_classes => e
|
|
138
|
+
if match
|
|
139
|
+
satisfied = match.is_a?(Regexp) ? e.message =~ match : e.message.include?(match)
|
|
140
|
+
unless satisfied
|
|
141
|
+
fail_assertion(message) do
|
|
142
|
+
"#{e.class} raised but message #{e.message.inspect} did not match #{match.inspect}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
return e
|
|
147
|
+
rescue => e
|
|
148
|
+
fail_assertion(message) do
|
|
149
|
+
"Expected #{error_class_names(error_classes)} but #{e.class} was raised: #{e.message}"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
fail_assertion(message) { "Expected #{error_class_names(error_classes)} but nothing was raised" }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Public: Assert that the block does NOT raise the specified exceptions.
|
|
157
|
+
# When no error classes are given, asserts that nothing is raised at all.
|
|
158
|
+
# When a match pattern is provided, only matching messages trigger failure.
|
|
159
|
+
#
|
|
160
|
+
# error_classes - Zero or more Exception subclasses that must not be raised.
|
|
161
|
+
# match - String or Regexp; only messages matching this cause failure (default nil).
|
|
162
|
+
# message - Optional String or Proc failure message.
|
|
163
|
+
# block - Block to execute.
|
|
164
|
+
#
|
|
165
|
+
# Raises AssertionError if the block raises a matching exception.
|
|
166
|
+
def refute_raises(*error_classes, match: nil, message: nil, &block)
|
|
167
|
+
if error_classes.empty?
|
|
168
|
+
begin
|
|
169
|
+
block.call
|
|
170
|
+
rescue => e
|
|
171
|
+
if match
|
|
172
|
+
satisfied = match.is_a?(Regexp) ? e.message =~ match : e.message.include?(match)
|
|
173
|
+
raise unless satisfied
|
|
174
|
+
fail_assertion(message) do
|
|
175
|
+
"Expected no exception with message matching #{match.inspect}, " \
|
|
176
|
+
"but #{e.class} was raised: #{e.message}"
|
|
177
|
+
end
|
|
178
|
+
else
|
|
179
|
+
fail_assertion(message) { "Expected no exception but #{e.class} was raised: #{e.message}" }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
else
|
|
183
|
+
begin
|
|
184
|
+
block.call
|
|
185
|
+
rescue *error_classes => e
|
|
186
|
+
if match
|
|
187
|
+
satisfied = match.is_a?(Regexp) ? e.message =~ match : e.message.include?(match)
|
|
188
|
+
if satisfied
|
|
189
|
+
fail_assertion(message) do
|
|
190
|
+
"#{e.class} raised with message matching #{match.inspect}: #{e.message}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
else
|
|
194
|
+
fail_assertion(message) do
|
|
195
|
+
"Expected #{error_class_names(error_classes)} not to be raised, " \
|
|
196
|
+
"but #{e.class} was raised: #{e.message}"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Public: Assert that a numeric value is within delta of the expected value.
|
|
204
|
+
# Internally adjusts for floating-point epsilon scaling.
|
|
205
|
+
#
|
|
206
|
+
# expected - Numeric reference value.
|
|
207
|
+
# actual - Numeric value under test.
|
|
208
|
+
# delta - Numeric maximum allowed difference.
|
|
209
|
+
# message - Optional String or Proc failure message.
|
|
210
|
+
#
|
|
211
|
+
# Raises AssertionError when the difference exceeds delta.
|
|
212
|
+
def assert_in_delta(expected:, actual:, delta:, message: nil)
|
|
213
|
+
return if within_delta?(actual, expected, delta)
|
|
214
|
+
fail_assertion(message) do
|
|
215
|
+
"Expected #{actual.inspect} to be within #{delta} of #{expected.inspect}, " \
|
|
216
|
+
"difference was #{(actual - expected).abs}"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Public: Assert that a numeric value is NOT within delta of the expected value.
|
|
221
|
+
#
|
|
222
|
+
# expected - Numeric reference value.
|
|
223
|
+
# actual - Numeric value under test.
|
|
224
|
+
# delta - Numeric maximum allowed difference.
|
|
225
|
+
# message - Optional String or Proc failure message.
|
|
226
|
+
#
|
|
227
|
+
# Raises AssertionError when the difference is within delta.
|
|
228
|
+
def refute_in_delta(expected:, actual:, delta:, message: nil)
|
|
229
|
+
return unless within_delta?(actual, expected, delta)
|
|
230
|
+
fail_assertion(message) do
|
|
231
|
+
"Expected #{actual.inspect} to be outside #{delta} of #{expected.inspect}, " \
|
|
232
|
+
"but difference was #{(actual - expected).abs}"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Public: Assert that a value matches a Regexp or includes a String.
|
|
237
|
+
#
|
|
238
|
+
# pattern - Regexp or String to match against.
|
|
239
|
+
# actual - The value under test.
|
|
240
|
+
# message - Optional String or Proc failure message.
|
|
241
|
+
#
|
|
242
|
+
# Raises AssertionError when actual does not match pattern.
|
|
243
|
+
def assert_match(pattern:, actual:, message: nil)
|
|
244
|
+
return if match?(pattern, actual)
|
|
245
|
+
fail_assertion(message) { "Expected #{actual.inspect} to match #{pattern.inspect}" }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Public: Assert that a value does NOT match a Regexp or include a String.
|
|
249
|
+
#
|
|
250
|
+
# pattern - Regexp or String to match against.
|
|
251
|
+
# actual - The value under test.
|
|
252
|
+
# message - Optional String or Proc failure message.
|
|
253
|
+
#
|
|
254
|
+
# Raises AssertionError when actual matches pattern.
|
|
255
|
+
def refute_match(pattern:, actual:, message: nil)
|
|
256
|
+
return unless match?(pattern, actual)
|
|
257
|
+
fail_assertion(message) { "Expected #{actual.inspect} not to match #{pattern.inspect}" }
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Public: Assert that a collection includes the given item.
|
|
261
|
+
#
|
|
262
|
+
# item - The element to look for.
|
|
263
|
+
# collection - An object responding to `include?`.
|
|
264
|
+
# message - Optional String or Proc failure message.
|
|
265
|
+
#
|
|
266
|
+
# Raises AssertionError when item is not found.
|
|
267
|
+
def assert_includes(item:, collection:, message: nil)
|
|
268
|
+
return if collection.include?(item)
|
|
269
|
+
fail_assertion(message) { "Expected collection to include #{item.inspect}" }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Public: Assert that a collection does NOT include the given item.
|
|
273
|
+
#
|
|
274
|
+
# item - The element to look for.
|
|
275
|
+
# collection - An object responding to `include?`.
|
|
276
|
+
# message - Optional String or Proc failure message.
|
|
277
|
+
#
|
|
278
|
+
# Raises AssertionError when item is found.
|
|
279
|
+
def refute_includes(item:, collection:, message: nil)
|
|
280
|
+
return unless collection.include?(item)
|
|
281
|
+
fail_assertion(message) { "Expected collection not to include #{item.inspect}" }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
private
|
|
285
|
+
|
|
286
|
+
def fail_assertion(custom_message, &default_message)
|
|
287
|
+
msg = custom_message ? resolve_message(custom_message) : default_message.call
|
|
288
|
+
raise Verity::AssertionError, msg
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def resolve_message(message)
|
|
292
|
+
message.is_a?(Proc) ? message.call : message
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def within_delta?(actual, expected, delta)
|
|
296
|
+
# Scale epsilon by the magnitude of the operands to account for floating
|
|
297
|
+
# point representation error (e.g. (1.1 - 1.0).abs > 0.1 in IEEE 754).
|
|
298
|
+
tolerance = delta + Float::EPSILON * [actual.abs, expected.abs].max
|
|
299
|
+
(actual - expected).abs <= tolerance
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def match?(pattern, actual)
|
|
303
|
+
pattern.is_a?(Regexp) ? actual =~ pattern : actual.include?(pattern)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def error_class_names(classes)
|
|
307
|
+
classes.map(&:name).join(", ")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def format_diff(actual, expected)
|
|
311
|
+
actual_str = PP.pp(actual, +"").chomp
|
|
312
|
+
expected_str = PP.pp(expected, +"").chomp
|
|
313
|
+
" actual: #{actual_str}\n expected: #{expected_str}"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Verity
|
|
6
|
+
# Public: Holds all user-configurable settings for a Verity test run.
|
|
7
|
+
# Access via `Verity.configuration` or inside a `Verity.configure` block.
|
|
8
|
+
#
|
|
9
|
+
# Examples
|
|
10
|
+
#
|
|
11
|
+
# Verity.configure do |c|
|
|
12
|
+
# c.test_globs = ["test/**/*_test.rb"]
|
|
13
|
+
# c.worker_count = :cpus
|
|
14
|
+
# c.reporter = Verity::Reporters::DotsReporter.new($stdout)
|
|
15
|
+
# end
|
|
16
|
+
class Configuration
|
|
17
|
+
# Public: String path for the SQLite manifest database. Use ":memory:" for
|
|
18
|
+
# an in-process database (single-worker only; cannot be used with parallel
|
|
19
|
+
# workers). Default: "verity/manifest.db" (relative to the process working
|
|
20
|
+
# directory, typically the project root).
|
|
21
|
+
#
|
|
22
|
+
# Public: Array of glob Strings matched against the working directory to
|
|
23
|
+
# discover test files. Default: ["verity/**/*_test.rb"].
|
|
24
|
+
#
|
|
25
|
+
# Public: Integer worker count, or :cpus / "cpus" to auto-detect from
|
|
26
|
+
# Etc.nprocessors. Default: 1.
|
|
27
|
+
#
|
|
28
|
+
# Public: Object implementing the Verity::Reporter interface that receives
|
|
29
|
+
# lifecycle callbacks. Default: ColoredDotsReporter writing to $stdout.
|
|
30
|
+
#
|
|
31
|
+
# Public: Test dispatch order for manifest runs: :random (default; shuffled
|
|
32
|
+
# once in the coordinator) or :fingerprint (sorted). A non-nil #shuffle_seed
|
|
33
|
+
# always implies a shuffle, even if test_order is :fingerprint.
|
|
34
|
+
#
|
|
35
|
+
# Public: Integer RNG seed for shuffled order. When nil and order is random,
|
|
36
|
+
# a seed is chosen, stored here, and printed to stderr (the number only)
|
|
37
|
+
# before workers start.
|
|
38
|
+
#
|
|
39
|
+
# Public: Optional Array of [absolute_path, Integer line] pairs (from CLI
|
|
40
|
+
# file:line). When non-empty, only tests whose #line matches, or that have
|
|
41
|
+
# an enclosing #group opened on that file:line, are runnable.
|
|
42
|
+
attr_accessor :manifest_path, :test_globs, :worker_count, :reporter,
|
|
43
|
+
:test_order, :shuffle_seed, :location_filters
|
|
44
|
+
|
|
45
|
+
def initialize
|
|
46
|
+
set_defaults!
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def set_defaults!
|
|
50
|
+
@manifest_path = "verity/manifest.db"
|
|
51
|
+
@test_globs = ["verity/**/*_test.rb"]
|
|
52
|
+
@worker_count = :cpus
|
|
53
|
+
@reporter = Verity::Reporters::ColoredDotsReporter.new($stdout)
|
|
54
|
+
@test_order = :random
|
|
55
|
+
@shuffle_seed = nil
|
|
56
|
+
@location_filters = []
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Public: Resolve worker_count to an Integer, expanding :cpus to the
|
|
60
|
+
# number of available processors.
|
|
61
|
+
#
|
|
62
|
+
# Returns a positive Integer.
|
|
63
|
+
# Raises ArgumentError if the value cannot be resolved or is less than 1.
|
|
64
|
+
def resolved_worker_count
|
|
65
|
+
n =
|
|
66
|
+
if self.class.cpus_worker_token?(worker_count)
|
|
67
|
+
[Etc.nprocessors, 1].max
|
|
68
|
+
else
|
|
69
|
+
begin
|
|
70
|
+
Integer(worker_count)
|
|
71
|
+
rescue TypeError, ArgumentError
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"worker_count must be a positive Integer or :cpus / \"cpus\" (got #{worker_count.inspect})"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
raise ArgumentError, "worker_count must be >= 1 (got #{n})" if n < 1
|
|
77
|
+
|
|
78
|
+
n
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Public: Check whether the manifest is configured as in-memory.
|
|
82
|
+
#
|
|
83
|
+
# Returns true when manifest_path is ":memory:".
|
|
84
|
+
def memory_manifest?
|
|
85
|
+
manifest_path == ":memory:"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Public: Expand test_globs into a sorted, deduplicated list of file paths.
|
|
89
|
+
#
|
|
90
|
+
# Returns a sorted Array of String file paths.
|
|
91
|
+
def test_files
|
|
92
|
+
test_globs.flat_map { |pattern| Dir.glob(pattern) }.uniq.sort
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class << self
|
|
96
|
+
# Internal: Determine whether a value represents the :cpus worker token.
|
|
97
|
+
# Accepts :cpus, :cpu, "cpus", or "cpu" (case-insensitive).
|
|
98
|
+
#
|
|
99
|
+
# value - Symbol or String to check.
|
|
100
|
+
#
|
|
101
|
+
# Returns true if the value is a cpus token.
|
|
102
|
+
def cpus_worker_token?(value)
|
|
103
|
+
case value
|
|
104
|
+
when :cpus, :cpu
|
|
105
|
+
true
|
|
106
|
+
when String
|
|
107
|
+
s = value.strip.downcase
|
|
108
|
+
s == "cpus" || s == "cpu"
|
|
109
|
+
else
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class << self
|
|
117
|
+
def configuration
|
|
118
|
+
@configuration ||= Configuration.new
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def configure
|
|
122
|
+
yield configuration
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def reset_configuration!
|
|
126
|
+
@configuration = Configuration.new
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "prism"
|
|
6
|
+
|
|
7
|
+
module Verity
|
|
8
|
+
# Public: Content-addressed fingerprinting for test bodies. Parses source
|
|
9
|
+
# files with Prism to produce stable identifiers that survive line-number
|
|
10
|
+
# shifts when unrelated code changes, while disambiguating tests with
|
|
11
|
+
# identical bodies by appending the line number.
|
|
12
|
+
module Fingerprint
|
|
13
|
+
HEX_LENGTH = 16
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
THREAD_KEY = :__verity_fp_plan__
|
|
17
|
+
|
|
18
|
+
# Internal: Parse the given source file and install its line-to-fingerprint
|
|
19
|
+
# mapping on the current thread. Must be called before loading a test file.
|
|
20
|
+
#
|
|
21
|
+
# absolute_path - String absolute filesystem path to the source file.
|
|
22
|
+
#
|
|
23
|
+
# Returns the plan Hash (line => fingerprint).
|
|
24
|
+
def install_plan!(absolute_path)
|
|
25
|
+
Thread.current[THREAD_KEY] = plan_file(absolute_path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Internal: Remove the current thread's fingerprint plan. Called after
|
|
29
|
+
# a test file finishes loading.
|
|
30
|
+
#
|
|
31
|
+
# Returns nil.
|
|
32
|
+
def clear_plan!
|
|
33
|
+
Thread.current[THREAD_KEY] = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Internal: Look up the fingerprint for a source line from the current
|
|
37
|
+
# thread's installed plan.
|
|
38
|
+
#
|
|
39
|
+
# line - Integer source line number.
|
|
40
|
+
#
|
|
41
|
+
# Returns a String fingerprint, or nil if no plan is active or the line
|
|
42
|
+
# has no entry.
|
|
43
|
+
def lookup(line)
|
|
44
|
+
Thread.current[THREAD_KEY]&.[](line)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Public: Generate a location-based fingerprint when the Prism plan
|
|
48
|
+
# does not cover a given line (e.g. dynamically generated tests).
|
|
49
|
+
#
|
|
50
|
+
# file - String file path.
|
|
51
|
+
# line - Integer line number.
|
|
52
|
+
#
|
|
53
|
+
# Returns a String in the form "relative/path:hex".
|
|
54
|
+
def fallback_fingerprint(file, line)
|
|
55
|
+
rel = relative_source_path(file)
|
|
56
|
+
sha = Digest::SHA1.hexdigest("#{file}:#{line}")[0, HEX_LENGTH]
|
|
57
|
+
"#{rel}:#{sha}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Internal: Parse a source file and build a Hash mapping each `test`
|
|
61
|
+
# call's line number to a content-addressed fingerprint string.
|
|
62
|
+
# Duplicate body hashes within the same file are disambiguated by
|
|
63
|
+
# appending the line number.
|
|
64
|
+
#
|
|
65
|
+
# absolute_path - String absolute path to the Ruby source file.
|
|
66
|
+
#
|
|
67
|
+
# Returns a Hash { Integer => String }.
|
|
68
|
+
def plan_file(absolute_path)
|
|
69
|
+
source = File.read(absolute_path, encoding: "UTF-8")
|
|
70
|
+
result = Prism.parse(source, filepath: File.expand_path(absolute_path))
|
|
71
|
+
return {} unless result.success?
|
|
72
|
+
|
|
73
|
+
program = result.value
|
|
74
|
+
rows = []
|
|
75
|
+
each_load_time_test(program) do |call|
|
|
76
|
+
body = call.block.body
|
|
77
|
+
canon = canonical(body)
|
|
78
|
+
body_hex = Digest::SHA1.hexdigest(canon)[0, HEX_LENGTH]
|
|
79
|
+
rows << { line: call.location.start_line, body_hex: body_hex }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
relative = relative_source_path(absolute_path)
|
|
83
|
+
by_hex = rows.group_by { _1[:body_hex] }
|
|
84
|
+
plan = {}
|
|
85
|
+
rows.each do |row|
|
|
86
|
+
line = row[:line]
|
|
87
|
+
body_hex = row[:body_hex]
|
|
88
|
+
plan[line] =
|
|
89
|
+
if by_hex[body_hex].length > 1
|
|
90
|
+
"#{relative}:#{body_hex}:#{line}"
|
|
91
|
+
else
|
|
92
|
+
"#{relative}:#{body_hex}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
plan
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def derive_method_suffix(fingerprint)
|
|
99
|
+
parts = fingerprint.split(":")
|
|
100
|
+
hex =
|
|
101
|
+
if parts.size >= 3 && parts.last.match?(/\A\d+\z/)
|
|
102
|
+
parts[-2]
|
|
103
|
+
else
|
|
104
|
+
parts[-1]
|
|
105
|
+
end
|
|
106
|
+
raise ArgumentError, "invalid fingerprint (expected ...:#{HEX_LENGTH} hex chars): #{fingerprint}" unless /\A[a-f0-9]{#{HEX_LENGTH}}\z/.match?(hex)
|
|
107
|
+
|
|
108
|
+
hex
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def relative_source_path(absolute_path)
|
|
114
|
+
abs = File.expand_path(absolute_path)
|
|
115
|
+
Pathname(abs).relative_path_from(Pathname(Dir.pwd)).to_s
|
|
116
|
+
rescue ArgumentError
|
|
117
|
+
File.basename(abs)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def each_load_time_test(node, inside_block = false, &block)
|
|
121
|
+
return unless node.is_a?(Prism::Node)
|
|
122
|
+
|
|
123
|
+
if node.is_a?(Prism::CallNode) && !inside_block && verity_test_call?(node)
|
|
124
|
+
block.call(node)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
node.compact_child_nodes.each do |child|
|
|
128
|
+
deeper = inside_block
|
|
129
|
+
deeper = true if node.is_a?(Prism::CallNode) && child.is_a?(Prism::BlockNode)
|
|
130
|
+
each_load_time_test(child, deeper, &block)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def verity_test_call?(node)
|
|
135
|
+
node.is_a?(Prism::CallNode) && node.name == :test && node.receiver.nil? && node.block
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def canonical(node)
|
|
139
|
+
return "" if node.nil?
|
|
140
|
+
|
|
141
|
+
if node.is_a?(Prism::StatementsNode)
|
|
142
|
+
return node.body.map { canonical(_1) }.join(";")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
if node.is_a?(Prism::Node)
|
|
146
|
+
label = node.class.name.split("::").last
|
|
147
|
+
inner = node.compact_child_nodes.map { canonical(_1) }.join(" ")
|
|
148
|
+
return "(#{label} #{inner})"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
raise ArgumentError, "unexpected node: #{node.class}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|