test-prof 0.1.0.beta1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +103 -0
- data/assets/flamegraph.demo.html +173 -0
- data/assets/flamegraph.template.html +196 -0
- data/assets/src/d3-tip.js +352 -0
- data/assets/src/d3-tip.min.js +1 -0
- data/assets/src/d3.flameGraph.css +92 -0
- data/assets/src/d3.flameGraph.js +459 -0
- data/assets/src/d3.flameGraph.min.css +1 -0
- data/assets/src/d3.flameGraph.min.js +1 -0
- data/assets/src/d3.v4.min.js +8 -0
- data/guides/any_fixture.md +60 -0
- data/guides/before_all.md +98 -0
- data/guides/event_prof.md +97 -0
- data/guides/factory_doctor.md +64 -0
- data/guides/factory_prof.md +85 -0
- data/guides/rspec_stamp.md +53 -0
- data/guides/rubocop.md +48 -0
- data/guides/ruby_prof.md +61 -0
- data/guides/stack_prof.md +43 -0
- data/lib/test-prof.rb +3 -0
- data/lib/test_prof/any_fixture.rb +67 -0
- data/lib/test_prof/cops/rspec/aggregate_failures.rb +140 -0
- data/lib/test_prof/event_prof/custom_events/factory_create.rb +51 -0
- data/lib/test_prof/event_prof/custom_events/sidekiq_inline.rb +48 -0
- data/lib/test_prof/event_prof/custom_events/sidekiq_jobs.rb +38 -0
- data/lib/test_prof/event_prof/custom_events.rb +5 -0
- data/lib/test_prof/event_prof/instrumentations/active_support.rb +16 -0
- data/lib/test_prof/event_prof/minitest.rb +3 -0
- data/lib/test_prof/event_prof/rspec.rb +94 -0
- data/lib/test_prof/event_prof.rb +177 -0
- data/lib/test_prof/ext/float_duration.rb +14 -0
- data/lib/test_prof/factory_doctor/factory_girl_patch.rb +12 -0
- data/lib/test_prof/factory_doctor/minitest.rb +3 -0
- data/lib/test_prof/factory_doctor/rspec.rb +96 -0
- data/lib/test_prof/factory_doctor.rb +133 -0
- data/lib/test_prof/factory_prof/factory_girl_patch.rb +12 -0
- data/lib/test_prof/factory_prof/printers/flamegraph.rb +71 -0
- data/lib/test_prof/factory_prof/printers/simple.rb +28 -0
- data/lib/test_prof/factory_prof.rb +140 -0
- data/lib/test_prof/logging.rb +25 -0
- data/lib/test_prof/recipes/rspec/any_fixture.rb +21 -0
- data/lib/test_prof/recipes/rspec/before_all.rb +23 -0
- data/lib/test_prof/rspec_stamp/parser.rb +103 -0
- data/lib/test_prof/rspec_stamp/rspec.rb +91 -0
- data/lib/test_prof/rspec_stamp.rb +135 -0
- data/lib/test_prof/rubocop.rb +3 -0
- data/lib/test_prof/ruby_prof/rspec.rb +13 -0
- data/lib/test_prof/ruby_prof.rb +194 -0
- data/lib/test_prof/stack_prof/rspec.rb +13 -0
- data/lib/test_prof/stack_prof.rb +120 -0
- data/lib/test_prof/version.rb +5 -0
- data/lib/test_prof.rb +108 -0
- metadata +227 -0
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ripper"
|
4
|
+
|
5
|
+
# rubocop: disable Metrics/CyclomaticComplexity
|
6
|
+
|
7
|
+
module TestProf
|
8
|
+
module RSpecStamp
|
9
|
+
# Parse examples headers
|
10
|
+
module Parser
|
11
|
+
# Contains the result of parsing
|
12
|
+
class Result
|
13
|
+
attr_accessor :fname, :desc
|
14
|
+
attr_reader :tags, :htags
|
15
|
+
|
16
|
+
def add_tag(v)
|
17
|
+
@tags ||= []
|
18
|
+
@tags << v
|
19
|
+
end
|
20
|
+
|
21
|
+
def add_htag(k, v)
|
22
|
+
@htags ||= []
|
23
|
+
@htags << [k, v]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class << self
|
28
|
+
def parse(code)
|
29
|
+
sexp = Ripper.sexp(code)
|
30
|
+
return unless sexp
|
31
|
+
|
32
|
+
# sexp has the following format:
|
33
|
+
# [:program,
|
34
|
+
# [
|
35
|
+
# [
|
36
|
+
# :command,
|
37
|
+
# [:@ident, "it", [1, 0]],
|
38
|
+
# [:args_add_block, [ ... ]]
|
39
|
+
# ]
|
40
|
+
# ]
|
41
|
+
# ]
|
42
|
+
#
|
43
|
+
# or
|
44
|
+
#
|
45
|
+
# [:program,
|
46
|
+
# [
|
47
|
+
# [
|
48
|
+
# :vcall,
|
49
|
+
# [:@ident, "it", [1, 0]]
|
50
|
+
# ]
|
51
|
+
# ]
|
52
|
+
# ]
|
53
|
+
res = Result.new
|
54
|
+
|
55
|
+
fcall = sexp[1][0][1]
|
56
|
+
fcall = fcall[1] if fcall.first == :fcall
|
57
|
+
res.fname = fcall[1]
|
58
|
+
|
59
|
+
args_block = sexp[1][0][2]
|
60
|
+
|
61
|
+
return res if args_block.nil?
|
62
|
+
|
63
|
+
args_block = args_block[1] if args_block.first == :arg_paren
|
64
|
+
|
65
|
+
args = args_block[1]
|
66
|
+
|
67
|
+
if args.first.first == :string_literal
|
68
|
+
res.desc = parse_literal(args.shift)
|
69
|
+
end
|
70
|
+
|
71
|
+
parse_arg(res, args.shift) until args.empty?
|
72
|
+
|
73
|
+
res
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def parse_arg(res, arg)
|
79
|
+
if arg.first == :symbol_literal
|
80
|
+
res.add_tag parse_literal(arg)
|
81
|
+
elsif arg.first == :bare_assoc_hash
|
82
|
+
parse_hash(res, arg[1])
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def parse_hash(res, hash_arg)
|
87
|
+
hash_arg.each do |(_, label, val)|
|
88
|
+
res.add_htag label[1][0..-2].to_sym, parse_literal(val)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Expr of the form:
|
93
|
+
# [:string_literal, [:string_content, [:@tstring_content, "is", [1, 4]]]]
|
94
|
+
def parse_literal(expr)
|
95
|
+
val = expr[1][1][1]
|
96
|
+
val = val.to_sym if expr[0] == :symbol_literal ||
|
97
|
+
expr[0] == :assoc_new
|
98
|
+
val
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TestProf
|
4
|
+
module RSpecStamp
|
5
|
+
class RSpecListener # :nodoc:
|
6
|
+
include Logging
|
7
|
+
|
8
|
+
NOTIFICATIONS = %i[
|
9
|
+
example_failed
|
10
|
+
].freeze
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@failed = 0
|
14
|
+
@ignored = 0
|
15
|
+
@total = 0
|
16
|
+
@examples = Hash.new { |h, k| h[k] = [] }
|
17
|
+
end
|
18
|
+
|
19
|
+
def example_failed(notification)
|
20
|
+
return if notification.example.pending?
|
21
|
+
|
22
|
+
location = notification.example.metadata[:location]
|
23
|
+
|
24
|
+
file, line = location.split(":")
|
25
|
+
|
26
|
+
@examples[file] << line.to_i
|
27
|
+
end
|
28
|
+
|
29
|
+
def stamp!
|
30
|
+
@examples.each do |file, lines|
|
31
|
+
stamp_file(file, lines.uniq)
|
32
|
+
end
|
33
|
+
|
34
|
+
msgs = []
|
35
|
+
|
36
|
+
msgs <<
|
37
|
+
<<~MSG
|
38
|
+
RSpec Stamp results
|
39
|
+
|
40
|
+
Total patches: #{@total}
|
41
|
+
Total files: #{@examples.keys.size}
|
42
|
+
|
43
|
+
Failed patches: #{@failed}
|
44
|
+
Ignored files: #{@ignored}
|
45
|
+
MSG
|
46
|
+
|
47
|
+
log :info, msgs.join
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def stamp_file(file, lines)
|
53
|
+
@total += lines.size
|
54
|
+
return if ignored?(file)
|
55
|
+
|
56
|
+
log :info, "(dry-run) Patching #{file}" if dry_run?
|
57
|
+
|
58
|
+
code = File.readlines(file)
|
59
|
+
|
60
|
+
@failed += RSpecStamp.apply_tags(code, lines, RSpecStamp.config.tags)
|
61
|
+
|
62
|
+
File.write(file, code.join) unless dry_run?
|
63
|
+
end
|
64
|
+
|
65
|
+
def ignored?(file)
|
66
|
+
ignored = RSpecStamp.config.ignore_files.find do |pattern|
|
67
|
+
file =~ pattern
|
68
|
+
end
|
69
|
+
|
70
|
+
return unless ignored
|
71
|
+
log :warn, "Ignore stamping file: #{file}"
|
72
|
+
@ignored += 1
|
73
|
+
end
|
74
|
+
|
75
|
+
def dry_run?
|
76
|
+
RSpecStamp.config.dry_run?
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Register EventProf listener
|
83
|
+
TestProf.activate('RSTAMP') do
|
84
|
+
RSpec.configure do |config|
|
85
|
+
listener = TestProf::RSpecStamp::RSpecListener.new
|
86
|
+
|
87
|
+
config.reporter.register_listener(listener, *TestProf::RSpecStamp::RSpecListener::NOTIFICATIONS)
|
88
|
+
|
89
|
+
config.after(:suite) { listener.stamp! }
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "test_prof/logging"
|
4
|
+
require "test_prof/rspec_stamp/parser"
|
5
|
+
|
6
|
+
module TestProf
|
7
|
+
# Mark RSpec examples with provided tags
|
8
|
+
module RSpecStamp
|
9
|
+
EXAMPLE_RXP = /(\s*)(\w+\s*(?:.*)\s*)(do|{)/
|
10
|
+
|
11
|
+
# RSpecStamp configuration
|
12
|
+
class Configuration
|
13
|
+
attr_accessor :ignore_files, :dry_run, :tags
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@ignore_files = [%r{spec/support}]
|
17
|
+
@dry_run = ENV['RSTAMP_DRY_RUN'] == '1'
|
18
|
+
self.tags = ENV['RSTAMP']
|
19
|
+
end
|
20
|
+
|
21
|
+
def dry_run?
|
22
|
+
@dry_run == true
|
23
|
+
end
|
24
|
+
|
25
|
+
def tags=(val)
|
26
|
+
@tags = if val.is_a?(String)
|
27
|
+
parse_tags(val)
|
28
|
+
else
|
29
|
+
val
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def parse_tags(str)
|
36
|
+
str.split(/\s*,\s*/).each_with_object([]) do |tag, acc|
|
37
|
+
k, v = tag.split(":")
|
38
|
+
acc << if v.nil?
|
39
|
+
k.to_sym
|
40
|
+
else
|
41
|
+
Hash[k.to_sym, v.to_sym]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
class << self
|
48
|
+
include TestProf::Logging
|
49
|
+
|
50
|
+
def config
|
51
|
+
@config ||= Configuration.new
|
52
|
+
end
|
53
|
+
|
54
|
+
def configure
|
55
|
+
yield config
|
56
|
+
end
|
57
|
+
|
58
|
+
# Accepts source code (as array of lines),
|
59
|
+
# line numbers (of example to apply tags)
|
60
|
+
# and an array of tags.
|
61
|
+
def apply_tags(code, lines, tags)
|
62
|
+
failed = 0
|
63
|
+
|
64
|
+
lines.each do |line|
|
65
|
+
unless stamp_example(code[line - 1], tags)
|
66
|
+
failed += 1
|
67
|
+
log :warn, "Failed to stamp: #{code[line - 1]}"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
failed
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
# rubocop: disable Metrics/CyclomaticComplexity
|
76
|
+
# rubocop: disable Metrics/PerceivedComplexity
|
77
|
+
def stamp_example(example, tags)
|
78
|
+
matches = example.match(EXAMPLE_RXP)
|
79
|
+
return false unless matches
|
80
|
+
|
81
|
+
code = matches[2]
|
82
|
+
block = matches[3]
|
83
|
+
|
84
|
+
parsed = Parser.parse(code)
|
85
|
+
return false unless parsed
|
86
|
+
|
87
|
+
parsed.desc ||= 'works'
|
88
|
+
|
89
|
+
tags.each do |t|
|
90
|
+
if t.is_a?(Hash)
|
91
|
+
t.keys.each { |k| parsed.add_htag(k, t[k]) }
|
92
|
+
else
|
93
|
+
parsed.add_tag(t)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
need_parens = block == "{"
|
98
|
+
|
99
|
+
tags_str = parsed.tags.map { |t| t.is_a?(Symbol) ? ":#{t}" : t }.join(", ") unless
|
100
|
+
parsed.tags.nil?
|
101
|
+
|
102
|
+
unless parsed.htags.nil?
|
103
|
+
htags_str = parsed.htags.map do |(k, v)|
|
104
|
+
vstr = v.is_a?(Symbol) ? ":#{v}" : quote(v)
|
105
|
+
|
106
|
+
"#{k}: #{vstr}"
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
replacement = "\\1#{parsed.fname}#{need_parens ? '(' : ' '}"\
|
111
|
+
"#{[quote(parsed.desc), tags_str, htags_str].compact.join(', ')}"\
|
112
|
+
"#{need_parens ? ') ' : ' '}\\3"
|
113
|
+
|
114
|
+
if config.dry_run?
|
115
|
+
log :info, "Patched: #{example.sub(EXAMPLE_RXP, replacement)}"
|
116
|
+
else
|
117
|
+
example.sub!(EXAMPLE_RXP, replacement)
|
118
|
+
end
|
119
|
+
true
|
120
|
+
end
|
121
|
+
# rubocop: enable Metrics/CyclomaticComplexity
|
122
|
+
# rubocop: enable Metrics/PerceivedComplexity
|
123
|
+
|
124
|
+
def quote(str)
|
125
|
+
if str.include?("'")
|
126
|
+
"\"#{str}\""
|
127
|
+
else
|
128
|
+
"'#{str}'"
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
require "test_prof/rspec_stamp/rspec" if defined?(RSpec)
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Shared example for RSpec to profile specific examples with RubyProf
|
4
|
+
RSpec.shared_context "ruby-prof", rprof: true do
|
5
|
+
prepend_before do
|
6
|
+
@ruby_prof_report = TestProf::RubyProf.profile
|
7
|
+
end
|
8
|
+
|
9
|
+
append_after do |ex|
|
10
|
+
next unless @ruby_prof_report
|
11
|
+
@ruby_prof_report.dump ex.full_description.parameterize
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,194 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TestProf
|
4
|
+
# RubyProf wrapper.
|
5
|
+
#
|
6
|
+
# Has 2 modes: global and per-example.
|
7
|
+
#
|
8
|
+
# Example:
|
9
|
+
#
|
10
|
+
# # To activate global profiling you can use env variable
|
11
|
+
# TEST_RUBY_PROF=1 rspec ...
|
12
|
+
#
|
13
|
+
# # or in your code
|
14
|
+
# TestProf::RubyProf.run
|
15
|
+
#
|
16
|
+
# To profile a specific examples add :rprof tag to it:
|
17
|
+
#
|
18
|
+
# it "is doing heavy stuff", :rprof do
|
19
|
+
# ...
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
module RubyProf
|
23
|
+
# RubyProf configuration
|
24
|
+
class Configuration
|
25
|
+
# Default list of methods to exclude from profile.
|
26
|
+
# Contains a lot of RSpec stuff.
|
27
|
+
ELIMINATE_METHODS = [
|
28
|
+
/instance_exec/,
|
29
|
+
/ExampleGroup>?#run/,
|
30
|
+
/Procsy/,
|
31
|
+
/AroundHook#execute_with/,
|
32
|
+
/HookCollections/,
|
33
|
+
/Array#(map|each)/
|
34
|
+
].freeze
|
35
|
+
|
36
|
+
PRINTERS = {
|
37
|
+
'flat' => 'FlatPrinter',
|
38
|
+
'flat_wln' => 'FlatWithLineNumbers',
|
39
|
+
'graph' => 'GraphPrinter',
|
40
|
+
'graph_html' => 'GraphHtmlPrinter',
|
41
|
+
'dot' => 'DotPrinter',
|
42
|
+
'.' => 'DotPrinter',
|
43
|
+
'call_stack' => 'CallStackPrinter',
|
44
|
+
'call_tree' => 'CallTreePrinter'
|
45
|
+
}.freeze
|
46
|
+
|
47
|
+
attr_accessor :printer, :mode, :min_percent,
|
48
|
+
:include_threads, :eliminate_methods
|
49
|
+
|
50
|
+
def initialize
|
51
|
+
@printer = :call_stack
|
52
|
+
@mode = :wall
|
53
|
+
@min_percent = 1
|
54
|
+
@include_threads = false
|
55
|
+
@eliminate_methods = ELIMINATE_METHODS
|
56
|
+
end
|
57
|
+
|
58
|
+
def include_threads?
|
59
|
+
include_threads == true
|
60
|
+
end
|
61
|
+
|
62
|
+
def eliminate_methods?
|
63
|
+
!eliminate_methods.nil? &&
|
64
|
+
!eliminate_methods.empty?
|
65
|
+
end
|
66
|
+
|
67
|
+
# Returns an array of printer type (ID) and class.
|
68
|
+
# Takes ENV variable TEST_RUBY_PROF_PRINTER into account.
|
69
|
+
def resolve_printer
|
70
|
+
type = ENV['TEST_RUBY_PROF_PRINTER'] || printer
|
71
|
+
|
72
|
+
return ['custom', type] if type.is_a?(Module)
|
73
|
+
|
74
|
+
type = type.to_s
|
75
|
+
|
76
|
+
raise ArgumentError, "Unknown printer: #{type}" unless
|
77
|
+
PRINTERS.key?(type)
|
78
|
+
|
79
|
+
[type, ::RubyProf.const_get(PRINTERS[type])]
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Wrapper over RubyProf profiler and printer
|
84
|
+
class Report
|
85
|
+
include TestProf::Logging
|
86
|
+
|
87
|
+
def initialize(profiler)
|
88
|
+
@profiler = profiler
|
89
|
+
end
|
90
|
+
|
91
|
+
# Stop profiling and generate the report
|
92
|
+
# using provided name.
|
93
|
+
def dump(name)
|
94
|
+
result = @profiler.stop
|
95
|
+
|
96
|
+
if config.eliminate_methods?
|
97
|
+
result.eliminate_methods!(config.eliminate_methods)
|
98
|
+
end
|
99
|
+
|
100
|
+
printer_type, printer_class = config.resolve_printer
|
101
|
+
path = build_path name, printer_type
|
102
|
+
|
103
|
+
File.open(path, 'w') do |f|
|
104
|
+
printer_class.new(result).print(f, min_percent: config.min_percent)
|
105
|
+
end
|
106
|
+
|
107
|
+
log :info, "RubyProf report generated: #{path}"
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
def build_path(name, printer)
|
113
|
+
TestProf.artefact_path(
|
114
|
+
"ruby-prof-report-#{printer}-#{config.mode}-#{name}.html"
|
115
|
+
)
|
116
|
+
end
|
117
|
+
|
118
|
+
def config
|
119
|
+
RubyProf.config
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
class << self
|
124
|
+
include Logging
|
125
|
+
|
126
|
+
def config
|
127
|
+
@config ||= Configuration.new
|
128
|
+
end
|
129
|
+
|
130
|
+
def configure
|
131
|
+
yield config
|
132
|
+
end
|
133
|
+
|
134
|
+
# Run RubyProf and automatically dump
|
135
|
+
# a report when the process exits.
|
136
|
+
#
|
137
|
+
# Use this method to profile the whole run.
|
138
|
+
def run
|
139
|
+
report = profile
|
140
|
+
|
141
|
+
return unless report
|
142
|
+
|
143
|
+
@locked = true
|
144
|
+
|
145
|
+
log :info, "RubyProf enabled"
|
146
|
+
|
147
|
+
at_exit { report.dump("total") }
|
148
|
+
end
|
149
|
+
|
150
|
+
def profile
|
151
|
+
return if locked?
|
152
|
+
return unless init_ruby_prof
|
153
|
+
|
154
|
+
options = {
|
155
|
+
merge_fibers: true
|
156
|
+
}
|
157
|
+
|
158
|
+
options[:include_threads] = [Thread.current] unless
|
159
|
+
config.include_threads?
|
160
|
+
|
161
|
+
profiler = ::RubyProf::Profile.new(options)
|
162
|
+
profiler.start
|
163
|
+
|
164
|
+
Report.new(profiler)
|
165
|
+
end
|
166
|
+
|
167
|
+
private
|
168
|
+
|
169
|
+
def locked?
|
170
|
+
@locked == true
|
171
|
+
end
|
172
|
+
|
173
|
+
def init_ruby_prof
|
174
|
+
return @initialized if instance_variable_defined?(:@initialized)
|
175
|
+
ENV["RUBY_PROF_MEASURE_MODE"] = config.mode.to_s
|
176
|
+
@initialized = TestProf.require(
|
177
|
+
'ruby-prof',
|
178
|
+
<<~MSG
|
179
|
+
Please, install 'ruby-prof' first:
|
180
|
+
# Gemfile
|
181
|
+
gem 'ruby-prof', require: false
|
182
|
+
MSG
|
183
|
+
)
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
require "test_prof/ruby_prof/rspec" if defined?(RSpec)
|
190
|
+
|
191
|
+
# Hook to run RubyProf globally
|
192
|
+
TestProf.activate('TEST_RUBY_PROF') do
|
193
|
+
TestProf::RubyProf.run
|
194
|
+
end
|