coverband 6.1.8 → 6.2.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '01680ffc24d42fce1b45f90fff31a9d190b029af8a83b0a1405ed81d2785bc10'
4
- data.tar.gz: 04daf91b9d7f64849d217075e71ff00d54cac2e64fd1b5c7fdd7e0b27943b56e
3
+ metadata.gz: c47e38934426a369c81dd7d08e5474ad0d8a784b541975c1aa7ee50fc9e2d1ce
4
+ data.tar.gz: 346c8263e9c961631bcf4af3019d8473177088c5b9ae3cb3844dc7d86bcd7497
5
5
  SHA512:
6
- metadata.gz: 0de107048e072e97c1bded932a59ac9b6be2c6ac94b045118977aae38282a109772b183bed1b7ca99d7867d3fc694f326a53e2e93174bbf372e7f0ba16e8bd13
7
- data.tar.gz: 57d8e1921056acb7f13e47b02caeb6207e635984a461c1952aafd0dbf4e445519d99ca8b369b60d11c3121b86da6f5f3d3d2a4e6aaf6d68fd2d3a8aa155d14fb
6
+ metadata.gz: 907a280ce513b23922b81c44674c135ab067e629ae9f8aaec6eb6a667946a52a85fd1e7a0fe17ee9b24c0a6b81b8b22bcb1e1c53e7ce75ccc7a9c3507b6dd183
7
+ data.tar.gz: 60198f0536094c5505ea532b238d9d38359f936edb6ea21854c946d3a5f63e7e02b66f1240596b787cad898546a3d48d41595d5d4cab28bb09face2a4f405499
data/README.md CHANGED
@@ -1,9 +1,9 @@
1
- <img src="https://raw.github.com/danmayer/coverband/master/docs/assets/logo/heads.svg?sanitize=true" width='300'>
1
+ <img src="https://raw.github.com/danmayer/coverband/main/docs/assets/logo/heads.svg?sanitize=true" width='300'>
2
2
 
3
3
  # Coverband
4
4
 
