eyes_core 3.0.4

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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/ext/eyes_core/extconf.rb +3 -0
  3. data/ext/eyes_core/eyes_core.c +80 -0
  4. data/ext/eyes_core/eyes_core.h +24 -0
  5. data/lib/applitools/capybara.rb +8 -0
  6. data/lib/applitools/chunky_png/resampling.rb +148 -0
  7. data/lib/applitools/chunky_png_patch.rb +8 -0
  8. data/lib/applitools/connectivity/proxy.rb +3 -0
  9. data/lib/applitools/connectivity/server_connector.rb +118 -0
  10. data/lib/applitools/core/app_environment.rb +29 -0
  11. data/lib/applitools/core/app_output.rb +17 -0
  12. data/lib/applitools/core/app_output_with_screenshot.rb +22 -0
  13. data/lib/applitools/core/argument_guard.rb +35 -0
  14. data/lib/applitools/core/batch_info.rb +18 -0
  15. data/lib/applitools/core/eyes_base.rb +463 -0
  16. data/lib/applitools/core/eyes_screenshot.rb +35 -0
  17. data/lib/applitools/core/fixed_cut_provider.rb +61 -0
  18. data/lib/applitools/core/fixed_scale_provider.rb +14 -0
  19. data/lib/applitools/core/helpers.rb +18 -0
  20. data/lib/applitools/core/location.rb +84 -0
  21. data/lib/applitools/core/match_result.rb +16 -0
  22. data/lib/applitools/core/match_results.rb +9 -0
  23. data/lib/applitools/core/match_window_data.rb +34 -0
  24. data/lib/applitools/core/match_window_task.rb +86 -0
  25. data/lib/applitools/core/mouse_trigger.rb +39 -0
  26. data/lib/applitools/core/rectangle_size.rb +46 -0
  27. data/lib/applitools/core/region.rb +180 -0
  28. data/lib/applitools/core/screenshot.rb +49 -0
  29. data/lib/applitools/core/session.rb +15 -0
  30. data/lib/applitools/core/session_start_info.rb +33 -0
  31. data/lib/applitools/core/test_results.rb +55 -0
  32. data/lib/applitools/core/text_trigger.rb +24 -0
  33. data/lib/applitools/core/trigger.rb +8 -0
  34. data/lib/applitools/extensions.rb +18 -0
  35. data/lib/applitools/eyes_logger.rb +45 -0
  36. data/lib/applitools/images/eyes.rb +204 -0
  37. data/lib/applitools/images/eyes_images_screenshot.rb +102 -0
  38. data/lib/applitools/method_tracer.rb +23 -0
  39. data/lib/applitools/sauce.rb +2 -0
  40. data/lib/applitools/utils/eyes_selenium_utils.rb +348 -0
  41. data/lib/applitools/utils/image_delta_compressor.rb +146 -0
  42. data/lib/applitools/utils/image_utils.rb +146 -0
  43. data/lib/applitools/utils/utils.rb +68 -0
  44. data/lib/applitools/version.rb +3 -0
  45. data/lib/eyes_core.rb +70 -0
  46. metadata +273 -0
