coverband 6.2.0 → 6.2.2

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +10 -2
  3. data/.gitignore +2 -1
  4. data/README.md +119 -2
  5. data/Rakefile +9 -1
  6. data/changes.md +16 -0
  7. data/lib/coverband/collectors/abstract_tracker.rb +17 -1
  8. data/lib/coverband/collectors/delta.rb +21 -7
  9. data/lib/coverband/collectors/query_burst_tracker.rb +268 -0
  10. data/lib/coverband/collectors/view_tracker.rb +11 -1
  11. data/lib/coverband/configuration.rb +14 -0
  12. data/lib/coverband/mcp/http_handler.rb +27 -26
  13. data/lib/coverband/reporters/web.rb +28 -2
  14. data/lib/coverband/utils/configuration_template.rb +4 -0
  15. data/lib/coverband/utils/file_list.rb +1 -0
  16. data/lib/coverband/utils/html_formatter.rb +7 -2
  17. data/lib/coverband/utils/tasks.rb +18 -0
  18. data/lib/coverband/version.rb +1 -1
  19. data/lib/coverband.rb +3 -2
  20. data/public/application.css +27 -0
  21. data/public/application.js +246 -0
  22. data/public/dead_code_session.js +173 -0
  23. data/test/benchmarks/benchmark.rake +18 -10
  24. data/test/benchmarks/benchmark_file_list_covered_lines.rb +54 -0
  25. data/test/coverband/benchmarks/benchmark_memory_leak_check_test.rb +43 -0
  26. data/test/coverband/collectors/delta_test.rb +17 -0
  27. data/test/coverband/collectors/query_burst_tracker_test.rb +77 -0
  28. data/test/coverband/collectors/view_tracker_test.rb +27 -0
  29. data/test/coverband/configuration_test.rb +12 -0
  30. data/test/coverband/mcp/http_handler_test.rb +58 -46
  31. data/test/coverband/reporters/web_test.rb +67 -0
  32. data/test/coverband/track_key_test.rb +9 -0
  33. data/test/forked/rails_route_tracker_stack_test.rb +1 -1
  34. data/test/forked/rails_view_tracker_stack_test.rb +1 -1
  35. data/test/integration/mcp_integration_test.rb +3 -3
  36. data/test/javascript/dead_code_session.test.mjs +123 -0
  37. data/views/abstract_tracker.erb +49 -2
  38. data/views/layout.erb +1 -0
  39. data/views/nav.erb +5 -0
  40. metadata +7 -1
