ruby_reactor 0.3.0 → 0.3.1

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,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module RSpec
5
+ module Matchers
6
+ # rubocop:disable Metrics/BlockLength
7
+ ::RSpec::Matchers.define :be_success do
8
+ match do |subject|
9
+ subject.ensure_executed!
10
+ subject.success?
11
+ end
12
+
13
+ failure_message do |subject|
14
+ result = subject.result
15
+ if result&.failure?
16
+ format_failure_message(result)
17
+ else
18
+ "expected reactor to be success, but failed (Status: #{subject.reactor_instance.context.status})"
19
+ end
20
+ end
21
+
22
+ def format_failure_message(error)
23
+ # Safely extract values
24
+ err_msg = error.respond_to?(:error) ? error.error.to_s : error.to_s
25
+ ex_class = error.respond_to?(:exception_class) ? error.exception_class : nil
26
+ step = error.respond_to?(:step_name) ? error.step_name : nil
27
+ file = error.respond_to?(:file_path) ? error.file_path : nil
28
+ line = error.respond_to?(:line_number) ? error.line_number : nil
29
+ snippet = error.respond_to?(:code_snippet) ? error.code_snippet : nil
30
+ backtrace = error.respond_to?(:backtrace) ? error.backtrace : nil
31
+
32
+ lines = []
33
+ lines << "Error: #{ex_class || "UnknownError"}"
34
+ lines << err_msg.to_s
35
+ lines << "Step: :#{step}" if step
36
+ lines << "File: #{file}:#{line}" if file
37
+
38
+ append_snippet(lines, snippet)
39
+ append_backtrace(lines, backtrace)
40
+
41
+ lines.join("\n")
42
+ end
43
+
44
+ def append_snippet(lines, snippet)
45
+ return unless snippet.is_a?(Array) && !snippet.empty?
46
+
47
+ lines << ""
48
+ snippet.each do |s|
49
+ prefix = s[:target] ? "--> " : " "
50
+ lines << "#{prefix}#{s[:content]}"
51
+ end
52
+ end
53
+
54
+ def append_backtrace(lines, backtrace)
55
+ return unless backtrace && !backtrace.empty?
56
+
57
+ lines << ""
58
+ lines << "Backtrace:"
59
+ lines << backtrace.take(10).map { |l| "- #{l}" }
60
+ end
61
+ end
62
+
63
+ ::RSpec::Matchers.define :be_failure do
64
+ match do |subject|
65
+ subject.ensure_executed!
66
+ subject.failure?
67
+ end
68
+
69
+ failure_message do |_subject|
70
+ "expected reactor to be failure, but succeeded"
71
+ end
72
+ end
73
+
74
+ ::RSpec::Matchers.define :have_run_step do |step_name|
75
+ match do |subject|
76
+ subject.ensure_executed!
77
+ @trace = subject.reactor_instance.context.execution_trace
78
+ @entry = @trace.find { |t| t[:step].to_s == step_name.to_s }
79
+
80
+ return false unless @entry
81
+
82
+ matches_result?(subject, step_name) && matches_order?
83
+ end
84
+
85
+ def matches_result?(subject, step_name)
86
+ return true unless @check_result
87
+
88
+ actual_result = subject.step_result(step_name)
89
+ if @expected_result.is_a?(Regexp)
90
+ actual_result.to_s.match?(@expected_result)
91
+ else
92
+ values_match?(@expected_result, actual_result)
93
+ end
94
+ end
95
+
96
+ def matches_order?
97
+ return true unless @after_step
98
+
99
+ after_index = @trace.index(@entry)
100
+ before_entry = @trace.find { |t| t[:step].to_s == @after_step.to_s }
101
+
102
+ return false unless before_entry
103
+
104
+ after_index > @trace.index(before_entry)
105
+ end
106
+
107
+ chain :returning do |value|
108
+ @check_result = true
109
+ @expected_result = value
110
+ end
111
+
112
+ chain :after do |step|
113
+ @after_step = step
114
+ end
115
+
116
+ failure_message do |subject|
117
+ msg = "expected reactor to have run step :#{step_name}"
118
+ if @check_result
119
+ actual_result = subject.step_result(step_name)
120
+ msg += " returning #{@expected_result.inspect}, but returned #{actual_result.inspect}"
121
+ end
122
+ msg += " after :#{@after_step}" if @after_step
123
+ msg
124
+ end
125
+ end
126
+
127
+ ::RSpec::Matchers.define :have_retried_step do |step_name|
128
+ match do |subject|
129
+ subject.ensure_executed!
130
+ attempts = subject.reactor_instance.context.retry_context.attempts_for_step(step_name)
131
+ retries = attempts - 1
132
+
133
+ if @expected_retries
134
+ retries == @expected_retries
135
+ else
136
+ retries.positive?
137
+ end
138
+ end
139
+
140
+ chain :times do |count|
141
+ @expected_retries = count
142
+ end
143
+
144
+ failure_message do |_subject|
145
+ msg = "expected reactor to have retried step :#{step_name}"
146
+ msg += " #{@expected_retries} times" if @expected_retries
147
+ msg
148
+ end
149
+ end
150
+
151
+ ::RSpec::Matchers.define :have_validation_error do |field|
152
+ match do |subject|
153
+ subject.ensure_executed!
154
+ return false unless subject.failure?
155
+
156
+ # Try to get validation errors from failure reason
157
+ reason = subject.reactor_instance.context.failure_reason || {}
158
+
159
+ # If failure is InputValidationError, it might be serialized differently
160
+ # Or stored in validation_errors key
161
+ errors = reason["validation_errors"] || reason[:validation_errors]
162
+
163
+ if errors
164
+ errors.key?(field.to_s) || errors.key?(field.to_sym)
165
+ else
166
+ false
167
+ end
168
+ end
169
+
170
+ failure_message do |_subject|
171
+ "expected reactor to have validation error on :#{field}"
172
+ end
173
+ end
174
+
175
+ # Matcher to check if reactor is paused at an interrupt
176
+ ::RSpec::Matchers.define :be_paused do
177
+ match do |subject|
178
+ subject.ensure_executed!
179
+ subject.paused?
180
+ end
181
+
182
+ failure_message do |subject|
183
+ "expected reactor to be paused, but status was #{subject.reactor_instance.context.status}"
184
+ end
185
+
186
+ failure_message_when_negated do |_subject|
187
+ "expected reactor not to be paused, but it is"
188
+ end
189
+ end
190
+
191
+ # Matcher to check if reactor is paused at a specific interrupt step
192
+ # Works with both single and multiple concurrent interrupts
193
+ ::RSpec::Matchers.define :be_paused_at do |*step_names|
194
+ match do |subject|
195
+ subject.ensure_executed!
196
+ return false unless subject.paused?
197
+
198
+ ready_steps = subject.ready_interrupt_steps
199
+ step_names.all? { |name| ready_steps.include?(name.to_sym) }
200
+ end
201
+
202
+ failure_message do |subject|
203
+ if subject.paused?
204
+ ready_steps = subject.ready_interrupt_steps
205
+ if step_names.size == 1
206
+ "expected reactor to be paused at :#{step_names.first}, " \
207
+ "but ready interrupt steps are: #{ready_steps.inspect}"
208
+ else
209
+ "expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
210
+ "but ready interrupt steps are: #{ready_steps.inspect}"
211
+ end
212
+ else
213
+ "expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
214
+ "but status was #{subject.reactor_instance.context.status}"
215
+ end
216
+ end
217
+
218
+ failure_message_when_negated do |_subject|
219
+ "expected reactor not to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, but it is"
220
+ end
221
+ end
222
+
223
+ # Matcher to check the exact set of ready interrupt steps
224
+ ::RSpec::Matchers.define :have_ready_interrupts do |*expected_steps|
225
+ match do |subject|
226
+ subject.ensure_executed!
227
+ return false unless subject.paused?
228
+
229
+ actual_steps = subject.ready_interrupt_steps.sort
230
+ expected = expected_steps.map(&:to_sym).sort
231
+ actual_steps == expected
232
+ end
233
+
234
+ failure_message do |subject|
235
+ if subject.paused?
236
+ actual_steps = subject.ready_interrupt_steps
237
+ "expected ready interrupt steps to be #{expected_steps.map { |s| ":#{s}" }}, " \
238
+ "but got #{actual_steps.inspect}"
239
+ else
240
+ "expected reactor to be paused with ready interrupt steps, " \
241
+ "but status was #{subject.reactor_instance.context.status}"
242
+ end
243
+ end
244
+
245
+ failure_message_when_negated do |subject|
246
+ actual_steps = subject.ready_interrupt_steps
247
+ "expected ready interrupt steps not to be #{expected_steps.map { |s| ":#{s}" }}, " \
248
+ "but it was #{actual_steps.inspect}"
249
+ end
250
+ end
251
+
252
+ # Add more matchers as per plan
253
+ # rubocop:enable Metrics/BlockLength
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_reactor"
4
+
5
+ module RubyReactor
6
+ module RSpec
7
+ # Patch StepExecutor to handle inline async execution during tests.
8
+ # This ensures that when a worker runs inline (e.g. Sidekiq::Testing.inline!),
9
+ # the calling executor can pick up the state change immediately and continue,
10
+ # preventing stalled execution in nested async scenarios.
11
+ module StepExecutorPatch
12
+ def execute_step(step_config)
13
+ # 1. Call original implementation
14
+ result = super
15
+
16
+ # 2. Add test-specific logic for inline async execution
17
+ # Only interfere if we got an AsyncResult and we are in a testing environment that supports inline execution
18
+ if should_check_inline_completion?(result)
19
+ # Check if it finished or paused inline (e.g. Sidekiq::Testing.inline!)
20
+ refresh_context_from_storage
21
+
22
+ # If the step itself now has a result, it means it ran inline
23
+ if @context.has_result?(step_config.name)
24
+ # If the step failed, we should return failure
25
+ return reconstruct_failure(@context.failure_reason) if @context.failed?
26
+
27
+ return nil # Continue to next step
28
+ end
29
+
30
+ # If the overall reactor finished or paused for other reasons (e.g. error in worker)
31
+ if @context.finished? || @context.status.to_s == "paused"
32
+ return reconstruct_failure(@context.failure_reason) if @context.failed?
33
+
34
+ if @context.status.to_s == "paused"
35
+ return RubyReactor::InterruptResult.new(
36
+ execution_id: @context.context_id,
37
+ intermediate_results: @context.intermediate_results
38
+ )
39
+ end
40
+ return nil # Finished successfully
41
+ end
42
+ end
43
+
44
+ # Return original result if no intervention needed
45
+ result
46
+ end
47
+
48
+ private
49
+
50
+ def refresh_context_from_storage
51
+ storage = RubyReactor::Configuration.instance.storage_adapter
52
+ reactor_class_name = @reactor_class.name
53
+ serialized = storage.retrieve_context(@context.context_id, reactor_class_name)
54
+ return unless serialized
55
+
56
+ reloaded_context = Context.deserialize_from_retry(serialized)
57
+
58
+ # Update local context state
59
+ @context.status = reloaded_context.status
60
+ @context.failure_reason = reloaded_context.failure_reason
61
+ @context.cancelled = reloaded_context.cancelled
62
+ @context.cancellation_reason = reloaded_context.cancellation_reason
63
+ @context.intermediate_results.merge!(reloaded_context.intermediate_results)
64
+ @context.execution_trace = reloaded_context.execution_trace
65
+ @context.private_data.merge!(reloaded_context.private_data)
66
+ @context.retry_context = reloaded_context.retry_context
67
+ @context.composed_contexts.merge!(reloaded_context.composed_contexts)
68
+ @context.map_operations.merge!(reloaded_context.map_operations)
69
+ @context.map_metadata = reloaded_context.map_metadata
70
+
71
+ # Also need to mark step as completed in dependency graph
72
+ reloaded_context.intermediate_results.each_key do |step_name|
73
+ @dependency_graph.complete_step(step_name.to_sym)
74
+ end
75
+ end
76
+
77
+ def should_check_inline_completion?(result)
78
+ return false unless result.is_a?(RubyReactor::AsyncResult) || result.is_a?(RubyReactor::RetryQueuedResult)
79
+ return true if defined?(Sidekiq::Testing) && Sidekiq::Testing.inline?
80
+
81
+ false
82
+ end
83
+ end
84
+ end
85
+ end