rails-profiler 0.24.0 → 0.26.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/profiler.css +24 -0
  3. data/app/assets/builds/profiler.js +994 -26
  4. data/app/controllers/profiler/api/console_controller.rb +46 -0
  5. data/app/controllers/profiler/api/profiles_controller.rb +1 -1
  6. data/app/controllers/profiler/api/test_runner_controller.rb +115 -0
  7. data/app/controllers/profiler/api/tests_controller.rb +46 -0
  8. data/app/controllers/profiler/test_runner_controller.rb +11 -0
  9. data/app/views/profiler/test_runner/index.html.erb +1 -0
  10. data/config/routes.rb +13 -0
  11. data/lib/profiler/collectors/console_collector.rb +57 -0
  12. data/lib/profiler/collectors/database_collector.rb +1 -1
  13. data/lib/profiler/collectors/test_collector.rb +75 -0
  14. data/lib/profiler/configuration.rb +14 -1
  15. data/lib/profiler/console_profiler.rb +102 -0
  16. data/lib/profiler/instrumentation/irb_instrumentation.rb +21 -0
  17. data/lib/profiler/mcp/resources/failing_tests.rb +40 -0
  18. data/lib/profiler/mcp/resources/slow_tests.rb +45 -0
  19. data/lib/profiler/mcp/server.rb +77 -6
  20. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +126 -0
  21. data/lib/profiler/mcp/tools/query_test_profiles.rb +109 -0
  22. data/lib/profiler/mcp/tools/run_tests.rb +112 -0
  23. data/lib/profiler/railtie.rb +21 -1
  24. data/lib/profiler/test_helpers/minitest_support.rb +39 -0
  25. data/lib/profiler/test_helpers/reporter.rb +121 -0
  26. data/lib/profiler/test_helpers/rspec_support.rb +33 -0
  27. data/lib/profiler/test_profiler.rb +140 -0
  28. data/lib/profiler/test_runner/discovery.rb +57 -0
  29. data/lib/profiler/test_runner/run_store.rb +120 -0
  30. data/lib/profiler/test_runner/runner.rb +106 -0
  31. data/lib/profiler/version.rb +1 -1
  32. metadata +23 -2
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class ConsoleController < ApplicationController
6
+ skip_before_action :verify_authenticity_token
7
+
8
+ def index
9
+ limit = (params[:limit] || 50).to_i
10
+ offset = (params[:offset] || 0).to_i
11
+ all = Profiler.storage.list(limit: 1000, offset: 0)
12
+ console = all.select { |p| p.profile_type == "console" }
13
+ page = console.drop(offset).first(limit + 1)
14
+ render json: {
15
+ profiles: page.first(limit).map(&:to_h),
16
+ limit: limit,
17
+ offset: offset,
18
+ has_more: page.size > limit
19
+ }
20
+ end
21
+
22
+ def show
23
+ profile = Profiler.storage.load(params[:id])
24
+
25
+ unless profile && profile.profile_type == "console"
26
+ return render json: { error: "Console profile not found" }, status: :not_found
27
+ end
28
+
29
+ render json: profile.to_h
30
+ end
31
+
32
+ def destroy
33
+ profile = Profiler.storage.load(params[:id])
34
+ return render json: { error: "Console profile not found" }, status: :not_found unless profile&.profile_type == "console"
35
+
36
+ Profiler.storage.delete(params[:id])
37
+ head :no_content
38
+ end
39
+
40
+ def clear
41
+ Profiler.storage.clear(type: "console")
42
+ head :no_content
43
+ end
44
+ end
45
+ end
46
+ end
@@ -9,7 +9,7 @@ module Profiler
9
9
  limit = (params[:limit] || 50).to_i
10
10
  offset = (params[:offset] || 0).to_i
11
11
  all = Profiler.storage.list(limit: 1000, offset: 0)
12
- http = all.reject { |p| p.profile_type == "job" }
12
+ http = all.select { |p| p.profile_type == "http" }
13
13
  page = http.drop(offset).first(limit + 1)
