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,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'base64'
5
+
6
+ # Screenshot functionality for Appom automation framework
7
+ # Provides screenshot capture, management, and comparison utilities
8
+ module Appom::Screenshot
9
+ # Enhanced screenshot utilities with automatic management
10
+ class ScreenshotManager
11
+ include Appom::Logging
12
+
13
+ DEFAULT_DIRECTORY = 'screenshots'
14
+ DEFAULT_FORMAT = :png
15
+ SUPPORTED_FORMATS = %i[png jpg jpeg].freeze
16
+
17
+ attr_reader :directory, :format, :auto_timestamp, :quality
18
+
19
+ def initialize(directory: DEFAULT_DIRECTORY, format: DEFAULT_FORMAT,
20
+ auto_timestamp: true, quality: 90)
21
+ @directory = directory
22
+ @format = validate_format(format)
23
+ @auto_timestamp = auto_timestamp
24
+ @quality = quality
25
+ @screenshot_count = 0
26
+
27
+ ensure_directory_exists
28
+ end
29
+
30
+ # Take screenshot with automatic naming
31
+ def capture(name = 'screenshot', element: nil, file_path: nil, full_page: false)
32
+ if file_path
33
+ # Use provided file path
34
+ filepath = file_path
35
+ filename = File.basename(file_path)
36
+ # Ensure directory exists
37
+ FileUtils.mkdir_p(File.dirname(filepath))
38
+ else
39
+ # Generate filename as before
40
+ filename = generate_filename(name)
41
+ filepath = File.join(@directory, filename)
42
+ end
43
+
44
+ begin
45
+ if element
46
+ # Element-specific screenshot
47
+ element.screenshot(filepath)
48
+ log_info("Element screenshot saved: #{filename}")
49
+ else
50
+ # Full screen screenshot
51
+ if full_page && driver.respond_to?(:save_screenshot)
52
+ # Use driver's save_screenshot for full page if available
53
+ driver.save_screenshot(filepath)
54
+ elsif driver.respond_to?(:screenshot)
55
+ driver.screenshot(filepath)
56
+ else
57
+ # Fallback for different driver types
58
+ screenshot_data = driver.driver.screenshot_as(:base64)
59
+ File.write(filepath, Base64.decode64(screenshot_data), mode: 'wb')
60
+ end
61
+ log_info("#{full_page ? 'Full page' : 'Full'} screenshot saved: #{filename}")
62
+ end
63
+
64
+ @screenshot_count += 1
65
+ filepath
66
+ rescue StandardError => e
67
+ log_error('Failed to take screenshot', { name: name, error: e.message })
68
+ nil
69
+ end
70
+ end
71
+
72
+ # Take screenshot on test failure
73
+ def capture_on_failure(test_name, exception = nil)
74
+ name = "FAIL_#{test_name}"
75
+ filepath = capture(name)
76
+
77
+ if filepath && exception
78
+ # Add exception info to a text file
79
+ info_file = filepath.gsub(/\.(png|jpe?g)$/i, '.txt')
80
+ File.write(info_file, format_exception_info(exception))
81
+ end
82
+
83
+ filepath
84
+ end
85
+
86
+ # Take before/after comparison screenshots
87
+ def capture_before_after(name)
88
+ before_path = capture("#{name}_BEFORE")
89
+
90
+ result = yield if block_given?
91
+
92
+ after_path = capture("#{name}_AFTER")
93
+
94
+ {
95
+ before: before_path,
96
+ after: after_path,
97
+ action_result: result,
98
+ }
99
+ end
100
+
101
+ # Take screenshot sequence during an action
102
+ def capture_sequence(name, interval: 1.0, max_duration: 10.0)
103
+ sequence_dir = File.join(@directory, "sequence_#{sanitize_name(name)}")
104
+ FileUtils.mkdir_p(sequence_dir)
105
+
106
+ screenshots = []
107
+ start_time = Time.now
108
+ sequence_count = 0
109
+
110
+ # Take initial screenshot
111
+ initial_path = File.join(sequence_dir, "#{sequence_count.to_s.rjust(3, '0')}.#{@format}")
112
+ if driver.respond_to?(:screenshot)
113
+ begin
114
+ driver.screenshot(initial_path)
115
+ screenshots << initial_path
116
+ sequence_count += 1
117
+ rescue StandardError => e
118
+ log_warn("Failed to capture initial sequence screenshot: #{e.message}")
119
+ end
120
+ end
121
+
122
+ # Start background screenshot capture
123
+ screenshot_thread = Thread.new do
124
+ while Time.now - start_time < max_duration
125
+ sleep(interval)
126
+ seq_path = File.join(sequence_dir, "#{sequence_count.to_s.rjust(3, '0')}.#{@format}")
127
+ begin
128
+ driver.screenshot(seq_path) if driver.respond_to?(:screenshot)
129
+ screenshots << seq_path
130
+ sequence_count += 1
131
+ rescue StandardError => e
132
+ log_warn("Failed to capture sequence screenshot: #{e.message}")
133
+ end
134
+ end
135
+ end
136
+
137
+ # Execute the action
138
+ result = yield if block_given?
139
+
140
+ # Stop screenshot capture
141
+ if screenshot_thread
142
+ screenshot_thread.kill
143
+ screenshot_thread.join(1.0) # Wait up to 1 second for thread to finish
144
+ end
145
+
146
+ log_info("Captured #{screenshots.size} screenshots in sequence")
147
+
148
+ {
149
+ directory: sequence_dir,
150
+ screenshots: screenshots,
151
+ count: screenshots.size,
152
+ action_result: result,
153
+ }
154
+ end
155
+
156
+ # Clean up old screenshots
157
+ def cleanup_old_screenshots(days_old: 7)
158
+ cutoff_time = Time.now - (days_old * 24 * 60 * 60)
159
+ deleted_count = 0
160
+
161
+ Dir.glob(File.join(@directory, '**', '*')).each do |file|
162
+ next unless File.file?(file)
163
+
164
+ if File.mtime(file) < cutoff_time
165
+ File.delete(file)
166
+ deleted_count += 1
167
+ end
168
+ end
169
+
170
+ log_info("Cleaned up #{deleted_count} old screenshots")
171
+ deleted_count
172
+ end
173
+
174
+ # Get screenshot statistics
175
+ def stats
176
+ return { total: 0, size: 0 } unless Dir.exist?(@directory)
177
+
178
+ files = Dir.glob(File.join(@directory, '**', '*')).select { |f| File.file?(f) }
179
+ total_size = files.sum { |f| File.size(f) }
180
+
181
+ {
182
+ total: files.size,
183
+ size: total_size,
184
+ size_bytes: total_size,
185
+ size_mb: (total_size / (1024 * 1024.0)).round(2),
186
+ session_count: @screenshot_count,
187
+ }
188
+ end
189
+
190
+ private
191
+
192
+ def driver
193
+ Appom.driver
194
+ end
195
+
196
+ def generate_filename(base_name)
197
+ sanitized = sanitize_name(base_name)
198
+ timestamp = @auto_timestamp ? "_#{Time.now.strftime('%Y%m%d_%H%M%S_%L')}" : ''
199
+ "#{sanitized}#{timestamp}.#{@format}"
200
+ end
201
+
202
+ def sanitize_name(name)
203
+ result = name.to_s
204
+
205
+ # First replace :: with a special placeholder that won't be affected by other operations
206
+ double_underscore_marker = 'XDOUBLEUNDERSCOREX'
207
+ result = result.gsub('::', double_underscore_marker)
208
+
209
+ # Replace all non-alphanumeric chars (except our marker characters) with underscore
210
+ result = result.gsub(/[^a-zA-Z0-9_X-]/, '_')
211
+
212
+ # Squeeze multiple underscores into single ones, but don't touch our marker
213
+ result = result.squeeze('_')
214
+
215
+ # Finally restore the double underscores for the original ::
216
+ result.gsub(double_underscore_marker, '__')
217
+ end
218
+
219
+ def validate_format(format)
220
+ format = format.to_sym
221
+ unless SUPPORTED_FORMATS.include?(format)
222
+ raise Appom::ConfigurationError.new('screenshot_format', format,
223
+ "Must be one of: #{SUPPORTED_FORMATS.join(', ')}",)
224
+ end
225
+ format
226
+ end
227
+
228
+ def ensure_directory_exists
229
+ FileUtils.mkdir_p(@directory)
230
+ rescue Errno::EROFS, Errno::EACCES => e
231
+ log_warn("Cannot create directory #{@directory}: #{e.message}")
232
+ end
233
+
234
+ def format_exception_info(exception)
235
+ <<~INFO
236
+ Exception occurred: #{exception.class}
237
+ Message: #{exception.message}
238
+ Timestamp: #{Time.now}
239
+ Backtrace:
240
+ #{exception.backtrace&.take(10)&.join("\n")}
241
+ INFO
242
+ end
243
+ end
244
+
245
+ # Screenshot comparison utilities
246
+ class ScreenshotComparison
247
+ include Appom::Logging
248
+
249
+ def initialize(tolerance: 0.1, highlight_differences: true)
250
+ @tolerance = tolerance
251
+ @highlight_differences = highlight_differences
252
+ end
253
+
254
+ # Compare two screenshots and return similarity percentage
255
+ def compare(image1_path, image2_path, output_path: nil)
256
+ begin
257
+ require 'mini_magick'
258
+ rescue LoadError
259
+ log_error('MiniMagick gem required for image comparison')
260
+ return nil
261
+ end
262
+
263
+ begin
264
+ img1 = MiniMagick::Image.open(image1_path)
265
+ img2 = MiniMagick::Image.open(image2_path)
266
+
267
+ # Resize images to same dimensions if needed
268
+ if img1.dimensions != img2.dimensions
269
+ log_warn('Images have different dimensions, resizing for comparison')
270
+ img2.resize("#{img1.width}x#{img1.height}")
271
+ end
272
+
273
+ # Compare images
274
+ diff = img1.compare(img2, 'mae') # Mean Absolute Error
275
+ similarity = (1.0 - diff) * 100
276
+
277
+ # Generate difference image if requested
278
+ generate_diff_image(img1, img2, output_path) if output_path && @highlight_differences
279
+
280
+ log_info("Image comparison: #{similarity.round(2)}% similar")
281
+ similarity
282
+ rescue StandardError => e
283
+ log_error('Image comparison failed', { error: e.message })
284
+ nil
285
+ end
286
+ end
287
+
288
+ # Check if images are similar within tolerance
289
+ def similar?(image1_path, image2_path, tolerance: @tolerance)
290
+ similarity = compare(image1_path, image2_path)
291
+ return false unless similarity
292
+
293
+ difference = 100 - similarity
294
+ difference <= tolerance
295
+ end
296
+
297
+ private
298
+
299
+ def generate_diff_image(img1, img2, output_path)
300
+ # Create difference highlight image
301
+ composite = MiniMagick::Tool::Composite.new do |c|
302
+ c.compose('difference')
303
+ c << img1.path
304
+ c << img2.path
305
+ c << output_path
306
+ end
307
+ composite.call
308
+ end
309
+ end
310
+
311
+ # Global screenshot instance
312
+ class << self
313
+ attr_writer :manager
314
+
315
+ def manager
316
+ @manager ||= ScreenshotManager.new
317
+ end
318
+
319
+ # Configure screenshot settings
320
+ def configure(directory: nil, format: nil, auto_timestamp: nil, quality: nil)
321
+ current_config = {
322
+ directory: manager.directory,
323
+ format: manager.format,
324
+ auto_timestamp: manager.auto_timestamp,
325
+ quality: manager.quality,
326
+ }
327
+
328
+ new_config = current_config.merge(
329
+ directory: directory || current_config[:directory],
330
+ format: format || current_config[:format],
331
+ auto_timestamp: auto_timestamp.nil? ? current_config[:auto_timestamp] : auto_timestamp,
332
+ quality: quality || current_config[:quality],
333
+ )
334
+
335
+ @manager = ScreenshotManager.new(**new_config)
336
+ end
337
+
338
+ # Convenience methods
339
+ def capture(name = 'screenshot', **)
340
+ manager.capture(name, **)
341
+ end
342
+
343
+ def capture_on_failure(test_name, exception = nil)
344
+ manager.capture_on_failure(test_name, exception)
345
+ end
346
+
347
+ def capture_before_after(name, &)
348
+ manager.capture_before_after(name, &)
349
+ end
350
+
351
+ def capture_sequence(name, **, &)
352
+ manager.capture_sequence(name, **, &)
353
+ end
354
+
355
+ def cleanup_old(days_old: 7)
356
+ manager.cleanup_old_screenshots(days_old: days_old)
357
+ end
358
+
359
+ def stats
360
+ manager.stats
361
+ end
362
+
363
+ def compare(image1, image2, **)
364
+ ScreenshotComparison.new(**).compare(image1, image2)
365
+ end
366
+
367
+ def similar?(image1, image2, **)
368
+ ScreenshotComparison.new(**).similar?(image1, image2)
369
+ end
370
+ end
371
+ end
data/lib/appom/section.rb CHANGED
@@ -1,28 +1,31 @@
1
- module Appom
2
- class Section
3
- include Appium
4
- include ElementContainer
5
- include ElementFinder
1
+ # frozen_string_literal: true
6
2
 
7
- attr_reader :root_element, :parent
3
+ require 'appom/helpers'
8
4
 
9
- def initialize(parent, root_element)
10
- @parent = parent
11
- @root_element = root_element
12
- end
5
+ # Base section class for Appom automation framework
6
+ # Represents a section of a page with its own elements
7
+ class Appom::Section
8
+ include Appium
9
+ include Appom::ElementContainer
10
+ include Appom::ElementFinder
11
+ include Appom::Helpers
13
12
 
14
- def page
15
- root_element || super
16
- end
13
+ attr_reader :root_element, :parent
17
14
 
18
- def parent_page
19
- candidate_page = parent
20
- until candidate_page.is_a?(Appom::Page)
21
- candidate_page = candidate_page.parent
22
- end
23
- candidate_page
24
- end
15
+ def initialize(parent, root_element)
16
+ @parent = parent
17
+ @root_element = root_element
18
+ end
19
+
20
+ def page
21
+ return root_element if root_element
22
+
23
+ parent
24
+ end
25
25
 
26
- private
26
+ def parent_page
27
+ candidate_page = parent
28
+ candidate_page = candidate_page.parent until candidate_page.is_a?(Appom::Page)
29
+ candidate_page
27
30
  end
28
31
  end