actionable-rb 1.9.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 +7 -0
- data/.claude-plugin/marketplace.json +15 -0
- data/.claude-plugin/plugin.json +9 -0
- data/.rspec +3 -0
- data/AGENTS.md +150 -0
- data/CHANGELOG.md +766 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +555 -0
- data/UPGRADING.md +957 -0
- data/USAGE.md +1291 -0
- data/docs/getting_started.md +611 -0
- data/lib/actionable/action.rb +1220 -0
- data/lib/actionable/batch_result.rb +92 -0
- data/lib/actionable/errors.rb +36 -0
- data/lib/actionable/history.rb +91 -0
- data/lib/actionable/input_dispatch.rb +94 -0
- data/lib/actionable/job.rb +157 -0
- data/lib/actionable/measurement.rb +64 -0
- data/lib/actionable/rails/after_commit.rb +142 -0
- data/lib/actionable/rails/proxy_validator.rb +59 -0
- data/lib/actionable/rails/transactions.rb +95 -0
- data/lib/actionable/rails.rb +24 -0
- data/lib/actionable/railtie.rb +10 -0
- data/lib/actionable/rbs.rb +502 -0
- data/lib/actionable/registry.rb +64 -0
- data/lib/actionable/result.rb +166 -0
- data/lib/actionable/results/failure.rb +43 -0
- data/lib/actionable/results/skipped.rb +30 -0
- data/lib/actionable/results/success.rb +14 -0
- data/lib/actionable/rspec/coverage/report.rb +264 -0
- data/lib/actionable/rspec/coverage/tracker.rb +330 -0
- data/lib/actionable/rspec/coverage.rb +45 -0
- data/lib/actionable/rspec/matchers.rb +266 -0
- data/lib/actionable/rspec/stubs.rb +250 -0
- data/lib/actionable/rspec.rb +40 -0
- data/lib/actionable/rubocop/config.yml +8 -0
- data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
- data/lib/actionable/rubocop/plugin.rb +31 -0
- data/lib/actionable/rubocop.rb +25 -0
- data/lib/actionable/runner.rb +300 -0
- data/lib/actionable/steps/action.rb +76 -0
- data/lib/actionable/steps/base.rb +107 -0
- data/lib/actionable/steps/case.rb +124 -0
- data/lib/actionable/steps/method.rb +39 -0
- data/lib/actionable/steps.rb +27 -0
- data/lib/actionable/value_match.rb +22 -0
- data/lib/actionable/verb_position.rb +574 -0
- data/lib/actionable/version.rb +6 -0
- data/lib/actionable-rb.rb +6 -0
- data/lib/actionable.rb +59 -0
- data/sig/actionable/dsl.rbs +42 -0
- data/sig/actionable/rails.rbs +16 -0
- data/sig/actionable.rbs +2593 -0
- data/skills/actionable/SKILL.md +214 -0
- metadata +132 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'oj'
|
|
5
|
+
require_relative '../../../actionable'
|
|
6
|
+
|
|
7
|
+
module Actionable
|
|
8
|
+
# Outcome coverage for a test suite (decision D22). While enabled, every real
|
|
9
|
+
# +.run+ of a named action class is recorded as +[class name, outcome, code]+,
|
|
10
|
+
# where outcome is +:success+, +:failure+ or +:skipped+. At the end of the
|
|
11
|
+
# suite those observations are compared with the outcomes each action can
|
|
12
|
+
# end with (its declarations, read at run time), and the outcomes no spec
|
|
13
|
+
# reached are reported.
|
|
14
|
+
#
|
|
15
|
+
# Loaded by +require 'actionable/rspec/coverage'+, which also installs the
|
|
16
|
+
# suite hook. Nothing here is loaded by the core.
|
|
17
|
+
module Coverage
|
|
18
|
+
# Prepended onto {Actionable::Action}'s singleton class, so every
|
|
19
|
+
# subclass's +.run+ (nested {Steps::Action} runs and {Action.run_each}
|
|
20
|
+
# included) passes through it. An RSpec stub of +.run+ defines the method
|
|
21
|
+
# on the subclass itself, ahead of this module, so a stubbed run is never
|
|
22
|
+
# recorded as the action being tested.
|
|
23
|
+
module Recorder
|
|
24
|
+
# Run the action and record the result's outcome.
|
|
25
|
+
#
|
|
26
|
+
# @return [Result]
|
|
27
|
+
def run(*, **)
|
|
28
|
+
result = super
|
|
29
|
+
Coverage.record(self, result)
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@mutex = Mutex.new
|
|
35
|
+
@observed = Set.new
|
|
36
|
+
@stubbed = Set.new
|
|
37
|
+
@enabled = false
|
|
38
|
+
|
|
39
|
+
# The default {.new_gap_hint}.
|
|
40
|
+
DEFAULT_NEW_GAP_HINT = 'Write a spec that reaches each one, or add it to the baseline:'
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# The class whose named descendants are reported. Defaults to
|
|
44
|
+
# {Actionable::Action}.
|
|
45
|
+
# @return [Class<Actionable::Action>]
|
|
46
|
+
attr_accessor :root
|
|
47
|
+
|
|
48
|
+
# Name filters for the report. A String matches as a name prefix, a
|
|
49
|
+
# Regexp by +match?+. With any include filter, only matching names are
|
|
50
|
+
# reported; an exclude filter drops matching names.
|
|
51
|
+
# @return [Array<String, Regexp>]
|
|
52
|
+
attr_accessor :include_prefixes, :exclude_prefixes
|
|
53
|
+
|
|
54
|
+
# Called before the actions are listed, so a host can load every action
|
|
55
|
+
# class first (+-> { Rails.application.eager_load! }+). Unloaded classes
|
|
56
|
+
# cannot be reported.
|
|
57
|
+
# @return [#call, nil]
|
|
58
|
+
attr_accessor :loader
|
|
59
|
+
|
|
60
|
+
# When true, report only actions with at least one real run, so a
|
|
61
|
+
# partial or targeted run doesn't flag every action it never touched.
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
attr_accessor :only_observed
|
|
64
|
+
|
|
65
|
+
# Path to the committed baseline of known gaps, a JSON or YAML file of
|
|
66
|
+
# +{"Action::Name" => [outcome keys]}+. With one set, the report fails
|
|
67
|
+
# on a gap the baseline doesn't list and on a stale entry; see
|
|
68
|
+
# {Report#baseline_problems}. +nil+ (the default) never fails.
|
|
69
|
+
# @return [String, nil]
|
|
70
|
+
attr_accessor :baseline
|
|
71
|
+
|
|
72
|
+
# The advice after "New untested outcomes." in {Report#baseline_message}.
|
|
73
|
+
# The default offers both fixes; a host whose baseline only shrinks can
|
|
74
|
+
# drop the second, e.g. +"Write a spec that reaches each one."+
|
|
75
|
+
# @return [String]
|
|
76
|
+
attr_accessor :new_gap_hint
|
|
77
|
+
|
|
78
|
+
# @return [Array<Symbol>] the gem failure codes counted as expected
|
|
79
|
+
# outcomes; see {.gem_codes=}
|
|
80
|
+
attr_reader :gem_codes
|
|
81
|
+
|
|
82
|
+
# Count some of {Actionable::Action::GEM_FAILURE_CODES} as expected
|
|
83
|
+
# outcomes. +:invalid_input+ counts for an action whose input has a
|
|
84
|
+
# required field; +:invalid_output+ for one whose output has a required
|
|
85
|
+
# field. Defaults to none, so an adopter opts in.
|
|
86
|
+
#
|
|
87
|
+
# @param codes [Array<Symbol, String>]
|
|
88
|
+
# @raise [ArgumentError] for a code the gem never emits
|
|
89
|
+
# @return [void]
|
|
90
|
+
def gem_codes=(codes)
|
|
91
|
+
codes = Array(codes).map(&:to_sym)
|
|
92
|
+
unknown = codes - Actionable::Action::GEM_FAILURE_CODES
|
|
93
|
+
unless unknown.empty?
|
|
94
|
+
raise ArgumentError,
|
|
95
|
+
"not a gem failure code: #{unknown.map(&:inspect).join(", ")} " \
|
|
96
|
+
"(the gem emits #{Actionable::Action::GEM_FAILURE_CODES.map(&:inspect).join(", ")})"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@gem_codes = codes
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Restore every setting to its default.
|
|
103
|
+
#
|
|
104
|
+
# @return [void]
|
|
105
|
+
def reset_config!
|
|
106
|
+
@root = Actionable::Action
|
|
107
|
+
@include_prefixes = []
|
|
108
|
+
@exclude_prefixes = []
|
|
109
|
+
@loader = nil
|
|
110
|
+
@gem_codes = []
|
|
111
|
+
@only_observed = false
|
|
112
|
+
@baseline = nil
|
|
113
|
+
@new_gap_hint = DEFAULT_NEW_GAP_HINT
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The outcomes +action_class+ can end with, as outcome keys:
|
|
117
|
+
# +"success"+, +"failure:<code>"+ for each declared failure (D20) and
|
|
118
|
+
# +"skipped:<code>"+ for each declared skip (D21), plus any configured
|
|
119
|
+
# {.gem_codes} that apply. Read from the declarations at run time.
|
|
120
|
+
#
|
|
121
|
+
# @param action_class [Class<Actionable::Action>]
|
|
122
|
+
# @return [Array<String>]
|
|
123
|
+
def expected_outcomes(action_class)
|
|
124
|
+
keys = ['success']
|
|
125
|
+
keys.concat(action_class.declared_failures.keys.map { |code| "failure:#{code}" })
|
|
126
|
+
keys.concat(action_class.declared_skips.keys.map { |code| "skipped:#{code}" })
|
|
127
|
+
gem_codes.each { |code| keys << "failure:#{code}" if gem_code_applies?(action_class, code) }
|
|
128
|
+
keys.uniq
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The action classes to report: named descendants of {.root} that have
|
|
132
|
+
# steps, whose name still resolves to the class, filtered by
|
|
133
|
+
# {.include_prefixes} and {.exclude_prefixes}. Calls {.loader} first.
|
|
134
|
+
#
|
|
135
|
+
# @return [Array<Class<Actionable::Action>>] sorted by name
|
|
136
|
+
def actions
|
|
137
|
+
loader&.call
|
|
138
|
+
descendants(root).select { |klass| reportable?(klass) }.sort_by(&:name)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The outcome key an observation counts toward: +"success"+, or
|
|
142
|
+
# +"<outcome>:<code>"+.
|
|
143
|
+
#
|
|
144
|
+
# @param outcome [Symbol, String]
|
|
145
|
+
# @param code [Symbol, String]
|
|
146
|
+
# @return [String]
|
|
147
|
+
def outcome_key(outcome, code)
|
|
148
|
+
outcome.to_s == 'success' ? 'success' : "#{outcome}:#{code}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Start recording runs. Installs {Recorder} the first time; safe to call
|
|
152
|
+
# again.
|
|
153
|
+
#
|
|
154
|
+
# @return [void]
|
|
155
|
+
def enable!
|
|
156
|
+
singleton = Actionable::Action.singleton_class
|
|
157
|
+
singleton.prepend(Recorder) unless singleton.ancestors.include?(Recorder)
|
|
158
|
+
@enabled = true
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Stop recording runs. {Recorder} stays installed but records nothing.
|
|
162
|
+
#
|
|
163
|
+
# @return [void]
|
|
164
|
+
def disable!
|
|
165
|
+
@enabled = false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @return [Boolean] whether runs are being recorded
|
|
169
|
+
def enabled?
|
|
170
|
+
@enabled
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Record one real run. Ignores anonymous classes and anything that isn't
|
|
174
|
+
# a {Result}.
|
|
175
|
+
#
|
|
176
|
+
# @param action_class [Class<Actionable::Action>]
|
|
177
|
+
# @param result [Result]
|
|
178
|
+
# @return [void]
|
|
179
|
+
def record(action_class, result)
|
|
180
|
+
observation = observation_for(action_class, result)
|
|
181
|
+
@mutex.synchronize { @observed << observation } if observation
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Record one run answered by the gem's stub helpers
|
|
185
|
+
# (+allow_actionable_success+ and the rest). Kept apart from {.observed}:
|
|
186
|
+
# a stubbed outcome was never produced by the action, so it never counts
|
|
187
|
+
# as tested, but the report shows it.
|
|
188
|
+
#
|
|
189
|
+
# @param action_class [Class<Actionable::Action>]
|
|
190
|
+
# @param result [Result]
|
|
191
|
+
# @return [void]
|
|
192
|
+
def record_stub(action_class, result)
|
|
193
|
+
observation = observation_for(action_class, result)
|
|
194
|
+
@mutex.synchronize { @stubbed << observation } if observation
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The runs answered by a gem stub helper so far, deduplicated.
|
|
198
|
+
#
|
|
199
|
+
# @return [Set<Array(String, Symbol, Symbol)>] +[class name, outcome, code]+
|
|
200
|
+
def stubbed
|
|
201
|
+
@mutex.synchronize { @stubbed.dup }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The real runs recorded so far, deduplicated.
|
|
205
|
+
#
|
|
206
|
+
# @return [Set<Array(String, Symbol, Symbol)>] +[class name, outcome, code]+
|
|
207
|
+
def observed
|
|
208
|
+
@mutex.synchronize { @observed.dup }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Write the runs recorded so far to +path+ as JSON, for a sharded suite:
|
|
212
|
+
# each shard writes its own file and one {Report} merges them. Creates
|
|
213
|
+
# the directory.
|
|
214
|
+
#
|
|
215
|
+
# @param path [String]
|
|
216
|
+
# @return [String] +path+
|
|
217
|
+
def write(path)
|
|
218
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
219
|
+
data = {version: 1, observed: observed.to_a.sort, stubbed: stubbed.to_a.sort}
|
|
220
|
+
File.write(path, Oj.dump(data, mode: :compat))
|
|
221
|
+
path
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Read a file written by {.write}.
|
|
225
|
+
#
|
|
226
|
+
# @param path [String]
|
|
227
|
+
# @return [Hash{Symbol=>Set<Array(String, Symbol, Symbol)>}] +{observed:, stubbed:}+
|
|
228
|
+
def read(path)
|
|
229
|
+
data = Oj.load(File.read(path), mode: :compat)
|
|
230
|
+
%w[observed stubbed].to_h do |set|
|
|
231
|
+
[set.to_sym, Array(data[set]).to_set { |name, outcome, code| [name, outcome.to_sym, code.to_sym] }]
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Forget every recorded run, real and stubbed.
|
|
236
|
+
#
|
|
237
|
+
# @return [void]
|
|
238
|
+
def reset!
|
|
239
|
+
@mutex.synchronize do
|
|
240
|
+
@observed.clear
|
|
241
|
+
@stubbed.clear
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
private
|
|
246
|
+
|
|
247
|
+
# @return [Array<Class>] every subclass below +klass+, recursively
|
|
248
|
+
def descendants(klass)
|
|
249
|
+
klass.subclasses.flat_map { |subclass| [subclass, *descendants(subclass)] }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Abstract actions are left out: an anonymous class has no name to
|
|
253
|
+
# report, and an action with no steps is a base class. A class whose
|
|
254
|
+
# name no longer resolves to it (a removed +stub_const+, a reloaded
|
|
255
|
+
# constant) is left out too.
|
|
256
|
+
def reportable?(klass)
|
|
257
|
+
name = klass.name
|
|
258
|
+
return false if name.nil? || klass.steps.empty?
|
|
259
|
+
return false unless resolves_to?(name, klass)
|
|
260
|
+
return false if include_prefixes.any? && include_prefixes.none? { |pattern| matches?(pattern, name) }
|
|
261
|
+
|
|
262
|
+
exclude_prefixes.none? { |pattern| matches?(pattern, name) }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def resolves_to?(name, klass)
|
|
266
|
+
Object.const_get(name).equal?(klass)
|
|
267
|
+
rescue NameError
|
|
268
|
+
false
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def matches?(pattern, name)
|
|
272
|
+
pattern.is_a?(Regexp) ? pattern.match?(name) : name.start_with?(pattern.to_s)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def gem_code_applies?(action_class, code)
|
|
276
|
+
case code
|
|
277
|
+
when :invalid_input then input_can_be_invalid?(action_class)
|
|
278
|
+
when :invalid_output then output_schemas(action_class).any? { |schema| requires_field?(schema) }
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# A fixed input with a required field, or a discriminated input with no
|
|
283
|
+
# default shape (an unmatched value fails) or a shape with one.
|
|
284
|
+
def input_can_be_invalid?(action_class)
|
|
285
|
+
return requires_field?(action_class.input_schema) if action_class.input_schema
|
|
286
|
+
|
|
287
|
+
dispatch = action_class.input_dispatch
|
|
288
|
+
return false unless dispatch
|
|
289
|
+
|
|
290
|
+
dispatch.default_schema.nil? || dispatch.schemas.any? { |schema| requires_field?(schema) }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Every output schema a run can be held to: the success's, and each skip
|
|
294
|
+
# or failure code's (decision D23), any of which can end it as
|
|
295
|
+
# +invalid_output+.
|
|
296
|
+
def output_schemas(action_class)
|
|
297
|
+
skips = action_class.skip_declarations.keys.map { |code| action_class.skip_output_schema(code) }
|
|
298
|
+
failures = action_class.failure_declarations.keys.map { |code| action_class.failure_output_schema(code) }
|
|
299
|
+
[action_class.output_schema, *skips, *failures].compact
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def requires_field?(schema)
|
|
303
|
+
return false unless schema
|
|
304
|
+
|
|
305
|
+
schema.metadata.to_h.values.any? { |field| field[:required] }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# @return [Array(String, Symbol, Symbol), nil]
|
|
309
|
+
def observation_for(action_class, result)
|
|
310
|
+
return unless enabled? && result.is_a?(Actionable::Result)
|
|
311
|
+
|
|
312
|
+
name = action_class.name
|
|
313
|
+
return if name.nil?
|
|
314
|
+
|
|
315
|
+
[name, outcome_of(result), result.code.to_sym]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# @param result [Result]
|
|
319
|
+
# @return [Symbol] +:success+, +:failure+ or +:skipped+
|
|
320
|
+
def outcome_of(result)
|
|
321
|
+
if result.failure? then :failure
|
|
322
|
+
elsif result.skipped? then :skipped
|
|
323
|
+
else :success
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
reset_config!
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Optional outcome coverage for an RSpec suite (decision D22). Load it with
|
|
4
|
+
# `require 'actionable/rspec/coverage'`, typically from spec/support. Never
|
|
5
|
+
# loaded by the core or by `actionable/rspec`.
|
|
6
|
+
#
|
|
7
|
+
# Requiring it starts recording every real action run and, when RSpec is
|
|
8
|
+
# loaded, adds an end-of-suite hook: it writes the runs to the file named by
|
|
9
|
+
# +ACTIONABLE_COVERAGE_FILE+ when that is set (one shard of a sharded suite),
|
|
10
|
+
# and otherwise prints the report and holds it to {Actionable::Coverage.baseline}.
|
|
11
|
+
require_relative 'coverage/tracker'
|
|
12
|
+
require_relative 'coverage/report'
|
|
13
|
+
|
|
14
|
+
module Actionable
|
|
15
|
+
module Coverage
|
|
16
|
+
class << self
|
|
17
|
+
# Start recording and, under RSpec, add the end-of-suite hook once.
|
|
18
|
+
#
|
|
19
|
+
# @return [void]
|
|
20
|
+
def install!
|
|
21
|
+
enable!
|
|
22
|
+
return if @installed || !defined?(::RSpec) || !::RSpec.respond_to?(:configure)
|
|
23
|
+
|
|
24
|
+
@installed = true
|
|
25
|
+
::RSpec.configure { |config| config.after(:suite) { Actionable::Coverage.finish_suite! } }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The end-of-suite step: write this process's runs to
|
|
29
|
+
# +ACTIONABLE_COVERAGE_FILE+ when it is set, since one shard can't judge
|
|
30
|
+
# the whole suite, and otherwise {.report!}.
|
|
31
|
+
#
|
|
32
|
+
# @param io [IO] where the text report goes
|
|
33
|
+
# @return [Report, String] the report, or the shard file's path
|
|
34
|
+
def finish_suite!(io: $stdout)
|
|
35
|
+
path = ENV['ACTIONABLE_COVERAGE_FILE'].to_s
|
|
36
|
+
return write(path) unless path.empty?
|
|
37
|
+
|
|
38
|
+
io.puts # the progress formatter leaves its line open
|
|
39
|
+
report!(io: io)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Actionable::Coverage.install!
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Actionable
|
|
4
|
+
module RSpec
|
|
5
|
+
# RSpec matcher methods mixed into example groups by +actionable/rspec+.
|
|
6
|
+
module Matchers
|
|
7
|
+
# Run an action and assert on its outcome.
|
|
8
|
+
#
|
|
9
|
+
# expect(CreateInvoice).to perform_actionable(amount: 5).and_succeed
|
|
10
|
+
# expect(CreateInvoice).to perform_actionable(bad).and_fail(:invalid_input)
|
|
11
|
+
# expect(Boom).to perform_actionable.and_raise(RuntimeError)
|
|
12
|
+
#
|
|
13
|
+
# @param args [Object] positional args forwarded to +.run+
|
|
14
|
+
# @param kwargs [Object] keyword args forwarded to +.run+
|
|
15
|
+
# @yieldparam result [Result, nil] the run's result (+nil+ when it raised)
|
|
16
|
+
# @yieldparam exception [StandardError, nil] what the run raised, if anything
|
|
17
|
+
# @yieldreturn [Object] truthy when the extra assertions hold
|
|
18
|
+
# @return [PerformActionable]
|
|
19
|
+
def perform_actionable(*args, **kwargs, &block)
|
|
20
|
+
PerformActionable.new(args, kwargs, block)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The matcher object returned by {Matchers#perform_actionable}. Runs the
|
|
25
|
+
# action under test and checks the configured outcome (decision D12). Bare
|
|
26
|
+
# +perform_actionable+ expects a success; chain +and_succeed+ / +and_fail+ /
|
|
27
|
+
# +and_raise+ to be explicit. Code and message expectations match with +===+,
|
|
28
|
+
# so exact values, Regexps, and classes all work.
|
|
29
|
+
class PerformActionable
|
|
30
|
+
# Backtrace frames shown under +ACTIONABLE_SHORT_BACKTRACE+ when
|
|
31
|
+
# +ACTIONABLE_BACKTRACE_QTY+ is unset.
|
|
32
|
+
# @return [Integer]
|
|
33
|
+
DEFAULT_BACKTRACE_QTY = 10
|
|
34
|
+
|
|
35
|
+
# @param args [Array<Object>] positional args forwarded to +.run+
|
|
36
|
+
# @param kwargs [Hash{Symbol=>Object}] keyword args forwarded to +.run+
|
|
37
|
+
# @param block [Proc, nil] extra assertions, called with the result and exception
|
|
38
|
+
def initialize(args, kwargs, block)
|
|
39
|
+
@args = args
|
|
40
|
+
@kwargs = kwargs
|
|
41
|
+
@block = block
|
|
42
|
+
@outcome = :success
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Expect a successful result, optionally matching +message+.
|
|
46
|
+
# @param message [Object, nil] matched against the result's message with +===+
|
|
47
|
+
# @yieldparam result [Result, nil]
|
|
48
|
+
# @yieldparam exception [StandardError, nil]
|
|
49
|
+
# @yieldreturn [Object] truthy when the extra assertions hold
|
|
50
|
+
# @return [self]
|
|
51
|
+
def and_succeed(message = nil, &block)
|
|
52
|
+
configure(:success, message, &block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Expect a failed result, optionally matching +code+ and +message+. On an
|
|
56
|
+
# action that declares failures, a Symbol +code+ it doesn't declare raises
|
|
57
|
+
# +ArgumentError+ (decision D20).
|
|
58
|
+
# @param code [Object, nil] matched against the result's code with +==+
|
|
59
|
+
# @param message [Object, nil] matched against the result's message with +===+
|
|
60
|
+
# @yieldparam result [Result, nil]
|
|
61
|
+
# @yieldparam exception [StandardError, nil]
|
|
62
|
+
# @yieldreturn [Object] truthy when the extra assertions hold
|
|
63
|
+
# @return [self]
|
|
64
|
+
def and_fail(code = nil, message = nil, &block)
|
|
65
|
+
@expected_code = code
|
|
66
|
+
configure(:failure, message, &block)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Expect a skipped result, optionally matching the reason +code+ and
|
|
70
|
+
# +message+ (decision D17). On an action that declares skips, a Symbol
|
|
71
|
+
# +code+ it doesn't declare raises +ArgumentError+ (decision D21).
|
|
72
|
+
# @param code [Object, nil] matched against the result's code with +==+
|
|
73
|
+
# @param message [Object, nil] matched against the result's message with +===+
|
|
74
|
+
# @yieldparam result [Result, nil]
|
|
75
|
+
# @yieldparam exception [StandardError, nil]
|
|
76
|
+
# @yieldreturn [Object] truthy when the extra assertions hold
|
|
77
|
+
# @return [self]
|
|
78
|
+
def and_skip(code = nil, message = nil, &block)
|
|
79
|
+
@expected_code = code
|
|
80
|
+
configure(:skip, message, &block)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Expect the run to raise +error_class+, optionally matching +message+.
|
|
84
|
+
# @param error_class [Class<Exception>]
|
|
85
|
+
# @param message [Object, nil] matched against the exception's message with +===+
|
|
86
|
+
# @yieldparam result [Result, nil]
|
|
87
|
+
# @yieldparam exception [StandardError, nil]
|
|
88
|
+
# @yieldreturn [Object] truthy when the extra assertions hold
|
|
89
|
+
# @return [self]
|
|
90
|
+
def and_raise(error_class = StandardError, message = nil, &block)
|
|
91
|
+
@expected_error = error_class
|
|
92
|
+
configure(:raise, message, &block)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param action_class [Class<Actionable::Action>]
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
def matches?(action_class)
|
|
98
|
+
@action_class = action_class
|
|
99
|
+
ensure_code_declared!
|
|
100
|
+
perform
|
|
101
|
+
primary_match? && block_match?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @param action_class [Class<Actionable::Action>]
|
|
105
|
+
# @return [Boolean]
|
|
106
|
+
def does_not_match?(action_class)
|
|
107
|
+
@action_class = action_class
|
|
108
|
+
ensure_code_declared!
|
|
109
|
+
perform
|
|
110
|
+
!primary_match?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [String]
|
|
114
|
+
def failure_message
|
|
115
|
+
"expected #{@action_class} to #{expectation_phrase}, but #{actual_phrase}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [String]
|
|
119
|
+
def failure_message_when_negated
|
|
120
|
+
"expected #{@action_class} not to #{expectation_phrase}, but it did"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @return [String]
|
|
124
|
+
def description
|
|
125
|
+
"perform actionable and #{@outcome}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# @param outcome [Symbol] +:success+, +:failure+, +:skip+ or +:raise+
|
|
131
|
+
# @param message [Object, nil]
|
|
132
|
+
# @yieldparam result [Result, nil]
|
|
133
|
+
# @yieldparam exception [StandardError, nil]
|
|
134
|
+
# @yieldreturn [Object] truthy when the extra assertions hold
|
|
135
|
+
# @return [self]
|
|
136
|
+
def configure(outcome, message, &block)
|
|
137
|
+
@outcome = outcome
|
|
138
|
+
@expected_message = message
|
|
139
|
+
@block = block if block
|
|
140
|
+
self
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# An +and_fail+ or +and_skip+ code the action could never end with is a
|
|
144
|
+
# broken spec, not a failed expectation, so it raises rather than not
|
|
145
|
+
# matching.
|
|
146
|
+
#
|
|
147
|
+
# @raise [ArgumentError] when the action declares the outcome's codes but not this one
|
|
148
|
+
# @return [void]
|
|
149
|
+
def ensure_code_declared!
|
|
150
|
+
return unless @expected_code.is_a?(Symbol)
|
|
151
|
+
|
|
152
|
+
check = {failure: :allows_failure_code?, skip: :allows_skip_code?}[@outcome]
|
|
153
|
+
return unless check && @action_class.respond_to?(check)
|
|
154
|
+
return if @action_class.public_send(check, @expected_code)
|
|
155
|
+
|
|
156
|
+
raise ArgumentError,
|
|
157
|
+
Actionable::RSpec.undeclared_code_message(@action_class, @expected_code, outcome: @outcome)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Run the action, keeping its result or the error it raised.
|
|
161
|
+
# @return [void]
|
|
162
|
+
def perform
|
|
163
|
+
@result = nil
|
|
164
|
+
@exception = nil
|
|
165
|
+
@result = @action_class.run(*@args, **@kwargs)
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
@exception = e
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# @return [Boolean, nil]
|
|
171
|
+
def primary_match?
|
|
172
|
+
case @outcome
|
|
173
|
+
when :success then succeeded?
|
|
174
|
+
when :failure then failed?
|
|
175
|
+
when :skip then skipped?
|
|
176
|
+
when :raise then raised?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @return [Boolean, nil]
|
|
181
|
+
def succeeded?
|
|
182
|
+
@exception.nil? && @result&.success? && message_matches?(@result&.message)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @return [Boolean, nil]
|
|
186
|
+
def failed?
|
|
187
|
+
@exception.nil? && @result&.failure? &&
|
|
188
|
+
(@expected_code.nil? || @expected_code == @result.code) &&
|
|
189
|
+
message_matches?(@result&.message)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @return [Boolean, nil]
|
|
193
|
+
def skipped?
|
|
194
|
+
@exception.nil? && @result&.skipped? &&
|
|
195
|
+
(@expected_code.nil? || @expected_code == @result.code) &&
|
|
196
|
+
message_matches?(@result&.message)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @return [Boolean]
|
|
200
|
+
def raised?
|
|
201
|
+
!@exception.nil? && @exception.is_a?(@expected_error) && message_matches?(@exception.message)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @param actual [String, nil]
|
|
205
|
+
# @return [Boolean]
|
|
206
|
+
def message_matches?(actual)
|
|
207
|
+
@expected_message.nil? || @expected_message === actual # rubocop:disable Style/CaseEquality
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# @return [Boolean]
|
|
211
|
+
def block_match?
|
|
212
|
+
return true unless @block
|
|
213
|
+
|
|
214
|
+
!!@block.call(@result, @exception)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# @return [String, nil]
|
|
218
|
+
def expectation_phrase
|
|
219
|
+
case @outcome
|
|
220
|
+
when :success then "succeed#{message_suffix}"
|
|
221
|
+
when :failure then "fail#{code_suffix}#{message_suffix}"
|
|
222
|
+
when :skip then "skip#{code_suffix}#{message_suffix}"
|
|
223
|
+
when :raise then "raise #{@expected_error}#{message_suffix}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# @return [String]
|
|
228
|
+
def code_suffix
|
|
229
|
+
@expected_code ? " with code #{@expected_code.inspect}" : ''
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @return [String]
|
|
233
|
+
def message_suffix
|
|
234
|
+
@expected_message ? " with message #{@expected_message.inspect}" : ''
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# @return [String]
|
|
238
|
+
def actual_phrase
|
|
239
|
+
if @exception
|
|
240
|
+
"it raised #{@exception.class}: #{@exception.message}\n#{formatted_backtrace}"
|
|
241
|
+
elsif @result
|
|
242
|
+
"got #{@result.inspect}"
|
|
243
|
+
else
|
|
244
|
+
'no result was produced'
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @return [String]
|
|
249
|
+
def formatted_backtrace
|
|
250
|
+
frames = @exception.backtrace || []
|
|
251
|
+
frames = frames.first(backtrace_qty) if short_backtrace?
|
|
252
|
+
frames.join("\n")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# @return [Boolean]
|
|
256
|
+
def short_backtrace?
|
|
257
|
+
ENV.key?('ACTIONABLE_SHORT_BACKTRACE')
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# @return [Integer]
|
|
261
|
+
def backtrace_qty
|
|
262
|
+
(ENV['ACTIONABLE_BACKTRACE_QTY'] || DEFAULT_BACKTRACE_QTY).to_i
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|