14
14
  render json: {
15
15
  profiles: page.first(limit).map(&:to_h),
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "profiler/test_runner/discovery"
4
+ require "profiler/test_runner/runner"
5
+
6
+ module Profiler
7
+ module Api
8
+ class TestRunnerController < ApplicationController
9
+ include ActionController::Live
10
+
11
+ skip_before_action :verify_authenticity_token
12
+
13
+ def files
14
+ framework = params[:framework]
15
+ tree = Profiler::TestRunner::Discovery.files(framework: framework)
16
+ frameworks = Profiler::TestRunner::Discovery.frameworks
17
+
18
+ render json: {
19
+ frameworks: frameworks,
20
+ tree: tree
21
+ }
22
+ end
23
+
24
+ def create
25
+ files = Array(params[:files])
26
+ framework = params[:framework] || detect_framework
27
+
28
+ if files.empty?
29
+ return render json: { error: "No files selected" }, status: :unprocessable_entity
30
+ end
31
+
32
+ # Validate paths are within Rails root (prevent path traversal)
33
+ root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
34
+ files.each do |f|
35
+ expanded = File.expand_path(File.join(root, f))
36
+ unless expanded.start_with?(root)
37
+ return render json: { error: "Invalid file path: #{f}" }, status: :unprocessable_entity
38
+ end
39
+ end
40
+
41
+ run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
42
+ render json: run.to_h, status: :created
43
+ end
44
+
45
+ def show
46
+ run = Profiler::TestRunner.run_store.find(params[:id])
47
+ return render json: { error: "Run not found" }, status: :not_found unless run
48
+
49
+ render json: run.to_h
50
+ end
51
+
52
+ # SSE endpoint — streams output chunks as server-sent events.
53
+ # Replaces polling for live test output in the frontend.
54
+ def stream
55
+ run = Profiler::TestRunner.run_store.find(params[:id])
56
+ unless run
57
+ render json: { error: "Run not found" }, status: :not_found
58
+ return
59
+ end
60
+
61
+ response.headers["Content-Type"] = "text/event-stream"
62
+ response.headers["Cache-Control"] = "no-cache"
63
+ response.headers["X-Accel-Buffering"] = "no"
64
+
65
+ sse = SSE.new(response.stream, retry: 1000, event: "output")
66
+ position = 0
67
+
68
+ begin
69
+ loop do
70
+ result = Profiler::TestRunner.run_store.wait_for_output(
71
+ params[:id], position: position, timeout: 15
72
+ )
73
+
74
+ result[:chunks].each do |chunk|
75
+ sse.write({ chunk: chunk })
76
+ end
77
+ position = result[:position]
78
+
79
+ if result[:finished]
80
+ current_run = Profiler::TestRunner.run_store.find(params[:id])
81
+ sse.write(
82
+ { status: result[:status], exit_code: current_run&.exit_code },
83
+ event: "done"
84
+ )
85
+ break
86
+ end
87
+ end
88
+ rescue ActionController::Live::ClientDisconnected, IOError
89
+ # Client navigated away — normal exit
90
+ ensure
91
+ sse.close
92
+ end
93
+ end
94
+
95
+ def destroy
96
+ killed = Profiler::TestRunner::Runner.kill(params[:id])
97
+ if killed
98
+ head :no_content
99
+ else
100
+ render json: { error: "Run not found or not running" }, status: :not_found
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def detect_framework
107
+ if Profiler::TestRunner::Discovery.rspec_available?
108
+ "rspec"
109
+ else
110
+ "minitest"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Api
5
+ class TestsController < ApplicationController
6
+ skip_before_action :verify_authenticity_token
7
+
8
+ def index
9
+ limit = (params[:limit] || 50).to_i
10
+ offset = (params[:offset] || 0).to_i
11
+ all = Profiler.storage.list(limit: 1000, offset: 0)
12
+ tests = all.select { |p| p.profile_type == "test" }
13
+ page = tests.drop(offset).first(limit + 1)
14
+ render json: {
15
+ profiles: page.first(limit).map(&:to_h),
16
+ limit: limit,
17
+ offset: offset,
18
+ has_more: page.size > limit
19
+ }
20
+ end
21
+
22
+ def show
23
+ profile = Profiler.storage.load(params[:id])
24
+
25
+ unless profile && profile.profile_type == "test"
26
+ return render json: { error: "Test profile not found" }, status: :not_found
27
+ end
28
+
29
+ render json: profile.to_h
30
+ end
31
+
32
+ def destroy
33
+ profile = Profiler.storage.load(params[:id])
34
+ return render json: { error: "Test profile not found" }, status: :not_found unless profile&.profile_type == "test"
35
+
36
+ Profiler.storage.delete(params[:id])
37
+ head :no_content
38
+ end
39
+
40
+ def clear
41
+ Profiler.storage.clear(type: "test")
42
+ head :no_content
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ class TestRunnerController < ApplicationController
5
+ layout "profiler/application"
6
+
7
+ def index
8
+ redirect_to profiler.root_path(section: "runner"), allow_other_host: false
9
+ end
10
+ end
11
+ end
@@ -0,0 +1 @@
1
+ <div id="profiler-test-runner"></div>
data/config/routes.rb CHANGED
@@ -22,6 +22,8 @@ Profiler::Engine.routes.draw do
22
22
  get "assets/profiler.js", to: "assets#main_js"
23
23
  get "assets/profiler.css", to: "assets#main_css"
24
24
 
25
+ get "test_runner", to: "test_runner#index"
26
+
25
27
  namespace :api do
26
28
  resources :profiles, only: [:index, :show, :destroy] do
27
29
  collection { delete :clear }
@@ -29,6 +31,12 @@ Profiler::Engine.routes.draw do
29
31
  resources :jobs, only: [:index, :show, :destroy] do
30
32
  collection { delete :clear }
31
33
  end
34
+ resources :console, only: [:index, :show, :destroy] do
35
+ collection { delete :clear }
36
+ end
37
+ resources :tests, only: [:index, :show, :destroy] do
38
+ collection { delete :clear }
39
+ end
32
40
  resources :outbound_http, only: [:index]
33
41
  get "toolbar/:token", to: "toolbar#show"
34
42
  post "ajax/link", to: "ajax#link"
@@ -37,5 +45,10 @@ Profiler::Engine.routes.draw do
37
45
  resource :env_vars, only: [:show, :update], controller: "env_vars"
38
46
  delete "env_vars/reset", to: "env_vars#reset_override"
39
47
  delete "env_vars/reset_all", to: "env_vars#reset_all"
48
+ get "test_runner/files", to: "test_runner#files"
49
+ post "test_runner/runs", to: "test_runner#create"
50
+ get "test_runner/runs/:id", to: "test_runner#show", as: :test_runner_run
51
+ get "test_runner/runs/:id/stream", to: "test_runner#stream", as: :test_runner_run_stream
52
+ delete "test_runner/runs/:id", to: "test_runner#destroy"
40
53
  end
41
54
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+
5
+ module Profiler
6
+ module Collectors
7
+ class ConsoleCollector < BaseCollector
8
+ def initialize(profile, expression:)
9
+ super(profile)
10
+ @expression = expression
11
+ @return_value = nil
12
+ @return_value_captured = false
13
+ end
14
+
15
+ def set_return_value(value)
16
+ @return_value = value.inspect.slice(0, 10_000)
17
+ @return_value_captured = true
18
+ rescue
19
+ @return_value = "(uninspectable)"
20
+ @return_value_captured = true
21
+ end
22
+
23
+ def icon
24
+ ">_"
25
+ end
26
+
27
+ def priority
28
+ 5
29
+ end
30
+
31
+ def tab_config
32
+ {
33
+ key: "console",
34
+ label: "Console",
35
+ icon: icon,
36
+ priority: priority,
37
+ enabled: true,
38
+ default_active: true
39
+ }
40
+ end
41
+
42
+ def collect
43
+ data = { expression: @expression }
44
+ data[:return_value] = @return_value if @return_value_captured
45
+ store_data(data)
46
+ end
47
+
48
+ def has_data?
49
+ @expression.to_s.length > 0
50
+ end
51
+
52
+ def toolbar_summary
53
+ { text: @expression.to_s[0, 30], color: "blue" }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -39,7 +39,7 @@ module Profiler
39
39
 
40
40
  # Skip schema queries and internal Rails queries
41
41
  next if payload[:name] == "SCHEMA"
42
- next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT)/i
42
+ next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i
43
43
 
