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,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ # Element caching system for Appom automation framework
6
+ # Provides intelligent element caching with TTL and LRU eviction policies
7
+ module Appom::ElementCache
8
+ # Element caching to improve performance for frequently accessed elements
9
+ class Cache
10
+ include Appom::Logging
11
+
12
+ attr_reader :max_size, :ttl
13
+
14
+ def initialize(max_size: 100, ttl: 300)
15
+ @cache = {}
16
+ @access_times = {}
17
+ @max_size = max_size
18
+ @ttl = ttl # Time to live in seconds
19
+ @stats = { hits: 0, misses: 0, evictions: 0, stores: 0, clears: 0, expirations: 0 }
20
+ end
21
+
22
+ # Store element in cache with strategy and value
23
+ def store(strategy, value, element)
24
+ cache_key = generate_key(strategy, value)
25
+ store_in_cache(cache_key, element)
26
+ @stats[:stores] += 1
27
+ cache_key
28
+ end
29
+
30
+ # Get element from cache by key
31
+ def get(cache_key)
32
+ unless @cache.key?(cache_key)
33
+ @stats[:misses] += 1
34
+ return nil
35
+ end
36
+
37
+ element, timestamp = @cache[cache_key]
38
+
39
+ # Check TTL
40
+ if Time.now - timestamp > @ttl
41
+ @cache.delete(cache_key)
42
+ @access_times.delete(cache_key)
43
+ @stats[:expirations] ||= 0
44
+ @stats[:expirations] += 1
45
+ @stats[:misses] += 1
46
+ return nil
47
+ end
48
+
49
+ # Check if element is still valid
50
+ unless valid_element?(element)
51
+ @cache.delete(cache_key)
52
+ @access_times.delete(cache_key)
53
+ @stats[:misses] += 1
54
+ return nil
55
+ end
56
+
57
+ # Update access time and refresh TTL
58
+ current_time = Time.now
59
+ @cache[cache_key] = [element, current_time]
60
+ @access_times[cache_key] = current_time
61
+ @stats[:hits] += 1
62
+ element
63
+ end
64
+
65
+ # Check if key exists in cache
66
+ def hit?(cache_key)
67
+ return false unless @cache.key?(cache_key)
68
+
69
+ element, timestamp = @cache[cache_key]
70
+
71
+ # Check TTL without updating stats
72
+ return false if Time.now - timestamp > @ttl
73
+
74
+ valid_element?(element)
75
+ end
76
+
77
+ # Get cache size
78
+ def size
79
+ @cache.size
80
+ end
81
+
82
+ # Get element from cache or find and cache it
83
+ def get_or_find(*find_args)
84
+ cache_key = generate_key(find_args)
85
+
86
+ if (cached_element = get(cache_key))
87
+ log_debug("Cache HIT for #{find_args.join(', ')}")
88
+ return cached_element
89
+ end
90
+
91
+ @stats[:misses] += 1
92
+ log_debug("Cache MISS for #{find_args.join(', ')}")
93
+
94
+ # Find element and cache it
95
+ element = yield if block_given?
96
+ store_in_cache(cache_key, element) if element
97
+ element
98
+ end
99
+
100
+ # Invalidate specific element
101
+ def invalidate(*find_args) # rubocop:disable Naming/PredicateMethod
102
+ cache_key = generate_key(find_args)
103
+ if @cache.delete(cache_key)
104
+ @access_times.delete(cache_key)
105
+ log_debug("Invalidated cache for #{find_args.join(', ')}")
106
+ true
107
+ else
108
+ false
109
+ end
110
+ end
111
+
112
+ # Clear all cached elements
113
+ def clear
114
+ @cache.clear
115
+ @access_times.clear
116
+ @stats[:clears] += 1
117
+ log_info('Element cache cleared')
118
+ end
119
+
120
+ # Clear all cached elements and reset statistics
121
+ def reset
122
+ @cache.clear
123
+ @access_times.clear
124
+ @stats = { hits: 0, misses: 0, evictions: 0, stores: 0, clears: 0, expirations: 0 }
125
+ log_info('Element cache reset')
126
+ end
127
+
128
+ # Get cache statistics
129
+ def statistics
130
+ {
131
+ size: @cache.size,
132
+ max_size: @max_size,
133
+ hit_rate: calculate_hit_rate,
134
+ **@stats,
135
+ }
136
+ end
137
+
138
+ # Alias for backward compatibility
139
+ def stats
140
+ statistics
141
+ end
142
+
143
+ # Check if element is still valid (exists and is stale)
144
+ def valid_element?(element)
145
+ return false unless element
146
+ return true unless element.respond_to?(:displayed?)
147
+
148
+ begin
149
+ # Try to access a property to check if element is stale
150
+ element.displayed?
151
+ true
152
+ rescue StandardError
153
+ # Element is stale or invalid
154
+ false
155
+ end
156
+ end
157
+
158
+ def generate_key(*args)
159
+ # Handle both old and new calling patterns
160
+ find_args = if args.length == 1 && args[0].is_a?(Array)
161
+ # Old pattern: generate_key([strategy, value])
162
+ args[0]
163
+ else
164
+ # New pattern: generate_key(strategy, value)
165
+ args
166
+ end
167
+
168
+ # Create consistent cache key from find arguments
169
+ Digest::MD5.hexdigest(find_args.to_s)
170
+ end
171
+
172
+ private
173
+
174
+ def get_from_cache(cache_key)
175
+ get(cache_key)
176
+ end
177
+
178
+ def store_in_cache(cache_key, element)
179
+ # Clean up expired entries first
180
+ cleanup_expired
181
+
182
+ # Evict oldest if at capacity
183
+ evict_lru if @cache.size >= @max_size
184
+
185
+ timestamp = Time.now
186
+ @cache[cache_key] = [element, timestamp]
187
+ @access_times[cache_key] = timestamp
188
+
189
+ log_debug("Cached element with key #{cache_key[0..8]}...")
190
+ end
191
+
192
+ def evict_lru
193
+ # Remove least recently used item
194
+ oldest_key = @access_times.min_by { |_k, v| v }&.first
195
+ return unless oldest_key
196
+
197
+ @cache.delete(oldest_key)
198
+ @access_times.delete(oldest_key)
199
+ @stats[:evictions] += 1
200
+ log_debug('Evicted LRU element from cache')
201
+ end
202
+
203
+ def cleanup_expired
204
+ current_time = Time.now
205
+ expired_keys = []
206
+
207
+ @cache.each do |key, (_element, timestamp)|
208
+ expired_keys << key if current_time - timestamp > @ttl
209
+ end
210
+
211
+ expired_keys.each do |key|
212
+ @cache.delete(key)
213
+ @access_times.delete(key)
214
+ @stats[:expirations] ||= 0
215
+ @stats[:expirations] += 1
216
+ end
217
+
218
+ return unless expired_keys.any?
219
+
220
+ log_debug("Cleaned up #{expired_keys.size} expired elements from cache")
221
+ end
222
+
223
+ def calculate_hit_rate
224
+ total = @stats[:hits] + @stats[:misses]
225
+ return 0.0 if total.zero?
226
+
227
+ (@stats[:hits].to_f / total * 100).round(2)
228
+ end
229
+ end
230
+
231
+ # Cache-aware element finder mixin
232
+ module CacheAwareFinder
233
+ def self.included(klass)
234
+ # Don't alias if methods don't exist yet
235
+ klass.send(:alias_method, :original_find_element, :find_element) if klass.method_defined?(:find_element)
236
+ return unless klass.method_defined?(:find_elements)
237
+
238
+ klass.send(:alias_method, :original_find_elements, :find_elements)
239
+ end
240
+
241
+ def find_element(strategy, locator, use_cache: true)
242
+ # If not using cache or caching is disabled, use standard find method
243
+ return _find_without_cache(strategy, locator) unless use_cache && begin
244
+ Appom.cache_config[:enabled]
245
+ rescue StandardError
246
+ true
247
+ end
248
+
249
+ # Use global cache
250
+ cache_key = Appom::ElementCache.cache.generate_key(strategy, locator)
251
+ cached = Appom::ElementCache.cache.get(cache_key)
252
+
253
+ return cached if cached
254
+
255
+ element = _find_without_cache(strategy, locator)
256
+ Appom::ElementCache.cache.store(strategy, locator, element) if element
257
+ element
258
+ end
259
+
260
+ private
261
+
262
+ def _find_without_cache(strategy, locator)
263
+ # If original method doesn't exist, delegate to the page/driver
264
+ if respond_to?(:original_find_element)
265
+ original_find_element(strategy, locator)
266
+ elsif respond_to?(:_find)
267
+ _find(strategy, locator)
268
+ else
269
+ page.find_element(strategy, locator)
270
+ end
271
+ end
272
+
273
+ public
274
+
275
+ def find_elements(strategy, locator, use_cache: true)
276
+ # If original method doesn't exist, delegate to the page/driver
277
+ return page.find_elements(strategy, locator) unless respond_to?(:original_find_elements)
278
+
279
+ return original_find_elements(strategy, locator) unless use_cache && begin
280
+ Appom.cache_config[:enabled]
281
+ rescue StandardError
282
+ true
283
+ end
284
+
285
+ # Use global cache
286
+ cache_key = Appom::ElementCache.cache.generate_key(strategy, locator)
287
+ cached = Appom::ElementCache.cache.get(cache_key)
288
+
289
+ return cached if cached
290
+
291
+ elements = original_find_elements(strategy, locator)
292
+ Appom::ElementCache.cache.store(strategy, locator, elements) if elements
293
+ elements
294
+ end
295
+
296
+ def element_cache
297
+ @element_cache ||= Cache.new(
298
+ max_size: Appom.cache_config[:max_size],
299
+ ttl: Appom.cache_config[:ttl],
300
+ )
301
+ end
302
+
303
+ # Clear cache for this finder
304
+ def clear_element_cache
305
+ element_cache.clear
306
+ end
307
+
308
+ # Get cache statistics
309
+ def cache_stats
310
+ element_cache.statistics
311
+ end
312
+ end
313
+
314
+ # Global cache instance and module methods
315
+ @global_cache = nil
316
+
317
+ module_function
318
+
319
+ def cache
320
+ @global_cache || (@cache ||= Cache.new)
321
+ end
322
+
323
+ def clear_cache
324
+ cache.clear
325
+ end
326
+
327
+ def reset_cache
328
+ cache.reset
329
+ end
330
+
331
+ def cache_element(strategy, value, element)
332
+ cache.store(strategy, value, element)
333
+ end
334
+
335
+ def get_cached_element(key)
336
+ cache.get(key)
337
+ end
338
+
339
+ def cache_hit?(key)
340
+ cache.hit?(key)
341
+ end
342
+
343
+ def cache_statistics
344
+ cache.statistics
345
+ end
346
+
347
+ def configure_cache(**)
348
+ @global_cache = Cache.new(**)
349
+ end
350
+ end
351
+
352
+ # Configuration for element caching
353
+ module Appom
354
+ class << self
355
+ attr_accessor :cache_config
356
+
357
+ def configure_cache(max_size: 50, ttl: 30, enabled: true)
358
+ @cache_config = {
359
+ max_size: max_size,
360
+ ttl: ttl,
361
+ enabled: enabled,
362
+ }
363
+ end
364
+ end
365
+
366
+ # Default cache configuration
367
+ @cache_config = {
368
+ max_size: 50,
369
+ ttl: 30,
370
+ enabled: true,
371
+ }
372
+ end