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.
- 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
|