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.
- checksums.yaml +4 -4
- data/README.md +170 -42
- data/lib/appom/configuration.rb +490 -0
- data/lib/appom/element_cache.rb +372 -0
- data/lib/appom/element_container.rb +257 -244
- data/lib/appom/element_finder.rb +142 -138
- data/lib/appom/element_state.rb +458 -0
- data/lib/appom/element_validation.rb +138 -0
- data/lib/appom/exceptions.rb +130 -0
- data/lib/appom/helpers.rb +328 -0
- data/lib/appom/logging.rb +106 -0
- data/lib/appom/page.rb +19 -10
- data/lib/appom/performance.rb +394 -0
- data/lib/appom/retry.rb +178 -0
- data/lib/appom/screenshot.rb +371 -0
- data/lib/appom/section.rb +24 -21
- data/lib/appom/smart_wait.rb +455 -0
- data/lib/appom/version.rb +4 -1
- data/lib/appom/visual.rb +600 -0
- data/lib/appom/wait.rb +96 -35
- data/lib/appom.rb +191 -20
- metadata +35 -19
|
@@ -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
|
data/lib/appom/retry.rb
ADDED
|
@@ -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
|