ruby_reactor 0.8.1 → 0.8.2
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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +7 -0
- data/README.md +1 -1
- data/lib/ruby_reactor/rspec/helpers.rb +4 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +203 -46
- data/lib/ruby_reactor/rspec.rb +8 -1
- data/lib/ruby_reactor/step/map_step.rb +4 -4
- data/lib/ruby_reactor/version.rb +1 -1
- data/specs/scoped_rspec_matchers.md +158 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31b3ac286396e1d29f1bad40e87f4f55b8ecc3e6c715e6cfd2ca0496d9c8dd0c
|
|
4
|
+
data.tar.gz: d518e5aceb0e5fa6dd43007e1f324612c84a53528e73a76463db7ecb652f0a95
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e91db7e2c548b834f285fe514747bc407b29fbb4cdb10e4aef44b05ff556d518ccf77ad4d02fc3e097907d7298843bc32b7e5a57d4f4a8ae687795dc702fa71
|
|
7
|
+
data.tar.gz: 6a2252e7fdf4740f1d297f2855bb60f07c4b66e0a551c8d90e4394fd1acafd829098323d40afb4f9bc650c76a53818d76566c70f65c89c4b60ed11f9018a3d48
|
data/CHANGELOG.md
CHANGED
|
@@ -129,6 +129,13 @@
|
|
|
129
129
|
ordinary `nil` success, and `result(:step)` hands the reader the `Halt` — the same way it
|
|
130
130
|
already hands over a `Failure`.
|
|
131
131
|
|
|
132
|
+
## [0.8.2](https://github.com/arturictus/ruby_reactor/compare/v0.8.1...v0.8.2) (2026-09-22)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
### Features
|
|
136
|
+
|
|
137
|
+
* Enhance mocking capabilities for nested reactors with scoped APIs and examples ([#58](https://github.com/arturictus/ruby_reactor/issues/58)) ([a462265](https://github.com/arturictus/ruby_reactor/commit/a462265b233f94f07fc39628b59a2bb038744fc9))
|
|
138
|
+
|
|
132
139
|
## [0.8.1](https://github.com/arturictus/ruby_reactor/compare/v0.8.0...v0.8.1) (2026-09-22)
|
|
133
140
|
|
|
134
141
|
|
data/README.md
CHANGED
|
@@ -1411,7 +1411,7 @@ end
|
|
|
1411
1411
|
RubyReactor provides testing utilities for RSpec. See the [Testing with RSpec](documentation/testing.md) guide for comprehensive documentation — including [unit-testing class-based steps](documentation/testing.md#testing-step-classes) directly.
|
|
1412
1412
|
|
|
1413
1413
|
```ruby
|
|
1414
|
-
RSpec.describe PaymentReactor do
|
|
1414
|
+
RSpec.describe PaymentReactor, type: :reactor do
|
|
1415
1415
|
it "processes payment successfully" do
|
|
1416
1416
|
subject = test_reactor(PaymentReactor, order_id: 123, amount: 99.99)
|
|
1417
1417
|
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
module RSpec
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# Entry-point helpers (`test_reactor`), auto-included into examples tagged
|
|
6
|
+
# `type: :reactor`. Specs that want them outside that tag should
|
|
7
|
+
# `include RubyReactor::RSpec::Helpers` explicitly. Sidekiq-coupled helpers
|
|
8
|
+
# live in `SidekiqHelpers`, scoped the same way.
|
|
8
9
|
module Helpers
|
|
9
10
|
# Build a `TestSubject` around a reactor invocation. Captures the run for
|
|
10
11
|
# later introspection via matchers; runs the reactor lazily on first
|
|
@@ -48,44 +48,93 @@ module RubyReactor
|
|
|
48
48
|
self
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
# Fluent API for mocking nested map steps
|
|
52
|
-
#
|
|
53
|
-
#
|
|
51
|
+
# Fluent/scoped API for mocking nested map steps.
|
|
52
|
+
#
|
|
53
|
+
# Without a block, returns a proxy scoped to this map step — chain
|
|
54
|
+
# `mock_step`/`failing_at`/`map`/`composed` on it to configure several
|
|
55
|
+
# things inside the same nested reactor:
|
|
56
|
+
# reactor.map(:my_map).mock_step(:a) { ... }.mock_step(:b) { ... }
|
|
57
|
+
#
|
|
58
|
+
# With a block, the proxy is yielded and the *outer* subject is
|
|
59
|
+
# returned, so sibling nested reactors can be configured in the same
|
|
60
|
+
# chain without leaking scope between them:
|
|
61
|
+
# test_reactor(Parent, params)
|
|
62
|
+
# .map(:my_map) { |m| m.mock_step(:a) { ... } }
|
|
63
|
+
# .composed(:other_child) { |c| c.mock_step(:b) { ... } }
|
|
54
64
|
def map(step_name)
|
|
55
|
-
StepProxy.new(self, step_name)
|
|
65
|
+
proxy = StepProxy.new(self, [step_name])
|
|
66
|
+
return proxy unless block_given?
|
|
67
|
+
|
|
68
|
+
yield proxy
|
|
69
|
+
self
|
|
56
70
|
end
|
|
57
71
|
|
|
58
|
-
# Fluent API for mocking nested compose steps
|
|
72
|
+
# Fluent/scoped API for mocking nested compose steps. See `#map` for
|
|
73
|
+
# the block vs. no-block behavior.
|
|
59
74
|
# @example
|
|
60
|
-
# reactor.
|
|
75
|
+
# reactor.composed(:my_sub_reactor).mock_step(:inner_step) { ... }
|
|
61
76
|
def composed(step_name)
|
|
62
77
|
# If already executed, return the traversed subject
|
|
63
78
|
return traverse_composed(step_name) if @executed
|
|
64
79
|
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
proxy = StepProxy.new(self, [step_name])
|
|
81
|
+
return proxy unless block_given?
|
|
82
|
+
|
|
83
|
+
yield proxy
|
|
84
|
+
self
|
|
67
85
|
end
|
|
68
86
|
alias compose composed
|
|
69
87
|
|
|
70
|
-
# Proxy
|
|
88
|
+
# Proxy scoped to a nested step path (e.g. [:parent_compose, :child_map]).
|
|
89
|
+
# All mutating calls stay scoped to this path: `mock_step`/`failing_at`
|
|
90
|
+
# return the proxy itself (not the outer subject) so several inner
|
|
91
|
+
# steps of the *same* nested reactor can be chained without resetting
|
|
92
|
+
# scope back to the parent.
|
|
71
93
|
class StepProxy
|
|
72
|
-
def initialize(subject,
|
|
94
|
+
def initialize(subject, step_path)
|
|
73
95
|
@subject = subject
|
|
74
|
-
@
|
|
96
|
+
@step_path = step_path
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def mock_step(inner_step_name, *nested_steps, element_index: nil, &block)
|
|
100
|
+
@subject.mock_step(*@step_path, inner_step_name, *nested_steps, element_index: element_index, &block)
|
|
101
|
+
self
|
|
75
102
|
end
|
|
76
103
|
|
|
77
|
-
def
|
|
78
|
-
@subject.
|
|
79
|
-
|
|
104
|
+
def failing_at(inner_step_name, *nested_steps, element_index: nil, &block)
|
|
105
|
+
@subject.failing_at(*@step_path, inner_step_name, *nested_steps, element_index: element_index, &block)
|
|
106
|
+
self
|
|
80
107
|
end
|
|
81
108
|
|
|
82
|
-
# Support deep nesting?
|
|
83
109
|
def map(inner_step_name)
|
|
84
|
-
|
|
110
|
+
proxy = self.class.new(@subject, @step_path + [inner_step_name])
|
|
111
|
+
return proxy unless block_given?
|
|
112
|
+
|
|
113
|
+
yield proxy
|
|
114
|
+
self
|
|
85
115
|
end
|
|
86
116
|
|
|
87
117
|
def composed(inner_step_name)
|
|
88
|
-
|
|
118
|
+
proxy = self.class.new(@subject, @step_path + [inner_step_name])
|
|
119
|
+
return proxy unless block_given?
|
|
120
|
+
|
|
121
|
+
yield proxy
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
alias compose composed
|
|
125
|
+
|
|
126
|
+
# Delegate everything else (run, success?, result, step_result, ...)
|
|
127
|
+
# to the outer subject, so a proxy can stand in for it wherever a
|
|
128
|
+
# `TestSubject` is expected (e.g. `expect(subject).to be_success`
|
|
129
|
+
# right after a non-block `.composed(:x).mock_step(:y)` chain).
|
|
130
|
+
def method_missing(name, ...)
|
|
131
|
+
return @subject.public_send(name, ...) if @subject.respond_to?(name)
|
|
132
|
+
|
|
133
|
+
super
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def respond_to_missing?(name, include_private = false)
|
|
137
|
+
@subject.respond_to?(name, include_private) || super
|
|
89
138
|
end
|
|
90
139
|
end
|
|
91
140
|
|
|
@@ -491,12 +540,13 @@ module RubyReactor
|
|
|
491
540
|
end
|
|
492
541
|
|
|
493
542
|
def prepare_execution_class
|
|
543
|
+
@force_sync = @async == false
|
|
544
|
+
|
|
494
545
|
# Even if no interceptors, we might need to subclass to force the whole
|
|
495
546
|
# reactor to run in-process.
|
|
496
|
-
return @reactor_class if @interceptors.empty? &&
|
|
547
|
+
return @reactor_class if @interceptors.empty? && !@force_sync
|
|
497
548
|
|
|
498
549
|
interceptors = @interceptors
|
|
499
|
-
force_sync = @async == false
|
|
500
550
|
|
|
501
551
|
execution_class = Class.new(@reactor_class) do
|
|
502
552
|
# 1. Copy configuration from parent
|
|
@@ -514,19 +564,17 @@ module RubyReactor
|
|
|
514
564
|
unique_name = "#{superclass.name}Mock#{object_id}"
|
|
515
565
|
define_singleton_method(:name) { unique_name }
|
|
516
566
|
RubyReactor::Registry.register(unique_name, self)
|
|
567
|
+
end
|
|
517
568
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
@steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
|
|
528
|
-
end
|
|
529
|
-
end
|
|
569
|
+
# 3. `async: false` / `run_async(false)` means "run this reactor's full
|
|
570
|
+
# logic here, in one process" — all the way down. Suppress this
|
|
571
|
+
# class's own `background` hand-off and `async_step`/`async_reactor`
|
|
572
|
+
# dispatch, then recursively do the same for every `compose`d/`map`ped
|
|
573
|
+
# child reactor (`apply_nested_interceptors` below clones from
|
|
574
|
+
# whatever class ends up wired in here, so it inherits this).
|
|
575
|
+
if @force_sync
|
|
576
|
+
strip_background_and_async!(execution_class)
|
|
577
|
+
force_sync_nested_reactors!(execution_class)
|
|
530
578
|
end
|
|
531
579
|
|
|
532
580
|
# 4. Apply Interceptors
|
|
@@ -535,6 +583,79 @@ module RubyReactor
|
|
|
535
583
|
execution_class
|
|
536
584
|
end
|
|
537
585
|
|
|
586
|
+
# Clears the background hand-off and any `async_dispatch` flags on a
|
|
587
|
+
# class's OWN steps. Nested composed/map children are separate classes,
|
|
588
|
+
# handled by `force_sync_nested_reactors!`.
|
|
589
|
+
def strip_background_and_async!(klass)
|
|
590
|
+
klass.instance_variable_set(:@background_handoff, nil)
|
|
591
|
+
klass.steps.each do |name, config|
|
|
592
|
+
next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
|
|
593
|
+
|
|
594
|
+
klass.steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# Rewrites every `compose`/`map` step to target a subclass that has
|
|
599
|
+
# itself been force-synced (background/async stripped, and recursively,
|
|
600
|
+
# its own nested composed/map children), so `run_async(false)` reaches
|
|
601
|
+
# every level of a reactor tree instead of only the top one.
|
|
602
|
+
def force_sync_nested_reactors!(klass)
|
|
603
|
+
klass.steps.each do |step_name, step_config|
|
|
604
|
+
arg_key, source = nested_reactor_source(step_config)
|
|
605
|
+
next unless source
|
|
606
|
+
|
|
607
|
+
forced_child = build_forced_sync_class(source.value)
|
|
608
|
+
|
|
609
|
+
new_args = step_config.arguments.dup
|
|
610
|
+
new_args[arg_key] = new_args[arg_key].merge(source: RubyReactor::Template::Value.new(forced_child))
|
|
611
|
+
# A fan-out map is NOT an `async_dispatch` step — its hand-off lives in
|
|
612
|
+
# the `fan_out` argument, so `strip_background_and_async!` never sees
|
|
613
|
+
# it. Turn it off here (on the clone, leaving the original config
|
|
614
|
+
# untouched for normal runs) or `run_async(false)` would still
|
|
615
|
+
# dispatch element jobs and leave the reactor parked at the map.
|
|
616
|
+
if arg_key == :mapped_reactor_class && new_args[:fan_out]
|
|
617
|
+
new_args[:fan_out] = new_args[:fan_out].merge(source: RubyReactor::Template::Value.new(false))
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
new_step_config = step_config.clone
|
|
621
|
+
new_step_config.instance_variable_set(:@arguments, new_args)
|
|
622
|
+
klass.steps[step_name] = new_step_config
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def nested_reactor_source(step_config)
|
|
627
|
+
return [nil, nil] unless step_config.respond_to?(:arguments)
|
|
628
|
+
|
|
629
|
+
args = step_config.arguments
|
|
630
|
+
# `async_reactor` is included: clearing its dispatch marker makes the
|
|
631
|
+
# child run inline, but inline means `child_class.run(...)` on the
|
|
632
|
+
# ORIGINAL class — whose own background/async steps would dispatch
|
|
633
|
+
# again. The child has to be force-synced too.
|
|
634
|
+
%i[mapped_reactor_class composed_reactor_class async_reactor_class].each do |arg_key|
|
|
635
|
+
source = args[arg_key]&.[](:source)
|
|
636
|
+
return [arg_key, source] if source.is_a?(RubyReactor::Template::Value)
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
[nil, nil]
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def build_forced_sync_class(original_child_reactor)
|
|
643
|
+
child_class = Class.new(original_child_reactor) do
|
|
644
|
+
define_singleton_method(:name) { original_child_reactor.name }
|
|
645
|
+
@steps = superclass.steps.dup
|
|
646
|
+
@inputs = superclass.inputs.dup
|
|
647
|
+
@input_validations = superclass.input_validations.dup
|
|
648
|
+
@middlewares = superclass.middlewares.dup
|
|
649
|
+
@return_step = superclass.return_step
|
|
650
|
+
@background_handoff = superclass.background_handoff
|
|
651
|
+
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
strip_background_and_async!(child_class)
|
|
655
|
+
force_sync_nested_reactors!(child_class)
|
|
656
|
+
child_class
|
|
657
|
+
end
|
|
658
|
+
|
|
538
659
|
def apply_interceptors(klass, interceptors)
|
|
539
660
|
# Group interceptors by the current level step
|
|
540
661
|
grouped = interceptors.group_by { |i| i[:step_path].first }
|
|
@@ -564,7 +685,7 @@ module RubyReactor
|
|
|
564
685
|
direct_interceptors.each do |interceptor|
|
|
565
686
|
case interceptor[:type]
|
|
566
687
|
when :failure
|
|
567
|
-
apply_failure_interceptor(step_config, target_step)
|
|
688
|
+
apply_failure_interceptor(step_config, target_step, step_config_orig, interceptor)
|
|
568
689
|
when :mock
|
|
569
690
|
apply_mock_interceptor(step_config, target_step, step_config_orig, interceptor)
|
|
570
691
|
end
|
|
@@ -595,9 +716,19 @@ module RubyReactor
|
|
|
595
716
|
|
|
596
717
|
original_child_reactor = target_reactor_class_source.value
|
|
597
718
|
|
|
598
|
-
# Dynamically subclass the child reactor
|
|
719
|
+
# Dynamically subclass the child reactor.
|
|
720
|
+
#
|
|
721
|
+
# It needs a resolvable identity of its OWN: a fan-out map serializes
|
|
722
|
+
# only `mapped_reactor_class.name` into each element job, and the worker
|
|
723
|
+
# resolves that name back through `const_get` first. Keeping the
|
|
724
|
+
# original name would therefore resolve to the original, unmocked class
|
|
725
|
+
# in every element. Registering a unique name makes the fan-out payload
|
|
726
|
+
# (and the `element_reactor_class` that `#map_elements` traverses) point
|
|
727
|
+
# at THIS class.
|
|
599
728
|
mocked_child_reactor = Class.new(original_child_reactor) do
|
|
600
|
-
|
|
729
|
+
unique_name = "#{original_child_reactor.name || "AnonymousReactor"}Mock#{object_id}"
|
|
730
|
+
define_singleton_method(:name) { unique_name }
|
|
731
|
+
RubyReactor::Registry.register(unique_name, self)
|
|
601
732
|
# Copy configuration
|
|
602
733
|
@steps = superclass.steps.dup
|
|
603
734
|
@inputs = superclass.inputs.dup
|
|
@@ -629,9 +760,40 @@ module RubyReactor
|
|
|
629
760
|
step_config.arguments[arg_key][:source] = RubyReactor::Template::Value.new(mocked_child_reactor)
|
|
630
761
|
end
|
|
631
762
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
763
|
+
# The original run behavior for a step, used both as the fallback when
|
|
764
|
+
# `element_index` excludes the current map element and as the `original`
|
|
765
|
+
# callable a mock block can invoke.
|
|
766
|
+
def original_impl_for(step_config_orig, target_step)
|
|
767
|
+
if step_config_orig.has_run_block?
|
|
768
|
+
step_config_orig.run_block
|
|
769
|
+
elsif step_config_orig.has_impl?
|
|
770
|
+
->(args, ctx) { step_config_orig.impl.run(args, ctx) }
|
|
771
|
+
else
|
|
772
|
+
->(_, _) { raise "No implementation found for #{target_step}" }
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
# A map element's own context carries `map_metadata[:index]`. When an
|
|
777
|
+
# interceptor is scoped to one element via `element_index:`, only that
|
|
778
|
+
# element's context matches — everything else falls through to the
|
|
779
|
+
# original implementation.
|
|
780
|
+
def matches_element_index?(context, element_index)
|
|
781
|
+
return true if element_index.nil?
|
|
782
|
+
|
|
783
|
+
meta = context.map_metadata
|
|
784
|
+
meta && (meta[:index] == element_index || meta["index"] == element_index)
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def apply_failure_interceptor(step_config, target_step, step_config_orig, interceptor)
|
|
788
|
+
element_index = interceptor[:conditions][:element_index]
|
|
789
|
+
original_impl = original_impl_for(step_config_orig, target_step)
|
|
790
|
+
|
|
791
|
+
failure_impl = lambda do |input, context|
|
|
792
|
+
if matches_element_index?(context, element_index)
|
|
793
|
+
RubyReactor::Failure("Simulated failure at #{target_step}")
|
|
794
|
+
else
|
|
795
|
+
original_impl.call(input, context)
|
|
796
|
+
end
|
|
635
797
|
end
|
|
636
798
|
|
|
637
799
|
step_config.instance_variable_set(:@run_block, failure_impl)
|
|
@@ -639,18 +801,13 @@ module RubyReactor
|
|
|
639
801
|
|
|
640
802
|
def apply_mock_interceptor(step_config, target_step, step_config_orig, interceptor)
|
|
641
803
|
mock_block = interceptor[:conditions][:block]
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
original_impl = if step_config_orig.has_run_block?
|
|
645
|
-
step_config_orig.run_block
|
|
646
|
-
elsif step_config_orig.has_impl?
|
|
647
|
-
->(args, ctx) { step_config_orig.impl.run(args, ctx) }
|
|
648
|
-
else
|
|
649
|
-
->(_, _) { raise "No implementation found for #{target_step}" }
|
|
650
|
-
end
|
|
804
|
+
element_index = interceptor[:conditions][:element_index]
|
|
805
|
+
original_impl = original_impl_for(step_config_orig, target_step)
|
|
651
806
|
|
|
652
807
|
# Create the new implementation that wraps the user block
|
|
653
808
|
wrapper_impl = lambda do |args, context|
|
|
809
|
+
next original_impl.call(args, context) unless matches_element_index?(context, element_index)
|
|
810
|
+
|
|
654
811
|
if mock_block.arity == 3
|
|
655
812
|
mock_block.call(args, context, original_impl)
|
|
656
813
|
else
|
data/lib/ruby_reactor/rspec.rb
CHANGED
|
@@ -21,7 +21,14 @@ module RubyReactor
|
|
|
21
21
|
def self.configure(config)
|
|
22
22
|
require_relative "rspec/step_executor_patch"
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# `test_reactor` and the async-job helpers are only auto-included into
|
|
25
|
+
# examples tagged `type: :reactor`, so a host app's other specs keep a
|
|
26
|
+
# clean namespace. Specs that want them without the tag can
|
|
27
|
+
# `include RubyReactor::RSpec::Helpers` explicitly.
|
|
28
|
+
#
|
|
29
|
+
# Matchers stay unconditional: `::RSpec::Matchers.define` registers them
|
|
30
|
+
# globally at load time, so gating this include would not scope anything.
|
|
31
|
+
config.include RubyReactor::RSpec::Helpers, REACTOR_METADATA
|
|
25
32
|
config.include RubyReactor::RSpec::Matchers
|
|
26
33
|
config.include RubyReactor::RSpec::SidekiqHelpers, REACTOR_METADATA
|
|
27
34
|
|
|
@@ -91,8 +91,8 @@ module RubyReactor
|
|
|
91
91
|
results = []
|
|
92
92
|
fail_fast = inputs[:fail_fast].nil? || inputs[:fail_fast]
|
|
93
93
|
|
|
94
|
-
inputs[:source].
|
|
95
|
-
result = execute_single_element(element)
|
|
94
|
+
inputs[:source].each_with_index do |element, index|
|
|
95
|
+
result = execute_single_element(element, index)
|
|
96
96
|
|
|
97
97
|
# An element-level Halt propagates as a run halt: stop immediately
|
|
98
98
|
# rather than being collected as a (nil) value.
|
|
@@ -109,7 +109,7 @@ module RubyReactor
|
|
|
109
109
|
results
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
def execute_single_element(element)
|
|
112
|
+
def execute_single_element(element, index)
|
|
113
113
|
mapped_inputs = self.class.build_mapped_inputs(inputs[:argument_mappings] || {}, context, element)
|
|
114
114
|
child_context = RubyReactor::Context.new(mapped_inputs, inputs[:mapped_reactor_class])
|
|
115
115
|
|
|
@@ -123,7 +123,7 @@ module RubyReactor
|
|
|
123
123
|
child_context.map_metadata = {
|
|
124
124
|
map_id: map_id,
|
|
125
125
|
parent_reactor_class_name: context.reactor_class.name,
|
|
126
|
-
index:
|
|
126
|
+
index: index
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
# Store reference in composed_contexts so the UI knows where to find elements
|
data/lib/ruby_reactor/version.rb
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Scoping the RSpec matchers to `type: :reactor`
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
`RubyReactor::RSpec.configure(config)` should add **nothing** to a host app's
|
|
6
|
+
example groups unless they opt in with `type: :reactor`. Helpers already work
|
|
7
|
+
that way. The 19 matchers do not — they are global the moment
|
|
8
|
+
`ruby_reactor/rspec` is required, and moving them behind the tag needs a
|
|
9
|
+
one-line-per-matcher change plus a migration.
|
|
10
|
+
|
|
11
|
+
## Current state
|
|
12
|
+
|
|
13
|
+
[lib/ruby_reactor/rspec.rb:20-42](../lib/ruby_reactor/rspec.rb#L20-L42):
|
|
14
|
+
|
|
15
|
+
| Piece | Scoped to `type: :reactor`? |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `Helpers` (`test_reactor`) | yes — `config.include …, REACTOR_METADATA` |
|
|
18
|
+
| `SidekiqHelpers` (`drain_async_jobs`, `pending_async_jobs`) | yes |
|
|
19
|
+
| `before(:each)` setup (fake mode, storage wipe, snooze reset) | yes |
|
|
20
|
+
| `Matchers` (19 matchers) | **no** |
|
|
21
|
+
| `StorageReset.install!`, `StepExecutor.prepend` | no — load-time monkeypatches, out of scope for this doc |
|
|
22
|
+
|
|
23
|
+
## Why the `config.include` line does nothing today
|
|
24
|
+
|
|
25
|
+
[matchers.rb](../lib/ruby_reactor/rspec/matchers.rb) declares every matcher as:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
module Matchers
|
|
29
|
+
::RSpec::Matchers.define :be_success do
|
|
30
|
+
...
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`define` is [`RSpec::Matchers::DSL#define`](https://github.com/rspec/rspec-expectations/blob/main/lib/rspec/matchers/dsl.rb),
|
|
36
|
+
and its entire body is a `define_method` **on the receiver**:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
def define(name, &declarations)
|
|
40
|
+
warn_about_block_args(name, declarations)
|
|
41
|
+
define_method name do |*expected, &block_arg|
|
|
42
|
+
RSpec::Matchers::DSL::Matcher.new(name, declarations, self, *expected, &block_arg)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
alias_method :matcher, :define
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The receiver is `::RSpec::Matchers`, which RSpec includes into *every* example
|
|
49
|
+
group. So the matcher methods land in the global spec namespace at require
|
|
50
|
+
time, and `RubyReactor::RSpec::Matchers` ends up an empty module — the
|
|
51
|
+
`config.include RubyReactor::RSpec::Matchers` line in
|
|
52
|
+
[rspec.rb:32](../lib/ruby_reactor/rspec.rb#L32) includes nothing. Adding
|
|
53
|
+
`REACTOR_METADATA` to that line scopes nothing either.
|
|
54
|
+
|
|
55
|
+
## The change
|
|
56
|
+
|
|
57
|
+
Extend the DSL into **our** module, so `define_method` targets
|
|
58
|
+
`RubyReactor::RSpec::Matchers`:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
module RubyReactor
|
|
62
|
+
module RSpec
|
|
63
|
+
module Matchers
|
|
64
|
+
extend ::RSpec::Matchers::DSL # <- add
|
|
65
|
+
|
|
66
|
+
matcher :be_success do # <- was ::RSpec::Matchers.define
|
|
67
|
+
...
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then gate the include:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
config.include RubyReactor::RSpec::Matchers, REACTOR_METADATA
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Mechanically: one `extend` line, 19 `::RSpec::Matchers.define` →
|
|
81
|
+
`matcher` rewrites (`sed` handles it), one changed line in `rspec.rb`, and the
|
|
82
|
+
comment at [rspec.rb:29-30](../lib/ruby_reactor/rspec.rb#L29-L30) — which
|
|
83
|
+
currently explains why matchers *can't* be scoped — deleted.
|
|
84
|
+
|
|
85
|
+
Nothing inside the matcher bodies changes. `Matchers.coordination_adapter`
|
|
86
|
+
([matchers.rb:260](../lib/ruby_reactor/rspec/matchers.rb#L260)) is called with
|
|
87
|
+
an explicit receiver and resolves lexically at match time, so it is unaffected.
|
|
88
|
+
|
|
89
|
+
### Verified
|
|
90
|
+
|
|
91
|
+
A probe against this repo's `spec_helper`, with one matcher declared each way:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
UNTAGGED scoped: expected :nope to respond to `probe_scoped?` # not included -> falls through
|
|
95
|
+
UNTAGGED global: GLOBAL-MATCHER-RAN: expected :ok, got :nope # still global
|
|
96
|
+
TAGGED scoped: SCOPED-MATCHER-RAN: expected :ok, got :nope # real matcher, chains and all
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Note the first line: scoping a matcher out does **not** produce a
|
|
100
|
+
`NoMethodError`. See below.
|
|
101
|
+
|
|
102
|
+
## The trap: `be_*` / `have_*` fall through to a predicate matcher
|
|
103
|
+
|
|
104
|
+
All 19 matcher names start with `be_` or `have_`, which is exactly the prefix
|
|
105
|
+
RSpec's dynamic predicate matchers claim. Once a matcher is no longer in scope,
|
|
106
|
+
`expect(x).to be_success` stops meaning "RubyReactor's `be_success`" and starts
|
|
107
|
+
meaning "call `x.success?`". Two consequences:
|
|
108
|
+
|
|
109
|
+
1. **`respond_to?(:be_success)` is not a scoping test.** It returns `true` in
|
|
110
|
+
any example group for any `be_*`/`have_*` name, because
|
|
111
|
+
`RSpec::Matchers#respond_to_missing?` claims the whole prefix. Test scoping
|
|
112
|
+
by running an expectation and reading the failure message (as the probe
|
|
113
|
+
above does), not by probing `respond_to?`.
|
|
114
|
+
2. **Some call sites degrade silently rather than breaking.** `TestSubject` and
|
|
115
|
+
the result objects define `success?`, `failure?`, `paused?`, `halted?` and
|
|
116
|
+
`skipped?` ([test_subject.rb:388-405](../lib/ruby_reactor/rspec/test_subject.rb#L388-L405),
|
|
117
|
+
[ruby_reactor.rb:50-62](../lib/ruby_reactor.rb#L50-L62)), so an untagged
|
|
118
|
+
`expect(result).to be_success` keeps passing via the predicate — with a
|
|
119
|
+
generic failure message instead of the rich one in
|
|
120
|
+
[matchers.rb:14-23](../lib/ruby_reactor/rspec/matchers.rb#L14-L23), and
|
|
121
|
+
without the `ensure_executed!` nudge the real matcher does. Anything with a
|
|
122
|
+
chain (`have_run_step(:x).returning(y)`, `be_halted.because(:period)`,
|
|
123
|
+
`be_locked.by(owner)`) or a non-predicate name fails loudly instead.
|
|
124
|
+
|
|
125
|
+
So the migration cost is not "find the red specs" — a suite can go green while
|
|
126
|
+
quietly asserting something weaker. Grep for the matcher names rather than
|
|
127
|
+
trusting a passing run.
|
|
128
|
+
|
|
129
|
+
## Migration
|
|
130
|
+
|
|
131
|
+
In this repo, 34 spec files use these matchers; 24 of them are not tagged
|
|
132
|
+
`type: :reactor`. Most only need the tag added to the `RSpec.describe` line, the
|
|
133
|
+
same edit made for `test_reactor` in `0a3ebdab`. Order of work:
|
|
134
|
+
|
|
135
|
+
1. Rewrite `matchers.rb` to the DSL-extend form, leave the include ungated —
|
|
136
|
+
suite must stay green (proves the rewrite alone changes nothing).
|
|
137
|
+
2. Gate the include, run the suite, tag the files that fail.
|
|
138
|
+
3. Grep the 24 untagged files for matcher names and tag the rest by hand —
|
|
139
|
+
step 2 will not catch the silent-degradation cases above.
|
|
140
|
+
4. Update [documentation/testing.md](../documentation/testing.md#L17-L35),
|
|
141
|
+
whose Setup section currently says matchers are available everywhere, and
|
|
142
|
+
the comment at [rspec.rb:29-30](../lib/ruby_reactor/rspec.rb#L29-L30).
|
|
143
|
+
|
|
144
|
+
## Breaking change
|
|
145
|
+
|
|
146
|
+
Downstream suites calling these matchers in untagged groups are affected, with
|
|
147
|
+
the same silent-degradation caveat: a user's suite may stay green while
|
|
148
|
+
asserting less. This warrants a `BREAKING CHANGE:` footer and a release note
|
|
149
|
+
that names the fallback behavior explicitly, not just "matchers are now
|
|
150
|
+
scoped".
|
|
151
|
+
|
|
152
|
+
## Alternative considered
|
|
153
|
+
|
|
154
|
+
Keep matchers global, on the grounds that `be_success` / `have_run_step` are
|
|
155
|
+
already ambiguous with RSpec's predicate matchers and that a host app gains
|
|
156
|
+
little from gating them. This costs nothing and breaks nobody — worth taking if
|
|
157
|
+
the namespace complaint is theoretical rather than something a host app has
|
|
158
|
+
actually hit.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_reactor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Artur
|
|
@@ -289,6 +289,7 @@ files:
|
|
|
289
289
|
- specs/deferred-003-step-lock-declarations/spec.md
|
|
290
290
|
- specs/deferred-003-step-lock-declarations/tasks.md
|
|
291
291
|
- specs/possible_feature.md
|
|
292
|
+
- specs/scoped_rspec_matchers.md
|
|
292
293
|
- teley/Dockerfile
|
|
293
294
|
homepage: https://github.com/arturictus/ruby_reactor
|
|
294
295
|
licenses: []
|