coverband 6.1.6 → 6.1.7

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +4 -4
  3. data/README.md +123 -0
  4. data/agents.md +217 -0
  5. data/bin/coverband-mcp +42 -0
  6. data/changes.md +18 -0
  7. data/coverband/log.272267 +1 -0
  8. data/coverband.gemspec +3 -1
  9. data/lib/coverband/collectors/route_tracker.rb +1 -1
  10. data/lib/coverband/collectors/view_tracker.rb +21 -13
  11. data/lib/coverband/configuration.rb +57 -18
  12. data/lib/coverband/integrations/resque.rb +2 -2
  13. data/lib/coverband/mcp/http_handler.rb +118 -0
  14. data/lib/coverband/mcp/server.rb +116 -0
  15. data/lib/coverband/mcp/tools/get_coverage_summary.rb +41 -0
  16. data/lib/coverband/mcp/tools/get_dead_methods.rb +69 -0
  17. data/lib/coverband/mcp/tools/get_file_coverage.rb +72 -0
  18. data/lib/coverband/mcp/tools/get_route_tracker_data.rb +60 -0
  19. data/lib/coverband/mcp/tools/get_translation_tracker_data.rb +60 -0
  20. data/lib/coverband/mcp/tools/get_uncovered_files.rb +73 -0
  21. data/lib/coverband/mcp/tools/get_view_tracker_data.rb +60 -0
  22. data/lib/coverband/mcp.rb +27 -0
  23. data/lib/coverband/reporters/base.rb +2 -4
  24. data/lib/coverband/reporters/web.rb +17 -14
  25. data/lib/coverband/utils/lines_classifier.rb +1 -1
  26. data/lib/coverband/utils/result.rb +2 -1
  27. data/lib/coverband/utils/source_file.rb +5 -5
  28. data/lib/coverband/utils/tasks.rb +31 -0
  29. data/lib/coverband/version.rb +1 -1
  30. data/lib/coverband.rb +2 -2
  31. data/test/coverband/file_store_integration_test.rb +72 -0
  32. data/test/coverband/file_store_redis_error_test.rb +56 -0
  33. data/test/coverband/github_issue_586_test.rb +46 -0
  34. data/test/coverband/initialization_timing_test.rb +71 -0
  35. data/test/coverband/mcp/http_handler_test.rb +159 -0
  36. data/test/coverband/mcp/security_test.rb +145 -0
  37. data/test/coverband/mcp/server_test.rb +125 -0
  38. data/test/coverband/mcp/tools/get_coverage_summary_test.rb +75 -0
  39. data/test/coverband/mcp/tools/get_dead_methods_test.rb +162 -0
  40. data/test/coverband/mcp/tools/get_file_coverage_test.rb +159 -0
  41. data/test/coverband/mcp/tools/get_route_tracker_data_test.rb +122 -0
  42. data/test/coverband/mcp/tools/get_translation_tracker_data_test.rb +122 -0
  43. data/test/coverband/mcp/tools/get_uncovered_files_test.rb +177 -0
  44. data/test/coverband/mcp/tools/get_view_tracker_data_test.rb +122 -0
  45. data/test/coverband/reporters/web_test.rb +5 -0
  46. data/test/coverband/tracker_initialization_test.rb +75 -0
  47. data/test/coverband/user_environment_simulation_test.rb +75 -0
  48. data/test/coverband/utils/lines_classifier_test.rb +1 -1
  49. data/test/integration/mcp_integration_test.rb +175 -0
  50. data/test/test_helper.rb +4 -5
  51. metadata +65 -6
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mcp"
5
+ rescue LoadError
6
+ raise LoadError, <<~MSG
7
+ The 'mcp' gem is required for MCP server support.
8
+ Add `gem 'mcp'` to your Gemfile and run `bundle install`.
9
+ MSG
10
+ end
11
+
12
+ require "coverband"
13
+ require "coverband/utils/html_formatter"
14
+ require "coverband/utils/result"
15
+ require "coverband/utils/file_list"
16
+ require "coverband/utils/source_file"
17
+ require "coverband/utils/lines_classifier"
18
+ require "coverband/utils/results"
19
+ require "coverband/reporters/json_report"
20
+
21
+ # Load dead methods support if available
22
+ if defined?(RubyVM::AbstractSyntaxTree)
23
+ require "coverband/utils/dead_methods"
24
+ end
25
+
26
+ require_relative "mcp/server"
27
+ require_relative "mcp/http_handler"
@@ -55,13 +55,11 @@ module Coverband
55
55
  fixed_report[name] = {}
