rubotium 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +24 -0
  6. data/Rakefile +1 -0
  7. data/bin/rubotium +26 -0
  8. data/lib/rubotium/adb/command.rb +21 -0
  9. data/lib/rubotium/adb/devices.rb +25 -0
  10. data/lib/rubotium/adb/install_command.rb +17 -0
  11. data/lib/rubotium/adb/instrumentation.rb +36 -0
  12. data/lib/rubotium/adb/shell.rb +30 -0
  13. data/lib/rubotium/adb/test_result_parser.rb +87 -0
  14. data/lib/rubotium/adb/uninstall_command.rb +17 -0
  15. data/lib/rubotium/adb.rb +13 -0
  16. data/lib/rubotium/apk/converter.rb +22 -0
  17. data/lib/rubotium/apk.rb +7 -0
  18. data/lib/rubotium/cmd.rb +17 -0
  19. data/lib/rubotium/device.rb +49 -0
  20. data/lib/rubotium/devices.rb +25 -0
  21. data/lib/rubotium/formatters/junit_formatter.rb +82 -0
  22. data/lib/rubotium/grouper.rb +40 -0
  23. data/lib/rubotium/jar_reader.rb +70 -0
  24. data/lib/rubotium/package.rb +36 -0
  25. data/lib/rubotium/runable_test.rb +11 -0
  26. data/lib/rubotium/test_case.rb +6 -0
  27. data/lib/rubotium/test_suite.rb +12 -0
  28. data/lib/rubotium/version.rb +3 -0
  29. data/lib/rubotium.rb +85 -0
  30. data/rubotium.gemspec +33 -0
  31. data/spec/fixtures/adb_devices_results.rb +23 -0
  32. data/spec/fixtures/adb_results.rb +60 -0
  33. data/spec/fixtures/jar_contents.rb +28 -0
  34. data/spec/fixtures/javap_classes.rb +52 -0
  35. data/spec/lib/rubotium/adb/adb_devices_spec.rb +25 -0
  36. data/spec/lib/rubotium/adb/adb_instrumentation_spec.rb +32 -0
  37. data/spec/lib/rubotium/adb/adb_result_parser_spec.rb +132 -0
  38. data/spec/lib/rubotium/adb/adb_shell_spec.rb +23 -0
  39. data/spec/lib/rubotium/devices_spec.rb +45 -0
  40. data/spec/lib/rubotium/formatters/junit_formatter_spec.rb +7 -0
  41. data/spec/lib/rubotium/grouper_spec.rb +56 -0
  42. data/spec/lib/rubotium/jar_reader_spec.rb +58 -0
  43. data/spec/lib/rubotium_spec.rb +13 -0
  44. data/spec/spec_helper.rb +20 -0
  45. metadata +256 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: a668e6a758f73295484b33a8bb12d7787ace3669
