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.
- checksums.yaml +4 -4
- data/.rubocop.yml +54 -0
- data/.travis.yml +2 -0
- data/Rakefile +3 -0
- data/eyes_selenium.gemspec +3 -2
- data/lib/applitools/base/image_position.rb +10 -0
- data/lib/applitools/base/mouse_trigger.rb +1 -1
- data/lib/applitools/base/point.rb +18 -5
- data/lib/applitools/base/region.rb +44 -9
- data/lib/applitools/base/server_connector.rb +7 -6
- data/lib/applitools/base/test_results.rb +4 -5
- data/lib/applitools/extensions.rb +17 -0
- data/lib/applitools/eyes.rb +40 -32
- data/lib/applitools/eyes_logger.rb +7 -7
- data/lib/applitools/method_tracer.rb +2 -2
- data/lib/applitools/selenium/browser.rb +220 -0
- data/lib/applitools/selenium/driver.rb +61 -59
- data/lib/applitools/selenium/element.rb +10 -5
- data/lib/applitools/selenium/match_window_data.rb +1 -1
- data/lib/applitools/selenium/match_window_task.rb +25 -18
- data/lib/applitools/selenium/mouse.rb +3 -3
- data/lib/applitools/selenium/viewport_size.rb +57 -48
- data/lib/applitools/utils/image_delta_compressor.rb +148 -150
- data/lib/applitools/utils/image_utils.rb +36 -3
- data/lib/applitools/utils/utils.rb +3 -6
- data/lib/applitools/version.rb +1 -1
- data/lib/eyes_selenium.rb +2 -2
- data/spec/driver_passthrough_spec.rb +1 -1
- data/spec/spec_helper.rb +2 -5
- data/test/appium_example_script.rb +15 -15
- data/test/test_script.rb +1 -1
- data/test/watir_test_script.rb +1 -1
- metadata +23 -4
@@ -25,7 +25,7 @@ module Applitools::Selenium
|
|
25
25
|
end
|
26
26
|
|
27
27
|
def ==(other)
|
28
|
-
other.
|
28
|
+
other.is_a?(web_element.class) && web_element == other
|
29
29
|
end
|
30
30
|
alias_method :eql?, :==
|
31
31
|
|
@@ -41,13 +41,18 @@ module Applitools::Selenium
|
|
41
41
|
|
42
42
|
def region
|
43
43
|
point = location
|
44
|
-
left
|
44
|
+
left = point.x
|
45
|
+
top = point.y
|
46
|
+
width = 0
|
47
|
+
height = 0
|
45
48
|
|
46
49
|
begin
|
47
50
|
dimension = size
|
48
|
-
width
|
49
|
-
|
51
|
+
width = dimension.width
|
52
|
+
height = dimension.height
|
53
|
+
rescue => e
|
50
54
|
# Not supported on all platforms.
|
55
|
+
Applitools::EyesLogger.error("Failed extracting size using JavaScript: (#{e.message})")
|
51
56
|
end
|
52
57
|
|
53
58
|
if left < 0
|
@@ -60,7 +65,7 @@ module Applitools::Selenium
|
|
60
65
|
top = 0
|
61
66
|
end
|
62
67
|
|
63
|
-
|
68
|
+
Applitools::Base::Region.new(left, top, width, height)
|
64
69
|
end
|
65
70
|
end
|
66
71
|
end
|
@@ -10,7 +10,7 @@ module Applitools::Selenium
|
|
10
10
|
@screenshot = screenshot
|
11
11
|
end
|
12
12
|
|
13
|
-
# IMPORTANT This method returns a hash WITHOUT the screenshot property. This is on
|
13
|
+
# IMPORTANT This method returns a hash WITHOUT the screenshot property. This is on purpose! The screenshot should
|
14
14
|
# not be included as part of the json.
|
15
15
|
def to_hash
|
16
16
|
{
|
@@ -25,26 +25,31 @@ module Applitools::Selenium
|
|
25
25
|
Applitools::EyesLogger.debug "Retry timeout set to: #{retry_timeout}"
|
26
26
|
|
27
27
|
start = Time.now
|
28
|
-
res =
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
28
|
+
res =
|
29
|
+
if retry_timeout.zero?
|
30
|
+
run(region, tag, rotation)
|
31
|
+
elsif run_once_after_wait
|
32
|
+
run(region, tag, rotation, retry_timeout)
|
33
|
+
else
|
34
|
+
run_with_intervals(region, tag, rotation, retry_timeout)
|
35
|
+
end
|
35
36
|
elapsed_time = Time.now - start
|
36
37
|
|
37
38
|
Applitools::EyesLogger.debug "match_window(): Completed in #{format('%.2f', elapsed_time)} seconds"
|
38
39
|
|
39
40
|
@last_checked_window = @current_screenshot
|
40
|
-
@last_screenshot_bounds =
|
41
|
-
|
41
|
+
@last_screenshot_bounds =
|
42
|
+
if region.empty?
|
43
|
+
Applitools::Base::Region.new(0, 0, last_checked_window.width, last_checked_window.height)
|
44
|
+
else
|
45
|
+
region
|
46
|
+
end
|
42
47
|
driver.clear_user_inputs
|
43
48
|
|
44
49
|
res
|
45
50
|
end
|
46
51
|
|
47
|
-
def run(region, tag, rotation, wait_before_run=nil)
|
52
|
+
def run(region, tag, rotation, wait_before_run = nil)
|
48
53
|
Applitools::EyesLogger.debug 'Trying matching once...'
|
49
54
|
|
50
55
|
if wait_before_run
|
@@ -85,10 +90,12 @@ module Applitools::Selenium
|
|
85
90
|
private
|
86
91
|
|
87
92
|
def get_clipped_region(region, image)
|
88
|
-
left
|
93
|
+
left = [region.left, 0].max
|
94
|
+
top = [region.top, 0].max
|
89
95
|
max_width = image.width - left
|
90
96
|
max_height = image.height - top
|
91
|
-
width
|
97
|
+
width = [region.width, max_width].min
|
98
|
+
height = [region.height, max_height].min
|
92
99
|
Applitools::Base::Region.new(left, top, width, height)
|
93
100
|
end
|
94
101
|
|
@@ -130,12 +137,12 @@ module Applitools::Selenium
|
|
130
137
|
trigger.control.intersect(last_screenshot_bounds)
|
131
138
|
if trigger.control.empty?
|
132
139
|
trigger_left -= - last_screenshot_bounds.left
|
133
|
-
trigger_top
|
140
|
+
trigger_top -= last_screenshot_bounds.top
|
134
141
|
updated_trigger = Applitools::Base::MouseTrigger.new(trigger.mouse_action, trigger.control,
|
135
142
|
Applitools::Base::Point.new(trigger_left, trigger_top))
|
136
143
|
else
|
137
|
-
trigger_left
|
138
|
-
trigger_top
|
144
|
+
trigger_left -= trigger.control.left
|
145
|
+
trigger_top -= trigger.control.top
|
139
146
|
control_left = trigger.control.left - last_screenshot_bounds.left
|
140
147
|
control_top = trigger.control.top - last_screenshot_bounds.top
|
141
148
|
updated_control = Applitools::Base::Region.new(control_left, control_top, trigger.control.width,
|
@@ -149,9 +156,9 @@ module Applitools::Selenium
|
|
149
156
|
Applitools::EyesLogger.info "Trigger ignored: #{trigger} (out of bounds)"
|
150
157
|
end
|
151
158
|
elsif trigger.is_a?(Applitools::Base::TextTrigger)
|
152
|
-
|
159
|
+
if !trigger.control.empty?
|
153
160
|
trigger.control.intersect(last_screenshot_bounds)
|
154
|
-
|
161
|
+
if !trigger.control.empty?
|
155
162
|
control_left = trigger.control.left - last_screenshot_bounds.left
|
156
163
|
control_top = trigger.control.top - last_screenshot_bounds.top
|
157
164
|
updated_control = Applitools::Base::Region.new(control_left, control_top, trigger.control.width,
|
@@ -179,7 +186,7 @@ module Applitools::Selenium
|
|
179
186
|
match_window_data_obj
|
180
187
|
end
|
181
188
|
|
182
|
-
def match(region, tag, rotation, ignore_mismatch=false)
|
189
|
+
def match(region, tag, rotation, ignore_mismatch = false)
|
183
190
|
Applitools::EyesLogger.debug 'Match called...'
|
184
191
|
data = prep_match_data(region, tag, rotation, ignore_mismatch)
|
185
192
|
match_result = Applitools::Base::ServerConnector.match_window(session, data)
|
@@ -35,16 +35,16 @@ module Applitools::Selenium
|
|
35
35
|
current_control = Applitools::Base::Region.new(0, 0, *location.values)
|
36
36
|
driver.user_inputs << Applitools::Base::MouseTrigger.new(:move, current_control, location)
|
37
37
|
element = element.web_element if element.is_a?(Applitools::Selenium::Element)
|
38
|
-
mouse.move_to(element,right_by, down_by)
|
38
|
+
mouse.move_to(element, right_by, down_by)
|
39
39
|
end
|
40
40
|
|
41
41
|
def move_by(right_by, down_by)
|
42
42
|
right = [0, right_by].max.round
|
43
43
|
down = [0, down_by].max.round
|
44
|
-
location = Applitools::Base::Point.new(right,down)
|
44
|
+
location = Applitools::Base::Point.new(right, down)
|
45
45
|
current_control = Applitools::Base::Region.new(0, 0, right, down)
|
46
46
|
driver.user_inputs << Applitools::Base::MouseTrigger.new(:move, current_control, location)
|
47
|
-
mouse.move_by(right_by,down_by)
|
47
|
+
mouse.move_by(right_by, down_by)
|
48
48
|
end
|
49
49
|
|
50
50
|
private
|
@@ -1,37 +1,41 @@
|
|
1
1
|
module Applitools::Selenium
|
2
2
|
class ViewportSize
|
3
|
-
JS_GET_VIEWPORT_HEIGHT = <<-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
else if (document.documentElement && document.documentElement.clientHeight) {
|
9
|
-
height = document.documentElement.clientHeight;
|
10
|
-
} else {
|
11
|
-
var b = document.getElementsByTagName("body")[0];
|
12
|
-
if (b.clientHeight) {
|
13
|
-
height = b.clientHeight;
|
3
|
+
JS_GET_VIEWPORT_HEIGHT = (<<-JS).freeze
|
4
|
+
return (function() {
|
5
|
+
var height = undefined;
|
6
|
+
if (window.innerHeight) {
|
7
|
+
height = window.innerHeight;
|
14
8
|
}
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
if (window.innerWidth) {
|
23
|
-
width = window.innerWidth
|
24
|
-
} else if (document.documentElement && document.documentElement.clientWidth) {
|
25
|
-
width = document.documentElement.clientWidth;
|
26
|
-
} else {
|
27
|
-
var b = document.getElementsByTagName("body")[0];
|
28
|
-
if (b.clientWidth) {
|
29
|
-
width = b.clientWidth;
|
9
|
+
else if (document.documentElement && document.documentElement.clientHeight) {
|
10
|
+
height = document.documentElement.clientHeight;
|
11
|
+
} else {
|
12
|
+
var b = document.getElementsByTagName("body")[0];
|
13
|
+
if (b.clientHeight) {
|
14
|
+
height = b.clientHeight;
|
15
|
+
}
|
30
16
|
}
|
31
|
-
}
|
32
17
|
|
33
|
-
|
34
|
-
|
18
|
+
return height;
|
19
|
+
}());
|
20
|
+
JS
|
21
|
+
|
22
|
+
JS_GET_VIEWPORT_WIDTH = (<<-JS).freeze
|
23
|
+
return (function() {
|
24
|
+
var width = undefined;
|
25
|
+
if (window.innerWidth) {
|
26
|
+
width = window.innerWidth
|
27
|
+
} else if (document.documentElement && document.documentElement.clientWidth) {
|
28
|
+
width = document.documentElement.clientWidth;
|
29
|
+
} else {
|
30
|
+
var b = document.getElementsByTagName("body")[0];
|
31
|
+
if (b.clientWidth) {
|
32
|
+
width = b.clientWidth;
|
33
|
+
}
|
34
|
+
}
|
35
|
+
|
36
|
+
return width;
|
37
|
+
}());
|
38
|
+
JS
|
35
39
|
|
36
40
|
VERIFY_SLEEP_PERIOD = 1.freeze
|
37
41
|
VERIFY_RETRIES = 3.freeze
|
@@ -54,7 +58,8 @@ module Applitools::Selenium
|
|
54
58
|
end
|
55
59
|
|
56
60
|
def extract_viewport_from_browser
|
57
|
-
width
|
61
|
+
width = nil
|
62
|
+
height = nil
|
58
63
|
begin
|
59
64
|
width = extract_viewport_width
|
60
65
|
height = extract_viewport_height
|
@@ -63,51 +68,52 @@ module Applitools::Selenium
|
|
63
68
|
end
|
64
69
|
|
65
70
|
if width.nil? || height.nil?
|
66
|
-
Applitools::EyesLogger.info
|
71
|
+
Applitools::EyesLogger.info 'Using window size as viewport size.'
|
67
72
|
|
68
73
|
width, height = *browser_size.values
|
69
|
-
width
|
74
|
+
width = width.ceil
|
75
|
+
height = height.ceil
|
70
76
|
|
71
77
|
if @driver.landscape_orientation? && height > width
|
72
78
|
width, height = height, width
|
73
79
|
end
|
74
80
|
end
|
75
81
|
|
76
|
-
Applitools::Base::Dimension.new(width,height)
|
82
|
+
Applitools::Base::Dimension.new(width, height)
|
77
83
|
end
|
78
84
|
|
79
85
|
alias_method :viewport_size, :extract_viewport_from_browser
|
80
86
|
|
81
87
|
def set
|
82
|
-
if @dimension.is_a?(Hash) && @dimension.
|
88
|
+
if @dimension.is_a?(Hash) && @dimension.key?(:width) && @dimension.key?(:height)
|
83
89
|
# If @dimension is hash of width/height, we convert it to a struct with width/height properties.
|
84
90
|
@dimension = Struct.new(:width, :height).new(@dimension[:width], @dimension[:height])
|
85
91
|
elsif !@dimension.respond_to?(:width) || !@dimension.respond_to?(:height)
|
86
|
-
raise ArgumentError, "expected #{@dimension.inspect}:#{@dimension.class} to respond to #width and #height, or
|
87
|
-
'
|
92
|
+
raise ArgumentError, "expected #{@dimension.inspect}:#{@dimension.class} to respond to #width and #height, or "\
|
93
|
+
'be a hash with these keys.'
|
88
94
|
end
|
89
95
|
|
90
|
-
|
96
|
+
resize_browser(@dimension)
|
91
97
|
verify_size(:browser_size)
|
92
98
|
|
93
|
-
|
99
|
+
current_viewport_size = extract_viewport_from_browser
|
94
100
|
|
95
|
-
|
96
|
-
(2 * browser_size.height) -
|
101
|
+
resize_browser(Applitools::Base::Dimension.new((2 * browser_size.width) - current_viewport_size.width,
|
102
|
+
(2 * browser_size.height) - current_viewport_size.height))
|
97
103
|
verify_size(:viewport_size)
|
98
104
|
end
|
99
105
|
|
100
|
-
def verify_size(to_verify
|
101
|
-
|
106
|
+
def verify_size(to_verify)
|
107
|
+
current_size = nil
|
102
108
|
|
103
|
-
|
104
|
-
sleep(
|
105
|
-
|
109
|
+
VERIFY_RETRIES.times do
|
110
|
+
sleep(VERIFY_SLEEP_PERIOD)
|
111
|
+
current_size = send(to_verify)
|
106
112
|
|
107
|
-
return if
|
113
|
+
return if current_size.values == @dimension.values
|
108
114
|
end
|
109
115
|
|
110
|
-
err_msg = "Failed setting #{to_verify} to #{@dimension.values} (current size: #{
|
116
|
+
err_msg = "Failed setting #{to_verify} to #{@dimension.values} (current size: #{current_size.values})"
|
111
117
|
|
112
118
|
Applitools::EyesLogger.error(err_msg)
|
113
119
|
raise Applitools::TestFailedError.new(err_msg)
|
@@ -117,7 +123,10 @@ module Applitools::Selenium
|
|
117
123
|
@driver.manage.window.size
|
118
124
|
end
|
119
125
|
|
120
|
-
def
|
126
|
+
def resize_browser(other)
|
127
|
+
# Before resizing the window, set its position to the upper left corner (otherwise, there might not be enough
|
128
|
+
# "space" below/next to it and the operation won't be successful).
|
129
|
+
@driver.manage.window.position = Selenium::WebDriver::Point.new(0, 0)
|
121
130
|
@driver.manage.window.size = other
|
122
131
|
end
|
123
132
|
|
@@ -1,150 +1,148 @@
|
|
1
|
-
require 'oily_png'
|
2
|
-
|
3
|
-
module Applitools::Utils
|
4
|
-
module ImageDeltaCompressor
|
5
|
-
extend self
|
6
|
-
|
7
|
-
BLOCK_SIZE = 10.freeze
|
8
|
-
|
9
|
-
# Compresses the target image based on the source image.
|
10
|
-
#
|
11
|
-
# +target+:: +ChunkyPNG::Canvas+ The image to compress based on the source image.
|
12
|
-
# +target_encoded+:: +Array+ The uncompressed image as binary string.
|
13
|
-
# +source+:: +ChunkyPNG::Canvas+ The source image used as a base for compressing the target image.
|
14
|
-
# +block_size+:: +Integer+ The width/height of each block.
|
15
|
-
#
|
16
|
-
# Returns +String+ The binary result (either the compressed image, or the uncompressed image if the compression
|
17
|
-
# is greater in length).
|
18
|
-
def compress_by_raw_blocks(target, target_encoded, source, block_size = BLOCK_SIZE)
|
19
|
-
# If we can't compress for any reason, return the target image as is.
|
20
|
-
if source.nil? || (source.height != target.height) || (source.width != target.width)
|
21
|
-
# Returning a COPY of the target binary string.
|
22
|
-
return String.new(target_encoded)
|
23
|
-
end
|
24
|
-
|
25
|
-
# Preparing the variables we need.
|
26
|
-
target_pixels = target.to_rgb_stream.unpack('C*')
|
27
|
-
source_pixels = source.to_rgb_stream.unpack('C*')
|
28
|
-
image_size = Dimension.new(target.width, target.height)
|
29
|
-
block_columns_count = (target.width / block_size) + ((target.width % block_size) == 0 ? 0 : 1)
|
30
|
-
block_rows_count = (target.height / block_size) + ((target.height % block_size) == 0 ? 0 : 1)
|
31
|
-
|
32
|
-
# IMPORTANT: The "-Zlib::MAX_WBITS" tells ZLib to create raw deflate compression, without the
|
33
|
-
# "Zlib headers" (this isn't documented in the Zlib page, I found this in some internet forum).
|
34
|
-
compressor = Zlib::Deflate.new(Zlib::BEST_COMPRESSION, -Zlib::MAX_WBITS)
|
35
|
-
|
36
|
-
compression_result = ''
|
37
|
-
|
38
|
-
# Writing the data header.
|
39
|
-
compression_result += PREAMBLE.encode('UTF-8')
|
40
|
-
compression_result += [FORMAT_RAW_BLOCKS].pack('C')
|
41
|
-
compression_result += [0].pack('S>') #Source id, Big Endian
|
42
|
-
compression_result += [block_size].pack('S>') #Big Endian
|
43
|
-
|
44
|
-
# We perform the compression for each channel.
|
45
|
-
3.times do |channel|
|
46
|
-
block_number = 0
|
47
|
-
block_rows_count.times do |block_row|
|
48
|
-
block_columns_count.times do |block_column|
|
49
|
-
actual_channel_index = 2 - channel # Since the image bytes are BGR and the server expects RGB...
|
50
|
-
compare_result = compare_and_copy_block_channel_data(source_pixels, target_pixels, image_size, 3,
|
51
|
-
block_column, block_row, actual_channel_index)
|
52
|
-
|
53
|
-
unless compare_result.identical
|
54
|
-
channel_bytes = compare_result.channel_bytes
|
55
|
-
string_to_compress = [channel].pack('C')
|
56
|
-
string_to_compress += [block_number].pack('L>')
|
57
|
-
string_to_compress += channel_bytes.pack('C*')
|
58
|
-
|
59
|
-
compression_result += compressor.deflate(string_to_compress)
|
60
|
-
|
61
|
-
# If the compressed data so far is greater than the uncompressed representation of the target, just return
|
62
|
-
# the target.
|
63
|
-
if compression_result.length > target_encoded.length
|
64
|
-
|
65
|
-
compressor.close
|
66
|
-
# Returning a copy of the target bytes.
|
67
|
-
return String.new(target_encoded)
|
68
|
-
end
|
69
|
-
end
|
70
|
-
|
71
|
-
block_number += 1
|
72
|
-
end
|
73
|
-
end
|
74
|
-
end
|
75
|
-
|
76
|
-
# Compress and flush any remaining uncompressed data in the input buffer.
|
77
|
-
compression_result += compressor.finish
|
78
|
-
compressor.close
|
79
|
-
|
80
|
-
# Returning the compressed result as a byte array.
|
81
|
-
compression_result
|
82
|
-
end
|
83
|
-
|
84
|
-
private
|
85
|
-
|
86
|
-
PREAMBLE = 'applitools'.freeze
|
87
|
-
FORMAT_RAW_BLOCKS = 3.freeze
|
88
|
-
|
89
|
-
Dimension = Struct.new(:width, :height)
|
90
|
-
CompareAndCopyBlockChannelDataResult = Struct.new(:identical, :channel_bytes)
|
91
|
-
|
92
|
-
# Computes the width and height of the image data contained in the block at the input column and row.
|
93
|
-
# +image_size+:: +Dimension+ The image size in pixels.
|
94
|
-
# +block_size+:: The block size for which we would like to compute the image data width and height.
|
95
|
-
# +block_column+:: The block column index.
|
96
|
-
# +block_row+:: The block row index.
|
97
|
-
# ++
|
98
|
-
# Returns the width and height of the image data contained in the block are returned as a +Dimension+.
|
99
|
-
def get_actual_block_size(image_size, block_size, block_column, block_row)
|
100
|
-
actual_width = [image_size.width - (block_column * block_size), block_size].min
|
101
|
-
actual_height = [image_size.height - (block_row * block_size), block_size].min
|
102
|
-
Dimension.new(actual_width, actual_height)
|
103
|
-
end
|
104
|
-
|
105
|
-
# Compares a block of pixels between the source and target and copies the target's block bytes to the result.
|
106
|
-
# +source_pixels+:: +Array+ of bytes, representing the pixels of the source image.
|
107
|
-
# +target_pixels+:: +Array+ of bytes, representing the pixels of the target image.
|
108
|
-
# +image_size+:: +Dimension+ The size of the source/target image (remember they must be the same size).
|
109
|
-
# +pixel_length+:: +Integer+ The number of bytes composing a pixel
|
110
|
-
# +block_size+:: +Integer+ The width/height of the block (block is a square, theoretically).
|
111
|
-
# +block_column+:: +Integer+ The block column index (when looking at the images as a grid of blocks).
|
112
|
-
# +block_row+:: +Integer+ The block row index (when looking at the images as a grid of blocks).
|
113
|
-
# +channel+:: +Integer+ The index of the channel we're comparing.
|
114
|
-
# ++
|
115
|
-
# Returns +CompareAndCopyBlockChannelDataResult+ object containing a flag saying whether the blocks are identical
|
116
|
-
# and a copy of the target block's bytes.
|
117
|
-
def compare_and_copy_block_channel_data(source_pixels, target_pixels, image_size, pixel_length, block_size,
|
118
|
-
block_column, block_row, channel)
|
119
|
-
identical = true
|
120
|
-
|
121
|
-
actual_block_size = get_actual_block_size(image_size, block_size, block_column, block_row)
|
122
|
-
|
123
|
-
# Getting the actual amount of data in the block we wish to copy.
|
124
|
-
actual_block_height = actual_block_size.height
|
125
|
-
actual_block_width = actual_block_size.width
|
126
|
-
|
127
|
-
stride = image_size.width * pixel_length
|
128
|
-
|
129
|
-
# Iterating the block's pixels and comparing the source and target.
|
130
|
-
channel_bytes = []
|
131
|
-
actual_block_height.times do |h|
|
132
|
-
offset = (((block_size * block_row) + h) * stride) + (block_size * block_column * pixel_length) + channel
|
133
|
-
actual_block_width.times do |
|
134
|
-
source_byte = source_pixels[offset]
|
135
|
-
target_byte = target_pixels[offset]
|
136
|
-
if source_byte != target_byte
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
end
|
150
|
-
end
|
1
|
+
require 'oily_png'
|
2
|
+
|
3
|
+
module Applitools::Utils
|
4
|
+
module ImageDeltaCompressor
|
5
|
+
extend self
|
6
|
+
|
7
|
+
BLOCK_SIZE = 10.freeze
|
8
|
+
|
9
|
+
# Compresses the target image based on the source image.
|
10
|
+
#
|
11
|
+
# +target+:: +ChunkyPNG::Canvas+ The image to compress based on the source image.
|
12
|
+
# +target_encoded+:: +Array+ The uncompressed image as binary string.
|
13
|
+
# +source+:: +ChunkyPNG::Canvas+ The source image used as a base for compressing the target image.
|
14
|
+
# +block_size+:: +Integer+ The width/height of each block.
|
15
|
+
#
|
16
|
+
# Returns +String+ The binary result (either the compressed image, or the uncompressed image if the compression
|
17
|
+
# is greater in length).
|
18
|
+
def compress_by_raw_blocks(target, target_encoded, source, block_size = BLOCK_SIZE)
|
19
|
+
# If we can't compress for any reason, return the target image as is.
|
20
|
+
if source.nil? || (source.height != target.height) || (source.width != target.width)
|
21
|
+
# Returning a COPY of the target binary string.
|
22
|
+
return String.new(target_encoded)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Preparing the variables we need.
|
26
|
+
target_pixels = target.to_rgb_stream.unpack('C*')
|
27
|
+
source_pixels = source.to_rgb_stream.unpack('C*')
|
28
|
+
image_size = Dimension.new(target.width, target.height)
|
29
|
+
block_columns_count = (target.width / block_size) + ((target.width % block_size) == 0 ? 0 : 1)
|
30
|
+
block_rows_count = (target.height / block_size) + ((target.height % block_size) == 0 ? 0 : 1)
|
31
|
+
|
32
|
+
# IMPORTANT: The "-Zlib::MAX_WBITS" tells ZLib to create raw deflate compression, without the
|
33
|
+
# "Zlib headers" (this isn't documented in the Zlib page, I found this in some internet forum).
|
34
|
+
compressor = Zlib::Deflate.new(Zlib::BEST_COMPRESSION, -Zlib::MAX_WBITS)
|
35
|
+
|
36
|
+
compression_result = ''
|
37
|
+
|
38
|
+
# Writing the data header.
|
39
|
+
compression_result += PREAMBLE.encode('UTF-8')
|
40
|
+
compression_result += [FORMAT_RAW_BLOCKS].pack('C')
|
41
|
+
compression_result += [0].pack('S>') # Source id, Big Endian
|
42
|
+
compression_result += [block_size].pack('S>') # Big Endian
|
43
|
+
|
44
|
+
# We perform the compression for each channel.
|
45
|
+
3.times do |channel|
|
46
|
+
block_number = 0
|
47
|
+
block_rows_count.times do |block_row|
|
48
|
+
block_columns_count.times do |block_column|
|
49
|
+
actual_channel_index = 2 - channel # Since the image bytes are BGR and the server expects RGB...
|
50
|
+
compare_result = compare_and_copy_block_channel_data(source_pixels, target_pixels, image_size, 3,
|
51
|
+
block_size, block_column, block_row, actual_channel_index)
|
52
|
+
|
53
|
+
unless compare_result.identical
|
54
|
+
channel_bytes = compare_result.channel_bytes
|
55
|
+
string_to_compress = [channel].pack('C')
|
56
|
+
string_to_compress += [block_number].pack('L>')
|
57
|
+
string_to_compress += channel_bytes.pack('C*')
|
58
|
+
|
59
|
+
compression_result += compressor.deflate(string_to_compress)
|
60
|
+
|
61
|
+
# If the compressed data so far is greater than the uncompressed representation of the target, just return
|
62
|
+
# the target.
|
63
|
+
if compression_result.length > target_encoded.length
|
64
|
+
compressor.finish
|
65
|
+
compressor.close
|
66
|
+
# Returning a copy of the target bytes.
|
67
|
+
return String.new(target_encoded)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
block_number += 1
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# Compress and flush any remaining uncompressed data in the input buffer.
|
77
|
+
compression_result += compressor.finish
|
78
|
+
compressor.close
|
79
|
+
|
80
|
+
# Returning the compressed result as a byte array.
|
81
|
+
compression_result
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
PREAMBLE = 'applitools'.freeze
|
87
|
+
FORMAT_RAW_BLOCKS = 3.freeze
|
88
|
+
|
89
|
+
Dimension = Struct.new(:width, :height)
|
90
|
+
CompareAndCopyBlockChannelDataResult = Struct.new(:identical, :channel_bytes)
|
91
|
+
|
92
|
+
# Computes the width and height of the image data contained in the block at the input column and row.
|
93
|
+
# +image_size+:: +Dimension+ The image size in pixels.
|
94
|
+
# +block_size+:: The block size for which we would like to compute the image data width and height.
|
95
|
+
# +block_column+:: The block column index.
|
96
|
+
# +block_row+:: The block row index.
|
97
|
+
# ++
|
98
|
+
# Returns the width and height of the image data contained in the block are returned as a +Dimension+.
|
99
|
+
def get_actual_block_size(image_size, block_size, block_column, block_row)
|
100
|
+
actual_width = [image_size.width - (block_column * block_size), block_size].min
|
101
|
+
actual_height = [image_size.height - (block_row * block_size), block_size].min
|
102
|
+
Dimension.new(actual_width, actual_height)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Compares a block of pixels between the source and target and copies the target's block bytes to the result.
|
106
|
+
# +source_pixels+:: +Array+ of bytes, representing the pixels of the source image.
|
107
|
+
# +target_pixels+:: +Array+ of bytes, representing the pixels of the target image.
|
108
|
+
# +image_size+:: +Dimension+ The size of the source/target image (remember they must be the same size).
|
109
|
+
# +pixel_length+:: +Integer+ The number of bytes composing a pixel
|
110
|
+
# +block_size+:: +Integer+ The width/height of the block (block is a square, theoretically).
|
111
|
+
# +block_column+:: +Integer+ The block column index (when looking at the images as a grid of blocks).
|
112
|
+
# +block_row+:: +Integer+ The block row index (when looking at the images as a grid of blocks).
|
113
|
+
# +channel+:: +Integer+ The index of the channel we're comparing.
|
114
|
+
# ++
|
115
|
+
# Returns +CompareAndCopyBlockChannelDataResult+ object containing a flag saying whether the blocks are identical
|
116
|
+
# and a copy of the target block's bytes.
|
117
|
+
def compare_and_copy_block_channel_data(source_pixels, target_pixels, image_size, pixel_length, block_size,
|
118
|
+
block_column, block_row, channel)
|
119
|
+
identical = true
|
120
|
+
|
121
|
+
actual_block_size = get_actual_block_size(image_size, block_size, block_column, block_row)
|
122
|
+
|
123
|
+
# Getting the actual amount of data in the block we wish to copy.
|
124
|
+
actual_block_height = actual_block_size.height
|
125
|
+
actual_block_width = actual_block_size.width
|
126
|
+
|
127
|
+
stride = image_size.width * pixel_length
|
128
|
+
|
129
|
+
# Iterating the block's pixels and comparing the source and target.
|
130
|
+
channel_bytes = []
|
131
|
+
actual_block_height.times do |h|
|
132
|
+
offset = (((block_size * block_row) + h) * stride) + (block_size * block_column * pixel_length) + channel
|
133
|
+
actual_block_width.times do |_w|
|
134
|
+
source_byte = source_pixels[offset]
|
135
|
+
target_byte = target_pixels[offset]
|
136
|
+
identical = false if source_byte != target_byte
|
137
|
+
channel_bytes << target_byte
|
138
|
+
offset += pixel_length
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# Returning the compare-and-copy result.
|
143
|
+
CompareAndCopyBlockChannelDataResult.new(identical, channel_bytes)
|
144
|
+
end
|
145
|
+
|
146
|
+
include Applitools::MethodTracer
|
147
|
+
end
|
148
|
+
end
|