cem_acpt 0.2.4 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/spec.yml +30 -0
  3. data/Gemfile +4 -3
  4. data/Gemfile.lock +95 -43
  5. data/README.md +144 -83
  6. data/cem_acpt.gemspec +12 -7
  7. data/exe/cem_acpt +41 -7
  8. data/lib/cem_acpt/config.rb +340 -0
  9. data/lib/cem_acpt/core_extensions.rb +17 -61
  10. data/lib/cem_acpt/goss/api/action_response.rb +175 -0
  11. data/lib/cem_acpt/goss/api.rb +83 -0
  12. data/lib/cem_acpt/goss.rb +8 -0
  13. data/lib/cem_acpt/image_name_builder.rb +0 -9
  14. data/lib/cem_acpt/logging/formatter.rb +97 -0
  15. data/lib/cem_acpt/logging.rb +168 -142
  16. data/lib/cem_acpt/platform/base.rb +26 -37
  17. data/lib/cem_acpt/platform/gcp.rb +48 -62
  18. data/lib/cem_acpt/platform.rb +30 -28
  19. data/lib/cem_acpt/provision/terraform/linux.rb +47 -0
  20. data/lib/cem_acpt/provision/terraform/os_data.rb +72 -0
  21. data/lib/cem_acpt/provision/terraform/windows.rb +22 -0
  22. data/lib/cem_acpt/provision/terraform.rb +193 -0
  23. data/lib/cem_acpt/provision.rb +20 -0
  24. data/lib/cem_acpt/puppet_helpers.rb +0 -1
  25. data/lib/cem_acpt/test_data.rb +23 -13
  26. data/lib/cem_acpt/test_runner/log_formatter/goss_action_response.rb +104 -0
  27. data/lib/cem_acpt/test_runner/log_formatter.rb +10 -0
  28. data/lib/cem_acpt/test_runner.rb +170 -3
  29. data/lib/cem_acpt/utils/puppet.rb +29 -0
  30. data/lib/cem_acpt/utils/ssh.rb +197 -0
  31. data/lib/cem_acpt/utils/terminal.rb +27 -0
  32. data/lib/cem_acpt/utils.rb +4 -138
  33. data/lib/cem_acpt/version.rb +1 -1
  34. data/lib/cem_acpt.rb +73 -23
  35. data/lib/terraform/gcp/linux/goss/puppet_idempotent.yaml +10 -0
  36. data/lib/terraform/gcp/linux/goss/puppet_noop.yaml +12 -0
  37. data/lib/terraform/gcp/linux/main.tf +191 -0
  38. data/lib/terraform/gcp/linux/systemd/goss-acpt.service +8 -0
  39. data/lib/terraform/gcp/linux/systemd/goss-idempotent.service +8 -0
  40. data/lib/terraform/gcp/linux/systemd/goss-noop.service +8 -0
  41. data/lib/terraform/gcp/windows/.keep +0 -0
  42. data/sample_config.yaml +22 -21
  43. metadata +151 -51
  44. data/lib/cem_acpt/bootstrap/bootstrapper.rb +0 -206
  45. data/lib/cem_acpt/bootstrap/operating_system/rhel_family.rb +0 -129
  46. data/lib/cem_acpt/bootstrap/operating_system.rb +0 -17
  47. data/lib/cem_acpt/bootstrap.rb +0 -12
  48. data/lib/cem_acpt/context.rb +0 -153
  49. data/lib/cem_acpt/platform/base/cmd.rb +0 -71
  50. data/lib/cem_acpt/platform/gcp/cmd.rb +0 -353
  51. data/lib/cem_acpt/platform/gcp/compute.rb +0 -332
  52. data/lib/cem_acpt/platform/vmpooler.rb +0 -24
  53. data/lib/cem_acpt/rspec_utils.rb +0 -242
  54. data/lib/cem_acpt/shared_objects.rb +0 -537
  55. data/lib/cem_acpt/spec_helper_acceptance.rb +0 -184
  56. data/lib/cem_acpt/test_runner/run_handler.rb +0 -187
  57. data/lib/cem_acpt/test_runner/runner.rb +0 -210
  58. data/lib/cem_acpt/test_runner/runner_result.rb +0 -103