56
56
  report.each_pair do |key, vals|
57
57
  filename = Coverband::Utils::AbsoluteFileConverter.convert(key)
58
- fixed_report[name][filename] = if fixed_report[name].key?(filename) && fixed_report[name][filename][DATA_KEY] && vals[DATA_KEY]
58
+ if fixed_report[name].key?(filename) && fixed_report[name][filename][DATA_KEY] && vals[DATA_KEY]
59
59
  merged_data = merge_arrays(fixed_report[name][filename][DATA_KEY], vals[DATA_KEY])
60
60
  vals[DATA_KEY] = merged_data
61
- vals
62
- else
63
- vals
64
61
  end
62
+ fixed_report[name][filename] = vals
65
63
  end
66
64
  end
67
65
  end
@@ -62,9 +62,9 @@ module Coverband
62
62
  Coverband.configuration.trackers.each do |tracker|
63
63
  if request_path_info.match(tracker.class::REPORT_ROUTE)
64
64
  tracker_route = true
65
- if request_path_info =~ %r{\/clear_.*_key}
65
+ if request_path_info =~ %r{/clear_.*_key}
66
66
  return clear_abstract_tracking_key(tracker)
67
- elsif request_path_info =~ %r{\/clear_.*}
67
+ elsif request_path_info =~ %r{/clear_.*}
68
68
  return clear_abstract_tracking(tracker)
69
69
  else
70
70
  return [200, {"content-type" => "text/html"}, [display_abstract_tracker(tracker)]]
@@ -75,9 +75,9 @@ module Coverband
75
75
  unless tracker_route
76
76
  if request.post?
77
77
  case request_path_info
78
- when %r{\/clear_file}
78
+ when %r{/clear_file}
79
79
  clear_file
80
- when %r{\/clear}
80
+ when %r{/clear}
81
81
  clear
82
82
  else
83
83
  [404, coverband_headers, ["404 error!"]]
@@ -86,21 +86,21 @@ module Coverband
86
86
  case request_path_info
87
87
  when /.*\.(css|js|gif|png)/
88
88
  @static.call(env)
89
- when %r{\/settings}
89
+ when %r{/settings}
90
90
  [200, coverband_headers, [settings]]
91
- when %r{\/view_tracker_data}
91
+ when %r{/view_tracker_data}
92
92
  [200, coverband_headers(content_type: "text/json"), [view_tracker_data]]
93
- when %r{\/enriched_debug_data}
93
+ when %r{/enriched_debug_data}
94
94
  [200, coverband_headers(content_type: "text/json"), [enriched_debug_data]]
95
- when %r{\/debug_data}
95
+ when %r{/debug_data}
96
96
  [200, coverband_headers(content_type: "text/json"), [debug_data]]
97
- when %r{\/load_file_details}
97
+ when %r{/load_file_details}
98
98
  [200, coverband_headers(content_type: "text/json"), [load_file_details]]
99
- when %r{\/json}
99
+ when %r{/json}
100
100
  [200, coverband_headers(content_type: "text/json"), [json]]
101
- when %r{\/report_json}
101
+ when %r{/report_json}
102
102
  [200, coverband_headers(content_type: "text/json"), [report_json]]
103
- when %r{\/$}
103
+ when %r{/$}
104
104
  [200, coverband_headers, [index]]
105
105
  else
106
106
  [404, coverband_headers, ["404 error!"]]
@@ -125,7 +125,10 @@ module Coverband
125
125
  end
126
126
 
127
127
  def json
128
- Coverband::Reporters::JSONReport.new(Coverband.configuration.store).report
128
+ Coverband::Reporters::JSONReport.new(
129
+ Coverband.configuration.store,
130
+ line_coverage: request.params["line_coverage"] == "true"
131
+ ).report
129
132
  end
130
133
 
