cuprite 0.2.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 +7 -0
- data/lib/capybara/cuprite.rb +28 -0
- data/lib/capybara/cuprite/browser.rb +286 -0
- data/lib/capybara/cuprite/browser/client.rb +70 -0
- data/lib/capybara/cuprite/browser/dom.rb +50 -0
- data/lib/capybara/cuprite/browser/frame.rb +109 -0
- data/lib/capybara/cuprite/browser/input.rb +123 -0
- data/lib/capybara/cuprite/browser/javascripts/index.js +407 -0
- data/lib/capybara/cuprite/browser/page.rb +278 -0
- data/lib/capybara/cuprite/browser/process.rb +167 -0
- data/lib/capybara/cuprite/browser/runtime.rb +194 -0
- data/lib/capybara/cuprite/browser/targets.rb +109 -0
- data/lib/capybara/cuprite/browser/web_socket.rb +60 -0
- data/lib/capybara/cuprite/cookie.rb +47 -0
- data/lib/capybara/cuprite/driver.rb +396 -0
- data/lib/capybara/cuprite/errors.rb +131 -0
- data/lib/capybara/cuprite/network/error.rb +25 -0
- data/lib/capybara/cuprite/network/request.rb +33 -0
- data/lib/capybara/cuprite/network/response.rb +42 -0
- data/lib/capybara/cuprite/node.rb +216 -0
- data/lib/capybara/cuprite/version.rb +7 -0
- metadata +231 -0
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Capybara::Cuprite
|
4
|
+
class Browser
|
5
|
+
class Targets
|
6
|
+
def initialize(browser)
|
7
|
+
@mutex = Mutex.new
|
8
|
+
@browser = browser
|
9
|
+
@_default = targets.first["targetId"]
|
10
|
+
|
11
|
+
@browser.subscribe("Target.detachedFromTarget") do |params|
|
12
|
+
page = remove_page(params["targetId"])
|
13
|
+
page&.close_connection
|
14
|
+
end
|
15
|
+
|
16
|
+
reset
|
17
|
+
end
|
18
|
+
|
19
|
+
def push(target_id, page = nil)
|
20
|
+
@targets[target_id] = page
|
21
|
+
end
|
22
|
+
|
23
|
+
def refresh
|
24
|
+
@mutex.synchronize do
|
25
|
+
targets.each { |t| push(t["targetId"]) if !default?(t) && !has?(t) }
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def page
|
30
|
+
raise NoSuchWindowError unless @page
|
31
|
+
@page
|
32
|
+
end
|
33
|
+
|
34
|
+
def window_handle
|
35
|
+
page.target_id
|
36
|
+
end
|
37
|
+
|
38
|
+
def window_handles
|
39
|
+
@mutex.synchronize { @targets.keys }
|
40
|
+
end
|
41
|
+
|
42
|
+
def switch_to_window(target_id)
|
43
|
+
page = @targets[target_id]
|
44
|
+
page ||= Page.new(target_id, @browser)
|
45
|
+
@targets[target_id] ||= page
|
46
|
+
@page = page
|
47
|
+
end
|
48
|
+
|
49
|
+
def open_new_window
|
50
|
+
target_id = @browser.command("Target.createTarget", url: "about:blank", browserContextId: @_context_id)["targetId"]
|
51
|
+
page = Page.new(target_id, @browser)
|
52
|
+
push(target_id, page)
|
53
|
+
target_id
|
54
|
+
end
|
55
|
+
|
56
|
+
def close_window(target_id)
|
57
|
+
remove_page(target_id)&.close
|
58
|
+
end
|
59
|
+
|
60
|
+
def within_window(locator)
|
61
|
+
original = window_handle
|
62
|
+
if window_handles.include?(locator)
|
63
|
+
switch_to_window(locator)
|
64
|
+
yield
|
65
|
+
else
|
66
|
+
raise NoSuchWindowError
|
67
|
+
end
|
68
|
+
ensure
|
69
|
+
switch_to_window(original)
|
70
|
+
end
|
71
|
+
|
72
|
+
def reset
|
73
|
+
if @page
|
74
|
+
@page.close
|
75
|
+
@browser.command("Target.disposeBrowserContext", browserContextId: @_context_id)
|
76
|
+
end
|
77
|
+
|
78
|
+
@page = nil
|
79
|
+
@targets = {}
|
80
|
+
@_context_id = nil
|
81
|
+
|
82
|
+
@_context_id = @browser.command("Target.createBrowserContext")["browserContextId"]
|
83
|
+
target_id = @browser.command("Target.createTarget", url: "about:blank", browserContextId: @_context_id)["targetId"]
|
84
|
+
@page = Page.new(target_id, @browser)
|
85
|
+
push(target_id, @page)
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def remove_page(target_id)
|
91
|
+
page = @targets.delete(target_id)
|
92
|
+
@page = nil if page && @page == page
|
93
|
+
page
|
94
|
+
end
|
95
|
+
|
96
|
+
def targets
|
97
|
+
@browser.command("Target.getTargets")["targetInfos"]
|
98
|
+
end
|
99
|
+
|
100
|
+
def default?(target)
|
101
|
+
@_default == target["targetId"]
|
102
|
+
end
|
103
|
+
|
104
|
+
def has?(target)
|
105
|
+
@targets.key?(target["targetId"])
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "socket"
|
5
|
+
require "forwardable"
|
6
|
+
require "websocket/driver"
|
7
|
+
|
8
|
+
module Capybara::Cuprite
|
9
|
+
class Browser
|
10
|
+
class WebSocket
|
11
|
+
extend Forwardable
|
12
|
+
|
13
|
+
delegate close: :@driver
|
14
|
+
|
15
|
+
attr_reader :url, :messages
|
16
|
+
|
17
|
+
def initialize(url, logger)
|
18
|
+
@url = url
|
19
|
+
@logger = logger
|
20
|
+
uri = URI.parse(@url)
|
21
|
+
@sock = TCPSocket.new(uri.host, uri.port)
|
22
|
+
@driver = ::WebSocket::Driver.client(self)
|
23
|
+
@messages = Queue.new
|
24
|
+
|
25
|
+
@driver.on(:message, &method(:on_message))
|
26
|
+
|
27
|
+
@thread = Thread.new do
|
28
|
+
begin
|
29
|
+
while data = @sock.readpartial(512)
|
30
|
+
@driver.parse(data)
|
31
|
+
end
|
32
|
+
rescue EOFError, Errno::ECONNRESET
|
33
|
+
@messages.close
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
@thread.priority = 1
|
38
|
+
|
39
|
+
@driver.start
|
40
|
+
end
|
41
|
+
|
42
|
+
def send_message(data)
|
43
|
+
@started_at ||= Time.now.to_f
|
44
|
+
json = data.to_json
|
45
|
+
@driver.text(json)
|
46
|
+
@logger&.puts("\n\n[#{Time.now.to_f - @started_at}] >>> #{json}")
|
47
|
+
end
|
48
|
+
|
49
|
+
def on_message(event)
|
50
|
+
data = JSON.parse(event.data)
|
51
|
+
@messages.push(data)
|
52
|
+
@logger&.puts("[#{Time.now.to_f - @started_at}] <<< #{event.data}\n")
|
53
|
+
end
|
54
|
+
|
55
|
+
def write(data)
|
56
|
+
@sock.write(data)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Capybara::Cuprite
|
4
|
+
class Cookie
|
5
|
+
def initialize(attributes)
|
6
|
+
@attributes = attributes
|
7
|
+
end
|
8
|
+
|
9
|
+
def name
|
10
|
+
@attributes["name"]
|
11
|
+
end
|
12
|
+
|
13
|
+
def value
|
14
|
+
@attributes["value"]
|
15
|
+
end
|
16
|
+
|
17
|
+
def domain
|
18
|
+
@attributes["domain"]
|
19
|
+
end
|
20
|
+
|
21
|
+
def path
|
22
|
+
@attributes["path"]
|
23
|
+
end
|
24
|
+
|
25
|
+
def size
|
26
|
+
@attributes["size"]
|
27
|
+
end
|
28
|
+
|
29
|
+
def secure?
|
30
|
+
@attributes["secure"]
|
31
|
+
end
|
32
|
+
|
33
|
+
def httponly?
|
34
|
+
@attributes["httpOnly"]
|
35
|
+
end
|
36
|
+
|
37
|
+
def session?
|
38
|
+
@attributes["session"]
|
39
|
+
end
|
40
|
+
|
41
|
+
def expires
|
42
|
+
if @attributes["expires"] > 0
|
43
|
+
Time.at(@attributes["expires"])
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,396 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "uri"
|
4
|
+
|
5
|
+
require "forwardable"
|
6
|
+
|
7
|
+
module Capybara::Cuprite
|
8
|
+
class Driver < Capybara::Driver::Base
|
9
|
+
extend Forwardable
|
10
|
+
|
11
|
+
delegate %i(restart quit status_code timeout timeout=) => :browser
|
12
|
+
|
13
|
+
attr_reader :app, :options
|
14
|
+
|
15
|
+
def initialize(app, options = {})
|
16
|
+
@app = app
|
17
|
+
@options = options.freeze
|
18
|
+
@started = false
|
19
|
+
end
|
20
|
+
|
21
|
+
def needs_server?
|
22
|
+
true
|
23
|
+
end
|
24
|
+
|
25
|
+
def browser
|
26
|
+
@browser ||= Browser.start(@options)
|
27
|
+
end
|
28
|
+
|
29
|
+
def visit(url)
|
30
|
+
@started = true
|
31
|
+
browser.visit(url)
|
32
|
+
end
|
33
|
+
|
34
|
+
def current_url
|
35
|
+
if Capybara::VERSION.to_f < 3.0
|
36
|
+
frame_url
|
37
|
+
else
|
38
|
+
browser.current_url
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def frame_url
|
43
|
+
browser.frame_url
|
44
|
+
end
|
45
|
+
|
46
|
+
def html
|
47
|
+
browser.body
|
48
|
+
end
|
49
|
+
alias_method :body, :html
|
50
|
+
|
51
|
+
def source
|
52
|
+
browser.source.to_s
|
53
|
+
end
|
54
|
+
|
55
|
+
def title
|
56
|
+
if Capybara::VERSION.to_f < 3.0
|
57
|
+
frame_title
|
58
|
+
else
|
59
|
+
browser.title
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def frame_title
|
64
|
+
browser.frame_title
|
65
|
+
end
|
66
|
+
|
67
|
+
def find(method, selector)
|
68
|
+
browser.find(method, selector).map { |target_id, node| Node.new(self, target_id, node) }
|
69
|
+
end
|
70
|
+
|
71
|
+
def find_xpath(selector)
|
72
|
+
find :xpath, selector
|
73
|
+
end
|
74
|
+
|
75
|
+
def find_css(selector)
|
76
|
+
find :css, selector
|
77
|
+
end
|
78
|
+
|
79
|
+
def click(x, y)
|
80
|
+
browser.click_coordinates(x, y)
|
81
|
+
end
|
82
|
+
|
83
|
+
def evaluate_script(script, *args)
|
84
|
+
result = browser.evaluate(script, *native_args(args))
|
85
|
+
unwrap_script_result(result)
|
86
|
+
end
|
87
|
+
|
88
|
+
def evaluate_async_script(script, *args)
|
89
|
+
result = browser.evaluate_async(script, session_wait_time, *native_args(args))
|
90
|
+
unwrap_script_result(result)
|
91
|
+
end
|
92
|
+
|
93
|
+
def execute_script(script, *args)
|
94
|
+
browser.execute(script, *native_args(args))
|
95
|
+
nil
|
96
|
+
end
|
97
|
+
|
98
|
+
def within_frame(name, &block)
|
99
|
+
browser.within_frame(name, &block)
|
100
|
+
end
|
101
|
+
|
102
|
+
def switch_to_frame(locator)
|
103
|
+
browser.switch_to_frame(locator)
|
104
|
+
end
|
105
|
+
|
106
|
+
def current_window_handle
|
107
|
+
browser.window_handle
|
108
|
+
end
|
109
|
+
|
110
|
+
def window_handles
|
111
|
+
browser.window_handles
|
112
|
+
end
|
113
|
+
|
114
|
+
def close_window(handle)
|
115
|
+
browser.close_window(handle)
|
116
|
+
end
|
117
|
+
|
118
|
+
def open_new_window
|
119
|
+
browser.open_new_window
|
120
|
+
end
|
121
|
+
|
122
|
+
def switch_to_window(handle)
|
123
|
+
browser.switch_to_window(handle)
|
124
|
+
end
|
125
|
+
|
126
|
+
def within_window(name, &block)
|
127
|
+
browser.within_window(name, &block)
|
128
|
+
end
|
129
|
+
|
130
|
+
def no_such_window_error
|
131
|
+
NoSuchWindowError
|
132
|
+
end
|
133
|
+
|
134
|
+
def reset!
|
135
|
+
browser.reset
|
136
|
+
browser.url_blacklist = @options[:url_blacklist] if @options.key?(:url_blacklist)
|
137
|
+
browser.url_whitelist = @options[:url_whitelist] if @options.key?(:url_whitelist)
|
138
|
+
@started = false
|
139
|
+
end
|
140
|
+
|
141
|
+
def save_screenshot(path, options = {})
|
142
|
+
browser.render(path, options)
|
143
|
+
end
|
144
|
+
alias_method :render, :save_screenshot
|
145
|
+
|
146
|
+
def render_base64(format = :png, options = {})
|
147
|
+
browser.render_base64(format, options)
|
148
|
+
end
|
149
|
+
|
150
|
+
def paper_size=(size = {})
|
151
|
+
browser.set_paper_size(size)
|
152
|
+
end
|
153
|
+
|
154
|
+
def zoom_factor=(zoom_factor)
|
155
|
+
browser.set_zoom_factor(zoom_factor)
|
156
|
+
end
|
157
|
+
|
158
|
+
def resize(width, height)
|
159
|
+
browser.resize(width, height)
|
160
|
+
end
|
161
|
+
alias_method :resize_window, :resize
|
162
|
+
|
163
|
+
def resize_window_to(handle, width, height)
|
164
|
+
within_window(handle) do
|
165
|
+
resize(width, height)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def maximize_window(handle)
|
170
|
+
resize_window_to(handle, *screen_size)
|
171
|
+
end
|
172
|
+
|
173
|
+
def window_size(handle)
|
174
|
+
within_window(handle) do
|
175
|
+
evaluate_script("[window.innerWidth, window.innerHeight]")
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
def scroll_to(left, top)
|
180
|
+
browser.scroll_to(left, top)
|
181
|
+
end
|
182
|
+
|
183
|
+
def network_traffic(type = nil)
|
184
|
+
browser.network_traffic(type)
|
185
|
+
end
|
186
|
+
|
187
|
+
def clear_network_traffic
|
188
|
+
browser.clear_network_traffic
|
189
|
+
end
|
190
|
+
|
191
|
+
def set_proxy(ip, port, type = "http", user = nil, password = nil)
|
192
|
+
browser.set_proxy(ip, port, type, user, password)
|
193
|
+
end
|
194
|
+
|
195
|
+
def headers
|
196
|
+
browser.headers
|
197
|
+
end
|
198
|
+
|
199
|
+
def headers=(headers)
|
200
|
+
browser.headers=(headers)
|
201
|
+
end
|
202
|
+
|
203
|
+
def add_headers(headers)
|
204
|
+
browser.add_headers(headers)
|
205
|
+
end
|
206
|
+
|
207
|
+
def add_header(name, value, permanent: true)
|
208
|
+
browser.add_header({ name => value }, permanent: permanent)
|
209
|
+
end
|
210
|
+
|
211
|
+
def response_headers
|
212
|
+
browser.response_headers
|
213
|
+
end
|
214
|
+
|
215
|
+
def cookies
|
216
|
+
browser.cookies
|
217
|
+
end
|
218
|
+
|
219
|
+
def set_cookie(name, value, options = {})
|
220
|
+
options = options.dup
|
221
|
+
options[:name] ||= name
|
222
|
+
options[:value] ||= value
|
223
|
+
options[:domain] ||= default_domain
|
224
|
+
|
225
|
+
expires = options.delete(:expires).to_i
|
226
|
+
options[:expires] = expires if expires > 0
|
227
|
+
|
228
|
+
browser.set_cookie(options)
|
229
|
+
end
|
230
|
+
|
231
|
+
def remove_cookie(name, **options)
|
232
|
+
options[:domain] = default_domain if options.empty?
|
233
|
+
browser.remove_cookie(options.merge(name: name))
|
234
|
+
end
|
235
|
+
|
236
|
+
def clear_cookies
|
237
|
+
browser.clear_cookies
|
238
|
+
end
|
239
|
+
|
240
|
+
def clear_memory_cache
|
241
|
+
browser.clear_memory_cache
|
242
|
+
end
|
243
|
+
|
244
|
+
# * Browser with set settings does not send `Authorize` on POST request
|
245
|
+
# * With manually set header browser makes next request with
|
246
|
+
# `Authorization: Basic Og==` header when settings are empty and the
|
247
|
+
# response was `401 Unauthorized` (which means Base64.encode64(":")).
|
248
|
+
# Combining both methods to reach proper behavior.
|
249
|
+
def basic_authorize(user, password)
|
250
|
+
browser.set_http_auth(user, password)
|
251
|
+
credentials = ["#{user}:#{password}"].pack("m*").strip
|
252
|
+
add_header("Authorization", "Basic #{credentials}")
|
253
|
+
end
|
254
|
+
|
255
|
+
def pause
|
256
|
+
# STDIN is not necessarily connected to a keyboard. It might even be closed.
|
257
|
+
# So we need a method other than keypress to continue.
|
258
|
+
|
259
|
+
# In jRuby - STDIN returns immediately from select
|
260
|
+
# see https://github.com/jruby/jruby/issues/1783
|
261
|
+
read, write = IO.pipe
|
262
|
+
Thread.new { IO.copy_stream(STDIN, write); write.close }
|
263
|
+
|
264
|
+
STDERR.puts "Cuprite execution paused. Press enter (or run 'kill -CONT #{Process.pid}') to continue."
|
265
|
+
|
266
|
+
signal = false
|
267
|
+
old_trap = trap("SIGCONT") { signal = true; STDERR.puts "\nSignal SIGCONT received" }
|
268
|
+
keyboard = IO.select([read], nil, nil, 1) until keyboard || signal # wait for data on STDIN or signal SIGCONT received
|
269
|
+
|
270
|
+
unless signal
|
271
|
+
begin
|
272
|
+
input = read.read_nonblock(80) # clear out the read buffer
|
273
|
+
puts unless input&.end_with?("\n")
|
274
|
+
rescue EOFError, IO::WaitReadable # Ignore problems reading from STDIN.
|
275
|
+
end
|
276
|
+
end
|
277
|
+
ensure
|
278
|
+
trap("SIGCONT", old_trap) # Restore the previous signal handler, if there was one.
|
279
|
+
STDERR.puts "Continuing"
|
280
|
+
end
|
281
|
+
|
282
|
+
def wait?
|
283
|
+
true
|
284
|
+
end
|
285
|
+
|
286
|
+
def invalid_element_errors
|
287
|
+
[Capybara::Cuprite::ObsoleteNode, Capybara::Cuprite::MouseEventFailed]
|
288
|
+
end
|
289
|
+
|
290
|
+
def go_back
|
291
|
+
browser.go_back
|
292
|
+
end
|
293
|
+
|
294
|
+
def go_forward
|
295
|
+
browser.go_forward
|
296
|
+
end
|
297
|
+
|
298
|
+
def refresh
|
299
|
+
browser.refresh
|
300
|
+
end
|
301
|
+
|
302
|
+
def accept_modal(type, options = {})
|
303
|
+
case type
|
304
|
+
when :confirm
|
305
|
+
browser.accept_confirm
|
306
|
+
when :prompt
|
307
|
+
browser.accept_prompt options[:with]
|
308
|
+
end
|
309
|
+
|
310
|
+
yield if block_given?
|
311
|
+
|
312
|
+
find_modal(options)
|
313
|
+
end
|
314
|
+
|
315
|
+
def dismiss_modal(type, options = {})
|
316
|
+
case type
|
317
|
+
when :confirm
|
318
|
+
browser.dismiss_confirm
|
319
|
+
when :prompt
|
320
|
+
browser.dismiss_prompt
|
321
|
+
end
|
322
|
+
|
323
|
+
yield if block_given?
|
324
|
+
find_modal(options)
|
325
|
+
end
|
326
|
+
|
327
|
+
private
|
328
|
+
|
329
|
+
def default_domain
|
330
|
+
if @started
|
331
|
+
URI.parse(browser.current_url).host
|
332
|
+
else
|
333
|
+
URI.parse(default_cookie_host).host || "127.0.0.1"
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
def native_args(args)
|
338
|
+
args.map { |arg| arg.is_a?(Capybara::Cuprite::Node) ? arg.native : arg }
|
339
|
+
end
|
340
|
+
|
341
|
+
def screen_size
|
342
|
+
@options[:screen_size] || [1366, 768]
|
343
|
+
end
|
344
|
+
|
345
|
+
def find_modal(options)
|
346
|
+
start_time = Time.now
|
347
|
+
timeout_sec = options.fetch(:wait) { session_wait_time }
|
348
|
+
expect_text = options[:text]
|
349
|
+
expect_regexp = expect_text.is_a?(Regexp) ? expect_text : Regexp.escape(expect_text.to_s)
|
350
|
+
not_found_msg = "Unable to find modal dialog"
|
351
|
+
not_found_msg += " with #{expect_text}" if expect_text
|
352
|
+
|
353
|
+
begin
|
354
|
+
modal_text = browser.modal_message
|
355
|
+
raise Capybara::ModalNotFound if modal_text.nil? || (expect_text && !modal_text.match(expect_regexp))
|
356
|
+
rescue Capybara::ModalNotFound => e
|
357
|
+
raise e, not_found_msg if (Time.now - start_time) >= timeout_sec
|
358
|
+
sleep(0.05)
|
359
|
+
retry
|
360
|
+
end
|
361
|
+
modal_text
|
362
|
+
end
|
363
|
+
|
364
|
+
def session_wait_time
|
365
|
+
if respond_to?(:session_options)
|
366
|
+
session_options.default_max_wait_time
|
367
|
+
else
|
368
|
+
begin
|
369
|
+
Capybara.default_max_wait_time
|
370
|
+
rescue
|
371
|
+
Capybara.default_wait_time
|
372
|
+
end
|
373
|
+
end
|
374
|
+
end
|
375
|
+
|
376
|
+
def default_cookie_host
|
377
|
+
if respond_to?(:session_options)
|
378
|
+
session_options.app_host
|
379
|
+
else
|
380
|
+
Capybara.app_host
|
381
|
+
end || ""
|
382
|
+
end
|
383
|
+
|
384
|
+
def unwrap_script_result(arg)
|
385
|
+
case arg
|
386
|
+
when Array
|
387
|
+
arg.map { |e| unwrap_script_result(e) }
|
388
|
+
when Hash
|
389
|
+
return Capybara::Cuprite::Node.new(self, arg["target_id"], arg["node"]) if arg["target_id"]
|
390
|
+
arg.each { |k, v| arg[k] = unwrap_script_result(v) }
|
391
|
+
else
|
392
|
+
arg
|
393
|
+
end
|
394
|
+
end
|
395
|
+
end
|
396
|
+
end
|