cem_acpt 0.8.7 → 0.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/spec.yml +0 -3
  3. data/Gemfile.lock +9 -1
  4. data/README.md +95 -13
  5. data/cem_acpt.gemspec +2 -1
  6. data/lib/cem_acpt/action_result.rb +8 -2
  7. data/lib/cem_acpt/actions.rb +153 -0
  8. data/lib/cem_acpt/bolt/cmd/base.rb +174 -0
  9. data/lib/cem_acpt/bolt/cmd/output.rb +315 -0
  10. data/lib/cem_acpt/bolt/cmd/task.rb +59 -0
  11. data/lib/cem_acpt/bolt/cmd.rb +22 -0
  12. data/lib/cem_acpt/bolt/errors.rb +49 -0
  13. data/lib/cem_acpt/bolt/helpers.rb +52 -0
  14. data/lib/cem_acpt/bolt/inventory.rb +62 -0
  15. data/lib/cem_acpt/bolt/project.rb +38 -0
  16. data/lib/cem_acpt/bolt/summary_results.rb +96 -0
  17. data/lib/cem_acpt/bolt/tasks.rb +181 -0
  18. data/lib/cem_acpt/bolt/tests.rb +415 -0
  19. data/lib/cem_acpt/bolt/yaml_file.rb +74 -0
  20. data/lib/cem_acpt/bolt.rb +142 -0
  21. data/lib/cem_acpt/cli.rb +6 -0
  22. data/lib/cem_acpt/config/base.rb +4 -0
  23. data/lib/cem_acpt/config/cem_acpt.rb +7 -1
  24. data/lib/cem_acpt/core_ext.rb +25 -0
  25. data/lib/cem_acpt/goss/api/action_response.rb +4 -0
  26. data/lib/cem_acpt/goss/api.rb +23 -25
  27. data/lib/cem_acpt/logging/formatter.rb +3 -3
  28. data/lib/cem_acpt/logging.rb +17 -1
  29. data/lib/cem_acpt/provision/terraform/linux.rb +1 -1
  30. data/lib/cem_acpt/test_data.rb +2 -0
  31. data/lib/cem_acpt/test_runner/log_formatter/base.rb +73 -0
  32. data/lib/cem_acpt/test_runner/log_formatter/bolt_error_formatter.rb +65 -0
  33. data/lib/cem_acpt/test_runner/log_formatter/bolt_output_formatter.rb +54 -0
  34. data/lib/cem_acpt/test_runner/log_formatter/bolt_summary_results_formatter.rb +64 -0
  35. data/lib/cem_acpt/test_runner/log_formatter/goss_action_response.rb +17 -30
  36. data/lib/cem_acpt/test_runner/log_formatter/goss_error_formatter.rb +31 -0
  37. data/lib/cem_acpt/test_runner/log_formatter/standard_error_formatter.rb +35 -0
  38. data/lib/cem_acpt/test_runner/log_formatter.rb +17 -5
  39. data/lib/cem_acpt/test_runner/test_results.rb +150 -0
  40. data/lib/cem_acpt/test_runner.rb +153 -53
  41. data/lib/cem_acpt/utils/files.rb +189 -0
  42. data/lib/cem_acpt/utils/finalizer_queue.rb +73 -0
  43. data/lib/cem_acpt/utils/shell.rb +13 -4
  44. data/lib/cem_acpt/version.rb +1 -1
  45. data/sample_config.yaml +13 -0
  46. metadata +41 -5
  47. data/lib/cem_acpt/test_runner/log_formatter/error_formatter.rb +0 -33
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module CemAcpt
6
+ module Bolt
7
+ module Cmd
8
+ # Wraps the output of a Bolt command
9
+ class Output
10
+ attr_reader :cmd_output, :items, :target_count, :elapsed_time, :error_obj
11
+
12
+ def initialize(cmd_output, strict: true, **item_defaults)
13
+ @original_cmd_output = cmd_output
14
+ @strict = strict
15
+ @item_defaults = item_defaults.transform_keys(&:to_s)
16
+ init_cmd_output_and_error_obj(cmd_output)
17
+ @target_count = @cmd_output['target_count'] || 0
18
+ @elapsed_time = @cmd_output['elapsed_time'] || 0
19
+ end
20
+
21
+ def action
22
+ items&.map(&:action)&.uniq || [@item_defaults['action']] || ['unknown']
23
+ end
24
+
25
+ def error
26
+ return nil unless error?
27
+
28
+ items.find(&:error?)&.error || error_obj
29
+ end
30
+
31
+ def error?
32
+ !error_obj.nil? || items.any?(&:error?)
33
+ end
34
+
35
+ def success?
36
+ !error?
37
+ end
38
+
39
+ def status
40
+ return 0 if success?
41
+
42
+ 1
43
+ end
44
+
45
+ def summary
46
+ @summary ||= [
47
+ "status: #{success? ? 'passed' : 'failed'}",
48
+ "items total: #{items.length}",
49
+ "items succeeded: #{items.count(&:success?)}",
50
+ "items failed: #{items.count(&:error?)}",
51
+ "target count: #{target_count}",
52
+ "elapsed time: #{elapsed_time}",
53
+ ].join(', ')
54
+ end
55
+
56
+ def summary?
57
+ true
58
+ end
59
+
60
+ def inspect
61
+ "#<#{self.class}:#{object_id} #{self}>"
62
+ end
63
+
64
+ def to_h
65
+ @cmd_output
66
+ end
67
+
68
+ def results
69
+ @results ||= new_results
70
+ end
71
+
72
+ def results?
73
+ !results.empty?
74
+ end
75
+
76
+ def to_s
77
+ return error.to_s if error?
78
+
79
+ JSON.pretty_generate(@cmd_output)
80
+ end
81
+
82
+ def ==(other)
83
+ return false unless other.is_a?(self.class)
84
+ return false unless to_h == other.to_h
85
+
86
+ items.zip(other.items).all? { |a, b| a == b }
87
+ end
88
+ alias eql? ==
89
+
90
+ # Exists solely for Bolt tests
91
+ def copy_with_new_items(new_items = [])
92
+ new_self = self.class.new(original_cmd_output, strict: @strict, **@item_defaults)
93
+ new_self.items = new_items
94
+ new_self
95
+ end
96
+
97
+ def method_missing(method, *args, **kwargs, &block)
98
+ if @cmd_output.respond_to?(method)
99
+ @cmd_output.send(method, *args, **kwargs, &block)
100
+ elsif error.respond_to?(method)
101
+ error.send(method, *args, **kwargs, &block)
102
+ else
103
+ super
104
+ end
105
+ end
106
+
107
+ def respond_to_missing?(method, include_private = false)
108
+ @cmd_output.respond_to?(method, include_private) || error.respond_to?(method, include_private) || super
109
+ end
110
+
111
+ protected
112
+
113
+ attr_reader :original_cmd_output
114
+ attr_writer :cmd_output, :items, :target_count, :elapsed_time, :error_obj
115
+
116
+ private
117
+
118
+ def init_cmd_output_and_error_obj(cmd_out)
119
+ case cmd_out
120
+ when String
121
+ init_cmd_output_and_error_obj_from_str(cmd_out)
122
+ when StandardError
123
+ @error_obj = cmd_out
124
+ @cmd_output = ruby_error_to_cmd_output_hash(cmd_out)
125
+ else
126
+ raise ArgumentError, "cmd_out must be a String or StandardError, got #{cmd_out.class}"
127
+ end
128
+ ensure
129
+ @items = (@cmd_output['items'] || []).map { |item| OutputItem.new(item) }
130
+ if @items.empty? && @error_obj.nil? && @strict
131
+ err = RuntimeError.new("Cannot set results, no error or items found for cmd_output:\n#{cmd_output}")
132
+ @error_obj = err
133
+ @items = ruby_error_to_cmd_output_hash(err)['items'].map { |item| OutputItem.new(item) }
134
+ end
135
+ end
136
+
137
+ def init_cmd_output_and_error_obj_from_str(cmd_out)
138
+ @cmd_output = JSON.parse(cmd_out)
139
+ if @cmd_output.key?('_error') || @cmd_output.key?('error')
140
+ err_hash = (@cmd_output['_error'] || @cmd_output['error'])
141
+ @cmd_output['items'] ||= []
142
+ @cmd_output['items'] << {
143
+ 'value' => {
144
+ '_error' => err_hash,
145
+ },
146
+ }
147
+ end
148
+ rescue JSON::ParserError => e
149
+ @error_obj = e
150
+ @cmd_output = ruby_error_to_cmd_output_hash(e)
151
+ end
152
+
153
+ def new_results
154
+ return new_error_results if items.empty?
155
+
156
+ items
157
+ end
158
+
159
+ def new_error_results
160
+ [error]
161
+ end
162
+
163
+ def ruby_error_to_cmd_output_hash(error)
164
+ error_kind = [
165
+ 'cem_acpt',
166
+ error.class.name.split('::').join('.'),
167
+ ].join('.')
168
+ details = { 'exit_code' => 1 }
169
+ details['backtrace'] = error.backtrace if error.backtrace
170
+ {
171
+ 'items' => [
172
+ {
173
+ 'value' => {
174
+ '_error' => {
175
+ 'kind' => error_kind,
176
+ 'msg' => error.to_s,
177
+ 'issue_code' => 'CEM_ACPT_ERROR',
178
+ 'details' => {
179
+ 'exit_code' => 1,
180
+ 'backtrace' => error.backtrace,
181
+ },
182
+ },
183
+ },
184
+ },
185
+ ],
186
+ }
187
+ end
188
+ end
189
+
190
+ # Represents a single item in the output of a Bolt command
191
+ class OutputItem
192
+ ATTR_DEFVAL = 'unknown'
193
+
194
+ attr_reader :target, :action, :object, :status, :value
195
+
196
+ def initialize(item_hash, **item_defaults)
197
+ @item_hash = item_hash
198
+ @item_defaults = item_defaults.transform_keys(&:to_s)
199
+ @target = item_hash['target'] || @item_defaults['target'] || ATTR_DEFVAL
200
+ @action = item_hash['action'] || @item_defaults['action'] || ATTR_DEFVAL
201
+ @object = item_hash['object'] || @item_defaults['object'] || ATTR_DEFVAL
202
+ @status = item_hash['status'] || 'failure'
203
+ @value = item_hash['value'] || {}
204
+ end
205
+
206
+ def error?
207
+ !success?
208
+ end
209
+
210
+ def error
211
+ return unless error?
212
+
213
+ @error ||= new_error
214
+ end
215
+
216
+ def success?
217
+ status == 'success'
218
+ end
219
+
220
+ def output
221
+ return nil if error?
222
+
223
+ value['_output'] || value['output'] || value
224
+ end
225
+
226
+ def to_h
227
+ @item_hash
228
+ end
229
+
230
+ def to_s
231
+ "#<#{self.class}:#{object_id.to_s(16)} #{target},#{action},#{object},#{status}>"
232
+ end
233
+
234
+ def inspect
235
+ to_s
236
+ end
237
+
238
+ def ==(other)
239
+ return false unless other.is_a?(self.class)
240
+
241
+ to_h == other.to_h
242
+ end
243
+ alias eql? ==
244
+
245
+ private
246
+
247
+ def new_error
248
+ if value.is_a?(Hash) && (value.key?('_error') || value.key?('error'))
249
+ OutputError.new(value['_error'] || value['error'])
250
+ else
251
+ OutputError.new(
252
+ {
253
+ 'kind' => 'cem_acpt.unknown',
254
+ 'msg' => value,
255
+ 'details' => { 'exit_code' => 1 },
256
+ },
257
+ )
258
+ end
259
+ end
260
+ end
261
+
262
+ # Represents a Bolt error value
263
+ class OutputError
264
+ attr_accessor :kind, :issue_code, :msg, :details
265
+
266
+ def initialize(error_hash)
267
+ @error_hash = error_hash
268
+ @kind = error_hash['kind']
269
+ @issue_code = error_hash['issue_code'] || 'OTHER_ERROR'
270
+ @msg = error_hash['msg']
271
+ @details = error_hash['details']
272
+ end
273
+
274
+ def error?
275
+ true
276
+ end
277
+
278
+ def success?
279
+ false
280
+ end
281
+
282
+ def status
283
+ 'failure'
284
+ end
285
+
286
+ def exit_code
287
+ details['exit_code'] || 1
288
+ end
289
+
290
+ def backtrace
291
+ details['backtrace'] || []
292
+ end
293
+
294
+ def to_s
295
+ "issue code: #{issue_code}, kind: #{kind}, message: #{msg}"
296
+ end
297
+
298
+ def inspect
299
+ "#<#{self.class.name}(#{self.class.object_id})#{self}>"
300
+ end
301
+
302
+ def to_h
303
+ @error_hash
304
+ end
305
+
306
+ def ==(other)
307
+ return false unless other.is_a?(self.class)
308
+
309
+ to_h == other.to_h
310
+ end
311
+ alias eql? ==
312
+ end
313
+ end
314
+ end
315
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module CemAcpt
6
+ module Bolt
7
+ module Cmd
8
+ # Base class for task commands
9
+ class TaskBase < Base
10
+ attr_accessor :task_name
11
+ attr_reader :sub_command, :item_defaults
12
+ option :module_path, '--modulepath', config_path: 'bolt.module_path', default: Dir.pwd
13
+ option :log_level, '--log-level', config_path: 'bolt.log_level', default: 'warn'
14
+ option :clear_cache, '--clear-cache', config_path: 'bolt.clear_cache', default: false, bool_flag: true
15
+ option :project, '--project', config_path: 'bolt.project.path'
16
+
17
+ def initialize(config, sub_command = nil, task_name = nil)
18
+ @config = config
19
+ @sub_command = sub_command
20
+ @task_name = task_name
21
+ @item_defaults = {
22
+ 'action' => 'task',
23
+ 'object' => @task_name,
24
+ }
25
+ super()
26
+ end
27
+
28
+ def command_family
29
+ 'task'
30
+ end
31
+
32
+ def cmd
33
+ join_array([bolt_bin, 'task', sub_command, task_name, options])
34
+ end
35
+ end
36
+
37
+ # Runs the Bolt task show command
38
+ class TaskShow < TaskBase
39
+ def initialize(config, task_name = nil, project: nil)
40
+ super(config, 'show', task_name)
41
+ @project = project.is_a?(String) ? project : project&.path
42
+ end
43
+ end
44
+
45
+ # Runs the Bolt task run command
46
+ class TaskRun < TaskBase
47
+ option :inventory, '--inventoryfile', config_path: 'bolt.inventory_path'
48
+ option :targets, '--targets', config_path: 'bolt.targets', default: 'nix'
49
+ supports_params
50
+
51
+ def initialize(config, task_name = nil, inventory: nil, project: nil)
52
+ super(config, 'run', task_name)
53
+ @inventory = inventory.is_a?(String) ? inventory : inventory&.path
54
+ @project = project.is_a?(String) ? project : project&.path
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cmd/task'
4
+
5
+ module CemAcpt
6
+ module Bolt
7
+ # Namespace for all Bolt command classes
8
+ module Cmd
9
+ # Represents the output of a Bolt command
10
+ # @param cmd_output [String, StandardError] the output of a Bolt command or an error
11
+ # @param strict [Boolean] whether to raise an error if the output does not match the expected format
12
+ # @param item_defaults [Hash] default values for the item.
13
+ # @option item_defaults [String] :action The action that was performed (ex: 'task')
14
+ # @option item_defaults [String] :object The object that the action was performed on (ex. The Bolt task name)
15
+ # @options item_defaults [String] :target The IP address of the target that the action was performed on
16
+ # @return [Output] a new Output object for the given command output
17
+ def self.new_output(cmd_output, strict: true, **item_defaults)
18
+ Output.new(cmd_output, strict: strict, **item_defaults)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CemAcpt
4
+ module Bolt
5
+ # Class for general Bolt errors. Can also wrap other errors.
6
+ class BoltActionError < StandardError
7
+ attr_reader :original_error, :bolt_action, :bolt_object
8
+
9
+ def initialize(msg = 'Bolt error occured', original_error = nil, bolt_action = nil, bolt_object = nil)
10
+ @original_error = original_error
11
+ @bolt_action = bolt_action
12
+ @bolt_object = bolt_object
13
+ unless @original_error.nil?
14
+ set_backtrace(@original_error.backtrace)
15
+ msg = "#{msg}: #{@original_error}"
16
+ end
17
+ super(msg)
18
+ end
19
+
20
+ def bolt_target
21
+ @bolt_target ||= @bolt_object&.target
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ bolt_action: bolt_action,
27
+ bolt_object: bolt_object,
28
+ original_error: original_error,
29
+ message: message,
30
+ backtrace: backtrace,
31
+ }
32
+ end
33
+ end
34
+
35
+ # Class for Bolt project errors. Can also wrap other errors.
36
+ class BoltProjectError < BoltActionError
37
+ def initialize(msg = 'Bolt project error occured', original_error = nil, *_args)
38
+ super(msg, original_error, 'project', 'bolt-project.yaml')
39
+ end
40
+ end
41
+
42
+ # Class for Bolt inventory errors. Can also wrap other errors.
43
+ class BoltInventoryError < BoltActionError
44
+ def initialize(msg = 'Bolt inventory error occured', original_error = nil, *_args)
45
+ super(msg, original_error, 'inventory', 'inventory.yaml')
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../utils/shell'
4
+
5
+ module CemAcpt
6
+ module Bolt
7
+ # Module containing helper methods for Bolt
8
+ module Helpers
9
+ BOLT_PROJECT_FILE = 'bolt-project.yaml'
10
+ INVENTORY_FILE = 'inventory.yaml'
11
+
12
+ def load_object_test(bolt_test_data, bolt_object)
13
+ return { params: {} } unless bolt_test_data
14
+
15
+ bolt_test_data[bolt_object].transform || { params: {} }
16
+ end
17
+
18
+ def new_bolt_project_hash(module_name, config)
19
+ {
20
+ 'name' => module_name,
21
+ 'analytics' => false,
22
+ }.merge(config.get('bolt.project')&.transform_keys(&:to_s) || {})
23
+ end
24
+
25
+ def new_inventory_hash(hosts, private_key, config)
26
+ {
27
+ 'groups' => [
28
+ {
29
+ 'name' => 'nix',
30
+ 'targets' => hosts,
31
+ 'config' => {
32
+ 'transport' => 'ssh',
33
+ 'ssh' => {
34
+ 'connect-timeout' => 60,
35
+ 'disconnect-timeout' => 60,
36
+ 'host-key-check' => false,
37
+ 'private-key' => private_key || '~/.ssh/id_rsa',
38
+ 'run-as' => 'root',
39
+ 'tmpdir' => '/var/tmp', # /tmp is usually noexec
40
+ }.merge(config.get('bolt.transport.ssh')&.transform_keys(&:to_s) || {}),
41
+ },
42
+ },
43
+ ],
44
+ }
45
+ end
46
+
47
+ def bolt_bin
48
+ @bolt_bin ||= CemAcpt::Utils::Shell.which('bolt', raise_if_not_found: true)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'yaml_file'
5
+
6
+ module CemAcpt
7
+ module Bolt
8
+ # Provides an abstraction for the Bolt inventory file
9
+ class Inventory < YamlFile
10
+ attr_reader :config, :hosts, :private_key
11
+
12
+ def initialize(config, hosts = [], private_key = nil)
13
+ path = config.get('bolt.inventory_path') || 'inventory.yaml'
14
+ super(path)
15
+ @config = config
16
+ @hosts = hosts
17
+ @private_key = private_key
18
+ @hash = new_inventory_hash(hosts, private_key, config)
19
+ end
20
+
21
+ def hosts=(hosts)
22
+ return if @hosts == hosts
23
+
24
+ @hosts = hosts
25
+ @hash = new_inventory_hash(hosts, @private_key, @config)
26
+ end
27
+
28
+ def private_key=(private_key)
29
+ return if @private_key == private_key
30
+
31
+ @private_key = private_key
32
+ @hash = new_inventory_hash(@hosts, private_key, @config)
33
+ end
34
+
35
+ private
36
+
37
+ def new_inventory_hash(hosts, private_key, config)
38
+ {
39
+ 'groups' => [
40
+ {
41
+ 'name' => 'nix',
42
+ 'targets' => hosts,
43
+ 'config' => {
44
+ 'transport' => 'ssh',
45
+ 'ssh' => {
46
+ 'connect-timeout' => 60,
47
+ 'disconnect-timeout' => 60,
48
+ 'host-key-check' => false,
49
+ 'private-key' => private_key || '~/.ssh/id_rsa',
50
+ 'run-as' => 'root',
51
+ 'tmpdir' => '/var/tmp', # /tmp is usually noexec
52
+ }.merge(config.get('bolt.transport.ssh')&.transform_keys(&:to_s) || {}),
53
+ },
54
+ },
55
+ ],
56
+ }
57
+ rescue StandardError => e
58
+ raise CemAcpt::Bolt::InventoryError.new('Error creating Bolt inventory hash', e)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'yaml_file'
5
+
6
+ module CemAcpt
7
+ module Bolt
8
+ # Provides an abstraction for the Bolt project file / config
9
+ class Project < YamlFile
10
+ attr_reader :config, :module_name
11
+
12
+ def initialize(config, module_name)
13
+ path = config.get('bolt.project.path') || 'bolt-project.yaml'
14
+ super(path)
15
+ @config = config
16
+ @module_name = module_name
17
+ @hash = new_bolt_project_hash(module_name, config)
18
+ end
19
+
20
+ def latest_saved?
21
+ # We consider the project file to be up to date if it is subset of the contents on disk
22
+ # or equal to the contents on disk
23
+ (@hash == @saved_hash) || lte_to_disk?
24
+ end
25
+
26
+ private
27
+
28
+ def new_bolt_project_hash(module_name, config)
29
+ {
30
+ 'name' => module_name,
31
+ 'analytics' => false,
32
+ }.merge(config.get('bolt.project')&.transform_keys(&:to_s) || {})
33
+ rescue StandardError => e
34
+ raise CemAcpt::Bolt::BoltProjectError.new('Error creating Bolt project hash', e)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../utils/finalizer_queue'
5
+
6
+ module CemAcpt
7
+ module Bolt
8
+ # Class that holds the results of the entire Bolt test suite.
9
+ class SummaryResults < CemAcpt::Utils::FinalizerQueue
10
+ alias all to_a
11
+
12
+ def error
13
+ require_finalized(binding)
14
+ return unless error?
15
+
16
+ @error ||= find { |r| !r.success? }
17
+ end
18
+
19
+ def error?
20
+ require_finalized(binding)
21
+ !success?
22
+ end
23
+
24
+ def success?
25
+ require_finalized(binding)
26
+ @success ||= all?(&:success?)
27
+ end
28
+
29
+ def success_count
30
+ require_finalized(binding)
31
+ @success_count ||= count(&:success?)
32
+ end
33
+
34
+ def failure_count
35
+ require_finalized(binding)
36
+ @failure_count ||= length - success_count
37
+ end
38
+
39
+ def status
40
+ require_finalized(binding)
41
+ success? ? 0 : 1
42
+ end
43
+
44
+ def status_str
45
+ require_finalized(binding)
46
+ success? ? 'passed' : 'failed'
47
+ end
48
+
49
+ def summary
50
+ require_finalized(binding)
51
+ @summary ||= [
52
+ "status: #{status_str}",
53
+ "tests total: #{length}",
54
+ "tests succeeded: #{success_count}",
55
+ "tests failed: #{failure_count}",
56
+ ].join(', ')
57
+ end
58
+
59
+ def summary?
60
+ require_finalized(binding)
61
+ true
62
+ end
63
+
64
+ def inspect
65
+ return "#<#{self.class}:#{object_id} unfinalized>" unless finalized?
66
+
67
+ "#<#{self.class}:#{object_id} #{self}>"
68
+ end
69
+
70
+ def to_h
71
+ require_finalized(binding)
72
+ { 'summary' => summary, 'status' => status, 'results' => map(&:to_h) }
73
+ end
74
+
75
+ def action
76
+ require_finalized(binding)
77
+ map { |rs| rs.results.map(&:action).uniq }.flatten.uniq
78
+ end
79
+
80
+ def results
81
+ require_finalized(binding)
82
+ @results ||= map(&:results).flatten
83
+ end
84
+
85
+ def results?
86
+ require_finalized(binding)
87
+ !results.empty?
88
+ end
89
+
90
+ def to_s
91
+ require_finalized(binding)
92
+ JSON.pretty_generate(to_h)
93
+ end
94
+ end
95
+ end
96
+ end