5
5
  [![GithubCI](https://github.com/danmayer/coverband/workflows/CI/badge.svg)](https://github.com/danmayer/coverband/actions)
6
- [![Coverage Status](https://coveralls.io/repos/github/danmayer/coverband/badge.svg?branch=master)](https://coveralls.io/github/danmayer/coverband?branch=master)
6
+ [![Coverage Status](https://coveralls.io/repos/github/danmayer/coverband/badge.svg?branch=main)](https://coveralls.io/github/danmayer/coverband?branch=main)
7
7
  [![Maintainability](https://api.codeclimate.com/v1/badges/1e6682f9540d75f26da7/maintainability)](https://codeclimate.com/github/danmayer/coverband/maintainability)
8
8
  [![Discord Shield](https://img.shields.io/discord/609509533999562753)](https://discord.gg/KAH38EV)
9
9
 
@@ -45,6 +45,28 @@ Coverband stores coverage data in Redis. The Redis endpoint is looked for in thi
45
45
 
46
46
  The redis store can also be explicitly defined within the `config/coverband.rb`. See [advanced config](#advanced-config).
47
47
 
48
+ ### Redis with TLS
49
+
50
+ For Redis servers that require TLS (such as AWS serverless ElastiCache), use the `rediss://` URL scheme instead of `redis://`. The Redis gem automatically enables TLS when it detects this scheme:
51
+
52
+ ```bash
53
+ # Example with AWS serverless ElastiCache
54
+ REDIS_URL=rediss://my-elasticache.abcdef.cache.amazonaws.com:6379
55
+ ```
56
+
57
+ Or in `config/coverband.rb`:
58
+
59
+ ```ruby
60
+ config.store = Coverband::Adapters::RedisStore.new(
61
+ Redis.new(url: "rediss://my-elasticache-endpoint:6379")
62
+ )
63
+ ```
64
+
65
+ The `rediss://` scheme works with:
66
+ - AWS ElastiCache (serverless or provisioned with required TLS)
67
+ - Any self-hosted Redis with TLS enabled
68
+ - Other managed Redis services that require TLS
69
+
48
70
  ## Gem Installation
49
71
 
50
72
  Add this line to your application's `Gemfile`, remember to `bundle install` after updating:
@@ -57,7 +79,7 @@ gem 'coverband'
57
79
 
58
80
  With older versions of Coverband, projects would report to redis using rack or sidekiq middleware. After Coverband 4.0, this should no longer be required and could cause performance issues. Reporting to redis is now automatically done within a background thread with no custom code needed.
59
81
 
60
- See [changelog](https://github.com/danmayer/coverband/blob/master/changes.md).
82
+ See [changelog](https://github.com/danmayer/coverband/blob/main/changes.md).
61
83
 
62
84
  ## Rails
63
85
 
@@ -77,10 +99,12 @@ run ActionController::Dispatcher.new
77
99
 
78
100
  ## Coverband Web UI
79
101
 
80
- ![image](https://raw.github.com/danmayer/coverband/master/docs/coverband_web_ui.png)
102
+ ![image](https://raw.github.com/danmayer/coverband/main/docs/coverband_web_ui.png)
81
103
 
82
104
  > You can check it out locally by running the [Coverband Demo App](https://github.com/danmayer/coverband_rails_example).
83
105
 
106
+ - View a shared demo at [https://coverband-rails-example.onrender.com/](https://coverband-rails-example.onrender.com/)
107
+
84
108
  - View overall coverage information
85
109
 
86
110
  - Drill into individual file coverage
@@ -208,7 +232,7 @@ Take Coverband for a spin on the live Heroku deployed [Coverband Demo](https://c
208
232
 
209
233
  If you need to configure Coverband, this can be done by creating a `config/coverband.rb` file relative to your project root.
210
234
 
211
- - See [lib/coverband/configuration.rb](https://github.com/danmayer/coverband/blob/master/lib/coverband/configuration.rb) for all options
235
+ - See [lib/coverband/configuration.rb](https://github.com/danmayer/coverband/blob/main/lib/coverband/configuration.rb) for all options
212
236
  - By default Coverband will try to store data to Redis \* Redis endpoint is looked for in this order: `ENV['COVERBAND_REDIS_URL']`, `ENV['REDIS_URL']`, or `localhost`
213
237
 
214
238
  Below is an example config file for a Rails 5 app:
@@ -269,7 +293,21 @@ This feature is enabled by default. To stop this feature, disable the feature in
269
293
 
270
294
  `config.track_views = false`
271
295
 
272
- ![image](https://raw.github.com/danmayer/coverband/master/docs/coverband_view_tracker.png)
296
+ #### ViewComponent Support
297
+
298
+ Coverband can also track [ViewComponent](https://viewcomponent.org/) renders. This requires:
299
+
300
+ - ViewComponent **>= 4.6.0**
301
+ - Instrumentation enabled in your app config:
302
+
303
+ ```ruby
304
+ # config/application.rb (or environment-specific config)
305
+ config.view_component.instrumentation_enabled = true
306
+ ```
307
+
308
+ When enabled, Coverband subscribes to the `render.view_component` notification and tracks which component templates are rendered, just like standard Rails views.
309
+
310
+ ![image](https://raw.github.com/danmayer/coverband/main/docs/coverband_view_tracker.png)
273
311
 
274
312
  ### Hiding settings
275
313
 
@@ -312,6 +350,8 @@ Add a wiggle (in seconds) to the background thread to avoid all your servers rep
312
350
 
313
351
  `config.reporting_wiggle = 30`
314
352
 
353
+ The default 30-second wiggle is suitable for most deployments. For larger fleets you may want to increase the wiggle proportionally (e.g. 1 second per server). The tradeoff is a small delay in coverage reporting freshness. The `reporting_wiggle` and `background_reporting_sleep_seconds` options work together — consider configuring both when tuning background reporting for your environment.
354
+
315
355
  Another way to avoid cache stampede is to omit some reporting on starting servers. Coverband stores the results of eager_loading to Redis at server startup. The eager_loading results are the same for all servers, so there is no need to save all results. By configuring the eager_loading results of some servers to be stored in Redis, we can reduce the load on Redis during deployment.
316
356
 
317
357
  ```ruby
@@ -323,11 +363,21 @@ config.send_deferred_eager_loading_data = rand(100) < 5
323
363
  config.send_deferred_eager_loading_data = ENV.fetch('ENABLE_EAGER_LOADING_COVERAGE', false)
324
364
  ```
325
365
 
366
+ Note: `defer_eager_loading_data = true` is required in order to use `send_deferred_eager_loading_data`. With both set, eager_loading data is deferred at startup and only sent by the subset of servers where `send_deferred_eager_loading_data` evaluates to `true`.
367
+
368
+ ### Oneshot Mode
369
+
370
+ Enabling [oneshot mode](https://docs.ruby-lang.org/en/master/Coverage.html#module-coverage-oneshot-lines-coverage) reduces the Ruby CPU overhead. The tradeoff is that you no longer get frequency data — you will only know whether a line was executed at least once, not how many times.
371
+
372
+ ```ruby
373
+ config.use_oneshot_lines_coverage = true
374
+ ```
375
+
326
376
  ### Redis Hash Store
327
377
 
328
378
  Coverband on very high volume sites with many server processes reporting can have a race condition which can cause hit counts to be inaccurate. To resolve the race condition and reduce Ruby memory overhead we have introduced a new Redis storage option. This moves the some of the work from the Ruby processes to Redis. It is worth noting because of this, it has larger demands on the Redis server. So adjust your Redis instance accordingly. To help reduce the extra redis load you can also change the background reporting frequency.
329
379
 
330
- - Use a dedicated Coverband redis instance: `config.store = Coverband::Adapters::HashRedisStore.new(Redis.new(url: redis_url))`
380
+ - To use Redis Hash Store: `config.store = Coverband::Adapters::HashRedisStore.new(Redis.new(url: redis_url))`
331
381
  - Adjust from default 30s reporting `config.background_reporting_sleep_seconds = 120`
332
382
 
333
383
  See more discussion [here](https://github.com/danmayer/coverband/issues/384).
@@ -400,10 +450,20 @@ gem 'coverband', require: ['alternative_coverband_patch']
400
450
 
401
451
  This conflict happens when a ruby method is patched twice, once using module prepend, and once using method aliasing. See this ruby issue for details. The fix is to apply all patches the same way. By default, Coverband will apply its patch using prepend, but you can change that to method aliasing by adding require: ['alternative_coverband_patch'] to the gem line as shown above.
402
452
 
403
- ### Redis Sizing Info
453
+ ### Redis Sizing & Configuration Info
404
454
 
405
455
  A few folks have asked about what size of Redis is needed to run Coverband. I have some of our largest services with hundreds of servers on cache.m3.medium with plenty of room to spare. I run most apps on the smallest AWS Redis instances available and bump up only if needed or if I am forced to be on a shared Redis instance, which I try to avoid. On Heroku, I have used it with most of the 3rd party and also been fine on the smallest Redis instances, if you have hundreds of dynos you would likely need to scale up. Also note there is a tradeoff one can make, `Coverband::Adapters::HashRedisStore` will use LUA on Redis and increase the Redis load, while being nicer to your app servers and avoid potential lost data during race conditions. While the `Coverband::Adapters::RedisStore` uses in app memory and merging and has lower load on Redis.
406
456
 
457
+ For a dedicated Coverband Redis instance, `allkeys-lfu` is a good choice for `maxmemory-policy` as it evicts the least-frequently-used keys first, meaning rarely-hit files are evicted before frequently-reported ones. On a shared Redis instance, be cautious with eviction policies that could interfere with other data. See [issue #595](https://github.com/danmayer/coverband/issues/595) for more discussion.
458
+
459
+ ### Ruby Overhead Reduction Checklist
460
+
461
+ If Coverband is adding meaningful latency to your application, work through this checklist:
462
+
463
+ * Enable oneshot mode (`config.use_oneshot_lines_coverage = true`) — reduces CPU overhead by only tracking whether a line ran, not how many times
464
+ * Enable Redis Hash Store — moves merging work from Ruby processes to Redis, reducing per-process memory overhead
465
+ * Only enable Coverband on a subset of server instances — requests routed to those servers will be slightly slower, but coverage data will still be gathered. This is most effective with a Least Outstanding Requests load balancing algorithm. Avoid enabling on all servers but only reporting on a subset of requests — loading the coverage module itself has a performance impact regardless of reporting.
466
+
407
467
  # Newer Features
408
468
 
409
469
  ### MCP Server for AI Assistants
data/changes.md CHANGED
@@ -1,3 +1,20 @@
1
+ ### 6.2.1
2
+
3
+ * Fix: Search now includes full filenames hidden by truncation in the web UI; users can search for complete file paths even when displaying truncated versions (#569)
4
+ * Feature: Added support for TLS Redis connections via `rediss://` URL scheme for use with serverless ElastiCache and other TLS-required Redis services (#580)
5
+ * Fix: MCP HTTP handler now properly uses `StreamableHTTPTransport` for full HTTP protocol support including GET, DELETE, and OPTIONS methods (#604)
6
+ * Fix: Suppressed Rails logger stdout pollution in stdio MCP mode to ensure clean JSON-RPC stream for Claude Desktop and other AI clients (#625)
7
+ * Fix: Tracker persistence now gracefully handles non-Redis stores (FileStore, NullStore) without raising errors at process exit (#637)
8
+ * Fix: Resolved Rack 1.6 compatibility issue in web reporter by detecting `Rack::Files` vs `Rack::File` class (#639)
9
+ * Docs: Added TLS Redis setup documentation to README for serverless ElastiCache configurations
10
+
11
+ ### 6.2.0
12
+
13
+ * Fix: Correct `Rack::Static` URL handling in the web reporter by using an empty string URL prefix; thanks @alpaca-tc (#635)
14
+ * Fix: Skip missing files during dead method scans to avoid errors in dynamic or removed-file environments; thanks @doug-hall (#632)
15
+ * Fix: Improve MCP dead method formatting for clearer tool output; thanks @doug-hall (#633)
16
+ * Docs: README updates (including `master` → `main` wording updates); thanks @jjb (#627)
17
+
1
18
  ### 6.1.8
2
19
 
3
20
  * Feature: Added temporal data to GetFileCoverage tool for MCP by @fabienpiette (#626)
@@ -53,6 +53,8 @@ module Coverband
53
53
  end
54
54
 
55
55
  def used_keys
56
+ return {} unless redis_store
57
+
56
58
  redis_store.hgetall(tracker_key)
57
59
  end
58
60
 
@@ -74,6 +76,8 @@ module Coverband
74
76
  end
75
77
 
76
78
  def tracking_since
79
+ return "N/A" unless redis_store
80
+
77
81
  if (tracking_time = redis_store.get(tracker_time_key))
78
82
  Time.at(tracking_time.to_i).iso8601
79
83
  else
@@ -82,18 +86,24 @@ module Coverband
82
86
  end
83
87
 
84
88
  def reset_recordings
89
+ return unless redis_store
90
+
85
91
  redis_store.del(tracker_key)
86
92
  redis_store.del(tracker_time_key)
87
93
  end
88
94
 
89
95
  def clear_key!(key)
90
96
  return unless key
97
+ return unless redis_store
98
+
91
99
  puts "#{tracker_key} key #{key}"
92
100
  redis_store.hdel(tracker_key, key)
93
101
  @logged_keys.delete(key)
94
102
  end
95
103
 
96
104
  def save_report
105
+ return unless redis_store
106
+
97
107
  redis_store.set(tracker_time_key, Time.now.to_i) unless @one_time_timestamp || tracker_time_key_exists?
98
108
  @one_time_timestamp = true
99
109
  reported_time = Time.now.to_i
@@ -138,10 +148,16 @@ module Coverband
138
148
  end
139
149
 
140
150
  def redis_store
141
- store.raw_store
151
+ @redis_store ||= begin
152
+ store.raw_store
153
+ rescue NotImplementedError
154
+ nil
155
+ end
142
156
  end
143
157
 
144
158
  def tracker_time_key_exists?
159
+ return false unless redis_store
160
+
145
161
  if defined?(redis_store.exists?)
146
162
  redis_store.exists?(tracker_time_key)
147
163
  else
@@ -33,6 +33,13 @@ module Coverband
33
33
  ActiveSupport::Notifications.subscribe(/render_(template|partial|collection).action_view/) do |name, start, finish, id, payload|
34
34
  Coverband.configuration.view_tracker.track_key(payload) unless name.include?("!")
35
35
  end
36
+
37
+ # ViewComponent >= 4.6.0 emits render.view_component with a view_identifier key
38
+ # containing the template path. Requires config.view_component.instrumentation_enabled = true
39
+ # in the host app.
40
+ ActiveSupport::Notifications.subscribe("render.view_component") do |name, start, finish, id, payload|
41
+ Coverband.configuration.view_tracker.track_key(identifier: payload[:view_identifier]) unless name.include?("!")
42
+ end
36
43
  end
37
44
 
38
45
  ###
@@ -61,6 +68,8 @@ module Coverband
61
68
  end
62
69
 
63
70
  def used_keys
71
+ return {} unless redis_store
72
+
64
73
  views = redis_store.hgetall(tracker_key)
65
74
  normalized_views = {}
66
75
  views.each_pair do |view, time|
@@ -93,6 +102,7 @@ module Coverband
93
102
 
94
103
  def clear_key!(filename)
95
104
  return unless filename
105
+ return unless redis_store
96
106
 
97
107
  filename = "#{@project_directory}/#{filename}"
98
108
  redis_store.hdel(tracker_key, filename)
@@ -126,7 +136,7 @@ module Coverband
126
136
 
127
137
  def concrete_target
128
138
  if defined?(Rails.application)
129
- Dir.glob("#{@project_directory}/{,packs,engines/*}/app/{views,components}/**/*.html.{erb,haml,slim}")
139
+ Dir.glob("#{@project_directory}/{,packs/*,engines/*}/app/{views,components}/**/*.html.{erb,haml,slim}")
130
140
  else
131
141
  []
132
142
  end
@@ -213,6 +213,9 @@ module Coverband
213
213
  Coverband::Adapters::WebServiceStore.new(service_url)
214
214
  else
215
215
  begin
216
+ # Redis gem automatically enables TLS when the scheme is 'rediss://'
217
+ # For serverless ElastiCache or other TLS-required Redis instances, use:
218
+ # REDIS_URL=rediss://your-endpoint:6379
216
219
  Coverband::Adapters::RedisStore.new(Redis.new(url: redis_url), redis_store_options)
217
220
  rescue Redis::CannotConnectError => error
218
221
  logger.info "Redis is not available (#{error}), defaulting to NullStore"
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Coverband
4
4
  module MCP
5
- # Rack middleware that adds MCP HTTP endpoint support.
5
+ # Rack middleware that adds MCP HTTP endpoint support using StreamableHTTPTransport.
6
6
  # Can be used to wrap the existing Coverband::Reporters::Web app
7
7
  # or mounted standalone.
8
8
  #
@@ -10,7 +10,7 @@ module Coverband
10
10
  # map "/coverage" do
11
11
  # run Coverband::MCP::HttpHandler.new(Coverband::Reporters::Web.new)
12
12
  # end
13
- # # MCP endpoint available at POST /coverage/mcp
13
+ # # MCP endpoint available at /coverage/mcp with full Streamable HTTP transport support
14
14
  #
15
15
  # Usage standalone:
16
16
  # map "/mcp" do
@@ -23,6 +23,7 @@ module Coverband
23
23
  def initialize(app = nil)
24
24
  @app = app
25
25
  @server = nil
26
+ @transport = nil
26
27
  end
27
28
 
28
29
  def call(env)
@@ -40,17 +41,22 @@ module Coverband
40
41
  private
41
42
 
42
43
  def mcp_request?(request)
43
- request.post? && request.path_info.end_with?(MCP_PATH)
44
+ # Accept GET, POST, DELETE, OPTIONS for StreamableHTTPTransport protocol
45
+ %w[GET POST DELETE OPTIONS].include?(request.request_method) &&
46
+ request.path_info.end_with?(MCP_PATH)
44
47
  end
45
48
 
46
49
  def handle_mcp_request(request)
50
+ # Handle CORS preflight
51
+ return cors_preflight_response if request.request_method == "OPTIONS"
52
+
47
53
  # Check authentication if MCP password is configured
48
54
  unless authenticate_mcp_request(request)
49
55
  return [401, {
50
56
  "Content-Type" => "application/json",
51
57
  "Access-Control-Allow-Origin" => "*",
52
- "Access-Control-Allow-Methods" => "POST, OPTIONS",
53
- "Access-Control-Allow-Headers" => "Content-Type, Authorization",
58
+ "Access-Control-Allow-Methods" => "GET, POST, DELETE, OPTIONS",
59
+ "Access-Control-Allow-Headers" => "Content-Type, Authorization, Accept, Mcp-Session-Id",
54
60
  "WWW-Authenticate" => 'Bearer realm="Coverband MCP"'
55
61
  }, [JSON.generate({
56
62
  "error" => "Authentication required",
@@ -58,29 +64,21 @@ module Coverband
58
64
  })]]
59
65
  end
60
66
 
61
- body = request.body.read
62
- json_request = JSON.parse(body)
63
- response = mcp_server.handle_json(json_request)
64
-
65
- # response might already be a JSON string, so check before converting
66
- response_body = response.is_a?(String) ? response : response.to_json
67
-
68
- [
69
- 200,
70
- {
71
- "content-type" => "application/json",
72
- "access-control-allow-origin" => "*",
73
- "access-control-allow-methods" => "POST, OPTIONS",
74
- "access-control-allow-headers" => "Content-Type"
75
- },
76
- [response_body]
77
- ]
78
- rescue JSON::ParserError => e
79
- error_response(400, "Invalid JSON: #{e.message}")
67
+ # Delegate to StreamableHTTPTransport which handles the full MCP HTTP protocol
68
+ # (GET for SSE streams, POST for requests/responses, DELETE for cleanup, etc.)
69
+ transport.handle_request(request)
80
70
  rescue => e
81
71
  error_response(500, "Server error: #{e.message}")
82
72
  end
83
73
 
74
+ def cors_preflight_response
75
+ [204, {
76
+ "Access-Control-Allow-Origin" => "*",
77
+ "Access-Control-Allow-Methods" => "GET, POST, DELETE, OPTIONS",
78
+ "Access-Control-Allow-Headers" => "Content-Type, Authorization, Accept, Mcp-Session-Id"
79
+ }, []]
80
+ end
81
+
84
82
  def authenticate_mcp_request(request)
85
83
  # If no MCP password is configured, allow access
86
84
  mcp_password = Coverband.configuration.mcp_password
@@ -98,8 +96,11 @@ module Coverband
98
96
  token == mcp_password
99
97
  end
100
98
 
101
- def mcp_server
102
- @server ||= Server.new
99
+ def transport
100
+ @transport ||= begin
101
+ server = ::Coverband::MCP::Server.new
102
+ ::MCP::Server::Transports::StreamableHTTPTransport.new(server.mcp_server)
103
+ end
103
104
  end
104
105
 
105
106
  def error_response(status, message)
@@ -28,21 +28,21 @@ module Coverband
28
28
 
29
29
  if file_pattern
30
30
  dead_methods = dead_methods.select do |method|
31
- File.fnmatch(file_pattern, method[:file_path], File::FNM_PATHNAME)
31
+ File.fnmatch(file_pattern, method.file_path, File::FNM_PATHNAME)
32
32
  end
33
33
  end
34
34
 
35
35
  # Group by file for easier reading
36
- grouped = dead_methods.group_by { |m| m[:file_path] }
36
+ grouped = dead_methods.group_by(&:file_path)
37
37
 
38
38
  result = grouped.map do |file_path, methods|
39
39
  {
40
40
  file: file_path,
41
41
  dead_methods: methods.map do |m|
42
42
  {
43
- class_name: m[:class_name],
44
- method_name: m[:method_name],
45
- line_number: m[:line_number]
43
+ class_name: m.class_name,
44
+ method_name: m.name,
45
+ line_number: m.first_line_number
46
46
  }
47
47
  end
48
48
  }
@@ -32,9 +32,11 @@ module Coverband
32
32
 
33
33
  def init_web
34
34
  full_path = Gem::Specification.find_by_name("coverband").full_gem_path
35
- @static = Rack::Static.new(self,
36
- root: File.expand_path("public", full_path),
37
- urls: [/.*\.css/, /.*\.js/, /.*\.gif/, /.*\.png/])
35
+ # Rack::Files was introduced in Rack 2.0; Rack 1.x uses Rack::File
36
+ rack_file_server = defined?(Rack::Files) ? Rack::Files : Rack::File
37
+ @file_server = rack_file_server.new(
38
+ File.expand_path("public", full_path)
39
+ )
38
40
  end
39
41
 
40
42
  def check_auth
@@ -85,7 +87,7 @@ module Coverband
85
87
  else
86
88
  case request_path_info
87
89
  when /.*\.(css|js|gif|png)/
88
- @static.call(env)
90
+ @file_server.get(env)
89
91
  when %r{/settings}
90
92
  [200, coverband_headers, [settings]]
91
93
  when %r{/view_tracker_data}
@@ -7,6 +7,7 @@
7
7
  # Coverband.configure do |config|
8
8
  ####
9
9
  # set a redis URL and set it with with some reasonable timeouts
10
+ # For TLS connections (e.g. AWS serverless ElastiCache), use rediss:// scheme
10
11
  ####
11
12
  # redis_url = ENV["COVERBAND_REDIS"] || ENV["REDIS_URL"] || "redis://localhost:6379"
12
13
  # config.store = Coverband::Adapters::RedisStore.new(
@@ -18,6 +19,9 @@
18
19
  # reconnect_delay_max: ENV.fetch("REDIS_RECONNECT_DELAY_MAX", 2.5)
19
20
  # )
20
21
  # )
22
+ #
23
+ # Example with TLS (rediss:// scheme automatically enables TLS):
24
+ # redis_url = "rediss://my-elasticache.abcdef.cache.amazonaws.com:6379"
21
25
 
22
26
  # Allow folks to reset the coverband data via the web UI
23
27
  # config.web_enable_clear = true
@@ -43,7 +43,11 @@ module Coverband
43
43
  # and runtime phases.
44
44
  coverage = Coverband.configuration.store.get_coverage_report[Coverband::MERGED_TYPE]
45
45
  coverage.flat_map do |file_path, coverage|
46
+ next [] unless File.exist?(file_path)
47
+
46
48
  scan(file_path: file_path, coverage: coverage["data"])
49
+ rescue Errno::ENOENT
50
+ []
47
51
  end
48
52
  end
49
53
 
@@ -11,6 +11,7 @@ module Coverband
11
11
  module Utils
12
12
  class FileList < Array
13
13
  # Returns the count of lines that have coverage
14
+ # Using sum avoids intermediate array allocation compared to map.inject
14
15
  def covered_lines
15
16
  return 0.0 if empty?
16
17
 
@@ -180,7 +180,10 @@ module Coverband
180
180
 
181
181
  def link_to_source_file(source_file)
182
182
  data_loader_url = "#{base_path}load_file_details?filename=#{source_file.filename}"
183
- %(<a href="##{id source_file}" class="src_link" title="#{shortened_filename source_file}" data-loader-url="#{data_loader_url}" onclick="src_link_click(this)">#{truncate(shortened_filename(source_file))}</a>)
183
+ full_filename = shortened_filename(source_file)
184
+ truncated_filename = truncate(full_filename)
185
+ # Include full filename in a hidden span so DataTables search can find it even when truncated
186
+ %(<a href="##{id source_file}" class="src_link" title="#{full_filename}" data-loader-url="#{data_loader_url}" onclick="src_link_click(this)">#{truncated_filename}<span style="display:none;">#{full_filename}</span></a>)
184
187
  end
185
188
 
186
189
  def truncate(text, length: 50)
@@ -128,10 +128,28 @@ namespace :coverband do
128
128
 
129
129
  desc "Start MCP server for AI assistant integration (set COVERBAND_MCP_HTTP=true for HTTP mode)"
130
130
  task :mcp do
131
+ # In stdio mode, we must suppress all non-JSON-RPC output to stdout to comply with
132
+ # the MCP stdio transport spec. Otherwise, Rails logger and gem output will pollute
133
+ # the JSON-RPC stream and break clients like Claude Desktop.
134
+ # See https://github.com/danmayer/coverband/issues/625
135
+ use_stdio_mode = !ENV["COVERBAND_MCP_HTTP"]
136
+
137
+ original_stdout = nil
138
+ if use_stdio_mode
139
+ # Save original stdout and redirect stdout to stderr temporarily
140
+ original_stdout = $stdout
141
+ $stdout = $stderr
142
+ end
143
+
131
144
  if Rake::Task.task_defined?("environment")
132
145
  Rake.application["environment"].invoke
133
146
  end
134
147
 
148
+ if use_stdio_mode
149
+ # Restore stdout for JSON-RPC communication over stdio
150
+ $stdout = original_stdout
151
+ end
152
+
135
153
  begin
136
154
  require "coverband/mcp"
137
155
  rescue LoadError
@@ -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.8"
8
+ VERSION = "6.2.1"
9
9
  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
@@ -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")
@@ -10,6 +10,16 @@ end
10
10
 
11
11
  if defined?(Coverband::MCP)
12
12
  class GetDeadMethodsTest < Minitest::Test
13
+ def build_dead_method(file_path:, class_name:, method_name:, line_number:)
14
+ Coverband::Utils::MethodDefinitionScanner::MethodDefinition.new(
15
+ first_line_number: line_number,
16
+ last_line_number: line_number,
17
+ name: method_name,
18
+ class_name: class_name,
19
+ file_path: file_path
20
+ )
21
+ end
22
+
13
23
  def setup
14
24
  super
15
25
  Coverband.configure do |config|
@@ -35,24 +45,24 @@ if defined?(Coverband::MCP)
35
45
  if defined?(RubyVM::AbstractSyntaxTree)
36
46
  test "call returns dead methods when AST support available" do
37
47
  mock_dead_methods = [
38
- {
48
+ build_dead_method(
39
49
  file_path: "/app/models/user.rb",
40
50
  class_name: "User",
41
51
  method_name: "unused_method",
42
52
  line_number: 10
43
- },
44
- {
53
+ ),
54
+ build_dead_method(
45
55
  file_path: "/app/models/user.rb",
46
56
  class_name: "User",
47
57
  method_name: "another_unused",
48
58
  line_number: 15
49
- },
50
- {
59
+ ),
60
+ build_dead_method(
51
61
  file_path: "/app/models/order.rb",
52
62
  class_name: "Order",
53
63
  method_name: "dead_method",
54
64
  line_number: 20
55
- }
65
+ )
56
66
  ]
57
67
 
58
68
  Coverband::Utils::DeadMethods.expects(:scan_all).returns(mock_dead_methods)
@@ -83,18 +93,18 @@ if defined?(Coverband::MCP)
83
93
 
84
94
  test "call filters by file_pattern when provided" do
85
95
  mock_dead_methods = [
86
- {
96
+ build_dead_method(
87
97
  file_path: "/app/models/user.rb",
88
98
  class_name: "User",
89
99
  method_name: "unused_method",
90
100
  line_number: 10
91
- },
92
- {
101
+ ),
102
+ build_dead_method(
93
103
  file_path: "/app/helpers/user_helper.rb",
94
104
  class_name: "UserHelper",
95
105
  method_name: "dead_helper",
96
106
  line_number: 5
97
- }
107
+ )
98
108
  ]
99
109
 
100
110
  Coverband::Utils::DeadMethods.expects(:scan_all).returns(mock_dead_methods)
@@ -45,6 +45,16 @@ module Coverband
45
45
  get "/json?line_coverage=true"
46
46
  assert last_response.ok?
47
47
  end
48
+
49
+ test "renders static files" do
50
+ get "/application.js"
51
+ assert last_response.ok?
52
+ end
53
+
54
+ test "renders 404 if static file doesn't exist" do
55
+ get "/unknown.js"
56
+ assert last_response.not_found?
57
+ end
48
58
  end
49
59
  end
50
60
 
@@ -48,6 +48,26 @@ if defined?(RubyVM::AbstractSyntaxTree)
48
48
  assert_equal(6, dead_method.last_line_number)
49
49
  end
50
50
 
51
+ def test_scan_all_skips_missing_files
52
+ missing_file = "./test/fixtures/missing_file.rb"
53
+ existing_file = "./test/dog.rb"
54
+ coverage_report = {
55
+ Coverband::MERGED_TYPE => {
56
+ missing_file => {"data" => []},
57
+ existing_file => {"data" => []}
58
+ }
59
+ }
60
+ dead_method = Object.new
61
+
62
+ Coverband.configuration.stubs(:store).returns(
63
+ stub(get_coverage_report: coverage_report)
64
+ )
65
+ DeadMethods.expects(:scan).with(file_path: existing_file, coverage: []).returns([dead_method])
66
+ DeadMethods.expects(:scan).with(file_path: missing_file, coverage: []).never
67
+
68
+ assert_equal [dead_method], DeadMethods.scan_all
69
+ end
70
+
51
71
  def test_output_all
52
72
  require_unique_file
53
73
  @coverband.report_coverage
@@ -84,9 +84,9 @@ if defined?(Coverband::MCP)
84
84
  test "HTTP handler integrates with MCP server" do
85
85
  handler = Coverband::MCP::HttpHandler.new
86
86
 
87
- # Verify handler can create and use MCP server
88
- server = handler.send(:mcp_server)
89
- assert_instance_of Coverband::MCP::Server, server
87
+ # Verify handler can create and use transport with MCP server
88
+ transport = handler.send(:transport)
89
+ assert_instance_of ::MCP::Server::Transports::StreamableHTTPTransport, transport
90
90
 
91
91
  # Verify handler responds to rack interface
92
92
  assert_respond_to handler, :call
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coverband
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.1.8
4
+ version: 6.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dan Mayer
@@ -446,6 +446,7 @@ files:
446
446
  - test/benchmarks/benchmark.rake
447
447
  - test/benchmarks/benchmark_delta.rb
448
448
  - test/benchmarks/benchmark_file_list.rb
449
+ - test/benchmarks/benchmark_file_list_covered_lines.rb
449
450
  - test/benchmarks/benchmark_unused_keys.rb
450
451
  - test/benchmarks/coverage_fork.sh
451
452
  - test/benchmarks/dog.rb