puppeteer-ruby 0.0.2

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 (61) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +36 -0
  5. data/.travis.yml +7 -0
  6. data/Dockerfile +6 -0
  7. data/Gemfile +6 -0
  8. data/README.md +41 -0
  9. data/Rakefile +1 -0
  10. data/bin/console +11 -0
  11. data/bin/setup +8 -0
  12. data/docker-compose.yml +15 -0
  13. data/example.rb +7 -0
  14. data/lib/puppeteer.rb +192 -0
  15. data/lib/puppeteer/async_await_behavior.rb +34 -0
  16. data/lib/puppeteer/browser.rb +240 -0
  17. data/lib/puppeteer/browser_context.rb +90 -0
  18. data/lib/puppeteer/browser_fetcher.rb +6 -0
  19. data/lib/puppeteer/browser_runner.rb +142 -0
  20. data/lib/puppeteer/cdp_session.rb +78 -0
  21. data/lib/puppeteer/concurrent_ruby_utils.rb +37 -0
  22. data/lib/puppeteer/connection.rb +254 -0
  23. data/lib/puppeteer/console_message.rb +24 -0
  24. data/lib/puppeteer/debug_print.rb +20 -0
  25. data/lib/puppeteer/device.rb +12 -0
  26. data/lib/puppeteer/devices.rb +885 -0
  27. data/lib/puppeteer/dom_world.rb +447 -0
  28. data/lib/puppeteer/element_handle.rb +433 -0
  29. data/lib/puppeteer/emulation_manager.rb +46 -0
  30. data/lib/puppeteer/errors.rb +4 -0
  31. data/lib/puppeteer/event_callbackable.rb +88 -0
  32. data/lib/puppeteer/execution_context.rb +230 -0
  33. data/lib/puppeteer/frame.rb +278 -0
  34. data/lib/puppeteer/frame_manager.rb +380 -0
  35. data/lib/puppeteer/if_present.rb +18 -0
  36. data/lib/puppeteer/js_handle.rb +142 -0
  37. data/lib/puppeteer/keyboard.rb +183 -0
  38. data/lib/puppeteer/keyboard/key_description.rb +19 -0
  39. data/lib/puppeteer/keyboard/us_keyboard_layout.rb +283 -0
  40. data/lib/puppeteer/launcher.rb +26 -0
  41. data/lib/puppeteer/launcher/base.rb +48 -0
  42. data/lib/puppeteer/launcher/browser_options.rb +41 -0
  43. data/lib/puppeteer/launcher/chrome.rb +165 -0
  44. data/lib/puppeteer/launcher/chrome_arg_options.rb +49 -0
  45. data/lib/puppeteer/launcher/launch_options.rb +68 -0
  46. data/lib/puppeteer/lifecycle_watcher.rb +168 -0
  47. data/lib/puppeteer/mouse.rb +120 -0
  48. data/lib/puppeteer/network_manager.rb +122 -0
  49. data/lib/puppeteer/page.rb +1001 -0
  50. data/lib/puppeteer/page/screenshot_options.rb +78 -0
  51. data/lib/puppeteer/remote_object.rb +124 -0
  52. data/lib/puppeteer/target.rb +150 -0
  53. data/lib/puppeteer/timeout_settings.rb +15 -0
  54. data/lib/puppeteer/touch_screen.rb +43 -0
  55. data/lib/puppeteer/version.rb +3 -0
  56. data/lib/puppeteer/viewport.rb +36 -0
  57. data/lib/puppeteer/wait_task.rb +6 -0
  58. data/lib/puppeteer/web_socket.rb +117 -0
  59. data/lib/puppeteer/web_socket_transport.rb +49 -0
  60. data/puppeteer-ruby.gemspec +29 -0
  61. metadata +213 -0
