appom 1.3.3 → 2.0.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.
@@ -0,0 +1,490 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'erb'
5
+ require 'json'
6
+
7
+ # Configuration management for Appom automation framework
8
+ # Handles loading and parsing of YAML configuration files with environment support
9
+ module Appom::Configuration
10
+ # Configuration management with environment-specific settings
11
+ class Config
12
+ include Appom::Logging
13
+
14
+ DEFAULT_CONFIG_FILE = 'appom.yml'
15
+ DEFAULT_CONFIG_PATHS = [
16
+ './config/appom.yml',
17
+ './appom.yml',
18
+ './test/appom.yml',
19
+ './spec/appom.yml',
20
+ ].freeze
21
+
22
+ attr_reader :data, :environment, :config_file
23
+
24
+ def initialize(config_file: nil, environment: nil)
25
+ @config_file = config_file || find_config_file
26
+ @environment = environment || detect_environment
27
+ @data = {}
28
+ load_configuration
29
+ end
30
+
31
+ # Get configuration value with dot notation
32
+ #
33
+ # @param key_path [String, Symbol] The configuration key path using dot notation
34
+ # @param default [Object] Default value to return if key is not found
35
+ # @return [Object] The configuration value or default
36
+ #
37
+ # @example Get a nested value
38
+ # config.get('appom.max_wait_time', 30)
39
+ # config.get('appium.server_url')
40
+ def get(key_path, default = nil)
41
+ keys = key_path.to_s.split('.')
42
+ value = keys.reduce(@data) { |hash, key| hash&.dig(key) }
43
+ value.nil? ? default : value
44
+ end
45
+
46
+ # Set configuration value with dot notation
47
+ #
48
+ # @param key_path [String, Symbol] The configuration key path using dot notation
49
+ # @param value [Object] The value to set
50
+ # @return [Object] The value that was set
51
+ #
52
+ # @example Set a nested value
53
+ # config.set('appom.max_wait_time', 45)
54
+ # config.set('custom.setting', 'value')
55
+ def set(key_path, value)
56
+ keys = key_path.to_s.split('.')
57
+ last_key = keys.pop
58
+
59
+ target = keys.reduce(@data) do |hash, key|
60
+ hash[key] ||= {}
61
+ end
62
+
63
+ target[last_key] = value
64
+ end
65
+
66
+ # Merge configuration hash
67
+ #
68
+ # @param other_config [Hash] Configuration hash to merge
69
+ # @return [Config] Self for method chaining
70
+ #
71
+ # @example Merge additional configuration
72
+ # config.merge!('appom' => { 'log_level' => 'debug' })
73
+ def merge!(other_config)
74
+ @data = deep_merge(@data, other_config)
75
+ self
76
+ end
77
+
78
+ # Reload configuration from file
79
+ #
80
+ # @return [Config] Self for method chaining
81
+ #
82
+ # @example Reload configuration after file changes
83
+ # config.reload!
84
+ def reload!
85
+ reload_configuration
86
+ log_info("Configuration reloaded from #{@config_file}")
87
+ self
88
+ end
89
+
90
+ # Validate configuration against schema
91
+ #
92
+ # @return [Boolean] True if validation passes
93
+ # @raise [ConfigurationError] If validation fails
94
+ #
95
+ # @example Validate current configuration
96
+ # config.validate!
97
+ def validate! # rubocop:disable Naming/PredicateMethod
98
+ schema = ConfigSchema.new
99
+ errors = schema.validate(@data)
100
+
101
+ if errors.any?
102
+ raise Appom::ConfigurationError.new('validation', @data,
103
+ "Configuration validation failed: #{errors.join(', ')}",)
104
+ end
105
+
106
+ log_info('Configuration validation passed')
107
+ true
108
+ end
109
+
110
+ # Save current configuration to file
111
+ #
112
+ # @param file_path [String, nil] Optional file path to save to, uses current config file if nil
113
+ # @return [void]
114
+ #
115
+ # @example Save configuration
116
+ # config.save!
117
+ # config.save!('backup_config.yml')
118
+ def save!(file_path = nil)
119
+ target_file = file_path || @config_file
120
+
121
+ File.write(target_file, YAML.dump(@data))
122
+ log_info("Configuration saved to #{target_file}")
123
+ end
124
+
125
+ # Get all configuration as hash
126
+ #
127
+ # @return [Hash] Deep copy of configuration data
128
+ #
129
+ # @example Get configuration as hash
130
+ # config_hash = config.to_h
131
+ def to_h
132
+ @data.dup
133
+ end
134
+
135
+ # Check if configuration key exists
136
+ #
137
+ # @param key_path [String, Symbol] The configuration key path using dot notation
138
+ # @return [Boolean] True if key exists, false otherwise
139
+ #
140
+ # @example Check if key exists
141
+ # config.key?('appom.max_wait_time') # => true
142
+ # config.key?('missing.key') # => false
143
+ def key?(key_path)
144
+ !get(key_path).nil?
145
+ end
146
+
147
+ private
148
+
149
+ def find_config_file
150
+ DEFAULT_CONFIG_PATHS.find { |path| File.exist?(path) } || DEFAULT_CONFIG_FILE
151
+ end
152
+
153
+ def detect_environment
154
+ ENV['APPOM_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
155
+ end
156
+
157
+ def reload_configuration
158
+ @data = {}
159
+
160
+ if File.exist?(@config_file)
161
+ load_from_file(@config_file)
162
+ else
163
+ log_warn("Configuration file not found: #{@config_file}, using defaults")
164
+ load_defaults
165
+ end
166
+
167
+ # Override with environment variables
168
+ load_from_environment
169
+
170
+ # Apply environment-specific settings
171
+ apply_environment_settings
172
+
173
+ # Don't log the standard message here - let reload! method handle it
174
+ end
175
+
176
+ def load_configuration
177
+ @data = {}
178
+
179
+ if File.exist?(@config_file)
180
+ load_from_file(@config_file)
181
+ else
182
+ log_warn("Configuration file not found: #{@config_file}, using defaults")
183
+ load_defaults
184
+ end
185
+
186
+ # Override with environment variables
187
+ load_from_environment
188
+
189
+ # Apply environment-specific settings
190
+ apply_environment_settings
191
+
192
+ log_info("Configuration loaded for environment: #{@environment}")
193
+ end
194
+
195
+ def load_from_file(file_path)
196
+ content = File.read(file_path)
197
+ # Process ERB templates
198
+ erb_content = ERB.new(content).result
199
+ yaml_data = YAML.safe_load(erb_content, aliases: true) || {}
200
+
201
+ @data = deep_merge(@data, yaml_data)
202
+ rescue StandardError => e
203
+ log_error("Failed to load configuration from #{file_path}", { error: e.message })
204
+ load_defaults
205
+ end
206
+
207
+ def load_defaults
208
+ @data = {
209
+ 'appium' => {
210
+ 'server_url' => 'http://localhost:4723/wd/hub',
211
+ 'timeout' => 30,
212
+ 'implicit_wait' => 5,
213
+ },
214
+ 'appom' => {
215
+ 'max_wait_time' => 30,
216
+ 'log_level' => 'info',
217
+ 'screenshot' => {
218
+ 'directory' => 'screenshots',
219
+ 'format' => 'png',
220
+ 'auto_timestamp' => true,
221
+ 'on_failure' => true,
222
+ },
223
+ 'cache' => {
224
+ 'enabled' => true,
225
+ 'max_size' => 50,
226
+ 'ttl' => 30,
227
+ },
228
+ 'retry' => {
229
+ 'max_attempts' => 3,
230
+ 'base_delay' => 0.5,
231
+ 'backoff_multiplier' => 1.5,
232
+ },
233
+ },
234
+ 'capabilities' => {
235
+ 'platformName' => 'iOS',
236
+ 'deviceName' => 'iPhone Simulator',
237
+ 'automationName' => 'XCUITest',
238
+ },
239
+ }
240
+ end
241
+
242
+ def load_from_environment
243
+ # Map environment variables to configuration paths
244
+ env_mappings = {
245
+ 'APPIUM_SERVER_URL' => 'appium.server_url',
246
+ 'APPIUM_TIMEOUT' => 'appium.timeout',
247
+ 'APPOM_MAX_WAIT_TIME' => 'appom.max_wait_time',
248
+ 'APPOM_LOG_LEVEL' => 'appom.log_level',
249
+ 'APPOM_SCREENSHOT_DIR' => 'appom.screenshot.directory',
250
+ 'APPOM_CACHE_ENABLED' => 'appom.cache.enabled',
251
+ 'DEVICE_NAME' => 'capabilities.deviceName',
252
+ 'PLATFORM_NAME' => 'capabilities.platformName',
253
+ 'APP_PATH' => 'capabilities.app',
254
+ }
255
+
256
+ env_mappings.each do |env_var, config_path|
257
+ next unless ENV[env_var]
258
+
259
+ value = parse_env_value(ENV.fetch(env_var, nil))
260
+ set(config_path, value)
261
+ log_debug("Override from ENV[#{env_var}]: #{config_path} = #{value}")
262
+ end
263
+ end
264
+
265
+ def apply_environment_settings
266
+ return unless @data[@environment]
267
+
268
+ env_config = @data[@environment]
269
+ @data = deep_merge(@data, env_config)
270
+ log_debug("Applied #{@environment} environment settings")
271
+ end
272
+
273
+ def parse_env_value(value)
274
+ # Try to parse as JSON/YAML for complex values
275
+ case value.downcase
276
+ when 'true' then true
277
+ when 'false' then false
278
+ when /^\d+$/ then value.to_i
279
+ when /^\d+\.\d+$/ then value.to_f
280
+ else
281
+ # Try parsing as JSON for arrays/hashes
282
+ begin
283
+ JSON.parse(value)
284
+ rescue JSON::ParserError
285
+ value
286
+ end
287
+ end
288
+ end
289
+
290
+ def deep_merge(hash1, hash2)
291
+ hash1.merge(hash2) do |_key, old_val, new_val|
292
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
293
+ deep_merge(old_val, new_val)
294
+ else
295
+ new_val
296
+ end
297
+ end
298
+ end
299
+ end
300
+
301
+ # Configuration validation schema
302
+ class ConfigSchema
303
+ REQUIRED_KEYS = %w[appom appium].freeze
304
+
305
+ APPOM_SCHEMA = {
306
+ 'max_wait_time' => { type: Numeric, min: 1, max: 300 },
307
+ 'log_level' => { type: String, values: %w[debug info warn error fatal] },
308
+ 'screenshot.directory' => { type: String },
309
+ 'screenshot.format' => { type: String, values: %w[png jpg jpeg] },
310
+ 'screenshot.auto_timestamp' => { type: [TrueClass, FalseClass] },
311
+ 'cache.enabled' => { type: [TrueClass, FalseClass] },
312
+ 'cache.max_size' => { type: Integer, min: 1, max: 1000 },
313
+ 'cache.ttl' => { type: Numeric, min: 1, max: 3600 },
314
+ }.freeze
315
+
316
+ def validate(config_data)
317
+ errors = []
318
+
319
+ # Check required top-level keys
320
+ REQUIRED_KEYS.each do |key|
321
+ errors << "Missing required configuration section: #{key}" unless config_data.key?(key)
322
+ end
323
+
324
+ # Validate appom configuration
325
+ errors.concat(validate_appom_config(config_data['appom'])) if config_data['appom']
326
+
327
+ errors
328
+ end
329
+
330
+ private
331
+
332
+ def validate_appom_config(appom_config)
333
+ errors = []
334
+
335
+ APPOM_SCHEMA.each do |key_path, constraints|
336
+ value = get_nested_value(appom_config, key_path)
337
+ next if value.nil? && !constraints[:required]
338
+
339
+ errors.concat(validate_value(key_path, value, constraints))
340
+ end
341
+
342
+ errors
343
+ end
344
+
345
+ def validate_value(key_path, value, constraints)
346
+ errors = []
347
+
348
+ errors.concat(validate_value_type(key_path, value, constraints))
349
+ errors.concat(validate_value_constraints(key_path, value, constraints))
350
+
351
+ errors
352
+ end
353
+
354
+ def validate_value_type(key_path, value, constraints)
355
+ return [] unless constraints[:type]
356
+
357
+ valid_types = Array(constraints[:type])
358
+ return [] if valid_types.any? { |type| value.is_a?(type) }
359
+
360
+ ["#{key_path} must be of type #{valid_types.join(' or ')}, got #{value.class}"]
361
+ end
362
+
363
+ def validate_value_constraints(key_path, value, constraints)
364
+ errors = []
365
+
366
+ errors.concat(validate_allowed_values(key_path, value, constraints))
367
+ errors.concat(validate_min_value(key_path, value, constraints))
368
+ errors.concat(validate_max_value(key_path, value, constraints))
369
+
370
+ errors
371
+ end
372
+
373
+ def validate_allowed_values(key_path, value, constraints)
374
+ return [] unless constraints[:values] && !constraints[:values].include?(value)
375
+
376
+ ["#{key_path} must be one of #{constraints[:values].join(', ')}, got #{value}"]
377
+ end
378
+
379
+ def validate_min_value(key_path, value, constraints)
380
+ return [] unless constraints[:min] && value.respond_to?(:<) && value < constraints[:min]
381
+
382
+ ["#{key_path} must be at least #{constraints[:min]}, got #{value}"]
383
+ end
384
+
385
+ def validate_max_value(key_path, value, constraints)
386
+ return [] unless constraints[:max] && value.respond_to?(:>) && value > constraints[:max]
387
+
388
+ ["#{key_path} must be at most #{constraints[:max]}, got #{value}"]
389
+ end
390
+
391
+ def get_nested_value(hash, key_path)
392
+ keys = key_path.split('.')
393
+ keys.reduce(hash) { |h, key| h&.dig(key) }
394
+ end
395
+ end
396
+
397
+ # Global configuration instance
398
+ class << self
399
+ attr_writer :config
400
+
401
+ def config
402
+ @config ||= Config.new
403
+ end
404
+
405
+ # Configure Appom with block or hash
406
+ def configure(config_data = nil, &)
407
+ if block_given?
408
+ yield config
409
+ elsif config_data
410
+ config.merge!(config_data)
411
+ end
412
+
413
+ # Apply configuration to Appom
414
+ apply_to_appom
415
+ config
416
+ end
417
+
418
+ # Load configuration from file
419
+ #
420
+ # @param file_path [String] Path to configuration file
421
+ # @param environment [String, nil] Optional environment override
422
+ # @return [Config] The loaded configuration instance
423
+ #
424
+ # @example Load configuration from custom file
425
+ # Appom::Configuration.load_from_file('config/custom.yml')
426
+ # Appom::Configuration.load_from_file('config/app.yml', environment: 'staging')
427
+ def load_from_file(file_path, environment: nil)
428
+ @config = Config.new(config_file: file_path, environment: environment)
429
+ apply_to_appom
430
+ @config
431
+ end
432
+
433
+ # Apply configuration values to Appom modules
434
+ def apply_to_appom
435
+ configure_appom_wait_time
436
+ configure_appom_logging
437
+ configure_appom_caching
438
+ configure_appom_screenshots
439
+ end
440
+
441
+ # Module-level convenience methods
442
+
443
+ # Get configuration value using dot notation
444
+ #
445
+ # @param key_path [String, Symbol] The configuration key path
446
+ # @param default [Object] Default value if key not found
447
+ # @return [Object] The configuration value or default
448
+ def get(key_path, default = nil)
449
+ config.get(key_path, default)
450
+ end
451
+
452
+ # Set configuration value using dot notation
453
+ #
454
+ # @param key_path [String, Symbol] The configuration key path
455
+ # @param value [Object] The value to set
456
+ # @return [Object] The value that was set
457
+ def set(key_path, value)
458
+ config.set(key_path, value)
459
+ end
460
+
461
+ private
462
+
463
+ def configure_appom_wait_time
464
+ Appom.max_wait_time = config.get('appom.max_wait_time', 30)
465
+ end
466
+
467
+ def configure_appom_logging
468
+ log_level = config.get('appom.log_level', 'info').to_sym
469
+ Appom.configure_logging(level: log_level)
470
+ end
471
+
472
+ def configure_appom_caching
473
+ cache_config = {
474
+ enabled: config.get('appom.cache.enabled', true),
475
+ max_size: config.get('appom.cache.max_size', 50),
476
+ ttl: config.get('appom.cache.ttl', 30),
477
+ }
478
+ Appom.configure_cache(**cache_config)
479
+ end
480
+
481
+ def configure_appom_screenshots
482
+ screenshot_config = {
483
+ directory: config.get('appom.screenshot.directory', 'screenshots'),
484
+ format: config.get('appom.screenshot.format', 'png').to_sym,
485
+ auto_timestamp: config.get('appom.screenshot.auto_timestamp', true),
486
+ }
487
+ Appom::Screenshot.configure(**screenshot_config) if defined?(Appom::Screenshot)
488
+ end
489
+ end
490
+ end