eyes_selenium 2.16.0 → 2.17.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.
@@ -3,10 +3,10 @@ require 'forwardable'
3
3
 
4
4
  module Applitools::EyesLogger
5
5
  class NullLogger < Logger
6
- def initialize(*args)
6
+ def initialize(*_args)
7
7
  end
8
8
 
9
- def add(*args, &block)
9
+ def add(*_args, &_block)
10
10
  end
11
11
  end
12
12
 
@@ -16,25 +16,25 @@ module Applitools::EyesLogger
16
16
  MANDATORY_METHODS = [:debug, :info, :close].freeze
17
17
  OPTIONAL_METHODS = [:warn, :error, :fatal].freeze
18
18
 
19
- def_delegators :@@log_handler, *MANDATORY_METHODS
19
+ def_delegators :@log_handler, *MANDATORY_METHODS
20
20
 
21
- @@log_handler = NullLogger.new
21
+ @log_handler = NullLogger.new
22
22
 
23
23
  def log_handler=(log_handler)
24
24
  raise Applitools::EyesError.new('log_handler must implement Logger!') unless valid?(log_handler)
25
25
 
26
- @@log_handler = log_handler
26
+ @log_handler = log_handler
27
27
  end
28
28
 
29
29
  OPTIONAL_METHODS.each do |method|
30
30
  define_singleton_method(method) do |msg|
31
- @@log_handler.respond_to?(method) ? @@log_handler.send(method, msg) : @@log_handler.info(msg)
31
+ @log_handler.respond_to?(method) ? @log_handler.send(method, msg) : @log_handler.info(msg)
32
32
  end
33
33
  end
34
34
 
35
35
  private
36
36
 
37
37
  def valid?(log_handler)
38
- MANDATORY_METHODS.all? {|method| log_handler.respond_to?(method)}
38
+ MANDATORY_METHODS.all? { |method| log_handler.respond_to?(method) }
39
39
  end
40
40
  end
@@ -15,8 +15,8 @@ module Applitools::MethodTracer
15
15
  end
16
16
  end
17
17
 
18
- instance_methods.each {|method_name| trace_method(base, method_name) }
19
- class_methods.each {|method_name| trace_method(base, method_name, false) }
18
+ instance_methods.each { |method_name| trace_method(base, method_name) }
19
+ class_methods.each { |method_name| trace_method(base, method_name, false) }
20
20
  end
21
21
  end
22
22
  end
