nitra 0.9.4 → 0.9.5

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.
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # Nitra
2
+ Nitra is a multi-process, optionally multi-server rspec and cucumber runner that uses forking to reduce memory usage and IPC to distribute builds amongst available CPUs efficiently.
3
+
4
+ ## Philosophy
5
+ * Nitra attempts to do the simplest thing possible
6
+ * Nitra (ab)uses unix primitives where possible
7
+ * Nitra doesn't do thing that unix already does better (eg. rsync)
8
+ * IPC is accomplished via pipes and select
9
+ * Forking is used heavily for several reasons
10
+ * Running nitra locally should be easy
11
+ * Running nitra on a cluster should be easy too (though verbose)
12
+ * Config files are a nuisance and should be stuffed into rake files (deals with the verbosity)
13
+
14
+ ## Usage
15
+ nitra [options] [spec_filename [...]]
16
+ -c, --cpus NUMBER Specify the number of CPUs to use on the host, or if specified after a --slave, on the slave
17
+ --cucumber Add full cucumber run, causes any files you list manually to be ignored
18
+ --debug Print debug output
19
+ -p, --print-failures Print failures immediately when they occur
20
+ -q, --quiet Quiet; don't display progress bar
21
+ --rake-after-runner task:1,task:2,task:3
22
+ The list of rake tasks to run, once per runner, in the runner's environment, just before the runner exits
23
+ --rake-before-runner task:1,task:2,task:3
24
+ The list of rake tasks to run, once per runner, in the runner's environment, after the runner starts
25
+ --rake-before-worker task:1,task:2,task:3
26
+ The list of rake tasks to run, once per worker, in the worker's environment, before the worker starts
27
+ --reset r
28
+ Reset database, equivalent to --rake-before-worker db:reset
29
+ --slave-mode Run in slave mode; ignores all other command-line options
30
+ --slave CONNECTION_COMMAND Provide a command that executes "nitra --slave-mode" on another host
31
+ --rspec Add full rspec run, causes any files you list manually to be ignored
32
+ -e, --environment ENV Set the RAILS_ENV to load
33
+ -h, --help Show this message
34
+
35
+ ### Getting started
36
+ First things first add nitra to your Gemfile:
37
+
38
+ gem 'nitra'
39
+
40
+ Then run your specs locally across your cpu's cores:
41
+
42
+ bundle exec nitra --rspec
43
+
44
+ This will just run all your specs using the default number of cpu's reported by your system. Hyperthreaded intels will report a high number which might not be a good fit, you can tune this with the -c option.
45
+
46
+ Clustered commands run slightly differently. Effectively nitra will fork and exec a command that it expects will be a nitra slave, this means we can use ssh as our tunnel of choice. It looks something like this:
47
+
48
+ bundle exec nitra --rspec --slave "ssh your.server.name 'cd your/project && bundle exec nitra --slave-mode'"
49
+
50
+ When nitra --slave command it forks, execs it, and assumes it's another process that's running "nitra --slave-mode".
51
+
52
+ ### Running a build cluster
53
+ Nitra doesn't prescribe how you get your code onto the other machines in your cluster. For example you can run a git checkout on the boxes you want to build on if it's the fastest way for you. For our part - we've had the most success with rsync.
54
+
55
+ Our build is run via rake tasks so we end up with a bunch of generated code to rsync files back and forth - here's a basic version that might help get you up and running:
56
+
57
+ namespace :nitra do
58
+ task :config do
59
+ @servers = [
60
+ {:name => "server1", :port => "66666", :c => "4"},
61
+ {:name => "server2", :port => "99999", :c => "2"},
62
+ {:name => "server3", :port => "77777", :c => "8"},
63
+ {:name => "server4", :port => "88888", :c => "4"}
64
+ ]
65
+ end
66
+
67
+ desc "Sync the local directory and install the gems onto the remote servers"
68
+ task :prep => :config do
69
+ @servers.each do |server|
70
+ fork do
71
+ system "ssh -p #{server[:port]} #{server[:name]} 'mkdir -p nitra/projects/your_project'"
72
+ exec %(rsync -aze 'ssh -q -p #{server[:port]}' --exclude .git --delete ./ #{server[:name]}:nitra/projects/your_project/ && ssh -q -t -p #{server[:port]} #{server[:name]} 'cd nitra/projects/your_project && bundle install --quiet --path ~/gems')
73
+ end
74
+ end
75
+ Process.waitall
76
+ end
77
+
78
+ task :all => :config do
79
+ cmd = %(bundle exec nitra -p -q --rspec --cucumber --rake-before-worker db:reset)
80
+ @servers.each do |server|
81
+ cmd << %( --slave "ssh -p #{server[:port]} #{server[:name]} 'cd nitra/projects/your_project && bundle exec nitra --slave-mode'" -c#{server[:c]})
82
+ end
83
+ system cmd
84
+ end
85
+ end
86
+
87
+ ## Copyright
88
+ Copyright 2012-2013 Roger Nesbitt, Powershop Limited, YouDo Limited. MIT licence.
data/bin/nitra CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
2
3
 
3
4
  require 'nitra'
4
5
 
@@ -7,5 +8,5 @@ Nitra::CommandLine.new(configuration, ARGV)
7
8
  if configuration.slave_mode
8
9
  Nitra::Slave::Server.new.run
9
10
  else
10
- exit Nitra::Client.new(configuration, ARGV).run ? 0 : 1
11
+ exit Nitra::Master.new(configuration, ARGV).run ? 0 : 1
11
12
  end
data/lib/nitra/channel.rb CHANGED
@@ -1,48 +1,50 @@
1
1
  require 'yaml'
2
2
 
3
- class Nitra::Channel
4
- ProtocolInvalidError = Class.new(StandardError)
3
+ module Nitra
4
+ class Channel
5
+ ProtocolInvalidError = Class.new(StandardError)
5
6
 
6
- attr_reader :rd, :wr
7
- attr_accessor :raise_epipe_on_write_error
7
+ attr_reader :rd, :wr
8
+ attr_accessor :raise_epipe_on_write_error
8
9
 
9
- def initialize(rd, wr)
10
- @rd = rd
11
- @wr = wr
12
- end
10
+ def initialize(rd, wr)
11
+ @rd = rd
12
+ @wr = wr
13
+ end
13
14
 
14
- def self.pipe
15
- c_rd, s_wr = IO.pipe
16
- s_rd, c_wr = IO.pipe
17
- [new(c_rd, c_wr), new(s_rd, s_wr)]
18
- end
15
+ def self.pipe
16
+ c_rd, s_wr = IO.pipe
17
+ s_rd, c_wr = IO.pipe
18
+ [new(c_rd, c_wr), new(s_rd, s_wr)]
19
+ end
19
20
 
20
- def self.read_select(channels)
21
- fds = IO.select(channels.collect(&:rd))
22
- fds.first.collect do |fd|
23
- channels.detect {|c| c.rd == fd}
21
+ def self.read_select(channels)
22
+ fds = IO.select(channels.collect(&:rd))
23
+ fds.first.collect do |fd|
24
+ channels.detect {|c| c.rd == fd}
25
+ end
24
26
  end
25
- end
26
27
 
27
- def close
28
- rd.close
29
- wr.close
30
- end
28
+ def close
29
+ rd.close
30
+ wr.close
31
+ end
31
32
 
32
- def read
33
- return unless line = rd.gets
34
- if result = line.strip.match(/\ANITRA,(\d+)\z/)
35
- data = rd.read(result[1].to_i)
36
- YAML.load(data)
37
- else
38
- raise ProtocolInvalidError, "Expected nitra length line, got #{line.inspect}"
33
+ def read
34
+ return unless line = rd.gets
35
+ if result = line.strip.match(/\ANITRA,(\d+)\z/)
36
+ data = rd.read(result[1].to_i)
37
+ YAML.load(data)
38
+ else
39
+ raise ProtocolInvalidError, "Expected nitra length line, got #{line.inspect}"
40
+ end
39
41
  end
40
- end
41
42
 
42
- def write(data)
43
- encoded = YAML.dump(data)
44
- wr.write("NITRA,#{encoded.length}\n#{encoded}")
45
- rescue Errno::EPIPE
46
- raise if raise_epipe_on_write_error
43
+ def write(data)
44
+ encoded = YAML.dump(data)
45
+ wr.write("NITRA,#{encoded.bytesize}\n#{encoded}")
46
+ rescue Errno::EPIPE
47
+ raise if raise_epipe_on_write_error
48
+ end
47
49
  end
48
50
  end
@@ -1,54 +1,74 @@
1
1
  require 'optparse'
2
2
 
3
- class Nitra::CommandLine
4
- attr_reader :configuration
3
+ module Nitra
4
+ class CommandLine
5
+ attr_reader :configuration
5
6
 
6
- def initialize(configuration, argv)
7
- @configuration = configuration
7
+ def initialize(configuration, argv)
8
+ @configuration = configuration
8
9
 
9
- OptionParser.new(argv) do |opts|
10
- opts.banner = "Usage: nitra [options] [spec_filename [...]]"
10
+ OptionParser.new do |opts|
11
+ opts.banner = "Usage: nitra [options] [spec_filename [...]]"
11
12
 
12
- opts.on("-c", "--cpus NUMBER", Integer, "Specify the number of CPUs to use on the host, or if specified after a --slave, on the slave") do |n|
13
- configuration.set_process_count n
14
- end
13
+ opts.on("-c", "--cpus NUMBER", Integer, "Specify the number of CPUs to use on the host, or if specified after a --slave, on the slave") do |n|
14
+ configuration.set_process_count n
15
+ end
15
16
 
16
- opts.on("-e", "--environment STRING", String, "The Rails environment to use, defaults to 'nitra'") do |environment|
17
- configuration.environment = environment
18
- end
17
+ opts.on("--cucumber", "Add full cucumber run, causes any files you list manually to be ignored") do
18
+ configuration.add_framework "cucumber"
19
+ end
19
20
 
20
- opts.on("--load", "Load schema into database before running specs") do
21
- configuration.load_schema = true
22
- end
21
+ opts.on("--debug", "Print debug output") do
22
+ configuration.debug = true
23
+ end
23
24
 
24
- opts.on("--migrate", "Migrate database before running specs") do
25
- configuration.migrate = true
26
- end
25
+ opts.on("-p", "--print-failures", "Print failures immediately when they occur") do
26
+ configuration.print_failures = true
27
+ end
27
28
 
28
- opts.on("-q", "--quiet", "Quiet; don't display progress bar") do
29
- configuration.quiet = true
30
- end
29
+ opts.on("-q", "--quiet", "Quiet; don't display progress bar") do
30
+ configuration.quiet = true
31
+ end
31
32
 
32
- opts.on("-p", "--print-failures", "Print failures immediately when they occur") do
33
- configuration.print_failures = true
34
- end
33
+ opts.on("--rake-after-runner task:1,task:2,task:3", Array, "The list of rake tasks to run, once per runner, in the runner's environment, just before the runner exits") do |rake_tasks|
34
+ configuration.add_rake_task(:after_runner, rake_tasks)
35
+ end
35
36
 
36
- opts.on("--slave CONNECTION_COMMAND", String, "Provide a command that executes \"nitra --slave-mode\" on another host") do |connection_command|
37
- configuration.slaves << {:command => connection_command, :cpus => nil}
38
- end
37
+ opts.on("--rake-before-runner task:1,task:2,task:3", Array, "The list of rake tasks to run, once per runner, in the runner's environment, after the runner starts") do |rake_tasks|
38
+ configuration.add_rake_task(:before_runner, rake_tasks)
39
+ end
39
40
 
40
- opts.on("--slave-mode", "Run in slave mode; ignores all other command-line options") do
41
- configuration.slave_mode = true
42
- end
41
+ opts.on("--rake-before-worker task:1,task:2,task:3", Array, "The list of rake tasks to run, once per worker, in the worker's environment, before the worker starts") do |rake_tasks|
42
+ configuration.add_rake_task(:before_worker, rake_tasks)
43
+ end
43
44
 
44
- opts.on("--debug", "Print debug output") do
45
- configuration.debug = true
46
- end
45
+ opts.on("-r", "--reset", "Reset database, equivalent to --rake-before-worker db:reset") do
46
+ configuration.add_rake_task(:before_worker, "db:reset")
47
+ end
47
48
 
48
- opts.on_tail("-h", "--help", "Show this message") do
49
- puts opts
50
- exit
51
- end
52
- end.parse!
49
+ opts.on("--slave-mode", "Run in slave mode; ignores all other command-line options") do
50
+ configuration.slave_mode = true
51
+ end
52
+
53
+ opts.on("--slave CONNECTION_COMMAND", String, "Provide a command that executes \"nitra --slave-mode\" on another host") do |connection_command|
54
+ configuration.add_slave connection_command
55
+ end
56
+
57
+ opts.on("--rspec", "Add full rspec run, causes any files you list manually to be ignored") do
58
+ configuration.add_framework "rspec"
59
+ end
60
+
61
+ opts.on("-e", "--environment ENV", String, "Set the RAILS_ENV to load") do |env|
62
+ configuration.environment = env
63
+ end
64
+
65
+ opts.on_tail("-h", "--help", "Show this message") do
66
+ puts opts
67
+ exit
68
+ end
69
+ end.parse!(argv)
70
+
71
+ configuration.set_default_framework
72
+ end
53
73
  end
54
74
  end
@@ -1,22 +1,44 @@
1
- class Nitra::Configuration
2
- attr_accessor :load_schema, :migrate, :debug, :quiet, :print_failures, :fork_for_each_file
3
- attr_accessor :process_count, :environment, :slaves, :slave_mode
4
-
5
- def initialize
6
- self.environment = "nitra"
7
- self.fork_for_each_file = true
8
- self.slaves = []
9
- end
1
+ require 'nitra/utils'
10
2
 
11
- def calculate_default_process_count
12
- self.process_count ||= Nitra::Utils.processor_count
13
- end
3
+ module Nitra
4
+ class Configuration
5
+ attr_accessor :debug, :quiet, :print_failures, :rake_tasks
6
+ attr_accessor :process_count, :environment, :slaves, :slave_mode, :framework, :frameworks
7
+
8
+ def initialize
9
+ self.environment = "nitra"
10
+ self.slaves = []
11
+ self.rake_tasks = {}
12
+ self.frameworks = []
13
+ calculate_default_process_count
14
+ end
15
+
16
+ def add_framework(framework)
17
+ frameworks << framework
18
+ end
19
+
20
+ def add_rake_task(name, list)
21
+ rake_tasks[name] = list
22
+ end
23
+
24
+ def add_slave(command)
25
+ slaves << {:command => command, :cpus => nil}
26
+ end
27
+
28
+ def set_default_framework
29
+ self.framework = frameworks.first if frameworks.any?
30
+ end
31
+
32
+ def calculate_default_process_count
33
+ self.process_count ||= Nitra::Utils.processor_count
34
+ end
14
35
 
15
- def set_process_count(n)
16
- if slaves.empty?
17
- self.process_count = n
18
- else
19
- slaves.last[:cpus] = n
36
+ def set_process_count(n)
37
+ if slaves.empty?
38
+ self.process_count = n
39
+ else
40
+ slaves.last[:cpus] = n
41
+ end
20
42
  end
21
43
  end
22
44
  end
@@ -0,0 +1,32 @@
1
+ require 'cucumber/rb_support/rb_language'
2
+ require 'cucumber/runtime'
3
+ module Cucumber
4
+ module RbSupport
5
+ class RbLanguage
6
+ # Reloading support files is bad for us. Idealy we'd subclass but since
7
+ # Cucumber's internals are a bit shit and insists on using the new keyword
8
+ # everywhere we have to monkeypatch it out or spend another 6 months
9
+ # rewriting it and getting patches accepted...
10
+ def load_code_file(code_file)
11
+ require File.expand_path(code_file)
12
+ end
13
+ end
14
+ end
15
+ class ResetableRuntime < Runtime
16
+ # Cucumber lacks a reset hook like the one Rspec has so we need to patch one in...
17
+ # Call this after configure so that the correct configuration is used to create the result set.
18
+ def reset
19
+ @results = Results.new(nil)
20
+ end
21
+
22
+ # Cucumber > 1.1.0 memoizes @loader which means we can't load in new files.
23
+ # Patch it back to how it used to work.
24
+ def features
25
+ @loader = Runtime::FeaturesLoader.new(
26
+ @configuration.feature_files,
27
+ @configuration.filters,
28
+ @configuration.tag_expression)
29
+ @loader.features
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,59 @@
1
+ ##
2
+ # Nitra::Formatter print out data regarding the run.
3
+ #
4
+ module Nitra
5
+ class Formatter
6
+ attr_accessor :start_time, :progress, :configuration
7
+
8
+ def initialize(progress, configuration)
9
+ self.progress = progress
10
+ self.configuration = configuration
11
+ end
12
+
13
+ def start
14
+ self.start_time = Time.now
15
+ print_progress
16
+ end
17
+
18
+ def print_progress
19
+ print_failures
20
+ print_bar
21
+ end
22
+
23
+ def finish
24
+ puts progress.filtered_output
25
+
26
+ puts "\n#{overview}"
27
+ puts "#{$aborted ? "Aborted after" : "Finished in"} #{"%0.1f" % (Time.now-start_time)} seconds"
28
+ end
29
+
30
+ private
31
+
32
+ ##
33
+ # Print the progress bar, doesn't do anything if we're in debug.
34
+ #
35
+ def print_bar
36
+ return if configuration.quiet || configuration.debug || progress.file_count == 0
37
+ total = 50
38
+ completed = (progress.files_completed / progress.file_count.to_f * total).to_i
39
+ print "\r[#{"X" * completed}#{"." * (total - completed)}] #{overview}\r"
40
+ $stdout.flush
41
+ end
42
+
43
+ ##
44
+ # Prints the output in the progress object and resets it if we've got eager printing turned on.
45
+ #
46
+ def print_failures
47
+ return unless progress.output.length > 0 && configuration.print_failures
48
+ puts progress.filtered_output
49
+ progress.output = ""
50
+ end
51
+
52
+ ##
53
+ # A simple overview of files processed so far and success/failure numbers.
54
+ #
55
+ def overview
56
+ "#{progress.files_completed}/#{progress.file_count} files | #{progress.example_count} examples, #{progress.failure_count} failures"
57
+ end
58
+ end
59
+ end
data/lib/nitra/master.rb CHANGED
@@ -1,18 +1,24 @@
1
1
  class Nitra::Master
2
- attr_reader :configuration, :files
2
+ attr_reader :configuration, :files, :frameworks, :current_framework
3
3
 
4
4
  def initialize(configuration, files = nil)
5
5
  @configuration = configuration
6
- @files = files
6
+ @frameworks = configuration.frameworks
7
+ if @frameworks.any?
8
+ load_files_from_framework_list
9
+ else
10
+ map_files_to_frameworks(files)
11
+ end
12
+ @current_framework = @frameworks.shift
13
+ @configuration.framework = @current_framework
7
14
  end
8
15
 
9
16
  def run
10
- @files = Dir["spec/**/*_spec.rb"] if files.nil? || files.empty?
11
- return if files.empty?
17
+ return if files_remaining == 0
12
18
 
13
19
  progress = Nitra::Progress.new
14
- progress.file_count = @files.length
15
- yield progress, nil
20
+ progress.file_count = files_remaining
21
+ formatter = Nitra::Formatter.new(progress, configuration)
16
22
 
17
23
  runners = []
18
24
 
@@ -27,24 +33,40 @@ class Nitra::Master
27
33
  end
28
34
 
29
35
  slave = Nitra::Slave::Client.new(configuration)
30
- runners += slave.connect("")
36
+ runners += slave.connect
37
+
38
+ formatter.start
31
39
 
32
40
  while runners.length > 0
33
41
  Nitra::Channel.read_select(runners).each do |channel|
34
42
  if data = channel.read
35
43
  case data["command"]
36
44
  when "next"
37
- channel.write "filename" => files.shift
45
+ if files_remaining == 0
46
+ channel.write "command" => "drain"
47
+ elsif data["framework"] == current_framework
48
+ channel.write "command" => "file", "filename" => next_file
49
+ else
50
+ channel.write "command" => "framework", "framework" => current_framework
51
+ end
52
+
38
53
  when "result"
39
- progress.files_completed += 1
40
- progress.example_count += data["example_count"] || 0
41
- progress.failure_count += data["failure_count"] || 0
42
- progress.output << data["text"]
43
- yield progress, data
54
+ examples = data["example_count"] || 0
55
+ failures = data["failure_count"] || 0
56
+ failure = data["return_code"].to_i != 0
57
+ progress.file_progress(examples, failures, failure, data["text"])
58
+ formatter.print_progress
59
+
60
+ when "error"
61
+ progress.fail("ERROR " + data["process"] + " " + data["text"])
62
+ formatter.progress
63
+ runners.delete channel
64
+
44
65
  when "debug"
45
66
  if configuration.debug
46
67
  puts "[DEBUG] #{data["text"]}"
47
68
  end
69
+
48
70
  when "stdout"
49
71
  if configuration.debug
50
72
  puts "STDOUT for #{data["process"]} #{data["filename"]}:\n#{data["text"]}" unless data["text"].empty?
@@ -58,11 +80,44 @@ class Nitra::Master
58
80
 
59
81
  debug "waiting for all children to exit..."
60
82
  Process.waitall
61
- progress
83
+
84
+ formatter.finish
85
+
86
+ !$aborted && progress.files_completed == progress.file_count && progress.failure_count.zero? && !progress.failure
62
87
  end
63
88
 
64
89
  protected
65
90
  def debug(*text)
66
91
  puts "master: #{text.join}" if configuration.debug
67
92
  end
93
+
94
+ def map_files_to_frameworks(files)
95
+ @files = files.group_by do |filename|
96
+ framework_name, framework_class = Nitra::Workers::Worker.worker_classes.find {|framework_name, framework_class| framework_class.filename_match?(filename)}
97
+ framework_name
98
+ end
99
+ @frameworks = @files.keys
100
+ end
101
+
102
+ def load_files_from_framework_list
103
+ @files = frameworks.inject({}) do |result, framework_name|
104
+ result[framework_name] = Nitra::Workers::Worker.worker_classes[framework_name].files
105
+ result
106
+ end
107
+ end
108
+
109
+ def files_remaining
110
+ files.values.inject(0) {|sum, filenames| sum + filenames.length}
111
+ end
112
+
113
+ def current_framework_files
114
+ files[current_framework]
115
+ end
116
+
117
+ def next_file
118
+ raise if files_remaining == 0
119
+ file = current_framework_files.shift
120
+ @current_framework = frameworks.shift if current_framework_files.length == 0
121
+ file
122
+ end
68
123
  end
@@ -1,8 +1,26 @@
1
1
  class Nitra::Progress
2
- attr_accessor :file_count, :files_completed, :example_count, :failure_count, :output
2
+ attr_accessor :file_count, :files_completed, :example_count, :failure_count, :output, :failure
3
3
 
4
4
  def initialize
5
5
  @file_count = @files_completed = @example_count = @failure_count = 0
6
6
  @output = ""
7
+ @failure = false
8
+ end
9
+
10
+ def file_progress(examples, failures, failure, text)
11
+ self.files_completed += 1
12
+ self.example_count += examples
13
+ self.failure_count += failures
14
+ self.failure ||= failure
15
+ self.output.concat text
16
+ end
17
+
18
+ def fail(message)
19
+ self.failure = true
20
+ self.output.concat message
21
+ end
22
+
23
+ def filtered_output
24
+ output.gsub(/\n\n\n+/, "\n\n")
7
25
  end
8
26
  end
@@ -0,0 +1,22 @@
1
+ module Nitra
2
+ class RailsTooling
3
+ ##
4
+ # Find the database config for the current TEST_ENV_NUMBER and manually initialise a connection.
5
+ #
6
+ def self.connect_to_database
7
+ return unless defined?(Rails)
8
+ # Config files are read at load time. Since we load rails in one env then
9
+ # change some flags to get different environments through forking we need
10
+ # always reload our database config...
11
+ ActiveRecord::Base.configurations = YAML.load(ERB.new(IO.read("#{Rails.root}/config/database.yml")).result)
12
+
13
+ ActiveRecord::Base.clear_all_connections!
14
+ ActiveRecord::Base.establish_connection
15
+ end
16
+
17
+ def self.reset_cache
18
+ return unless defined?(Rails)
19
+ Rails.cache.reset if Rails.cache.respond_to?(:reset)
20
+ end
21
+ end
22
+ end