@@ -0,0 +1,102 @@
1
+ module Applitools::Images
2
+ # @!visibility private
3
+ class EyesImagesScreenshot < ::Applitools::EyesScreenshot
4
+ SCREENSHOT_AS_IS = Applitools::EyesScreenshot::COORDINATE_TYPES[:screenshot_as_is].freeze
5
+ CONTEXT_RELATIVE = Applitools::EyesScreenshot::COORDINATE_TYPES[:context_relative].freeze
6
+
7
+ def initialize(image, options = {})
8
+ super image
9
+ return if (location = options[:location]).nil?
10
+ Applitools::ArgumentGuard.is_a? location, 'options[:location]', Applitools::Location
11
+ @bounds = Applitools::Region.new location.x, location.y, image.width, image.height
12
+ end
13
+
14
+ def convert_location(location, from, to)
15
+ Applitools::ArgumentGuard.not_nil location, 'location'
16
+ Applitools::ArgumentGuard.not_nil from, 'from'
17
+ Applitools::ArgumentGuard.not_nil to, 'to'
18
+
19
+ Applitools::ArgumentGuard.is_a? location, 'location', Applitools::Location
20
+
21
+ result = Applitools::Location.new location.x, location.y
22
+ return result if from == to
23
+
24
+ case from
25
+ when SCREENSHOT_AS_IS
26
+ raise "Coordinate type conversation error: #{from} -> #{to}" unless to == CONTEXT_RELATIVE
27
+ result.offset bounds
28
+ return result
29
+ when CONTEXT_RELATIVE
30
+ raise "Coordinate type conversation error: #{from} -> #{to}" unless to == SCREENSHOT_AS_IS
31
+ result.offset(Applitools::Location.new(-bounds.x, -bounds.y))
32
+ return result
33
+ else
34
+ raise "Coordinate type conversation error: #{from} -> #{to}"
35
+ end
36
+ end
37
+
38
+ def convert_region_location(region, from, to)
39
+ Applitools::ArgumentGuard.not_nil region, 'region'
40
+ return Core::Region.new(0, 0, 0, 0) if region.empty?
41
+
42
+ Applitools::ArgumentGuard.not_nil from, 'from'
43
+ Applitools::ArgumentGuard.not_nil to, 'to'
44
+
45
+ updated_location = convert_location region.location, from, to
46
+
47
+ Applitools::Region.new updated_location.x, updated_location.y, region.width, region.height
48
+ end
49
+
50
+ def intersected_region(region, from, to = CONTEXT_RELATIVE)
51
+ Applitools::ArgumentGuard.not_nil region, 'region'
52
+ Applitools::ArgumentGuard.not_nil from, 'coordinates Type (from)'
53
+
54
+ return Applitools::Region.new(0, 0, 0, 0) if region.empty?
55
+
56
+ intersected_region = convert_region_location region, from, to
57
+ intersected_region.intersect bounds
58
+ return intersected_region if intersected_region.empty?
59
+
60
+ intersected_region.location = convert_location intersected_region.location, to, from
61
+ intersected_region
62
+ end
63
+
64
+ def location_in_screenshot(location, coordinates_type)
65
+ Applitools::ArgumentGuard.not_nil location, 'location'
66
+ Applitools::ArgumentGuard.not_nil coordinates_type, 'coordinates_type'
67
+ location = convert_location(location, coordinates_type, CONTEXT_RELATIVE)
68
+
69
+ unless bounds.contains? location.left, location.top
70
+ raise Applitools::OutOfBoundsException.new "Location #{location} is not available in screenshot!"
71
+ end
72
+
73
+ convert_location location, CONTEXT_RELATIVE, SCREENSHOT_AS_IS
74
+ end
75
+
76
+ def sub_screenshot(region, coordinates_type, throw_if_clipped)
77
+ Applitools::ArgumentGuard.not_nil region, 'region'
78
+ Applitools::ArgumentGuard.not_nil coordinates_type, 'coordinates_type'
79
+
80
+ sub_screen_region = intersected_region region, coordinates_type, SCREENSHOT_AS_IS
81
+
82
+ if sub_screen_region.empty? || (throw_if_clipped && !region.size_equals?(sub_screen_region))
83
+ Applitools::OutOfBoundsException.new "Region #{sub_screen_region} (#{coordinates_type}) is out of " \
84
+ " screenshot bounds #{bounds}"
85
+ end
86
+
87
+ sub_screenshot_image = Applitools::Screenshot.new image.crop(sub_screen_region.left, sub_screen_region.top,
88
+ sub_screen_region.width, sub_screen_region.height).to_datastream.to_blob
89
+
90
+ relative_sub_screenshot_region = convert_region_location(sub_screen_region, SCREENSHOT_AS_IS, CONTEXT_RELATIVE)
91
+
92
+ Applitools::Images::EyesImagesScreenshot.new sub_screenshot_image,
93
+ location: relative_sub_screenshot_region.location
94
+ end
95
+
96
+ private
97
+
98
+ def bounds
99
+ @bounds ||= Applitools::Region.new(0, 0, image.width, image.height)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,23 @@
1
+ # @!visibility private
2
+ module Applitools::MethodTracer
3
+ def self.included(base)
4
+ instance_methods = base.instance_methods(false) + base.private_instance_methods(false)
5
+ class_methods = base.methods(false)
6
+
7
+ base.class_eval do
8
+ def self.trace_method(base, method_name, instance = true)
9
+ original_method = instance ? instance_method(method_name) : method(method_name)
10
+
11
+ send(instance ? :define_method : :define_singleton_method, method_name) do |*args, &block|
12
+ Applitools::EyesLogger.debug "-> #{base}##{method_name}"
13
+ return_value = (instance ? original_method.bind(self) : original_method).call(*args, &block)
14
+ Applitools::EyesLogger.debug "<- #{base}##{method_name}"
15
+ return_value
16
+ end
17
+ end
18
+
19
+ instance_methods.each { |method_name| trace_method(base, method_name) }
20
+ class_methods.each { |method_name| trace_method(base, method_name, false) }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,2 @@
1
+ require_relative 'capybara' if defined? Capybara
2
+ Applitools.require_dir 'selenium/sauce'
@@ -0,0 +1,348 @@
1
+ module Applitools::Utils
2
+ module EyesSeleniumUtils
3
+ extend self
4
+
5
+ # @!visibility private
6
+ JS_GET_VIEWPORT_SIZE = <<-JS.freeze
7
+ return (function() {
8
+ var height = undefined;
9
+ var width = undefined;
10
+ if (window.innerHeight) {height = window.innerHeight;}
11
+ else if (document.documentElement && document.documentElement.clientHeight)
12
+ {height = document.documentElement.clientHeight;}
13
+ else { var b = document.getElementsByTagName('body')[0];
14
+ if (b.clientHeight) {height = b.clientHeight;}
15
+ };
16
+
17
+ if (window.innerWidth) {width = window.innerWidth;}
18
+ else if (document.documentElement && document.documentElement.clientWidth)
19
+ {width = document.documentElement.clientWidth;}
20
+ else { var b = document.getElementsByTagName('body')[0];
21
+ if (b.clientWidth) {width = b.clientWidth;}
22
+ };
23
+ return [width, height];
24
+ }());
25
+ JS
26
+
27
+ # @!visibility private
28
+ JS_GET_USER_AGENT = <<-JS.freeze
29
+ return navigator.userAgent;
30
+ JS
31
+
32
+ # @!visibility private
33
+ JS_GET_DEVICE_PIXEL_RATIO = <<-JS.freeze
34
+ return window.devicePixelRatio;
35
+ JS
36
+
37
+ # @!visibility private
38
+ JS_GET_PAGE_METRICS = <<-JS.freeze
39
+ return {
40
+ scrollWidth: document.documentElement.scrollWidth,
41
+ bodyScrollWidth: document.body.scrollWidth,
42
+ clientHeight: document.documentElement.clientHeight,
43
+ bodyClientHeight: document.body.clientHeight,
44
+ scrollHeight: document.documentElement.scrollHeight,
45
+ bodyScrollHeight: document.body.scrollHeight
46
+ };
47
+ JS
48
+
49
+ # @!visibility private
50
+ JS_GET_CONTENT_ENTIRE_SIZE = <<-JS.freeze
51
+ var scrollWidth = document.documentElement.scrollWidth;
52
+ var bodyScrollWidth = document.body.scrollWidth;
53
+ var totalWidth = Math.max(scrollWidth, bodyScrollWidth);
54
+ var clientHeight = document.documentElement.clientHeight;
55
+ var bodyClientHeight = document.body.clientHeight;
56
+ var scrollHeight = document.documentElement.scrollHeight;
57
+ var bodyScrollHeight = document.body.scrollHeight;
58
+ var maxDocElementHeight = Math.max(clientHeight, scrollHeight);
59
+ var maxBodyHeight = Math.max(bodyClientHeight, bodyScrollHeight);
60
+ var totalHeight = Math.max(maxDocElementHeight, maxBodyHeight);
61
+ return [totalWidth, totalHeight];
62
+ JS
63
+
64
+ # @!visibility private
65
+ JS_GET_CURRENT_SCROLL_POSITION = <<-JS.freeze
66
+ return (function() {
67
+ var doc = document.documentElement;
68
+ var x = (window.scrollX || window.pageXOffset || doc.scrollLeft) - (doc.clientLeft || 0);
69
+ var y = (window.scrollY || window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
70
+
71
+ return {left: parseInt(x, 10) || 0, top: parseInt(y, 10) || 0};
72
+ }());
73
+ JS
74
+
75
+ # @!visibility private
76
+ JS_SCROLL_TO = <<-JS.freeze
77
+ window.scrollTo(%{left}, %{top});
78
+ JS
79
+
80
+ # @!visibility private
81
+ JS_GET_CURRENT_TRANSFORM = <<-JS.freeze
82
+ return document.body.style.transform;
83
+ JS
84
+
85
+ # @!visibility private
86
+ JS_SET_TRANSFORM = <<-JS.freeze
87
+ return (function() {
88
+ var originalTransform = document.body.style.transform;
89
+ document.body.style.transform = '%{transform}';
90
+ return originalTransform;
91
+ }());
92
+ JS
93
+
94
+ # @!visibility private
95
+ JS_SET_OVERFLOW = <<-JS.freeze
96
+ return (function() {
97
+ var origOF = document.documentElement.style.overflow;
98
+ document.documentElement.style.overflow = '%{overflow}';
99
+ return origOF;
100
+ }());
101
+ JS
102
+
103
+ JS_GET_TRANSFORM_VALUE = <<-JS.freeze
104
+ document.documentElement.style['%{key}']
105
+ JS
106
+
107
+ JS_SET_TRANSFORM_VALUE = <<-JS.freeze
108
+ document.documentElement.style['%{key}'] = '%{value}'
109
+ JS
110
+
111
+ JS_TRANSFORM_KEYS = ['transform', '-webkit-transform'].freeze
112
+
113
+ # @!visibility private
114
+ OVERFLOW_HIDDEN = 'hidden'.freeze
115
+
116
+ BROWSER_SIZE_CALCULATION_RETRIES = 3
117
+
118
+ # Number of attemts to set browser size
119
+ VERIFY_RETRIES = 3
120
+
121
+ # A time delay (in seconds) before next attempt to set browser size
122
+ VERIFY_SLEEP_PERIOD = 1
123
+
124
+ # Maximum different (in pixels) between calculated browser size and real browser size when it tries to achieve
125
+ # target size incrementally
126
+ MAX_DIFF = 3
127
+
128
+ # true if test is running on mobile device
129
+ def mobile_device?
130
+ return $driver if $driver && $driver.is_a?(Appium::Driver)
131
+ nil
132
+ end
133
+
134
+ # true if test is running on Android device
135
+ def android?(driver)
136
+ driver.respond_to?(:appium_device) && driver.appium_device == :android
137
+ end
138
+
139
+ # true if test is running on iOS device
140
+ def ios?(driver)
141
+ driver.respond_to?(:appium_device) && driver.appium_device == :ios
142
+ end
143
+
144
+ # @param [Applitools::Selenium::Driver] driver
145
+ def platform_version(driver)
146
+ driver.respond_to?(:caps) && driver.caps[:platformVersion]
147
+ end
148
+
149
+ # @param [Applitools::Selenium::Driver] executor
150
+ # @return [Applitools::Location] {Applitools::Location} instance which indicates current scroll
151
+ # position
152
+ def current_scroll_position(executor)
153
+ position = Applitools::Utils.symbolize_keys executor.execute_script(JS_GET_CURRENT_SCROLL_POSITION).to_hash
154
+ Applitools::Location.new position[:left], position[:top]
155
+ end
156
+
157
+ # scrolls browser to position specified by point.
158
+ # @param [Applitools::Selenium::Driver] executor
159
+ # @param [Applitools::Location] point position to scroll to. It can be any object,
160
+ # having left and top properties
161
+ def scroll_to(executor, point)
162
+ with_timeout(0.25) { executor.execute_script(JS_SCROLL_TO % { left: point.left, top: point.top }) }
163
+ end
164
+
165
+ # @param [Applitools::Selenium::Driver] executor
166
+ def extract_viewport_size(executor)
167
+ Applitools::EyesLogger.debug 'extract_viewport_size()'
168
+
169
+ begin
170
+ width, height = executor.execute_script(JS_GET_VIEWPORT_SIZE)
171
+ result = Applitools::RectangleSize.from_any_argument width: width, height: height
172
+ Applitools::EyesLogger.debug "Viewport size is #{result}."
173
+ return result
174
+ rescue => e
175
+ Applitools::EyesLogger.error "Failed extracting viewport size using JavaScript: (#{e.message})"
176
+ end
177
+
178
+ Applitools::EyesLogger.info 'Using window size as viewport size.'
179
+
180
+ width, height = executor.manage.window.size.to_a
181
+ width, height = height, width if executor.landscape_orientation? && height > width
182
+
183
+ result = Applitools::RectangleSize.new width, height
184
+ Applitools::EyesLogger.debug "Viewport size is #{result}."
185
+ result
186
+ end
187
+
188
+ # @param [Applitools::Selenium::Driver] executor
189
+ def entire_page_size(executor)
190
+ metrics = page_metrics(executor)
191
+ max_document_element_height = [metrics[:client_height], metrics[:scroll_height]].max
192
+ max_body_height = [metrics[:body_client_height], metrics[:body_scroll_height]].max
193
+
194
+ total_width = [metrics[:scroll_width], metrics[:body_scroll_width]].max
195
+ total_height = [max_document_element_height, max_body_height].max
196
+
197
+ Applitools::RectangleSize.new(total_width, total_height)
198
+ end
199
+
200
+ def current_frame_content_entire_size(executor)
201
+ dimensions = executor.execute_script(JS_GET_CONTENT_ENTIRE_SIZE)
202
+ Applitools::RectangleSize.new(dimensions.first.to_i, dimensions.last.to_i)
203
+ rescue
204
+ raise Applitools::EyesDriverOperationException.new 'Failed to extract entire size!'
205
+ end
206
+
207
+ def current_transforms(executor)
208
+ script =
209
+ "return { #{JS_TRANSFORM_KEYS.map { |tk| "'#{tk}': #{JS_GET_TRANSFORM_VALUE % { key: tk }}" }.join(', ')} };"
210
+ executor.execute_script(script)
211
+ end
212
+
213
+ def set_current_transforms(executor, transform)
214
+ value = {}
215
+ JS_TRANSFORM_KEYS.map { |tk| value[tk] = transform }
216
+ set_transforms(executor, value)
217
+ end
218
+
219
+ def set_transforms(executor, value)
220
+ script = value.keys.map { |k| JS_SET_TRANSFORM_VALUE % { key: k, value: value[k] } }.join('; ')
221
+ executor.execute_script(script)
222
+ end
223
+
224
+ def translate_to(executor, location)
225
+ set_current_transforms(executor, "translate(-#{location.x}px, -#{location.y}px)")
226
+ end
227
+
228
+ # @param [Applitools::Selenium::Driver] executor
229
+ def device_pixel_ratio(executor)
230
+ executor.execute_script(JS_GET_DEVICE_PIXEL_RATIO)
231
+ end
232
+
233
+ # @param [Applitools::Selenium::Driver] executor
234
+ def page_metrics(executor)
235
+ Applitools::Utils.underscore_hash_keys(executor.execute_script(JS_GET_PAGE_METRICS))
236
+ end
237
+
238
+ # @param [Applitools::Selenium::Driver] executor
239
+ def hide_scrollbars(executor)
240
+ set_overflow executor, OVERFLOW_HIDDEN
241
+ end
242
+
243
+ # @param [Applitools::Selenium::Driver] executor
244
+ def set_overflow(executor, overflow)
245
+ with_timeout(0.1) { executor.execute_script(JS_SET_OVERFLOW % { overflow: overflow }) }
246
+ end
247
+
248
+ # @param [Applitools::Selenium::Driver] executor
249
+ # @param [Applitools::RectangleSize] viewport_size
250
+ def set_viewport_size(executor, viewport_size)
251
+ Applitools::ArgumentGuard.not_nil 'viewport_size', viewport_size
252
+ Applitools::EyesLogger.info "Set viewport size #{viewport_size}"
253
+
254
+ required_size = Applitools::RectangleSize.from_any_argument viewport_size
255
+ actual_viewport_size = Applitools::RectangleSize.from_any_argument(extract_viewport_size(executor))
256
+
257
+ Applitools::EyesLogger.info "Initial viewport size: #{actual_viewport_size}"
258
+
259
+ if actual_viewport_size == required_size
260
+ Applitools::EyesLogger.info 'Required size is already set.'
261
+ return
262
+ end
263
+
264
+ # Before resizing the window, set its position to the upper left corner (otherwise, there might not be enough
265
+ # "space" below/next to it and the operation won't be successful).
266
+ begin
267
+ executor.manage.window.position = Selenium::WebDriver::Point.new(0, 0)
268
+ rescue Selenium::WebDriver::Error::UnsupportedOperationError => e
269
+ Applitools::EyesLogger.error e.message << '\n Continue...'
270
+ end
271
+
272
+ set_browser_size_by_viewport_size(executor, actual_viewport_size, required_size)
273
+
274
+ actual_viewport_size = extract_viewport_size(executor)
275
+ return if actual_viewport_size == required_size
276
+
277
+ # Additional attempt. This Solves the "maximized browser" bug
278
+ # (border size for maximized browser sometimes different than
279
+ # non-maximized, so the original browser size calculation is
280
+ # wrong).
281
+
282
+ Applitools::EyesLogger.info 'Trying workaround for maximization...'
283
+
284
+ set_browser_size_by_viewport_size(executor, actual_viewport_size, required_size)
285
+
286
+ actual_viewport_size = extract_viewport_size(executor)
287
+ Applitools::EyesLogger.info "Current viewport size: #{actual_viewport_size}"
288
+ return if actual_viewport_size == required_size
289
+
290
+ width_diff = actual_viewport_size.width - required_size.width
291
+ width_step = width_diff > 0 ? -1 : 1
292
+ height_diff = actual_viewport_size.height - required_size.height
293
+ height_step = height_diff > 0 ? -1 : 1
294
+
295
+ browser_size = Applitools::RectangleSize.from_any_argument(executor.manage.window.size)
296
+
297
+ current_width_change = 0
298
+ current_height_change = 0
299
+
300
+ if width_diff.abs <= MAX_DIFF && height_diff <= MAX_DIFF
301
+ Applitools::EyesLogger.info 'Trying workaround for zoom...'
302
+ while current_width_change.abs <= width_diff && current_height_change.abs <= height_diff
303
+
304
+ current_width_change += width_step if actual_viewport_size.width != required_size.width
305
+ current_height_change += height_step if actual_viewport_size.height != required_size.height
306
+
307
+ set_browser_size executor,
308
+ browser_size.dup + Applitools::RectangleSize.new(current_width_change, current_height_change)
309
+
310
+ actual_viewport_size = Applitools::RectangleSize.from_any_argument extract_viewport_size(executor)
311
+ Applitools::EyesLogger.info "Current viewport size: #{actual_viewport_size}"
312
+ return if actual_viewport_size == required_size
313
+ end
314
+ Applitools::EyesLogger.error 'Zoom workaround failed.'
315
+ end
316
+
317
+ raise Applitools::TestFailedError.new 'Failed to set viewport size'
318
+ end
319
+
320
+ def set_browser_size(executor, required_size)
321
+ retries_left = VERIFY_RETRIES
322
+ current_size = Applitools::RectangleSize.new(0, 0)
323
+ while retries_left > 0 && current_size != required_size
324
+ Applitools::EyesLogger.info "Trying to set browser size to #{required_size}"
325
+ executor.manage.window.size = required_size
326
+ sleep VERIFY_SLEEP_PERIOD
327
+ current_size = Applitools::RectangleSize.from_any_argument(executor.manage.window.size)
328
+ Applitools::EyesLogger.info "Current browser size: #{required_size}"
329
+ retries_left -= 1
330
+ end
331
+ current_size == required_size
332
+ end
333
+
334
+ def set_browser_size_by_viewport_size(executor, actual_viewport_size, required_size)
335
+ browser_size = Applitools::RectangleSize.from_any_argument(executor.manage.window.size)
336
+ Applitools::EyesLogger.info "Current browser size: #{browser_size}"
337
+ required_browser_size = browser_size + required_size - actual_viewport_size
338
+ set_browser_size(executor, required_browser_size)
339
+ end
340
+
341
+ private
342
+
343
+ def with_timeout(timeout, &_block)
344
+ raise 'You have to pass block to method with_timeout' unless block_given?
345
+ yield.tap { sleep timeout }
346
+ end
347
+ end
348
+ end