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.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +10 -2
- data/.gitignore +2 -1
- data/README.md +119 -2
- data/Rakefile +9 -1
- data/changes.md +16 -0
- data/lib/coverband/collectors/abstract_tracker.rb +17 -1
- data/lib/coverband/collectors/delta.rb +21 -7
- data/lib/coverband/collectors/query_burst_tracker.rb +268 -0
- data/lib/coverband/collectors/view_tracker.rb +11 -1
- data/lib/coverband/configuration.rb +14 -0
- data/lib/coverband/mcp/http_handler.rb +27 -26
- data/lib/coverband/reporters/web.rb +28 -2
- 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 +7 -2
- data/lib/coverband/utils/tasks.rb +18 -0
- data/lib/coverband/version.rb +1 -1
- data/lib/coverband.rb +3 -2
- data/public/application.css +27 -0
- data/public/application.js +246 -0
- data/public/dead_code_session.js +173 -0
- data/test/benchmarks/benchmark.rake +18 -10
- data/test/benchmarks/benchmark_file_list_covered_lines.rb +54 -0
- data/test/coverband/benchmarks/benchmark_memory_leak_check_test.rb +43 -0
- data/test/coverband/collectors/delta_test.rb +17 -0
- data/test/coverband/collectors/query_burst_tracker_test.rb +77 -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/coverband/reporters/web_test.rb +67 -0
- data/test/coverband/track_key_test.rb +9 -0
- data/test/forked/rails_route_tracker_stack_test.rb +1 -1
- data/test/forked/rails_view_tracker_stack_test.rb +1 -1
- data/test/integration/mcp_integration_test.rb +3 -3
- data/test/javascript/dead_code_session.test.mjs +123 -0
- data/views/abstract_tracker.erb +49 -2
- data/views/layout.erb +1 -0
- data/views/nav.erb +5 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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")
|