cmdx-rspec 1.4.0 → 2.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.DS_Store +0 -0
  3. data/CHANGELOG.md +25 -0
  4. data/README.md +337 -169
  5. data/lib/cmdx/rspec/helpers.rb +453 -291
  6. data/lib/cmdx/rspec/matchers/be_complete.rb +20 -0
  7. data/lib/cmdx/rspec/matchers/be_deprecated.rb +14 -52
  8. data/lib/cmdx/rspec/matchers/be_interrupted.rb +20 -0
  9. data/lib/cmdx/rspec/matchers/be_ko.rb +19 -0
  10. data/lib/cmdx/rspec/matchers/be_ok.rb +19 -0
  11. data/lib/cmdx/rspec/matchers/be_successful.rb +8 -20
  12. data/lib/cmdx/rspec/matchers/have_been_retried.rb +41 -0
  13. data/lib/cmdx/rspec/matchers/have_been_rolled_back.rb +23 -0
  14. data/lib/cmdx/rspec/matchers/have_callback.rb +46 -0
  15. data/lib/cmdx/rspec/matchers/have_chain_root.rb +30 -0
  16. data/lib/cmdx/rspec/matchers/have_chain_size.rb +29 -0
  17. data/lib/cmdx/rspec/matchers/have_duration.rb +30 -0
  18. data/lib/cmdx/rspec/matchers/have_empty_context.rb +4 -16
  19. data/lib/cmdx/rspec/matchers/have_empty_metadata.rb +3 -9
  20. data/lib/cmdx/rspec/matchers/have_errors_on.rb +50 -0
  21. data/lib/cmdx/rspec/matchers/have_failed.rb +7 -24
  22. data/lib/cmdx/rspec/matchers/have_input.rb +41 -0
  23. data/lib/cmdx/rspec/matchers/have_matching_context.rb +5 -20
  24. data/lib/cmdx/rspec/matchers/have_matching_metadata.rb +5 -17
  25. data/lib/cmdx/rspec/matchers/have_middleware.rb +29 -0
  26. data/lib/cmdx/rspec/matchers/have_no_errors.rb +30 -0
  27. data/lib/cmdx/rspec/matchers/have_output.rb +46 -0
  28. data/lib/cmdx/rspec/matchers/have_pipeline_tasks.rb +27 -0
  29. data/lib/cmdx/rspec/matchers/have_retry_on.rb +36 -0
  30. data/lib/cmdx/rspec/matchers/have_skipped.rb +7 -24
  31. data/lib/cmdx/rspec/matchers/have_tag.rb +30 -0
  32. data/lib/cmdx/rspec/matchers/raise_cmdx_fault.rb +74 -0
  33. data/lib/cmdx/rspec/version.rb +2 -1
  34. data/lib/cmdx/rspec.rb +22 -3
  35. metadata +25 -5
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ---
6
6
 
7
- Collection of RSpec matchers for the CMDx framework.
7
+ Collection of RSpec matchers and helpers for the CMDx framework.
8
8
 