131
134
  def report_json
@@ -241,7 +244,7 @@ module Coverband
241
244
  # %r{\/.*\/}.match?(request.path) ? request.path.match("\/.*\/")[0] : "/"
242
245
  # ^^ the above is NOT valid Ruby 2.3/2.4 even though rubocop / standard think it is
243
246
  def base_path
244
- (request.path =~ %r{\/.*\/}) ? request.path.match("/.*/")[0] : "/"
247
+ (request.path =~ %r{/.*/}) ? request.path.match("/.*/")[0] : "/"
245
248
  end
246
249
  end
247
250
  end
@@ -23,7 +23,7 @@ module Coverband
23
23
  WHITESPACE_OR_COMMENT_LINE = Regexp.union(WHITESPACE_LINE, COMMENT_LINE)
24
24
 
25
25
  def self.no_cov_line
26
- /^(\s*)#(\s*)(\:nocov\:)/o
26
+ /^(\s*)#(\s*)(:nocov:)/o
27
27
  end
28
28
 
29
29
  def self.no_cov_line?(line)
@@ -14,12 +14,13 @@ module Coverband
14
14
  module Utils
15
15
  class Result
16
16
  extend Forwardable
17
+
17
18
  # Returns the original Coverage.result used for this instance of Coverband::Result
18
19
  attr_reader :original_result
19
20
  # Returns all files that are applicable to this result (sans filters!)
20
21
  # as instances of Coverband::SourceFile. Aliased as :source_files
21
22
  attr_reader :files
22
- alias source_files files
23
+ alias_method :source_files, :files
23
24
  # Explicitly set the Time this result has been created
24
25
  attr_writer :created_at
25
26
 
@@ -29,9 +29,9 @@ module Coverband
29
29
  attr_reader :coverage_posted
30
30
 
31
31
  # Lets grab some fancy aliases, shall we?
32
- alias source src
33
- alias line line_number
34
- alias number line_number
32
+ alias_method :source, :src
33
+ alias_method :line, :line_number
34
+ alias_method :number, :line_number
35
35
 
36
36
  def initialize(src, line_number, coverage, coverage_posted = nil)
37
37
  raise ArgumentError, "Only String accepted for source" unless src.is_a?(String)
@@ -132,14 +132,14 @@ module Coverband
132
132
  # suppress reading unused source code.
133
133
  @src ||= File.open(filename, "rb", &:readlines)
134
134
  end
135
- alias source src
135
+ alias_method :source, :src
136
136
 
137
137
  # Returns all source lines for this file as instances of SimpleCov::SourceFile::Line,
138
138
  # and thus including coverage data. Aliased as :source_lines
139
139
  def lines
140
140
  @lines ||= build_lines
141
141
  end
142
- alias source_lines lines
142
+ alias_method :source_lines, :lines
143
143
 
144
144
  def build_lines
145
145
  coverage_exceeding_source_warn if coverage.size > src.size
@@ -126,6 +126,37 @@ namespace :coverband do
126
126
  Port: ENV.fetch("COVERBAND_COVERAGE_PORT", 9022).to_i
127
127
  end
128
128
 
129
+ desc "Start MCP server for AI assistant integration (set COVERBAND_MCP_HTTP=true for HTTP mode)"
130
+ task :mcp do
131
+ if Rake::Task.task_defined?("environment")
132
+ Rake.application["environment"].invoke
133
+ end
134
+
135
+ begin
136
+ require "coverband/mcp"
137
+ rescue LoadError
138
+ abort "The 'mcp' gem is required for MCP server support. Add `gem 'mcp'` to your Gemfile."
139
+ end
140
+
141
+ server = Coverband::MCP::Server.new
142
+
143
+ if ENV["COVERBAND_MCP_HTTP"]
144
+ # HTTP mode with Streamable HTTP transport (SSE)
145
+ begin
146
+ require "rackup"
147
+ rescue LoadError
148
+ abort "The 'rackup' gem is required for HTTP mode. Add `gem 'rackup'` to your Gemfile."
149
+ end
150
+
151
+ port = ENV.fetch("COVERBAND_MCP_PORT", 9023).to_i
152
+ host = ENV.fetch("COVERBAND_MCP_HOST", "localhost")
153
+ server.run_http(port: port, host: host)
154
+ else
155
+ # Default stdio mode
156
+ server.run_stdio
157
+ end
158
+ end
159
+
129
160
  # experimental dead method detection using RubyVM::AbstractSyntaxTree
