appom 1.4.0 → 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,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Performance monitoring for Appom automation framework
4
+ # Tracks and analyzes performance metrics for test execution
5
+ module Appom::Performance
6
+ # Performance monitoring and metrics collection
7
+ class Monitor
8
+ include Appom::Logging
9
+
10
+ attr_reader :metrics, :started_at
11
+
12
+ def initialize
13
+ @metrics = {}
14
+ @started_at = Time.now
15
+ @current_operations = {}
16
+ reset_session_metrics
17
+ end
18
+
19
+ # Start timing an operation
20
+ def start_timing(operation_name, context = {})
21
+ operation_id = generate_operation_id
22
+ @current_operations[operation_id] = {
23
+ name: operation_name,
24
+ start_time: Time.now,
25
+ context: context,
26
+ }
27
+
28
+ log_debug("Started timing: #{operation_name}", context)
29
+ operation_id
30
+ end
31
+
32
+ # End timing an operation
33
+ def end_timing(operation_id, success: true, additional_context: {})
34
+ operation = @current_operations.delete(operation_id)
35
+ return unless operation
36
+
37
+ duration = Time.now - operation[:start_time]
38
+
39
+ record_metric(
40
+ operation[:name],
41
+ duration,
42
+ success: success,
43
+ context: operation[:context].merge(additional_context),
44
+ )
45
+
46
+ log_debug("Completed timing: #{operation[:name]} (#{(duration * 1000).round(2)}ms)")
47
+ duration
48
+ end
49
+
50
+ # Time a block of code
51
+ def time_operation(operation_name, context = {})
52
+ operation_id = start_timing(operation_name, context)
53
+ Time.now
54
+ success = true
55
+ result = nil
56
+
57
+ begin
58
+ result = yield
59
+ rescue StandardError => e
60
+ success = false
61
+ raise e
62
+ ensure
63
+ end_timing(operation_id, success: success, additional_context: {
64
+ exception: success ? nil : e&.class&.name,
65
+ },)
66
+ end
67
+
68
+ result
69
+ end
70
+
71
+ # Record a metric manually
72
+ def record_metric(name, duration, success: true, context: {})
73
+ @metrics[name] ||= initialize_metric(name)
74
+ metric = @metrics[name]
75
+
76
+ metric[:total_calls] += 1
77
+ metric[:total_duration] += duration
78
+ metric[:successful_calls] += 1 if success
79
+ metric[:failed_calls] += 1 unless success
80
+
81
+ # Update min/max
82
+ metric[:min_duration] = [metric[:min_duration], duration].min
83
+ metric[:max_duration] = [metric[:max_duration], duration].max
84
+
85
+ # Calculate rolling averages (last 10 calls)
86
+ metric[:recent_durations] << duration
87
+ metric[:recent_durations] = metric[:recent_durations].last(10)
88
+
89
+ # Store context for analysis
90
+ metric[:contexts] << context.merge(success: success, duration: duration)
91
+ metric[:contexts] = metric[:contexts].last(50) # Keep last 50 contexts
92
+
93
+ # Update percentiles for larger samples
94
+ return unless (metric[:total_calls] % 10).zero?
95
+
96
+ update_percentiles(metric)
97
+ end
98
+
99
+ # Get performance statistics
100
+ def stats(operation_name = nil)
101
+ if operation_name
102
+ calculate_stats(@metrics[operation_name]) if @metrics[operation_name]
103
+ else
104
+ @metrics.transform_values { |metric| calculate_stats(metric) }
105
+ end
106
+ end
107
+
108
+ # Get performance summary
109
+ def summary
110
+ total_operations = @metrics.values.sum { |m| m[:total_calls] }
111
+ total_duration = @metrics.values.sum { |m| m[:total_duration] }
112
+
113
+ {
114
+ session_duration: Time.now - @started_at,
115
+ total_operations: total_operations,
116
+ total_duration: total_duration,
117
+ average_operation_time: total_operations.positive? ? total_duration / total_operations : 0,
118
+ operations_per_second: total_operations / (Time.now - @started_at),
119
+ slowest_operations: slowest_operations(5),
120
+ most_frequent_operations: most_frequent_operations(5),
121
+ success_rate: calculate_overall_success_rate,
122
+ }
123
+ end
124
+
125
+ # Get slowest operations
126
+ def slowest_operations(limit = 10)
127
+ operations = @metrics.map do |name, metric|
128
+ {
129
+ name: name,
130
+ max_duration: metric[:max_duration],
131
+ avg_duration: metric[:total_duration] / metric[:total_calls],
132
+ total_calls: metric[:total_calls],
133
+ }
134
+ end
135
+ operations.sort_by { |op| -op[:max_duration] }.first(limit)
136
+ end
137
+
138
+ # Get most frequent operations
139
+ def most_frequent_operations(limit = 10)
140
+ operations = @metrics.map do |name, metric|
141
+ {
142
+ name: name,
143
+ total_calls: metric[:total_calls],
144
+ avg_duration: metric[:total_duration] / metric[:total_calls],
145
+ success_rate: (metric[:successful_calls].to_f / metric[:total_calls] * 100).round(2),
146
+ }
147
+ end
148
+ operations.sort_by { |op| -op[:total_calls] }.first(limit)
149
+ end
150
+
151
+ # Export metrics to file
152
+ def export_metrics(format: :json, file_path: nil)
153
+ file_path ||= "appom_metrics_#{Time.now.strftime('%Y%m%d_%H%M%S')}.#{format}"
154
+
155
+ data = {
156
+ exported_at: Time.now,
157
+ session_started: @started_at,
158
+ summary: summary,
159
+ detailed_metrics: stats,
160
+ }
161
+
162
+ case format
163
+ when :json
164
+ File.write(file_path, JSON.pretty_generate(data))
165
+ when :yaml
166
+ File.write(file_path, YAML.dump(data))
167
+ when :csv
168
+ export_to_csv(file_path, data)
169
+ else
170
+ raise ArgumentError, "Unsupported format: #{format}"
171
+ end
172
+
173
+ log_info("Performance metrics exported to #{file_path}")
174
+ file_path
175
+ end
176
+
177
+ # Reset all metrics
178
+ def reset!
179
+ @metrics.clear
180
+ @started_at = Time.now
181
+ @current_operations.clear
182
+ reset_session_metrics
183
+ log_info('Performance metrics reset')
184
+ end
185
+
186
+ # Check for performance regressions
187
+ def check_regressions(baseline_file, threshold_percent = 20)
188
+ return {} unless File.exist?(baseline_file)
189
+
190
+ baseline = load_baseline(baseline_file)
191
+ regressions = {}
192
+
193
+ @metrics.each do |name, current_metric|
194
+ baseline_metric = baseline[name]
195
+ next unless baseline_metric
196
+
197
+ current_avg = current_metric[:total_duration] / current_metric[:total_calls]
198
+ baseline_avg = baseline_metric['avg_duration'] || baseline_metric[:avg_duration]
199
+
200
+ next unless current_avg > baseline_avg * (1 + (threshold_percent / 100.0))
201
+
202
+ regression_percent = ((current_avg - baseline_avg) / baseline_avg * 100).round(2)
203
+ regressions[name] = {
204
+ current_avg: current_avg,
205
+ baseline_avg: baseline_avg,
206
+ regression_percent: regression_percent,
207
+ }
208
+ end
209
+
210
+ regressions
211
+ end
212
+
213
+ private
214
+
215
+ def reset_session_metrics
216
+ @session_metrics = {
217
+ element_finds: 0,
218
+ wait_operations: 0,
219
+ interactions: 0,
220
+ screenshots: 0,
221
+ }
222
+ end
223
+
224
+ def generate_operation_id
225
+ "#{Time.now.to_f}_#{rand(1000)}"
226
+ end
227
+
228
+ def initialize_metric(name)
229
+ {
230
+ name: name,
231
+ total_calls: 0,
232
+ successful_calls: 0,
233
+ failed_calls: 0,
234
+ total_duration: 0.0,
235
+ min_duration: Float::INFINITY,
236
+ max_duration: 0.0,
237
+ recent_durations: [],
238
+ contexts: [],
239
+ percentiles: {},
240
+ }
241
+ end
242
+
243
+ def calculate_stats(metric)
244
+ return {} unless metric && metric[:total_calls].positive?
245
+
246
+ {
247
+ name: metric[:name],
248
+ total_calls: metric[:total_calls],
249
+ successful_calls: metric[:successful_calls],
250
+ failed_calls: metric[:failed_calls],
251
+ success_rate: (metric[:successful_calls].to_f / metric[:total_calls] * 100).round(2),
252
+ total_duration: metric[:total_duration],
253
+ avg_duration: metric[:total_duration] / metric[:total_calls],
254
+ min_duration: metric[:min_duration] == Float::INFINITY ? 0 : metric[:min_duration],
255
+ max_duration: metric[:max_duration],
256
+ recent_avg: metric[:recent_durations].empty? ? 0 : metric[:recent_durations].sum / metric[:recent_durations].size,
257
+ percentiles: metric[:percentiles],
258
+ }
259
+ end
260
+
261
+ def update_percentiles(metric)
262
+ all_durations = metric[:contexts].map { |c| c[:duration] }.sort
263
+ return if all_durations.empty?
264
+
265
+ metric[:percentiles] = {
266
+ p50: percentile(all_durations, 50),
267
+ p75: percentile(all_durations, 75),
268
+ p90: percentile(all_durations, 90),
269
+ p95: percentile(all_durations, 95),
270
+ p99: percentile(all_durations, 99),
271
+ }
272
+ end
273
+
274
+ def percentile(sorted_array, percentile)
275
+ return 0 if sorted_array.empty?
276
+
277
+ index = (percentile / 100.0 * (sorted_array.length - 1)).round
278
+ sorted_array[index]
279
+ end
280
+
281
+ def calculate_overall_success_rate
282
+ total_calls = @metrics.values.sum { |m| m[:total_calls] }
283
+ return 100.0 if total_calls.zero?
284
+
285
+ successful_calls = @metrics.values.sum { |m| m[:successful_calls] }
286
+ (successful_calls.to_f / total_calls * 100).round(2)
287
+ end
288
+
289
+ def export_to_csv(file_path, data)
290
+ require 'csv'
291
+
292
+ CSV.open(file_path, 'w') do |csv|
293
+ # Headers
294
+ csv << ['Operation', 'Total Calls', 'Success Rate', 'Avg Duration', 'Min Duration', 'Max Duration']
295
+
296
+ # Data rows
297
+ data[:detailed_metrics].each do |name, stats|
298
+ csv << [
299
+ name,
300
+ stats[:total_calls],
301
+ stats[:success_rate],
302
+ stats[:avg_duration],
303
+ stats[:min_duration],
304
+ stats[:max_duration],
305
+ ]
306
+ end
307
+ end
308
+ end
309
+
310
+ def load_baseline(file_path)
311
+ case File.extname(file_path)
312
+ when '.json'
313
+ JSON.parse(File.read(file_path))['detailed_metrics'] || {}
314
+ when '.yml', '.yaml'
315
+ YAML.load_file(file_path, permitted_classes: [Time])['detailed_metrics'] || {}
316
+ else
317
+ {}
318
+ end
319
+ rescue StandardError => e
320
+ log_error("Failed to load baseline from #{file_path}: #{e.message}")
321
+ {}
322
+ end
323
+ end
324
+
325
+ # Performance-aware method wrapper
326
+ module MethodInstrumentation
327
+ def self.included(klass)
328
+ klass.extend(ClassMethods)
329
+ end
330
+
331
+ # Class methods for method instrumentation
332
+ module ClassMethods
333
+ # Instrument methods for performance monitoring
334
+ def instrument_method(method_name, operation_name: nil)
335
+ operation_name ||= "#{name}##{method_name}"
336
+
337
+ alias_method "#{method_name}_without_instrumentation", method_name
338
+
339
+ define_method(method_name) do |*args, &block|
340
+ Appom::Performance.monitor.time_operation(operation_name) do
341
+ send("#{method_name}_without_instrumentation", *args, &block)
342
+ end
343
+ end
344
+ end
345
+
346
+ # Instrument all methods matching pattern
347
+ def instrument_methods(pattern, operation_prefix: nil)
348
+ operation_prefix ||= name
349
+
350
+ instance_methods(false).grep(pattern).each do |method_name|
351
+ instrument_method(method_name, operation_name: "#{operation_prefix}##{method_name}")
352
+ end
353
+ end
354
+ end
355
+ end
356
+
357
+ # Global performance monitor
358
+ class << self
359
+ attr_writer :monitor
360
+
361
+ def monitor
362
+ @monitor ||= Monitor.new
363
+ end
364
+
365
+ # Convenience methods
366
+ def time_operation(name, context = {}, &)
367
+ monitor.time_operation(name, context, &)
368
+ end
369
+
370
+ def record_metric(name, duration, **)
371
+ monitor.record_metric(name, duration, **)
372
+ end
373
+
374
+ def stats(operation_name = nil)
375
+ monitor.stats(operation_name)
376
+ end
377
+
378
+ def summary
379
+ monitor.summary
380
+ end
381
+
382
+ def export_metrics(**)
383
+ monitor.export_metrics(**)
384
+ end
385
+
386
+ def reset!
387
+ monitor.reset!
388
+ end
389
+
390
+ def check_regressions(baseline_file, threshold_percent = 20)
391
+ monitor.check_regressions(baseline_file, threshold_percent)
392
+ end
393
+ end
394
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Retry functionality for Appom automation framework
4
+ # Provides exponential backoff retry logic for flaky operations
5
+ module Appom::Retry
6
+ # Default retry configuration
7
+ DEFAULT_RETRY_COUNT = 3
8
+ DEFAULT_RETRY_DELAY = 0.5
9
+ DEFAULT_BACKOFF_MULTIPLIER = 1.5
10
+
11
+ # Configuration class for retry operations
12
+ class RetryConfig
13
+ attr_accessor :max_attempts, :base_delay, :backoff_multiplier, :max_delay,
14
+ :retry_on_exceptions, :retry_if, :on_retry
15
+
16
+ def initialize
17
+ @max_attempts = DEFAULT_RETRY_COUNT
18
+ @base_delay = DEFAULT_RETRY_DELAY
19
+ @backoff_multiplier = DEFAULT_BACKOFF_MULTIPLIER
20
+ @max_delay = 30 # seconds
21
+ @retry_on_exceptions = [Appom::ElementNotFoundError, Appom::ElementStateError,
22
+ Appom::WaitError, StandardError,]
23
+ @retry_if = nil
24
+ @on_retry = nil
25
+ end
26
+ end
27
+
28
+ class << self
29
+ # Execute a block with retry logic
30
+ def with_retry(config = RetryConfig.new)
31
+ return unless block_given?
32
+
33
+ attempt = 1
34
+ delay = config.base_delay
35
+
36
+ begin
37
+ yield
38
+ rescue *config.retry_on_exceptions => e
39
+ # Check if we should retry based on custom condition
40
+ raise e if config.retry_if && !config.retry_if.call(e, attempt)
41
+
42
+ raise e unless attempt < config.max_attempts
43
+
44
+ # Call retry callback if provided
45
+ config.on_retry&.call(e, attempt, delay)
46
+
47
+ Kernel.sleep(delay)
48
+ attempt += 1
49
+ delay = [delay * config.backoff_multiplier, config.max_delay].min
50
+ retry
51
+ end
52
+ end
53
+
54
+ # Configure retry behavior for element operations
55
+ def configure_element_retry
56
+ config = RetryConfig.new
57
+ yield(config) if block_given?
58
+ config
59
+ end
60
+ end
61
+
62
+ # Mixin for adding retry capabilities to classes
63
+ module RetryMethods
64
+ # Retry element finding with exponential backoff
65
+ def find_with_retry(element_name, **retry_options)
66
+ config = build_retry_config(retry_options)
67
+
68
+ Appom::Retry.with_retry(config) do
69
+ send(element_name)
70
+ end
71
+ end
72
+
73
+ # Retry element interaction (tap, click, etc.)
74
+ def interact_with_retry(element_name, action = :tap, **retry_options)
75
+ config = build_retry_config(retry_options)
76
+
77
+ Appom::Retry.with_retry(config) do
78
+ element = send(element_name)
79
+ perform_element_action(element, action, retry_options)
80
+ element
81
+ end
82
+ end
83
+
84
+ # Retry getting element text
85
+ def get_text_with_retry(element_name, **retry_options)
86
+ config = build_retry_config(retry_options)
87
+
88
+ Appom::Retry.with_retry(config) do
89
+ element = send(element_name)
90
+ text = element.text
91
+
92
+ # Validate text if validation block provided
93
+ raise Appom::ElementStateError.new(element_name, 'valid text', text) if retry_options[:validate_text] && !retry_options[:validate_text].call(text)
94
+
95
+ text
96
+ end
97
+ end
98
+
99
+ # Retry waiting for element state
100
+ def wait_for_state_with_retry(element_name, state = :displayed, **retry_options)
101
+ config = build_retry_config(retry_options)
102
+
103
+ Appom::Retry.with_retry(config) do
104
+ element = send(element_name)
105
+ validate_element_state(element, element_name, state)
106
+ element
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def perform_element_action(element, action, options)
113
+ case action
114
+ when :tap, :click
115
+ element.tap
116
+ when :clear
117
+ element.clear
118
+ when :send_keys
119
+ element.send_keys(options[:text] || '')
120
+ else
121
+ element.send(action)
122
+ end
123
+ end
124
+
125
+ def validate_element_state(element, element_name, state)
126
+ case state
127
+ when :displayed
128
+ validate_displayed_state(element, element_name)
129
+ when :enabled
130
+ validate_enabled_state(element, element_name)
131
+ when :not_displayed
132
+ validate_not_displayed_state(element, element_name)
133
+ else
134
+ raise Appom::ConfigurationError.new('element_state', state, 'Unknown state')
135
+ end
136
+ end
137
+
138
+ def validate_displayed_state(element, element_name)
139
+ return if element.displayed?
140
+
141
+ raise Appom::ElementStateError.new(element_name, 'displayed', 'not displayed')
142
+ end
143
+
144
+ def validate_enabled_state(element, element_name)
145
+ return if element.enabled?
146
+
147
+ raise Appom::ElementStateError.new(element_name, 'enabled', 'disabled')
148
+ end
149
+
150
+ def validate_not_displayed_state(element, element_name)
151
+ return unless element.displayed?
152
+
153
+ raise Appom::ElementStateError.new(element_name, 'not displayed', 'displayed')
154
+ end
155
+
156
+ def build_retry_config(options)
157
+ config = Appom::Retry::RetryConfig.new
158
+ config.max_attempts = options.fetch(:max_attempts, DEFAULT_RETRY_COUNT)
159
+ config.base_delay = options.fetch(:base_delay, DEFAULT_RETRY_DELAY)
160
+ config.backoff_multiplier = options.fetch(:backoff_multiplier, DEFAULT_BACKOFF_MULTIPLIER)
161
+ config.max_delay = options.fetch(:max_delay, 30)
162
+
163
+ config.retry_on_exceptions = Array(options[:retry_on]) if options[:retry_on]
164
+
165
+ config.retry_if = options[:retry_if] if options[:retry_if]
166
+
167
+ if options[:on_retry]
168
+ config.on_retry = options[:on_retry]
169
+ elsif respond_to?(:log_warn)
170
+ config.on_retry = lambda { |error, attempt, delay|
171
+ log_warn("Retry attempt #{attempt}/#{config.max_attempts}: #{error.message} (delay: #{delay}s)")
172
+ }
173
+ end
174
+
175
+ config
176
+ end
177
+ end
178
+ end