@@ -0,0 +1,220 @@
1
+ module Applitools::Selenium
2
+ class Browser
3
+ JS_GET_USER_AGENT = (<<-JS).freeze
4
+ return navigator.userAgent;
5
+ JS
6
+
7
+ JS_GET_DEVICE_PIXEL_RATIO = (<<-JS).freeze
8
+ return window.devicePixelRatio;
9
+ JS
10
+
11
+ JS_GET_PAGE_METRICS = (<<-JS).freeze
12
+ return {
13
+ scrollWidth: document.documentElement.scrollWidth,
14
+ bodyScrollWidth: document.body.scrollWidth,
15
+ clientHeight: document.documentElement.clientHeight,
16
+ bodyClientHeight: document.body.clientHeight,
17
+ scrollHeight: document.documentElement.scrollHeight,
18
+ bodyScrollHeight: document.body.scrollHeight
19
+ };
20
+ JS
21
+
22
+ JS_GET_CURRENT_SCROLL_POSITION = (<<-JS).freeze
23
+ return (function() {
24
+ var doc = document.documentElement;
25
+ var x = (window.scrollX || window.pageXOffset || doc.scrollLeft) - (doc.clientLeft || 0);
26
+ var y = (window.scrollY || window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
27
+
28
+ return {left: parseInt(x, 10) || 0, top: parseInt(y, 10) || 0};
29
+ }());
30
+ JS
31
+
32
+ JS_SCROLL_TO = (<<-JS).freeze
33
+ window.scrollTo(%{left}, %{top});
34
+ JS
35
+
36
+ JS_GET_CURRENT_TRANSFORM = (<<-JS).freeze
37
+ return document.body.style.transform;
38
+ JS
39
+
40
+ JS_SET_TRANSFORM = (<<-JS).freeze
41
+ return (function() {
42
+ var originalTransform = document.body.style.transform;
43
+ document.body.style.transform = '%{transform}';
44
+ return originalTransform;
45
+ }());
46
+ JS
47
+
48
+ JS_SET_OVERFLOW = (<<-JS).freeze
49
+ return (function() {
50
+ var origOF = document.documentElement.style.overflow;
51
+ document.documentElement.style.overflow = '%{overflow}';
52
+ return origOF;
53
+ }());
54
+ JS
55
+
56
+ EPSILON_WIDTH = 12.freeze
57
+ MIN_SCREENSHOT_PART_HEIGHT = 10.freeze
58
+ MAX_SCROLLBAR_SIZE = 50.freeze
59
+ OVERFLOW_HIDDEN = 'hidden'.freeze
60
+
61
+ def initialize(driver, eyes)
62
+ @driver = driver
63
+ @eyes = eyes
64
+ end
65
+
66
+ def chrome?
67
+ @driver.browser == :chrome
68
+ end
69
+
70
+ def user_agent
71
+ return @user_agent if defined?(@user_agent)
72
+
73
+ @user_agent = begin
74
+ execute_script(JS_GET_USER_AGENT).freeze
75
+ rescue => e
76
+ Applitools::EyesLogger.error "Failed to obtain user-agent: (#{e.message})"
77
+
78
+ nil
79
+ end
80
+ end
81
+
82
+ def image_normalization_factor(image)
83
+ if image.width == @eyes.viewport_size.extract_viewport_from_browser.width ||
84
+ (image.width - entire_page_size.width).abs <= EPSILON_WIDTH
85
+ return 1
86
+ end
87
+
88
+ 1.to_f / device_pixel_ratio
89
+ end
90
+
91
+ def entire_page_size
92
+ max_document_element_height = [page_metrics[:client_height], page_metrics[:scroll_height]].max
93
+ max_body_height = [page_metrics[:body_client_height], page_metrics[:body_scroll_height]].max
94
+
95
+ total_width = [page_metrics[:scroll_width], page_metrics[:body_scroll_width]].max
96
+ total_height = [max_document_element_height, max_body_height].max
97
+
98
+ Applitools::Base::Dimension.new(total_width, total_height)
99
+ end
100
+
101
+ def current_scroll_position
102
+ position = Applitools::Utils.underscore_hash_keys(execute_script(JS_GET_CURRENT_SCROLL_POSITION))
103
+ Applitools::Base::Point.new(position[:left], position[:top])
104
+ end
105
+
106
+ def scroll_to(point)
107
+ execute_script(JS_SCROLL_TO % { left: point.left, top: point.top }, 0.25)
108
+ end
109
+
110
+ def current_transform
111
+ execute_script(JS_GET_CURRENT_TRANSFORM)
112
+ end
113
+
114
+ # rubocop:disable Style/AccessorMethodName
115
+ def set_transform(transform)
116
+ execute_script(JS_SET_TRANSFORM % { transform: transform }, 0.25)
117
+ end
118
+
119
+ def set_overflow(overflow)
120
+ execute_script(JS_SET_OVERFLOW % { overflow: overflow }, 0.1)
121
+ end
122
+ # rubocop:enable Style/AccessorMethodName
123
+
124
+ def translate_to(point)
125
+ set_transform("translate(-#{point.left}px, -#{point.top}px)")
126
+ end
127
+
128
+ def fullpage_screenshot
129
+ # Scroll to the top/left corner of the screen.
130
+ original_scroll_position = current_scroll_position
131
+ scroll_to(Applitools::Base::Point::TOP_LEFT)
132
+ if current_scroll_position != Applitools::Base::Point::TOP_LEFT
133
+ raise 'Could not scroll to the top/left corner of the screen!'
134
+ end
135
+
136
+ # Translate to top/left of the page (notice this is different from JavaScript scrolling).
137
+ if @eyes.use_css_transition
138
+ original_transform = current_transform
139
+ translate_to(Applitools::Base::Point::TOP_LEFT)
140
+ end
141
+
142
+ # Hide scrollbars.
143
+ original_overflow = set_overflow(OVERFLOW_HIDDEN) if @eyes.hide_scrollbars
144
+
145
+ # Take screenshot of the (0,0) tile.
146
+ screenshot = @driver.visible_screenshot
147
+
148
+ # Normalize screenshot width/height.
149
+ size_factor = 1
150
+ page_size = entire_page_size
151
+ factor = image_normalization_factor(screenshot)
152
+ if factor == 0.5
153
+ size_factor = 2
154
+ page_size.width *= size_factor
155
+ page_size.height *= size_factor
156
+ page_size.width = [page_size.width, screenshot.width].max
157
+ end
158
+
159
+ # NOTE: this is required! Since when calculating the screenshot parts for full size, we use a screenshot size
160
+ # which is a bit smaller (see comment below).
161
+ if @eyes.force_fullpage_screenshot && (screenshot.width < page_size.width || screenshot.height < page_size.height)
162
+ # We use a smaller size than the actual screenshot size in order to eliminate duplication of bottom scroll bars,
163
+ # as well as footer-like elements with fixed position.
164
+ max_scrollbar_size = @eyes.use_css_transition ? 0 : MAX_SCROLLBAR_SIZE
165
+ height = [screenshot.height - (max_scrollbar_size * size_factor), MIN_SCREENSHOT_PART_HEIGHT * size_factor].max
166
+ screenshot_part_size = Applitools::Base::Dimension.new(screenshot.width, height)
167
+
168
+ subregios = Applitools::Base::Region.new(0, 0, page_size.width,
169
+ page_size.height).subregions(screenshot_part_size)
170
+ parts = subregios.map do |screenshot_part|
171
+ # Skip (0,0), as we already got the screenshot.
172
+ if screenshot_part.left == 0 && screenshot_part.top == 0
173
+ next Applitools::Base::ImagePosition.new(screenshot, Applitools::Base::Point::TOP_LEFT)
174
+ end
175
+
176
+ process_screenshot_part(screenshot_part, size_factor)
177
+ end
178
+
179
+ screenshot = Applitools::Utils::ImageUtils.stitch_images(page_size, parts)
180
+ end
181
+
182
+ set_overflow(original_overflow) if @eyes.hide_scrollbars
183
+ set_transform(original_transform) if @eyes.use_css_transition
184
+
185
+ scroll_to(original_scroll_position)
186
+
187
+ screenshot
188
+ end
189
+
190
+ private
191
+
192
+ def execute_script(script, stabilization_time = nil)
193
+ @driver.execute_script(script).tap { sleep(stabilization_time) if stabilization_time }
194
+ end
195
+
196
+ def device_pixel_ratio
197
+ @device_pixel_ratio ||= execute_script(JS_GET_DEVICE_PIXEL_RATIO).freeze
198
+ end
199
+
200
+ def page_metrics
201
+ Applitools::Utils.underscore_hash_keys(execute_script(JS_GET_PAGE_METRICS))
202
+ end
203
+
204
+ def process_screenshot_part(part, size_factor)
205
+ part_coords = Applitools::Base::Point.new(part.left, part.top)
206
+ part_coords_normalized = Applitools::Base::Point.new(part.left.to_f / size_factor, part.top.to_f / size_factor)
207
+
208
+ if @eyes.use_css_transition
209
+ translate_to(part_coords_normalized)
210
+ current_position = part_coords
211
+ else
212
+ scroll_to(part_coords_normalized)
213
+ position = current_scroll_position
214
+ current_position = Applitools::Base::Point.new(position.left * size_factor, position.top * size_factor)
215
+ end
216
+
217
+ Applitools::Base::ImagePosition.new(@driver.visible_screenshot, current_position)
218
+ end
219
+ end
220
+ end
@@ -9,13 +9,10 @@ module Applitools::Selenium
9
9
  include Selenium::WebDriver::DriverExtensions::HasInputDevices
10
10
 
11
11
  RIGHT_ANGLE = 90.freeze
12
- ANDROID = 'ANDROID'.freeze
13
12
  IOS = 'IOS'.freeze
13
+ ANDROID = 'ANDROID'.freeze
14
14
  LANDSCAPE = 'LANDSCAPE'.freeze
15
15
 
16
- IE = 'ie'.freeze
17
- FIREFOX = 'firefox'.freeze
18
-
19
16
  FINDERS = {
20
17
  class: 'class name',
21
18
  class_name: 'class name',
@@ -29,9 +26,10 @@ module Applitools::Selenium
29
26
  xpath: 'xpath'
30
27
  }.freeze
31
28
 
32
- JS_GET_USER_AGENT = 'return navigator.userAgent;'.freeze
29
+ attr_reader :browser
33
30
 
34
31
  def_delegators :@eyes, :user_inputs, :clear_user_inputs
32
+ def_delegators :@browser, :user_agent
35
33
 
36
34
  # If driver is not provided, Applitools::Selenium::Driver will raise an EyesError exception.
37
35
  def initialize(eyes, options)
@@ -39,33 +37,9 @@ module Applitools::Selenium
39
37
 
40
38
  @is_mobile_device = options.fetch(:is_mobile_device, false)
41
39
  @eyes = eyes
40
+ @browser = Applitools::Selenium::Browser.new(self, @eyes)
42
41
 
43
- raise 'Uncapable of taking screenshots!' unless capabilities.takes_screenshot?
44
- end
45
-
46
- # Rotates the image as necessary. The rotation is either manually forced by passing a value in
47
- # the +rotation+ parameter, or automatically inferred if the +rotation+ parameter is +nil+.
48
- #
49
- # +driver+:: +Applitools::Selenium::Driver+ The driver which produced the screenshot.
50
- # +image+:: +ChunkyPNG::Canvas+ The image to normalize.
51
- # +rotation+:: +Integer+|+nil+ The degrees by which to rotate the image: positive values = clockwise rotation,
52
- # negative values = counter-clockwise, 0 = force no rotation, +nil+ = rotate automatically when needed.
53
- def self.normalize_rotation(driver, image, rotation)
54
- return if rotation == 0
55
-
56
- num_quadrants = 0
57
- if !rotation.nil?
58
- if rotation % RIGHT_ANGLE != 0
59
- raise Applitools::EyesError.new("Currently only quadrant rotations are supported. Current rotation: "\
60
- "#{rotation}")
61
- end
62
- num_quadrants = (rotation / RIGHT_ANGLE).to_i
63
- elsif rotation.nil? && driver.mobile_device? && driver.landscape_orientation? && image.height > image.width
64
- # For Android, we need to rotate images to the right, and for iOS to the left.
65
- num_quadrants = driver.android? ? 1 : -1
66
- end
67
-
68
- Applitools::Utils::ImageUtils.quadrant_rotate!(image, num_quadrants)
42
+ raise 'Incapable of taking screenshots!' unless capabilities.takes_screenshot?
69
43
  end
70
44
 
71
45
  # Returns:
@@ -81,26 +55,12 @@ module Applitools::Selenium
81
55
  version.nil? ? nil : version.to_s
82
56
  end
83
57
 
84
- # Returns:
85
- # +true+ if the driver is an Android driver.
86
- def android?
87
- platform_name.to_s.upcase == ANDROID
88
- end
89
-
90
- # Returns:
91
- # +true+ if the driver is an iOS driver.
92
- def ios?
93
- platform_name.to_s.upcase == IOS
94
- end
95
-
96
58
  # Returns:
97
59
  # +true+ if the driver orientation is landscape.
98
60
  def landscape_orientation?
99
- begin
100
- driver.orientation.to_s.upcase == LANDSCAPE
101
- rescue NameError
102
- Applitools::EyesLogger.debug 'driver has no "orientation" attribute. Assuming: portrait.'
103
- end
61
+ driver.orientation.to_s.upcase == LANDSCAPE
62
+ rescue NameError
63
+ Applitools::EyesLogger.debug 'driver has no "orientation" attribute. Assuming: portrait.'
104
64
  end
105
65
 
106
66
  # Returns:
@@ -119,19 +79,24 @@ module Applitools::Selenium
119
79
  #
120
80
  # Returns: +String+ A screenshot in the requested format.
121
81
  def screenshot_as(output_type, rotation = nil)
122
- screenshot = Applitools::Utils::ImageUtils.png_image_from_base64(driver.screenshot_as(:base64))
123
- Applitools::Selenium::Driver.normalize_rotation(self, screenshot, rotation)
82
+ image = mobile_device? ? visible_screenshot : @browser.fullpage_screenshot
83
+
84
+ Applitools::Selenium::Driver.normalize_image(self, image, rotation)
124
85
 
125
86
  case output_type
126
87
  when :base64
127
- screenshot = Applitools::Utils::ImageUtils.base64_from_png_image(screenshot)
88
+ image = Applitools::Utils::ImageUtils.base64_from_png_image(image)
128
89
  when :png
129
- screenshot = Applitools::Utils::ImageUtils.bytes_from_png_image(screenshot)
90
+ image = Applitools::Utils::ImageUtils.bytes_from_png_image(image)
130
91
  else
131
- raise Applitools::EyesError.new("Unsupported screenshot output type: #{output_type.to_s}")
92
+ raise Applitools::EyesError.new("Unsupported screenshot output type: #{output_type}")
132
93
  end
133
94
 
134
- screenshot.force_encoding('BINARY')
95
+ image.force_encoding('BINARY')
96
+ end
97
+
98
+ def visible_screenshot
99
+ Applitools::Utils::ImageUtils.png_image_from_base64(driver.screenshot_as(:base64))
135
100
  end
136
101
 
137
102
  def mouse
@@ -159,12 +124,12 @@ module Applitools::Selenium
159
124
  driver.find_elements(how, what).map { |el| Applitools::Selenium::Element.new(self, el) }
160
125
  end
161
126
 
162
- def user_agent
163
- @user_agent ||= execute_script JS_GET_USER_AGENT
164
- rescue => e
165
- Applitools::EyesLogger.error "Failed to obtain user-agent string (#{e.message})"
127
+ def android?
128
+ platform_name.to_s.upcase == ANDROID
129
+ end
166
130
 
167
- nil
131
+ def ios?
132
+ platform_name.to_s.upcase == IOS
168
133
  end
169
134
 
170
135
  private
@@ -190,5 +155,42 @@ module Applitools::Selenium
190
155
  raise ArgumentError, "wrong number of arguments (#{args.size} for 2)"
191
156
  end
192
157
  end
158
+
159
+ def self.normalize_image(driver, image, rotation)
160
+ normalize_rotation(driver, image, rotation)
161
+ normalize_width(driver, image)
162
+ end
163
+
164
+ # Rotates the image as necessary. The rotation is either manually forced by passing a value in
165
+ # the +rotation+ parameter, or automatically inferred if the +rotation+ parameter is +nil+.
166
+ #
167
+ # +driver+:: +Applitools::Selenium::Driver+ The driver which produced the screenshot.
168
+ # +image+:: +ChunkyPNG::Canvas+ The image to normalize.
169
+ # +rotation+:: +Integer+|+nil+ The degrees by which to rotate the image: positive values = clockwise rotation,
170
+ # negative values = counter-clockwise, 0 = force no rotation, +nil+ = rotate automatically when needed.
171
+ def self.normalize_rotation(driver, image, rotation)
172
+ return if rotation == 0
173
+
174
+ num_quadrants = 0
175
+ if !rotation.nil?
176
+ if rotation % RIGHT_ANGLE != 0
177
+ raise Applitools::EyesError.new('Currently only quadrant rotations are supported. Current rotation: '\
178
+ "#{rotation}")
179
+ end
180
+ num_quadrants = (rotation / RIGHT_ANGLE).to_i
181
+ elsif rotation.nil? && driver.mobile_device? && driver.landscape_orientation? && image.height > image.width
182
+ # For Android, we need to rotate images to the right, and for iOS to the left.
183
+ num_quadrants = driver.android? ? 1 : -1
184
+ end
185
+
186
+ Applitools::Utils::ImageUtils.quadrant_rotate!(image, num_quadrants)
187
+ end
188
+
189
+ def self.normalize_width(driver, image)
190
+ return if driver.mobile_device?
191
+
192
+ normalization_factor = driver.browser.image_normalization_factor(image)
193
+ Applitools::Utils::ImageUtils.scale!(image, normalization_factor) unless normalization_factor == 1
194
+ end
193
195
  end
194
196
  end