@@ -0,0 +1,90 @@
1
+ class Puppeteer::BrowserContext
2
+ include Puppeteer::EventCallbackable
3
+
4
+ # @param {!Puppeteer.Connection} connection
5
+ # @param {!Browser} browser
6
+ # @param {?string} contextId
7
+ def initialize(connection, browser, context_id)
8
+ @connection = connection
9
+ @browser = browser
10
+ @id = context_id
11
+ end
12
+
13
+ # @return {!Array<!Target>} target
14
+ def targets
15
+ @browser.targets.select{ |target| target.browser_context == self }
16
+ end
17
+
18
+ # @param {function(!Target):boolean} predicate
19
+ # @param {{timeout?: number}=} options
20
+ # @return {!Promise<!Target>}
21
+ def wait_for_target(predicate:, timeout: nil)
22
+ @browser.wait_for_target(
23
+ predicate: ->(target) { target.browser_context == self && predicate.call(target) },
24
+ timeout: timeout,
25
+ )
26
+ end
27
+
28
+ # @return {!Promise<!Array<!Puppeteer.Page>>}
29
+ def pages
30
+ targets.select{ |target| target.type == 'page' }.map(&:page).reject{ |page| !page }
31
+ end
32
+
33
+ def incognito?
34
+ !@id
35
+ end
36
+
37
+ # /**
38
+ # * @param {string} origin
39
+ # * @param {!Array<string>} permissions
40
+ # */
41
+ # async overridePermissions(origin, permissions) {
42
+ # const webPermissionToProtocol = new Map([
43
+ # ['geolocation', 'geolocation'],
44
+ # ['midi', 'midi'],
45
+ # ['notifications', 'notifications'],
46
+ # ['push', 'push'],
47
+ # ['camera', 'videoCapture'],
48
+ # ['microphone', 'audioCapture'],
49
+ # ['background-sync', 'backgroundSync'],
50
+ # ['ambient-light-sensor', 'sensors'],
51
+ # ['accelerometer', 'sensors'],
52
+ # ['gyroscope', 'sensors'],
53
+ # ['magnetometer', 'sensors'],
54
+ # ['accessibility-events', 'accessibilityEvents'],
55
+ # ['clipboard-read', 'clipboardRead'],
56
+ # ['clipboard-write', 'clipboardWrite'],
57
+ # ['payment-handler', 'paymentHandler'],
58
+ # // chrome-specific permissions we have.
59
+ # ['midi-sysex', 'midiSysex'],
60
+ # ]);
61
+ # permissions = permissions.map(permission => {
62
+ # const protocolPermission = webPermissionToProtocol.get(permission);
63
+ # if (!protocolPermission)
64
+ # throw new Error('Unknown permission: ' + permission);
65
+ # return protocolPermission;
66
+ # });
67
+ # await this._connection.send('Browser.grantPermissions', {origin, browserContextId: this._id || undefined, permissions});
68
+ # }
69
+
70
+ # async clearPermissionOverrides() {
71
+ # await this._connection.send('Browser.resetPermissions', {browserContextId: this._id || undefined});
72
+ # }
73
+
74
+ # @return [Future<Puppeteer::Page>]
75
+ def new_page
76
+ @browser.create_page_in_context(@id)
77
+ end
78
+
79
+ # @return [Browser]
80
+ def browser
81
+ @browser
82
+ end
83
+
84
+ def close
85
+ if !@id
86
+ raise 'Non-incognito profiles cannot be closed!'
87
+ end
88
+ @browser.dispose_context(@id)
89
+ end
90
+ end
@@ -0,0 +1,6 @@
1
+ # Download latest chromium.
2
+ class Puppeteer::BrowserFetcher
3
+ def initialize(project_root, options = {})
4
+ # 未実装
5
+ end
6
+ end
@@ -0,0 +1,142 @@
1
+ require 'fileutils'
2
+ require 'open3'
3
+ require 'timeout'
4
+
5
+ # https://github.com/puppeteer/puppeteer/blob/master/lib/Launcher.js
6
+ class Puppeteer::BrowserRunner
7
+ # @param {string} executablePath
8
+ # @param {!Array<string>} processArguments
9
+ # @param {string=} tempDirectory
10
+ def initialize(executable_path, process_arguments, temp_directory)
11
+ @executable_path = executable_path
12
+ @process_arguments = process_arguments
13
+ @temp_directory = temp_directory
14
+ @proc = nil
15
+ @connection = nil
16
+ @closed = true
17
+ @listeners = []
18
+ end
19
+
20
+ attr_reader :proc, :connection
21
+
22
+ class BrowserProcess
23
+ def initialize(env, executable_path, args)
24
+ stdin, @stdout, @stderr, @thread = Open3.popen3(env, executable_path, *args)
25
+ stdin.close
26
+ end
27
+
28
+ def dispose
29
+ [@stdout, @stderr].each{ |io| io.close unless io.closed? }
30
+ @thread.join
31
+ end
32
+
33
+ attr_reader :stdout, :stderr
34
+ end
35
+
36
+ # @param {!(Launcher.LaunchOptions)=} options
37
+ def start(
38
+ executable_path: nil,
39
+ ignore_default_args: nil,
40
+ handle_SIGINT: nil,
41
+ handle_SIGTERM: nil,
42
+ handle_SIGHUP: nil,
43
+ timeout: nil,
44
+ dumpio: nil,
45
+ env: nil,
46
+ pipe: nil
47
+ )
48
+ @launch_options = Puppeteer::Launcher::LaunchOptions.new({
49
+ executable_path: executable_path,
50
+ ignore_default_args: ignore_default_args,
51
+ handle_SIGINT: handle_SIGINT,
52
+ handle_SIGTERM: handle_SIGTERM,
53
+ handle_SIGHUP: handle_SIGHUP,
54
+ timeout: timeout,
55
+ dumpio: dumpio,
56
+ env: env,
57
+ pipe: pipe,
58
+ }.compact)
59
+ @proc = BrowserProcess.new(
60
+ @launch_options.env,
61
+ @executable_path,
62
+ @process_arguments,
63
+ )
64
+ # if (dumpio) {
65
+ # this.proc.stderr.pipe(process.stderr);
66
+ # this.proc.stdout.pipe(process.stdout);
67
+ # }
68
+ @closed = false
69
+ @process_closing = -> {
70
+ @proc.dispose
71
+ @closed = true
72
+ if @temp_directory
73
+ FileUtils.rm_rf(@temp_directory)
74
+ end
75
+ }
76
+ trap(:EXIT) do
77
+ kill
78
+ end
79
+
80
+ if @launch_options.handle_SIGINT?
81
+ trap(:INT) do
82
+ kill
83
+ exit 130
84
+ end
85
+ end
86
+
87
+ if @launch_options.handle_SIGTERM?
88
+ trap(:TERM) do
89
+ close
90
+ end
91
+ end
92
+
93
+ if @launch_options.handle_SIGHUP?
94
+ trap(:HUP) do
95
+ close
96
+ end
97
+ end
98
+ end
99
+
100
+ # @return {Promise}
101
+ def close
102
+ return if @closed
103
+
104
+ if @temp_directory
105
+ kill
106
+ elsif @connection
107
+ @connection.sendCommand("Browser.close")
108
+ end
109
+ end
110
+
111
+ # @return {Promise}
112
+ def kill
113
+ end
114
+
115
+
116
+ # @param {!({usePipe?: boolean, timeout: number, slowMo: number, preferredRevision: string})} options
117
+ # @return {!Promise<!Connection>}
118
+ def setup_connection(use_pipe:, timeout:, slow_mo:, preferred_revision:)
119
+ if !use_pipe
120
+ browser_ws_endpoint = wait_for_ws_endpoint(@proc, timeout, preferred_revision)
121
+ transport = Puppeteer::WebSocketTransport.create(browser_ws_endpoint)
122
+ @connection = Puppeteer::Connection.new(browser_ws_endpoint, transport, slow_mo)
123
+ else
124
+ raise NotImplementedError.new("PipeTransport is not yet implemented")
125
+ end
126
+
127
+ @connection
128
+ end
129
+
130
+ private def wait_for_ws_endpoint(browser_process, timeout, preferred_revision)
131
+ Timeout.timeout(timeout / 1000) do
132
+ loop do
133
+ line = browser_process.stderr.readline
134
+ /^DevTools listening on (ws:\/\/.*)$/.match(line) do |m|
135
+ return m[1]
136
+ end
137
+ end
138
+ end
139
+ rescue Timeout::Error
140
+ raise Puppeteer::TimeoutError.new("Timed out after #{timeout} ms while trying to connect to the browser! Only Chrome at revision r#{preferredRevision} is guaranteed to work.")
141
+ end
142
+ end
@@ -0,0 +1,78 @@
1
+ class Puppeteer::CDPSession
2
+ include Puppeteer::EventCallbackable
3
+ using Puppeteer::AsyncAwaitBehavior
4
+
5
+ class Error < StandardError ; end
6
+
7
+ # @param {!Connection} connection
8
+ # @param {string} targetType
9
+ # @param {string} sessionId
10
+ def initialize(connection, target_type, session_id)
11
+ @callbacks = {}
12
+ @connection = connection
13
+ @target_type = target_type
14
+ @session_id = session_id
15
+ end
16
+
17
+ attr_reader :connection
18
+
19
+ # @param method [String]
20
+ # @param params [Hash]
21
+ # @returns [Hash]
22
+ def send_message(method, params = {})
23
+ await async_send_message(method, params)
24
+ end
25
+
26
+ # @param method [String]
27
+ # @param params [Hash]
28
+ # @returns [Future<Hash>]
29
+ def async_send_message(method, params = {})
30
+ if !@connection
31
+ raise Error.new("Protocol error (#{method}): Session closed. Most likely the #{@target_type} has been closed.")
32
+ end
33
+ id = @connection.raw_send(message: { sessionId: @session_id, method: method, params: params })
34
+ promise = resolvable_future
35
+ @callbacks[id] = Puppeteer::Connection::MessageCallback.new(method: method, promise: promise)
36
+ promise
37
+ end
38
+
39
+ # @param {{id?: number, method: string, params: Object, error: {message: string, data: any}, result?: *}} object
40
+ def handle_message(message)
41
+ if message['id']
42
+ if callback = @callbacks.delete(message['id'])
43
+ if message['error']
44
+ callback.reject(
45
+ Puppeteer::Connection::ProtocolError.new(
46
+ method: callback.method,
47
+ error_message: response['error']['message'],
48
+ error_data: response['error']['data']))
49
+ else
50
+ callback.resolve(message['result'])
51
+ end
52
+ else
53
+ raise Error.new("unknown id: #{message['id']}")
54
+ end
55
+ else
56
+ emit_event message['method'], message['params']
57
+ end
58
+ end
59
+
60
+ def detach
61
+ if !@connection
62
+ raise Error.new("Session already detarched. Most likely the #{@target_type} has been closed.")
63
+ end
64
+ @connection.send_message('Target.detachFromTarget', sessionId: @session_id)
65
+ end
66
+
67
+ def handle_closed
68
+ @callbacks.values.each do |callback|
69
+ callback.reject(
70
+ Puppeteer::Connection::ProtocolError.new(
71
+ method: callback.method,
72
+ error_message: 'Target Closed.'))
73
+ end
74
+ @callbacks.clear
75
+ @connection = nil
76
+ emit_event 'Events.CDPSession.Disconnected'
77
+ end
78
+ end
@@ -0,0 +1,37 @@
1
+ # utility methods for Concurrent::Promises.
2
+ module Puppeteer::ConcurrentRubyUtils
3
+ def await_all(*args)
4
+ if args.length == 1 && args[0].is_a?(Enumerable)
5
+ Concurrent::Promises.zip(*(args[0])).value!
6
+ else
7
+ Concurrent::Promises.zip(*args).value!
8
+ end
9
+ end
10
+
11
+ def await_any(*args)
12
+ if args.length == 1 && args[0].is_a?(Enumerable)
13
+ Concurrent::Promises.any(*(args[0])).value!
14
+ else
15
+ Concurrent::Promises.any(*args).value!
16
+ end
17
+ end
18
+
19
+ # blocking get value of Future.
20
+ def await(future_or_value)
21
+ if future_or_value.is_a?(Concurrent::Promises::Future)
22
+ future_or_value.value!
23
+ else
24
+ future_or_value
25
+ end
26
+ end
27
+
28
+ def future(&block)
29
+ Concurrent::Promises.future(&block)
30
+ end
31
+
32
+ def resolvable_future
33
+ Concurrent::Promises.resolvable_future
34
+ end
35
+ end
36
+
37
+ include Puppeteer::ConcurrentRubyUtils
@@ -0,0 +1,254 @@
1
+ require 'json'
2
+
3
+ class Puppeteer::Connection
4
+ include Puppeteer::DebugPrint
5
+ include Puppeteer::EventCallbackable
6
+ using Puppeteer::AsyncAwaitBehavior
7
+
8
+ class ProtocolError < StandardError
9
+ def initialize(method:, error_message:, error_data: nil)
10
+ msg = "Protocol error (#{method}): #{error_message}"
11
+ if error_data
12
+ super("#{msg} #{error_data}")
13
+ else
14
+ super(msg)
15
+ end
16
+ end
17
+ end
18
+
19
+ # callback object stored in @callbacks.
20
+ class MessageCallback
21
+ # @param method [String]
22
+ # @param promise [Concurrent::Promises::ResolvableFuture]
23
+ def initialize(method:, promise:)
24
+ @method = method
25
+ @promise = promise
26
+ end
27
+
28
+ def resolve(result)
29
+ @promise.fulfill(result)
30
+ end
31
+
32
+ def reject(error)
33
+ @promise.reject(error)
34
+ end
35
+
36
+ attr_reader :method
37
+ end
38
+
39
+ def initialize(url, transport, delay = 0)
40
+ @url = url
41
+ @last_id = 0
42
+ @callbacks = {}
43
+ @delay = delay
44
+
45
+ @transport = transport
46
+ @transport.on_message do |data|
47
+ async_handle_message(JSON.parse(data))
48
+ end
49
+ @transport.on_close do |reason, code|
50
+ handle_close(reason, code)
51
+ end
52
+
53
+ @sessions = {}
54
+ @closed = false
55
+ end
56
+
57
+ def self.from_session(session)
58
+ session.connection
59
+ end
60
+
61
+ # @param {string} sessionId
62
+ # @return {?CDPSession}
63
+ def session(session_id)
64
+ @sessions[session_id]
65
+ end
66
+
67
+ def url
68
+ @url
69
+ end
70
+
71
+ # @param {string} method
72
+ # @param {!Object=} params
73
+ def send_message(method, params = {})
74
+ await async_send_message(method, params)
75
+ end
76
+
77
+ def async_send_message(method, params = {})
78
+ id = raw_send(message: { method: method, params: params })
79
+ promise = resolvable_future
80
+ @callbacks[id] = MessageCallback.new(method: method, promise: promise)
81
+ promise
82
+ end
83
+
84
+ private def generate_id
85
+ @last_id += 1
86
+ end
87
+
88
+ def raw_send(message:)
89
+ id = generate_id
90
+ payload = JSON.fast_generate(message.compact.merge(id: id))
91
+ @transport.send_text(payload)
92
+ request_debug_printer.handle_payload(payload)
93
+ id
94
+ end
95
+
96
+ # Just for effective debugging :)
97
+ class RequestDebugPrinter
98
+ include Puppeteer::DebugPrint
99
+
100
+ def handle_payload(payload)
101
+ debug_puts "SEND >> #{decorate(payload)}"
102
+ end
103
+
104
+ private def decorate(payload)
105
+ payload.gsub(/"method":"([^"]+)"/, "\"method\":\"\u001b[32m\\1\u001b[0m\"")
106
+ end
107
+ end
108
+
109
+ class ResponseDebugPrinter
110
+ include Puppeteer::DebugPrint
111
+
112
+ NON_DEBUG_PRINT_METHODS = [
113
+ 'Network.dataReceived',
114
+ 'Network.loadingFinished',
115
+ 'Network.requestWillBeSent',
116
+ 'Network.requestWillBeSentExtraInfo',
117
+ 'Network.responseReceived',
118
+ 'Network.responseReceivedExtraInfo',
119
+ 'Page.lifecycleEvent',
120
+ ]
121
+
122
+ def handle_message(message)
123
+ if skip_debug_print?(message['method'])
124
+ debug_print "."
125
+ @prev_log_skipped = true
126
+ else
127
+ debug_print "\n" if @prev_log_skipped
128
+ @prev_log_skipped = nil
129
+ debug_puts "RECV << #{decorate(message)}"
130
+ end
131
+ end
132
+
133
+ private def skip_debug_print?(method)
134
+ method && NON_DEBUG_PRINT_METHODS.include?(method)
135
+ end
136
+
137
+ private def decorate(message)
138
+ # decorate RED for error.
139
+ if message['error']
140
+ return "\u001b[31m#{message}\u001b[0m"
141
+ end
142
+
143
+ # ignore method call response, or with no method.
144
+ return message if message['id'] || !message['method']
145
+
146
+ # decorate cyan for method name.
147
+ message.to_s.gsub(message['method'], "\u001b[36m#{message['method']}\u001b[0m")
148
+ end
149
+ end
150
+
151
+ private def request_debug_printer
152
+ @request_debug_printer ||= RequestDebugPrinter.new
153
+ end
154
+
155
+ private def response_debug_printer
156
+ @response_debug_printer ||= ResponseDebugPrinter.new
157
+ end
158
+
159
+ private def handle_message(message)
160
+ if @delay > 0
161
+ sleep(@delay / 1000.0)
162
+ end
163
+
164
+ response_debug_printer.handle_message(message)
165
+
166
+ case message['method']
167
+ when 'Target.attachedToTarget'
168
+ session_id = message['params']['sessionId']
169
+ session = Puppeteer::CDPSession.new(self, message['params']['targetInfo']['type'], session_id)
170
+ @sessions[session_id] = session
171
+ when 'Target.detachedFromTarget'
172
+ session_id = message['params']['sessionId']
173
+ session = @sessions[session_id]
174
+ if session
175
+ session._onClosed
176
+ @sessions.delete(session_id)
177
+ end
178
+ end
179
+
180
+ if message['sessionId']
181
+ session_id = message['sessionId']
182
+ @sessions[session_id]&.handle_message(message)
183
+ elsif message['id']
184
+ # Callbacks could be all rejected if someone has called `.dispose()`.
185
+ if callback = @callbacks.delete(message['id'])
186
+ if message['error']
187
+ callback.reject(
188
+ ProtocolError.new(
189
+ method: callback.method,
190
+ error_message: response['error']['message'],
191
+ error_data: response['error']['data']))
192
+ else
193
+ callback.resolve(message["result"])
194
+ end
195
+ end
196
+ else
197
+ emit_event message['method'], message['params']
198
+ end
199
+ end
200
+
201
+ private async def async_handle_message(message)
202
+ handle_message(message)
203
+ end
204
+
205
+ private def handle_close
206
+ return if @closed
207
+ @closed = true
208
+ @transport.on_message
209
+ @transport.on_close
210
+ @callbacks.values.each do |callback|
211
+ callback.reject(
212
+ ProtocolError.new(
213
+ method: callback.method,
214
+ error_message: 'Target Closed.'))
215
+ end
216
+ @callbacks.clear
217
+ @sessions.values.each do |session|
218
+ session.handle_closed
219
+ end
220
+ @sessions.clear
221
+ emit_event 'Events.Connection.Disconnected'
222
+ end
223
+
224
+ def on_close(&block)
225
+ @on_close = block
226
+ end
227
+
228
+ def on_message(&block)
229
+ @on_message = block
230
+ end
231
+
232
+ def dispose
233
+ handle_close
234
+ @transport.close
235
+ end
236
+
237
+ # @param {Protocol.Target.TargetInfo} targetInfo
238
+ # @return [CDPSession]
239
+ def create_session(target_info)
240
+ result = send_message('Target.attachToTarget', targetId: target_info.target_id, flatten: true)
241
+ session_id = result['sessionId']
242
+
243
+ # Target.attachedToTarget is often notified after the result of Target.attachToTarget.
244
+ # D, [2020-04-04T23:04:30.736311 #91875] DEBUG -- : RECV << {"id"=>2, "result"=>{"sessionId"=>"DA002F8A95B04710502CB40D8430B95A"}}
245
+ # D, [2020-04-04T23:04:30.736649 #91875] DEBUG -- : RECV << {"method"=>"Target.attachedToTarget", "params"=>{"sessionId"=>"DA002F8A95B04710502CB40D8430B95A", "targetInfo"=>{"targetId"=>"EBAB949A7DE63F12CB94268AD3A9976B", "type"=>"page", "title"=>"about:blank", "url"=>"about:blank", "attached"=>true, "browserContextId"=>"46D23767E9B79DD9E589101121F6DADD"}, "waitingForDebugger"=>false}}
246
+ # So we have to wait for "Target.attachedToTarget" a bit.
247
+ 20.times do
248
+ if @sessions[session_id]
249
+ return @sessions[session_id]
250
+ end
251
+ sleep 0.1
252
+ end
253
+ end
254
+ end