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,458 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Element state tracking for Appom automation framework
4
+ # Tracks element state changes and provides monitoring capabilities
5
+ module Appom::ElementState
6
+ # Tracks element states and changes over time
7
+ class Tracker
8
+ include Appom::Logging
9
+
10
+ attr_reader :tracked_elements, :state_history
11
+
12
+ def initialize
13
+ @tracked_elements = {}
14
+ @state_history = []
15
+ @observers = []
16
+ @tracking_enabled = true
17
+ end
18
+
19
+ # Start tracking an element
20
+ def track_element(element, name: nil, context: {})
21
+ element_id = generate_element_id(element, name)
22
+
23
+ state = capture_element_state(element)
24
+
25
+ @tracked_elements[element_id] = {
26
+ element: element,
27
+ name: name || element_id,
28
+ context: context,
29
+ first_seen: Time.now,
30
+ last_updated: Time.now,
31
+ current_state: state,
32
+ previous_states: [],
33
+ change_count: 0,
34
+ }
35
+
36
+ log_debug("Started tracking element: #{name || element_id}")
37
+ notify_observers(:element_tracked, element_id, state)
38
+
39
+ element_id
40
+ end
41
+
42
+ # Update element state and detect changes
43
+ def update_element_state(element_id)
44
+ return unless @tracking_enabled
45
+
46
+ tracked = @tracked_elements[element_id]
47
+ return unless tracked
48
+
49
+ begin
50
+ new_state = capture_element_state(tracked[:element])
51
+ old_state = tracked[:current_state]
52
+
53
+ if state_changed?(old_state, new_state)
54
+ # Store previous state
55
+ tracked[:previous_states] << {
56
+ state: old_state,
57
+ timestamp: tracked[:last_updated],
58
+ duration: Time.now - tracked[:last_updated],
59
+ }
60
+
61
+ # Keep only last 10 states
62
+ tracked[:previous_states] = tracked[:previous_states].last(10)
63
+
64
+ # Update current state
65
+ tracked[:current_state] = new_state
66
+ tracked[:last_updated] = Time.now
67
+ tracked[:change_count] += 1
68
+
69
+ # Record in history
70
+ change_event = {
71
+ timestamp: Time.now,
72
+ element_id: element_id,
73
+ element_name: tracked[:name],
74
+ old_state: old_state,
75
+ new_state: new_state,
76
+ changes: calculate_changes(old_state, new_state),
77
+ }
78
+
79
+ @state_history << change_event
80
+ @state_history = @state_history.last(1000) # Keep last 1000 changes
81
+
82
+ log_debug("Element state changed: #{tracked[:name]}", change_event[:changes])
83
+ notify_observers(:state_changed, element_id, change_event)
84
+
85
+ change_event
86
+ end
87
+ rescue StandardError => e
88
+ log_warn("Failed to update element state: #{e.message}")
89
+ nil
90
+ end
91
+ end
92
+
93
+ # Get current state of tracked element
94
+ def element_state(element_id)
95
+ tracked = @tracked_elements[element_id]
96
+ return nil unless tracked
97
+
98
+ # Update state before returning
99
+ update_element_state(element_id)
100
+ tracked[:current_state]
101
+ end
102
+
103
+ # Get element state history
104
+ def element_history(element_id, limit: 50)
105
+ @state_history
106
+ .select { |event| event[:element_id] == element_id }
107
+ .last(limit)
108
+ end
109
+
110
+ # Wait for element state change
111
+ def wait_for_state_change(element_id, expected_changes: {}, timeout: 10, interval: 0.5)
112
+ start_time = Time.now
113
+
114
+ loop do
115
+ current_state = element_state(element_id)
116
+
117
+ return current_state if expected_changes.all? { |key, value| current_state&.dig(key) == value }
118
+
119
+ if Time.now - start_time > timeout
120
+ raise Appom::TimeoutError,
121
+ "Element state did not change as expected within #{timeout}s. " \
122
+ "Expected: #{expected_changes}, Current: #{current_state}"
123
+ end
124
+
125
+ sleep interval
126
+ end
127
+ end
128
+
129
+ # Wait for element to be in specific state
130
+ def wait_for_state(element_id, condition, timeout: 10, interval: 0.5)
131
+ start_time = Time.now
132
+
133
+ loop do
134
+ current_state = element_state(element_id)
135
+
136
+ result = case condition
137
+ when Proc
138
+ condition.call(current_state)
139
+ when Hash
140
+ condition.all? { |key, value| current_state&.dig(key) == value }
141
+ else
142
+ false
143
+ end
144
+
145
+ return current_state if result
146
+
147
+ if Time.now - start_time > timeout
148
+ raise Appom::TimeoutError,
149
+ "Element did not reach expected state within #{timeout}s. Current: #{current_state}"
150
+ end
151
+
152
+ sleep interval
153
+ end
154
+ end
155
+
156
+ # Stop tracking an element
157
+ def stop_tracking(element_id)
158
+ tracked = @tracked_elements.delete(element_id)
159
+ return unless tracked
160
+
161
+ log_debug("Stopped tracking element: #{tracked[:name]}")
162
+ notify_observers(:element_untracked, element_id)
163
+
164
+ # Return final summary
165
+ {
166
+ name: tracked[:name],
167
+ tracking_duration: Time.now - tracked[:first_seen],
168
+ change_count: tracked[:change_count],
169
+ final_state: tracked[:current_state],
170
+ }
171
+ end
172
+
173
+ # Get tracking summary
174
+ def tracking_summary
175
+ {
176
+ total_tracked: @tracked_elements.count,
177
+ total_changes: @state_history.count,
178
+ most_active: most_active_elements(5),
179
+ recent_changes: @state_history.last(10),
180
+ tracking_enabled: @tracking_enabled,
181
+ }
182
+ end
183
+
184
+ # Find elements by state criteria
185
+ def find_elements_by_state(criteria)
186
+ if criteria.is_a?(Hash)
187
+ # Always return array of tracked hashes for consistency with test expectations
188
+ @tracked_elements.values.select { |tracked| criteria.all? { |key, value| tracked[:current_state]&.dig(key) == value } }
189
+ else
190
+ @tracked_elements.values.select do |tracked|
191
+ current_state = tracked[:current_state]
192
+ case criteria
193
+ when Proc
194
+ criteria.call(current_state)
195
+ else
196
+ false
197
+ end
198
+ end
199
+ end
200
+ end
201
+
202
+ # Add state change observer
203
+ def add_observer(&block)
204
+ @observers << block
205
+ block
206
+ end
207
+
208
+ # Remove observer
209
+ def remove_observer(observer)
210
+ @observers.delete(observer)
211
+ end
212
+
213
+ # Enable/disable tracking
214
+ def tracking_enabled=(enabled)
215
+ @tracking_enabled = enabled
216
+ log_info("Element state tracking #{enabled ? 'enabled' : 'disabled'}")
217
+ end
218
+
219
+ # Clear all tracking data
220
+ def clear!
221
+ @tracked_elements.clear
222
+ @state_history.clear
223
+ log_info('Element state tracking data cleared')
224
+ end
225
+
226
+ # Export tracking data
227
+ def export_tracking_data(file_path: nil, format: :json)
228
+ file_path ||= "element_state_tracking_#{Time.now.strftime('%Y%m%d_%H%M%S')}.#{format}"
229
+
230
+ data = {
231
+ exported_at: Time.now,
232
+ summary: tracking_summary,
233
+ tracked_elements: serialize_tracked_elements,
234
+ state_history: @state_history,
235
+ }
236
+
237
+ case format
238
+ when :json
239
+ File.write(file_path, JSON.pretty_generate(data))
240
+ when :yaml
241
+ File.write(file_path, YAML.dump(data))
242
+ else
243
+ raise ArgumentError, "Unsupported format: #{format}"
244
+ end
245
+
246
+ log_info("Element state tracking data exported to #{file_path}")
247
+ file_path
248
+ end
249
+
250
+ private
251
+
252
+ def generate_element_id(element, name)
253
+ if name
254
+ name.to_s
255
+ else
256
+ # Try to generate meaningful ID from element
257
+ begin
258
+ attrs = []
259
+ attrs << element.attribute(:id) if element.attribute(:id)
260
+ attrs << element.attribute(:class) if element.attribute(:class)
261
+ attrs << element.tag_name if element.respond_to?(:tag_name)
262
+
263
+ id = attrs.any? ? attrs.join('_') : "element_#{element.object_id}"
264
+ id.gsub(/[^\w\-_]/, '_')
265
+ rescue StandardError
266
+ "element_#{element.object_id}"
267
+ end
268
+ end
269
+ end
270
+
271
+ def capture_element_state(element)
272
+ state = {
273
+ captured_at: Time.now,
274
+ exists: false,
275
+ displayed: false,
276
+ enabled: false,
277
+ selected: false,
278
+ text: nil,
279
+ attributes: {},
280
+ location: nil,
281
+ size: nil,
282
+ }
283
+
284
+ begin
285
+ state[:exists] = element.respond_to?(:displayed?) || element.respond_to?(:enabled?)
286
+
287
+ if state[:exists]
288
+ state[:displayed] = element.displayed? if element.respond_to?(:displayed?)
289
+ state[:enabled] = element.enabled? if element.respond_to?(:enabled?)
290
+ state[:selected] = element.selected? if element.respond_to?(:selected?)
291
+ state[:text] = element.text if element.respond_to?(:text)
292
+
293
+ # Capture common attributes
294
+ if element.respond_to?(:attribute)
295
+ %w[id class name type value placeholder].each do |attr|
296
+ value = element.attribute(attr.to_sym) || element.attribute(attr)
297
+ state[:attributes][attr.to_sym] = value if value
298
+ end
299
+ end
300
+
301
+ # Capture location and size
302
+ state[:location] = element.location if element.respond_to?(:location)
303
+
304
+ state[:size] = element.size if element.respond_to?(:size)
305
+ end
306
+ rescue StandardError => e
307
+ state[:error] = e.message
308
+ state[:exists] = false
309
+ end
310
+
311
+ state
312
+ end
313
+
314
+ def state_changed?(old_state, new_state)
315
+ # Compare relevant state properties, excluding timestamp fields
316
+ comparison_keys = %i[exists displayed enabled selected text attributes location size]
317
+
318
+ comparison_keys.any? { |key| old_state[key] != new_state[key] }
319
+ end
320
+
321
+ def calculate_changes(old_state, new_state)
322
+ changes = {}
323
+
324
+ old_state.each do |key, old_value|
325
+ new_value = new_state[key]
326
+
327
+ next unless old_value != new_value
328
+
329
+ changes[key] = {
330
+ from: old_value,
331
+ to: new_value,
332
+ }
333
+ end
334
+
335
+ changes
336
+ end
337
+
338
+ def most_active_elements(limit)
339
+ @tracked_elements
340
+ .map { |_id, tracked| [tracked[:name], tracked[:change_count]] }
341
+ .sort_by { |_, count| -count }
342
+ .first(limit)
343
+ .to_h
344
+ end
345
+
346
+ def notify_observers(event_type, *args)
347
+ @observers.each do |observer|
348
+ observer.call(event_type, *args)
349
+ rescue StandardError => e
350
+ log_error("Observer error: #{e.message}")
351
+ end
352
+ end
353
+
354
+ def serialize_tracked_elements
355
+ @tracked_elements.transform_values do |tracked|
356
+ {
357
+ name: tracked[:name],
358
+ context: tracked[:context],
359
+ first_seen: tracked[:first_seen],
360
+ last_updated: tracked[:last_updated],
361
+ current_state: tracked[:current_state],
362
+ change_count: tracked[:change_count],
363
+ previous_states_count: tracked[:previous_states].size,
364
+ }
365
+ end
366
+ end
367
+ end
368
+
369
+ # Element state monitoring mixin
370
+ module Monitoring
371
+ def self.included(base)
372
+ base.extend(ClassMethods)
373
+ end
374
+
375
+ # Class methods for element state tracking
376
+ module ClassMethods
377
+ def track_state_changes
378
+ @state_tracking_enabled = true
379
+ end
380
+
381
+ def state_tracking_enabled?
382
+ @state_tracking_enabled ||= false
383
+ end
384
+ end
385
+
386
+ # Track this element's state
387
+ def track_state(name: nil, context: {})
388
+ return unless self.class.state_tracking_enabled?
389
+
390
+ ::Appom::ElementState.tracker.track_element(self, name: name, context: context)
391
+ end
392
+
393
+ # Update state and return changes
394
+ def update_state
395
+ return unless self.class.state_tracking_enabled?
396
+
397
+ ::Appom::ElementState.tracker.update_element_state(element_id) if defined?(@element_id)
398
+ end
399
+
400
+ # Get current state
401
+ def current_state
402
+ return unless self.class.state_tracking_enabled?
403
+
404
+ ::Appom::ElementState.tracker.element_state(element_id) if defined?(@element_id)
405
+ end
406
+
407
+ # Wait for state change
408
+ def wait_for_state_change(**args)
409
+ return unless self.class.state_tracking_enabled?
410
+
411
+ ::Appom::ElementState.tracker.wait_for_state_change(element_id, args.empty? ? {} : args) if defined?(@element_id)
412
+ end
413
+
414
+ private
415
+
416
+ def element_id
417
+ @element_id ||= ::Appom::ElementState.tracker.generate_element_id(self, nil)
418
+ end
419
+
420
+ def initialize(*)
421
+ super if defined?(super)
422
+ end
423
+ end
424
+
425
+ # Global state tracker
426
+ class << self
427
+ attr_writer :tracker
428
+
429
+ def tracker
430
+ @tracker ||= Tracker.new
431
+ end
432
+
433
+ # Convenience methods
434
+ def track_element(element, **)
435
+ tracker.track_element(element, **)
436
+ end
437
+
438
+ def element_state(element_id)
439
+ tracker.element_state(element_id)
440
+ end
441
+
442
+ def wait_for_state_change(element_id, **)
443
+ tracker.wait_for_state_change(element_id, **)
444
+ end
445
+
446
+ def tracking_summary
447
+ tracker.tracking_summary
448
+ end
449
+
450
+ def clear!
451
+ tracker.clear!
452
+ end
453
+
454
+ def export_data(**)
455
+ tracker.export_tracking_data(**)
456
+ end
457
+ end
458
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Element validation module for Appom automation framework
4
+ # Provides validation for element definitions and arguments
5
+ module Appom::ElementValidation
6
+ # Boolean values for validation
7
+ BOOLEAN_VALUES = [true, false].freeze
8
+
9
+ # Valid Appium locator strategies
10
+ VALID_LOCATOR_STRATEGIES = [
11
+ :accessibility_id,
12
+ :android_uiautomator,
13
+ :android_viewtag,
14
+ :android_data_matcher,
15
+ :android_view_matcher,
16
+ :ios_predicate,
17
+ :ios_uiautomation,
18
+ :ios_class_chain,
19
+ :class_name,
20
+ :class, # Alias for class_name
21
+ :css,
22
+ :id,
23
+ :name,
24
+ :link_text,
25
+ :partial_link_text,
26
+ :tag_name,
27
+ :xpath,
28
+ ].freeze
29
+
30
+ class << self
31
+ # Validate element definition arguments
32
+ def validate_element_args(name, *find_args)
33
+ validate_element_name(name)
34
+ validate_find_arguments(find_args)
35
+ end
36
+
37
+ # Validate section definition arguments
38
+ def validate_section_args(name, *args)
39
+ validate_element_name(name)
40
+
41
+ # Extract section class and find args
42
+ find_args = args.dup
43
+
44
+ if find_args.first.is_a?(Class)
45
+ section_class = find_args.shift
46
+ validate_section_class(section_class)
47
+ end
48
+
49
+ validate_find_arguments(find_args) unless find_args.empty?
50
+ end
51
+
52
+ private
53
+
54
+ def validate_element_name(name)
55
+ raise Appom::ConfigurationError.new('element_name', name, 'Element name must be a symbol') unless name.is_a?(Symbol)
56
+
57
+ raise Appom::ConfigurationError.new('element_name', name, 'Element name cannot be empty') if name.to_s.empty?
58
+
59
+ # Check for reserved method names
60
+ reserved_methods = %i[page parent root_element initialize]
61
+ return unless reserved_methods.include?(name)
62
+
63
+ raise Appom::ConfigurationError.new('element_name', name, 'Element name conflicts with reserved method')
64
+ end
65
+
66
+ def validate_find_arguments(find_args)
67
+ return if find_args.empty? # Allow empty args for some cases
68
+
69
+ flattened_args = find_args.flatten
70
+ return if flattened_args.empty?
71
+
72
+ # First argument should be a locator strategy (symbol)
73
+ locator_strategy = flattened_args.first
74
+ unless locator_strategy.is_a?(Symbol)
75
+ raise Appom::ConfigurationError.new('locator_strategy', locator_strategy,
76
+ 'First argument must be a symbol representing locator strategy',)
77
+ end
78
+
79
+ unless VALID_LOCATOR_STRATEGIES.include?(locator_strategy)
80
+ valid_strategies = VALID_LOCATOR_STRATEGIES.map(&:to_s).join(', ')
81
+ raise Appom::ConfigurationError.new('locator_strategy', locator_strategy,
82
+ "Invalid locator strategy. Valid strategies: #{valid_strategies}",)
83
+ end
84
+
85
+ # Second argument should be the locator value (string)
86
+ if flattened_args.size > 1
87
+ locator_value = flattened_args[1]
88
+ unless locator_value.is_a?(String) || locator_value.is_a?(Hash)
89
+ raise Appom::ConfigurationError.new('locator_value', locator_value,
90
+ 'Locator value must be a string or hash',)
91
+ end
92
+
93
+ if locator_value.is_a?(String) && locator_value.empty?
94
+ raise Appom::ConfigurationError.new('locator_value', locator_value,
95
+ 'Locator value cannot be empty',)
96
+ end
97
+ else
98
+ raise Appom::ConfigurationError.new('find_arguments', find_args,
99
+ 'Missing locator value. Expected format: :strategy, "value"',)
100
+ end
101
+
102
+ # Validate optional hash arguments
103
+ flattened_args[2..]&.each do |arg|
104
+ validate_element_options(arg) if arg.is_a?(Hash)
105
+ end
106
+ end
107
+
108
+ def validate_element_options(options)
109
+ valid_options = %i[text visible enabled timeout]
110
+
111
+ options.each do |key, value|
112
+ unless valid_options.include?(key)
113
+ valid_keys = valid_options.map(&:to_s).join(', ')
114
+ raise Appom::ConfigurationError.new('element_option', key,
115
+ "Invalid option. Valid options: #{valid_keys}",)
116
+ end
117
+
118
+ case key
119
+ when :text
120
+ raise Appom::ConfigurationError.new('text_option', value, 'Text option must be a string') unless value.is_a?(String)
121
+ when :visible, :enabled
122
+ raise Appom::ConfigurationError.new("#{key}_option", value, "#{key.capitalize} option must be true or false") unless BOOLEAN_VALUES.include?(value)
123
+ when :timeout
124
+ raise Appom::ConfigurationError.new('timeout_option', value, 'Timeout must be a positive number') unless value.is_a?(Numeric) && value.positive?
125
+ end
126
+ end
127
+ end
128
+
129
+ def validate_section_class(klass)
130
+ raise Appom::ConfigurationError.new('section_class', klass, 'Section class must be a Class') unless klass.is_a?(Class)
131
+
132
+ return if klass.ancestors.include?(Appom::Section)
133
+
134
+ raise Appom::ConfigurationError.new('section_class', klass,
135
+ 'Section class must inherit from Appom::Section',)
136
+ end
137
+ end
138
+ end