cem_acpt 0.2.5 → 0.6.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 (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 -345
  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