@@ -0,0 +1,173 @@
1
+ // Pure logic for the dead-code review session. Loaded in the browser as
2
+ // window.CoverbandDeadCode and in Node (tests) via require.
3
+ (function (root, factory) {
4
+ if (typeof module === "object" && module.exports) {
5
+ module.exports = factory();
6
+ } else {
7
+ root.CoverbandDeadCode = factory();
8
+ }
9
+ })(typeof self !== "undefined" ? self : this, function () {
10
+ function emptyState() {
11
+ return { hiddenPaths: [], markedPaths: [], hideRuntimeUsed: false, startedAt: null };
12
+ }
13
+
14
+ function parseState(json) {
15
+ var raw;
16
+ try { raw = JSON.parse(json); } catch (e) { raw = null; }
17
+ var state = emptyState();
18
+ if (!raw || typeof raw !== "object") return state;
19
+ if (Object.prototype.toString.call(raw.hiddenPaths) === "[object Array]") {
20
+ state.hiddenPaths = raw.hiddenPaths.filter(function (p) {
21
+ return typeof p === "string" && p.length > 0;
22
+ });
23
+ }
24
+ if (Object.prototype.toString.call(raw.markedPaths) === "[object Array]") {
25
+ state.markedPaths = raw.markedPaths.filter(function (m) {
26
+ return m && typeof m.path === "string" && m.path.length > 0;
27
+ }).map(function (m) {
28
+ return {
29
+ path: m.path,
30
+ comment: typeof m.comment === "string" ? m.comment : "",
31
+ markedAt: typeof m.markedAt === "string" ? m.markedAt : ""
32
+ };
33
+ });
34
+ }
35
+ state.hideRuntimeUsed = raw.hideRuntimeUsed === true;
36
+ state.startedAt = typeof raw.startedAt === "string" ? raw.startedAt : null;
37
+ return state;
38
+ }
39
+
40
+ function pathMatches(entry, path) {
41
+ if (entry.charAt(entry.length - 1) === "/") return path.indexOf(entry) === 0;
42
+ return path === entry;
43
+ }
44
+
45
+ function matchesAny(entries, path) {
46
+ for (var i = 0; i < entries.length; i++) {
47
+ if (pathMatches(entries[i], path)) return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ function isMarked(state, path) {
53
+ for (var i = 0; i < state.markedPaths.length; i++) {
54
+ if (pathMatches(state.markedPaths[i].path, path)) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ function isExcludedIgnoringMarks(state, path, runtimePercent) {
60
+ if (matchesAny(state.hiddenPaths, path)) return true;
61
+ if (state.hideRuntimeUsed && typeof runtimePercent === "number" && runtimePercent > 0) return true;
62
+ return false;
63
+ }
64
+
65
+ function isExcluded(state, path, runtimePercent) {
66
+ return isExcludedIgnoringMarks(state, path, runtimePercent) || isMarked(state, path);
67
+ }
68
+
69
+ function addHidden(state, entry) {
70
+ if (state.hiddenPaths.indexOf(entry) === -1) state.hiddenPaths.push(entry);
71
+ }
72
+
73
+ function removeHidden(state, entry) {
74
+ var i = state.hiddenPaths.indexOf(entry);
75
+ if (i !== -1) state.hiddenPaths.splice(i, 1);
76
+ }
77
+
78
+ function clearHides(state) {
79
+ state.hiddenPaths = [];
80
+ state.hideRuntimeUsed = false;
81
+ }
82
+
83
+ function addMark(state, path, comment, markedAt) {
84
+ removeMark(state, path);
85
+ state.markedPaths.push({ path: path, comment: comment || "", markedAt: markedAt || "" });
86
+ }
87
+
88
+ function removeMark(state, path) {
89
+ for (var i = state.markedPaths.length - 1; i >= 0; i--) {
90
+ if (state.markedPaths[i].path === path) state.markedPaths.splice(i, 1);
91
+ }
92
+ }
93
+
94
+ function updateComment(state, path, comment) {
95
+ for (var i = 0; i < state.markedPaths.length; i++) {
96
+ if (state.markedPaths[i].path === path) state.markedPaths[i].comment = comment;
97
+ }
98
+ }
99
+
100
+ function clearMarks(state) {
101
+ state.markedPaths = [];
102
+ }
103
+
104
+ function csvField(value) {
105
+ var s = String(value == null ? "" : value);
106
+ if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
107
+ return s;
108
+ }
109
+
110
+ function toCSV(markedPaths) {
111
+ var lines = ["path,comment,marked_at"];
112
+ for (var i = 0; i < markedPaths.length; i++) {
113
+ var m = markedPaths[i];
114
+ lines.push([csvField(m.path), csvField(m.comment), csvField(m.markedAt)].join(","));
115
+ }
116
+ return lines.join("\r\n") + "\r\n";
117
+ }
118
+
119
+ function segmentPrefixes(path) {
120
+ var parts = path.split("/");
121
+ var out = [];
122
+ var prefix = "";
123
+ for (var i = 0; i < parts.length; i++) {
124
+ var isFile = i === parts.length - 1;
125
+ prefix += parts[i] + (isFile ? "" : "/");
126
+ out.push({ label: parts[i], prefix: prefix, isFile: isFile });
127
+ }
128
+ return out;
129
+ }
130
+
131
+ // Extracts the full path from a DataTables cell's rendered HTML (the
132
+ // title attribute on the source link), e.g. '<a title="app/foo.rb">foo.rb</a>'.
133
+ function extractPath(cellHtml) {
134
+ var m = String(cellHtml).match(/title="([^"]*)"/);
135
+ return m ? m[1] : "";
136
+ }
137
+
138
+ // Parses the runtime % column (aData[2]) of a report row into a number,
139
+ // or null when it isn't numeric (e.g. "-" for untracked files).
140
+ function rowRuntime(aData) {
141
+ var n = parseFloat(String(aData[2]));
142
+ return isNaN(n) ? null : n;
143
+ }
144
+
145
+ function escapeHtml(s) {
146
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
147
+ }
148
+
149
+ function csvFilename(date) {
150
+ return "coverband-dead-code-" + date.toISOString().slice(0, 10) + ".csv";
151
+ }
152
+
153
+ return {
154
+ emptyState: emptyState,
155
+ parseState: parseState,
156
+ pathMatches: pathMatches,
157
+ isExcluded: isExcluded,
158
+ isExcludedIgnoringMarks: isExcludedIgnoringMarks,
159
+ addHidden: addHidden,
160
+ removeHidden: removeHidden,
161
+ clearHides: clearHides,
162
+ addMark: addMark,
163
+ removeMark: removeMark,
164
+ updateComment: updateComment,
165
+ clearMarks: clearMarks,
166
+ toCSV: toCSV,
167
+ segmentPrefixes: segmentPrefixes,
168
+ extractPath: extractPath,
169
+ rowRuntime: rowRuntime,
170
+ escapeHtml: escapeHtml,
171
+ csvFilename: csvFilename
172
+ };
173
+ });
@@ -173,14 +173,20 @@ namespace :benchmarks do
173
173
  }.pretty_print
174
174
  data = $stdout.string
175
175
  $stdout = previous_out
176
- unless data.match?("Total retained: 0 bytes")
177
- puts data
178
- raise "leaking memory!!!"
179
- end
176
+ assert_no_coverband_leak(data)
180
177
  ensure
181
178
  $stdout = previous_out
182
179
  end
183
180
 
181
+ # Check only that coverband itself retains no objects.
182
+ # External gems (json, redis-client) may retain strings via internal
183
+ # deduplication caches in newer Ruby versions — that is not a coverband leak.
184
+ def assert_no_coverband_leak(data)
185
+ return unless data.match(/retained objects by gem(.*)retained objects by file/m)&.[](0)&.match?(/coverband/)
186
+ puts data
187
+ raise "leaking memory!!!"
188
+ end
189
+
184
190
  def measure_memory_report_coverage
185
191
  require "memory_profiler"
186
192
  require "stringio"
@@ -205,16 +211,18 @@ namespace :benchmarks do
205
211
  # we clear this as this one variable is expected to retain memory and is a false positive
206
212
  ###
207
213
  Coverband::Collectors::Delta.class_variable_set(:@@previous_coverage, nil)
208
- # Clear the RelativeFileConverter cache to avoid retaining strings
209
- Coverband::Utils::RelativeFileConverter.reset
210
214
  end
215
+ # Reset file converter caches INSIDE the block but AFTER the loop.
216
+ # This lets GC collect the instance (and any strings allocated in it during the loop)
217
+ # before MemoryProfiler measures retained objects, so those strings aren't flagged.
218
+ # The instance was created before the block (during warmup), so its Regexp objects
219
+ # are invisible to MemoryProfiler even if GC defers collecting them.
220
+ Coverband::Utils::RelativeFileConverter.reset
221
+ Coverband::Utils::AbsoluteFileConverter.reset
211
222
  }.pretty_print
212
223
  data = $stdout.string
213
224
  $stdout = previous_out
214
- unless data.match?("Total retained: 0 bytes")
215
- puts data
216
- raise "leaking memory!!!"
217
- end
225
+ assert_no_coverband_leak(data)
218
226
  ensure
219
227
  $stdout = previous_out
220
228
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark/ips"
4
+ require "memory_profiler"
5
+
6
+ # Mock SourceFile to simulate the overhead of creating line objects
7
+ class MockSourceFile
8
+ attr_reader :covered_lines_count
9
+
10
+ def initialize(count)
11
+ @covered_lines_count = count
12
+ end
13
+
14
+ def covered_lines
15
+ # Simulate allocating an array of objects
16
+ Array.new(@covered_lines_count) { Object.new }
17
+ end
18
+ end
19
+
20
+ class FileList < Array
21
+ def covered_lines_original
22
+ return 0.0 if empty?
23
+ map { |f| f.covered_lines.count }.inject(:+)
24
+ end
25
+
26
+ def covered_lines_optimized
27
+ return 0.0 if empty?
28
+ sum(&:covered_lines_count)
29
+ end
30
+ end
31
+
32
+ # Generate data: 10,000 files with varying coverage
33
+ files = Array.new(10000) { |i| MockSourceFile.new(i % 100) }
34
+ file_list = FileList.new(files)
35
+
36
+ puts "---------------------------------------------------"
37
+ puts "Memory Profiling: FileList#covered_lines"
38
+
39
+ puts "\nOriginal (map.inject):"
40
+ report = MemoryProfiler.report { file_list.covered_lines_original }
41
+ puts " Total allocated: #{report.total_allocated_memsize} bytes (#{report.total_allocated} objects)"
42
+
43
+ puts "\nOptimized (sum):"
44
+ report = MemoryProfiler.report { file_list.covered_lines_optimized }
45
+ puts " Total allocated: #{report.total_allocated_memsize} bytes (#{report.total_allocated} objects)"
46
+
47
+ puts "\n---------------------------------------------------"
48
+ puts "Benchmark: FileList#covered_lines"
49
+
50
+ Benchmark.ips do |x|
51
+ x.report("map.inject") { file_list.covered_lines_original }
52
+ x.report("sum") { file_list.covered_lines_optimized }
53
+ x.compare!
54
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path("../../test_helper", File.dirname(__FILE__))
4
+ require "rake"
5
+
6
+ load File.expand_path("../../benchmarks/benchmark.rake", File.dirname(__FILE__)) unless respond_to?(:assert_no_coverband_leak, true)
7
+
8
+ class BenchmarkMemoryLeakCheckTest < Minitest::Test
9
+ def test_raises_when_coverband_retains_objects
10
+ data = <<~REPORT
11
+ retained objects by gem
12
+ -----------------------------------
13
+ 1 coverband
14
+
15
+ retained objects by file
16
+ -----------------------------------
17
+ REPORT
18
+
19
+ error = assert_raises(RuntimeError) { send(:assert_no_coverband_leak, data) }
20
+ assert_equal "leaking memory!!!", error.message
21
+ end
22
+
23
+ # Regression test for https://github.com/danmayer/coverband/pull/647 CI failures:
24
+ # newer Ruby/json versions retain a small internal string-dedup cache that is not
25
+ # a Coverband leak, so it must not trip the check.
26
+ def test_does_not_raise_when_only_external_gems_retain_memory
27
+ data = <<~REPORT
28
+ retained objects by gem
29
+ -----------------------------------
30
+ 7 json-2.21.2
31
+ 1 redis-client-0.30.1
32
+
33
+ retained objects by file
34
+ -----------------------------------
35
+ REPORT
36
+
37
+ send(:assert_no_coverband_leak, data)
38
+ end
39
+
40
+ def test_does_not_raise_when_nothing_retained
41
+ send(:assert_no_coverband_leak, "Total retained: 0 bytes (0 objects)\n")
42
+ end
43
+ end
@@ -92,6 +92,23 @@ class CollectorsDeltaTest < Minitest::Test
92
92
  assert_equal({"car.rb" => [nil, 0, 2, 0]}, results)
93
93
  end
94
94
 
95
+ test "Coverage with oneshot_lines payload works when coverband oneshot mode is disabled" do
96
+ current_coverage = {
97
+ "car.rb" => {oneshot_lines: [2, 3]}
98
+ }
99
+ ::Coverage.expects(:line_stub).with("car.rb").returns([nil, 0, 0, 0, nil])
100
+ ::Coverage.expects(:peek_result).returns(current_coverage)
101
+ results = Coverband::Collectors::Delta.results
102
+ assert_equal({"car.rb" => [nil, 1, 1, 0, nil]}, results)
103
+
104
+ current_coverage = {
105
+ "car.rb" => {oneshot_lines: [2, 3, 4]}
106
+ }
107
+ ::Coverage.expects(:peek_result).returns(current_coverage)
108
+ results = Coverband::Collectors::Delta.results
109
+ assert_equal({"car.rb" => [nil, 0, 0, 1, nil]}, results)
110
+ end
111
+
95
112
  test "oneshot coverage calls clear" do
96
113
  Coverband.configuration.stubs(:use_oneshot_lines_coverage).returns(true)
97
114
  current_coverage = {
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path("../../test_helper", File.dirname(__FILE__))
4
+
5
+ class QueryBurstTrackerTest < Minitest::Test
6
+ def tracker
7
+ Coverband::Collectors::QueryBurstTracker.expects(:supported_version?).at_least_once.returns(true)
8
+ Coverband::Collectors::QueryBurstTracker.new(store: fake_store)
9
+ end
10
+
11
+ def tracker_key
12
+ tracker.send(:tracker_key)
13
+ end
14
+
15
+ def tracker_time_key
16
+ tracker.send(:tracker_time_key)
17
+ end
18
+
19
+ def setup
20
+ super
21
+ fake_store.raw_store.del(tracker_key)
22
+ fake_store.raw_store.del(tracker_time_key)
23
+ end
24
+
25
+ test "track key aggregates request SQL stats and threshold hits" do
26
+ Coverband.configuration.query_burst_query_count_threshold = 30
27
+ Coverband.configuration.query_burst_sql_time_threshold_ms = 100.0
28
+
29
+ subject = tracker
30
+ key = "controller:users#index"
31
+
32
+ subject.track_key(key: key, queries: 40, sql_time_ms: 120.5)
33
+ subject.track_key(key: key, queries: 10, sql_time_ms: 20.0)
34
+ subject.save_report
35
+
36
+ assert_equal [key], subject.used_keys.keys
37
+
38
+ stats = subject.used_key_stats[key]
39
+ assert_equal 2, stats["requests"]
40
+ assert_equal 50, stats["total_queries"]
41
+ assert_equal 140.5, stats["total_sql_time_ms"]
42
+ assert_equal 40, stats["max_queries"]
43
+ assert_equal 120.5, stats["max_sql_time_ms"]
44
+ assert_equal 1, stats["threshold_hits"]
45
+ end
46
+
47
+ test "as_json includes thresholds and tracked keys" do
48
+ subject = tracker
49
+ key = "job:HardWorker queue:default"
50
+
51
+ subject.track_key(key: key, queries: 3, sql_time_ms: 12.0)
52
+ subject.save_report
53
+
54
+ parsed = JSON.parse(subject.as_json)
55
+ assert_equal Coverband.configuration.query_burst_query_count_threshold, parsed.dig("thresholds", "query_count")
56
+ assert_equal Coverband.configuration.query_burst_sql_time_threshold_ms, parsed.dig("thresholds", "sql_time_ms")
57
+ assert parsed["used_keys"].key?(key)
58
+ end
59
+
60
+ test "clear key removes tracked query burst stats" do
61
+ subject = tracker
62
+ key = "controller:orders#show"
63
+
64
+ subject.track_key(key: key, queries: 31, sql_time_ms: 55.0)
65
+ subject.save_report
66
+ assert_equal [key], subject.used_keys.keys
67
+
68
+ subject.clear_key!(key)
69
+ assert_equal [], subject.used_keys.keys
70
+ end
71
+
72
+ protected
73
+
74
+ def fake_store
75
+ @fake_store ||= Coverband::Adapters::RedisStore.new(Coverband::Test.redis, redis_namespace: "coverband_test")
76
+ end
77
+ end
@@ -107,6 +107,19 @@ class ViewTrackerTest < Minitest::Test
107
107
  assert_equal [], tracker.used_keys.keys
108
108
  end
109
109
 
110
+ test "track view_component renders via railtie" do
111
+ Coverband::Collectors::ViewTracker.expects(:supported_version?).returns(true)
112
+ store = fake_store
113
+ file_path = "#{File.expand_path(Coverband.configuration.root)}/app/components/example_component.html.erb"
114
+ tracker = Coverband::Collectors::ViewTracker.new(store: store, roots: "dir")
115
+ Coverband.configuration.expects(:view_tracker).returns(tracker).at_least_once
116
+ tracker.railtie!
117
+
118
+ ActiveSupport::Notifications.instrument("render.view_component", view_identifier: file_path)
119
+
120
+ assert_includes tracker.logged_keys, file_path
121
+ end
122
+
110
123
  test "reset store" do
111
124
  Coverband::Collectors::ViewTracker.expects(:supported_version?).returns(true)
112
125
  store = fake_store
@@ -128,6 +141,20 @@ class ViewTrackerTest < Minitest::Test
128
141
  assert_equal [], tracker.logged_keys
129
142
  end
130
143
 
144
+ test "no-op tracker operations with non-redis stores" do
145
+ Coverband::Collectors::ViewTracker.expects(:supported_version?).returns(true)
146
+ store = Coverband::Adapters::NullStore.new
147
+ tracker = Coverband::Collectors::ViewTracker.new(store: store, roots: "dir")
148
+
149
+ tracker.track_key(identifier: "file")
150
+ tracker.save_report
151
+
152
+ assert_equal({}, tracker.used_keys)
153
+ assert_equal "N/A", tracker.tracking_since
154
+ assert_nil tracker.reset_recordings
155
+ assert_nil tracker.clear_key!("file")
156
+ end
157
+
131
158
  protected
132
159
 
133
160
  def fake_store
@@ -112,4 +112,16 @@ class BaseTest < Minitest::Test
112
112
  config.redis_url = "redis://localhost:3333"
113
113
  end
114
114
  end
115
+
116
+ test "redis_url with rediss:// scheme for TLS support" do
117
+ Coverband::Collectors::Coverage.instance.reset_instance
118
+ Coverband.configuration.reset
119
+ # Verify that rediss:// URLs (TLS) can be configured
120
+ # The Redis gem will automatically enable TLS when it sees the rediss:// scheme
121
+ Coverband.configure do |config|
122
+ config.redis_url = "rediss://my-elasticache.example.com:6379"
123
+ end
124
+
125
+ assert_equal "rediss://my-elasticache.example.com:6379", Coverband.configuration.redis_url
126
+ end
115
127
  end
@@ -37,13 +37,13 @@ if defined?(Coverband::MCP)
37
37
  end
38
38
  end
39
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"})
40
+ test "handles MCP POST requests at /mcp endpoint via StreamableHTTPTransport" do
41
+ # Mock the transport to verify it's called
42
+ transport_mock = mock("transport")
43
+ transport_mock.expects(:handle_request).returns([200, {"Content-Type" => "application/json"}, ["{}"]])
44
44
 
45
45
  handler = Coverband::MCP::HttpHandler.new
46
- handler.expects(:mcp_server).returns(server_mock)
46
+ handler.expects(:transport).returns(transport_mock)
47
47
 
48
48
  @app = handler
49
49
 
@@ -56,12 +56,21 @@ if defined?(Coverband::MCP)
56
56
  post "/mcp", json_request, {"CONTENT_TYPE" => "application/json"}
57
57
 
58
58
  assert_equal 200, last_response.status
59
- assert_equal "application/json", last_response.content_type
59
+ end
60
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"]
61
+ test "handles MCP GET requests at /mcp endpoint via StreamableHTTPTransport" do
62
+ # Mock the transport to verify it's called for GET
63
+ transport_mock = mock("transport")
64
+ transport_mock.expects(:handle_request).returns([200, {"Content-Type" => "text/event-stream"}, []])
65
+
66
+ handler = Coverband::MCP::HttpHandler.new
67
+ handler.expects(:transport).returns(transport_mock)
68
+
69
+ @app = handler
70
+
71
+ get "/mcp", {}, {"ACCEPT" => "text/event-stream"}
72
+
73
+ assert_equal 200, last_response.status
65
74
  end
66
75
 
67
76
  test "returns 404 for non-MCP requests when no wrapped app" do
@@ -81,51 +90,49 @@ if defined?(Coverband::MCP)
81
90
  assert_equal "wrapped app response", last_response.body
82
91
  end
83
92
 
84
- test "only responds to POST requests for MCP endpoint" do
85
- get "/mcp"
93
+ test "responds to GET, POST, DELETE, OPTIONS for MCP endpoint" do
94
+ transport_mock = mock("transport")
95
+ transport_mock.expects(:handle_request).at_least(3).returns([200, {"Content-Type" => "application/json"}, ["{}"]])
86
96
 
87
- assert_equal 404, last_response.status
88
- end
97
+ handler = Coverband::MCP::HttpHandler.new
98
+ handler.expects(:transport).at_least(3).returns(transport_mock)
89
99
 
90
- test "handles invalid JSON gracefully" do
91
- post "/mcp", "invalid json", {"CONTENT_TYPE" => "application/json"}
100
+ @app = handler
92
101
 
93
- assert_equal 400, last_response.status
94
- assert_equal "application/json", last_response.content_type
102
+ # POST request
103
+ post "/mcp", "{}", {"CONTENT_TYPE" => "application/json"}
104
+ assert_equal 200, last_response.status
95
105
 
96
- response = JSON.parse(last_response.body)
97
- assert_includes response["error"], "Invalid JSON"
98
- end
106
+ # GET request
107
+ get "/mcp", {}, {"ACCEPT" => "text/event-stream"}
108
+ assert_equal 200, last_response.status
99
109
 
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"))
110
+ # DELETE request
111
+ delete "/mcp"
112
+ assert_equal 200, last_response.status
113
+ end
104
114
 
115
+ test "handles CORS preflight OPTIONS request" do
105
116
  handler = Coverband::MCP::HttpHandler.new
106
- handler.expects(:mcp_server).returns(server_mock)
107
-
108
117
  @app = handler
109
118
 
110
- json_request = {"test" => "request"}.to_json
111
- post "/mcp", json_request, {"CONTENT_TYPE" => "application/json"}
112
-
113
- assert_equal 500, last_response.status
119
+ options "/mcp"
114
120
 
115
- response = JSON.parse(last_response.body)
116
- assert_includes response["error"], "Server error: Test error"
121
+ assert_equal 204, last_response.status
122
+ assert_equal "*", last_response.headers["Access-Control-Allow-Origin"]
123
+ assert_equal "GET, POST, DELETE, OPTIONS", last_response.headers["Access-Control-Allow-Methods"]
117
124
  end
118
125
 
119
- test "mcp_server is lazily initialized" do
120
- handler = Coverband::MCP::HttpHandler.new
126
+ test "delegates non-MCP requests to wrapped app for non-POST" do
127
+ @app = app_with_wrapped_handler
121
128
 
122
- # First call creates the server
123
- server1 = handler.send(:mcp_server)
124
- assert_instance_of Coverband::MCP::Server, server1
129
+ get "/other-path"
130
+ assert_equal 200, last_response.status
131
+ assert_equal "wrapped app response", last_response.body
125
132
 
126
- # Second call returns the same instance
127
- server2 = handler.send(:mcp_server)
128
- assert_same server1, server2
133
+ delete "/other-path"
134
+ assert_equal 200, last_response.status
135
+ assert_equal "wrapped app response", last_response.body
129
136
  end
130
137
 
131
138
  test "mcp_request? correctly identifies MCP requests" do
@@ -136,15 +143,20 @@ if defined?(Coverband::MCP)
136
143
  request = Rack::Request.new(env)
137
144
  assert handler.send(:mcp_request?, request)
138
145
 
139
- # POST request to /some-path/mcp (ends with /mcp)
140
- env = Rack::MockRequest.env_for("/some-path/mcp", method: "POST")
146
+ # GET request to /mcp
147
+ env = Rack::MockRequest.env_for("/mcp", method: "GET")
141
148
  request = Rack::Request.new(env)
142
149
  assert handler.send(:mcp_request?, request)
143
150
 
144
- # GET request to /mcp
145
- env = Rack::MockRequest.env_for("/mcp", method: "GET")
151
+ # DELETE request to /mcp
152
+ env = Rack::MockRequest.env_for("/mcp", method: "DELETE")
146
153
  request = Rack::Request.new(env)
147
- refute handler.send(:mcp_request?, request)
154
+ assert handler.send(:mcp_request?, request)
155
+
156
+ # OPTIONS request to /mcp
157
+ env = Rack::MockRequest.env_for("/mcp", method: "OPTIONS")
158
+ request = Rack::Request.new(env)
159
+ assert handler.send(:mcp_request?, request)
148
160
 
149
161
  # POST request to /other-path
150
162
  env = Rack::MockRequest.env_for("/other-path", method: "POST")