sonar 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.travis.yml +6 -0
- data/Gemfile +2 -0
- data/LICENSE +19 -0
- data/README.md +283 -0
- data/Rakefile +27 -0
- data/lib/sonar.rb +97 -0
- data/lib/sonar/cookies.rb +125 -0
- data/lib/sonar/session.rb +150 -0
- data/sonar.gemspec +25 -0
- data/test/setup.rb +152 -0
- data/test/test__app.rb +71 -0
- data/test/test__auth.rb +144 -0
- data/test/test__cookies.rb +284 -0
- data/test/test__follow_redirect.rb +40 -0
- data/test/test__headers.rb +94 -0
- data/test/test__map.rb +73 -0
- data/test/test__params.rb +47 -0
- data/test/test__request_methods.rb +76 -0
- data/test/test__session.rb +49 -0
- metadata +131 -0
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
|
2
|
+
Copyright (c) 2012-2013 Walter Smith <waltee.smith@gmail.com>
|
3
|
+
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
5
|
+
of this software and associated documentation files (the "Software"),
|
6
|
+
to deal in the Software without restriction, including without limitation
|
7
|
+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
8
|
+
and/or sell copies of the Software, and to permit persons to whom the Software
|
9
|
+
is furnished to do so, subject to the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
12
|
+
copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
17
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
18
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
19
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,283 @@
|
|
1
|
+
|
2
|
+
<a href="https://travis-ci.org/waltee/sonar">
|
3
|
+
<img src="https://travis-ci.org/waltee/sonar.png" align="right"></a>
|
4
|
+
|
5
|
+
# Sonar
|
6
|
+
|
7
|
+
**API for Testing Rack Apps with easy**
|
8
|
+
|
9
|
+
## Install
|
10
|
+
|
11
|
+
```bash
|
12
|
+
$ [sudo] gem install sonar
|
13
|
+
```
|
14
|
+
|
15
|
+
## Load
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
require 'sonar'
|
19
|
+
```
|
20
|
+
|
21
|
+
## Use
|
22
|
+
|
23
|
+
Simply `include Sonar` in your tests.
|
24
|
+
|
25
|
+
Or use it directly, by initialize a session via `SonarSession.new`
|
26
|
+
|
27
|
+
## App
|
28
|
+
|
29
|
+
When mixin used, call `app RackApp` inside testing suite to set app to be tested.
|
30
|
+
|
31
|
+
**Minitest Example:**
|
32
|
+
|
33
|
+
```ruby
|
34
|
+
require 'sonar'
|
35
|
+
|
36
|
+
class MyTests < MiniTest::Unit::TestCase
|
37
|
+
|
38
|
+
include Sonar
|
39
|
+
|
40
|
+
def setup
|
41
|
+
app MyRackApp
|
42
|
+
end
|
43
|
+
|
44
|
+
def test
|
45
|
+
get '/url'
|
46
|
+
assert_equal last_response.status, 200
|
47
|
+
end
|
48
|
+
end
|
49
|
+
```
|
50
|
+
|
51
|
+
**[Specular](https://github.com/waltee/specular) Example:**
|
52
|
+
|
53
|
+
```ruby
|
54
|
+
Spec.new do
|
55
|
+
app MyRackApp
|
56
|
+
|
57
|
+
get '/url'
|
58
|
+
expect(last_response.status) == 200
|
59
|
+
end
|
60
|
+
```
|
61
|
+
|
62
|
+
Multiple apps can be tested within same suite.<br/>
|
63
|
+
Each app will run own session.
|
64
|
+
|
65
|
+
**Minitest Example:**
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
require 'sonar'
|
69
|
+
|
70
|
+
class MyTests < MiniTest::Unit::TestCase
|
71
|
+
|
72
|
+
include Sonar
|
73
|
+
|
74
|
+
def setup
|
75
|
+
app MyRackApp
|
76
|
+
end
|
77
|
+
|
78
|
+
def test
|
79
|
+
# querying default app
|
80
|
+
get '/url'
|
81
|
+
assert_equal last_response.status, 200
|
82
|
+
|
83
|
+
# testing ForumApp
|
84
|
+
app ForumApp
|
85
|
+
get '/posts'
|
86
|
+
assert_equal last_response.status, 200
|
87
|
+
|
88
|
+
# back to default app
|
89
|
+
app MyRackApp
|
90
|
+
get '/url'
|
91
|
+
assert_equal last_response.status, 200
|
92
|
+
end
|
93
|
+
end
|
94
|
+
```
|
95
|
+
|
96
|
+
When using session manually, you should set app at initialization.
|
97
|
+
|
98
|
+
**Example:**
|
99
|
+
|
100
|
+
```ruby
|
101
|
+
session = SonarSession.new MyRackApp
|
102
|
+
session.get '/url'
|
103
|
+
assert_equal session.last_response.status, 200
|
104
|
+
```
|
105
|
+
|
106
|
+
|
107
|
+
## Resetting App
|
108
|
+
|
109
|
+
Sometimes you need to start over with a new app in pristine state, i.e. no cookies, no headers etc.
|
110
|
+
|
111
|
+
To achieve this, simply call `reset_app!` (or `reset_browser!`).
|
112
|
+
|
113
|
+
This will reset currently tested app. Other tested apps will stay untouched.
|
114
|
+
|
115
|
+
When creating sessions manually, app can NOT be switched/reset.<br/>
|
116
|
+
To test another app, simply create another session.
|
117
|
+
|
118
|
+
## Requests
|
119
|
+
|
120
|
+
Use one of `get`, `post`, `put`, `patch`, `delete`, `options`, `head`
|
121
|
+
to make requests via Sonar browser.
|
122
|
+
|
123
|
+
To make a secure request, add `s_` prefix:
|
124
|
+
|
125
|
+
```ruby
|
126
|
+
s_get '/path'
|
127
|
+
s_post '/path'
|
128
|
+
# etc.
|
129
|
+
```
|
130
|
+
|
131
|
+
To make a request via XHR, aka Ajax, add `_x` suffix:
|
132
|
+
|
133
|
+
```ruby
|
134
|
+
get_x '/path'
|
135
|
+
post_x '/path'
|
136
|
+
# etc.
|
137
|
+
```
|
138
|
+
|
139
|
+
To make a secure request via XHR, add both `s_` and `_x`:
|
140
|
+
|
141
|
+
```ruby
|
142
|
+
s_get_x '/path'
|
143
|
+
s_post_x '/path'
|
144
|
+
# etc.
|
145
|
+
```
|
146
|
+
|
147
|
+
In terms of arguments, making HTTP requests via Sonar is identical to calling regular Ruby methods.<br/>
|
148
|
+
That's it, you do not need to join parameters into a string.<br/>
|
149
|
+
Just pass them as usual arguments:
|
150
|
+
|
151
|
+
```ruby
|
152
|
+
post '/news', :create, :title => rand
|
153
|
+
post '/news', :update, id, :title => rand
|
154
|
+
get '/news', :delete, id
|
155
|
+
```
|
156
|
+
|
157
|
+
## Map
|
158
|
+
|
159
|
+
Previous example works just fine, however it is redundant and inconsistent.<br/>
|
160
|
+
Just imagine that tested app changed its base URL from /news to /headlines.
|
161
|
+
|
162
|
+
The solution is simple.<br/>
|
163
|
+
Use `map` to define a base URL that will be prepended to each request,<br/>
|
164
|
+
except ones starting with a slash or a protocol(http://, https:// etc.) of course.
|
165
|
+
|
166
|
+
```ruby
|
167
|
+
Spec.new do
|
168
|
+
app MyRackApp
|
169
|
+
map '/news'
|
170
|
+
|
171
|
+
post :create, :title => rand
|
172
|
+
post :update, id, :title => rand
|
173
|
+
get :delete, id
|
174
|
+
end
|
175
|
+
```
|
176
|
+
|
177
|
+
**Note:** requests starting with a slash or protocol(http://, https:// etc.)
|
178
|
+
wont use base URL defined by `map`.<br/>
|
179
|
+
|
180
|
+
**Note:** when you switching tested app, make sure you also change the map.
|
181
|
+
|
182
|
+
To disable mapping, simply call `map nil`
|
183
|
+
|
184
|
+
## Cookies
|
185
|
+
|
186
|
+
**Set cookies:**
|
187
|
+
|
188
|
+
```ruby
|
189
|
+
cookies['name'] = 'value'
|
190
|
+
```
|
191
|
+
|
192
|
+
**Read cookies:**
|
193
|
+
|
194
|
+
```ruby
|
195
|
+
cookie = cookies['name']
|
196
|
+
```
|
197
|
+
|
198
|
+
**Delete a cookie:**
|
199
|
+
|
200
|
+
```ruby
|
201
|
+
cookies.delete 'name'
|
202
|
+
```
|
203
|
+
|
204
|
+
|
205
|
+
**Clear all cookies:**
|
206
|
+
|
207
|
+
```ruby
|
208
|
+
cookies.clear
|
209
|
+
```
|
210
|
+
|
211
|
+
Each app uses its own cookies jar.
|
212
|
+
|
213
|
+
## Headers
|
214
|
+
|
215
|
+
Sonar allow to set headers that will be sent to app on all consequent requests.
|
216
|
+
|
217
|
+
**Set headers:**
|
218
|
+
|
219
|
+
```ruby
|
220
|
+
header['User-Agent'] = 'Sonar'
|
221
|
+
header['Content-Type'] = 'text/plain'
|
222
|
+
header['rack.input'] = 'someString'
|
223
|
+
# etc.
|
224
|
+
```
|
225
|
+
|
226
|
+
**Read headers:**
|
227
|
+
|
228
|
+
```ruby
|
229
|
+
header = headers['User-Agent']
|
230
|
+
# etc.
|
231
|
+
```
|
232
|
+
|
233
|
+
**Delete a header:**
|
234
|
+
|
235
|
+
```ruby
|
236
|
+
headers.delete 'User-Agent'
|
237
|
+
```
|
238
|
+
|
239
|
+
**Clear all headers:**
|
240
|
+
|
241
|
+
```ruby
|
242
|
+
headers.clear
|
243
|
+
```
|
244
|
+
|
245
|
+
Each app uses its own headers.
|
246
|
+
|
247
|
+
## Authorization
|
248
|
+
|
249
|
+
**Basic Auth:**
|
250
|
+
|
251
|
+
```ruby
|
252
|
+
authorize 'user', 'pass'
|
253
|
+
```
|
254
|
+
|
255
|
+
**Reset earlier set Basic authorization header:**
|
256
|
+
|
257
|
+
```ruby
|
258
|
+
reset_basic_auth!
|
259
|
+
```
|
260
|
+
|
261
|
+
**Digest Auth:**
|
262
|
+
|
263
|
+
```ruby
|
264
|
+
digest_authorize 'user', 'pass'
|
265
|
+
```
|
266
|
+
|
267
|
+
**Reset earlier set Digest authorization header:**
|
268
|
+
|
269
|
+
```ruby
|
270
|
+
reset_digest_auth!
|
271
|
+
```
|
272
|
+
|
273
|
+
**Reset ANY earlier set authorization header:**
|
274
|
+
|
275
|
+
```ruby
|
276
|
+
reset_auth!
|
277
|
+
```
|
278
|
+
|
279
|
+
## Follow Redirects
|
280
|
+
|
281
|
+
By default, Sonar wont follow redirects.
|
282
|
+
|
283
|
+
If last response is a redirect and you want Sonar to follow it, use `follow_redirect!`
|
data/Rakefile
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
require 'bundler/gem_helper'
|
4
|
+
|
5
|
+
task default: :test
|
6
|
+
|
7
|
+
require './test/setup'
|
8
|
+
|
9
|
+
desc 'Run all tests'
|
10
|
+
task :test do
|
11
|
+
::Dir['./test/test__*.rb'].each { |f| require f }
|
12
|
+
session = Specular.new
|
13
|
+
session.boot { include Sonar }
|
14
|
+
session.before do |app|
|
15
|
+
if app && app.respond_to?(:base_url)
|
16
|
+
app(app)
|
17
|
+
map(app.base_url)
|
18
|
+
get
|
19
|
+
end
|
20
|
+
end
|
21
|
+
session.run /SonarTest/, trace: true
|
22
|
+
puts session.failures if session.failed?
|
23
|
+
puts session.summary
|
24
|
+
session.exit_code
|
25
|
+
end
|
26
|
+
|
27
|
+
Bundler::GemHelper.install_tasks
|
data/lib/sonar.rb
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'uri'
|
3
|
+
require 'rack'
|
4
|
+
|
5
|
+
|
6
|
+
module SonarConstants
|
7
|
+
|
8
|
+
REQUEST_METHODS = %w[GET POST PUT PATCH DELETE OPTIONS HEAD TRACE].freeze
|
9
|
+
SESSION_METHODS = %w[
|
10
|
+
header headers cookies last_request last_response
|
11
|
+
auth authorize basic_authorize digest_auth digest_authorize
|
12
|
+
reset_auth! reset_basic_auth! reset_digest_auth!
|
13
|
+
].freeze
|
14
|
+
DEFAULT_HOST = 'sonar.org'.freeze
|
15
|
+
DEFAULT_ENV = {'REMOTE_ADDR' => '127.0.0.1', 'HTTP_HOST' => DEFAULT_HOST}.freeze
|
16
|
+
end
|
17
|
+
|
18
|
+
module Sonar
|
19
|
+
|
20
|
+
# switch session
|
21
|
+
#
|
22
|
+
# sonar using app based sessions, that's it, creates sessions based on app __id__.
|
23
|
+
# you can test multiple apps and use `app RackApp` to switch between them.
|
24
|
+
#
|
25
|
+
def app app = nil
|
26
|
+
@__sonar__app__ = app if app
|
27
|
+
@__sonar__app__
|
28
|
+
end
|
29
|
+
|
30
|
+
def map *args
|
31
|
+
@__sonar__base_url__ = args.first if args.size > 0
|
32
|
+
@__sonar__base_url__
|
33
|
+
end
|
34
|
+
|
35
|
+
# reset session for current app.
|
36
|
+
# everything will be reset - cookies, headers, authorizations etc.
|
37
|
+
def reset_app!
|
38
|
+
__sonar__session__ :reset
|
39
|
+
end
|
40
|
+
|
41
|
+
alias reset_browser! reset_app!
|
42
|
+
|
43
|
+
::SonarConstants::REQUEST_METHODS.each do |request_method|
|
44
|
+
define_method request_method.downcase do |*args|
|
45
|
+
params = args.last.is_a?(Hash) ? args.pop : {}
|
46
|
+
request :http, request_method, args.compact.join('/'), params
|
47
|
+
end
|
48
|
+
# secure
|
49
|
+
define_method 's_%s' % request_method.downcase do |*args|
|
50
|
+
params = args.last.is_a?(Hash) ? args.pop : {}
|
51
|
+
request :https, request_method, args.compact.join('/'), params
|
52
|
+
end
|
53
|
+
# xhr
|
54
|
+
define_method '%s_x' % request_method.downcase do |*args|
|
55
|
+
params = args.last.is_a?(Hash) ? args.pop : {}
|
56
|
+
request :http, request_method, args.compact.join('/'), params, {'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'}
|
57
|
+
end
|
58
|
+
# secure xhr
|
59
|
+
define_method 's_%s_x' % request_method.downcase do |*args|
|
60
|
+
params = args.last.is_a?(Hash) ? args.pop : {}
|
61
|
+
request :https, request_method, args.compact.join('/'), params, {'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'}
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
::SonarConstants::SESSION_METHODS.each do |m|
|
66
|
+
define_method m do |*args|
|
67
|
+
__sonar__session__.send m, *args
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def request scheme, request_method, uri, params, env = {}
|
72
|
+
uri = [@__sonar__base_url__, uri].compact.join('/') unless uri =~ /\A\/|\A[\w|\d]+\:\/\//
|
73
|
+
uri = ::URI.parse(uri.gsub(/\A\/+/, '/'))
|
74
|
+
uri.scheme ||= scheme.to_s
|
75
|
+
uri.host ||= ::SonarConstants::DEFAULT_HOST
|
76
|
+
uri.path = '/' << uri.path unless uri.path =~ /\A\//
|
77
|
+
params.is_a?(Hash) && params.each_pair do |k, v|
|
78
|
+
(v.is_a?(Numeric) || v.is_a?(Symbol)) && params.update(k => v.to_s)
|
79
|
+
end
|
80
|
+
__sonar__session__.invoke_request request_method, uri, params, env
|
81
|
+
end
|
82
|
+
|
83
|
+
def follow_redirect!
|
84
|
+
last_response.redirect? ||
|
85
|
+
raise('Last response is not an redirect!')
|
86
|
+
scheme = last_request.env['HTTPS'] == 'on' ? 'https' : 'http'
|
87
|
+
request scheme, 'GET', last_response['Location'], {}, {'HTTP_REFERER' => last_request.url}
|
88
|
+
end
|
89
|
+
|
90
|
+
def __sonar__session__ reset = false
|
91
|
+
(@__sonar__session__ ||= {})[app.__id__] = ::SonarSession.new(app) if reset
|
92
|
+
(@__sonar__session__ ||= {})[app.__id__] ||= ::SonarSession.new(app)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
require 'sonar/cookies'
|
97
|
+
require 'sonar/session'
|
@@ -0,0 +1,125 @@
|
|
1
|
+
class SonarCookies
|
2
|
+
include ::Rack::Utils
|
3
|
+
|
4
|
+
def initialize
|
5
|
+
@jar = {}
|
6
|
+
end
|
7
|
+
|
8
|
+
def jar uri, cookies = nil
|
9
|
+
host = (((uri && uri.host) || ::SonarConstants::DEFAULT_HOST).split('.')[-2..-1]||[]).join('.').downcase
|
10
|
+
@jar[host] = cookies if cookies
|
11
|
+
@jar[host] ||= []
|
12
|
+
end
|
13
|
+
|
14
|
+
def [] name
|
15
|
+
cookies = to_hash
|
16
|
+
cookies[name] && cookies[name].value
|
17
|
+
end
|
18
|
+
|
19
|
+
def []= name, value
|
20
|
+
persist '%s=%s' % [name, ::Rack::Utils.escape(value)]
|
21
|
+
end
|
22
|
+
|
23
|
+
def delete name
|
24
|
+
jar nil, jar(nil).reject { |c| c.name == name }
|
25
|
+
end
|
26
|
+
|
27
|
+
def clear
|
28
|
+
jar nil, []
|
29
|
+
end
|
30
|
+
|
31
|
+
%w[size empty?].each do |m|
|
32
|
+
define_method m do |*args|
|
33
|
+
jar(nil).send __method__, *args
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def persist raw_cookies, uri = nil
|
38
|
+
return unless raw_cookies.is_a?(String)
|
39
|
+
|
40
|
+
# before adding new cookies, lets cleanup expired ones
|
41
|
+
jar uri, jar(uri).reject { |c| c.expired? }
|
42
|
+
|
43
|
+
raw_cookies = raw_cookies.strip.split("\n").reject { |c| c.empty? }
|
44
|
+
|
45
|
+
raw_cookies.each do |raw_cookie|
|
46
|
+
cookie = Cookie.new(raw_cookie, uri)
|
47
|
+
cookie.valid?(uri) || next
|
48
|
+
jar(uri, jar(uri).reject { |existing_cookie| cookie.replaces? existing_cookie })
|
49
|
+
jar(uri) << cookie
|
50
|
+
end
|
51
|
+
jar(uri).sort!
|
52
|
+
end
|
53
|
+
|
54
|
+
def to_s uri = nil
|
55
|
+
to_hash(uri).values.map { |c| c.raw }.join(';')
|
56
|
+
end
|
57
|
+
|
58
|
+
def to_hash uri = nil
|
59
|
+
jar(uri).inject({}) do |cookies, cookie|
|
60
|
+
cookies.merge((uri ? cookie.dispose_for?(uri) : true) ? {cookie.name => cookie} : {})
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
class Cookie
|
65
|
+
include ::Rack::Utils
|
66
|
+
|
67
|
+
attr_reader :raw, :name, :value, :domain, :path, :expires, :default_host
|
68
|
+
|
69
|
+
def initialize raw, uri
|
70
|
+
@default_host = ::SonarConstants::DEFAULT_HOST
|
71
|
+
|
72
|
+
uri ||= default_uri
|
73
|
+
uri.host ||= default_host
|
74
|
+
|
75
|
+
@raw, @options = raw.split(/[;,] */n, 2)
|
76
|
+
@name, @value = parse_query(@raw, ';').to_a.first
|
77
|
+
@options = parse_query(@options, ';')
|
78
|
+
|
79
|
+
@domain = @options['domain'] || uri.host || default_host
|
80
|
+
@domain = '.' << @domain unless @domain =~ /\A\./
|
81
|
+
|
82
|
+
@path = @options['path'] || uri.path.sub(/\/[^\/]*\Z/, '')
|
83
|
+
|
84
|
+
(expires = @options['expires']) && (@expires = ::Time.parse(expires))
|
85
|
+
end
|
86
|
+
|
87
|
+
def replaces? cookie
|
88
|
+
[name.downcase, domain, path] == [cookie.name.downcase, cookie.domain, cookie.path]
|
89
|
+
end
|
90
|
+
|
91
|
+
def empty?
|
92
|
+
value.nil? || value.empty?
|
93
|
+
end
|
94
|
+
|
95
|
+
def secure?
|
96
|
+
@options.has_key?('secure')
|
97
|
+
end
|
98
|
+
|
99
|
+
def expired?
|
100
|
+
expires && expires < ::Time.now.gmtime
|
101
|
+
end
|
102
|
+
|
103
|
+
def dispose_for? uri
|
104
|
+
expired? ? false : valid?(uri)
|
105
|
+
end
|
106
|
+
|
107
|
+
def valid? uri = nil
|
108
|
+
uri ||= default_uri
|
109
|
+
uri.host ||= default_host
|
110
|
+
|
111
|
+
(secure? ? uri.scheme == 'https' : true) &&
|
112
|
+
(uri.host =~ /#{::Regexp.escape(domain.sub(/\A\./, ''))}\Z/i) &&
|
113
|
+
(uri.path =~ /\A#{::Regexp.escape(path)}/)
|
114
|
+
end
|
115
|
+
|
116
|
+
def <=> cookie
|
117
|
+
[name, path, domain.reverse] <=> [cookie.name, cookie.path, cookie.domain.reverse]
|
118
|
+
end
|
119
|
+
|
120
|
+
private
|
121
|
+
def default_uri
|
122
|
+
::URI.parse('//' << default_host << '/')
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|