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.
- 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 -121
- 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 -33
- data/lib/appom.rb +191 -31
- metadata +35 -19
|
@@ -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
|
-
|
|
2
|
-
class Section
|
|
3
|
-
include Appium
|
|
4
|
-
include ElementContainer
|
|
5
|
-
include ElementFinder
|
|
1
|
+
# frozen_string_literal: true
|
|
6
2
|
|
|
7
|
-
|
|
3
|
+
require 'appom/helpers'
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
root_element || super
|
|
16
|
-
end
|
|
13
|
+
attr_reader :root_element, :parent
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|