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.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +10 -2
- data/.gitignore +2 -1
- data/README.md +59 -0
- data/Rakefile +9 -1
- data/changes.md +6 -0
- data/lib/coverband/collectors/delta.rb +21 -7
- data/lib/coverband/collectors/query_burst_tracker.rb +268 -0
- data/lib/coverband/configuration.rb +11 -0
- data/lib/coverband/reporters/web.rb +25 -1
- data/lib/coverband/utils/html_formatter.rb +3 -1
- 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/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/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/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 +6 -1
data/public/application.js
CHANGED
|
@@ -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">×</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 % > 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") + '">×</button>' +
|
|
354
|
+
'<button type="button" class="dcs-mark" title="Mark ' + escapeHtml(segs[i].prefix) + ' for deletion">🗑</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">×</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">×</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, "&").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,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
|