data/exe/cem_acpt CHANGED
@@ -11,6 +11,25 @@ options = {}
11
11
  parser = OptionParser.new do |opts|
12
12
  opts.banner = 'Usage: cem_acpt [options]'
13
13
 
14
+ opts.on('-h', '--help', 'Show this help message') do
15
+ puts opts
16
+ exit 0
17
+ end
18
+
19
+ opts.on('-a', '--only-actions ACTIONS', 'Set actions. Example: -a "acpt,noop"') do |a|
20
+ options[:actions] ||= {}
21
+ options[:actions][:only] = a.split(',')
22
+ end
23
+
24
+ opts.on('-A', '--except-actions ACTIONS', 'Set excluded actions. Example: -A "noop,idempotent"') do |a|
25
+ options[:actions] ||= {}
26
+ options[:actions][:except] = a.split(',')
27
+ end
28
+
29
+ opts.on('-t', '--tests TESTS', 'Set tests. Example: -t "test1,test2"') do |t|
30
+ options[:tests] = t.split(',')
31
+ end
32
+
14
33
  opts.on('-D', '--debug', 'Enable debug logging') do
15
34
  options[:log_level] = 'debug'
16
35
  end
@@ -39,7 +58,7 @@ parser = OptionParser.new do |opts|
39
58
  end
40
59
 
41
60
  opts.on('-I', '--CI', 'Run in CI mode') do
42
- options[:CI] = true
61
+ options[:ci_mode] = true
43
62
  options[:log_format] = 'github_action'
44
63
  end
45
64
 
@@ -55,12 +74,21 @@ parser = OptionParser.new do |opts|
55
74
  options[:verbose] = true
56
75
  end
57
76
 
77
+ opts.on('-S', '--no-epehemeral-ssh-key', 'Do not generate an ephemeral SSH key for test suites') do
78
+ options[:no_ephemeral_ssh_key] = true
79
+ end
80
+
58
81
  opts.on('-V', '--version', 'Show the cem_acpt version') do
59
- options[:show_version] = true
82
+ puts CemAcpt.version(as_str: true)
83
+ exit 0
60
84
  end
61
85
 
62
- opts.on('-S', '--no-epehemeral-ssh-key', 'Do not generate an ephemeral SSH key for test suites') do
63
- options[:no_ephemeral_ssh_key] = true
86
+ opts.on('-Y', '--print-yaml-config', 'Loads and prints the config as YAML. Other specified options will be added to the config.') do
87
+ options[:print_yaml_config] = true
88
+ end
89
+
90
+ opts.on('-X', '--explain-config', 'Loads and prints the config explanation. Other specified options will be added to the config.') do
91
+ options[:explain_config] = true
64
92
  end
65
93
 
66
94
  # NOT IMPLEMENTED
@@ -70,12 +98,18 @@ parser = OptionParser.new do |opts|
70
98
  end
71
99
 
72
100
  parser.parse!
73
- if options[:show_version]
74
- puts CemAcpt::VERSION
101
+ if options[:print_yaml_config]
102
+ options.delete(:print_yaml_config)
103
+ puts CemAcpt.print_config(options, format: :yaml)
104
+ exit 0
105
+ end
106
+ if options[:explain_config]
107
+ options.delete(:explain_config)
108
+ puts CemAcpt.print_config(options, format: :explain)
75
109
  exit 0
76
110
  end
111
+ # Set CLI defaults
77
112
  options[:module_dir] = Dir.pwd unless options[:module_dir]
78
- options[:platforms] = %w[gcp vmpooler] unless options[:platform]
79
113
  if (options[:log_level] == 'debug' || options[:verbose]) && !options[:quiet]
80
114
  puts '#################### RUNNING ACCEPTANCE TEST SUITE ####################'
81
115
  puts "Using options from command line: #{options}"