130
161
  # combined with the coverband coverage.
131
162
  if defined?(RubyVM::AbstractSyntaxTree)
@@ -5,5 +5,5 @@
5
5
  # use format "4.2.1.rc.1" ~> 4.2.1.rc to prerelease versions like v4.2.1.rc.2 and v4.2.1.rc.3
6
6
  ###
7
7
  module Coverband
8
- VERSION = "6.1.6"
8
+ VERSION = "6.1.7"
9
9
  end
data/lib/coverband.rb CHANGED
@@ -51,8 +51,8 @@ module Coverband
51
51
  yield(configuration)
52
52
  elsif File.exist?(configuration_file)
53
53
  load configuration_file
54
- else
55
- configuration.logger.debug("using default configuration") if Coverband.configuration.verbose
54
+ elsif Coverband.configuration.verbose
55
+ configuration.logger.debug("using default configuration")
56
56
  end
57
57
  @@configured = true
58
58
  coverage_instance.reset_instance
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path("../test_helper", File.dirname(__FILE__))
4
+
5
+ class FileStoreIntegrationTest < Minitest::Test
6
+ def setup
7
+ super
8
+ # Reset state
9
+ Thread.current[:coverband_instance] = nil
10
+ Coverband.class_variable_set(:@@configured, false)
11
+ end
12
+
13
+ def teardown
14
+ super
15
+ Thread.current[:coverband_instance] = nil
16
+ Coverband.configure do |config|
17
+ # Reset to default
18
+ end
19
+ Coverband.class_variable_set(:@@configured, false)
20
+ end
21
+
22
+ test "auto-start with FileStore configuration does not try Redis" do
23
+ # Clear any existing store to simulate fresh start
24
+ Coverband.configuration.instance_variable_set(:@store, nil)
25
+
26
+ # This simulates the typical user setup
27
+ Coverband.configure do |config|
28
+ config.root = Dir.pwd
29
+ config.background_reporting_enabled = false
30
+ config.store = Coverband::Adapters::FileStore.new("tmp/integration_test_coverage")
31
+ config.logger = Logger.new($stdout)
32
+ config.verbose = false
33
+ end
34
+
35
+ # Start coverband - this should work without Redis errors
36
+ begin
37
+ Coverband.start
38
+ Coverband.report_coverage
39
+ rescue Redis::CannotConnectError => e
40
+ flunk "Should not try to connect to Redis when FileStore is configured: #{e.message}"
41
+ rescue => e
42
+ flunk "Unexpected error: #{e.class}: #{e.message}"
43
+ end
44
+
45
+ # Verify we're using the correct store
46
+ assert_instance_of Coverband::Adapters::FileStore, Coverband.configuration.store
47
+ end
48
+
49
+ test "store fallback works even in auto-start scenario" do
50
+ # Backup original ENV to avoid side effects
51
+ original_disable = ENV["COVERBAND_DISABLE_AUTO_START"]
52
+
53
+ begin
54
+ # Enable auto-start
55
+ ENV.delete("COVERBAND_DISABLE_AUTO_START")
56
+
57
+ # Clear configuration state
58
+ Coverband.configuration.instance_variable_set(:@store, nil)
59
+ Coverband.class_variable_set(:@@configured, false)
60
+
61
+ # Mock Redis to fail
62
+ Redis.expects(:new).raises(Redis::CannotConnectError.new("Connection refused")).at_least_once
63
+
64
+ # This should not raise an error even with Redis unavailable
65
+ store = Coverband.configuration.store
66
+
67
+ assert_instance_of Coverband::Adapters::NullStore, store
68
+ ensure
69
+ ENV["COVERBAND_DISABLE_AUTO_START"] = original_disable
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path("../test_helper", File.dirname(__FILE__))
4
+
5
+ class FileStoreRedisErrorTest < Minitest::Test
6
+ def setup
7
+ super
8
+ # Reset the singleton instance to ensure clean state
9
+ Coverband::Collectors::Coverage.instance.reset_instance
10
+ end
11
+
12
+ def teardown
13
+ super
14
+ Thread.current[:coverband_instance] = nil
15
+ Coverband.configure do |config|
16
+ # Reset to default redis store
17
+ end
18
+ Coverband::Collectors::Coverage.instance.reset_instance
19
+ end
20
+
21
+ test "Default store gracefully handles Redis connection failure" do
22
+ # Clear any existing store configuration
23
+ Coverband.configuration.instance_variable_set(:@store, nil)
24
+
25
+ # Mock Redis to simulate connection failure
26
+ Redis.expects(:new).raises(Redis::CannotConnectError.new("Connection refused"))
27
+
28
+ # This should not raise an error, instead it should fall back to NullStore
29
+ store = Coverband.configuration.store
30
+
31
+ assert_instance_of Coverband::Adapters::NullStore, store
32
+ end
33
+
34
+ test "FileStore configuration overrides default store" do
35
+ # Clear any existing store
36
+ Coverband.configuration.instance_variable_set(:@store, nil)
37
+
38
+ file_store = Coverband::Adapters::FileStore.new("tmp/test_coverage")
39
+ Coverband.configure do |config|
40
+ config.store = file_store
41
+ end
42
+
43
+ assert_same file_store, Coverband.configuration.store
44
+ assert_instance_of Coverband::Adapters::FileStore, Coverband.configuration.store
45
+ end
46
+
47
+ test "FileStore is properly set after configuration" do
48
+ store_path = "tmp/test_coverage"
49
+ Coverband.configure do |config|
50
+ config.store = Coverband::Adapters::FileStore.new(store_path)
51
+ end
52
+
53
+ assert_instance_of Coverband::Adapters::FileStore, Coverband.configuration.store
54
+ refute Coverband.configuration.store.is_a?(Coverband::Adapters::RedisStore)
55
+ end
56
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This test demonstrates the fix for GitHub issue #586
4
+ # https://github.com/danmayer/coverband/issues/586
5
+
6
+ require File.expand_path("../test_helper", File.dirname(__FILE__))
7
+
8
+ class GitHubIssue586Test < Minitest::Test
9
+ def setup
10
+ super
11
+ # Reset the singleton instance to ensure clean state
12
+ Coverband::Collectors::Coverage.instance.reset_instance
13
+ end
14
+
15
+ def teardown
16
+ super
17
+ Thread.current[:coverband_instance] = nil
18
+ Coverband.configure do |config|
19
+ # Reset to default
20
+ end
21
+ Coverband::Collectors::Coverage.instance.reset_instance
22
+ end
23
+
24
+ test "GitHub issue #586: FileStore should not cause Redis connection errors" do
25
+ # This reproduces the exact configuration from the GitHub issue
26
+ Coverband.configure do |config|
27
+ config.root = Dir.pwd
28
+ config.background_reporting_enabled = false
29
+ config.store = Coverband::Adapters::FileStore.new("tmp/coverband_log")
30
+ config.logger = Logger.new($stdout)
31
+ config.verbose = false
32
+ end
33
+
34
+ # This should not raise Redis connection errors
35
+ begin
36
+ # Simulate running ruby code to analyze
37
+ Coverband.start
38
+ Coverband.report_coverage
39
+
40
+ # Verify the store is what the user configured
41
+ assert_instance_of Coverband::Adapters::FileStore, Coverband.configuration.store
42
+ rescue Redis::CannotConnectError => e
43
+ flunk "Should not get Redis connection error when using FileStore: #{e.message}"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This test simulates the exact scenario from GitHub issue #586
4
+ # to verify the timing and sequence of initialization
5
+
6
+ require File.expand_path("../test_helper", File.dirname(__FILE__))
7
+
8
+ class InitializationTimingTest < Minitest::Test
9
+ def setup
10
+ super
11
+ # Clear all state to simulate fresh require
12
+ Thread.current[:coverband_instance] = nil
13
+ Coverband.class_variable_set(:@@configured, false)
14
+ Coverband.configuration.instance_variable_set(:@store, nil)
15
+ end
16
+
17
+ def teardown
18
+ super
19
+ Thread.current[:coverband_instance] = nil
20
+ Coverband.configure do |config|
21
+ # Reset to default
22
+ end
23
+ Coverband.class_variable_set(:@@configured, false)
24
+ end
25
+
26
+ test "simulate auto-start before user configuration" do
27
+ # Clear configuration to simulate fresh require
28
+ Coverband.configuration.instance_variable_set(:@store, nil)
29
+ Coverband.class_variable_set(:@@configured, false)
30
+
31
+ # Mock Redis to fail when auto-start tries to create default store
32
+ Redis.expects(:new).raises(Redis::CannotConnectError.new("Connection refused")).once
33
+
34
+ # This simulates what happens during auto-start (configure + start)
35
+ # before user has configured anything
36
+ Coverband.configure # No block, no file
37
+ Coverband.start
38
+
39
+ # At this point, the store should have fallen back to NullStore
40
+ assert_instance_of Coverband::Adapters::NullStore, Coverband.configuration.store
41
+
42
+ # Now user tries to configure with FileStore
43
+ Coverband.configure do |config|
44
+ config.store = Coverband::Adapters::FileStore.new("coverband/log")
45
+ end
46
+
47
+ # Should now use the configured FileStore
48
+ assert_instance_of Coverband::Adapters::FileStore, Coverband.configuration.store
49
+ end
50
+
51
+ test "check what happens if store is accessed before user configures it" do
52
+ # Clear configuration to simulate unconfigured state
53
+ Coverband.configuration.instance_variable_set(:@store, nil)
54
+ Coverband.class_variable_set(:@@configured, false)
55
+
56
+ # Mock Redis to fail
57
+ Redis.expects(:new).raises(Redis::CannotConnectError.new("Connection refused")).once
58
+
59
+ # Access store before configuration - should trigger fallback
60
+ store = Coverband.configuration.store
61
+ assert_instance_of Coverband::Adapters::NullStore, store
62
+
63
+ # Now configure with FileStore
64
+ Coverband.configure do |config|
65
+ config.store = Coverband::Adapters::FileStore.new("coverband/log")
66
+ end
67
+
68
+ # Should now use the configured FileStore
69
+ assert_instance_of Coverband::Adapters::FileStore, Coverband.configuration.store
70
+ end
71
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path("../../test_helper", File.dirname(__FILE__))
4
+ require "rack/test"
5
+
6
+ begin
7
+ require "coverband/mcp"
8
+ rescue LoadError
9
+ puts "MCP gem not available, skipping MCP HTTP handler tests"
10
+ end
11
+
12
+ if defined?(Coverband::MCP)
13
+ class MCPHttpHandlerTest < Minitest::Test
14
+ include Rack::Test::Methods
15
+
16
+ def setup
17
+ super
18
+ Coverband.configure do |config|
19
+ config.store = Coverband::Adapters::RedisStore.new(Redis.new(db: 2))
20
+ config.mcp_enabled = true # Enable MCP for testing
21
+ end
22
+ end
23
+
24
+ def teardown
25
+ super
26
+ Coverband.configuration.store&.clear!
27
+ end
28
+
29
+ def app
30
+ @app ||= Coverband::MCP::HttpHandler.new
31
+ end
32
+
33
+ def app_with_wrapped_handler
34
+ @wrapped_app ||= begin
35
+ mock_app = lambda { |env| [200, {"Content-Type" => "text/plain"}, ["wrapped app response"]] }
36
+ Coverband::MCP::HttpHandler.new(mock_app)
37
+ end
38
+ end
39
+
40
+ test "handles MCP requests at /mcp endpoint" do
41
+ # Mock the server to return a simple response
42
+ server_mock = mock("server")
43
+ server_mock.expects(:handle_json).returns({"result" => "success"})
44
+
45
+ handler = Coverband::MCP::HttpHandler.new
46
+ handler.expects(:mcp_server).returns(server_mock)
47
+
48
+ @app = handler
49
+
50
+ json_request = {
51
+ "jsonrpc" => "2.0",
52
+ "method" => "tools/list",
53
+ "id" => 1
54
+ }.to_json
55
+
56
+ post "/mcp", json_request, {"CONTENT_TYPE" => "application/json"}
57
+
58
+ assert_equal 200, last_response.status
59
+ assert_equal "application/json", last_response.content_type
60
+
61
+ # Check CORS headers
62
+ assert_equal "*", last_response.headers["Access-Control-Allow-Origin"]
63
+ assert_equal "POST, OPTIONS", last_response.headers["Access-Control-Allow-Methods"]
64
+ assert_equal "Content-Type", last_response.headers["Access-Control-Allow-Headers"]
65
+ end
66
+
67
+ test "returns 404 for non-MCP requests when no wrapped app" do
68
+ get "/other-path"
69
+
70
+ assert_equal 404, last_response.status
71
+ assert_equal "text/plain", last_response.content_type
72
+ assert_equal "Not Found", last_response.body
73
+ end
74
+
75
+ test "delegates non-MCP requests to wrapped app" do
76
+ @app = app_with_wrapped_handler
77
+
78
+ get "/other-path"
79
+
80
+ assert_equal 200, last_response.status
81
+ assert_equal "wrapped app response", last_response.body
82
+ end
83
+
84
+ test "only responds to POST requests for MCP endpoint" do
85
+ get "/mcp"
86
+
87
+ assert_equal 404, last_response.status
88
+ end
89
+
90
+ test "handles invalid JSON gracefully" do
91
+ post "/mcp", "invalid json", {"CONTENT_TYPE" => "application/json"}
92
+
93
+ assert_equal 400, last_response.status
94
+ assert_equal "application/json", last_response.content_type
95
+
96
+ response = JSON.parse(last_response.body)
97
+ assert_includes response["error"], "Invalid JSON"
98
+ end
99
+
100
+ test "handles server errors gracefully" do
101
+ # Mock server to raise an error
102
+ server_mock = mock("server")
103
+ server_mock.expects(:handle_json).raises(StandardError.new("Test error"))
104
+
105
+ handler = Coverband::MCP::HttpHandler.new
106
+ handler.expects(:mcp_server).returns(server_mock)
107
+
108
+ @app = handler
109
+
110
+ json_request = {"test" => "request"}.to_json
111
+ post "/mcp", json_request, {"CONTENT_TYPE" => "application/json"}
112
+
113
+ assert_equal 500, last_response.status
114
+
115
+ response = JSON.parse(last_response.body)
116
+ assert_includes response["error"], "Server error: Test error"
117
+ end
118
+
119
+ test "mcp_server is lazily initialized" do
120
+ handler = Coverband::MCP::HttpHandler.new
121
+
122
+ # First call creates the server
123
+ server1 = handler.send(:mcp_server)
124
+ assert_instance_of Coverband::MCP::Server, server1
125
+
126
+ # Second call returns the same instance
127
+ server2 = handler.send(:mcp_server)
128
+ assert_same server1, server2
129
+ end
130
+
131
+ test "mcp_request? correctly identifies MCP requests" do
132
+ handler = Coverband::MCP::HttpHandler.new
133
+
134
+ # POST request to /mcp path
135
+ env = Rack::MockRequest.env_for("/mcp", method: "POST")
136
+ request = Rack::Request.new(env)
137
+ assert handler.send(:mcp_request?, request)
138
+
139
+ # POST request to /some-path/mcp (ends with /mcp)
140
+ env = Rack::MockRequest.env_for("/some-path/mcp", method: "POST")
141
+ request = Rack::Request.new(env)
142
+ assert handler.send(:mcp_request?, request)
143
+
144
+ # GET request to /mcp
145
+ env = Rack::MockRequest.env_for("/mcp", method: "GET")
146
+ request = Rack::Request.new(env)
147
+ refute handler.send(:mcp_request?, request)
148
+
149
+ # POST request to /other-path
150
+ env = Rack::MockRequest.env_for("/other-path", method: "POST")
151
+ request = Rack::Request.new(env)
152
+ refute handler.send(:mcp_request?, request)
153
+ end
154
+
155
+ test "constant MCP_PATH is defined" do
156
+ assert_equal "/mcp", Coverband::MCP::HttpHandler::MCP_PATH
157
+ end
158
+ end
159
+ end