App-Test-Generator
view release on metacpan or search on metacpan
bin/test-generator-index view on Meta::CPAN
# Dependency correlation analysis
# -------------------------------
# --------------------------------------------------
# Maximum number of CPAN Testers reports to fetch
# per grade when performing dependency correlation
# analysis â acts as a safety rail against runaway
# API requests on distributions with many failures
# --------------------------------------------------
Readonly my $MAX_REPORTS_PER_GRADE => 20;
# --------------------------------------------------
# Enable dependency correlation analysis against
# CPAN Testers reports. Set to 0 to disable if the
# API is unreachable or the analysis is too slow.
# --------------------------------------------------
Readonly my $ENABLE_DEP_ANALYSIS => 1;
# Read and decode data
my $cover_db = eval { decode_json(read_file($config{cover_db})) };
my $mutation_db = eval { decode_json(read_file($config{mutation_db})) };
# --------------------------------------------------
# Compute coverage percentage from only our own files,
# excluding absolute paths (installed CPAN modules)
# which inflate Devel::Cover's pre-aggregated Total.
# --------------------------------------------------
my ($coverage_pct, $badge_color) = (0, 'red');
if($cover_db->{summary}) {
my ($sum, $count) = (0, 0);
for my $f (keys %{ $cover_db->{summary} }) {
next if $f eq 'Total';
next if $f =~ /^\//; # skip absolute paths
$sum += $cover_db->{summary}{$f}{total}{percentage} // 0;
$count++;
}
if($count) {
my $pct = _own_file_coverage_pct($cover_db->{summary});
$coverage_pct = defined $pct ? int($pct) : 0;
$badge_color = $coverage_pct > $config{med_threshold} ? 'brightgreen'
: $coverage_pct > $config{low_threshold} ? 'yellow'
: 'red';
}
}
Readonly my $coverage_badge_url => "https://img.shields.io/badge/coverage-${coverage_pct}%25-${badge_color}";
# Start HTML
my @html; # build in array, join later
push @html, <<"HTML";
<!DOCTYPE html>
<html>
<head>
<title>$config{package_name} Coverage Report</title>
<style>
body { font-family: sans-serif; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.low { background-color: #fdd; }
.med { background-color: #ffd; }
.high { background-color: #dfd; }
.badges img { margin-right: 10px; }
.disabled-icon {
opacity: 0.4;
cursor: default;
}
.icon-link {
text-decoration: none;
}
.icon-link:hover {
opacity: 0.7;
cursor: pointer;
}
.coverage-badge {
padding: 2px 6px;
border-radius: 4px;
font-weight: bold;
color: white;
font-size: 0.9em;
}
.badge-good { background-color: #4CAF50; }
.badge-warn { background-color: #FFC107; }
.badge-bad { background-color: #F44336; }
.summary-row {
font-weight: bold;
background-color: #f0f0f0;
}
td.positive { color: green; font-weight: bold; }
td.negative { color: red; font-weight: bold; }
td.neutral { color: gray; }
/* Show cursor points on the headers to show that they are clickable */
th { background-color: #f2f2f2; cursor: pointer; }
th.sortable {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
th .arrow {
color: #aaa; /* dimmed for inactive */
font-weight: normal;
}
th .arrow.active {
color: #000; /* dark for active */
font-weight: bold;
}
.sparkline {
display: inline-block;
vertical-align: middle;
}
tr.cpan-fail td {
background-color: #fdd;
}
tr.cpan-unknown td {
background-color: #eee;
color: #666;
}
tr.cpan-na td {
background-color: #ffffde;
color: #666;
}
.new-failure {
background: #c00;
color: #fff;
font-weight: bold;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85em;
}
.notice {
padding: 8px 12px;
margin: 10px 0;
border-radius: 4px;
font-size: 0.95em;
}
.notice strong {
font-weight: bold;
}
.notice.perl-version-cliff {
background-color: #fff3cd; /* soft amber */
border: 1px solid #ffeeba;
color: #856404;
}
.notice.perl-version-cliff a {
color: #533f03;
text-decoration: underline;
}
.notice.perl-version-cliff a:hover {
text-decoration: none;
}
.notice.locale-cliff {
border-left: 4px solid #d97706;
background: #fffbeb;
padding: 0.5em 1em;
}
.notice.rt-issues {
background: #fff6e5;
border-left: 4px solid #d9822b;
}
table.root-causes {
border-collapse: collapse;
width: 100%;
margin-bottom: 1.5em;
}
table.root-causes th,
table.root-causes td {
border: 1px solid #ccc;
padding: 8px;
vertical-align: top;
}
table.root-causes tr.high {
background-color: #dfd;
}
table.root-causes tr.med {
background-color: #ffd;
}
table.root-causes tr.low {
background-color: #fdd;
}
</style>
</head>
<body>
<div class="badges">
<a href="https://github.com/$config{github_user}/$config{github_repo}">
<img src="https://img.shields.io/github/stars/$config{github_user}/$config{github_repo}?style=social" alt="GitHub stars">
</a>
<img src="$coverage_badge_url" alt="Coverage badge">
</div>
<h1>$config{package_name}</h1><h2>Coverage Report</h2>
<table data-sort-col="0" data-sort-order="asc">
<!-- Make the column headers clickable -->
<thead>
<tr>
<th class="sortable" onclick="sortTable(this, 0)"><span class="label">File</span> <span class="arrow active">▲</span></th>
<th class="sortable" onclick="sortTable(this, 1)"><span class="label">Stmt</span> <span class="arrow">▲</span></th>
<th class="sortable" onclick="sortTable(this, 2)"><span class="label">Branch</span> <span class="arrow">▲</span></th>
<th class="sortable" onclick="sortTable(this, 3)"><span class="label">Cond</span> <span class="arrow">▲</span></th>
<th class="sortable" onclick="sortTable(this, 4)"><span class="label">Sub</span> <span class="arrow">▲</span></th>
<th class="sortable" onclick="sortTable(this, 5)"><span class="label">Total</span> <span class="arrow">▲</span></th>
<th class="sortable" onclick="sortTable(this, 6)"><span class="label">Δ</span> <span class="arrow">▲</span></th>
</tr>
</thead>
<tbody>
HTML
my @history_files = bsd_glob("coverage_history/*.json");
# Cache historical data instead of reading for each file
my %historical_cache;
for my $hist_file (@history_files) {
my $json = eval { decode_json(read_file($hist_file)) };
$historical_cache{$hist_file} = $json if $json;
}
# Load previous snapshot for delta comparison
my @history = sort { $a cmp $b } @history_files;
my $prev_data;
if(@history >= 1) {
my $prev_file = $history[-1]; # Most recent before current
$prev_data = $historical_cache{$prev_file};
}
my %deltas;
if($prev_data) {
for my $file (keys %{$cover_db->{summary}}) {
next if $file eq 'Total';
next if $file =~ /^\//; # skip absolute paths
# Normalise blib/ paths and deduplicate against lib/ entries
my $delta_key = $file;
if($file =~ /^blib\/lib\/(.+)$/) {
next if exists $cover_db->{summary}{"lib/$1"};
$delta_key = "lib/$1";
}
my $curr = $cover_db->{summary}{$file}{total}{percentage} // 0;
bin/test-generator-index view on Meta::CPAN
</div>
</div>
<canvas id="coverageTrend" width="600" height="300"></canvas>
<!-- Zoom controls for the trend chart -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Add chartjs-plugin-zoom (required for wheel/pinch/drag zoom & pan) -->
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom\@2.1.1/dist/chartjs-plugin-zoom.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
<script>
function linearRegression(data) {
const xs = data.map(p => new Date(p.x).getTime());
const ys = data.map(p => p.y);
const n = xs.length;
const sumX = xs.reduce((a, b) => a + b, 0);
const sumY = ys.reduce((a, b) => a + b, 0);
const sumXY = xs.reduce((acc, val, i) => acc + val * ys[i], 0);
const sumX2 = xs.reduce((acc, val) => acc + val * val, 0);
if(n < 2 || (n * sumX2 - sumX * sumX) === 0) {
return [];
}
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
return xs.map(x => ({
x: new Date(x).toISOString(),
y: slope * x + intercept
}));
}
HTML
my $js_data = join(",\n", @data_points);
push @html, "const dataPoints = [ $js_data ];";
push @html, <<'HTML';
const regressionPoints = linearRegression(dataPoints);
// Try to register the zoom plugin (handles different UMD builds)
(function registerZoomPlugin(){
try {
const candidates = ['chartjsPluginZoom','ChartZoom','zoomPlugin','chartjs_plugin_zoom','ChartjsPluginZoom','chartjsPluginZoom'];
for(const name of candidates) {
if(window[name]) {
try { Chart.register(window[name]); console.log('Registered zoom plugin:', name); return; } catch(e) { console.warn('zoom register failed for', name, e); }
}
}
// Some CDN builds auto-register the plugin; if nothing found that's OK (feature disabled).
} catch(e) {
console.warn('registerZoomPlugin error', e);
}
})();
const ctx = document.getElementById('coverageTrend').getContext('2d');
const chart = new Chart(ctx, {
type: 'line',
data: {
datasets: [{
label: 'Total Coverage (%)',
data: dataPoints,
borderColor: 'green',
backgroundColor: 'rgba(0,128,0,0.1)',
pointRadius: 5,
pointHoverRadius: 7,
pointStyle: 'circle',
fill: false,
tension: 0.3,
pointBackgroundColor: function(context) {
return context.raw.pointBackgroundColor || 'gray';
}
}, {
label: 'Regression Line',
data: regressionPoints,
borderColor: 'blue',
borderDash: [5, 5],
pointRadius: 0,
fill: false,
tension: 0.0
}]
}, options: {
scales: {
x: {
type: 'time',
time: {
tooltipFormat: 'MMM d, yyyy HH:mm:ss',
unit: 'day'
},
title: { display: true, text: 'Commit Date' }
},
y: { beginAtZero: true, max: 100, title: { display: true, text: 'Coverage (%)' } }
}, plugins: {
legend: {
display: true,
position: 'top', // You can also use 'bottom', 'left', or 'right'
labels: {
boxWidth: 12,
padding: 10,
font: {
size: 12,
weight: 'bold'
}
}
}, tooltip: {
callbacks: {
label: function(context) {
const raw = context.raw;
const coverage = raw.y.toFixed(1);
const delta = raw.delta?.toFixed(1) ?? '0.0';
const sign = delta > 0 ? '+' : delta < 0 ? '-' : '±';
// const baseLine = `${raw.label}: ${coverage}% (${sign}${Math.abs(delta)}%)`;
const baseLine = `${coverage}% (${sign}${Math.abs(delta)}%)`;
const commentLine = raw.comment ? raw.comment : null;
return commentLine ? [baseLine, commentLine] : [baseLine];
}
}
} , zoom: { // Enable zoom & pan on the x-axis for the trend chart
pan: {
enabled: true,
mode: 'x'
}, zoom: {
wheel: {
enabled: true
bin/test-generator-index view on Meta::CPAN
# High score â drop the 'only' and 'but'
$exec_summary = "Tests execute $stmt_pct% of the code "
. "and detect $data->{score}% of injected faults.";
} elsif($data->{score} >= $config{low_threshold}) {
# Moderate score â neutral wording
$exec_summary = "Tests execute $stmt_pct% of the code "
. "but detect only $data->{score}% of injected faults.";
} else {
# Low score â add actionable note
$exec_summary = "Tests execute $stmt_pct% of the code "
. "but detect only $data->{score}% of injected faults â "
. 'consider adding targeted tests to kill surviving mutants.';
}
push @html, $exec_summary;
push @html, '</div>';
}
return \@html;
}
sub _file_score {
my $file_data = $_[0];
my $killed = scalar @{ $file_data->{killed} || [] };
my $survived = scalar @{ $file_data->{survived} || [] };
my $total = $killed + $survived;
return $total ? ($killed / $total) * 100 : 0;
}
# --------------------------------------------------
# _ter_badge
#
# Purpose: Format a single TER percentage value as
# a colour-coded HTML badge, consistent
# with the coverage badge style used
# elsewhere in the dashboard.
#
# Entry: $pct - percentage value (0-100), or
# undef if data is unavailable
# $label - fallback text to display when
# $pct is undef (e.g. 'n/a')
#
# Exit: Returns an HTML span string. Never
# returns undef.
#
# Side effects: None.
#
# Notes: Thresholds are taken from %config:
# >= med_threshold -> green (badge-good)
# >= low_threshold -> yellow (badge-warn)
# < low_threshold -> red (badge-bad)
# Undef input produces a grey badge with
# title="No data".
# --------------------------------------------------
sub _ter_badge {
my ($pct, $label) = @_;
unless(defined $pct) {
return qq{<span class="coverage-badge" style="background-color:#999" title="No data">$label</span>};
}
my ($class, $tooltip) =
$pct >= $config{med_threshold} ? ('badge-good', 'Excellent') :
$pct >= $config{low_threshold} ? ('badge-warn', 'Moderate') :
('badge-bad', 'Needs improvement');
return sprintf(
'<span class="coverage-badge %s" title="%s">%.1f%%</span>',
$class, $tooltip, $pct
);
}
# --------------------------------------------------
# _group_by_file
#
# Purpose: Partition a flat list of mutant hashrefs
# (from the mutation JSON) into a nested
# hashref keyed by source file, then by
# status (survived/killed).
#
# Entry: $data - decoded mutation JSON hashref
# containing 'survived' and 'killed'
# arrayrefs.
#
# Exit: Returns a hashref of the form:
# { filename => { survived => [...],
# killed => [...] } }
# Mutants missing a 'file' field are
# silently skipped.
#
# --------------------------------------------------
sub _group_by_file {
my $data = $_[0];
my %files;
for my $status (qw(survived killed)) {
next unless $data->{$status};
for my $m (@{$data->{$status}}) {
next unless ref $m;
next unless defined $m->{file};
push @{$files{$m->{file}}{$status}}, $m;
}
}
# Propagate per-file skipped line counts from MUTANT_SKIP annotations
if($data->{skipped}) {
for my $file (keys %{$data->{skipped}}) {
$files{$file}{skipped} = $data->{skipped}{$file};
}
}
return \%files;
}
# --------------------------------------------------
# _mutant_file_report
bin/test-generator-index view on Meta::CPAN
my ($from, $to) = @_;
# Convert both to .html filenames
$from .= '.html';
$to .= '.html';
# Use File::Spec to compute correct relative path
return File::Spec->abs2rel($to, dirname($from));
}
sub _mutant_file_header {
return qq{
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
/* --------------------------------------------------
CSS Variables (Light Mode Default)
-------------------------------------------------- */
:root {
--bg: #ffffff;
--text: #000000;
--table-header: #333;
--table-header-text: #ffffff;
--survived-1: #f8d7da;
--survived-2: #f5b7b1;
--survived-3: #ec7063;
--killed: #d4edda;
--border: #cccccc;
}
/* --------------------------------------------------
Dark Mode Overrides
-------------------------------------------------- */
html[data-theme='dark'] {
--bg: #1e1e1e;
--text: #dddddd;
--table-header: #222;
--table-header-text: #ffffff;
--survived-1: #5c2b2e;
--survived-2: #7b2c2f;
--survived-3: #a93226;
--killed: #1e4620;
--border: #555;
}
/* --------------------------------------------------
Global Styles
-------------------------------------------------- */
body {
font-family: sans-serif;
background: var(--bg);
color: var(--text);
}
table {
border-collapse: collapse;
width: 100%;
}
th {
background: var(--table-header);
color: var(--table-header-text);
}
.survived-1 { background-color: var(--survived-1); }
.survived-2 { background-color: var(--survived-2); }
.survived-3 { background-color: var(--survived-3); }
.killed { background-color: var(--killed); }
.legend {
border: 1px solid #ccc;
background: #fafafa;
padding: 10px;
margin: 15px 0;
font-size: 0.9em;
}
.legend pre {
background: #f4f4f4;
padding: 5px;
}
.legend-box {
display: inline-block;
width: 16px;
height: 16px;
margin: 0 6px 0 20px;
vertical-align: middle;
border: 1px solid var(--border);
}
/* White box for non-mutated lines */
.legend-box.none {
background-color: var(--bg);
}
/* --------------------------------------------------
Suggested Test Box Styling
Theme-aware and readable in light & dark modes
-------------------------------------------------- */
.suggested-test {
margin-top: 6px;
margin-bottom: 12px;
/* Use theme variables instead of hardcoded colors */
background: var(--bg);
color: var(--text);
padding: 8px;
border-radius: 4px;
/* Subtle border for visual separation */
border: 1px solid var(--border);
}
/* Label styling */
.suggest-label {
font-weight: bold;
margin-bottom: 4px;
}
/* Ensure the test code block inherits readable colors */
.suggested-test pre {
background: transparent; /* Prevent nested dark blocks */
color: inherit; /* Match theme text color */
margin: 0;
font-family: monospace;
}
pre { line-height: 1.4; }
pre > details {
margin: 0.2em 0;
}
pre > details:first-child {
margin-top: 0;
}
pre > details:last-child {
margin-bottom: 0;
}
pre details,
pre summary,
pre ul,
pre li {
white-space: normal;
margin: 0;
padding: 0;
line-height: 1.2;
}
.nav { margin-bottom: 1em; }
.toggle {
float: right;
cursor: pointer;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 4px;
}
/* Tooltip container */
.tooltip {
position: relative;
cursor: help;
}
/* Tooltip bubble */
.tooltip:hover::after {
content: attr(data-tooltip);
position: absolute;
left: 0;
top: 100%;
background: var(--table-header);
color: var(--table-header-text);
padding: 6px 10px;
white-space: normal;
max-width: 300px;
min-width: 30ch;
font-size: 12px;
border-radius: 6px;
z-index: 1000;
margin-left: 10ch; /* move tooltip ~10 characters to the right */
}
.mutant-details {
margin-left: 2em;
font-size: 0.9em;
}
/* Indent the list of mutations that displays when expanding by 8 characters */
pre details.mutant-details ul {
padding-left: 8ch;
margin: 0.2em 0;
}
.mutant-details summary {
cursor: pointer;
font-weight: bold;
}
.lcsaj-dot {
color: #5555ff;
font-size: 10px;
margin-right: 3px;
}
.lcsaj-dot-uncovered {
color: #cc4444;
font-size: 10px;
margin-right: 3px;
}
.lcsaj-tip {
position: relative;
display: inline-block;
}
.lcsaj-tip .lcsaj-tip-text {
visibility: hidden;
background-color: #333;
color: #fff;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
white-space: nowrap;
position: fixed;
z-index: 9999;
pointer-events: none;
}
.lcsaj-tip:hover .lcsaj-tip-text {
visibility: visible;
}
</style>
</head>
<body>
<button class="toggle" onclick="toggleTheme()">ð Toggle Theme</button>
};
}
sub _mutant_file_footer {
return qq{
<script>
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
(function() {
const saved = localStorage.getItem('theme');
if(saved) {
document.documentElement.setAttribute('data-theme', saved);
}
})();
document.addEventListener("mousemove", function(e) {
document.querySelectorAll(".lcsaj-tip-text").forEach(function(tip) {
tip.style.left = (e.clientX + 12) + "px";
tip.style.top = (e.clientY + 12) + "px";
});
});
</script>
</body>
</html>
};
}
# --------------------------------------------------
# _coverage_totals
#
# Purpose: Extract aggregate structural coverage
# totals from a Devel::Cover JSON report,
# computed only across the project's own
# source files. Used to populate the
# Structural Coverage and Executive
# Summary sections of the dashboard.
( run in 2.231 seconds using v1.01-cache-2.11-cpan-d8267643d1d )