@@ -0,0 +1,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+ require_relative 'core_extensions'
6
+
7
+ module CemAcpt
8
+ using CemAcpt::CoreExtensions::ExtendedHash
9
+
10
+ # Holds the configuration for cem_acpt
11
+ class Config
12
+ KEYS = %i[
13
+ actions
14
+ ci_mode
15
+ config_file
16
+ image_name_builder
17
+ log_level
18
+ log_file
19
+ log_format
20
+ module_dir
21
+ node_data
22
+ no_ephemeral_ssh_key
23
+ platform
24
+ provisioner
25
+ quiet
26
+ terraform
27
+ test_data
28
+ tests
29
+ user_config
30
+ verbose
31
+ ].freeze
32
+
33
+ attr_reader :config, :env_vars
34
+
35
+ def initialize(opts: {}, config_file: nil, load_user_config: true)
36
+ @load_user_config = load_user_config
37
+ load(opts: opts, config_file: config_file)
38
+ end
39
+
40
+ # The default configuration
41
+ def defaults
42
+ {
43
+ actions: {},
44
+ ci_mode: false,
45
+ config_file: nil,
46
+ image_name_builder: {
47
+ character_substitutions: ['_', '-'],
48
+ parts: ['cem-acpt', '$image_fam', '$collection', '$firewall'],
49
+ join_with: '-',
50
+ },
51
+ log_level: 'info',
52
+ log_file: nil,
53
+ log_format: 'text',
54
+ module_dir: Dir.pwd,
55
+ node_data: {},
56
+ no_ephemeral_ssh_key: false,
57
+ platform: {
58
+ name: 'gcp',
59
+ },
60
+ quiet: false,
61
+ test_data: {
62
+ for_each: {
63
+ collection: %w[puppet7],
64
+ },
65
+ vars: {},
66
+ name_pattern_vars: %r{^(?<framework>[a-z]+)_(?<image_fam>[a-z0-9-]+)_(?<firewall>[a-z]+)_(?<framework_vars>[-_a-z0-9]+)$},
67
+ vars_post_processing: {
68
+ new_vars: [
69
+ {
70
+ name: 'profile',
71
+ string_split: {
72
+ from: 'framework_vars',
73
+ using: '_',
74
+ part: 0,
75
+ },
76
+ },
77
+ {
78
+ name: 'level',
79
+ string_split: {
80
+ from: 'framework_vars',
81
+ using: '_',
82
+ part: 1,
83
+ },
84
+ },
85
+ ],
86
+ delete_vars: %w[framework_vars],
87
+ },
88
+ },
89
+ tests: [],
90
+ verbose: false,
91
+ }
92
+ end
93
+
94
+ # Load the configuration from the environment variables, config file, and opts
95
+ # The order of precedence is:
96
+ # 1. environment variables
97
+ # 2. user config file (config.yaml in user_config_dir)
98
+ # 3. specified config file (if it exists)
99
+ # 4. opts
100
+ # 5. static options (set in this class)
101
+ # @param opts [Hash] The options to load
102
+ # @param config_file [String] The config file to load
103
+ # @return [self] This object with the config loaded
104
+ def load(opts: {}, config_file: nil)
105
+ create_config_dirs!
106
+ init_config!(opts: opts, config_file: config_file)
107
+ add_env_vars!(@config)
108
+ @config.merge!(user_config) if user_config && @load_user_config
109
+ @config.merge!(config_from_file) if config_from_file
110
+ @config.merge!(@options) if @options
111
+ add_static_options!(@config)
112
+ @config.format! # Symbolize keys of all hashes
113
+ validate_config!
114
+ # Freeze the config so it can't be modified
115
+ # This helps with thread safety and deterministic behavior
116
+ @config.freeze
117
+ self
118
+ end
119
+ alias to_h config
120
+
121
+ def user_config_dir
122
+ @user_config_dir ||= @config.dget('user_config.dir')
123
+ end
124
+
125
+ def explain
126
+ explanation = {}
127
+ %i[defaults env_vars user_config config_from_file options].each do |source|
128
+ source_vals = send(source).dup
129
+ next if source_vals.nil? || source_vals.empty?
130
+
131
+ # The loop below will overwrite the value of explanation[key] if the same key is found in multiple sources
132
+ # This is intentional, as the last source to set the value is the one that should be used
133
+ source_vals.each do |key, value|
134
+ explanation[key] = source if @config.dget(key.to_s) == value
135
+ end
136
+ end
137
+ explained = explanation.each_with_object([]) do |(key, value), ary|
138
+ ary << "Key '#{key}' from source '#{value}'"
139
+ end
140
+ explained.join("\n")
141
+ end
142
+
143
+ def [](key)
144
+ if key.is_a?(Symbol)
145
+ @config[key].dup
146
+ elsif key.is_a?(String)
147
+ @config.dget(key).dup
148
+ else
149
+ raise ArgumentError, "Invalid key type '#{key.class}'"
150
+ end
151
+ end
152
+
153
+ def get(dot_key)
154
+ @config.dget(dot_key).dup
155
+ end
156
+ alias dget get
157
+
158
+ def has?(dot_key)
159
+ !!get(dot_key)
160
+ end
161
+
162
+ def empty?
163
+ @config.empty?
164
+ end
165
+
166
+ def ci_mode?
167
+ !!get('ci_mode') || !!(ENV['GITHUB_ACTIONS'] || ENV['CI'])
168
+ end
169
+ alias ci? ci_mode?
170
+
171
+ def debug_mode?
172
+ get('log_level') == 'debug'
173
+ end
174
+ alias debug? debug_mode?
175
+
176
+ def verbose_mode?
177
+ !!get('verbose')
178
+ end
179
+ alias verbose? verbose_mode?
180
+
181
+ def quiet_mode?
182
+ !!get('quiet')
183
+ end
184
+ alias quiet? quiet_mode?
185
+
186
+ def to_yaml
187
+ @config.to_yaml
188
+ end
189
+
190
+ def to_json(*args)
191
+ @config.to_json(*args)
192
+ end
193
+
194
+ private
195
+
196
+ attr_reader :options
197
+
198
+ def user_config_dir
199
+ @user_config_dir ||= File.join(Dir.home, '.cem_acpt')
200
+ end
201
+
202
+ def user_config_file
203
+ @user_config_file ||= File.join(user_config_dir, 'config.yaml')
204
+ end
205
+
206
+ def terraform_dir
207
+ @terraform_dir ||= File.join(user_config_dir, 'terraform')
208
+ end
209
+
210
+ def valid_env_var?(env_var)
211
+ env_var.start_with?('CEM_ACPT_') && ENV[env_var]
212
+ end
213
+
214
+ def env_var_to_dot_key(env_var)
215
+ env_var.sub('CEM_ACPT_', '').gsub(%r{__}, '.').downcase
216
+ end
217
+
218
+ def add_static_options!(config)
219
+ config.dset('user_config.dir', user_config_dir)
220
+ config.dset('user_config.file', user_config_file)
221
+ config.dset('provisioner', 'terraform')
222
+ config.dset('terraform.dir', terraform_dir)
223
+ set_third_party_env_vars!(config)
224
+ end
225
+
226
+ # Certain environment variables are used by other tools like GitHub Actions
227
+ # This method sets the relative config values for those environment variables.
228
+ # This is the last step in composing the config, so it will override any
229
+ # values set by the user.
230
+ def set_third_party_env_vars!(config)
231
+ if ENV['RUNNER_DEBUG'] == '1'
232
+ config.dset('log_level', 'debug')
233
+ config.dset('verbose', true)
234
+ end
235
+ if ENV['GITHUB_ACTIONS'] == 'true' || ENV['CI'] == 'true'
236
+ config.dset('ci_mode', true)
237
+ end
238
+ end
239
+
240
+ # Used to source the config during loading of config files.
241
+ # Because config may not be fully loaded yet, this method
242
+ # checks the options hash first, then the current config,
243
+ # then the environment variables for the given key
244
+ # @param key [String] The key to find in dot notation
245
+ # @return [Any] The value of the key
246
+ def find_option(key)
247
+ return @options.dget(key) if @options.dget(key)
248
+ return @config.dget(key) if @config.dget(key)
249
+ ENV.each do |k, v|
250
+ next unless valid_env_var?(k)
251
+
252
+ return v if env_var_to_dot_key(k) == key
253
+ end
254
+ nil
255
+ end
256
+
257
+ def init_config!(opts: {}, config_file: nil)
258
+ # Blank out the config
259
+ @user_config = {}
260
+ @config_from_file = {}
261
+ @options = {}
262
+ @config = defaults.dup
263
+ # Set the parameterized defaults
264
+ config_file = ENV['CEM_ACPT_CONFIG_FILE'] if config_file.nil?
265
+ @config.dset('config_file', config_file) if config_file
266
+ @options = opts || {}
267
+ end
268
+
269
+ def add_env_vars!(config)
270
+ @env_vars = {}
271
+ # First load known environment variables into their respective config keys
272
+ # Then load any environment variables that start with CEM_ACPT_<known key> into their respective config keys
273
+ ENV.each do |env_var, value|
274
+ next unless valid_env_var?(env_var)
275
+
276
+ key = env_var_to_dot_key(env_var) # Convert CEM_ACPT_<key> to <dotkey>
277
+ next unless KEYS.include?(key.split('.').first.to_sym) # Skip if the key is not a known config key
278
+ @env_vars[key] = value
279
+ config.dset(key, value)
280
+ end
281
+ end
282
+
283
+ def user_config
284
+ return @user_config unless @user_config.nil? || @user_config.empty?
285
+
286
+ @user_config = if user_config_file && File.exist?(user_config_file)
287
+ load_config_file(user_config_file)
288
+ else
289
+ {}
290
+ end
291
+
292
+ @user_config
293
+ end
294
+
295
+ def config_from_file
296
+ return @config_from_file unless @config_from_file.nil? || @config_from_file.empty?
297
+
298
+ conf_file = find_option('config_file')
299
+ return {} if conf_file.nil? || conf_file.empty?
300
+ unless conf_file
301
+ warn "Invalid config_file type '#{conf_file.class}'. Must be a String."
302
+ return {}
303
+ end
304
+
305
+ mod_dir = find_option('module_dir')
306
+ if mod_dir && File.exist?(File.join(mod_dir, conf_file))
307
+ @config_from_file = load_config_file(File.join(mod_dir, conf_file))
308
+ elsif File.exist?(File.expand_path(conf_file))
309
+ @config_from_file = load_config_file(File.expand_path(conf_file))
310
+ else
311
+ err_msg = [
312
+ "Config file '#{File.expand_path(conf_file)}' does not exist.",
313
+ ]
314
+ err_msg << "Config file '#{File.join(mod_dir, conf_file)}' does not exist." if mod_dir
315
+ raise err_msg.join("\n")
316
+ end
317
+
318
+ @config_from_file
319
+ end
320
+
321
+ def load_config_file(config_file)
322
+ return {} if config_file.nil? || config_file.empty? || !File.exist?(File.expand_path(config_file))
323
+
324
+ loaded = YAML.safe_load_file(File.expand_path(config_file), permitted_classes: [Regexp])
325
+ loaded.format!
326
+ loaded
327
+ end
328
+
329
+ def validate_config!
330
+ @config.each do |key, _value|
331
+ warn "Unknown config key: #{key}" unless KEYS.include?(key)
332
+ end
333
+ end
334
+
335
+ def create_config_dirs!
336
+ FileUtils.mkdir_p(user_config_dir) unless Dir.exist?(user_config_dir)
337
+ FileUtils.cp_r(File.expand_path(File.join(__dir__, '..', 'terraform')), user_config_dir)
338
+ end
339
+ end
340
+ end
@@ -1,66 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This module holds extensions to Ruby and the Ruby stdlib
4
- # Extensions related to deep_freeze were pulled from: https://gist.github.com/steakknife/1a37057b3b8539f4aca3
3
+ # This module holds extensions and refinements to Ruby and the Ruby stdlib
5
4
  module CemAcpt::CoreExtensions
