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
data/lib/verity.rb
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "verity/version"
|
|
4
|
+
require_relative "verity/fingerprint"
|
|
5
|
+
require_relative "verity/reporter"
|
|
6
|
+
require_relative "verity/reporters/parallel_summary_reporter"
|
|
7
|
+
require_relative "verity/reporters/documentation_reporter"
|
|
8
|
+
require_relative "verity/reporters/dots_reporter"
|
|
9
|
+
require_relative "verity/reporters/colored_dots"
|
|
10
|
+
require_relative "verity/reporters/null_reporter"
|
|
11
|
+
require_relative "verity/reporters/test_reporter"
|
|
12
|
+
require_relative "verity/reporters/composite_reporter"
|
|
13
|
+
require_relative "verity/configuration"
|
|
14
|
+
require_relative "verity/assertions"
|
|
15
|
+
require_relative "verity/manifest"
|
|
16
|
+
require_relative "verity/runner"
|
|
17
|
+
|
|
18
|
+
module Verity
|
|
19
|
+
# Public: Source location of an enclosing `group` block at registration time.
|
|
20
|
+
GroupScope = Data.define(:title, :file, :line)
|
|
21
|
+
|
|
22
|
+
# Public: Immutable value object representing a single registered test case.
|
|
23
|
+
#
|
|
24
|
+
# fingerprint - String content-based identifier for the test body.
|
|
25
|
+
# description - String human-readable name supplied to the `test` DSL.
|
|
26
|
+
# tags - Array of Symbols applied directly to the test.
|
|
27
|
+
# timeout - Numeric seconds (or nil); enforced by Runner with stdlib Timeout.
|
|
28
|
+
# requires - Array of Symbols naming shared preconditions.
|
|
29
|
+
# resources - Hash of keyword resources forwarded from `test`.
|
|
30
|
+
# file - String absolute path of the source file.
|
|
31
|
+
# line - Integer source line number.
|
|
32
|
+
# fn - Proc (block) containing the test body.
|
|
33
|
+
# group_path - Frozen Array of Strings representing nested group titles.
|
|
34
|
+
# inherited_group_tags - Frozen Array of Symbols from enclosing group tags.
|
|
35
|
+
# group_scopes - Frozen Array of GroupScope (enclosing groups, outer first).
|
|
36
|
+
Test = Data.define(
|
|
37
|
+
:fingerprint, :description, :tags, :timeout, :requires, :resources, :file, :line, :fn,
|
|
38
|
+
:group_path, :inherited_group_tags, :group_scopes
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Public: Compute all tags that apply to a test, combining enclosing group
|
|
42
|
+
# tags with the test's own tags (outer groups first).
|
|
43
|
+
#
|
|
44
|
+
# test - A Verity::Test instance.
|
|
45
|
+
#
|
|
46
|
+
# Returns an Array of Symbols.
|
|
47
|
+
def self.effective_tags(test)
|
|
48
|
+
Array(test.inherited_group_tags).map(&:to_sym) + Array(test.tags).map(&:to_sym)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Public: Validate a value for Verity::Test#timeout.
|
|
52
|
+
#
|
|
53
|
+
# Allows +nil+ (no limit). Otherwise +timeout+ must be a finite Numeric
|
|
54
|
+
# strictly greater than zero (+Complex+ is rejected).
|
|
55
|
+
#
|
|
56
|
+
# Raises ArgumentError when invalid.
|
|
57
|
+
def self.validate_test_timeout!(timeout)
|
|
58
|
+
return if timeout.nil?
|
|
59
|
+
|
|
60
|
+
unless timeout.is_a?(Numeric) && !timeout.is_a?(Complex)
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"test timeout must be nil or a positive finite Numeric (got #{timeout.class}: #{timeout.inspect})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
non_finite =
|
|
66
|
+
(timeout.is_a?(Float) && !timeout.finite?) ||
|
|
67
|
+
(timeout.respond_to?(:infinite?) && !timeout.infinite?.nil?)
|
|
68
|
+
|
|
69
|
+
raise ArgumentError, "test timeout must be finite (got #{timeout.inspect})" if non_finite
|
|
70
|
+
|
|
71
|
+
return if timeout > 0
|
|
72
|
+
|
|
73
|
+
raise ArgumentError, "test timeout must be positive (got #{timeout.inspect})"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Public: Check whether a test is tagged with :skip.
|
|
77
|
+
#
|
|
78
|
+
# test - A Verity::Test instance.
|
|
79
|
+
#
|
|
80
|
+
# Returns true if the test should be skipped.
|
|
81
|
+
def self.skipped?(test) = effective_tags(test).include?(:skip)
|
|
82
|
+
|
|
83
|
+
# Public: Check whether a test is tagged with :focus.
|
|
84
|
+
#
|
|
85
|
+
# test - A Verity::Test instance.
|
|
86
|
+
#
|
|
87
|
+
# Returns true if the test has the focus tag.
|
|
88
|
+
def self.focus_tag?(test) = effective_tags(test).include?(:focus)
|
|
89
|
+
|
|
90
|
+
# Public: Collect the tests that should actually execute. Skipped tests are
|
|
91
|
+
# excluded; when any remaining test has :focus, only focused tests are kept.
|
|
92
|
+
#
|
|
93
|
+
# Returns an Array of Verity::Test.
|
|
94
|
+
def self.runnable_tests
|
|
95
|
+
base = Registry.all.reject { skipped?(_1) }
|
|
96
|
+
base = if base.any? { focus_tag?(_1) }
|
|
97
|
+
base.select { focus_tag?(_1) }
|
|
98
|
+
else
|
|
99
|
+
base
|
|
100
|
+
end
|
|
101
|
+
filter_by_location_filters(base)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Public: Detect whether focus filtering narrowed the suite — at least one
|
|
105
|
+
# candidate has :focus and at least one does not.
|
|
106
|
+
#
|
|
107
|
+
# candidates - Array of Verity::Test (already excluding skipped tests).
|
|
108
|
+
#
|
|
109
|
+
# Returns true when the suite is a strict focus-filtered subset.
|
|
110
|
+
def self.focus_filter_active?(candidates)
|
|
111
|
+
return false if candidates.empty?
|
|
112
|
+
|
|
113
|
+
candidates.any? { focus_tag?(_1) } && candidates.any? { !focus_tag?(_1) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Internal: Keep tests that match any configured location filter (test line
|
|
117
|
+
# or an enclosing group line). Paths compared via File.expand_path.
|
|
118
|
+
#
|
|
119
|
+
# tests - Array of Verity::Test (typically after skip/focus narrowing).
|
|
120
|
+
#
|
|
121
|
+
# Returns a filtered Array.
|
|
122
|
+
def self.filter_by_location_filters(tests)
|
|
123
|
+
filters = configuration.location_filters
|
|
124
|
+
return tests if filters.nil? || filters.empty?
|
|
125
|
+
|
|
126
|
+
matched = tests.select { |t| filters.any? { |pair| location_filter_match?(t, pair[0], pair[1]) } }
|
|
127
|
+
if matched.empty? && !tests.empty?
|
|
128
|
+
hint = filters.map { |p, l| "#{File.expand_path(p)}:#{l}" }.join(", ")
|
|
129
|
+
warn "verity: no tests matched location filter (#{hint})"
|
|
130
|
+
end
|
|
131
|
+
matched
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Internal: True if (path, line) is this test's `test` line or a GroupScope line.
|
|
135
|
+
def self.location_filter_match?(test, path, line)
|
|
136
|
+
exp = File.expand_path(path)
|
|
137
|
+
return true if File.expand_path(test.file) == exp && test.line == line
|
|
138
|
+
|
|
139
|
+
test.group_scopes.any? { |g| File.expand_path(g.file) == exp && g.line == line }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Internal: Push a group frame onto the current thread's group stack.
|
|
143
|
+
# Called by DSL#group during test file loading.
|
|
144
|
+
#
|
|
145
|
+
# title - String title for the group.
|
|
146
|
+
# tags - Array of Symbols (default []).
|
|
147
|
+
# file - String absolute path of the `group` call site.
|
|
148
|
+
# line - Integer line of the `group` call.
|
|
149
|
+
#
|
|
150
|
+
# Returns the updated stack Array.
|
|
151
|
+
def self.push_group(title, tags:, file:, line:)
|
|
152
|
+
entry = {
|
|
153
|
+
title: title.to_s,
|
|
154
|
+
tags: Array(tags).map(&:to_sym),
|
|
155
|
+
file: file,
|
|
156
|
+
line: line
|
|
157
|
+
}
|
|
158
|
+
(Thread.current[:verity_group_stack] ||= []) << entry
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Internal: Pop the most recent group frame from the current thread's stack.
|
|
162
|
+
#
|
|
163
|
+
# Returns the removed Hash entry, or nil.
|
|
164
|
+
def self.pop_group
|
|
165
|
+
Thread.current[:verity_group_stack]&.pop
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Internal: Snapshot of nested group titles for the current thread, used
|
|
169
|
+
# at registration time to capture a test's group ancestry.
|
|
170
|
+
#
|
|
171
|
+
# Returns a frozen Array of Strings.
|
|
172
|
+
def self.group_path_for_registration
|
|
173
|
+
stack = Thread.current[:verity_group_stack]
|
|
174
|
+
return [].freeze if stack.nil? || stack.empty?
|
|
175
|
+
|
|
176
|
+
stack.map { _1[:title] }.freeze
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Internal: Enclosing group source scopes for the current thread (outer first).
|
|
180
|
+
#
|
|
181
|
+
# Returns a frozen Array of GroupScope.
|
|
182
|
+
def self.group_scopes_for_registration
|
|
183
|
+
stack = Thread.current[:verity_group_stack]
|
|
184
|
+
return [].freeze if stack.nil? || stack.empty?
|
|
185
|
+
|
|
186
|
+
stack.map { |g| GroupScope.new(g[:title], g[:file], g[:line]) }.freeze
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Internal: Collect all tags from enclosing groups for the current thread,
|
|
190
|
+
# flattened in nesting order (outermost first).
|
|
191
|
+
#
|
|
192
|
+
# Returns a frozen Array of Symbols.
|
|
193
|
+
def self.inherited_group_tags_for_registration
|
|
194
|
+
stack = Thread.current[:verity_group_stack]
|
|
195
|
+
return [].freeze if stack.nil? || stack.empty?
|
|
196
|
+
|
|
197
|
+
stack.flat_map { |g| g[:tags] }.freeze
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Internal: Reset the current thread's group stack to empty. Called before
|
|
201
|
+
# loading each test file to prevent cross-file leakage.
|
|
202
|
+
#
|
|
203
|
+
# Returns an empty Array.
|
|
204
|
+
def self.clear_group_stack!
|
|
205
|
+
Thread.current[:verity_group_stack] = []
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Public: Resolve a reporter instance from a CLI or config string.
|
|
209
|
+
#
|
|
210
|
+
# Built-in names (case-insensitive): "documentation" ("doc"), "colored"
|
|
211
|
+
# ("colored_dots"), "dots", "null" ("none", "silent"). Custom reporters
|
|
212
|
+
# use the form "path/to/reporter.rb:ClassName".
|
|
213
|
+
#
|
|
214
|
+
# spec - String reporter name or "path:ClassName" pair.
|
|
215
|
+
#
|
|
216
|
+
# Examples
|
|
217
|
+
#
|
|
218
|
+
# Verity.build_reporter("dots")
|
|
219
|
+
# # => #<Verity::Reporters::DotsReporter ...>
|
|
220
|
+
#
|
|
221
|
+
# Verity.build_reporter("./my_reporter.rb:MyReporter")
|
|
222
|
+
# # => #<MyReporter ...>
|
|
223
|
+
#
|
|
224
|
+
# Returns an Object that includes Verity::Reporter.
|
|
225
|
+
# Raises ArgumentError if the spec is blank or unrecognised.
|
|
226
|
+
def self.build_reporter(spec)
|
|
227
|
+
raise ArgumentError, "reporter name cannot be blank" if spec.nil? || spec.strip.empty?
|
|
228
|
+
|
|
229
|
+
case spec.strip.downcase
|
|
230
|
+
when "documentation", "doc"
|
|
231
|
+
Reporters::DocumentationReporter.new($stdout)
|
|
232
|
+
when "colored", "colored_dots"
|
|
233
|
+
Reporters::ColoredDotsReporter.new($stdout)
|
|
234
|
+
when "dots"
|
|
235
|
+
Reporters::DotsReporter.new($stdout)
|
|
236
|
+
when "null", "none", "silent"
|
|
237
|
+
Reporters::NullReporter.new
|
|
238
|
+
else
|
|
239
|
+
reporter_from_path_and_class(spec.strip)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def self.reporter_from_path_and_class(spec)
|
|
244
|
+
raise ArgumentError, build_reporter_unknown_message(spec) unless spec.include?(":")
|
|
245
|
+
|
|
246
|
+
path, cname = spec.split(":", 2)
|
|
247
|
+
path = path.strip
|
|
248
|
+
cname = cname.strip
|
|
249
|
+
if path.empty? || cname.empty?
|
|
250
|
+
raise ArgumentError, "custom reporter must be path/to.rb:ClassName (got #{spec.inspect})"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
abs = File.expand_path(path)
|
|
254
|
+
unless File.file?(abs)
|
|
255
|
+
raise ArgumentError, "reporter file not found: #{abs}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
load abs
|
|
259
|
+
cls = constantize_reporter_class(cname)
|
|
260
|
+
unless cls.is_a?(Class) && cls.included_modules.include?(Reporter)
|
|
261
|
+
raise ArgumentError, "#{cname} must be a class that includes Verity::Reporter (got #{cls.class})"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
cls.new
|
|
265
|
+
end
|
|
266
|
+
private_class_method :reporter_from_path_and_class
|
|
267
|
+
|
|
268
|
+
def self.constantize_reporter_class(cname)
|
|
269
|
+
parts = cname.split("::")
|
|
270
|
+
parts.shift if parts.first&.empty?
|
|
271
|
+
raise ArgumentError, "invalid reporter class name #{cname.inspect}" if parts.empty? || parts.any?(&:empty?)
|
|
272
|
+
|
|
273
|
+
parts.reduce(Object) { |mod, part| mod.const_get(part, false) }
|
|
274
|
+
end
|
|
275
|
+
private_class_method :constantize_reporter_class
|
|
276
|
+
|
|
277
|
+
def self.build_reporter_unknown_message(spec)
|
|
278
|
+
"unknown reporter #{spec.inspect}; use documentation, colored, dots, null, or path/to.rb:ClassName"
|
|
279
|
+
end
|
|
280
|
+
private_class_method :build_reporter_unknown_message
|
|
281
|
+
|
|
282
|
+
# Internal: Global test registry. Tests are appended during file loading
|
|
283
|
+
# and queried at run time by the Runner and manifest.
|
|
284
|
+
module Registry
|
|
285
|
+
@tests = []
|
|
286
|
+
|
|
287
|
+
# Internal: Add a test to the global registry.
|
|
288
|
+
#
|
|
289
|
+
# test - A Verity::Test instance.
|
|
290
|
+
#
|
|
291
|
+
# Returns the updated Array.
|
|
292
|
+
def self.register(test) = @tests << test
|
|
293
|
+
|
|
294
|
+
# Internal: Return a shallow copy of all registered tests.
|
|
295
|
+
#
|
|
296
|
+
# Returns an Array of Verity::Test.
|
|
297
|
+
def self.all = @tests.dup
|
|
298
|
+
|
|
299
|
+
# Internal: Remove every registered test. Used before re-loading files.
|
|
300
|
+
#
|
|
301
|
+
# Returns an empty Array.
|
|
302
|
+
def self.clear = @tests.clear
|
|
303
|
+
|
|
304
|
+
# Internal: Look up a test by its fingerprint string.
|
|
305
|
+
#
|
|
306
|
+
# fingerprint - String fingerprint to match.
|
|
307
|
+
#
|
|
308
|
+
# Returns a Verity::Test or nil.
|
|
309
|
+
def self.find(fingerprint) = @tests.find { |t| t.fingerprint == fingerprint }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Public: Methods mixed into Object so that `test` and `group` are
|
|
313
|
+
# available at the top level in test files.
|
|
314
|
+
module DSL
|
|
315
|
+
include Assertions
|
|
316
|
+
|
|
317
|
+
# Public: Define a named group of tests. Groups may be nested and
|
|
318
|
+
# contribute tags that are inherited by every enclosed test.
|
|
319
|
+
#
|
|
320
|
+
# title - String group name shown in reporter output.
|
|
321
|
+
# tags - Array of Symbols applied to all tests in this group (default []).
|
|
322
|
+
# block - Block containing nested `test` and `group` calls.
|
|
323
|
+
#
|
|
324
|
+
# Raises ArgumentError if no block is given.
|
|
325
|
+
def group(title, tags: [], &block)
|
|
326
|
+
raise ArgumentError, "`group` requires a block" unless block
|
|
327
|
+
|
|
328
|
+
loc = caller_locations(1, 1).first
|
|
329
|
+
pushed = false
|
|
330
|
+
Verity.push_group(title, tags: tags, file: loc.path, line: loc.lineno)
|
|
331
|
+
pushed = true
|
|
332
|
+
yield
|
|
333
|
+
ensure
|
|
334
|
+
Verity.pop_group if pushed
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Public: Register a single test case. The block is stored and executed
|
|
338
|
+
# later by the Runner.
|
|
339
|
+
#
|
|
340
|
+
# description - String human-readable test name.
|
|
341
|
+
# tags - Array of Symbols (e.g. :focus, :skip) (default []).
|
|
342
|
+
# timeout - Numeric seconds or nil for no timeout (default nil). If set,
|
|
343
|
+
# must be a positive finite Numeric (+Complex+ and strings are rejected).
|
|
344
|
+
# requires - Array of Symbols naming shared preconditions (default []).
|
|
345
|
+
# resources - Hash of keyword arguments forwarded as resource metadata.
|
|
346
|
+
# fn - Block containing assertions and test logic.
|
|
347
|
+
#
|
|
348
|
+
# Returns the newly registered Verity::Test.
|
|
349
|
+
def test(description, tags: [], timeout: nil, requires: [], **resources, &fn)
|
|
350
|
+
Verity.validate_test_timeout!(timeout)
|
|
351
|
+
location = caller_locations(1, 1).first
|
|
352
|
+
file = location.path
|
|
353
|
+
line = location.lineno
|
|
354
|
+
fingerprint = Verity::Fingerprint.lookup(line) || Verity::Fingerprint.fallback_fingerprint(file, line)
|
|
355
|
+
|
|
356
|
+
Verity::Registry.register(
|
|
357
|
+
Verity::Test.new(
|
|
358
|
+
fingerprint:,
|
|
359
|
+
description:,
|
|
360
|
+
tags:,
|
|
361
|
+
timeout:,
|
|
362
|
+
requires:,
|
|
363
|
+
resources:,
|
|
364
|
+
file:,
|
|
365
|
+
line:,
|
|
366
|
+
fn:,
|
|
367
|
+
group_path: Verity.group_path_for_registration,
|
|
368
|
+
inherited_group_tags: Verity.inherited_group_tags_for_registration,
|
|
369
|
+
group_scopes: Verity.group_scopes_for_registration
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Public: Register a callback invoked once per worker process before any
|
|
376
|
+
# tests run (useful for DB setup, connection pooling, etc.).
|
|
377
|
+
#
|
|
378
|
+
# block - Proc to execute.
|
|
379
|
+
#
|
|
380
|
+
# Returns the updated callback Array.
|
|
381
|
+
def self.before_worker_start(&block) = hooks[:before_worker_start] << block
|
|
382
|
+
|
|
383
|
+
# Public: Register a callback invoked before each individual test.
|
|
384
|
+
#
|
|
385
|
+
# block - Proc to execute.
|
|
386
|
+
#
|
|
387
|
+
# Returns the updated callback Array.
|
|
388
|
+
def self.before_test(&block) = hooks[:before_test] << block
|
|
389
|
+
|
|
390
|
+
# Public: Register a callback invoked after each individual test.
|
|
391
|
+
#
|
|
392
|
+
# block - Proc to execute.
|
|
393
|
+
#
|
|
394
|
+
# Returns the updated callback Array.
|
|
395
|
+
def self.after_test(&block) = hooks[:after_test] << block
|
|
396
|
+
|
|
397
|
+
# Public: Declare a named resource with conflict rules for parallel
|
|
398
|
+
# scheduling.
|
|
399
|
+
#
|
|
400
|
+
# name - Symbol resource name.
|
|
401
|
+
# conflicts_with - Conflict specification stored for the scheduler.
|
|
402
|
+
#
|
|
403
|
+
# Returns the updated resolvers Hash.
|
|
404
|
+
def self.register_resource(name, conflicts_with:)
|
|
405
|
+
resource_resolvers[name] = conflicts_with
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Public: Build the set of test fingerprints that must not be claimed because
|
|
409
|
+
# they conflict with at least one currently-running test's resources.
|
|
410
|
+
#
|
|
411
|
+
# running_resources - Array of resource Hashes (string keys/values) from
|
|
412
|
+
# Manifest#running_resources.
|
|
413
|
+
# tests - Array of Verity::Test to check (default: Registry.all).
|
|
414
|
+
#
|
|
415
|
+
# Returns an Array of fingerprint Strings. Returns [] when no resolvers are
|
|
416
|
+
# registered or running_resources is empty (bypass fast-path).
|
|
417
|
+
def self.conflict_exclusion_list(running_resources, tests: Registry.all)
|
|
418
|
+
return [] if resource_resolvers.empty? || running_resources.empty?
|
|
419
|
+
|
|
420
|
+
tests.select { |t| test_conflicts_with_running?(t.resources, running_resources) }
|
|
421
|
+
.map(&:fingerprint)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def self.test_conflicts_with_running?(test_resources, running_resources)
|
|
425
|
+
resource_resolvers.any? do |resource_type, resolver|
|
|
426
|
+
mine = normalize_resource_value(
|
|
427
|
+
test_resources[resource_type] || test_resources[resource_type.to_s]
|
|
428
|
+
)
|
|
429
|
+
next false if mine.nil? || mine.empty?
|
|
430
|
+
|
|
431
|
+
running_resources.any? do |running_res|
|
|
432
|
+
theirs = normalize_resource_value(
|
|
433
|
+
running_res[resource_type.to_s] || running_res[resource_type]
|
|
434
|
+
)
|
|
435
|
+
next false if theirs.nil? || theirs.empty?
|
|
436
|
+
|
|
437
|
+
resolver.call(mine, theirs)
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
private_class_method :test_conflicts_with_running?
|
|
442
|
+
|
|
443
|
+
def self.normalize_resource_value(val)
|
|
444
|
+
return nil if val.nil? || val == false
|
|
445
|
+
|
|
446
|
+
Array(val).map(&:to_s)
|
|
447
|
+
end
|
|
448
|
+
private_class_method :normalize_resource_value
|
|
449
|
+
|
|
450
|
+
# Internal: Lazily-initialised Hash of lifecycle hook Arrays keyed by
|
|
451
|
+
# :before_worker_start, :before_test, and :after_test.
|
|
452
|
+
#
|
|
453
|
+
# Returns a Hash.
|
|
454
|
+
def self.hooks
|
|
455
|
+
@hooks ||= { before_worker_start: [], before_test: [], after_test: [] }
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Internal: Lazily-initialised Hash mapping resource names to their
|
|
459
|
+
# conflict specifications.
|
|
460
|
+
#
|
|
461
|
+
# Returns a Hash.
|
|
462
|
+
def self.resource_resolvers
|
|
463
|
+
@resource_resolvers ||= {}
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Public: Discover and load all test files according to Configuration#test_globs.
|
|
467
|
+
# Clears the registry, installs fingerprint plans, and loads each file.
|
|
468
|
+
#
|
|
469
|
+
# Returns nothing meaningful.
|
|
470
|
+
def self.load_discovery!
|
|
471
|
+
Registry.clear
|
|
472
|
+
configuration.test_files.each do |path|
|
|
473
|
+
clear_group_stack!
|
|
474
|
+
abs = File.expand_path(path)
|
|
475
|
+
Verity::Fingerprint.install_plan!(abs)
|
|
476
|
+
begin
|
|
477
|
+
load abs
|
|
478
|
+
ensure
|
|
479
|
+
Verity::Fingerprint.clear_plan!
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Internal: Runnable tests in coordinator dispatch order for the manifest
|
|
485
|
+
# (fingerprint sort or random shuffle). Shuffle is used when test_order is
|
|
486
|
+
# :random or when shuffle_seed is set. Auto-chosen seeds are printed to stderr
|
|
487
|
+
# as a single line (the integer only).
|
|
488
|
+
#
|
|
489
|
+
# Returns Array of Verity::Test.
|
|
490
|
+
def self.ordered_runnable_tests
|
|
491
|
+
list = runnable_tests
|
|
492
|
+
order = configuration.test_order
|
|
493
|
+
unless %i[fingerprint random].include?(order)
|
|
494
|
+
raise ArgumentError,
|
|
495
|
+
"test_order must be :fingerprint or :random (got #{order.inspect})"
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
rng_seed = configuration.shuffle_seed
|
|
499
|
+
use_random = order == :random || !rng_seed.nil?
|
|
500
|
+
|
|
501
|
+
if use_random
|
|
502
|
+
s = rng_seed
|
|
503
|
+
if s.nil?
|
|
504
|
+
s = Random.new_seed
|
|
505
|
+
configuration.shuffle_seed = s
|
|
506
|
+
warn s.to_s
|
|
507
|
+
end
|
|
508
|
+
list.shuffle(random: Random.new(s))
|
|
509
|
+
else
|
|
510
|
+
list.sort_by(&:fingerprint)
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
private_class_method :ordered_runnable_tests
|
|
514
|
+
|
|
515
|
+
# Public: Main entry point — discover tests, set up the manifest, and
|
|
516
|
+
# execute. When worker_count > 1 the run forks child processes that each
|
|
517
|
+
# claim work from a shared SQLite manifest.
|
|
518
|
+
#
|
|
519
|
+
# worker_id - Integer base worker id for single-process mode (default 0).
|
|
520
|
+
#
|
|
521
|
+
# Returns true if every test passed, false otherwise.
|
|
522
|
+
# Raises ArgumentError if parallel mode uses a :memory: manifest.
|
|
523
|
+
# Raises NotImplementedError if fork is unavailable for parallel mode.
|
|
524
|
+
def self.run(worker_id: 0)
|
|
525
|
+
load_discovery!
|
|
526
|
+
|
|
527
|
+
workers = configuration.resolved_worker_count
|
|
528
|
+
|
|
529
|
+
path = configuration.manifest_path
|
|
530
|
+
if workers > 1
|
|
531
|
+
if configuration.memory_manifest?
|
|
532
|
+
raise ArgumentError,
|
|
533
|
+
"manifest_path cannot be :memory: when worker_count > 1 (SQLite memory DBs are not shared across processes)"
|
|
534
|
+
end
|
|
535
|
+
unless Process.respond_to?(:fork)
|
|
536
|
+
raise NotImplementedError, "Parallel workers require Kernel#fork (not available on this platform)"
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
sync_manifest!(path)
|
|
540
|
+
pids = workers.times.map do |wid|
|
|
541
|
+
fork do
|
|
542
|
+
Verity.send(:run_manifest_child, path, worker_id: wid)
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
ok = pids.all? do |pid|
|
|
546
|
+
_, status = Process.wait2(pid)
|
|
547
|
+
status.success?
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
manifest = Manifest.open(path)
|
|
551
|
+
begin
|
|
552
|
+
abandoned = manifest.reclaim_abandoned_running!
|
|
553
|
+
ok &&= abandoned.zero?
|
|
554
|
+
|
|
555
|
+
rep = configuration.reporter
|
|
556
|
+
rep.on_run_start(total: manifest.example_count, worker_id: 0)
|
|
557
|
+
|
|
558
|
+
manifest.each_parallel_replay_result do |result|
|
|
559
|
+
rep.on_test_complete(result: result, worker_id: 0)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
Registry.all.select { skipped?(_1) }.sort_by(&:fingerprint).each do |t|
|
|
563
|
+
rep.on_test_complete(
|
|
564
|
+
result: Runner::Result.new(test: t, status: :skip, error: nil),
|
|
565
|
+
worker_id: 0
|
|
566
|
+
)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
skip_count = Registry.all.count { skipped?(_1) }
|
|
570
|
+
counts = manifest.count_by_status.merge("skipped" => skip_count)
|
|
571
|
+
problem_rows = manifest.failures_for_report
|
|
572
|
+
rep.on_parallel_complete(counts: counts, problem_rows: problem_rows)
|
|
573
|
+
ensure
|
|
574
|
+
manifest.close
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
ok
|
|
578
|
+
else
|
|
579
|
+
manifest = Manifest.open(path)
|
|
580
|
+
begin
|
|
581
|
+
manifest.migrate!
|
|
582
|
+
manifest.replace_tests(ordered_runnable_tests)
|
|
583
|
+
Runner.new.run_manifest(manifest, worker_id:)
|
|
584
|
+
ensure
|
|
585
|
+
manifest.close
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def self.sync_manifest!(path)
|
|
591
|
+
manifest = Manifest.open(path)
|
|
592
|
+
begin
|
|
593
|
+
manifest.migrate!
|
|
594
|
+
manifest.replace_tests(ordered_runnable_tests)
|
|
595
|
+
ensure
|
|
596
|
+
manifest.close
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
private_class_method :sync_manifest!
|
|
600
|
+
|
|
601
|
+
def self.run_manifest_child(path, worker_id:)
|
|
602
|
+
manifest = Manifest.open(path)
|
|
603
|
+
ok = false
|
|
604
|
+
begin
|
|
605
|
+
ok = Runner.new(reporter: Reporters::NullReporter.new).run_manifest(manifest, worker_id:)
|
|
606
|
+
ensure
|
|
607
|
+
manifest.close
|
|
608
|
+
end
|
|
609
|
+
exit(ok ? 0 : 1)
|
|
610
|
+
end
|
|
611
|
+
private_class_method :run_manifest_child
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
Object.include(Verity::DSL)
|