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.
- checksums.yaml +4 -4
- data/.DS_Store +0 -0
- data/CHANGELOG.md +25 -0
- data/README.md +337 -169
- data/lib/cmdx/rspec/helpers.rb +453 -291
- data/lib/cmdx/rspec/matchers/be_complete.rb +20 -0
- data/lib/cmdx/rspec/matchers/be_deprecated.rb +14 -52
- data/lib/cmdx/rspec/matchers/be_interrupted.rb +20 -0
- data/lib/cmdx/rspec/matchers/be_ko.rb +19 -0
- data/lib/cmdx/rspec/matchers/be_ok.rb +19 -0
- data/lib/cmdx/rspec/matchers/be_successful.rb +8 -20
- data/lib/cmdx/rspec/matchers/have_been_retried.rb +41 -0
- data/lib/cmdx/rspec/matchers/have_been_rolled_back.rb +23 -0
- data/lib/cmdx/rspec/matchers/have_callback.rb +46 -0
- data/lib/cmdx/rspec/matchers/have_chain_root.rb +30 -0
- data/lib/cmdx/rspec/matchers/have_chain_size.rb +29 -0
- data/lib/cmdx/rspec/matchers/have_duration.rb +30 -0
- data/lib/cmdx/rspec/matchers/have_empty_context.rb +4 -16
- data/lib/cmdx/rspec/matchers/have_empty_metadata.rb +3 -9
- data/lib/cmdx/rspec/matchers/have_errors_on.rb +50 -0
- data/lib/cmdx/rspec/matchers/have_failed.rb +7 -24
- data/lib/cmdx/rspec/matchers/have_input.rb +41 -0
- data/lib/cmdx/rspec/matchers/have_matching_context.rb +5 -20
- data/lib/cmdx/rspec/matchers/have_matching_metadata.rb +5 -17
- data/lib/cmdx/rspec/matchers/have_middleware.rb +29 -0
- data/lib/cmdx/rspec/matchers/have_no_errors.rb +30 -0
- data/lib/cmdx/rspec/matchers/have_output.rb +46 -0
- data/lib/cmdx/rspec/matchers/have_pipeline_tasks.rb +27 -0
- data/lib/cmdx/rspec/matchers/have_retry_on.rb +36 -0
- data/lib/cmdx/rspec/matchers/have_skipped.rb +7 -24
- data/lib/cmdx/rspec/matchers/have_tag.rb +30 -0
- data/lib/cmdx/rspec/matchers/raise_cmdx_fault.rb +74 -0
- data/lib/cmdx/rspec/version.rb +2 -1
- data/lib/cmdx/rspec.rb +22 -3
- metadata +25 -5
data/lib/cmdx/rspec/helpers.rb
CHANGED
|
@@ -2,373 +2,442 @@
|
|
|
2
2
|
|
|
3
3
|
module CMDx
|
|
4
4
|
module RSpec
|
|
5
|
-
#
|
|
5
|
+
# RSpec helpers for stubbing and asserting Task execution. Each helper
|
|
6
|
+
# builds a frozen {CMDx::Result} carrying the requested {CMDx::Signal}
|
|
7
|
+
# and wires it into a fresh {CMDx::Chain} so callers see realistic
|
|
8
|
+
# execution output without invoking the Task's `work`.
|
|
9
|
+
#
|
|
10
|
+
# Mix into example groups via `config.include CMDx::RSpec::Helpers`.
|
|
6
11
|
module Helpers
|
|
7
12
|
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# @
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
13
|
+
# Anonymous task used by result builders when no +task+ is supplied.
|
|
14
|
+
STUB_TASK = Class.new(CMDx::Task) do
|
|
15
|
+
def self.name
|
|
16
|
+
"CMDx::RSpec::StubTask"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
private_constant :STUB_TASK
|
|
20
|
+
|
|
21
|
+
# Builds a frozen {CMDx::Result} without stubbing task execution.
|
|
22
|
+
# Use when a collaborator must return a result, when seeding a chain,
|
|
23
|
+
# or anywhere a real result is needed outside `stub_task_*`.
|
|
24
|
+
#
|
|
25
|
+
# @param status [Symbol, CMDx::Signal] `:success`, `:skipped`, `:failed`,
|
|
26
|
+
# `:echo`, or a pre-built signal
|
|
27
|
+
# @param reason [String, nil] human-readable outcome reason
|
|
28
|
+
# @param cause [Exception, nil] originating exception on failure
|
|
29
|
+
# @param metadata [Hash] signal metadata payload
|
|
30
|
+
# @param upstream_result [CMDx::Result] required for `:echo`; must be failed
|
|
31
|
+
# @param task [Class] task class for `result.task` (defaults to an internal stub)
|
|
32
|
+
# @param chain [CMDx::Chain, nil] chain to join (a fresh one is created when omitted)
|
|
33
|
+
# @param context [Hash, CMDx::Context, nil] task context; other keyword args
|
|
34
|
+
# are forwarded to `task.new` when +context+ is omitted
|
|
35
|
+
# @param root [Boolean] whether this result is the chain root
|
|
36
|
+
# @param strict [Boolean] whether the result was produced via `execute!`
|
|
37
|
+
# @param deprecated [Boolean] whether the result is flagged deprecated
|
|
38
|
+
# @param retries [Integer] retry count beyond the first attempt
|
|
39
|
+
# @param duration [Float, nil] execution duration in milliseconds
|
|
40
|
+
# @param rolled_back [Boolean] whether rollback ran for this result
|
|
41
|
+
# @param tid [String, nil] execution identifier (defaults to a uuid_v7)
|
|
42
|
+
# @return [CMDx::Result]
|
|
43
|
+
# @raise [ArgumentError] for unknown statuses or invalid echo input
|
|
44
|
+
# @example
|
|
45
|
+
# result = build_result(:success, snapshot:)
|
|
46
|
+
# allow(FetchSnapshot).to receive(:execute).and_return(result)
|
|
47
|
+
def build_result(status, **options)
|
|
48
|
+
signal = resolve_result_signal(
|
|
49
|
+
status,
|
|
50
|
+
reason: options[:reason],
|
|
51
|
+
cause: options[:cause],
|
|
52
|
+
metadata: options.fetch(:metadata, {}),
|
|
53
|
+
upstream_result: options[:upstream_result]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
assemble_result(signal, **options.except(:reason, :cause, :metadata, :upstream_result))
|
|
57
|
+
end
|
|
30
58
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
59
|
+
# @return [CMDx::Result] a successful result (`state: complete`, `status: success`)
|
|
60
|
+
# @see #build_result
|
|
61
|
+
def build_successful_result(metadata: {}, **context)
|
|
62
|
+
build_result(:success, metadata:, **context)
|
|
63
|
+
end
|
|
34
64
|
|
|
35
|
-
|
|
65
|
+
# @return [CMDx::Result] a skipped result (`state: interrupted`, `status: skipped`)
|
|
66
|
+
# @see #build_result
|
|
67
|
+
def build_skipped_result(reason: nil, cause: nil, metadata: {}, **context)
|
|
68
|
+
build_result(:skipped, reason:, cause:, metadata:, **context)
|
|
69
|
+
end
|
|
36
70
|
|
|
37
|
-
|
|
71
|
+
# @return [CMDx::Result] a failed result (`state: interrupted`, `status: failed`)
|
|
72
|
+
# @see #build_result
|
|
73
|
+
def build_failed_result(reason: nil, cause: nil, metadata: {}, **context)
|
|
74
|
+
build_result(:failed, reason:, cause:, metadata:, **context)
|
|
38
75
|
end
|
|
39
76
|
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
# @
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
#
|
|
48
|
-
# @example Stubbing successful execution with context
|
|
49
|
-
# stub_task_success!(MyCommand, user_id: 123, role: "admin")
|
|
50
|
-
#
|
|
51
|
-
# result = MyCommand.execute!(user_id: 123, role: "admin")
|
|
52
|
-
# expect(result).to be_successful
|
|
77
|
+
# @param upstream_result [CMDx::Result] the failed result being propagated
|
|
78
|
+
# @return [CMDx::Result] a failed result echoing +upstream_result+
|
|
79
|
+
# @see #build_result
|
|
80
|
+
def build_echoed_result(upstream_result, metadata: {}, **context)
|
|
81
|
+
build_result(:echo, upstream_result:, metadata:, **context)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Stubs `command.execute` to return a frozen successful Result.
|
|
53
85
|
#
|
|
54
|
-
# @
|
|
55
|
-
#
|
|
86
|
+
# @param command [Class] the Task class to stub
|
|
87
|
+
# @param metadata [Hash] payload exposed via `result.metadata`
|
|
88
|
+
# @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
|
|
89
|
+
# @return [CMDx::Result] the frozen Result installed on the stub
|
|
90
|
+
# @example
|
|
91
|
+
# stub_task_success(SomeTask, metadata: { id: 1 })
|
|
92
|
+
def stub_task_success(command, metadata: {}, **context)
|
|
93
|
+
build_stub(command, :execute, CMDx::Signal.success(nil, metadata:), context)
|
|
94
|
+
end
|
|
95
|
+
alias stub_task stub_task_success
|
|
96
|
+
|
|
97
|
+
# Stubs `command.execute!` (bang variant) to return a frozen successful Result.
|
|
56
98
|
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
99
|
+
# @param command [Class] the Task class to stub
|
|
100
|
+
# @param metadata [Hash] payload exposed via `result.metadata`
|
|
101
|
+
# @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
|
|
102
|
+
# @return [CMDx::Result] the frozen Result installed on the stub
|
|
59
103
|
def stub_task_success!(command, metadata: {}, **context)
|
|
60
|
-
|
|
61
|
-
result = task.result
|
|
62
|
-
|
|
63
|
-
result.metadata.merge!(metadata)
|
|
64
|
-
result.executing!
|
|
65
|
-
result.executed!
|
|
66
|
-
|
|
67
|
-
allow(command).to receive(:execute!).and_return(result)
|
|
68
|
-
|
|
69
|
-
result
|
|
104
|
+
build_stub(command, :execute!, CMDx::Signal.success(nil, metadata:), context, strict: true)
|
|
70
105
|
end
|
|
106
|
+
alias stub_task! stub_task_success!
|
|
71
107
|
|
|
72
|
-
#
|
|
108
|
+
# Stubs `command.execute` to return a frozen skipped Result.
|
|
73
109
|
#
|
|
74
|
-
# @param command [Class]
|
|
75
|
-
# @param reason [String, nil]
|
|
76
|
-
# @param cause [
|
|
77
|
-
# @param metadata [Hash]
|
|
78
|
-
# @param context [Hash
|
|
79
|
-
#
|
|
80
|
-
# @return [CMDx::Result] The skipped result object
|
|
81
|
-
#
|
|
82
|
-
# @example Stubbing skipped execution with context
|
|
83
|
-
# stub_task_skip(MyCommand, foo: "bar")
|
|
84
|
-
#
|
|
85
|
-
# result = MyCommand.execute(foo: "bar")
|
|
86
|
-
# expect(result).to have_skipped
|
|
87
|
-
#
|
|
88
|
-
# @example Stubbing skipped execution with reason
|
|
89
|
-
# stub_task_skip(MyCommand, reason: "Skipped for testing", foo: "bar")
|
|
90
|
-
#
|
|
91
|
-
# result = MyCommand.execute(foo: "bar")
|
|
92
|
-
# expect(result).to have_skipped(reason: "Skipped for testing")
|
|
110
|
+
# @param command [Class] the Task class to stub
|
|
111
|
+
# @param reason [String, nil] human-readable skip reason
|
|
112
|
+
# @param cause [Exception, nil] originating cause attached to the signal
|
|
113
|
+
# @param metadata [Hash] payload exposed via `result.metadata`
|
|
114
|
+
# @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
|
|
115
|
+
# @return [CMDx::Result] the frozen Result installed on the stub
|
|
93
116
|
def stub_task_skip(command, reason: nil, cause: nil, metadata: {}, **context)
|
|
94
|
-
|
|
95
|
-
result = task.result
|
|
96
|
-
cause ||= CMDx::SkipFault.new(result)
|
|
97
|
-
|
|
98
|
-
result.executing!
|
|
99
|
-
result.skip!(reason, halt: false, cause:, **metadata)
|
|
100
|
-
|
|
101
|
-
allow(command).to receive(:execute).and_return(result)
|
|
102
|
-
|
|
103
|
-
result
|
|
117
|
+
build_stub(command, :execute, CMDx::Signal.skipped(reason, metadata:, cause:), context)
|
|
104
118
|
end
|
|
105
119
|
|
|
106
|
-
#
|
|
120
|
+
# Stubs `command.execute!` (bang variant) to return a frozen skipped Result.
|
|
107
121
|
#
|
|
108
|
-
# @param command [Class]
|
|
109
|
-
# @param reason [String, nil]
|
|
110
|
-
# @param cause [
|
|
111
|
-
# @param metadata [Hash]
|
|
112
|
-
# @param context [Hash
|
|
113
|
-
#
|
|
114
|
-
# @return [CMDx::Result] The skipped result object
|
|
115
|
-
#
|
|
116
|
-
# @example Stubbing skipped execution with context
|
|
117
|
-
# stub_task_skip!(MyCommand, foo: "bar")
|
|
118
|
-
#
|
|
119
|
-
# result = MyCommand.execute!(foo: "bar")
|
|
120
|
-
# expect(result).to have_skipped
|
|
121
|
-
#
|
|
122
|
-
# @example Stubbing skipped execution with reason
|
|
123
|
-
# stub_task_skip!(MyCommand, reason: "Skipped for testing", foo: "bar")
|
|
124
|
-
#
|
|
125
|
-
# result = MyCommand.execute!(foo: "bar")
|
|
126
|
-
# expect(result).to have_skipped(reason: "Skipped for testing")
|
|
122
|
+
# @param command [Class] the Task class to stub
|
|
123
|
+
# @param reason [String, nil] human-readable skip reason
|
|
124
|
+
# @param cause [Exception, nil] originating cause attached to the signal
|
|
125
|
+
# @param metadata [Hash] payload exposed via `result.metadata`
|
|
126
|
+
# @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
|
|
127
|
+
# @return [CMDx::Result] the frozen Result installed on the stub
|
|
127
128
|
def stub_task_skip!(command, reason: nil, cause: nil, metadata: {}, **context)
|
|
128
|
-
|
|
129
|
-
result = task.result
|
|
130
|
-
cause ||= CMDx::SkipFault.new(result)
|
|
131
|
-
|
|
132
|
-
result.executing!
|
|
133
|
-
result.skip!(reason, halt: false, cause:, **metadata)
|
|
134
|
-
|
|
135
|
-
allow(command).to receive(:execute!).and_return(result)
|
|
136
|
-
|
|
137
|
-
result
|
|
129
|
+
build_stub(command, :execute!, CMDx::Signal.skipped(reason, metadata:, cause:), context, strict: true)
|
|
138
130
|
end
|
|
139
131
|
|
|
140
|
-
#
|
|
132
|
+
# Stubs `command.execute` to return a frozen failed Result.
|
|
141
133
|
#
|
|
142
|
-
# @param command [Class]
|
|
143
|
-
# @param reason [String, nil]
|
|
144
|
-
# @param cause [
|
|
145
|
-
# @param metadata [Hash]
|
|
146
|
-
# @param context [Hash
|
|
147
|
-
#
|
|
148
|
-
# @return [CMDx::Result] The failed result object
|
|
149
|
-
#
|
|
150
|
-
# @example Stubbing failed execution with context
|
|
151
|
-
# stub_task_fail(MyCommand, foo: "bar")
|
|
152
|
-
#
|
|
153
|
-
# result = MyCommand.execute(foo: "bar")
|
|
154
|
-
# expect(result).to have_failed
|
|
155
|
-
#
|
|
156
|
-
# @example Stubbing failed execution with reason
|
|
157
|
-
# stub_task_fail(MyCommand, reason: "Failed for testing", foo: "bar")
|
|
158
|
-
#
|
|
159
|
-
# result = MyCommand.execute(foo: "bar")
|
|
160
|
-
# expect(result).to have_failed(reason: "Failed for testing")
|
|
134
|
+
# @param command [Class] the Task class to stub
|
|
135
|
+
# @param reason [String, nil] human-readable failure reason
|
|
136
|
+
# @param cause [Exception, nil] originating cause attached to the signal
|
|
137
|
+
# @param metadata [Hash] payload exposed via `result.metadata`
|
|
138
|
+
# @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
|
|
139
|
+
# @return [CMDx::Result] the frozen Result installed on the stub
|
|
161
140
|
def stub_task_fail(command, reason: nil, cause: nil, metadata: {}, **context)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
cause ||= CMDx::FailFault.new(result)
|
|
141
|
+
build_stub(command, :execute, CMDx::Signal.failed(reason, metadata:, cause:), context)
|
|
142
|
+
end
|
|
165
143
|
|
|
166
|
-
|
|
167
|
-
|
|
144
|
+
# Stubs `command.execute!` so a call raises like real strict execution:
|
|
145
|
+
# {CMDx::Fault} for signal failures, or the original exception when
|
|
146
|
+
# +cause+ is set (mirrors {CMDx::Runtime#raise_signal!}).
|
|
147
|
+
#
|
|
148
|
+
# @param command [Class] the Task class to stub
|
|
149
|
+
# @param reason [String, nil] human-readable failure reason
|
|
150
|
+
# @param cause [Exception, nil] originating cause attached to the signal
|
|
151
|
+
# @param metadata [Hash] payload exposed via `result.metadata`
|
|
152
|
+
# @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
|
|
153
|
+
# @return [CMDx::Result] the frozen failed Result backing the stub (for inspection)
|
|
154
|
+
def stub_task_fail!(command, reason: nil, cause: nil, metadata: {}, **context)
|
|
155
|
+
build_stub(command, :execute!, CMDx::Signal.failed(reason, metadata:, cause:), context, strict: true)
|
|
156
|
+
end
|
|
168
157
|
|
|
169
|
-
|
|
158
|
+
# Stubs `command.execute` to return a frozen failed Result whose
|
|
159
|
+
# `cause` is an instance of +exception+. Models the "rescued
|
|
160
|
+
# StandardError -> failed signal" path that Runtime takes when a
|
|
161
|
+
# task's `work` raises something other than a Fault.
|
|
162
|
+
#
|
|
163
|
+
# @param command [Class] the Task class to stub
|
|
164
|
+
# @param exception [Class<StandardError>, StandardError] the cause
|
|
165
|
+
# @param message [String, nil] message used when constructing the
|
|
166
|
+
# exception (when +exception+ is a Class) and to derive the reason
|
|
167
|
+
# @param metadata [Hash]
|
|
168
|
+
# @param context [Hash{Symbol => Object}]
|
|
169
|
+
# @return [CMDx::Result] the frozen Result installed on the stub
|
|
170
|
+
# @example
|
|
171
|
+
# stub_task_error(MyCommand, Net::OpenTimeout, "boom")
|
|
172
|
+
def stub_task_error(command, exception, message = nil, metadata: {}, **context)
|
|
173
|
+
ex = exception.is_a?(Class) ? exception.new(message || "stubbed") : exception
|
|
174
|
+
reason = "[#{ex.class}] #{ex.message}"
|
|
175
|
+
build_stub(command, :execute, CMDx::Signal.failed(reason, metadata:, cause: ex), context)
|
|
176
|
+
end
|
|
170
177
|
|
|
171
|
-
|
|
178
|
+
# Stubs `command.execute` to return a frozen failed Result that
|
|
179
|
+
# echoes +upstream_result+. Models the `throw!`-then-propagate path
|
|
180
|
+
# used by nested tasks/workflows.
|
|
181
|
+
#
|
|
182
|
+
# @param command [Class] the Task class to stub
|
|
183
|
+
# @param upstream_result [CMDx::Result] the originating failure
|
|
184
|
+
# @param metadata [Hash]
|
|
185
|
+
# @param context [Hash{Symbol => Object}]
|
|
186
|
+
# @return [CMDx::Result] the frozen Result installed on the stub
|
|
187
|
+
def stub_task_throw(command, upstream_result, metadata: {}, **context)
|
|
188
|
+
validate_echo_upstream!(upstream_result)
|
|
189
|
+
build_stub(command, :execute, CMDx::Signal.echoed(upstream_result, metadata:), context)
|
|
172
190
|
end
|
|
173
191
|
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
# @param reason [String, nil] Optional reason for failure
|
|
178
|
-
# @param cause [CMDx::Fault, nil] Optional cause for failure
|
|
179
|
-
# @param metadata [Hash] Optional metadata to pass to the result
|
|
180
|
-
# @param context [Hash] Optional keyword arguments to pass to the command
|
|
181
|
-
#
|
|
182
|
-
# @return [CMDx::Result] The failed result object
|
|
192
|
+
# Stubs `command.execute` to return a successful Result flagged as
|
|
193
|
+
# `deprecated?`. Useful when asserting deprecation surfaces without
|
|
194
|
+
# triggering the real `Deprecation` action.
|
|
183
195
|
#
|
|
184
|
-
# @
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
# stub_task_fail!(MyCommand, reason: "Failed for testing", foo: "bar")
|
|
192
|
-
#
|
|
193
|
-
# result = MyCommand.execute!(foo: "bar")
|
|
194
|
-
# expect(result).to have_failed(reason: "Failed for testing")
|
|
195
|
-
def stub_task_fail!(command, reason: nil, cause: nil, metadata: {}, **context)
|
|
196
|
-
task = command.new(context)
|
|
197
|
-
result = task.result
|
|
198
|
-
cause ||= CMDx::FailFault.new(result)
|
|
196
|
+
# @param command [Class] the Task class to stub
|
|
197
|
+
# @param metadata [Hash]
|
|
198
|
+
# @param context [Hash{Symbol => Object}]
|
|
199
|
+
# @return [CMDx::Result]
|
|
200
|
+
def stub_task_deprecated(command, metadata: {}, **context)
|
|
201
|
+
build_stub(command, :execute, CMDx::Signal.success(nil, metadata:), context, deprecated: true)
|
|
202
|
+
end
|
|
199
203
|
|
|
200
|
-
|
|
201
|
-
|
|
204
|
+
# Captures lines written to a temporary CMDx logger for the duration
|
|
205
|
+
# of the block. Restores the previous logger on exit.
|
|
206
|
+
#
|
|
207
|
+
# @yield runs the block with `CMDx.configuration.logger` swapped
|
|
208
|
+
# @return [Array<String>] captured log lines
|
|
209
|
+
# @example
|
|
210
|
+
# logs = capture_cmdx_logs { MyCommand.execute }
|
|
211
|
+
# expect(logs.join).to include("status=success")
|
|
212
|
+
def capture_cmdx_logs(&)
|
|
213
|
+
raise ArgumentError, "block required" unless block_given?
|
|
214
|
+
|
|
215
|
+
io = StringIO.new
|
|
216
|
+
previous = CMDx.configuration.logger
|
|
217
|
+
formatter = previous&.formatter || CMDx::LogFormatters::Line.new
|
|
218
|
+
|
|
219
|
+
CMDx.configuration.logger = Logger.new(io, formatter:)
|
|
220
|
+
yield
|
|
221
|
+
io.string.lines.map(&:chomp)
|
|
222
|
+
ensure
|
|
223
|
+
CMDx.configuration.logger = previous if previous
|
|
224
|
+
end
|
|
202
225
|
|
|
203
|
-
|
|
226
|
+
# Subscribes to telemetry events on +command+'s telemetry registry
|
|
227
|
+
# for the duration of the block. Captures every emitted event.
|
|
228
|
+
# Tasks subclassing +command+ also fire (telemetry is cloned at
|
|
229
|
+
# class definition; the registry array is shared by reference until
|
|
230
|
+
# +dup+).
|
|
231
|
+
#
|
|
232
|
+
# @param command [Class] the Task class whose telemetry to listen on
|
|
233
|
+
# @param events [Array<Symbol>] event names to subscribe to;
|
|
234
|
+
# defaults to all of {CMDx::Telemetry::EVENTS}
|
|
235
|
+
# @yield runs the block with subscribers attached
|
|
236
|
+
# @return [Array<CMDx::Telemetry::Event>] captured events in emission order
|
|
237
|
+
# @example
|
|
238
|
+
# events = subscribe_telemetry(MyCommand, :task_executed) { MyCommand.execute }
|
|
239
|
+
# expect(events.map(&:name)).to eq([:task_executed])
|
|
240
|
+
def subscribe_telemetry(command, *events, &)
|
|
241
|
+
raise ArgumentError, "block required" unless block_given?
|
|
242
|
+
|
|
243
|
+
events = CMDx::Telemetry::EVENTS if events.empty?
|
|
244
|
+
captured = []
|
|
245
|
+
telemetry = command.telemetry
|
|
246
|
+
listener = ->(event) { captured << event }
|
|
247
|
+
|
|
248
|
+
begin
|
|
249
|
+
events.each { |e| telemetry.subscribe(e, listener) }
|
|
250
|
+
yield
|
|
251
|
+
ensure
|
|
252
|
+
events.each { |e| telemetry.unsubscribe(e, listener) }
|
|
253
|
+
end
|
|
204
254
|
|
|
205
|
-
|
|
255
|
+
captured
|
|
206
256
|
end
|
|
207
257
|
|
|
208
|
-
#
|
|
258
|
+
# Captures the {CMDx::Chain} produced by +command+'s execution
|
|
259
|
+
# within the block. Subscribes to +command+'s `:task_executed`
|
|
260
|
+
# telemetry to grab the chain reference before Runtime teardown
|
|
261
|
+
# clears it.
|
|
209
262
|
#
|
|
210
|
-
# @param command [Class]
|
|
211
|
-
# @
|
|
263
|
+
# @param command [Class] the Task class whose chain to capture
|
|
264
|
+
# @yield the block to execute
|
|
265
|
+
# @return [CMDx::Chain, nil] the chain (frozen by Runtime), or nil
|
|
266
|
+
# when +command+ didn't run as a root during the block
|
|
267
|
+
# @example
|
|
268
|
+
# chain = with_cmdx_chain(MyWorkflow) { MyWorkflow.execute }
|
|
269
|
+
# expect(chain.size).to be > 1
|
|
270
|
+
def with_cmdx_chain(command)
|
|
271
|
+
raise ArgumentError, "block required" unless block_given?
|
|
272
|
+
|
|
273
|
+
captured = nil
|
|
274
|
+
telemetry = command.telemetry
|
|
275
|
+
listener = ->(event) { captured ||= event.payload[:result].chain if event.root }
|
|
276
|
+
|
|
277
|
+
begin
|
|
278
|
+
telemetry.subscribe(:task_executed, listener)
|
|
279
|
+
yield
|
|
280
|
+
ensure
|
|
281
|
+
telemetry.unsubscribe(:task_executed, listener)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
captured
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Restores `command.execute` to its original implementation.
|
|
288
|
+
# When `context` is supplied, only the matching argument signature is unstubbed.
|
|
212
289
|
#
|
|
290
|
+
# @param command [Class] the Task class to unstub
|
|
291
|
+
# @param context [Hash{Symbol => Object}] argument signature whose stub to release
|
|
213
292
|
# @return [void]
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
# unstub_task(MyCommand, foo: "bar")
|
|
217
|
-
#
|
|
218
|
-
# MyCommand.execute(foo: "bar")
|
|
219
|
-
#
|
|
220
|
-
# @example Unstubbing execute without context
|
|
221
|
-
# unstub_task(MyCommand)
|
|
222
|
-
#
|
|
223
|
-
# MyCommand.execute
|
|
224
|
-
def unstub_task(command, **context)
|
|
225
|
-
if context.empty?
|
|
293
|
+
def unstub_task(command, *args, **context)
|
|
294
|
+
if args.empty? && context.empty?
|
|
226
295
|
allow(command).to receive(:execute).and_call_original
|
|
227
296
|
else
|
|
228
|
-
allow(command).to receive(:execute).with(**context).and_call_original
|
|
297
|
+
allow(command).to receive(:execute).with(*args, **context).and_call_original
|
|
229
298
|
end
|
|
230
299
|
end
|
|
231
300
|
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
# @param command [Class] The command class to unstub execution on
|
|
235
|
-
# @param context [Hash] Optional keyword arguments to match against
|
|
301
|
+
# Restores `command.execute!` to its original implementation.
|
|
302
|
+
# When `context` is supplied, only the matching argument signature is unstubbed.
|
|
236
303
|
#
|
|
304
|
+
# @param command [Class] the Task class to unstub
|
|
305
|
+
# @param context [Hash{Symbol => Object}] argument signature whose stub to release
|
|
237
306
|
# @return [void]
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
# unstub_task!(MyCommand, foo: "bar")
|
|
241
|
-
#
|
|
242
|
-
# MyCommand.execute!(foo: "bar")
|
|
243
|
-
#
|
|
244
|
-
# @example Unstubbing execute! without context
|
|
245
|
-
# unstub_task!(MyCommand)
|
|
246
|
-
#
|
|
247
|
-
# MyCommand.execute!
|
|
248
|
-
def unstub_task!(command, **context)
|
|
249
|
-
if context.empty?
|
|
307
|
+
def unstub_task!(command, *args, **context)
|
|
308
|
+
if args.empty? && context.empty?
|
|
250
309
|
allow(command).to receive(:execute!).and_call_original
|
|
251
310
|
else
|
|
252
|
-
allow(command).to receive(:execute!).with(**context).and_call_original
|
|
311
|
+
allow(command).to receive(:execute!).with(*args, **context).and_call_original
|
|
253
312
|
end
|
|
254
313
|
end
|
|
255
314
|
|
|
256
|
-
# Sets
|
|
257
|
-
#
|
|
258
|
-
# @param command [Class] The command class to expect execution on
|
|
259
|
-
# @param context [Hash] Optional keyword arguments to match against
|
|
260
|
-
#
|
|
261
|
-
# @return [RSpec::Mocks::MessageExpectation] The RSpec expectation object
|
|
262
|
-
#
|
|
263
|
-
# @example Expecting execution with context
|
|
264
|
-
# expect_task_execution(MyCommand, user_id: 123, role: "admin")
|
|
315
|
+
# Sets a positive message expectation that `command.execute` is invoked.
|
|
316
|
+
# When `context` is supplied, the expectation is constrained to that signature.
|
|
265
317
|
#
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
# @
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
# MyCommand.execute
|
|
272
|
-
def expect_task_execution(command, **context)
|
|
273
|
-
if context.empty?
|
|
318
|
+
# @param command [Class] the Task class to expect
|
|
319
|
+
# @param context [Hash{Symbol => Object}] argument signature to match
|
|
320
|
+
# @return [RSpec::Mocks::MessageExpectation]
|
|
321
|
+
def expect_task_execution(command, *args, **context)
|
|
322
|
+
if args.empty? && context.empty?
|
|
274
323
|
expect(command).to receive(:execute)
|
|
275
324
|
else
|
|
276
|
-
expect(command).to receive(:execute).with(**context)
|
|
325
|
+
expect(command).to receive(:execute).with(*args, **context)
|
|
277
326
|
end
|
|
278
327
|
end
|
|
279
328
|
|
|
280
|
-
# Sets
|
|
281
|
-
#
|
|
282
|
-
# @param command [Class] The command class to expect execution on
|
|
283
|
-
# @param context [Hash] Optional keyword arguments to match against
|
|
284
|
-
#
|
|
285
|
-
# @return [RSpec::Mocks::MessageExpectation] The RSpec expectation object
|
|
329
|
+
# Sets a positive message expectation that `command.execute!` is invoked.
|
|
330
|
+
# When `context` is supplied, the expectation is constrained to that signature.
|
|
286
331
|
#
|
|
287
|
-
# @
|
|
288
|
-
#
|
|
289
|
-
#
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
# @example Expecting execution without context
|
|
293
|
-
# expect_task_execution!(MyCommand)
|
|
294
|
-
#
|
|
295
|
-
# MyCommand.execute!
|
|
296
|
-
def expect_task_execution!(command, **context)
|
|
297
|
-
if context.empty?
|
|
332
|
+
# @param command [Class] the Task class to expect
|
|
333
|
+
# @param context [Hash{Symbol => Object}] argument signature to match
|
|
334
|
+
# @return [RSpec::Mocks::MessageExpectation]
|
|
335
|
+
def expect_task_execution!(command, *args, **context)
|
|
336
|
+
if args.empty? && context.empty?
|
|
298
337
|
expect(command).to receive(:execute!)
|
|
299
338
|
else
|
|
300
|
-
expect(command).to receive(:execute!).with(**context)
|
|
339
|
+
expect(command).to receive(:execute!).with(*args, **context)
|
|
301
340
|
end
|
|
302
341
|
end
|
|
303
342
|
|
|
304
|
-
# Sets
|
|
305
|
-
#
|
|
306
|
-
# @param command [Class] The command class to expect execution on
|
|
307
|
-
# @param context [Hash] Optional keyword arguments to match against
|
|
308
|
-
#
|
|
309
|
-
# @return [RSpec::Mocks::MessageExpectation] The RSpec expectation object
|
|
343
|
+
# Sets a negative message expectation that `command.execute` is not invoked.
|
|
344
|
+
# When `context` is supplied, only the matching signature is forbidden.
|
|
310
345
|
#
|
|
311
|
-
# @
|
|
312
|
-
#
|
|
313
|
-
#
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
# @example Expecting no execution with empty context
|
|
317
|
-
# expect_no_task_execution(MyCommand)
|
|
318
|
-
#
|
|
319
|
-
# MyCommand.execute
|
|
320
|
-
def expect_no_task_execution(command, **context)
|
|
321
|
-
if context.empty?
|
|
346
|
+
# @param command [Class] the Task class to guard
|
|
347
|
+
# @param context [Hash{Symbol => Object}] argument signature to forbid
|
|
348
|
+
# @return [RSpec::Mocks::MessageExpectation]
|
|
349
|
+
def expect_no_task_execution(command, *args, **context)
|
|
350
|
+
if args.empty? && context.empty?
|
|
322
351
|
expect(command).not_to receive(:execute)
|
|
323
352
|
else
|
|
324
|
-
expect(command).not_to receive(:execute).with(**context)
|
|
353
|
+
expect(command).not_to receive(:execute).with(*args, **context)
|
|
325
354
|
end
|
|
326
355
|
end
|
|
327
356
|
|
|
328
|
-
# Sets
|
|
329
|
-
#
|
|
330
|
-
# @param command [Class] The command class to expect execution on
|
|
331
|
-
# @param context [Hash] Optional keyword arguments to match against
|
|
357
|
+
# Sets a negative message expectation that `command.execute!` is not invoked.
|
|
358
|
+
# When `context` is supplied, only the matching signature is forbidden.
|
|
332
359
|
#
|
|
333
|
-
# @
|
|
334
|
-
#
|
|
335
|
-
# @
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
360
|
+
# @param command [Class] the Task class to guard
|
|
361
|
+
# @param context [Hash{Symbol => Object}] argument signature to forbid
|
|
362
|
+
# @return [RSpec::Mocks::MessageExpectation]
|
|
363
|
+
def expect_no_task_execution!(command, *args, **context)
|
|
364
|
+
if args.empty? && context.empty?
|
|
365
|
+
expect(command).not_to receive(:execute!)
|
|
366
|
+
else
|
|
367
|
+
expect(command).not_to receive(:execute!).with(*args, **context)
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Asserts that `command.execute` was invoked (after the fact). Requires a
|
|
372
|
+
# prior spy, e.g. `allow(command).to receive(:execute).and_call_original`
|
|
373
|
+
# or a `stub_task_*` helper. When `context` is supplied, the assertion is
|
|
374
|
+
# constrained to that signature.
|
|
375
|
+
#
|
|
376
|
+
# @param command [Class] the Task class to assert against
|
|
377
|
+
# @param context [Hash{Symbol => Object}] argument signature to match
|
|
378
|
+
# @return [RSpec::Mocks::Matchers::HaveReceived]
|
|
379
|
+
def expected_task_execution(command, *args, **context)
|
|
380
|
+
if args.empty? && context.empty?
|
|
381
|
+
expect(command).to have_received(:execute)
|
|
382
|
+
else
|
|
383
|
+
expect(command).to have_received(:execute).with(*args, **context)
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Asserts that `command.execute!` was invoked (after the fact). Requires a
|
|
388
|
+
# prior spy on `execute!`. When `context` is supplied, the assertion is
|
|
389
|
+
# constrained to that signature.
|
|
339
390
|
#
|
|
340
|
-
# @
|
|
341
|
-
#
|
|
391
|
+
# @param command [Class] the Task class to assert against
|
|
392
|
+
# @param context [Hash{Symbol => Object}] argument signature to match
|
|
393
|
+
# @return [RSpec::Mocks::Matchers::HaveReceived]
|
|
394
|
+
def expected_task_execution!(command, *args, **context)
|
|
395
|
+
if args.empty? && context.empty?
|
|
396
|
+
expect(command).to have_received(:execute!)
|
|
397
|
+
else
|
|
398
|
+
expect(command).to have_received(:execute!).with(*args, **context)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Asserts that `command.execute` was not invoked (after the fact). Requires
|
|
403
|
+
# a prior spy on `execute`. When `context` is supplied, only the matching
|
|
404
|
+
# signature is checked.
|
|
342
405
|
#
|
|
343
|
-
#
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
406
|
+
# @param command [Class] the Task class to assert against
|
|
407
|
+
# @param context [Hash{Symbol => Object}] argument signature to forbid
|
|
408
|
+
# @return [RSpec::Mocks::Matchers::HaveReceived]
|
|
409
|
+
def expected_no_task_execution(command, *args, **context)
|
|
410
|
+
if args.empty? && context.empty?
|
|
411
|
+
expect(command).not_to have_received(:execute)
|
|
347
412
|
else
|
|
348
|
-
expect(command).not_to
|
|
413
|
+
expect(command).not_to have_received(:execute).with(*args, **context)
|
|
349
414
|
end
|
|
350
415
|
end
|
|
351
416
|
|
|
352
|
-
#
|
|
417
|
+
# Asserts that `command.execute!` was not invoked (after the fact). Requires
|
|
418
|
+
# a prior spy on `execute!`. When `context` is supplied, only the matching
|
|
419
|
+
# signature is checked.
|
|
353
420
|
#
|
|
354
|
-
# @param command [Class]
|
|
355
|
-
# @
|
|
356
|
-
# @return [
|
|
357
|
-
|
|
358
|
-
|
|
421
|
+
# @param command [Class] the Task class to assert against
|
|
422
|
+
# @param context [Hash{Symbol => Object}] argument signature to forbid
|
|
423
|
+
# @return [RSpec::Mocks::Matchers::HaveReceived]
|
|
424
|
+
def expected_no_task_execution!(command, *args, **context)
|
|
425
|
+
if args.empty? && context.empty?
|
|
426
|
+
expect(command).not_to have_received(:execute!)
|
|
427
|
+
else
|
|
428
|
+
expect(command).not_to have_received(:execute!).with(*args, **context)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Yields each distinct Task class reachable from a Workflow's pipeline,
|
|
433
|
+
# in first-seen order, so callers can stub them in a single block.
|
|
359
434
|
#
|
|
435
|
+
# @param command [Class] a Workflow class (must include `CMDx::Workflow`)
|
|
436
|
+
# @yieldparam task [Class] a Task class referenced by the workflow pipeline
|
|
437
|
+
# @return [void]
|
|
438
|
+
# @raise [ArgumentError] when no block is given or `command` is not a Workflow
|
|
360
439
|
# @example
|
|
361
|
-
# stub_workflow_tasks(MyWorkflow)
|
|
362
|
-
# if t == TaskB
|
|
363
|
-
# stub_task_success(t)
|
|
364
|
-
# elsif t == TaskC
|
|
365
|
-
# stub_task_skip(t)
|
|
366
|
-
# else
|
|
367
|
-
# stub_task_success(t)
|
|
368
|
-
# end
|
|
369
|
-
# end
|
|
370
|
-
#
|
|
371
|
-
# MyWorkflow.execute
|
|
440
|
+
# stub_workflow_tasks(MyWorkflow) { |t| stub_task_success(t) }
|
|
372
441
|
def stub_workflow_tasks(command, &)
|
|
373
442
|
if !block_given?
|
|
374
443
|
raise ArgumentError, "block required"
|
|
@@ -379,6 +448,99 @@ module CMDx
|
|
|
379
448
|
command.pipeline.flat_map(&:tasks).uniq.each(&)
|
|
380
449
|
end
|
|
381
450
|
|
|
451
|
+
private
|
|
452
|
+
|
|
453
|
+
# @api private
|
|
454
|
+
def resolve_result_signal(status, reason:, cause:, metadata:, upstream_result:)
|
|
455
|
+
case status
|
|
456
|
+
when CMDx::Signal
|
|
457
|
+
status
|
|
458
|
+
when :success, :successful
|
|
459
|
+
CMDx::Signal.success(reason, metadata:, cause:)
|
|
460
|
+
when :skip, :skipped
|
|
461
|
+
CMDx::Signal.skipped(reason, metadata:, cause:)
|
|
462
|
+
when :fail, :failed, :failure
|
|
463
|
+
CMDx::Signal.failed(reason, metadata:, cause:)
|
|
464
|
+
when :echo, :echoed
|
|
465
|
+
validate_echo_upstream!(upstream_result)
|
|
466
|
+
CMDx::Signal.echoed(upstream_result, metadata:, cause:)
|
|
467
|
+
else
|
|
468
|
+
raise ArgumentError, "unknown result status #{status.inspect}"
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# @api private
|
|
473
|
+
def validate_echo_upstream!(upstream_result)
|
|
474
|
+
return if upstream_result.is_a?(CMDx::Result) && upstream_result.failed?
|
|
475
|
+
|
|
476
|
+
raise ArgumentError, "upstream result must be a failed CMDx::Result"
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Constructs a frozen {CMDx::Result} from +signal+ and wires it into a
|
|
480
|
+
# {CMDx::Chain} so callers observe the same shape as a real execution.
|
|
481
|
+
#
|
|
482
|
+
# @api private
|
|
483
|
+
# @return [CMDx::Result]
|
|
484
|
+
LIFECYCLE_OPTIONS = %i[root strict deprecated retries duration rolled_back tid].freeze
|
|
485
|
+
private_constant :LIFECYCLE_OPTIONS
|
|
486
|
+
|
|
487
|
+
def assemble_result(signal, task: nil, chain: nil, context: nil, **options)
|
|
488
|
+
context_data = options.except(*LIFECYCLE_OPTIONS)
|
|
489
|
+
task_class = task || STUB_TASK
|
|
490
|
+
context_input = context || context_data
|
|
491
|
+
task_instance = task_class.new(context_input)
|
|
492
|
+
chain ||= CMDx::Chain.new
|
|
493
|
+
result = CMDx::Result.new(
|
|
494
|
+
chain,
|
|
495
|
+
task_instance,
|
|
496
|
+
signal,
|
|
497
|
+
root: options.fetch(:root, true),
|
|
498
|
+
strict: options.fetch(:strict, false),
|
|
499
|
+
deprecated: options.fetch(:deprecated, false),
|
|
500
|
+
retries: options.fetch(:retries, 0),
|
|
501
|
+
duration: options.fetch(:duration, nil),
|
|
502
|
+
rolled_back: options.fetch(:rolled_back, false),
|
|
503
|
+
tid: options.fetch(:tid, SecureRandom.uuid_v7)
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
chain.unshift(result)
|
|
507
|
+
|
|
508
|
+
result
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Constructs a frozen {CMDx::Result} from `signal` and stubs `command.method`
|
|
512
|
+
# to return it.
|
|
513
|
+
#
|
|
514
|
+
# @api private
|
|
515
|
+
# @param command [Class] the Task class being stubbed
|
|
516
|
+
# @param method [Symbol] the message to stub (`:execute` or `:execute!`)
|
|
517
|
+
# @param signal [CMDx::Signal] outcome payload to wrap
|
|
518
|
+
# @param context [Hash] context overrides for `command.new`
|
|
519
|
+
# @return [CMDx::Result] the frozen Result installed on the stub
|
|
520
|
+
def build_stub(command, method, signal, context, **options)
|
|
521
|
+
raise ArgumentError, "command must be a Task class" unless command <= CMDx::Task
|
|
522
|
+
|
|
523
|
+
result = assemble_result(signal, **options, task: command, context:)
|
|
524
|
+
|
|
525
|
+
if options.fetch(:strict, false) && result.failed?
|
|
526
|
+
allow(command).to receive(method) { raise_strict_failure!(result) }
|
|
527
|
+
else
|
|
528
|
+
allow(command).to receive(method).and_return(result)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
result
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# @api private
|
|
535
|
+
# @param result [CMDx::Result] a failed strict result
|
|
536
|
+
# @raise [CMDx::Fault, StandardError]
|
|
537
|
+
def raise_strict_failure!(result)
|
|
538
|
+
cause = result.cause
|
|
539
|
+
raise(cause) if cause && !cause.is_a?(CMDx::Fault)
|
|
540
|
+
|
|
541
|
+
raise(CMDx::Fault, result.caused_failure, cause:)
|
|
542
|
+
end
|
|
543
|
+
|
|
382
544
|
end
|
|
383
545
|
end
|
|
384
546
|
end
|