6
- # DeepFreeze recursively freezes all keys and values in a hash
7
- # Currently unused, but was used at one point and may be useful again
8
- module DeepFreeze
9
- # Holds deep_freeze extensions to Kernel
10
- module Kernel
11
- alias deep_freeze freeze
12
- alias deep_frozen? frozen?
13
- end
14
-
15
- # Holds deep_freeze extensions to Enumerable
16
- module Enumerable
17
- def deep_freeze
18
- unless @deep_frozen
19
- each(&:deep_freeze)
20
- @deep_frozen = true
21
- end
22
- freeze
23
- end
24
-
25
- def deep_frozen?
26
- !!@deep_frozen
27
- end
28
- end
29
-
30
- # Holds deep_freeze extensions to Hash
31
- module Hash
32
- def deep_freeze
33
- transform_values! do |value|
34
- value.respond_to?(:deep_freeze) ? value.deep_freeze : value.freeze
35
- end
36
- freeze
37
- @deep_frozen = true
38
- end
39
-
40
- def deep_frozen?
41
- !!@deep_frozen
42
- end
43
- end
44
-
45
- # Holds deep_freeze extensions to OpenStruct
46
- module OpenStruct
47
- def deep_freeze
48
- unless deep_frozen?
49
- @table.reduce({}) do |h, (key, value)|
50
- fkey = key.respond_to?(:deep_freeze) ? key.deep_freeze : key
51
- fval = value.respond_to?(:deep_freeze) ? value.deep_freeze : value
52
- h.merge(fkey => fval)
53
- end.freeze
54
- @deep_frozen = true
55
- end
56
- end
57
-
58
- def deep_frozen?
59
- !!@deep_frozen
60
- end
61
- end
62
- end
63
-
64
5
  # Refines the Hash class with some convenience methods.
