cem_acpt 0.8.7 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
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