eyes_selenium 2.16.0 → 2.17.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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