ruptr 0.1.3

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.
data/lib/ruptr/main.rb ADDED
@@ -0,0 +1,439 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'pathname'
5
+
6
+ require_relative 'suite'
7
+ require_relative 'utils'
8
+ require_relative 'runner'
9
+ require_relative 'timing_cache'
10
+ require_relative 'golden_master'
11
+ require_relative 'report'
12
+ require_relative 'sink'
13
+ require_relative 'progress'
14
+ require_relative 'formatter'
15
+ require_relative 'tabular'
16
+ require_relative 'tap'
17
+
18
+ require_relative 'minitest'
19
+ require_relative 'testunit'
20
+ require_relative 'rspec'
21
+
22
+ module Ruptr
23
+ class Main
24
+ DEFAULT_STATE_DIRNAME = '.ruptr-state'
25
+ DEFAULT_PROJECT_LOAD_PATHS = %w[lib].freeze
26
+
27
+ def initialize(project_dir = '.')
28
+ @project_path = Pathname(project_dir)
29
+ @extra_load_paths = []
30
+ @extra_requires = []
31
+ @add_default_project_load_paths = true
32
+ @warnings = $VERBOSE
33
+ @verbosity = 0
34
+ @capture_output = true
35
+ @monkey_patch = false
36
+ @formatter_name = nil
37
+ @runner_name = nil
38
+ @pager_mode = true
39
+ @pager_only_on_problem = true
40
+ @output_path = nil
41
+ @include_names = []
42
+ @exclude_names = []
43
+ @include_tags = []
44
+ @exclude_tags = []
45
+ @include_tags_values = {}
46
+ @exclude_tags_values = {}
47
+ @only_test_files = []
48
+ @operations = []
49
+ end
50
+
51
+ attr_accessor :extra_load_paths,
52
+ :extra_requires,
53
+ :warnings,
54
+ :verbosity,
55
+ :capture_output,
56
+ :monkey_patch,
57
+ :parallel_jobs,
58
+ :output_path,
59
+ :only_test_files
60
+
61
+ def load_all_tests? = @only_test_files.empty?
62
+
63
+ def parse_options(argv)
64
+ argv = argv.dup
65
+ OptionParser.new do |op|
66
+ op.on('-C', '--project-path=PATH') { |s| @project_path = Pathname(s) }
67
+ op.on('--state-directory=PATH') { |s| @state_path = Pathname(s) }
68
+ op.on('--[no-]default-project-load-paths') { |v| @add_default_project_load_paths = v }
69
+ op.on('-I', '--include=PATH') { |s| @extra_load_paths << s }
70
+ op.on('-r', '--require=PATH') { |s| @extra_requires << s }
71
+ op.on('--[no-]capture-output') { |v| @capture_output = v }
72
+ op.on('-m', '--[no-]monkey-patch') { |v| @monkey_patch = v }
73
+ op.on('-w', '--[no-]warnings') { |v| @warnings = v ? true : nil }
74
+ op.on('-o', '--output=PATH') { |s| @output_path = Pathname(s) }
75
+ op.on('-f', '--formatter=NAME') { |s| @formatter_name = s }
76
+ op.on('--[no-]pager') { |v| @pager_mode = v }
77
+ op.on('--[no-]pager-only-on-problem') { |v| @pager_only_on_problem = v }
78
+ op.on('--runner=NAME') { |s| @runner_name = s }
79
+ op.on('-j', '--jobs=N') do |s|
80
+ n = s.to_i
81
+ n = Runner::Parallel.default_parallel_jobs unless n.positive?
82
+ @parallel_jobs = n
83
+ end
84
+ op.on('-e', '--example=STRING') { |s| @include_names << Regexp.new(Regexp.quote(s)) }
85
+ op.on('-E', '--example-matches=REGEXP') { |s| @include_names << Regexp.new(s) }
86
+ op.on('-t', '--tag=TAG[:VALUE]') do |s|
87
+ name, value = s.split(':', 2)
88
+ name.delete_prefix!('~') if (exclude = name.start_with?('~'))
89
+ name = name.to_sym
90
+ if value
91
+ value = value.delete_prefix(':').to_sym if value.start_with?(':')
92
+ (exclude ? @exclude_tags_values : @include_tags_values)[name] = value
93
+ else
94
+ (exclude ? @exclude_tags : @include_tags) << name
95
+ end
96
+ end
97
+ op.on('-q', '--[no-]quiet') { |v| @verbosity -= v ? 1 : 0 }
98
+ op.on('-v', '--[no-]verbose') { |v| @verbosity += v ? 1 : 0 }
99
+ op.on('--golden-accept') { @operations << :golden_accept }
100
+ op.on('--show-test-suite') { @operations << :show_test_suite }
101
+ end.order!(argv)
102
+ @only_test_files = argv.dup
103
+ end
104
+
105
+ def state_path
106
+ @state_path ||= @project_path / Pathname(DEFAULT_STATE_DIRNAME)
107
+ unless @state_path_made
108
+ @state_path.mkpath
109
+ @state_path_made = true
110
+ end
111
+ @state_path
112
+ end
113
+
114
+ def with_state_directory_lock
115
+ (state_path / "lock").open(File::CREAT | File::RDWR) do |io|
116
+ io.flock(File::LOCK_EX | File::LOCK_NB) or fail "state directory locked"
117
+ yield
118
+ end
119
+ end
120
+
121
+ def compat_layers
122
+ @compat_layers ||= Compat.subclasses.map(&:new)
123
+ end
124
+
125
+ def project_load_paths
126
+ @project_load_paths ||=
127
+ (@extra_load_paths +
128
+ if @add_default_project_load_paths
129
+ DEFAULT_PROJECT_LOAD_PATHS.map { |path| (@project_path / path).to_s } +
130
+ compat_layers.flat_map(&:default_project_load_paths)
131
+ else
132
+ []
133
+ end).uniq
134
+ end
135
+
136
+ def each_test_file(&)
137
+ if load_all_tests?
138
+ compat_layers.each { |compat| compat.each_default_project_test_file(@project_path, &) }
139
+ else
140
+ @only_test_files.each(&)
141
+ end
142
+ end
143
+
144
+ private def prepare_compat
145
+ compat_layers.each do |compat|
146
+ compat.global_install!
147
+ compat.global_monkey_patch! if @monkey_patch
148
+ end
149
+ end
150
+
151
+ private def finalize_compat
152
+ compat_layers.each(&:finalize_configuration!)
153
+ end
154
+
155
+ private def load_test_files
156
+ return if @test_files_loaded
157
+ prepare_compat
158
+ @load_user_time, @load_system_time, @load_real_time = Ruptr.measure_processor_and_real_time do
159
+ $LOAD_PATH.unshift(*project_load_paths)
160
+ @extra_requires.each { |name| require(name) }
161
+ each_test_file do |path|
162
+ path = path.to_s
163
+ require(%r{\A\.{0,2}/}.match?(path) ? path : "./#{path}")
164
+ end
165
+ end
166
+ finalize_compat
167
+ @test_files_loaded = true
168
+ end
169
+
170
+ def loaded_test_suite
171
+ @loaded_test_suite ||= TestSuite.new.tap do |ts|
172
+ load_test_files
173
+ @total_loaded_test_cases_before_internal_filtering = 0
174
+ compat_layers.each do |compat|
175
+ tg = compat.adapted_test_group
176
+ @total_loaded_test_cases_before_internal_filtering += tg.count_test_cases
177
+ ts.add_test_subgroup(compat.filter_test_group(tg))
178
+ end
179
+ end
180
+ end
181
+
182
+ def filtered_test_suite
183
+ @filtered_test_suite ||=
184
+ if @include_names.empty? && @exclude_names.empty? &&
185
+ @include_tags.empty? && @exclude_tags.empty? &&
186
+ @include_tags_values.empty? && @exclude_tags_values.empty?
187
+ loaded_test_suite
188
+ else
189
+ loaded_test_suite.filter_test_cases_recursive do |tc|
190
+ (@include_names.empty? || @include_names.any? { |v| v === tc.description }) &&
191
+ (@exclude_names.empty? || @exclude_names.none? { |v| v === tc.description }) &&
192
+ (@include_tags.empty? || @include_tags.any? { |k| tc.tags.include?(k) }) &&
193
+ (@exclude_tags.empty? || @exclude_tags.none? { |k| tc.tags.include?(k) }) &&
194
+ (@include_tags_values.empty? || @include_tags_values.any? { |k, v| v === tc.tags[k] }) &&
195
+ (@exclude_tags_values.empty? || @exclude_tags_values.none? { |k, v| v === tc.tags[k] })
196
+ end
197
+ end
198
+ end
199
+
200
+ def report_total_filtered
201
+ return if @verbosity.negative?
202
+ loaded_test_suite
203
+ n = @total_loaded_test_cases_before_internal_filtering
204
+ m = filtered_test_suite.count_test_cases
205
+ $stderr.puts "#{n - m} test cases filtered" unless n == m
206
+ end
207
+
208
+ def pager_mode?
209
+ @pager_mode && !@output_path && $stdout.tty?
210
+ end
211
+
212
+ private def open_output
213
+ fail if @output_file
214
+ if pager_mode? && (!@pager_only_on_problem || test_suite_problem?)
215
+ @output_file = IO.popen(ENV['PAGER'] || 'more', 'w',
216
+ external_encoding: $stdout.external_encoding)
217
+ @output_file_close = true
218
+ else
219
+ if @output_path
220
+ @output_file = @output_path.open('w')
221
+ @output_file_close = true
222
+ else
223
+ @output_file = $stdout
224
+ @output_file_close = false
225
+ end
226
+ end
227
+ @output_file
228
+ end
229
+
230
+ private def close_output
231
+ @output_file.close if @output_file_close
232
+ @output_file = @output_file_close = nil
233
+ end
234
+
235
+ def formatter
236
+ @formatter ||= begin
237
+ formatter_class = if @formatter_name
238
+ Formatter.find_formatter(@formatter_name.to_sym) or fail "unknown formatter: #{@formatter_name}"
239
+ else
240
+ Formatter.class_from_env
241
+ end
242
+ opts = {}
243
+ opts[:verbosity] = @verbosity if formatter_class.include?(Formatter::Verbosity)
244
+ output = open_output
245
+ if formatter_class.include?(Formatter::Colorizing)
246
+ opts[:colorizer] = TTYColors.for(pager_mode? ? $stdout : output)
247
+ end
248
+ opts = Formatter.opts_from_env(formatter_class, **opts)
249
+ formatter_class.new(output, **opts)
250
+ end
251
+ end
252
+
253
+ def timing_cache
254
+ @timing_cache ||= TimingCache.new(state_path, filtered_test_suite)
255
+ end
256
+
257
+ def golden_master
258
+ @golden_master ||= GoldenMaster.new(
259
+ state_path,
260
+ original_test_suite: load_all_tests? ? loaded_test_suite : nil,
261
+ filtered_test_suite: filtered_test_suite,
262
+ )
263
+ end
264
+
265
+ def runner
266
+ @runner ||= begin
267
+ runner_class = if @runner_name
268
+ Runner.find_runner(@runner_name.to_sym) or fail "unknown runner: #{@runner_name}"
269
+ else
270
+ Runner.class_from_env(default_parallel: @parallel_jobs != 1)
271
+ end
272
+ opts = {
273
+ timing_store: timing_cache.timing_store,
274
+ golden_store: golden_master.golden_store,
275
+ capture_output: @capture_output,
276
+ }
277
+ opts[:parallel_jobs] = @parallel_jobs if !@parallel_jobs.nil? && runner_class <= Runner::Parallel
278
+ opts = Runner.opts_from_env(runner_class, **opts)
279
+ runner_class.new(**opts)
280
+ end
281
+ end
282
+
283
+ def warmup
284
+ Process.warmup if Process.respond_to?(:warmup)
285
+ end
286
+
287
+ def test_suite_passed?
288
+ @report ? @report.passed? : @sink_passed.passed?
289
+ end
290
+
291
+ def test_suite_problem?
292
+ if @report
293
+ @report.failed? || @report.each_test_case_result.any? { |_, tr| tr.captured_stderr? }
294
+ else
295
+ !test_suite_passed?
296
+ end
297
+ end
298
+
299
+ private def sink
300
+ @sink ||= begin
301
+ sinks = []
302
+ if pager_mode?
303
+ sinks << Report::Builder.new((@report = Report.new))
304
+ sinks << Progress::StatusLine.new($stderr) if !@verbosity.negative? && $stderr.tty?
305
+ else
306
+ sinks << formatter
307
+ sinks << (@sink_passed = Sink::Passed.new)
308
+ end
309
+ sinks << timing_cache.timing_store
310
+ Sink::Tee.for(sinks)
311
+ end
312
+ end
313
+
314
+ private def plan_header
315
+ header = {
316
+ planned_test_case_count: filtered_test_suite.count_test_cases,
317
+ }
318
+ sink.begin_plan(header)
319
+ end
320
+
321
+ private def plan_footer
322
+ footer = {
323
+ test_files_load_user_time: @load_user_time,
324
+ test_files_load_system_time: @load_system_time,
325
+ test_files_load_real_time: @load_real_time,
326
+ test_suite_run_user_time: @run_user_time,
327
+ test_suite_run_system_time: @run_system_time,
328
+ test_suite_run_real_time: @run_real_time,
329
+ overall_user_time: @overall_user_time,
330
+ overall_system_time: @overall_system_time,
331
+ overall_real_time: @overall_real_time,
332
+ }
333
+ sink.finish_plan(footer)
334
+ end
335
+
336
+ private def speedup_capture_output(&)
337
+ return yield unless @capture_output
338
+ CaptureOutput.fixed_install!(&)
339
+ end
340
+
341
+ private def run_test_suite
342
+ speedup_capture_output do
343
+ @run_real_time = Ruptr.measure_real_time do
344
+ @run_user_time, @run_system_time = Ruptr.measure_processor_time do
345
+ runner.dispatch(filtered_test_suite, sink)
346
+ end
347
+ end
348
+ end
349
+ end
350
+
351
+ private def save_timing_cache
352
+ @timing_cache&.save!(replace: load_all_tests? && loaded_test_suite.equal?(filtered_test_suite))
353
+ end
354
+
355
+ private def save_golden_master
356
+ @golden_master&.save_trial!
357
+ end
358
+
359
+ private def save_bookkeeping
360
+ save_timing_cache
361
+ save_golden_master
362
+ end
363
+
364
+ def output_report
365
+ return unless @report
366
+ @report.emit(formatter)
367
+ rescue Errno::EPIPE
368
+ end
369
+
370
+ private def possibly_with_warnings
371
+ saved = $VERBOSE
372
+ $VERBOSE = @warnings
373
+ yield
374
+ ensure
375
+ $VERBOSE = saved
376
+ end
377
+
378
+ private def measure_overall_time(&)
379
+ @overall_user_time, @overall_system_time, @overall_real_time =
380
+ Ruptr.measure_processor_and_real_time(&)
381
+ end
382
+
383
+ private def run_tests
384
+ possibly_with_warnings do
385
+ measure_overall_time do
386
+ plan_header
387
+ report_total_filtered
388
+ warmup
389
+ with_state_directory_lock do
390
+ run_test_suite
391
+ save_bookkeeping
392
+ end
393
+ end
394
+ plan_footer
395
+ output_report
396
+ ensure
397
+ close_output
398
+ end
399
+ end
400
+
401
+ private def golden_accept
402
+ golden_master.accept_trial!
403
+ end
404
+
405
+ private def show_test_suite
406
+ report_total_filtered
407
+
408
+ io = $stdout
409
+ w1, w2 = 1, 1
410
+ traverse = lambda do |te, prefix, last|
411
+ io << prefix << (last ? '└' : '├') << '─' * w1 if prefix
412
+ io << (te.test_group? && !te.empty? ? (w2.zero? ? '┮' : '┬') : (w2.zero? ? '╼' : '─'))
413
+ io << '─' * w2.pred << '╼' unless w2.zero?
414
+ io << ' ' << (te.label || '...') << "\n"
415
+ if te.test_group?
416
+ prefix = prefix ? prefix + (last ? ' ' : '│') + ' ' * w1 : ''
417
+ last_te = nil
418
+ te.each_test_element do |te|
419
+ traverse.call(last_te, prefix, false) if last_te
420
+ last_te = te
421
+ end
422
+ traverse.call(last_te, prefix, true) if last_te
423
+ end
424
+ end
425
+ traverse.call(filtered_test_suite, nil, true)
426
+ end
427
+
428
+ def run
429
+ # NOTE: this method's return value is passed Kernel#exit
430
+ if @operations.empty?
431
+ run_tests
432
+ test_suite_passed?
433
+ else
434
+ @operations.each { |operation_name| send(operation_name) }
435
+ true
436
+ end
437
+ end
438
+ end
439
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../minitest'
4
+ Ruptr::Compat::Minitest.new.prepare_autorun!
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'suite'
4
+ require_relative 'compat'
5
+ require_relative 'autorun'
6
+ require_relative 'adapters/assertions'
7
+
8
+ module Ruptr
9
+ class Compat
10
+ class Minitest < self
11
+ def default_project_load_paths = %w[test]
12
+
13
+ def default_project_test_globs = %w[test/**/*_test.rb test/**/test_*.rb]
14
+
15
+ def global_install!
16
+ if Object.const_defined?(:Minitest)
17
+ return if Object.const_get(:Minitest) == @adapter_module
18
+ fail "minitest already loaded!"
19
+ end
20
+ Object.const_set(:Minitest, adapter_module)
21
+ this = self
22
+ m = Module.new do
23
+ define_method(:require) do |name|
24
+ name = name.to_path unless name.is_a?(String)
25
+ case name
26
+ when 'minitest', 'minitest/test', 'minitest/proveit', 'minitest/hooks'
27
+ return
28
+ when 'minitest/autorun'
29
+ this.schedule_autorun!
30
+ return
31
+ when 'minitest/stub_const'
32
+ nil
33
+ else
34
+ fail "#{self.class.name}: unknown minitest library: #{name}" if name.start_with?('minitest/')
35
+ end
36
+ super(name)
37
+ end
38
+ end
39
+ Kernel.prepend(m)
40
+ end
41
+
42
+ def adapter_module
43
+ @adapter_module ||= Module.new do
44
+ def self.def_module(name, &) = const_set(name, Module.new(&))
45
+ def self.def_class(name, &) = const_set(name, Class.new(&))
46
+
47
+ const_set(:Assertion, Ruptr::Assertions::AssertionError)
48
+
49
+ def_module(:Assertions) do
50
+ include Adapters::RuptrAssertions
51
+ end
52
+
53
+ def_module(:Hooks) do
54
+ # TODO: #around_all/#before_all/#after_all
55
+
56
+ def around = yield
57
+
58
+ def ruptr_wrap_test_instance
59
+ ran = false
60
+ super do
61
+ around do
62
+ yield
63
+ ran = true
64
+ end
65
+ end
66
+ raise SkippedException unless ran
67
+ end
68
+ end
69
+
70
+ adapter_module = self
71
+
72
+ def_class(:Test) do
73
+ def self.parallelize_me! = nil
74
+
75
+ def self.prove_it? = false
76
+ def self.prove_it! = define_singleton_method(:prove_it?) { true }
77
+
78
+ def name
79
+ @test_method_name
80
+ end
81
+
82
+ def name=(v)
83
+ @test_method_name = v
84
+ end
85
+
86
+ def prove_it
87
+ flunk("Prove it!") if self.class.prove_it? && ruptr_assertions_count.zero?
88
+ end
89
+
90
+ def setup = nil
91
+ def teardown = nil
92
+
93
+ include TestInstance
94
+ include adapter_module::Assertions
95
+ end
96
+ end
97
+ end
98
+
99
+ private def make_run_block(klass, method_name)
100
+ lambda do |context|
101
+ inst = klass.new
102
+ inst.name = method_name
103
+ inst.ruptr_initialize_test_instance(context)
104
+ inst.ruptr_wrap_test_instance do
105
+ inst.setup
106
+ inst.public_send(method_name)
107
+ inst.prove_it
108
+ ensure
109
+ inst.teardown
110
+ end
111
+ end
112
+ end
113
+
114
+ def adapted_test_group
115
+ # TODO: Test descriptions should be "<class_name>#<method_name>"?
116
+ traverse = lambda do |klass|
117
+ root = klass.equal?(adapter_module::Test)
118
+ TestGroup.new(root ? "[Minitest]" : klass.name,
119
+ identifier: root ? :minitest : klass.name).tap do |tg|
120
+ klass.public_instance_methods(true)
121
+ .filter { |sym| sym.start_with?('test_') }.each do |test_method_name|
122
+ tc = TestCase.new(test_method_name.to_s, &make_run_block(klass, test_method_name))
123
+ tg.add_test_case(tc)
124
+ end
125
+ klass.subclasses.each do |subklass|
126
+ tg.add_test_subgroup(traverse.call(subklass))
127
+ end
128
+ end
129
+ end
130
+ traverse.call(adapter_module::Test)
131
+ end
132
+ end
133
+ end
134
+ end