65
6
  # Must call `using CemAcpt::CoreExtensions::HashExtensions`
66
7
  # before these methods will be available.
@@ -83,6 +24,7 @@ module CemAcpt::CoreExtensions
83
24
  def has?(path)
84
25
  !!dot_dig(path)
85
26
  end
27
+ alias dhas? has?
86
28
 
87
29
  # Digs into a Hash using a dot-separated path.
88
30
  # If the path is not found, returns nil.
@@ -91,7 +33,16 @@ module CemAcpt::CoreExtensions
91
33
  # hash.dot_dig('a.b.c') # => 1
92
34
  def dot_dig(path)
93
35
  dig(*path.split('.').map(&:to_sym)) || dig(*path.split('.'))
36
+ rescue TypeError
37
+ # TypeError is raised if parts of the path don't have the #dig method
38
+ # This can happen if you do something like:
39
+ # hash = {a: {b: {c: 1}}}
40
+ # hash.dot_dig('a.b.c.d')
41
+ # The integer 1 doesn't have the #dig method, so we get a TypeError
42
+ # Since this means the path is invalid, we return nil
43
+ nil
94
44
  end
45
+ alias dget dot_dig
95
46
 
96
47
  # Stores a value in a nested Hash using a dot-separated path
97
48
  # to dig through keys.