4
+ data.tar.gz: 32bb1aedd3650eadb296d6cbe3c8030d85cbe013
5
+ SHA512:
6
+ metadata.gz: e803e2b146f3654ef5b790f6e4c989b66afaf5aefd172c37c5c03b68ba43014d56ce1b7bd68af8123d84431d5fb953c2b895b4bd1c2f4f4a4c6294a99dd5987f
7
+ data.tar.gz: 999a97ea8be422f305648034fc05eab2dac5a12210bac25f1f7e0bb67c887d812773df18b75b75d00a890625cbf068a1847a54633877076aa9185fea4c268ae9
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in rubotium.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Slawomir Smiechura
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # Rubotium
2
+
3
+ This is an Android's Instrumentation test runner. It's in quite early phase but already solves couple of issues with instrumentation tests:
4
+
5
+ * runs tests in parallel
6
+ * retries failed tests
7
+ * does execute all the package tests even if the app dies during the execution
8
+
9
+ ## Installation
10
+
11
+ gem install rubotium
12
+
13
+ ## Usage
14
+
15
+ $ rubotium -t <path_to_tests.apk> -a <path_to_application.apk> -r <instrumentation_test_runner>
16
+ $ rubotium -h for help
17
+
18
+ ## Contributing
19
+
20
+ 1. Fork it ( http://github.com/<my-github-username>/rubotium/fork )
21
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
22
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
23
+ 4. Push to the branch (`git push origin my-new-feature`)
24
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/bin/rubotium ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubotium'
4
+
5
+ require 'trollop'
6
+ opts = Trollop::options do
7
+ opt :test_jar_path, 'Path to the tests .jar file', :type => :string, :short => '-j'
8
+ opt :test_apk_path, 'Path to the tests .apk file', :type => :string, :short => '-t'
9
+ opt :app_apk_path, 'Path to the app .apk file', :type => :string, :short => '-a'
10
+ opt :rerun, 'Retries count', :default => 0, :short => '-R'
11
+ opt :out, 'Report file', :default => 'report.xml', :short => '-o'
12
+ opt :device, 'Match devices', :type => :string, :short => '-d'
13
+ opt :runner, 'Test runner', :type => :string, :short => 'r'
14
+ end
15
+
16
+ params = {
17
+ :tests_jar_path => opts[:test_jar_path],
18
+ :tests_apk_path => opts[:test_apk_path],
19
+ :app_apk_path => opts[:app_apk_path],
20
+ :rerun_count => opts[:rerun],
21
+ :report => opts[:out],
22
+ :device_matcher => opts[:device],
23
+ :runner => opts[:runner]
24
+ }
25
+
26
+ Rubotium.new(params)
@@ -0,0 +1,21 @@
1
+ module Rubotium
2
+ module Adb
3
+ class Command
4
+ def initialize(device_serial)
5
+ @device_serial = device_serial
6
+ end
7
+
8
+ def execute(command_to_run)
9
+ puts "EXECUTING_COMMAND: #{adb_command} #{command_to_run.executable_command}"
10
+ CMD.run_command(adb_command + " " + command_to_run.executable_command)
11
+ end
12
+
13
+ private
14
+ attr_reader :device_serial
15
+
16
+ def adb_command
17
+ "adb -s #{device_serial} "
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ module Rubotium
2
+ module Adb
3
+ class Devices
4
+ attr_reader :list
5
+
6
+ def initialize
7
+ @list = parse(CMD.run_command('adb devices',{ :timeout => 5 } ))
8
+ end
9
+
10
+ private
11
+ def parse result
12
+ list = result.split("\n")
13
+ list.shift
14
+ attached_devices list
15
+ end
16
+
17
+ def attached_devices(list)
18
+ list.collect { |device|
19
+ parts = device.split("\t")
20
+ parts.last == 'device' ? parts.first : nil
21
+ }.compact
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ module Rubotium
2
+ module Adb
3
+ class InstallCommand
4
+ COMMAND = 'install'
5
+ def initialize(apk_path)
6
+ @apk_path = apk_path
7
+ end
8
+
9
+ def executable_command
10
+ "#{COMMAND} #{apk_path}"
11
+ end
12
+
13
+ private
14
+ attr_reader :apk_path
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ module Rubotium
2
+ module Adb
3
+ class NoTestRunnerError < StandardError; end
4
+ class NoTestPackageError < StandardError; end
5
+
6
+ class Instrumentation
7
+ attr_accessor :test_package_name, :test_runner
8
+ attr_reader :adb_shell
9
+
10
+ def initialize(device)
11
+ @adb_shell = Rubotium::Adb::Shell.new(device)
12
+ end
13
+
14
+ def run_test(runable_test)
15
+ check_packages
16
+ result = adb_shell.run_command(instrument_command(runable_test.package_name, runable_test.test_name))
17
+ TestResultParser.new(result, runable_test.package_name, runable_test.test_name)
18
+ end
19
+
20
+ private
21
+ def instrument_command package_name, test_name
22
+ "am instrument -w -e class #{class_test(package_name, test_name)} #{test_package_name}/#{test_runner}"
23
+ end
24
+
25
+ def class_test package_name, test_name
26
+ "#{package_name}##{test_name}"
27
+ end
28
+
29
+ def check_packages
30
+ raise NoTestRunnerError if !test_runner
31
+ raise NoTestPackageError if !test_package_name
32
+ end
33
+
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,30 @@
1
+ module Rubotium
2
+ module Adb
3
+ class Shell
4
+ ADB = 'adb'
5
+ attr_reader :device_serial
6
+
7
+ def initialize(device_serial)
8
+ @device_serial = device_serial
9
+ end
10
+
11
+ def run_command command_to_run
12
+ CMD.run_command(command + " " + command_to_run)
13
+ end
14
+
15
+ private
16
+ def command
17
+ [ADB, device, 'shell'].compact.join(" ")
18
+ end
19
+
20
+ def device
21
+ if device_serial && !device_serial.empty?
22
+ ['-s', device_serial]
23
+ else
24
+ nil
25
+ end
26
+ end
27
+
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,87 @@
1
+ module Rubotium
2
+ module Adb
3
+ class TestResultParser
4
+ ERROR_STATUS = 'ERROR'
5
+ FAIL_STATUS = 'FAIL'
6
+ OK_STATUS = 'OK'
7
+
8
+ attr_reader :result, :stack_trace, :time, :error_message, :status, :package_name, :test_name
9
+
10
+ def initialize(result, package_name, test_name)
11
+ @result = result
12
+ @package_name = package_name
13
+ @test_name = test_name
14
+ parse
15
+ end
16
+
17
+ def failed?
18
+ @status == FAIL_STATUS
19
+ end
20
+
21
+ def passed?
22
+ @status == OK_STATUS
23
+ end
24
+
25
+ def errored?
26
+ @status == ERROR_STATUS
27
+ end
28
+
29
+ private
30
+ def parse
31
+ if ENV['DEBUG']
32
+ p result
33
+ end
34
+ @stack_trace = has_failed? ? get_stack_trace : ""
35
+ @status = get_status
36
+ @time = has_errored? ? 0 : get_time
37
+ @error_message = has_errored? ? get_error : ""
38
+ end
39
+
40
+ def get_status
41
+ if has_errored?
42
+ ERROR_STATUS
43
+ elsif has_failed?
44
+ FAIL_STATUS
45
+ else
46
+ OK_STATUS
47
+ end
48
+ end
49
+
50
+ def has_errored?
51
+ ['INSTRUMENTATION_ABORTED', 'INSTRUMENTATION_RESULT','INSTRUMENTATION_STATUS'].any? {|error_message|
52
+ result.include? error_message
53
+ }
54
+ end
55
+
56
+ def has_failed?
57
+ result.include? 'FAILURES'
58
+ end
59
+
60
+ def get_time
61
+ result.match(/Time: (-?\d+.\d+)/)[1].to_f.abs
62
+ end
63
+
64
+ def get_stack_trace
65
+ result.split("\r\n\r\n")[0].split(/^/)[3..-1].join
66
+ end
67
+
68
+ def get_error
69
+ if instrumentation_error
70
+ result.match(/INSTRUMENTATION_RESULT: longMsg=(.*)\r/)[1]
71
+ elsif application_error
72
+ result.match(/INSTRUMENTATION_ABORTED: (.*)/)[1]
73
+ else
74
+ result.match(/INSTRUMENTATION_STATUS: Error=(.*)\r/)[1]
75
+ end
76
+ end
77
+
78
+ def application_error
79
+ result.match(/INSTRUMENTATION_ABORTED/) != nil
80
+ end
81
+
82
+ def instrumentation_error
83
+ result.match(/INSTRUMENTATION_RESULT: longMsg/) != nil
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,17 @@
1
+ module Rubotium
2
+ module Adb
3
+ class UninstallCommand
4
+ COMMAND = 'uninstall'
5
+ def initialize(package_name)
6
+ @package_name = package_name
7
+ end
8
+
9
+ def executable_command
10
+ "#{COMMAND} #{package_name}"
11
+ end
12
+
13
+ private
14
+ attr_reader :package_name
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ require_relative 'adb/command'
2
+ require_relative 'adb/install_command'
3
+ require_relative 'adb/uninstall_command'
4
+ require_relative 'adb/devices'
5
+ require_relative 'adb/test_result_parser'
6
+ require_relative 'adb/shell'
7
+ require_relative 'adb/instrumentation'
8
+
9
+ module Rubotium
10
+ module Adb
11
+
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ require 'dex2jar'
2
+ module Rubotium
3
+ module Apk
4
+ class Converter
5
+ def initialize(apk_path, output_path)
6
+ @apk_path = apk_path
7
+ @output_path = output_path
8
+ end
9
+
10
+ def convert_to_jar
11
+ Dex2jar.execute("-f -o #{output_path}", [apk_path])
12
+ end
13
+
14
+ private
15
+ attr_reader :apk_path, :output_path
16
+
17
+ def file_exists?
18
+ File.exist? apk_path
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,7 @@
1
+ require_relative 'apk/converter'
2
+
3
+ module Rubotium
4
+ module Apk
5
+
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ require 'timeout'
2
+ module Rubotium
3
+ class CMD
4
+ class << self
5
+ def run_command(command_to_run, opts = {})
6
+ begin
7
+ Timeout::timeout(opts[:timeout] || 10 * 60) {
8
+ puts "[EXECUTING]: #{command_to_run}" if ENV['DEBUG']
9
+ `#{command_to_run}`
10
+ }
11
+ rescue Timeout::Error
12
+ ""
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,49 @@
1
+ module Rubotium
2
+ class Device
3
+ attr_accessor :testsuite
4
+ attr_reader :serial, :results
5
+ def initialize(serial, test_runner)
6
+ @runner = test_runner
7
+ @retry = 1
8
+ @serial = serial
9
+ @results = {}
10
+ @command = Rubotium::Adb::Command.new(serial)
11
+ end
12
+
13
+ def test_runner_name= name
14
+ runner.test_runner = name
15
+ end
16
+
17
+ def test_package_name= name
18
+ runner.test_package_name = name
19
+ end
20
+
21
+ def install(apk_path)
22
+ command.execute(Rubotium::Adb::InstallCommand.new(apk_path))
23
+ end
24
+
25
+ def uninstall(package_name)
26
+ command.execute(Rubotium::Adb::UninstallCommand.new(package_name))
27
+ end
28
+
29
+ def run_tests
30
+ raise(NoTestSuiteError, "Please setup test suite before running tests") if testsuite.nil?
31
+ puts "Running tests"
32
+ testsuite.each{|runable_test|
33
+ @results[runable_test.package_name] = []
34
+ puts runable_test.name
35
+ run_count = 0
36
+ puts "TEST: #{runable_test.name}"
37
+ while ((result = runner.run_test(runable_test)) && (result.failed? || result.errored?) && run_count < @retry ) do
38
+ puts "RERUNNING TEST: #{runable_test.name}, STATUS: #{result.status}"
39
+ run_count += 1
40
+ end
41
+ puts "FINISHED with status: #{result.status}"
42
+ @results[runable_test.package_name].push(result)
43
+ }
44
+ end
45
+
46
+ private
47
+ attr_reader :runner, :command
48
+ end
49
+ end
@@ -0,0 +1,25 @@
1
+ module Rubotium
2
+ class Devices
3
+ def initialize(device_matcher = '')
4
+ @attached_devices = Adb::Devices.new.list
5
+ @matched = matched_devices device_matcher
6
+ end
7
+
8
+ def all
9
+ raise NoDevicesError if attached_devices.empty?
10
+ raise NoMatchedDevicesError if matched.empty?
11
+ matched.map{|device|
12
+ Device.new(device, Adb::Instrumentation.new(device))
13
+ }
14
+ end
15
+
16
+ private
17
+ attr_reader :matched, :attached_devices
18
+
19
+ def matched_devices(device_matcher)
20
+ device_matcher ? attached_devices.select{|device|
21
+ device.include? device_matcher
22
+ } : attached_devices
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,82 @@
1
+ require 'builder'
2
+
3
+ module Rubotium
4
+ module Formatters
5
+ class JunitFormatter
6
+ attr_reader :xml
7
+ def initialize(results, path_to_file)
8
+ @xml = Builder::XmlMarkup.new :target => ensure_io(path_to_file), :indent => 2
9
+
10
+ xml.testsuites do
11
+ results.each{|package_name, tests|
12
+ start_test_suite(package_name, tests)
13
+ }
14
+ end
15
+ end
16
+ private
17
+ def start_test_suite(package_name, tests)
18
+ failures = get_failures(tests)
19
+ errors = get_errors(tests)
20
+ tests_time = get_tests_time(tests)
21
+ tests_count = tests.count
22
+ params = {
23
+ :errors => errors,
24
+ :failures => failures,
25
+ :name => package_name,
26
+ :tests => tests_count,
27
+ :time => tests_time,
28
+ :timestamp => Time.now
29
+ }
30
+
31
+ xml.testsuite(params) do
32
+ tests.each { |test|
33
+ print_testcase(test)
34
+ }
35
+ end
36
+ end
37
+
38
+ def print_testcase(test)
39
+ xml.testcase :classname=>test.package_name, :name=>test.test_name, :time=>test.time do
40
+ has_failures(test)
41
+ has_errors(test)
42
+ end
43
+ end
44
+
45
+ def has_failures(test)
46
+ if test.failed?
47
+ xml.failure :message=>"", :type=>"" do
48
+ xml.cdata! test.stack_trace
49
+ end
50
+ end
51
+ end
52
+
53
+ def has_errors(test)
54
+ if test.errored?
55
+ xml.error :message=>"", :type=>"" do
56
+ xml.cdata! test.error_message
57
+ end
58
+ end
59
+ end
60
+
61
+ def get_tests_time(tests)
62
+ tests.inject(0){|time_sum, test| time_sum += test.time.to_f }
63
+ end
64
+
65
+ def get_errors(tests)
66
+ tests.select{|test|
67
+ test.errored?
68
+ }.count
69
+ end
70
+
71
+ def get_failures(tests)
72
+ tests.select{|test|
73
+ test.failed?
74
+ }.count
75
+ end
76
+
77
+ def ensure_io(path_to_file)
78
+ File.open(path_to_file, 'w')
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,40 @@
1
+ module Rubotium
2
+ class Grouper
3
+ def initialize(test_suites, num_of_groups)
4
+ @test_suites = test_suites
5
+ @num_of_groups = num_of_groups
6
+ end
7
+
8
+ def create_groups
9
+ runnable_tests.each{|runnable|
10
+ next_bucket.push(runnable)
11
+ }
12
+ buckets
13
+ end
14
+
15
+ def runnable_tests
16
+ test_suites.map {|test_suite|
17
+ test_suite.test_cases.map{|test|
18
+ RunableTest.new(test_suite.name, test.name)
19
+ }
20
+ }.flatten
21
+ end
22
+
23
+ def next_bucket
24
+ buckets_enum.next
25
+ end
26
+
27
+ def buckets_enum
28
+ @buckets_enum ||= buckets.cycle
29
+ end
30
+
31
+
32
+ def buckets
33
+ @buckets ||= Array.new(num_of_groups) { [] }
34
+ end
35
+
36
+ private
37
+ attr_reader :test_suites, :num_of_groups
38
+
39
+ end
40
+ end
@@ -0,0 +1,70 @@
1
+ require 'set'
2
+ module Rubotium
3
+ class JarReader
4
+ TEST_PATTERN = Regexp.new('public void (test.*)\(')
5
+ attr_reader :path_to_jar
6
+
7
+ def initialize(jar_path)
8
+ @path_to_jar = jar_path
9
+ end
10
+
11
+ def test_suites
12
+ Converter.new(classes_in_jar).convert
13
+ end
14
+
15
+ def get_tests
16
+ parse
17
+ end
18
+
19
+ private
20
+ def classes_in_jar
21
+ CMD.run_command("jar -tf #{path_to_jar} | grep '.class'")
22
+ end
23
+
24
+ def tests_in_class(suite_name)
25
+ CMD.run_command "javap -classpath #{path_to_jar} #{suite_name}"
26
+ end
27
+
28
+ def parse
29
+ test_suites.map{|test_suite|
30
+ tests_in_class(test_suite.name).scan(TEST_PATTERN).flatten.each{|test|
31
+ test_suite.add_test_case(TestCase.new(test))
32
+ }
33
+ test_suite
34
+ }.delete_if{|test_suite| test_suite.test_cases.empty?}
35
+ end
36
+
37
+
38
+ class Converter
39
+ attr_reader :list
40
+ def initialize(list_of_classes)
41
+ @list = list_of_classes.split
42
+ end
43
+
44
+ def convert
45
+ deduplicated_test_suites
46
+ end
47
+
48
+ private
49
+ def test_suites
50
+ converted_class_names.map{|class_name|
51
+ TestSuite.new(class_name)
52
+ }
53
+ end
54
+
55
+ def converted_class_names
56
+ list.map{|class_name|
57
+ class_name.gsub("/", ".")
58
+ .gsub(".class", "")
59
+ .gsub(/\$.*/, "")
60
+ }
61
+ end
62
+
63
+ def deduplicated_test_suites
64
+ test_suites.uniq{|test_suite|
65
+ test_suite.name
66
+ }
67
+ end
68
+ end
69
+ end
70
+ end