coverband 6.2.1 → 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.
@@ -33,8 +33,14 @@ $(document).ready(function () {
33
33
  ],
34
34
  }
35
35
 
36
+ tableOptions.fnDrawCallback = function (oSettings) {
37
+ if (window.deadCodeOnDraw) window.deadCodeOnDraw(oSettings);
38
+ };
39
+
36
40
  $(".file_list").dataTable(tableOptions);
37
41
 
42
+ initDeadCodeSession();
43
+
38
44
  // TODO: add support for searching on server side
39
45
  // best docs on our version of datatables 1.7 https://datatables.net/beta/1.7/examples/server_side/server_side.html
40
46
  if ($(".file_list.unsorted").length == 1) {
@@ -220,4 +226,244 @@ $(document).ready(function () {
220
226
  $("#loading").fadeOut();
221
227
  $("#wrapper").show();
222
228
  $(".dataTables_filter input").focus();
229
+
230
+ function initDeadCodeSession() {
231
+ if (!window.CoverbandDeadCode || !$(".file_list").length) return;
232
+ var D = window.CoverbandDeadCode;
233
+ var storageKey = "coverband_deadcode_session:" + ($(".file_list").data("coverageurl") || "/");
234
+ var state;
235
+ try {
236
+ state = D.parseState(window.localStorage.getItem(storageKey));
237
+ } catch (e) {
238
+ console && console.warn && console.warn("coverband dead-code session: localStorage unavailable", e);
239
+ state = D.emptyState();
240
+ }
241
+ if (!state.startedAt) state.startedAt = new Date().toISOString();
242
+
243
+ function saveState() {
244
+ try {
245
+ window.localStorage.setItem(storageKey, JSON.stringify(state));
246
+ } catch (e) { /* private mode etc.; session still works in-memory */ }
247
+ }
248
+ saveState();
249
+
250
+ var extractPath = D.extractPath;
251
+ var rowRuntime = D.rowRuntime;
252
+
253
+ // Custom filter: hide rows excluded by the session (DataTables 1.7 API)
254
+ $.fn.dataTableExt.afnFiltering.push(function (oSettings, aData, iDataIndex) {
255
+ if (!$(oSettings.nTable).hasClass("file_list")) return true;
256
+ return !D.isExcluded(state, extractPath(aData[0]), rowRuntime(aData));
257
+ });
258
+
259
+ function redrawTables() {
260
+ $(".file_list").each(function () {
261
+ $(this).dataTable().fnDraw();
262
+ });
263
+ updateBadges();
264
+ }
265
+
266
+ function updateBadges() {
267
+ var hidden = 0;
268
+ $(".file_list").each(function () {
269
+ var data = $(this).dataTable().fnGetData();
270
+ for (var i = 0; i < data.length; i++) {
271
+ if (D.isExcludedIgnoringMarks(state, extractPath(data[i][0]), rowRuntime(data[i]))) hidden++;
272
+ }
273
+ });
274
+ $("#dcs-hidden-count").text(hidden + " hidden");
275
+ $("#dcs-marked-count").text(state.markedPaths.length);
276
+ var chips = $("#dcs-chips").empty();
277
+ $.each(state.hiddenPaths, function (_i, entry) {
278
+ var chip = $('<span class="dcs-chip"></span>').text(entry);
279
+ $('<button class="dcs-chip-x" title="Unhide">&times;</button>')
280
+ .appendTo(chip)
281
+ .bind("click", function () {
282
+ D.removeHidden(state, entry);
283
+ saveState();
284
+ redrawTables();
285
+ });
286
+ chips.append(chip);
287
+ });
288
+ chips.toggle(state.hiddenPaths.length > 0 && chips.data("open") === true);
289
+ $("#dcs-chips-toggle").toggle(state.hiddenPaths.length > 0)
290
+ .text("Hidden items (" + state.hiddenPaths.length + ")");
291
+ }
292
+
293
+ // Toolbar
294
+ var toolbar = $(
295
+ '<div id="dcs-toolbar">' +
296
+ ' <strong>Dead-code session</strong>' +
297
+ ' <label><input type="checkbox" id="dcs-runtime-toggle"> Hide files with runtime % &gt; 0</label>' +
298
+ ' <span class="dcs-badge" id="dcs-hidden-count">0 hidden</span>' +
299
+ ' <button type="button" id="dcs-marked-btn">Marked for deletion: <span id="dcs-marked-count">0</span></button>' +
300
+ ' <button type="button" id="dcs-chips-toggle">Hidden items</button>' +
301
+ ' <button type="button" id="dcs-reset">Reset session</button>' +
302
+ ' <div id="dcs-chips"></div>' +
303
+ '</div>'
304
+ );
305
+ $("#content").prepend(toolbar);
306
+
307
+ $("#dcs-runtime-toggle")
308
+ .prop("checked", state.hideRuntimeUsed)
309
+ .bind("change", function () {
310
+ state.hideRuntimeUsed = this.checked;
311
+ saveState();
312
+ redrawTables();
313
+ });
314
+
315
+ $("#dcs-chips-toggle").bind("click", function () {
316
+ var chips = $("#dcs-chips");
317
+ chips.data("open", chips.data("open") !== true);
318
+ updateBadges();
319
+ });
320
+
321
+ $("#dcs-reset").bind("click", function () {
322
+ if (!confirm("Reset session? This unhides everything (marked-for-deletion list is kept).")) return;
323
+ window.CoverbandDeadCode.clearHides(state);
324
+ $("#dcs-runtime-toggle").prop("checked", false);
325
+ saveState();
326
+ redrawTables();
327
+ });
328
+
329
+ // Exposed for Tasks 3-4 and the shared fnDrawCallback
330
+ window.deadCodeSession = {
331
+ D: D, state: state, saveState: saveState,
332
+ redrawTables: redrawTables, updateBadges: updateBadges,
333
+ extractPath: extractPath
334
+ };
335
+ window.deadCodeOnDraw = function (oSettings) {
336
+ if (window.deadCodeDecorate) window.deadCodeDecorate(oSettings);
337
+ updateBadges();
338
+ };
339
+
340
+ var escapeHtml = D.escapeHtml;
341
+
342
+ window.deadCodeDecorate = function (oSettings) {
343
+ $(oSettings.nTable).find("tbody td a.src_link").not(".dcs-done").each(function () {
344
+ var link = $(this);
345
+ var fullPath = link.attr("title");
346
+ if (!fullPath) return;
347
+ var segs = D.segmentPrefixes(fullPath);
348
+ var html = "";
349
+ for (var i = 0; i < segs.length; i++) {
350
+ html += '<span class="dcs-seg" data-prefix="' + escapeHtml(segs[i].prefix) + '">' +
351
+ escapeHtml(segs[i].label) +
352
+ '<span class="dcs-actions">' +
353
+ '<button type="button" class="dcs-hide" title="Hide ' + escapeHtml(segs[i].prefix) + (segs[i].isFile ? "" : " and everything under it") + '">&times;</button>' +
354
+ '<button type="button" class="dcs-mark" title="Mark ' + escapeHtml(segs[i].prefix) + ' for deletion">&#128465;</button>' +
355
+ '</span></span>' + (segs[i].isFile ? "" : "/");
356
+ }
357
+ link.html(html).addClass("dcs-done");
358
+
359
+ link.find(".dcs-hide").bind("click", function (e) {
360
+ e.preventDefault();
361
+ e.stopPropagation();
362
+ D.addHidden(state, $(this).closest(".dcs-seg").attr("data-prefix"));
363
+ saveState();
364
+ redrawTables();
365
+ return false;
366
+ });
367
+ link.find(".dcs-mark").bind("click", function (e) {
368
+ e.preventDefault();
369
+ e.stopPropagation();
370
+ openMarkPopup($(this).closest(".dcs-seg").attr("data-prefix"), e.pageX, e.pageY);
371
+ return false;
372
+ });
373
+ });
374
+ };
375
+
376
+ function closeMarkPopup() { $("#dcs-mark-popup").remove(); }
377
+
378
+ function openMarkPopup(prefix, x, y) {
379
+ closeMarkPopup();
380
+ var popup = $(
381
+ '<div id="dcs-mark-popup">' +
382
+ ' <div class="dcs-popup-path"></div>' +
383
+ ' <textarea id="dcs-mark-comment" rows="2" placeholder="Optional comment (why is this dead?)"></textarea>' +
384
+ ' <div class="dcs-popup-actions">' +
385
+ ' <button type="button" id="dcs-mark-confirm">Mark for deletion</button>' +
386
+ ' <button type="button" id="dcs-mark-cancel">Cancel</button>' +
387
+ ' </div>' +
388
+ '</div>'
389
+ );
390
+ popup.find(".dcs-popup-path").text(prefix);
391
+ popup.css({ left: Math.min(x, $(window).width() - 340) + "px", top: (y + 8) + "px" });
392
+ $("body").append(popup);
393
+ $("#dcs-mark-comment").focus();
394
+ $("#dcs-mark-cancel").bind("click", closeMarkPopup);
395
+ $("#dcs-mark-confirm").bind("click", function () {
396
+ D.addMark(state, prefix, $("#dcs-mark-comment").val(), new Date().toISOString());
397
+ saveState();
398
+ closeMarkPopup();
399
+ redrawTables();
400
+ });
401
+ }
402
+
403
+ function closeMarkedModal() { $("#dcs-modal-overlay").remove(); }
404
+
405
+ function downloadCSV() {
406
+ var blob = new Blob([D.toCSV(state.markedPaths)], { type: "text/csv;charset=utf-8" });
407
+ var a = document.createElement("a");
408
+ a.href = URL.createObjectURL(blob);
409
+ a.download = D.csvFilename(new Date());
410
+ document.body.appendChild(a);
411
+ a.click();
412
+ document.body.removeChild(a);
413
+ URL.revokeObjectURL(a.href);
414
+ }
415
+
416
+ function openMarkedModal() {
417
+ closeMarkedModal();
418
+ var overlay = $('<div id="dcs-modal-overlay"><div id="dcs-modal">' +
419
+ '<div id="dcs-modal-head"><strong>Marked for deletion</strong>' +
420
+ '<button type="button" id="dcs-modal-close">&times;</button></div>' +
421
+ '<table id="dcs-modal-table"><thead><tr>' +
422
+ '<th>Path</th><th>Comment</th><th>Marked at</th><th></th>' +
423
+ '</tr></thead><tbody></tbody></table>' +
424
+ '<div id="dcs-modal-foot">' +
425
+ '<button type="button" id="dcs-csv">Download CSV</button>' +
426
+ '<button type="button" id="dcs-start-over">Start over</button>' +
427
+ '</div></div></div>');
428
+ var tbody = overlay.find("tbody");
429
+ if (state.markedPaths.length === 0) {
430
+ tbody.append('<tr><td colspan="4"><em>Nothing marked yet.</em></td></tr>');
431
+ }
432
+ $.each(state.markedPaths.slice(), function (_i, m) {
433
+ var tr = $("<tr></tr>");
434
+ $('<td class="dcs-mono"></td>').text(m.path).appendTo(tr);
435
+ var commentInput = $('<input type="text" class="dcs-comment-edit">').val(m.comment)
436
+ .bind("change", function () {
437
+ D.updateComment(state, m.path, $(this).val());
438
+ saveState();
439
+ });
440
+ $("<td></td>").append(commentInput).appendTo(tr);
441
+ $('<td class="dcs-mono"></td>').text(m.markedAt).appendTo(tr);
442
+ $("<td></td>").append(
443
+ $('<button type="button" title="Unmark">&times;</button>').bind("click", function () {
444
+ D.removeMark(state, m.path);
445
+ saveState();
446
+ redrawTables();
447
+ openMarkedModal(); // rebuild
448
+ })
449
+ ).appendTo(tr);
450
+ tbody.append(tr);
451
+ });
452
+ $("body").append(overlay);
453
+ $("#dcs-modal-close").bind("click", closeMarkedModal);
454
+ overlay.bind("click", function (e) { if (e.target === this) closeMarkedModal(); });
455
+ $("#dcs-csv").bind("click", downloadCSV);
456
+ $("#dcs-start-over").bind("click", function () {
457
+ if (!confirm("Clear the entire marked-for-deletion list?")) return;
458
+ D.clearMarks(state);
459
+ saveState();
460
+ redrawTables();
461
+ openMarkedModal();
462
+ });
463
+ }
464
+
465
+ $("#dcs-marked-btn").bind("click", openMarkedModal);
466
+
467
+ redrawTables();
468
+ }
223
469
  });
@@ -0,0 +1,173 @@
1
+ // Pure logic for the dead-code review session. Loaded in the browser as
2
+ // window.CoverbandDeadCode and in Node (tests) via require.
3
+ (function (root, factory) {
4
+ if (typeof module === "object" && module.exports) {
5
+ module.exports = factory();
6
+ } else {
7
+ root.CoverbandDeadCode = factory();
8
+ }
9
+ })(typeof self !== "undefined" ? self : this, function () {
10
+ function emptyState() {
11
+ return { hiddenPaths: [], markedPaths: [], hideRuntimeUsed: false, startedAt: null };
12
+ }
13
+
14
+ function parseState(json) {
15
+ var raw;
16
+ try { raw = JSON.parse(json); } catch (e) { raw = null; }
17
+ var state = emptyState();
18
+ if (!raw || typeof raw !== "object") return state;
19
+ if (Object.prototype.toString.call(raw.hiddenPaths) === "[object Array]") {
20
+ state.hiddenPaths = raw.hiddenPaths.filter(function (p) {
21
+ return typeof p === "string" && p.length > 0;
22
+ });
23
+ }
24
+ if (Object.prototype.toString.call(raw.markedPaths) === "[object Array]") {
25
+ state.markedPaths = raw.markedPaths.filter(function (m) {
26
+ return m && typeof m.path === "string" && m.path.length > 0;
27
+ }).map(function (m) {
28
+ return {
29
+ path: m.path,
30
+ comment: typeof m.comment === "string" ? m.comment : "",
31
+ markedAt: typeof m.markedAt === "string" ? m.markedAt : ""
32
+ };
33
+ });
34
+ }
35
+ state.hideRuntimeUsed = raw.hideRuntimeUsed === true;
36
+ state.startedAt = typeof raw.startedAt === "string" ? raw.startedAt : null;
37
+ return state;
38
+ }
39
+
40
+ function pathMatches(entry, path) {
41
+ if (entry.charAt(entry.length - 1) === "/") return path.indexOf(entry) === 0;
42
+ return path === entry;
43
+ }
44
+
45
+ function matchesAny(entries, path) {
46
+ for (var i = 0; i < entries.length; i++) {
47
+ if (pathMatches(entries[i], path)) return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ function isMarked(state, path) {
53
+ for (var i = 0; i < state.markedPaths.length; i++) {
54
+ if (pathMatches(state.markedPaths[i].path, path)) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ function isExcludedIgnoringMarks(state, path, runtimePercent) {
60
+ if (matchesAny(state.hiddenPaths, path)) return true;
61
+ if (state.hideRuntimeUsed && typeof runtimePercent === "number" && runtimePercent > 0) return true;
62
+ return false;
63
+ }
64
+
65
+ function isExcluded(state, path, runtimePercent) {
66
+ return isExcludedIgnoringMarks(state, path, runtimePercent) || isMarked(state, path);
67
+ }
68
+
69
+ function addHidden(state, entry) {
70
+ if (state.hiddenPaths.indexOf(entry) === -1) state.hiddenPaths.push(entry);
71
+ }
72
+
73
+ function removeHidden(state, entry) {
74
+ var i = state.hiddenPaths.indexOf(entry);
75
+ if (i !== -1) state.hiddenPaths.splice(i, 1);
76
+ }
77
+
78
+ function clearHides(state) {
79
+ state.hiddenPaths = [];
80
+ state.hideRuntimeUsed = false;
81
+ }
82
+
83
+ function addMark(state, path, comment, markedAt) {
84
+ removeMark(state, path);
85
+ state.markedPaths.push({ path: path, comment: comment || "", markedAt: markedAt || "" });
86
+ }
87
+
88
+ function removeMark(state, path) {
89
+ for (var i = state.markedPaths.length - 1; i >= 0; i--) {
90
+ if (state.markedPaths[i].path === path) state.markedPaths.splice(i, 1);
91
+ }
92
+ }
93
+
94
+ function updateComment(state, path, comment) {
95
+ for (var i = 0; i < state.markedPaths.length; i++) {
96
+ if (state.markedPaths[i].path === path) state.markedPaths[i].comment = comment;
97
+ }
98
+ }
99
+
100
+ function clearMarks(state) {
101
+ state.markedPaths = [];
102
+ }
103
+
104
+ function csvField(value) {
105
+ var s = String(value == null ? "" : value);
106
+ if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
107
+ return s;
108
+ }
109
+
110
+ function toCSV(markedPaths) {
111
+ var lines = ["path,comment,marked_at"];
112
+ for (var i = 0; i < markedPaths.length; i++) {
113
+ var m = markedPaths[i];
114
+ lines.push([csvField(m.path), csvField(m.comment), csvField(m.markedAt)].join(","));
115
+ }
116
+ return lines.join("\r\n") + "\r\n";
117
+ }
118
+
119
+ function segmentPrefixes(path) {
120
+ var parts = path.split("/");
121
+ var out = [];
122
+ var prefix = "";
123
+ for (var i = 0; i < parts.length; i++) {
124
+ var isFile = i === parts.length - 1;
125
+ prefix += parts[i] + (isFile ? "" : "/");
126
+ out.push({ label: parts[i], prefix: prefix, isFile: isFile });
127
+ }
128
+ return out;
129
+ }
130
+
131
+ // Extracts the full path from a DataTables cell's rendered HTML (the
132
+ // title attribute on the source link), e.g. '<a title="app/foo.rb">foo.rb</a>'.
133
+ function extractPath(cellHtml) {
134
+ var m = String(cellHtml).match(/title="([^"]*)"/);
135
+ return m ? m[1] : "";
136
+ }
137
+
138
+ // Parses the runtime % column (aData[2]) of a report row into a number,
139
+ // or null when it isn't numeric (e.g. "-" for untracked files).
140
+ function rowRuntime(aData) {
141
+ var n = parseFloat(String(aData[2]));
142
+ return isNaN(n) ? null : n;
143
+ }
144
+
145
+ function escapeHtml(s) {
146
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
147
+ }
148
+
149
+ function csvFilename(date) {
150
+ return "coverband-dead-code-" + date.toISOString().slice(0, 10) + ".csv";
151
+ }
152
+
153
+ return {
154
+ emptyState: emptyState,
155
+ parseState: parseState,
156
+ pathMatches: pathMatches,
157
+ isExcluded: isExcluded,
158
+ isExcludedIgnoringMarks: isExcludedIgnoringMarks,
159
+ addHidden: addHidden,
160
+ removeHidden: removeHidden,
161
+ clearHides: clearHides,
162
+ addMark: addMark,
163
+ removeMark: removeMark,
164
+ updateComment: updateComment,
165
+ clearMarks: clearMarks,
166
+ toCSV: toCSV,
167
+ segmentPrefixes: segmentPrefixes,
168
+ extractPath: extractPath,
169
+ rowRuntime: rowRuntime,
170
+ escapeHtml: escapeHtml,
171
+ csvFilename: csvFilename
172
+ };
173
+ });
@@ -173,14 +173,20 @@ namespace :benchmarks do
173
173
  }.pretty_print
174
174
  data = $stdout.string
175
175
  $stdout = previous_out
176
- unless data.match?("Total retained: 0 bytes")
177
- puts data
178
- raise "leaking memory!!!"
179
- end
176
+ assert_no_coverband_leak(data)
180
177
  ensure
181
178
  $stdout = previous_out
182
179
  end
183
180
 
181
+ # Check only that coverband itself retains no objects.
182
+ # External gems (json, redis-client) may retain strings via internal
183
+ # deduplication caches in newer Ruby versions — that is not a coverband leak.
184
+ def assert_no_coverband_leak(data)
185
+ return unless data.match(/retained objects by gem(.*)retained objects by file/m)&.[](0)&.match?(/coverband/)
186
+ puts data
187
+ raise "leaking memory!!!"
188
+ end
189
+
184
190
  def measure_memory_report_coverage
185
191
  require "memory_profiler"
186
192
  require "stringio"
@@ -205,16 +211,18 @@ namespace :benchmarks do
205
211
  # we clear this as this one variable is expected to retain memory and is a false positive
206
212
  ###
207
213
  Coverband::Collectors::Delta.class_variable_set(:@@previous_coverage, nil)
208
- # Clear the RelativeFileConverter cache to avoid retaining strings
209
- Coverband::Utils::RelativeFileConverter.reset
210
214
  end
215
+ # Reset file converter caches INSIDE the block but AFTER the loop.
216
+ # This lets GC collect the instance (and any strings allocated in it during the loop)
217
+ # before MemoryProfiler measures retained objects, so those strings aren't flagged.
218
+ # The instance was created before the block (during warmup), so its Regexp objects
219
+ # are invisible to MemoryProfiler even if GC defers collecting them.
220
+ Coverband::Utils::RelativeFileConverter.reset
221
+ Coverband::Utils::AbsoluteFileConverter.reset
211
222
  }.pretty_print
212
223
  data = $stdout.string
213
224
  $stdout = previous_out
214
- unless data.match?("Total retained: 0 bytes")
215
- puts data
216
- raise "leaking memory!!!"
217
- end
225
+ assert_no_coverband_leak(data)
218
226
  ensure
219
227
  $stdout = previous_out
220
228
  end
@@ -0,0 +1,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