@@ -99,10 +50,15 @@ module CemAcpt::CoreExtensions
99
50
  # hash = {a: {b: {c: 1}}}
100
51
  # hash.dot_store('a.b.c', 2)
101
52
  # hash #=> {a: {b: {c: 2}}}
53
+ # hash.dot_store('a.b.d', 3)
54
+ # hash #=> {a: {b: {c: 2, d: 3}}}
102
55
  def dot_store(path, value)
103
56
  *key, last = path.split('.').map(&:to_sym)
104
- key.inject(self, :fetch)[last] = value
57
+ key.inject(self) do |memo, k|
58
+ memo[k] ||= {}
59
+ end[last] = value
105
60
  end
61
+ alias dset dot_store
106
62
  end
107
63
  end
108
64
  end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CemAcpt
4
+ module Goss
5
+ module Api
6
+ class ActionResponse
7
+ attr_reader :host, :action, :body
8
+
9
+ def initialize(host, action, status, body)
10
+ @host = host
11
+ @action = action
12
+ @status = status
13
+ @body = body
14
+ end
15
+
16
+ def to_s
17
+ "#<#{self.class.name}:0x#{object_id.to_s(16)}>"
18
+ end
19
+
20
+ def inspect
21
+ to_s
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ host: host,
27
+ action: action,
28
+ status: @status,
29
+ body: @body,
30
+ }
31
+ end
32
+
33
+ def status
34
+ @status.to_i
35
+ end
36
+ alias http_status status
37
+
38
+ def success?
39
+ status == 200
40
+ end
41
+
42
+ def results
43
+ @results ||= @body['results'].map { |r| ActionResponseResult.new(r) }
44
+ end
45
+
46
+ def results?
47
+ !results.nil? && !results.empty?
48
+ end
49
+
50
+ def summary
51
+ @summary ||= ActionResponseSummary.new(@body['summary'])
52
+ end
53
+
54
+ def summary?
55
+ !summary.nil? && !summary.empty?
56
+ end
57
+ end
58
+
59
+ module DurationHandler
60
+ DURATION_UNITS = %i[nanoseconds milliseconds seconds].freeze
61
+
62
+ Duration = Struct.new(:duration, :unit, :round) do
63
+ def to_f
64
+ return @to_f if defined?(@to_f)
65
+ return 0.0 if duration.nil?
66
+
67
+ case unit
68
+ when :nanoseconds
69
+ @to_f = duration.to_f.round(round)
70
+ when :milliseconds
71
+ @to_f = (duration.to_f / 1_000_000).round(round)
72
+ when :seconds
73
+ @to_f = (duration.to_f / 1_000_000_000).round(round)
74
+ else
75
+ raise ArgumentError, "Invalid unit #{unit}, must be one of #{DURATION_UNITS}"
76
+ end
77
+ @to_f
78
+ end
79
+
80
+ def to_s
81
+ return @to_s if defined?(@to_s)
82
+
83
+ case unit
84
+ when :nanoseconds
85
+ @to_s = "#{to_f}ns"
86
+ when :milliseconds
87
+ @to_s = "#{to_f}ms"
88
+ when :seconds
89
+ @to_s = "#{to_f}s"
90
+ else
91
+ raise ArgumentError, "Invalid unit #{unit}, must be one of #{DURATION_UNITS}"
92
+ end
93
+ @to_s
94
+ end
95
+ end
96
+
97
+ # @param unit [Symbol] The unit to return the duration in
98
+ # @param round [Integer] The number of decimal places to round to
99
+ # @return [Duration] The Duration object
100
+ def duration(unit: :seconds, round: 3)
101
+ @all_durations ||= {}
102
+ @all_durations[unit] ||= {}
103
+ @all_durations[unit][round] ||= Duration.new(@duration, unit, round)
104
+ end
105
+ end
106
+
107
+ class ActionResponseSummary
108
+ include DurationHandler
109
+
110
+ attr_reader :failed_count, :summary_line, :test_count
111
+
112
+ def initialize(summary)
113
+ @summary = summary
114
+ @duration = @summary['total-duration']
115
+ @failed_count = @summary['failed-count']
116
+ @summary_line = @summary['summary-line']
117
+ @test_count = @summary['test-count']
118
+ end
119
+ alias to_s summary_line
120
+ alias total_duration duration
121
+
122
+ def to_h
123
+ @summary
124
+ end
125
+
126
+ def failed_percentage
127
+ @failed_percentage ||= (test_count.zero? ? 0.00 : (failed_count.to_f / test_count.to_f) * 100).round(2)
128
+ end
129
+
130
+ def passed_count
131
+ @passed_count ||= test_count - failed_count
132
+ end
133
+ end
134
+
135
+ class ActionResponseResult
136
+ include DurationHandler
137
+
138
+ attr_reader(:duration, :err, :expected, :found, :human, :meta, :property,
139
+ :resource_id, :resource_type, :result, :skipped, :successful,
140
+ :summary_line, :test_type, :title)
141
+
142
+ def initialize(raw_result)
143
+ @raw_result = raw_result
144
+ @duration = @raw_result['duration']
145
+ @err = @raw_result['err']
146
+ @expected = @raw_result['expected']
147
+ @found = @raw_result['found']
148
+ @human = @raw_result['human']
149
+ @meta = @raw_result['meta']
150
+ @property = @raw_result['property']
151
+ @resource_id = @raw_result['resource-id']
152
+ @resource_type = @raw_result['resource-type']
153
+ @result = @raw_result['result']
154
+ @skipped = @raw_result['skipped']
155
+ @successful = @raw_result['successful']
156
+ @summary_line = @raw_result['summary-line']
157
+ @test_type = @raw_result['test-type']
158
+ @title = @raw_result['title']
159
+ end
160
+ alias error err
161
+ alias to_s summary_line
162
+ alias skipped? skipped
163
+ alias success? successful
164
+
165
+ def to_h
166
+ @raw_result
167
+ end
168
+
169
+ def error?
170
+ !err.nil?
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end