44
44
  query = Models::SqlQuery.new(
45
45
  sql: payload[:sql],
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+
5
+ module Profiler
6
+ module Collectors
7
+ class TestCollector < BaseCollector
8
+ def initialize(profile, test_name:, test_file:, test_line:, framework:)
9
+ super(profile)
10
+ @test_name = test_name
11
+ @test_file = test_file
12
+ @test_line = test_line
13
+ @framework = framework
14
+ @status = "running"
15
+ @exception_message = nil
16
+ end
17
+
18
+ def icon
19
+ "🧪"
20
+ end
21
+
22
+ def priority
23
+ 5
24
+ end
25
+
26
+ def tab_config
27
+ {
28
+ key: "test",
29
+ label: "Test",
30
+ icon: icon,
31
+ priority: priority,
32
+ enabled: true,
33
+ default_active: true
34
+ }
35
+ end
36
+
37
+ def update_status(status, exception_message = nil)
38
+ @status = status
39
+ @exception_message = exception_message
40
+ end
41
+
42
+ def update_extra(assertions: nil, skip_reason: nil)
43
+ @assertions = assertions
44
+ @skip_reason = skip_reason
45
+ end
46
+
47
+ def collect
48
+ store_data({
49
+ test_name: @test_name,
50
+ test_file: @test_file,
51
+ test_line: @test_line,
52
+ framework: @framework.to_s,
53
+ status: @status,
54
+ exception_message: @exception_message,
55
+ assertions: @assertions,
56
+ skip_reason: @skip_reason
57
+ })
58
+ end
59
+
60
+ def has_data?
61
+ true
62
+ end
63
+
64
+ def toolbar_summary
65
+ color = case @status
66
+ when "passed" then "green"
67
+ when "failed" then "red"
68
+ when "pending" then "orange"
69
+ else "gray"
70
+ end
71
+ { text: @status, color: color }
72
+ end
73
+ end
74
+ end
75
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Profiler
4
4
  class Configuration
5
- attr_accessor :enabled, :storage, :storage_options, :collectors,
5
+ attr_accessor :enabled, :storage_options, :collectors,
6
6
  :skip_paths, :slow_query_threshold, :max_queries_warning,
7
7
  :track_memory, :memory_warning_threshold,
8
8
  :mcp_enabled, :mcp_transport, :mcp_port,
@@ -11,6 +11,8 @@ module Profiler
11
11
  :track_ajax, :ajax_skip_paths,
12
12
  :track_http, :slow_http_threshold, :http_skip_hosts,
13
13
  :track_jobs,
14
+ :track_console,
15
+ :track_tests,
14
16
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
15
17
  :compress_bodies, :compress_body_threshold
16
18
 
@@ -42,6 +44,8 @@ module Profiler
42
44
  @slow_http_threshold = 500 # milliseconds
43
45
  @http_skip_hosts = []
44
46
  @track_jobs = true
47
+ @track_console = true
48
+ @track_tests = false
45
49
  @track_mailers = true
46
50
  @capture_mail_body = false
47
51
  @sanitize_mailer_recipients = false
@@ -70,6 +74,15 @@ module Profiler
70
74
  end
71
75
  end
72
76
 
77
+ def storage
78
+ @storage
79
+ end
80
+
81
+ def storage=(value)
82
+ @storage = value
83
+ @storage_backend = nil
84
+ end
85
+
73
86
  def storage_backend
74
87
  @storage_backend ||= build_storage_backend
75
88
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models/profile"
4
+ require_relative "current_context"
5
+ require_relative "collectors/console_collector"
6
+ require_relative "collectors/database_collector"
7
+ require_relative "collectors/cache_collector"
8
+ require_relative "collectors/http_collector"
9
+ require_relative "collectors/dump_collector"
10
+ require_relative "collectors/log_collector"
11
+ require_relative "collectors/exception_collector"
12
+ require_relative "collectors/env_collector"
13
+ require_relative "collectors/flamegraph_collector"
14
+
15
+ module Profiler
16
+ class ConsoleProfiler
17
+ CONSOLE_COLLECTOR_CLASSES = [
18
+ Collectors::DatabaseCollector,
19
+ Collectors::CacheCollector,
20
+ Collectors::HttpCollector,
21
+ Collectors::DumpCollector,
22
+ Collectors::LogCollector,
23
+ Collectors::ExceptionCollector,
24
+ Collectors::EnvCollector,
25
+ Collectors::FlameGraphCollector
26
+ ].freeze
27
+
28
+ def self.profile(expression:, &block)
29
+ return block.call unless Profiler.enabled? && Profiler.configuration.track_console
30
+
31
+ new(expression: expression).run(&block)
32
+ end
33
+
34
+ def initialize(expression:)
35
+ @expression = expression
36
+ end
37
+
38
+ def run(&block)
39
+ profile = Models::Profile.new
40
+ profile.profile_type = "console"
41
+ profile.gem_version = Profiler::VERSION
42
+ profile.path = @expression.length > 200 ? "#{@expression[0, 200]}..." : @expression
43
+ profile.method = "CONSOLE"
44
+
45
+ console_collector = Collectors::ConsoleCollector.new(profile, expression: @expression)
46
+ collectors = [console_collector] + CONSOLE_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
47
+ collectors.each { |c| c.subscribe if c.respond_to?(:subscribe) }
48
+
49
+ exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
50
+
51
+ memory_before = current_memory if Profiler.configuration.track_memory
52
+
53
+ console_status = "completed"
54
+
55
+ previous_token = Profiler::CurrentContext.token
56
+ Profiler::CurrentContext.token = profile.token
57
+ result = nil
58
+ begin
59
+ result = block.call
60
+ console_collector.set_return_value(result)
61
+ result
62
+ rescue => e
63
+ console_status = "failed"
64
+ exception_collector&.capture(e)
65
+ raise
66
+ ensure
67
+ Profiler::CurrentContext.token = previous_token
68
+ if Profiler.configuration.track_memory
69
+ profile.memory = current_memory - memory_before
70
+ end
71
+
72
+ profile.finish(console_status == "completed" ? 200 : 500)
73
+
74
+ collectors.each do |collector|
75
+ begin
76
+ collector.collect if collector.respond_to?(:collect)
77
+ profile.add_collector_metadata(collector)
78
+ rescue => e
79
+ warn "Profiler ConsoleProfiler: Collector #{collector.class} failed: #{e.message}"
80
+ end
81
+ end
82
+
83
+ Profiler.storage.save(profile.token, profile)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def current_memory
90
+ return 0 unless defined?(GC.stat)
91
+
92
+ stats = GC.stat
93
+ if stats.key?(:total_allocated_size)
94
+ stats[:total_allocated_size]
95
+ elsif stats.key?(:total_allocated_objects)
96
+ stats[:total_allocated_objects] * 40
97
+ else
98
+ 0
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Instrumentation
5
+ module IrbInstrumentation
6
+ IRB_SKIP_COMMANDS = %w[exit quit exit! irb help].freeze
7
+
8
+ def evaluate(line, line_no, *args)
9
+ Profiler.env_override_store.apply!
10
+ stripped = line.to_s.strip
11
+ skip = !Profiler.enabled? ||
12
+ !Profiler.configuration.track_console ||
13
+ stripped.empty? ||
14
+ IRB_SKIP_COMMANDS.include?(stripped)
15
+ return super if skip
16
+
17
+ Profiler::ConsoleProfiler.profile(expression: stripped) { super }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Resources
6
+ class FailingTests
7
+ def self.call
8
+ profiles = Profiler.storage.list(limit: 500)
9
+ failing = profiles.select do |p|
10
+ next false unless p.profile_type == "test"
11
+ test_data = p.collector_data("test") || {}
12
+ test_data["status"] == "failed"
13
+ end.first(20)
14
+
15
+ data = failing.map do |profile|
16
+ test_data = profile.collector_data("test") || {}
17
+ db_data = profile.collector_data("database") || {}
18
+
19
+ {
20
+ token: profile.token,
21
+ test_name: test_data["test_name"] || profile.path,
22
+ framework: test_data["framework"],
23
+ file: "#{test_data["test_file"]}:#{test_data["test_line"]}",
24
+ exception: test_data["exception_message"],
25
+ duration_ms: profile.duration&.round(2),
26
+ query_count: db_data["total_queries"].to_i,
27
+ timestamp: profile.started_at&.iso8601
28
+ }
29
+ end
30
+
31
+ {
32
+ uri: "profiler://failing-tests",
33
+ mimeType: "application/json",
34
+ text: JSON.pretty_generate({ total: data.size, failing_tests: data })
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Resources
6
+ class SlowTests
7
+ def self.call
8
+ profiles = Profiler.storage.list(limit: 500)
9
+ tests = profiles.select { |p| p.profile_type == "test" }
10
+ .sort_by { |p| -p.duration }
11
+ .first(10)
12
+
13
+ data = tests.map do |profile|
14
+ test_data = profile.collector_data("test") || {}
15
+ db_data = profile.collector_data("database") || {}
16
+ queries = db_data["queries"] || []
17
+ n1 = queries.group_by { |q| normalize_sql(q["sql"].to_s) }.any? { |_, qs| qs.size >= 3 }
18
+
19
+ {
20
+ token: profile.token,
21
+ test_name: test_data["test_name"] || profile.path,
22
+ status: test_data["status"],
23
+ framework: test_data["framework"],
24
+ file: test_data["test_file"],
25
+ duration_ms: profile.duration&.round(2),
26
+ query_count: db_data["total_queries"].to_i,
27
+ n1_detected: n1,
28
+ timestamp: profile.started_at&.iso8601
29
+ }
30
+ end
31
+
32
+ {
33
+ uri: "profiler://slow-tests",
34
+ mimeType: "application/json",
35
+ text: JSON.pretty_generate({ total: data.size, slow_tests: data })
36
+ }
37
+ end
38
+
39
+ def self.normalize_sql(sql)
40
+ sql.gsub(/\$\d+/, "?").gsub(/\b\d+\b/, "?").gsub(/'[^']*'/, "?").gsub(/"[^"]*"/, "?").strip
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end