9
9
  [Changelog](./CHANGELOG.md) · [Report Bug](https://github.com/drexed/cmdx-rspec/issues) · [Request Feature](https://github.com/drexed/cmdx-rspec/issues)
10
10
 
@@ -15,141 +15,277 @@
15
15
 
16
16
  # CMDx::RSpec
17
17
 
18
- Collection of RSpec matchers for [CMDx](https://github.com/drexed/cmdx).
18
+ RSpec matchers and helpers for asserting [CMDx](https://github.com/drexed/cmdx) task and workflow behavior — result state, errors, faults, callbacks, retries, chains, and more — without invoking real `work` blocks.
19
+
20
+ ## Requirements
21
+
22
+ - Ruby: MRI 3.3+ or a compatible JRuby/TruffleRuby release
23
+ - CMDx 2.0+
19
24
 
20
25
  ## Installation
21
26
 
22
- Add this line to your application's Gemfile:
27
+ ```sh
28
+ gem install cmdx-rspec
29
+ # - or -
30
+ bundle add cmdx-rspec --group test
31
+ ```
32
+
33
+ Require the library in `spec_helper.rb` (or equivalent):
23
34
 
24
35
  ```ruby
25
- gem 'cmdx-rspec'
36
+ require "cmdx/rspec"
26
37
  ```
27
38
 
28
- And then execute:
39
+ This loads every matcher under `RSpec::Matchers` and exposes the helpers under `CMDx::RSpec::Helpers`. See [Helpers](#helpers) for how to mix the helpers into your example groups.
40
+
41
+ ### Relationship to the CMDx testing guide
29
42
 
30
- $ bundle
43
+ The [CMDx testing docs](https://drexed.github.io/cmdx/testing/) use RSpec’s built-in predicate matchers on `CMDx::Result` — for example `be_success` calls `success?`, `be_failed` calls `failed?`, and `be_skipped` calls `skipped?`.
31
44
 
32
- Or install it yourself as:
45
+ This gem adds matchers that pin both **state** and **status** via `result.to_h` (`be_successful`, `have_skipped`, `have_failed`), plus declaration matchers, chain helpers, and stub/build utilities. You can use both styles in the same suite.
33
46
 
34
- $ gem install cmdx-rspec
47
+ For `execute!`, CMDx only raises when the outcome is **failed** (a `CMDx::Fault`, or the original exception when `result.cause` is a non-fault). Skips and successes return a result even in strict mode.
35
48
 
36
49
  ## Matchers
37
50
 
38
- ### be_successful
51
+ All result-oriented matchers raise `ArgumentError` when given a subject that isn't a `CMDx::Result`. Class-oriented matchers accept either the Task class or an instance.
39
52
 
40
- Asserts that a CMDx task result indicates successful execution.
53
+ ### Result state & status
54
+
55
+ #### `be_successful`
56
+
57
+ Asserts a `CMDx::Result` completed with `state: "complete"` and `status: "success"`. Extra keyword args are forwarded to a `result.to_h` inclusion check, so any field can be constrained inline.
41
58
 
42
59
  ```ruby
43
- it "returns success" do
44
- result = SomeTask.execute
60
+ expect(SomeTask.execute).to be_successful
61
+ expect(SomeTask.execute).to be_successful(metadata: { id: 1 })
62
+ ```
45
63
 
46
- expect(result).to be_successful
47
- end
64
+ #### `have_skipped`
65
+
66
+ Asserts a result was skipped (`state: "interrupted"`, `status: "skipped"`). Extra keyword args constrain other `result.to_h` fields.
67
+
68
+ ```ruby
69
+ expect(result).to have_skipped
70
+ expect(result).to have_skipped(reason: "out of stock", metadata: { code: "NA" })
48
71
  ```
49
72
 
50
- ### have_skipped
73
+ #### `have_failed`
51
74
 
52
- Asserts that a CMDx task result indicates the task was skipped during execution.
75
+ Asserts a result failed (`state: "interrupted"`, `status: "failed"`). Extra keyword args constrain other `result.to_h` fields.
53
76
 
54
77
  ```ruby
55
- it "returns skipped" do
56
- result = SomeTask.execute
78
+ expect(result).to have_failed
79
+ expect(result).to have_failed(
80
+ reason: "boom",
81
+ cause: be_a(NoMethodError)
82
+ )
83
+ ```
57
84
 
58
- # Default result
59
- expect(result).to have_skipped
85
+ #### `be_ok` / `be_ko`
60
86
 
61
- # Custom result
62
- expect(result).to have_skipped(
63
- reason: "Skipping for a custom reason",
64
- cause: be_a(CMDx::SkipFault)
65
- # Other members of `result.to_h`...
66
- )
67
- end
87
+ `be_ok` delegates to `Result#ok?` (success or skipped). `be_ko` delegates to `Result#ko?` (skipped or failed). Skipped results satisfy **both** matchers.
88
+
89
+ ```ruby
90
+ expect(result).to be_ok
91
+ expect(result).to be_ko
68
92
  ```
69
93
 
70
- ### have_failed
94
+ #### `be_complete` / `be_interrupted`
71
95
 
72
- Asserts that a CMDx task result indicates execution failure.
96
+ State-only assertions. `be_complete` passes when `state == "complete"`; `be_interrupted` when `state == "interrupted"`.
73
97
 
74
98
  ```ruby
75
- it "returns failure" do
76
- result = SomeTask.execute
99
+ expect(result).to be_complete
100
+ expect(result).to be_interrupted
101
+ ```
77
102
 
78
- # Default result
79
- expect(result).to have_failed
103
+ ### Result data
80
104
 
81
- # Custom result
82
- expect(result).to have_failed(
83
- reason: "Failed for a custom reason",
84
- cause: be_a(NoMethodError)
85
- # Other members of `result.to_h`...
86
- )
87
- end
105
+ #### `have_empty_metadata` / `have_matching_metadata`
106
+
107
+ `have_empty_metadata` requires `metadata` to be empty. `have_matching_metadata` performs a partial-hash inclusion match (and delegates to `have_empty_metadata` when called with no args).
108
+
109
+ ```ruby
110
+ expect(result).to have_empty_metadata
111
+ expect(result).to have_matching_metadata(status_code: 500)
88
112
  ```
89
113
 
90
- ### have_empty_metadata
114
+ #### `have_empty_context` / `have_matching_context`
91
115
 
92
- Asserts that a CMDx task result has no metadata.
116
+ Same pattern for the result's context. Both accept a `Hash`, `CMDx::Context`, or `CMDx::Result` (the result's `.context` is unwrapped automatically).
93
117
 
94
118
  ```ruby
95
- it "returns empty metadata" do
96
- result = SomeTask.execute
119
+ expect(result).to have_empty_context
120
+ expect(result).to have_matching_context(stored_id: 123)
121
+ ```
97
122
 
98
- expect(result).to have_empty_metadata
99
- end
123
+ ### Errors
124
+
125
+ #### `have_no_errors`
126
+
127
+ Passes when the subject's `errors` collection is empty. Accepts a `CMDx::Result`, `CMDx::Task` instance, or `CMDx::Errors`.
128
+
129
+ ```ruby
130
+ expect(result).to have_no_errors
100
131
  ```
101
132
 
102
- ### have_matching_metadata
133
+ #### `have_errors_on`
103
134
 
104
- Asserts that a CMDx task result contains specific metadata.
135
+ Asserts at least one error is present under `key`. Optional positional `messages` further constrain the matcher — all must be present.
105
136
 
106
137
  ```ruby
107
- it "returns matching metadata" do
108
- result = SomeTask.execute
138
+ expect(result).to have_errors_on(:email)
139
+ expect(result).to have_errors_on(:email, "is required")
140
+ expect(task).to have_errors_on(:email, "is required", "is invalid")
141
+ ```
109
142
 
110
- expect(result).to have_matching_metadata(status_code: 500)
111
- end
143
+ ### Execution metrics
144
+
145
+ #### `have_been_retried`
146
+
147
+ Passes when the result was retried at least once. Pass an integer to require an exact retry count.
148
+
149
+ ```ruby
150
+ expect(result).to have_been_retried
151
+ expect(result).to have_been_retried(3)
112
152
  ```
113
153
 
114
- ### have_empty_context
154
+ #### `have_been_rolled_back`
115
155
 
116
- Asserts that a CMDx task result has no context data.
156
+ Passes when a failing task ran its rollback hook.
117
157
 
118
158
  ```ruby
119
- it "returns empty context" do
120
- result = SomeTask.execute
159
+ expect(result).to have_been_rolled_back
160
+ ```
121
161
 
122
- expect(result).to have_empty_context
123
- end
162
+ #### `have_duration`
163
+
164
+ Asserts the result's duration (in milliseconds) falls within the supplied bounds. At least one of `:less_than` or `:greater_than` is required.
165
+
166
+ ```ruby
167
+ expect(result).to have_duration(less_than: 100)
168
+ expect(result).to have_duration(greater_than: 0.1, less_than: 50)
124
169
  ```
125
170
 
126
- ### have_matching_context
171
+ ### Faults
172
+
173
+ #### `raise_cmdx_fault`
127
174
 
128
- Asserts that a CMDx task result contains specific context data.
175
+ Block matcher that asserts a `CMDx::Fault` is raised. Optionally constrain by originating task class, reason, or underlying cause.
129
176
 
130
177
  ```ruby
131
- it "returns matching context" do
132
- result = SomeTask.execute
178
+ expect { SomeTask.execute! }.to raise_cmdx_fault
179
+ expect { SomeTask.execute! }.to raise_cmdx_fault(SomeTask)
180
+ expect { SomeTask.execute! }
181
+ .to raise_cmdx_fault(SomeTask)
182
+ .with_reason(/invalid/)
183
+ .with_cause(MyError)
184
+ ```
133
185
 
134
- expect(result).to have_matching_context(stored_result: 123)
135
- end
186
+ `with_reason` accepts a string (equality) or a Regexp; `with_cause` accepts a class (matches via `is_a?`) or a value (matched with `values_match?`).
187
+
188
+ In application code you can also use `CMDx::Fault.for?`, `Fault.reason?`, and `Fault.matches?` in `rescue` clauses; this matcher is the RSpec block equivalent for `execute!`.
189
+
190
+ ### Chains
191
+
192
+ #### `have_chain_root`
193
+
194
+ Passes when the chain's root task class matches (or is a subclass of) `task_class`. Accepts a `CMDx::Chain` or `CMDx::Result`.
195
+
196
+ ```ruby
197
+ expect(result).to have_chain_root(MyWorkflow)
136
198
  ```
137
199
 
138
- ### be_deprecated
200
+ #### `have_chain_size`
139
201
 
140
- Asserts that a CMDx task result indicates the task is deprecated.
202
+ Passes when the chain's size matches `expected`. Accepts a `CMDx::Chain` or `CMDx::Result`.
141
203
 
142
204
  ```ruby
143
- it "returns deprecated" do
144
- expect(SomeTask).to be_deprecated
145
- end
205
+ expect(result).to have_chain_size(3)
206
+ ```
207
+
208
+ For failure propagation (`origin`, `caused_failure`, `threw_failure`), assert on the result directly — for example `expect(result.caused_failure.task).to eq(ValidateCard)` — as described in the [Result chain analysis](https://drexed.github.io/cmdx/outcomes/result/) docs.
209
+
210
+ ### Task class declarations
211
+
212
+ These matchers introspect a Task class's configuration. They accept either the class or an instance.
213
+
214
+ #### `be_deprecated`
215
+
216
+ Asserts a Task class is marked deprecated. Optionally constrain the deprecation behavior via a positional value or a chained convenience method.
217
+
218
+ ```ruby
219
+ expect(SomeTask).to be_deprecated
220
+ expect(SomeTask).to be_deprecated.with_warning # :warn
221
+ expect(SomeTask).to be_deprecated.with_logging # :log
222
+ expect(SomeTask).to be_deprecated.with_error # :error
223
+ expect(SomeTask).to be_deprecated.with_behavior(:custom)
224
+ ```
225
+
226
+ #### `have_input` / `have_output`
227
+
228
+ Asserts the class declares the given input/output. Keyword args are matched (partial) against the parameter's serialized `to_h`.
229
+
230
+ ```ruby
231
+ expect(SomeTask).to have_input(:user_id)
232
+ expect(SomeTask).to have_input(:user_id, required: true)
233
+ expect(SomeTask).to have_input(:user_id, options: hash_including(coerce: :integer))
234
+ expect(SomeTask).to have_output(:total, required: true)
235
+ ```
236
+
237
+ #### `have_callback`
238
+
239
+ Asserts a callback is registered for `event`. Optional `callable` further constrains the match — by `==` for symbols/lambdas, or by `is_a?` when given a class.
240
+
241
+ ```ruby
242
+ expect(SomeTask).to have_callback(:before_execution)
243
+ expect(SomeTask).to have_callback(:before_execution, :authenticate!)
244
+ expect(SomeTask).to have_callback(:on_failed, AlertOnFailure)
245
+ ```
246
+
247
+ #### `have_middleware`
248
+
249
+ Asserts the class registered `middleware`. Class arguments match by `is_a?` or `==`; other values match by `==`.
250
+
251
+ ```ruby
252
+ expect(SomeTask).to have_middleware(LoggingMiddleware)
253
+ ```
254
+
255
+ #### `have_retry_on`
256
+
257
+ Asserts the class is configured to retry on `exception`. Keyword args check `CMDx::Retry` configuration values (`:limit`, `:delay`, `:max_delay`, `:jitter`).
258
+
259
+ ```ruby
260
+ expect(SomeTask).to have_retry_on(Net::OpenTimeout)
261
+ expect(SomeTask).to have_retry_on(Net::OpenTimeout, limit: 5, jitter: :exponential)
262
+ ```
263
+
264
+ #### `have_tag`
265
+
266
+ Asserts the subject carries `tag`. Accepts a Task class (reads `settings.tags`) or a `CMDx::Result` (reads `result.tags`).
267
+
268
+ ```ruby
269
+ expect(SomeTask).to have_tag(:critical)
270
+ expect(result).to have_tag(:critical)
271
+ ```
272
+
273
+ ### Workflows
274
+
275
+ #### `have_pipeline_tasks`
276
+
277
+ Asserts a `CMDx::Workflow` class declares the given pipeline tasks. Order-sensitive by default; chain `.in_any_order` for set comparison.
278
+
279
+ ```ruby
280
+ expect(MyWorkflow).to have_pipeline_tasks(StepA, StepB, StepC)
281
+ expect(MyWorkflow).to have_pipeline_tasks(StepA, StepC, StepB).in_any_order
146
282
  ```
147
283
 
148
284
  ## Helpers
149
285
 
150
- ### Including Helper Modules
286
+ ### Including helper modules
151
287
 
152
- Include the helper modules in your RSpec configuration or example groups:
288
+ Mix into all example groups via RSpec config, or include in specific groups:
153
289
 
154
290
  ```ruby
155
291
  RSpec.configure do |config|
@@ -157,162 +293,194 @@ RSpec.configure do |config|
157
293
  end
158
294
  ```
159
295
 
160
- Or include them in specific example groups:
161
-
162
296
  ```ruby
163
297
  describe MyFeature do
164
298
  include CMDx::RSpec::Helpers
165
-
166
- # Your specs...
299
+ # ...
167
300
  end
168
301
  ```
169
302
 
170
- ### Stubs
171
-
172
- Helper methods for stubbing CMDx command execution.
303
+ ### Result builders
173
304
 
174
- #### Types
305
+ Build a real `CMDx::Result` without stubbing `execute` — useful when a collaborator must return a result, when seeding a chain, or anywhere you need a result object in isolation. Extra keyword args (besides the documented ones) are forwarded to `task.new` as context data, same as the stubs below.
175
306
 
176
307
  ```ruby
177
- it "stubs task executions by type" do
178
- # eg: SomeTask.execute
179
- stub_task_success(SomeTask)
180
- stub_task_skip(SomeTask)
181
- stub_task_fail(SomeTask)
182
-
183
- # eg: SomeTask.execute!
184
- stub_task_success!(SomeTask)
185
- stub_task_skip!(SomeTask)
186
- stub_task_fail!(SomeTask)
308
+ result = build_successful_result(snapshot:)
309
+ allow(FetchSnapshot).to receive(:execute).and_return(result)
187
310
 
188
- # Your specs...
189
- end
311
+ build_skipped_result(reason: "not applicable")
312
+ build_failed_result(reason: "boom", cause: StandardError.new("kaput"))
313
+ build_echoed_result(upstream_failed_result)
190
314
 
191
- it "stubs task with arguments" do
192
- # eg: SomeTask.execute(some: "value")
193
- stub_task_success(SomeTask, some: "value")
315
+ build_result(:success, snapshot:, metadata: { id: 1 })
316
+ build_result(:failed, reason: "nope", strict: true, task: SomeTask)
317
+ build_result(:echo, upstream_result: failed_result)
318
+ ```
194
319
 
195
- # eg: SomeTask.execute!(some: "value")
196
- stub_task_skip!(SomeTask, some: "value")
320
+ Options:
197
321
 
198
- # Your specs...
199
- end
322
+ ```ruby
323
+ build_successful_result(task: SomeTask, chain: existing_chain, context: CMDx::Context.new(foo: 1))
324
+ build_result(:success, deprecated: true, retries: 2, rolled_back: true)
200
325
  ```
201
326
 
202
- #### Options
327
+ ### Stubs
328
+
329
+ Each stub builds a frozen `CMDx::Result` carrying the requested signal and wires it into a fresh `CMDx::Chain`, so callers see realistic execution shape without invoking the task's `work`. Any extra keyword args (besides the documented ones) are forwarded to `command.new` as context overrides.
330
+
331
+ #### Result-type stubs
203
332
 
204
333
  ```ruby
205
- it "stubs task with metadata" do
206
- stub_task_success(SomeTask, metadata: { some: "value" })
334
+ # Non-bang variants stub `SomeTask.execute`
335
+ stub_task_success(SomeTask) # alias: stub_task
336
+ stub_task_skip(SomeTask)
337
+ stub_task_fail(SomeTask)
338
+
339
+ # Bang variants stub `SomeTask.execute!`
340
+ stub_task_success!(SomeTask) # alias: stub_task! — returns result
341
+ stub_task_skip!(SomeTask) # returns result (skips do not raise under execute!)
342
+ stub_task_fail!(SomeTask) # raises CMDx::Fault, or re-raises cause when set
343
+
344
+ # Stub a specific argument signature
345
+ stub_task_success(SomeTask, some: "value") # SomeTask.execute(some: "value")
346
+ stub_task_skip!(SomeTask, some: "value") # SomeTask.execute!(some: "value")
347
+ ```
207
348
 
208
- # Your specs...
209
- end
349
+ Common options:
210
350
 
211
- it "stubs task with a custom reason" do
212
- stub_task_skip!(SomeTask, reason: "Skipping for a custom reason")
351
+ ```ruby
352
+ stub_task_success(SomeTask, metadata: { id: 1 })
353
+ stub_task_skip!(SomeTask, reason: "out of stock")
354
+ stub_task_fail!(SomeTask, cause: NoMethodError.new("boom"))
355
+ ```
213
356
 
214
- # Your specs...
215
- end
357
+ #### Specialized stubs
216
358
 
217
- it "stubs task with a custom cause" do
218
- stub_task_fail!(SomeTask, cause: NoMethodError.new("just blow it up"))
359
+ ```ruby
360
+ # Models the rescued StandardError -> failed signal path that Runtime
361
+ # takes when `work` raises something other than a Fault.
362
+ stub_task_error(SomeTask, Net::OpenTimeout, "boom")
219
363
 
220
- # Your specs...
221
- end
364
+ # Models the `throw!`-then-propagate path used by nested tasks/workflows.
365
+ # `upstream_result` must be a failed CMDx::Result.
366
+ stub_task_throw(SomeTask, upstream_result)
367
+
368
+ # Returns a successful Result flagged as `deprecated?` without triggering
369
+ # the real Deprecation action.
370
+ stub_task_deprecated(SomeTask)
222
371
  ```
223
372
 
224
- #### Workflows
373
+ #### Workflow stubs
225
374
 
226
- Yields each distinct task class from the workflow’s pipeline (first-seen order) so you can stub them in one place.
375
+ `stub_workflow_tasks` yields each distinct Task class reachable from a Workflow's pipeline (first-seen order) so you can stub them in one place.
227
376
 
228
377
  ```ruby
229
- it "stubs every pipeline task for a workflow" do
230
- stub_workflow_tasks(MyWorkflow) do |t|
231
- if t == TaskB
232
- stub_task_success(t)
233
- elsif t == TaskC
234
- stub_task_skip(t)
235
- else
236
- stub_task_success(t)
237
- end
378
+ stub_workflow_tasks(MyWorkflow) do |task|
379
+ case task
380
+ when TaskB
381
+ stub_task_fail(task)
382
+ when TaskC
383
+ stub_task_skip(task)
384
+ else
385
+ stub_task_success(task)
238
386
  end
387
+ end
239
388
 
240
- MyWorkflow.execute
389
+ MyWorkflow.execute
390
+ ```
241
391
 
242
- # Your specs...
243
- end
392
+ #### Unstubbing
393
+
394
+ Restores the original implementation. When `context` is supplied, only that argument signature is unstubbed.
395
+
396
+ ```ruby
397
+ unstub_task(SomeTask) # SomeTask.execute
398
+ unstub_task!(SomeTask) # SomeTask.execute!
399
+ unstub_task(SomeTask, some: "value") # SomeTask.execute(some: "value")
244
400
  ```
245
401
 
246
- #### Reset
402
+ ### Mocks
403
+
404
+ Message expectations on `execute` / `execute!`. When `context` is supplied, the expectation is constrained to that signature.
247
405
 
248
406
  ```ruby
249
- it "unstubs task executions by type" do
250
- # eg: SomeTask.execute
251
- unstub_task(SomeTask)
407
+ expect_task_execution(SomeTask)
408
+ expect_task_execution!(SomeTask)
409
+ expect_task_execution(SomeTask, some: "value")
410
+
411
+ expect_no_task_execution(SomeTask)
412
+ expect_no_task_execution!(SomeTask)
413
+ expect_no_task_execution(SomeTask, some: "value")
414
+
415
+ # after the fact; requires a spy
416
+ expected_task_execution(SomeTask)
417
+ expected_task_execution!(SomeTask)
418
+ expected_task_execution(SomeTask, some: "value")
419
+
420
+ expected_no_task_execution(SomeTask)
421
+ expected_no_task_execution!(SomeTask)
422
+ expected_no_task_execution(SomeTask, some: "value")
423
+ ```
252
424
 
253
- # eg: SomeTask.execute!
254
- unstub_task!(SomeTask)
425
+ ### RuboCop: `RSpec/NoExpectationExample`
255
426
 
256
- # Your specs...
257
- end
427
+ The [RSpec/NoExpectationExample](https://docs.rubocop.org/rubocop-rspec/latest/cops_rspec.html#rspecnoexpectationexample) cop flags examples that do not call an RSpec expectation (for example `expect`, `is_expected`, or `should`). That is intentional for examples whose only assertion is a message expectation registered by [`expect_task_execution`](#mocks), [`expect_no_task_execution`](#mocks), or the bang variants — those helpers call `expect(command).to receive(...)` internally, but the example body often contains no top-level `expect`.
258
428
 
259
- it "unstubs task with arguments" do
260
- # eg: SomeTask.execute(some: "value")
261
- unstub_task(SomeTask, some: "value")
429
+ RuboCop already treats method names matching `^expect_` and `^assert_` as expectations via the cop’s default `AllowedPatterns`. Add `^expected_` if you use the post-action [`expected_*`](#mocks) helpers (`expected_task_execution`, `expected_no_task_execution`, and their `!` variants), which assert with `have_received` instead of `receive`.
262
430
 
263
- # eg: SomeTask.execute!(some: "value")
264
- unstub_task!(SomeTask, some: "value")
431
+ Merge the following into `.rubocop.yml` (adjust if your file already configures this cop):
265
432
 
266
- # Your specs...
267
- end
433
+ ```yaml
434
+ RSpec/NoExpectationExample:
435
+ AllowedPatterns:
436
+ - "^expect_"
437
+ - "^expected_"
438
+ - "^assert_"
268
439
  ```
269
440
 
270
- ### Mocks
441
+ Examples that only stub or exercise behavior with an explicit matcher — for example `stub_task_success` followed by `expect(result).to be_successful` — do not need an allowed pattern. Workflow setup with `stub_workflow_tasks` is the same when you assert on outcomes afterward.
442
+
443
+ If you define your own expectation helpers, either name them with an `expect_` / `assert_` prefix or register them under `RSpec/Language/Expectations` in `.rubocop.yml` as described in the cop documentation.
271
444
 
272
- Helper methods for setting expectations on CMDx command execution.
445
+ ### Diagnostics
273
446
 
274
- #### Types
447
+ #### `capture_cmdx_logs`
448
+
449
+ Captures lines written to a temporary `CMDx.configuration.logger` for the duration of the block. The previous logger is restored on exit.
275
450
 
276
451
  ```ruby
277
- it "mocks task executions by type" do
278
- # eg: SomeTask.execute
279
- expect_task_execution(SomeTask)
280
- expect_no_task_execution(SomeTask)
452
+ logs = capture_cmdx_logs { MyCommand.execute }
453
+ expect(logs.join).to include("status=success")
454
+ ```
281
455
 
282
- # eg: SomeTask.execute!
283
- expect_task_execution!(BangCommand)
284
- expect_no_task_execution!(SomeTask)
456
+ #### `subscribe_telemetry`
285
457
 
286
- # Your specs...
287
- end
458
+ Subscribes to telemetry events on a Task's telemetry registry for the duration of the block and returns every emitted event in order. Tasks subclassing `command` also fire (the registry is shared by reference until `dup`). Defaults to all events in `CMDx::Telemetry::EVENTS`.
288
459
 
289
- it "mocks task with arguments" do
290
- # eg: SomeTask.execute(some: "value")
291
- expect_task_execution(SomeTask, some: "value")
292
- expect_no_task_execution(SomeTask, some: "value")
460
+ ```ruby
461
+ events = subscribe_telemetry(MyCommand, :task_executed) { MyCommand.execute }
462
+ expect(events.map(&:name)).to eq([:task_executed])
463
+ ```
293
464
 
294
- # eg: SomeTask.execute!(some: "value")
295
- expect_task_execution!(SomeTask, some: "value")
296
- expect_no_task_execution!(SomeTask, some: "value")
465
+ #### `with_cmdx_chain`
297
466
 
298
- # Your specs...
299
- end
467
+ Captures the `CMDx::Chain` produced by the first root execution of `command` within the block. Returns `nil` if `command` didn't run as a root.
468
+
469
+ ```ruby
470
+ chain = with_cmdx_chain(MyWorkflow) { MyWorkflow.execute }
471
+ expect(chain.size).to be > 1
300
472
  ```
301
473
 
302
474
  ## Development
303
475
 
304
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
476
+ Run `bin/setup` to install dependencies, then `rake spec` to run the tests. Use `bin/console` for an interactive prompt.
305
477
 
306
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
478
+ Release flow: bump `lib/cmdx/rspec/version.rb`, then `bundle exec rake release` to tag, push, and publish to [rubygems.org](https://rubygems.org).
307
479
 
308
480
  ## Contributing
309
481
 
310
- Bug reports and pull requests are welcome on GitHub at https://github.com/drexed/cmdx-rspec. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/drexed/cmdx-rspec/blob/master/CODE_OF_CONDUCT.md).
482
+ Bug reports and pull requests are welcome at <https://github.com/drexed/cmdx-rspec>. Contributors are expected to follow the [code of conduct](https://github.com/drexed/cmdx-rspec/blob/master/CODE_OF_CONDUCT.md).
311
483
 
312
484
  ## License
313
485
 
314
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
315
-
316
- ## Code of Conduct
317
-
318
- Everyone interacting in the Cmdx::Rspec project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/drexed/cmdx-rspec/blob/master/CODE_OF_CONDUCT.md).
486
+ Released under the [MIT License](https://opensource.org/licenses/MIT).