coverband 6.2.0 → 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 +4 -4
- data/README.md +60 -2
- data/changes.md +10 -0
- data/lib/coverband/collectors/abstract_tracker.rb +17 -1
- data/lib/coverband/collectors/view_tracker.rb +11 -1
- data/lib/coverband/configuration.rb +3 -0
- data/lib/coverband/mcp/http_handler.rb +27 -26
- data/lib/coverband/reporters/web.rb +3 -1
- data/lib/coverband/utils/configuration_template.rb +4 -0
- data/lib/coverband/utils/file_list.rb +1 -0
- data/lib/coverband/utils/html_formatter.rb +4 -1
- data/lib/coverband/utils/tasks.rb +18 -0
- data/lib/coverband/version.rb +1 -1
- data/test/benchmarks/benchmark_file_list_covered_lines.rb +54 -0
- data/test/coverband/collectors/view_tracker_test.rb +27 -0
- data/test/coverband/configuration_test.rb +12 -0
- data/test/coverband/mcp/http_handler_test.rb +58 -46
- data/test/integration/mcp_integration_test.rb +3 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c47e38934426a369c81dd7d08e5474ad0d8a784b541975c1aa7ee50fc9e2d1ce
|
|
4
|
+
data.tar.gz: 346c8263e9c961631bcf4af3019d8473177088c5b9ae3cb3844dc7d86bcd7497
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 907a280ce513b23922b81c44674c135ab067e629ae9f8aaec6eb6a667946a52a85fd1e7a0fe17ee9b24c0a6b81b8b22bcb1e1c53e7ce75ccc7a9c3507b6dd183
|
|
7
|
+
data.tar.gz: 60198f0536094c5505ea532b238d9d38359f936edb6ea21854c946d3a5f63e7e02b66f1240596b787cad898546a3d48d41595d5d4cab28bb09face2a4f405499
|
data/README.md
CHANGED
|
@@ -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:
|
|
@@ -271,6 +293,20 @@ This feature is enabled by default. To stop this feature, disable the feature in
|
|
|
271
293
|
|
|
272
294
|
`config.track_views = false`
|
|
273
295
|
|
|
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
|
+
|
|
274
310
|

|
|
275
311
|
|
|
276
312
|
### Hiding settings
|
|
@@ -314,6 +350,8 @@ Add a wiggle (in seconds) to the background thread to avoid all your servers rep
|
|
|
314
350
|
|
|
315
351
|
`config.reporting_wiggle = 30`
|
|
316
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
|
+
|
|
317
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.
|
|
318
356
|
|
|
319
357
|
```ruby
|
|
@@ -325,11 +363,21 @@ config.send_deferred_eager_loading_data = rand(100) < 5
|
|
|
325
363
|
config.send_deferred_eager_loading_data = ENV.fetch('ENABLE_EAGER_LOADING_COVERAGE', false)
|
|
326
364
|
```
|
|
327
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
|
+
|
|
328
376
|
### Redis Hash Store
|
|
329
377
|
|
|
330
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.
|
|
331
379
|
|
|
332
|
-
-
|
|
380
|
+
- To use Redis Hash Store: `config.store = Coverband::Adapters::HashRedisStore.new(Redis.new(url: redis_url))`
|
|
333
381
|
- Adjust from default 30s reporting `config.background_reporting_sleep_seconds = 120`
|
|
334
382
|
|
|
335
383
|
See more discussion [here](https://github.com/danmayer/coverband/issues/384).
|
|
@@ -402,10 +450,20 @@ gem 'coverband', require: ['alternative_coverband_patch']
|
|
|
402
450
|
|
|
403
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.
|
|
404
452
|
|
|
405
|
-
### Redis Sizing Info
|
|
453
|
+
### Redis Sizing & Configuration Info
|
|
406
454
|
|
|
407
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.
|
|
408
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
|
+
|
|
409
467
|
# Newer Features
|
|
410
468
|
|
|
411
469
|
### MCP Server for AI Assistants
|
data/changes.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
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
|
+
|
|
1
11
|
### 6.2.0
|
|
2
12
|
|
|
3
13
|
* Fix: Correct `Rack::Static` URL handling in the web reporter by using an empty string URL prefix; thanks @alpaca-tc (#635)
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
102
|
-
@
|
|
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)
|
|
@@ -32,7 +32,9 @@ module Coverband
|
|
|
32
32
|
|
|
33
33
|
def init_web
|
|
34
34
|
full_path = Gem::Specification.find_by_name("coverband").full_gem_path
|
|
35
|
-
|
|
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(
|
|
36
38
|
File.expand_path("public", full_path)
|
|
37
39
|
)
|
|
38
40
|
end
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
data/lib/coverband/version.rb
CHANGED
|
@@ -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
|
|
42
|
-
|
|
43
|
-
|
|
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(:
|
|
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
|
-
|
|
59
|
+
end
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 "
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
97
|
+
handler = Coverband::MCP::HttpHandler.new
|
|
98
|
+
handler.expects(:transport).at_least(3).returns(transport_mock)
|
|
89
99
|
|
|
90
|
-
|
|
91
|
-
post "/mcp", "invalid json", {"CONTENT_TYPE" => "application/json"}
|
|
100
|
+
@app = handler
|
|
92
101
|
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
# POST request
|
|
103
|
+
post "/mcp", "{}", {"CONTENT_TYPE" => "application/json"}
|
|
104
|
+
assert_equal 200, last_response.status
|
|
95
105
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
106
|
+
# GET request
|
|
107
|
+
get "/mcp", {}, {"ACCEPT" => "text/event-stream"}
|
|
108
|
+
assert_equal 200, last_response.status
|
|
99
109
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
111
|
-
post "/mcp", json_request, {"CONTENT_TYPE" => "application/json"}
|
|
112
|
-
|
|
113
|
-
assert_equal 500, last_response.status
|
|
119
|
+
options "/mcp"
|
|
114
120
|
|
|
115
|
-
|
|
116
|
-
|
|
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 "
|
|
120
|
-
|
|
126
|
+
test "delegates non-MCP requests to wrapped app for non-POST" do
|
|
127
|
+
@app = app_with_wrapped_handler
|
|
121
128
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
129
|
+
get "/other-path"
|
|
130
|
+
assert_equal 200, last_response.status
|
|
131
|
+
assert_equal "wrapped app response", last_response.body
|
|
125
132
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
#
|
|
140
|
-
env = Rack::MockRequest.env_for("/
|
|
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
|
-
#
|
|
145
|
-
env = Rack::MockRequest.env_for("/mcp", method: "
|
|
151
|
+
# DELETE request to /mcp
|
|
152
|
+
env = Rack::MockRequest.env_for("/mcp", method: "DELETE")
|
|
146
153
|
request = Rack::Request.new(env)
|
|
147
|
-
|
|
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")
|
|
@@ -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
|
-
|
|
89
|
-
assert_instance_of
|
|
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.2.
|
|
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
|