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.
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'suite'
4
+ require_relative 'compat'
5
+ require_relative 'autorun'
6
+ require_relative 'rspec/example_group'
7
+ require_relative 'rspec/configuration'
8
+
9
+ module Ruptr
10
+ class Compat
11
+ class RSpec < self
12
+ def default_project_load_paths = %w[spec]
13
+
14
+ def default_project_test_globs = %w[spec/**/*_spec.rb]
15
+
16
+ def global_install!
17
+ if Object.const_defined?(:RSpec)
18
+ return if Object.const_get(:RSpec) == @adapter_module
19
+ fail "rspec already loaded!"
20
+ end
21
+ Object.const_set(:RSpec, adapter_module)
22
+ this = self
23
+ m = Module.new do
24
+ define_method(:require) do |name|
25
+ name = name.to_path unless name.is_a?(String)
26
+ if name.start_with?('rspec/')
27
+ case name.delete_prefix('rspec/')
28
+ when 'version',
29
+ 'support',
30
+ %r{\Asupport/},
31
+ 'matchers',
32
+ %r{\Amatchers/},
33
+ 'expectations',
34
+ %r{\Aexpectations/},
35
+ 'mocks',
36
+ %r{\Amocks/}
37
+ nil
38
+ when 'core'
39
+ return
40
+ when 'autorun'
41
+ this.schedule_autorun!
42
+ return
43
+ else
44
+ fail "#{self.class.name}: unknown rspec library: #{name}"
45
+ end
46
+ end
47
+ super(name)
48
+ end
49
+ end
50
+ Kernel.prepend(m)
51
+ end
52
+
53
+ def global_monkey_patch!
54
+ a = adapter_module
55
+ m = Module.new do
56
+ extend Forwardable
57
+ define_method(:ruptr_rspec_adapter) { a }
58
+ def_delegators :ruptr_rspec_adapter,
59
+ :describe, :context,
60
+ :shared_examples, :shared_examples_for, :shared_context
61
+ end
62
+ TOPLEVEL_BINDING.receiver.extend(m)
63
+ Module.prepend(m)
64
+ end
65
+
66
+ def load_default_frameworks
67
+ adapter_module.configure do |config|
68
+ if config.expectation_frameworks.empty? && config.mock_frameworks.empty? ||
69
+ config.expectation_frameworks.include?(:rspec) && config.mock_frameworks.include?(:rspec)
70
+ begin
71
+ config.expect_with(:rspec)
72
+ rescue LoadError
73
+ end
74
+ begin
75
+ config.mock_with(:rspec)
76
+ rescue LoadError
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ def finalize_configuration!
83
+ load_default_frameworks
84
+ end
85
+
86
+ class Adapter < Module
87
+ extend Forwardable
88
+
89
+ def configuration = @configuration ||= Configuration.new(self)
90
+
91
+ def configure = yield configuration
92
+
93
+ def_delegators :root_example_group,
94
+ :example_group, :describe, :context,
95
+ :fdescribe, :fcontext, :xdescribe, :xcontext,
96
+ :shared_examples, :shared_examples_for, :shared_context,
97
+ :before, :after
98
+ end
99
+
100
+ def adapter_module
101
+ @adapter_module ||= begin
102
+ adapter_module = Adapter.new
103
+ root_example_group = Class.new(ExampleGroup)
104
+ adapter_module.define_singleton_method(:root_example_group) { root_example_group }
105
+ root_example_group.define_singleton_method(:configuration) { adapter_module.configuration }
106
+ shared_context_module = SharedContext.dup
107
+ shared_context_module.define_method(:configuration) { adapter_module.configuration }
108
+ adapter_module.const_set(:SharedContext, shared_context_module)
109
+ adapter_module
110
+ end
111
+ end
112
+
113
+ private def make_test_case(example_group, example)
114
+ TestCase.new(example.label, tags: example.metadata) do |context|
115
+ instance = example_group.new
116
+ context.rspec_carryover_instance_variables(instance) # XXX
117
+ instance.ruptr_initialize_test_instance(context)
118
+ begin
119
+ instance.ruptr_wrap_test_instance { instance.run_example(example) }
120
+ rescue *Ruptr.passthrough_exceptions,
121
+ SkippedExceptionMixin
122
+ raise
123
+ rescue Exception
124
+ raise PendingSkippedException, instance.pending_reason if instance.pending?
125
+ raise
126
+ else
127
+ raise PendingPassedError, instance.pending_reason if instance.pending?
128
+ end
129
+ end
130
+ end
131
+
132
+ private def make_test_group(example_group)
133
+ block = if example_group.need_wrap_context?
134
+ lambda do |context, &nest|
135
+ instance = example_group.new
136
+ context.rspec_carryover_instance_variables(instance) # XXX
137
+ instance.ruptr_initialize_test_instance(context)
138
+ instance.ruptr_wrap_test_instance { instance.wrap_context(&nest) }
139
+ end
140
+ end
141
+ root = example_group.equal?(adapter_module.root_example_group)
142
+ TestGroup.new(root ? "[RSpec]" : example_group.label,
143
+ identifier: root ? :rspec : example_group.label,
144
+ tags: example_group.metadata, &block)
145
+ end
146
+
147
+ def adapted_test_group
148
+ traverse = lambda do |example_group|
149
+ make_test_group(example_group).tap do |tg|
150
+ example_group.each_example do |example|
151
+ tc = make_test_case(example_group, example)
152
+ tg.add_test_case(tc)
153
+ end
154
+ example_group.each_example_group do |child_example_group|
155
+ tg.add_test_subgroup(traverse.call(child_example_group))
156
+ end
157
+ end
158
+ end
159
+ traverse.call(adapter_module.root_example_group)
160
+ end
161
+
162
+ def filter_test_group(test_group)
163
+ conf = adapter_module.configuration
164
+
165
+ return test_group if conf.inclusion_filter.empty? && conf.exclusion_filter.empty?
166
+
167
+ matches_inclusion_filters = lambda do |tc|
168
+ conf.inclusion_filter.empty? ||
169
+ conf.inclusion_filter.any? { |f| ExampleGroup.filter_matches?(f, tc.tags) }
170
+ end
171
+ matches_exclusion_filters = lambda do |tc|
172
+ conf.exclusion_filter.empty? ||
173
+ conf.exclusion_filter.none? { |f| ExampleGroup.filter_matches?(f, tc.tags) }
174
+ end
175
+
176
+ test_group = super
177
+ filtered_test_group = test_group.filter_test_cases_recursive do |tc|
178
+ matches_exclusion_filters === tc && matches_inclusion_filters === tc
179
+ end
180
+ if conf.run_all_when_everything_filtered &&
181
+ filtered_test_group.count_test_cases.zero?
182
+ filtered_test_group = test_group.filter_test_cases_recursive do |tc|
183
+ matches_exclusion_filters === tc
184
+ end
185
+ end
186
+
187
+ filtered_test_group
188
+ end
189
+
190
+ class Ruptr::Context
191
+ attr_accessor :rspec_example_group_instance,
192
+ :rspec_example_group_instance_variables_carried_over
193
+
194
+ def rspec_carryover_instance_variables(instance)
195
+ context = parent
196
+ loop do
197
+ if context.rspec_example_group_instance_variables_carried_over
198
+ if (parent_instance = context.rspec_example_group_instance)
199
+ ExampleGroup.carryover_instance_variables(parent_instance, instance)
200
+ end
201
+ break
202
+ end
203
+ context = context.parent or break
204
+ end
205
+ self.rspec_example_group_instance = instance # XXX
206
+ self.rspec_example_group_instance_variables_carried_over = true;
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'suite'
4
+ require_relative 'result'
5
+ require_relative 'instance'
6
+ require_relative 'exceptions'
7
+ require_relative 'surrogate_exception'
8
+ require_relative 'utils'
9
+ require_relative 'capture_output'
10
+
11
+ module Ruptr
12
+ class Context
13
+ def initialize(runner, test_element, parent)
14
+ @runner = runner
15
+ @test_element = test_element
16
+ @parent = parent
17
+ @assertions_count = 0
18
+ end
19
+
20
+ attr_reader :runner, :test_element, :parent
21
+ attr_accessor :assertions_count
22
+ end
23
+
24
+ class TestElement
25
+ private def maybe_capture_output(context, &)
26
+ if context.runner.capture_output
27
+ CaptureOutput.capture_output(&)
28
+ else
29
+ yield
30
+ [nil, nil]
31
+ end
32
+ end
33
+
34
+ def make_result(context)
35
+ status = exception = user_time = system_time = nil
36
+ captured_stdout, captured_stderr = maybe_capture_output(context) do
37
+ user_time, system_time = Ruptr.measure_processor_time do
38
+ yield
39
+ rescue *Ruptr.passthrough_exceptions
40
+ raise
41
+ rescue SkippedExceptionMixin => exception
42
+ status = :skipped
43
+ rescue Exception => exception
44
+ status = :failed
45
+ else
46
+ status = :passed
47
+ end
48
+ end
49
+ assertions = context.assertions_count
50
+ TestResult.new(status,
51
+ user_time:, system_time:, assertions:, exception:,
52
+ captured_stdout:, captured_stderr:)
53
+ end
54
+ end
55
+
56
+ class TestCase
57
+ def run_result(runner, group_context)
58
+ context = Context.new(runner, self, group_context)
59
+ make_result(context) { run_context(context) }
60
+ end
61
+ end
62
+
63
+ class TestGroup
64
+ def must_wrap? = block?
65
+
66
+ def wrap_result(runner, parent_context)
67
+ context = Context.new(runner, self, parent_context)
68
+ make_result(context) do
69
+ if must_wrap?
70
+ wrap_context(context) { yield context }
71
+ else
72
+ yield context
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ class TestResult
79
+ def make_marshallable
80
+ return unless @exception
81
+ begin
82
+ Marshal.dump(@exception)
83
+ rescue TypeError
84
+ @exception = SurrogateException.from(@exception)
85
+ end
86
+ end
87
+ end
88
+
89
+ class Runner
90
+ class << self
91
+ def class_from_env(env = ENV, default_parallel: true)
92
+ if (s = env['RUPTR_RUNNER'])
93
+ find_runner(s.to_sym) or fail "unknown runner: #{s}"
94
+ else
95
+ n = (s = env['RUPTR_JOBS']) ? s.to_i : default_parallel ? 0 : 1
96
+ n = Runner::Parallel.default_parallel_jobs unless n.positive?
97
+ n > 1 ? Runner::Forking : Runner
98
+ end
99
+ end
100
+
101
+ def opts_from_env(klass, env = ENV, **opts)
102
+ if klass <= Runner::Parallel
103
+ if !opts.key?(:parallel_jobs) && (s = env['RUPTR_JOBS'])
104
+ n = s.to_i
105
+ n = Runner::Parallel.default_parallel_jobs unless n.positive?
106
+ opts[:parallel_jobs] = n
107
+ end
108
+ end
109
+ opts
110
+ end
111
+
112
+ def from_env(env = ENV, **opts)
113
+ klass = class_from_env(env)
114
+ opts = opts_from_env(klass, env, **opts)
115
+ klass.new(**opts)
116
+ end
117
+
118
+ attr_accessor :runner_names
119
+
120
+ def find_runner(name)
121
+ traverse = proc do |c|
122
+ return c if c.runner_names&.include?(name)
123
+ c.subclasses.each(&traverse)
124
+ end
125
+ traverse.call(self)
126
+ nil
127
+ end
128
+ end
129
+
130
+ self.runner_names = %i[single s]
131
+
132
+ def initialize(randomize: false, timing_store: nil, golden_store: nil, capture_output: true)
133
+ @randomize = randomize
134
+ @timing_store = timing_store
135
+ @golden_store = golden_store
136
+ @capture_output = capture_output
137
+ end
138
+
139
+ attr_reader :timing_store, :golden_store, :capture_output
140
+
141
+ def expected_processor_time(te) = @timing_store ? @timing_store[te] : Float::INFINITY
142
+
143
+ class BatchYielder
144
+ Batch = Struct.new(:group_context, :test_cases, :fork_overlap)
145
+
146
+ private
147
+
148
+ def initialize(runner, sink, &block)
149
+ @runner = runner
150
+ @sink = sink
151
+ @process_batch = block
152
+ end
153
+
154
+ def wrap_group(tg, gc)
155
+ @sink.submit_group(tg) do
156
+ ok = false
157
+ tr = tg.wrap_result(@runner, gc) do |gc|
158
+ ok = true
159
+ yield gc
160
+ end
161
+ traverse_group_children_blocked(tg, tr.failed? ? :blocked : :skipped) unless ok
162
+ tr
163
+ end
164
+ end
165
+
166
+ def traverse_group(tg, gc)
167
+ wrap_group(tg, gc) { |gc| traverse_group_children(tg, gc) }
168
+ end
169
+
170
+ def traverse_group_children_blocked(tg, status = :blocked)
171
+ tg.each_test_case do |tc|
172
+ @sink.submit_case(tc, TestResult.new(status))
173
+ end
174
+ tg.each_test_subgroup do |tg|
175
+ @sink.submit_group(tg) do
176
+ traverse_group_children_blocked(tg, status)
177
+ TestResult.new(status)
178
+ end
179
+ end
180
+ end
181
+
182
+ def traverse_group_children(tg, gc)
183
+ batched_cases = []
184
+ pending_groups = []
185
+ gather = lambda do |tg|
186
+ tg.each_test_case do |tc|
187
+ batched_cases << tc
188
+ end
189
+ tg.each_test_subgroup do |tg|
190
+ if tg.must_wrap?
191
+ pending_groups << tg
192
+ else
193
+ @sink.submit_group(tg) do
194
+ gather.call(tg)
195
+ TestResult.new(:passed)
196
+ end
197
+ end
198
+ end
199
+ end
200
+ gather.call(tg)
201
+
202
+ if @randomize
203
+ batched_cases.shuffle!
204
+ elsif @runner.timing_store
205
+ batched_cases.sort_by! { |tc| -@runner.expected_processor_time(tc) }
206
+ end
207
+ @process_batch.call(Batch.new(gc, batched_cases, !tg.tags[:ruptr_no_fork_overlap]))
208
+
209
+ if @randomize
210
+ pending_groups.shuffle!
211
+ elsif @runner.timing_store
212
+ pending_groups.sort_by! { |tg| -@runner.expected_processor_time(tg) }
213
+ end
214
+ pending_groups.each do |tg|
215
+ traverse_group(tg, gc)
216
+ end
217
+ end
218
+
219
+ public def yield_batches(tg, gc = nil)
220
+ traverse_group(tg, gc)
221
+ end
222
+ end
223
+
224
+ private def each_batch(ts, sink, &)
225
+ BatchYielder.new(self, sink, &).yield_batches(ts)
226
+ end
227
+
228
+ private def dispatch_batch(batch, sink)
229
+ batch.test_cases.each do |tc|
230
+ sink.submit_case(tc) { tc.run_result(self, batch.group_context) }
231
+ end
232
+ end
233
+
234
+ def dispatch(ts, sink)
235
+ each_batch(ts, sink) do |batch|
236
+ dispatch_batch(batch, sink)
237
+ golden_store&.flush_trial
238
+ end
239
+ end
240
+
241
+ def run_sink(ts, sink)
242
+ fields = { planned_test_case_count: ts.count_test_cases }
243
+ sink.submit_plan(fields) { dispatch(ts, sink) }
244
+ end
245
+
246
+ def run_report(ts, report = Report.new)
247
+ run_sink(ts, Report::Builder.new(report))
248
+ report
249
+ end
250
+
251
+ class Parallel < self
252
+ def self.default_parallel_jobs
253
+ require 'etc'
254
+ Etc.nprocessors
255
+ end
256
+
257
+ def initialize(parallel_jobs: self.class.default_parallel_jobs, **opts)
258
+ super(**opts)
259
+ @parallel_jobs = parallel_jobs
260
+ end
261
+
262
+ attr_reader :parallel_jobs
263
+ end
264
+
265
+ class Threaded < Parallel
266
+ self.runner_names = %i[t thread threads threaded]
267
+
268
+ def dispatch_batch(batch, sink)
269
+ pending_mtx = Mutex.new
270
+ sink_mtx = Mutex.new
271
+ pending_tcs = batch.test_cases
272
+ [parallel_jobs, pending_tcs.size].min.times.map do
273
+ Thread.new do
274
+ # TODO: reduce locking overhead?
275
+ while (tc = pending_mtx.synchronize { pending_tcs.shift })
276
+ sink_mtx.synchronize { sink.begin_case(tc) }
277
+ tr = tc.run_result(self, batch.group_context)
278
+ sink_mtx.synchronize { sink.finish_case(tc, tr) }
279
+ end
280
+ end
281
+ end.each(&:join)
282
+ end
283
+ end
284
+
285
+ class Forking < Parallel
286
+ self.runner_names = %i[f fork forking p process processes]
287
+
288
+ private def child_worker_loop(batch, all_tcs, read_io, write_io)
289
+ loop do
290
+ request = begin
291
+ Marshal.load(read_io)
292
+ rescue EOFError
293
+ break
294
+ end
295
+ response = all_tcs.values_at(*request).map do |tc|
296
+ tc.run_result(self, batch.group_context).tap(&:make_marshallable)
297
+ end
298
+ Marshal.dump(response, write_io)
299
+ end
300
+ ensure
301
+ read_io.close
302
+ write_io.close
303
+ end
304
+
305
+ Worker = Struct.new(:pid, :read_io, :write_io, :pending_tcs, :stale) do
306
+ def close
307
+ read_io.close
308
+ write_io.close
309
+ end
310
+ end
311
+
312
+ Master = Struct.new(:sink, :all_workers, :free_workers)
313
+
314
+ private def spawn_worker(master, batch, all_tcs)
315
+ pid, parent_read_io, parent_write_io = Ruptr.fork_piped_worker do |child_read_io, child_write_io|
316
+ ENV['RUPTR_WORKER_PROCESS_INDEX'] = master.all_workers.size.to_s
317
+ master.all_workers.reverse_each(&:close)
318
+ child_worker_loop(batch, all_tcs, child_read_io, child_write_io)
319
+ golden_store&.flush_trial
320
+ end
321
+ worker = Worker.new(pid, parent_read_io, parent_write_io, [], false)
322
+ master.all_workers << worker
323
+ master.free_workers << worker
324
+ end
325
+
326
+ private def distribute_work(master, all_tcs, remaining_tc_indexes)
327
+ workers = master.free_workers.pop(remaining_tc_indexes.size)
328
+ return if workers.empty?
329
+ worker_tc_indexes = workers.map { [] }
330
+ worker_total_time = workers.map { 0 }
331
+ i = 0
332
+ while (tc_index = remaining_tc_indexes.first)
333
+ tc = all_tcs[tc_index]
334
+ ptime = expected_processor_time(tc)
335
+ break if worker_tc_indexes[i].size >= 8 ||
336
+ !worker_tc_indexes[i].empty? && worker_total_time[i] + ptime >= 0.005
337
+ remaining_tc_indexes.shift
338
+ worker_tc_indexes[i] << tc_index
339
+ worker_total_time[i] += ptime
340
+ i = (i + 1) % workers.size
341
+ end
342
+ workers.zip(worker_tc_indexes) do |worker, tc_indexes|
343
+ worker.pending_tcs = all_tcs.values_at(*tc_indexes)
344
+ Marshal.dump(tc_indexes, worker.write_io)
345
+ worker.pending_tcs.each do |tc|
346
+ master.sink.begin_case(tc)
347
+ end
348
+ end
349
+ end
350
+
351
+ private def process_responses(master)
352
+ busy_workers = master.all_workers - master.free_workers
353
+ ready_ios, _, _ = IO.select(busy_workers.map(&:read_io))
354
+ ready_ios.each do |ready_io|
355
+ worker = busy_workers.find { |w| w.read_io == ready_io }
356
+ results = Marshal.load(ready_io)
357
+ worker.pending_tcs.zip(results).each do |tc, tr|
358
+ master.sink.finish_case(tc, tr)
359
+ end
360
+ worker.pending_tcs = nil
361
+ master.free_workers.push(worker)
362
+ end
363
+ end
364
+
365
+ private def drop_workers(master, workers)
366
+ workers.each { |w| w.write_io.close }
367
+ workers.each do |w|
368
+ Process.wait(w.pid)
369
+ raise "worker process #{$?}" unless $?.success?
370
+ end
371
+ master.all_workers -= workers
372
+ master.free_workers -= workers
373
+ end
374
+
375
+ private def finish_workers(master)
376
+ process_responses(master) until (master.all_workers - master.free_workers).empty?
377
+ drop_workers(master, master.all_workers)
378
+ end
379
+
380
+ private def workers_count_for_test_cases(tcs)
381
+ # for very fast test cases, forking a process isn't worth it
382
+ m = [tcs.size, parallel_jobs].min
383
+ a = 0
384
+ tcs.each do |tc|
385
+ a += expected_processor_time(tc) * 512
386
+ return m if a >= m
387
+ end
388
+ [a.to_i, 1].max
389
+ end
390
+
391
+ def dispatch(ts, sink)
392
+ master = Master.new(sink, [], [])
393
+ # NOTE: Each iteration of #yield_batches is run with a global state setup for this
394
+ # particular batch of test cases. Worker processes must not be reused if they were forked
395
+ # while a different state was active.
396
+ total_workers_limit = parallel_jobs
397
+ each_batch(ts, sink) do |batch|
398
+ all_tcs = batch.test_cases
399
+ batch_workers_limit = workers_count_for_test_cases(all_tcs)
400
+ unless batch_workers_limit > 1
401
+ # Fallback to single-threaded serial execution.
402
+ dispatch_batch(batch, sink)
403
+ next
404
+ end
405
+ batch_workers_count = 0
406
+ remaining_tc_indexes = all_tcs.size.times.to_a
407
+ until remaining_tc_indexes.empty?
408
+ while master.all_workers.size < total_workers_limit &&
409
+ batch_workers_count < batch_workers_limit
410
+ spawn_worker(master, batch, all_tcs)
411
+ batch_workers_count += 1
412
+ end
413
+ distribute_work(master, all_tcs, remaining_tc_indexes)
414
+ # See if we can spawn workers for the next batch before waiting on results.
415
+ break if remaining_tc_indexes.empty?
416
+ process_responses(master)
417
+ drop_workers(master, master.free_workers.select(&:stale))
418
+ end
419
+ master.all_workers.each { |w| w.stale = true }
420
+ if batch.fork_overlap
421
+ drop_workers(master, master.free_workers)
422
+ else
423
+ finish_workers(master)
424
+ end
425
+ end
426
+ finish_workers(master)
427
+ ensure
428
+ master.all_workers.each(&:close)
429
+ master.all_workers = master.free_workers = nil
430
+ end
431
+ end
432
+ end
433
+ end
data/lib/ruptr/sink.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruptr
4
+ module Sink
5
+ def begin_plan(_fields) = nil
6
+ def finish_plan(_fields) = nil
7
+
8
+ def submit_plan(fields = {})
9
+ begin_plan(fields)
10
+ yield
11
+ ensure
12
+ finish_plan(fields)
13
+ end
14
+
15
+ private def begin_element(_te) = nil
16
+ private def finish_element(_te, _tr) = nil
17
+
18
+ def begin_case(tc) = begin_element(tc)
19
+ def finish_case(tc, tr) = finish_element(tc, tr)
20
+
21
+ def submit_case(tc, tr = yield)
22
+ begin_case(tc)
23
+ finish_case(tc, tr)
24
+ end
25
+
26
+ def begin_group(tg) = begin_element(tg)
27
+ def finish_group(tg, tr) = finish_element(tg, tr)
28
+
29
+ def submit_group(tg, tr = (tr_missing = true; nil))
30
+ begin_group(tg)
31
+ finish_group(tg, tr_missing ? yield : tr)
32
+ end
33
+
34
+ class Tee
35
+ include Sink
36
+
37
+ def self.for(targets)
38
+ return targets.first if targets.size == 1
39
+ new(targets)
40
+ end
41
+
42
+ def initialize(targets) = @targets = targets
43
+
44
+ %i[begin_plan finish_plan begin_case finish_case begin_group finish_group].each do |method_name|
45
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
46
+ def #{method_name}(...) = @targets.each { |target| target.#{method_name}(...) }
47
+ RUBY
48
+ end
49
+ end
50
+
51
+ class Passed
52
+ include Sink
53
+ def begin_plan(_) = @passed = true
54
+ def finish_element(_, tr) = @passed &&= !tr.failed?
55
+ def passed? = @passed
56
+ end
57
+ end
58
+ end