@reportforge/playwright-pdf 0.23.0 → 0.25.0
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.
- package/README.md +3 -3
- package/ci-templates/azure-devops/azure-pipelines.yml +58 -58
- package/ci-templates/github-actions/basic-workflow.yml +44 -44
- package/ci-templates/gitlab-ci/.gitlab-ci.yml +42 -42
- package/ci-templates/jenkins/Jenkinsfile +54 -54
- package/dist/cli/index.js +0 -0
- package/dist/index.d.mts +6 -3
- package/dist/index.d.ts +6 -3
- package/dist/index.js +307 -24
- package/dist/index.mjs +307 -24
- package/dist/templates/layouts/detailed.hbs +41 -41
- package/dist/templates/layouts/executive.hbs +41 -41
- package/dist/templates/layouts/minimal.hbs +41 -41
- package/dist/templates/partials/analysis-oneliner.hbs +3 -3
- package/dist/templates/partials/chart-summary-grid.hbs +40 -40
- package/dist/templates/partials/chart-trend-card.hbs +16 -16
- package/dist/templates/partials/charts.hbs +433 -433
- package/dist/templates/partials/ci-environment.hbs +56 -56
- package/dist/templates/partials/cover-page.hbs +48 -48
- package/dist/templates/partials/defect-log.hbs +79 -79
- package/dist/templates/partials/executive-hero-summary.hbs +66 -66
- package/dist/templates/partials/executive-summary.hbs +78 -78
- package/dist/templates/partials/failure-card.hbs +35 -35
- package/dist/templates/partials/head.hbs +17 -17
- package/dist/templates/partials/release-gate.hbs +84 -84
- package/dist/templates/partials/requirements-matrix.hbs +51 -51
- package/dist/templates/partials/suite-breakdown.hbs +89 -89
- package/dist/templates/partials/watermark.hbs +42 -42
- package/package.json +1 -2
|
@@ -1,433 +1,433 @@
|
|
|
1
|
-
{{#if options.showCharts}}
|
|
2
|
-
{{#if options.heroTrend}}
|
|
3
|
-
{{> chart-summary-grid options=options}}
|
|
4
|
-
{{> chart-trend-card options=options}}
|
|
5
|
-
{{else}}
|
|
6
|
-
{{> chart-summary-grid options=options}}
|
|
7
|
-
{{> chart-trend-card options=options}}
|
|
8
|
-
{{/if}}
|
|
9
|
-
|
|
10
|
-
{{#if options.showTrend}}
|
|
11
|
-
{{#if charts.flakinessTable}}
|
|
12
|
-
<div class="flakiness-card">
|
|
13
|
-
<div class="flakiness-card__header">
|
|
14
|
-
<span class="flakiness-card__title">Top flaky tests — last {{charts.flakinessTable.[0].totalRuns}} runs</span>
|
|
15
|
-
</div>
|
|
16
|
-
<table class="flakiness-table">
|
|
17
|
-
<thead>
|
|
18
|
-
<tr>
|
|
19
|
-
<th class="flakiness-table__test">Test</th>
|
|
20
|
-
<th class="flakiness-table__rate">Flake rate</th>
|
|
21
|
-
<th class="flakiness-table__count">Runs flaky</th>
|
|
22
|
-
<th class="flakiness-table__spark">History</th>
|
|
23
|
-
</tr>
|
|
24
|
-
</thead>
|
|
25
|
-
<tbody>
|
|
26
|
-
{{#each charts.flakinessTable}}
|
|
27
|
-
<tr>
|
|
28
|
-
<td class="flakiness-table__test" title="{{this.title}}">{{this.title}}</td>
|
|
29
|
-
<td class="flakiness-table__rate">{{this.flakeRate}}%</td>
|
|
30
|
-
<td class="flakiness-table__count">{{this.flakeCount}} / {{this.totalRuns}}</td>
|
|
31
|
-
<td class="flakiness-table__spark">
|
|
32
|
-
<span class="flaky-spark">
|
|
33
|
-
{{#each this.sparkline}}
|
|
34
|
-
<span class="flaky-dot{{#if this}} flaky-dot--hit{{/if}}"></span>
|
|
35
|
-
{{/each}}
|
|
36
|
-
</span>
|
|
37
|
-
</td>
|
|
38
|
-
</tr>
|
|
39
|
-
{{/each}}
|
|
40
|
-
</tbody>
|
|
41
|
-
</table>
|
|
42
|
-
</div>
|
|
43
|
-
{{/if}}
|
|
44
|
-
{{/if}}
|
|
45
|
-
|
|
46
|
-
<script>
|
|
47
|
-
(function() {
|
|
48
|
-
// Chart.js measures axis labels with whatever font is available at chart
|
|
49
|
-
// construction. The embedded Inter loads asynchronously (even from a data:
|
|
50
|
-
// URI), so charts built immediately measure ticks with narrower fallback
|
|
51
|
-
// metrics and then PAINT with Inter — wide-glyph labels overflowed their
|
|
52
|
-
// measured gutter by a character. Build every chart only after the document
|
|
53
|
-
// fonts settle; the timeout fallback guards a hung fonts.ready so
|
|
54
|
-
// __chartsReady can never be starved.
|
|
55
|
-
var inited = false;
|
|
56
|
-
function initCharts() {
|
|
57
|
-
if (inited) return;
|
|
58
|
-
inited = true;
|
|
59
|
-
var remaining = 0;
|
|
60
|
-
function chartDone() { if (--remaining === 0) window.__chartsReady = true; }
|
|
61
|
-
|
|
62
|
-
var passed = {{charts.passRate.passed}};
|
|
63
|
-
var failed = {{charts.passRate.failed}};
|
|
64
|
-
var timedOut = {{charts.passRate.timedOut}};
|
|
65
|
-
var skipped = {{charts.passRate.skipped}};
|
|
66
|
-
var flaky = {{charts.passRate.flaky}};
|
|
67
|
-
// Pass rate is the canonical server-side stat (flaky counts as pass, denominator
|
|
68
|
-
// is every test) so the doughnut centre can never disagree with the KPI strip.
|
|
69
|
-
var passRate = {{stats.passRate}};
|
|
70
|
-
|
|
71
|
-
/* ── Centre-text plugin for the doughnut ─────────────────────────────── */
|
|
72
|
-
var centerTextPlugin = {
|
|
73
|
-
id: 'centerText',
|
|
74
|
-
afterDatasetsDraw: function(chart) {
|
|
75
|
-
if (chart.canvas.id !== 'passRateChart') return;
|
|
76
|
-
var ctx = chart.ctx;
|
|
77
|
-
var cx = (chart.chartArea.left + chart.chartArea.right) / 2;
|
|
78
|
-
var cy = (chart.chartArea.top + chart.chartArea.bottom) / 2;
|
|
79
|
-
/* Size the centre text off the doughnut's actual hole, not a fixed px
|
|
80
|
-
value — the executive template's 150px canvas has a smaller inner
|
|
81
|
-
radius than the 186px canvas used elsewhere, and fixed 28px/9px text
|
|
82
|
-
overran the hole into the ring on the smaller size. */
|
|
83
|
-
var arc = chart.getDatasetMeta(0).data[0];
|
|
84
|
-
var innerRadius = (arc && arc.innerRadius) ? arc.innerRadius : 50;
|
|
85
|
-
var mainSize = Math.max(15, Math.round(innerRadius * 0.56));
|
|
86
|
-
var subSize = Math.max(7, Math.round(innerRadius * 0.175));
|
|
87
|
-
ctx.save();
|
|
88
|
-
ctx.textAlign = 'center';
|
|
89
|
-
ctx.textBaseline = 'middle';
|
|
90
|
-
/* large percentage */
|
|
91
|
-
ctx.font = '700 ' + mainSize + 'px "Source Serif Pro", Georgia, serif';
|
|
92
|
-
ctx.fillStyle = '#1F1F1C';
|
|
93
|
-
ctx.fillText(passRate + '%', cx, cy - mainSize * 0.24);
|
|
94
|
-
/* label below */
|
|
95
|
-
ctx.font = '600 ' + subSize + 'px Inter, sans-serif';
|
|
96
|
-
ctx.fillStyle = '#8C8B82';
|
|
97
|
-
ctx.fillText('PASS RATE', cx, cy + mainSize * 0.46);
|
|
98
|
-
ctx.restore();
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
Chart.register(centerTextPlugin);
|
|
102
|
-
|
|
103
|
-
/* ── Pass rate doughnut ──────────────────────────────────────────────── */
|
|
104
|
-
// Rounded, slightly separated segments + a thicker ring read as a designed
|
|
105
|
-
// gauge rather than a default pie; the centre % (drawn by centerTextPlugin)
|
|
106
|
-
// scales off the inner radius automatically.
|
|
107
|
-
remaining += 1;
|
|
108
|
-
new Chart(document.getElementById('passRateChart'), {
|
|
109
|
-
type: 'doughnut',
|
|
110
|
-
data: {
|
|
111
|
-
labels: ['Passed', 'Failed', 'Timed Out', 'Skipped', 'Flaky'],
|
|
112
|
-
datasets: [{
|
|
113
|
-
data: [passed, failed, timedOut, skipped, flaky],
|
|
114
|
-
backgroundColor: ['#3F7D58', '#C3553C', '#B5803A', '#7A786E', '#CA8A04'],
|
|
115
|
-
borderColor: ['#FAF9F5', '#FAF9F5', '#FAF9F5', '#FAF9F5', '#FAF9F5'],
|
|
116
|
-
borderWidth: 1,
|
|
117
|
-
borderRadius: 5,
|
|
118
|
-
spacing: 2,
|
|
119
|
-
hoverOffset: 4
|
|
120
|
-
}]
|
|
121
|
-
},
|
|
122
|
-
options: {
|
|
123
|
-
responsive: false,
|
|
124
|
-
cutout: '64%',
|
|
125
|
-
plugins: {
|
|
126
|
-
legend: {
|
|
127
|
-
position: 'bottom',
|
|
128
|
-
labels: {
|
|
129
|
-
font: { size: 9, family: 'Inter, sans-serif' },
|
|
130
|
-
color: '#5A5A52', padding: 10,
|
|
131
|
-
usePointStyle: true, pointStyle: 'circle', boxWidth: 7, boxHeight: 7
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
},
|
|
135
|
-
animation: { onComplete: chartDone }
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
/* ── Horizontal suite results bar(s) ─────────────────────────────────── */
|
|
140
|
-
// Muted per-suite total at each bar's end — the count is scannable without
|
|
141
|
-
// tracing gridlines. Guarded by canvas id prefix so it fires on every suite
|
|
142
|
-
// chunk canvas and nothing else.
|
|
143
|
-
var suiteTotalsPlugin = {
|
|
144
|
-
id: 'suiteBarTotals',
|
|
145
|
-
afterDatasetsDraw: function(chart) {
|
|
146
|
-
if (chart.canvas.id.indexOf('suitesBarChart') !== 0) return;
|
|
147
|
-
var ctx = chart.ctx;
|
|
148
|
-
for (var i = 0; i < chart.data.labels.length; i++) {
|
|
149
|
-
var total = 0;
|
|
150
|
-
chart.data.datasets.forEach(function(ds) { total += ds.data[i] || 0; });
|
|
151
|
-
if (!total) continue;
|
|
152
|
-
var y = chart.scales.y.getPixelForValue(i);
|
|
153
|
-
var x = chart.scales.x.getPixelForValue(total);
|
|
154
|
-
ctx.save();
|
|
155
|
-
ctx.font = '600 8.5px Inter, sans-serif';
|
|
156
|
-
ctx.fillStyle = '#8C8B82';
|
|
157
|
-
ctx.textAlign = 'left';
|
|
158
|
-
ctx.textBaseline = 'middle';
|
|
159
|
-
ctx.fillText(String(total), x + 5, y);
|
|
160
|
-
ctx.restore();
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
};
|
|
164
|
-
Chart.register(suiteTotalsPlugin);
|
|
165
|
-
|
|
166
|
-
// Shared config so the compact small-run card and the chunked large-run
|
|
167
|
-
// canvases render identically bar-for-bar.
|
|
168
|
-
function suiteChartConfig(labels, passed, failed, timedOut, skipped) {
|
|
169
|
-
return {
|
|
170
|
-
type: 'bar',
|
|
171
|
-
data: {
|
|
172
|
-
labels: labels,
|
|
173
|
-
datasets: [
|
|
174
|
-
{ label: 'Passed', data: passed, backgroundColor: '#3F7D58', borderRadius: 4 },
|
|
175
|
-
{ label: 'Failed', data: failed, backgroundColor: '#C3553C', borderRadius: 4 },
|
|
176
|
-
{ label: 'Timed Out', data: timedOut, backgroundColor: '#B5803A', borderRadius: 4 },
|
|
177
|
-
{ label: 'Skipped', data: skipped, backgroundColor: '#7A786E', borderRadius: 4 }
|
|
178
|
-
]
|
|
179
|
-
},
|
|
180
|
-
options: {
|
|
181
|
-
indexAxis: 'y',
|
|
182
|
-
responsive: false,
|
|
183
|
-
// Left: small gutter margin for the y labels (enlarging scale.width in
|
|
184
|
-
// afterFit is not honoured by the layout pass); exact fit comes from
|
|
185
|
-
// building charts after fonts.ready so tick measurement uses Inter.
|
|
186
|
-
// Right: room for the per-suite total drawn past the bar end.
|
|
187
|
-
layout: { padding: { left: 16, right: 22 } },
|
|
188
|
-
scales: {
|
|
189
|
-
x: {
|
|
190
|
-
stacked: true,
|
|
191
|
-
// maxTicksLimit instead of stepSize:1 — a suite with 40+ tests would
|
|
192
|
-
// otherwise spray 40+ ticks across the axis. precision:0 keeps the
|
|
193
|
-
// auto-chosen steps integral.
|
|
194
|
-
ticks: {
|
|
195
|
-
font: { size: 9, family: 'Inter, sans-serif' }, color: '#5A5A52',
|
|
196
|
-
maxTicksLimit: 12, precision: 0
|
|
197
|
-
},
|
|
198
|
-
grid: { color: 'rgba(31,31,28,0.06)' },
|
|
199
|
-
border: { color: '#D6D4C8' }
|
|
200
|
-
},
|
|
201
|
-
y: {
|
|
202
|
-
stacked: true,
|
|
203
|
-
ticks: {
|
|
204
|
-
font: { size: 10, family: 'Inter, sans-serif' }, color: '#1F1F1C', autoSkip: false,
|
|
205
|
-
// Truncate only very long suite / describe labels; measurement runs
|
|
206
|
-
// post-fonts.ready so the gutter fits exactly what gets painted.
|
|
207
|
-
callback: function(value) {
|
|
208
|
-
var l = this.getLabelForValue(value);
|
|
209
|
-
return l.length > 24 ? l.slice(0, 23) + '…' : l;
|
|
210
|
-
}
|
|
211
|
-
},
|
|
212
|
-
grid: { display: false },
|
|
213
|
-
border: { color: '#D6D4C8' }
|
|
214
|
-
}
|
|
215
|
-
},
|
|
216
|
-
plugins: {
|
|
217
|
-
legend: {
|
|
218
|
-
position: 'bottom',
|
|
219
|
-
labels: {
|
|
220
|
-
font: { size: 9, family: 'Inter, sans-serif' }, color: '#5A5A52',
|
|
221
|
-
padding: 12,
|
|
222
|
-
usePointStyle: true, pointStyle: 'circle', boxWidth: 7, boxHeight: 7
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
},
|
|
226
|
-
animation: { onComplete: chartDone }
|
|
227
|
-
}
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
{{#if suiteChartLarge}}
|
|
232
|
-
// Large run: one canvas per page-sized chunk — every suite keeps its own bar.
|
|
233
|
-
var suiteChunks = [
|
|
234
|
-
{{#each suiteChartChunks}}
|
|
235
|
-
{
|
|
236
|
-
id: '{{this.canvasId}}',
|
|
237
|
-
labels: [{{{jsonArray this.rows 'label'}}}],
|
|
238
|
-
passed: [{{{jsonArray this.rows 'passed'}}}],
|
|
239
|
-
failed: [{{{jsonArray this.rows 'failed'}}}],
|
|
240
|
-
timedOut: [{{{jsonArray this.rows 'timedOut'}}}],
|
|
241
|
-
skipped: [{{{jsonArray this.rows 'skipped'}}}]
|
|
242
|
-
}{{#unless @last}},{{/unless}}
|
|
243
|
-
{{/each}}
|
|
244
|
-
];
|
|
245
|
-
remaining += suiteChunks.length;
|
|
246
|
-
suiteChunks.forEach(function(c) {
|
|
247
|
-
new Chart(document.getElementById(c.id), suiteChartConfig(c.labels, c.passed, c.failed, c.timedOut, c.skipped));
|
|
248
|
-
});
|
|
249
|
-
{{else}}
|
|
250
|
-
remaining += 1;
|
|
251
|
-
new Chart(document.getElementById('suitesBarChart'), suiteChartConfig(
|
|
252
|
-
[{{{jsonArray charts.suiteResults 'label'}}}],
|
|
253
|
-
[{{{jsonArray charts.suiteResults 'passed'}}}],
|
|
254
|
-
[{{{jsonArray charts.suiteResults 'failed'}}}],
|
|
255
|
-
[{{{jsonArray charts.suiteResults 'timedOut'}}}],
|
|
256
|
-
[{{{jsonArray charts.suiteResults 'skipped'}}}]
|
|
257
|
-
));
|
|
258
|
-
{{/if}}
|
|
259
|
-
|
|
260
|
-
{{#if options.showTrend}}{{#if charts.trend}}
|
|
261
|
-
// entries are newest-first in storage; reverse for oldest-left (chronological) display
|
|
262
|
-
var trendRaw = {{{safeJsonData charts.trend.entries}}};
|
|
263
|
-
var trendData = trendRaw.slice().reverse();
|
|
264
|
-
var trendMonths = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
265
|
-
|
|
266
|
-
var trendLabels = trendData.map(function(e) {
|
|
267
|
-
var d = new Date(e.timestamp);
|
|
268
|
-
return trendMonths[d.getMonth()] + ' ' + d.getDate() + ' ' +
|
|
269
|
-
String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
270
|
-
});
|
|
271
|
-
var trendRates = trendData.map(function(e) { return e.passRate; });
|
|
272
|
-
|
|
273
|
-
// Points colored by verdict for instant visual distinction across runs
|
|
274
|
-
var trendPointColors = trendData.map(function(e) {
|
|
275
|
-
return e.verdict === 'passed' ? '#3F7D58' :
|
|
276
|
-
e.verdict === 'failed' ? '#C3553C' :
|
|
277
|
-
e.verdict === 'partial' ? '#CA8A04' : '#7A786E';
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
// Smart y-axis: pad above/below the actual data range so variation is readable
|
|
281
|
-
// rather than a flat line at the top of a 0–100 axis when all runs pass at 96%.
|
|
282
|
-
// Pick the step FIRST, then snap the bounds to multiples of that step, so every
|
|
283
|
-
// tick (min, min+step, … max) is a round value — never 45% / 70% from a min that
|
|
284
|
-
// isn't itself a multiple of the step. Clamps to 0/100 keep the bounds round too.
|
|
285
|
-
var minRate = Math.min.apply(null, trendRates);
|
|
286
|
-
var maxRate = Math.max.apply(null, trendRates);
|
|
287
|
-
var yPad = Math.max(8, Math.round((maxRate - minRate) * 0.4));
|
|
288
|
-
var rawMin = Math.max(0, minRate - yPad);
|
|
289
|
-
var rawMax = Math.min(100, maxRate + yPad);
|
|
290
|
-
var yStep = (rawMax - rawMin) <= 20 ? 5 : (rawMax - rawMin) <= 50 ? 10 : 25;
|
|
291
|
-
var yMin = Math.max(0, Math.floor(rawMin / yStep) * yStep);
|
|
292
|
-
var yMax = Math.min(100, Math.ceil(rawMax / yStep) * yStep);
|
|
293
|
-
|
|
294
|
-
// Data-label plugin: renders pass-rate % above each point.
|
|
295
|
-
// Canvas ID guard prevents firing on the doughnut and bar charts.
|
|
296
|
-
var trendLabelPlugin = {
|
|
297
|
-
id: 'trendLabels',
|
|
298
|
-
afterDatasetsDraw: function(chart) {
|
|
299
|
-
if (chart.canvas.id !== 'trendChart') return;
|
|
300
|
-
var ctx2d = chart.ctx;
|
|
301
|
-
var meta = chart.getDatasetMeta(0);
|
|
302
|
-
var ds = chart.data.datasets[0];
|
|
303
|
-
meta.data.forEach(function(pt, i) {
|
|
304
|
-
ctx2d.save();
|
|
305
|
-
ctx2d.font = '700 8.5px Inter, sans-serif';
|
|
306
|
-
ctx2d.fillStyle = trendPointColors[i];
|
|
307
|
-
ctx2d.textAlign = 'center';
|
|
308
|
-
ctx2d.textBaseline = 'bottom';
|
|
309
|
-
ctx2d.fillText(ds.data[i] + '%', pt.x, pt.y - 7);
|
|
310
|
-
ctx2d.restore();
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
};
|
|
314
|
-
Chart.register(trendLabelPlugin);
|
|
315
|
-
|
|
316
|
-
var trendCanvas = document.getElementById('trendChart');
|
|
317
|
-
var trendCtx2d = trendCanvas.getContext('2d');
|
|
318
|
-
var trendGrad = trendCtx2d.createLinearGradient(0, 0, 0, 160);
|
|
319
|
-
trendGrad.addColorStop(0, 'rgba(204,120,92,0.22)');
|
|
320
|
-
trendGrad.addColorStop(1, 'rgba(204,120,92,0.02)');
|
|
321
|
-
|
|
322
|
-
remaining += 1;
|
|
323
|
-
new Chart(trendCanvas, {
|
|
324
|
-
type: 'line',
|
|
325
|
-
data: {
|
|
326
|
-
labels: trendLabels,
|
|
327
|
-
datasets: [{
|
|
328
|
-
data: trendRates,
|
|
329
|
-
borderColor: '#CC785C',
|
|
330
|
-
backgroundColor: trendGrad,
|
|
331
|
-
fill: true,
|
|
332
|
-
tension: 0.35,
|
|
333
|
-
pointRadius: 5,
|
|
334
|
-
pointBackgroundColor: trendPointColors,
|
|
335
|
-
pointBorderColor: '#FFFFFF',
|
|
336
|
-
pointBorderWidth: 2,
|
|
337
|
-
borderWidth: 2,
|
|
338
|
-
}]
|
|
339
|
-
},
|
|
340
|
-
options: {
|
|
341
|
-
animation: { onComplete: chartDone },
|
|
342
|
-
responsive: false,
|
|
343
|
-
layout: { padding: { top: 22, right: 10, bottom: 4, left: 4 } },
|
|
344
|
-
scales: {
|
|
345
|
-
y: {
|
|
346
|
-
min: yMin,
|
|
347
|
-
max: yMax,
|
|
348
|
-
ticks: {
|
|
349
|
-
callback: function(v) { return v + '%'; },
|
|
350
|
-
font: { size: 8.5, family: 'Inter, sans-serif' },
|
|
351
|
-
color: '#8C8B82',
|
|
352
|
-
stepSize: yStep,
|
|
353
|
-
},
|
|
354
|
-
grid: { color: 'rgba(31,31,28,0.07)' },
|
|
355
|
-
border: { color: 'transparent' },
|
|
356
|
-
},
|
|
357
|
-
x: {
|
|
358
|
-
ticks: {
|
|
359
|
-
font: { size: 8, family: 'Inter, sans-serif' },
|
|
360
|
-
color: '#8C8B82',
|
|
361
|
-
maxRotation: 0,
|
|
362
|
-
},
|
|
363
|
-
grid: { display: false },
|
|
364
|
-
border: { color: '#E5E3D8' },
|
|
365
|
-
}
|
|
366
|
-
},
|
|
367
|
-
plugins: { legend: { display: false } },
|
|
368
|
-
}
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
// Run history table — oldest-first (top→bottom matches chart left→right).
|
|
372
|
-
// Built via DOM methods (not innerHTML) so no HTML-injection risk.
|
|
373
|
-
var histEl = document.getElementById('run-history-table');
|
|
374
|
-
if (histEl) {
|
|
375
|
-
trendData.forEach(function(e) {
|
|
376
|
-
var d = new Date(e.timestamp);
|
|
377
|
-
var dateStr = trendMonths[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear() +
|
|
378
|
-
' · ' +
|
|
379
|
-
String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
380
|
-
var durStr = e.duration >= 60000
|
|
381
|
-
? Math.floor(e.duration / 60000) + 'm ' + Math.round((e.duration % 60000) / 1000) + 's'
|
|
382
|
-
: (e.duration / 1000).toFixed(1) + 's';
|
|
383
|
-
var dotCls = e.verdict === 'passed' ? 'verdict-passed' :
|
|
384
|
-
e.verdict === 'failed' ? 'verdict-failed' :
|
|
385
|
-
e.verdict === 'partial' ? 'verdict-partial' : 'verdict-unknown';
|
|
386
|
-
var rateCls = e.verdict === 'passed' ? 'rh-rate--pass' :
|
|
387
|
-
e.verdict === 'failed' ? 'rh-rate--fail' :
|
|
388
|
-
e.verdict === 'partial' ? 'rh-rate--partial' : '';
|
|
389
|
-
|
|
390
|
-
var row = document.createElement('div');
|
|
391
|
-
row.className = 'rh-item rh-item--' + e.verdict;
|
|
392
|
-
|
|
393
|
-
var dot = document.createElement('span');
|
|
394
|
-
dot.className = 'rh-dot verdict-dot ' + dotCls;
|
|
395
|
-
row.appendChild(dot);
|
|
396
|
-
|
|
397
|
-
var dateEl = document.createElement('span');
|
|
398
|
-
dateEl.className = 'rh-date';
|
|
399
|
-
dateEl.textContent = dateStr;
|
|
400
|
-
row.appendChild(dateEl);
|
|
401
|
-
|
|
402
|
-
var rateEl = document.createElement('span');
|
|
403
|
-
rateEl.className = 'rh-rate' + (rateCls ? ' ' + rateCls : '');
|
|
404
|
-
rateEl.textContent = e.passRate + '%';
|
|
405
|
-
row.appendChild(rateEl);
|
|
406
|
-
|
|
407
|
-
var testsEl = document.createElement('span');
|
|
408
|
-
testsEl.className = 'rh-tests';
|
|
409
|
-
testsEl.textContent = e.total + ' tests';
|
|
410
|
-
row.appendChild(testsEl);
|
|
411
|
-
|
|
412
|
-
var durEl = document.createElement('span');
|
|
413
|
-
durEl.className = 'rh-dur';
|
|
414
|
-
durEl.textContent = durStr;
|
|
415
|
-
row.appendChild(durEl);
|
|
416
|
-
|
|
417
|
-
histEl.appendChild(row);
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
{{/if}}{{/if}}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
if (document.fonts && document.fonts.ready && typeof document.fonts.ready.then === 'function') {
|
|
424
|
-
document.fonts.ready.then(initCharts, initCharts);
|
|
425
|
-
setTimeout(initCharts, 3000);
|
|
426
|
-
} else {
|
|
427
|
-
initCharts();
|
|
428
|
-
}
|
|
429
|
-
})();
|
|
430
|
-
</script>
|
|
431
|
-
{{else}}
|
|
432
|
-
<script>window.__chartsReady = true;</script>
|
|
433
|
-
{{/if}}
|
|
1
|
+
{{#if options.showCharts}}
|
|
2
|
+
{{#if options.heroTrend}}
|
|
3
|
+
{{> chart-summary-grid options=options}}
|
|
4
|
+
{{> chart-trend-card options=options}}
|
|
5
|
+
{{else}}
|
|
6
|
+
{{> chart-summary-grid options=options}}
|
|
7
|
+
{{> chart-trend-card options=options}}
|
|
8
|
+
{{/if}}
|
|
9
|
+
|
|
10
|
+
{{#if options.showTrend}}
|
|
11
|
+
{{#if charts.flakinessTable}}
|
|
12
|
+
<div class="flakiness-card">
|
|
13
|
+
<div class="flakiness-card__header">
|
|
14
|
+
<span class="flakiness-card__title">Top flaky tests — last {{charts.flakinessTable.[0].totalRuns}} runs</span>
|
|
15
|
+
</div>
|
|
16
|
+
<table class="flakiness-table">
|
|
17
|
+
<thead>
|
|
18
|
+
<tr>
|
|
19
|
+
<th class="flakiness-table__test">Test</th>
|
|
20
|
+
<th class="flakiness-table__rate">Flake rate</th>
|
|
21
|
+
<th class="flakiness-table__count">Runs flaky</th>
|
|
22
|
+
<th class="flakiness-table__spark">History</th>
|
|
23
|
+
</tr>
|
|
24
|
+
</thead>
|
|
25
|
+
<tbody>
|
|
26
|
+
{{#each charts.flakinessTable}}
|
|
27
|
+
<tr>
|
|
28
|
+
<td class="flakiness-table__test" title="{{this.title}}">{{this.title}}</td>
|
|
29
|
+
<td class="flakiness-table__rate">{{this.flakeRate}}%</td>
|
|
30
|
+
<td class="flakiness-table__count">{{this.flakeCount}} / {{this.totalRuns}}</td>
|
|
31
|
+
<td class="flakiness-table__spark">
|
|
32
|
+
<span class="flaky-spark">
|
|
33
|
+
{{#each this.sparkline}}
|
|
34
|
+
<span class="flaky-dot{{#if this}} flaky-dot--hit{{/if}}"></span>
|
|
35
|
+
{{/each}}
|
|
36
|
+
</span>
|
|
37
|
+
</td>
|
|
38
|
+
</tr>
|
|
39
|
+
{{/each}}
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
</div>
|
|
43
|
+
{{/if}}
|
|
44
|
+
{{/if}}
|
|
45
|
+
|
|
46
|
+
<script>
|
|
47
|
+
(function() {
|
|
48
|
+
// Chart.js measures axis labels with whatever font is available at chart
|
|
49
|
+
// construction. The embedded Inter loads asynchronously (even from a data:
|
|
50
|
+
// URI), so charts built immediately measure ticks with narrower fallback
|
|
51
|
+
// metrics and then PAINT with Inter — wide-glyph labels overflowed their
|
|
52
|
+
// measured gutter by a character. Build every chart only after the document
|
|
53
|
+
// fonts settle; the timeout fallback guards a hung fonts.ready so
|
|
54
|
+
// __chartsReady can never be starved.
|
|
55
|
+
var inited = false;
|
|
56
|
+
function initCharts() {
|
|
57
|
+
if (inited) return;
|
|
58
|
+
inited = true;
|
|
59
|
+
var remaining = 0;
|
|
60
|
+
function chartDone() { if (--remaining === 0) window.__chartsReady = true; }
|
|
61
|
+
|
|
62
|
+
var passed = {{charts.passRate.passed}};
|
|
63
|
+
var failed = {{charts.passRate.failed}};
|
|
64
|
+
var timedOut = {{charts.passRate.timedOut}};
|
|
65
|
+
var skipped = {{charts.passRate.skipped}};
|
|
66
|
+
var flaky = {{charts.passRate.flaky}};
|
|
67
|
+
// Pass rate is the canonical server-side stat (flaky counts as pass, denominator
|
|
68
|
+
// is every test) so the doughnut centre can never disagree with the KPI strip.
|
|
69
|
+
var passRate = {{stats.passRate}};
|
|
70
|
+
|
|
71
|
+
/* ── Centre-text plugin for the doughnut ─────────────────────────────── */
|
|
72
|
+
var centerTextPlugin = {
|
|
73
|
+
id: 'centerText',
|
|
74
|
+
afterDatasetsDraw: function(chart) {
|
|
75
|
+
if (chart.canvas.id !== 'passRateChart') return;
|
|
76
|
+
var ctx = chart.ctx;
|
|
77
|
+
var cx = (chart.chartArea.left + chart.chartArea.right) / 2;
|
|
78
|
+
var cy = (chart.chartArea.top + chart.chartArea.bottom) / 2;
|
|
79
|
+
/* Size the centre text off the doughnut's actual hole, not a fixed px
|
|
80
|
+
value — the executive template's 150px canvas has a smaller inner
|
|
81
|
+
radius than the 186px canvas used elsewhere, and fixed 28px/9px text
|
|
82
|
+
overran the hole into the ring on the smaller size. */
|
|
83
|
+
var arc = chart.getDatasetMeta(0).data[0];
|
|
84
|
+
var innerRadius = (arc && arc.innerRadius) ? arc.innerRadius : 50;
|
|
85
|
+
var mainSize = Math.max(15, Math.round(innerRadius * 0.56));
|
|
86
|
+
var subSize = Math.max(7, Math.round(innerRadius * 0.175));
|
|
87
|
+
ctx.save();
|
|
88
|
+
ctx.textAlign = 'center';
|
|
89
|
+
ctx.textBaseline = 'middle';
|
|
90
|
+
/* large percentage */
|
|
91
|
+
ctx.font = '700 ' + mainSize + 'px "Source Serif Pro", Georgia, serif';
|
|
92
|
+
ctx.fillStyle = '#1F1F1C';
|
|
93
|
+
ctx.fillText(passRate + '%', cx, cy - mainSize * 0.24);
|
|
94
|
+
/* label below */
|
|
95
|
+
ctx.font = '600 ' + subSize + 'px Inter, sans-serif';
|
|
96
|
+
ctx.fillStyle = '#8C8B82';
|
|
97
|
+
ctx.fillText('PASS RATE', cx, cy + mainSize * 0.46);
|
|
98
|
+
ctx.restore();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
Chart.register(centerTextPlugin);
|
|
102
|
+
|
|
103
|
+
/* ── Pass rate doughnut ──────────────────────────────────────────────── */
|
|
104
|
+
// Rounded, slightly separated segments + a thicker ring read as a designed
|
|
105
|
+
// gauge rather than a default pie; the centre % (drawn by centerTextPlugin)
|
|
106
|
+
// scales off the inner radius automatically.
|
|
107
|
+
remaining += 1;
|
|
108
|
+
new Chart(document.getElementById('passRateChart'), {
|
|
109
|
+
type: 'doughnut',
|
|
110
|
+
data: {
|
|
111
|
+
labels: ['Passed', 'Failed', 'Timed Out', 'Skipped', 'Flaky'],
|
|
112
|
+
datasets: [{
|
|
113
|
+
data: [passed, failed, timedOut, skipped, flaky],
|
|
114
|
+
backgroundColor: ['#3F7D58', '#C3553C', '#B5803A', '#7A786E', '#CA8A04'],
|
|
115
|
+
borderColor: ['#FAF9F5', '#FAF9F5', '#FAF9F5', '#FAF9F5', '#FAF9F5'],
|
|
116
|
+
borderWidth: 1,
|
|
117
|
+
borderRadius: 5,
|
|
118
|
+
spacing: 2,
|
|
119
|
+
hoverOffset: 4
|
|
120
|
+
}]
|
|
121
|
+
},
|
|
122
|
+
options: {
|
|
123
|
+
responsive: false,
|
|
124
|
+
cutout: '64%',
|
|
125
|
+
plugins: {
|
|
126
|
+
legend: {
|
|
127
|
+
position: 'bottom',
|
|
128
|
+
labels: {
|
|
129
|
+
font: { size: 9, family: 'Inter, sans-serif' },
|
|
130
|
+
color: '#5A5A52', padding: 10,
|
|
131
|
+
usePointStyle: true, pointStyle: 'circle', boxWidth: 7, boxHeight: 7
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
animation: { onComplete: chartDone }
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/* ── Horizontal suite results bar(s) ─────────────────────────────────── */
|
|
140
|
+
// Muted per-suite total at each bar's end — the count is scannable without
|
|
141
|
+
// tracing gridlines. Guarded by canvas id prefix so it fires on every suite
|
|
142
|
+
// chunk canvas and nothing else.
|
|
143
|
+
var suiteTotalsPlugin = {
|
|
144
|
+
id: 'suiteBarTotals',
|
|
145
|
+
afterDatasetsDraw: function(chart) {
|
|
146
|
+
if (chart.canvas.id.indexOf('suitesBarChart') !== 0) return;
|
|
147
|
+
var ctx = chart.ctx;
|
|
148
|
+
for (var i = 0; i < chart.data.labels.length; i++) {
|
|
149
|
+
var total = 0;
|
|
150
|
+
chart.data.datasets.forEach(function(ds) { total += ds.data[i] || 0; });
|
|
151
|
+
if (!total) continue;
|
|
152
|
+
var y = chart.scales.y.getPixelForValue(i);
|
|
153
|
+
var x = chart.scales.x.getPixelForValue(total);
|
|
154
|
+
ctx.save();
|
|
155
|
+
ctx.font = '600 8.5px Inter, sans-serif';
|
|
156
|
+
ctx.fillStyle = '#8C8B82';
|
|
157
|
+
ctx.textAlign = 'left';
|
|
158
|
+
ctx.textBaseline = 'middle';
|
|
159
|
+
ctx.fillText(String(total), x + 5, y);
|
|
160
|
+
ctx.restore();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
Chart.register(suiteTotalsPlugin);
|
|
165
|
+
|
|
166
|
+
// Shared config so the compact small-run card and the chunked large-run
|
|
167
|
+
// canvases render identically bar-for-bar.
|
|
168
|
+
function suiteChartConfig(labels, passed, failed, timedOut, skipped) {
|
|
169
|
+
return {
|
|
170
|
+
type: 'bar',
|
|
171
|
+
data: {
|
|
172
|
+
labels: labels,
|
|
173
|
+
datasets: [
|
|
174
|
+
{ label: 'Passed', data: passed, backgroundColor: '#3F7D58', borderRadius: 4 },
|
|
175
|
+
{ label: 'Failed', data: failed, backgroundColor: '#C3553C', borderRadius: 4 },
|
|
176
|
+
{ label: 'Timed Out', data: timedOut, backgroundColor: '#B5803A', borderRadius: 4 },
|
|
177
|
+
{ label: 'Skipped', data: skipped, backgroundColor: '#7A786E', borderRadius: 4 }
|
|
178
|
+
]
|
|
179
|
+
},
|
|
180
|
+
options: {
|
|
181
|
+
indexAxis: 'y',
|
|
182
|
+
responsive: false,
|
|
183
|
+
// Left: small gutter margin for the y labels (enlarging scale.width in
|
|
184
|
+
// afterFit is not honoured by the layout pass); exact fit comes from
|
|
185
|
+
// building charts after fonts.ready so tick measurement uses Inter.
|
|
186
|
+
// Right: room for the per-suite total drawn past the bar end.
|
|
187
|
+
layout: { padding: { left: 16, right: 22 } },
|
|
188
|
+
scales: {
|
|
189
|
+
x: {
|
|
190
|
+
stacked: true,
|
|
191
|
+
// maxTicksLimit instead of stepSize:1 — a suite with 40+ tests would
|
|
192
|
+
// otherwise spray 40+ ticks across the axis. precision:0 keeps the
|
|
193
|
+
// auto-chosen steps integral.
|
|
194
|
+
ticks: {
|
|
195
|
+
font: { size: 9, family: 'Inter, sans-serif' }, color: '#5A5A52',
|
|
196
|
+
maxTicksLimit: 12, precision: 0
|
|
197
|
+
},
|
|
198
|
+
grid: { color: 'rgba(31,31,28,0.06)' },
|
|
199
|
+
border: { color: '#D6D4C8' }
|
|
200
|
+
},
|
|
201
|
+
y: {
|
|
202
|
+
stacked: true,
|
|
203
|
+
ticks: {
|
|
204
|
+
font: { size: 10, family: 'Inter, sans-serif' }, color: '#1F1F1C', autoSkip: false,
|
|
205
|
+
// Truncate only very long suite / describe labels; measurement runs
|
|
206
|
+
// post-fonts.ready so the gutter fits exactly what gets painted.
|
|
207
|
+
callback: function(value) {
|
|
208
|
+
var l = this.getLabelForValue(value);
|
|
209
|
+
return l.length > 24 ? l.slice(0, 23) + '…' : l;
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
grid: { display: false },
|
|
213
|
+
border: { color: '#D6D4C8' }
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
plugins: {
|
|
217
|
+
legend: {
|
|
218
|
+
position: 'bottom',
|
|
219
|
+
labels: {
|
|
220
|
+
font: { size: 9, family: 'Inter, sans-serif' }, color: '#5A5A52',
|
|
221
|
+
padding: 12,
|
|
222
|
+
usePointStyle: true, pointStyle: 'circle', boxWidth: 7, boxHeight: 7
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
animation: { onComplete: chartDone }
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
{{#if suiteChartLarge}}
|
|
232
|
+
// Large run: one canvas per page-sized chunk — every suite keeps its own bar.
|
|
233
|
+
var suiteChunks = [
|
|
234
|
+
{{#each suiteChartChunks}}
|
|
235
|
+
{
|
|
236
|
+
id: '{{this.canvasId}}',
|
|
237
|
+
labels: [{{{jsonArray this.rows 'label'}}}],
|
|
238
|
+
passed: [{{{jsonArray this.rows 'passed'}}}],
|
|
239
|
+
failed: [{{{jsonArray this.rows 'failed'}}}],
|
|
240
|
+
timedOut: [{{{jsonArray this.rows 'timedOut'}}}],
|
|
241
|
+
skipped: [{{{jsonArray this.rows 'skipped'}}}]
|
|
242
|
+
}{{#unless @last}},{{/unless}}
|
|
243
|
+
{{/each}}
|
|
244
|
+
];
|
|
245
|
+
remaining += suiteChunks.length;
|
|
246
|
+
suiteChunks.forEach(function(c) {
|
|
247
|
+
new Chart(document.getElementById(c.id), suiteChartConfig(c.labels, c.passed, c.failed, c.timedOut, c.skipped));
|
|
248
|
+
});
|
|
249
|
+
{{else}}
|
|
250
|
+
remaining += 1;
|
|
251
|
+
new Chart(document.getElementById('suitesBarChart'), suiteChartConfig(
|
|
252
|
+
[{{{jsonArray charts.suiteResults 'label'}}}],
|
|
253
|
+
[{{{jsonArray charts.suiteResults 'passed'}}}],
|
|
254
|
+
[{{{jsonArray charts.suiteResults 'failed'}}}],
|
|
255
|
+
[{{{jsonArray charts.suiteResults 'timedOut'}}}],
|
|
256
|
+
[{{{jsonArray charts.suiteResults 'skipped'}}}]
|
|
257
|
+
));
|
|
258
|
+
{{/if}}
|
|
259
|
+
|
|
260
|
+
{{#if options.showTrend}}{{#if charts.trend}}
|
|
261
|
+
// entries are newest-first in storage; reverse for oldest-left (chronological) display
|
|
262
|
+
var trendRaw = {{{safeJsonData charts.trend.entries}}};
|
|
263
|
+
var trendData = trendRaw.slice().reverse();
|
|
264
|
+
var trendMonths = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
265
|
+
|
|
266
|
+
var trendLabels = trendData.map(function(e) {
|
|
267
|
+
var d = new Date(e.timestamp);
|
|
268
|
+
return trendMonths[d.getMonth()] + ' ' + d.getDate() + ' ' +
|
|
269
|
+
String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
270
|
+
});
|
|
271
|
+
var trendRates = trendData.map(function(e) { return e.passRate; });
|
|
272
|
+
|
|
273
|
+
// Points colored by verdict for instant visual distinction across runs
|
|
274
|
+
var trendPointColors = trendData.map(function(e) {
|
|
275
|
+
return e.verdict === 'passed' ? '#3F7D58' :
|
|
276
|
+
e.verdict === 'failed' ? '#C3553C' :
|
|
277
|
+
e.verdict === 'partial' ? '#CA8A04' : '#7A786E';
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Smart y-axis: pad above/below the actual data range so variation is readable
|
|
281
|
+
// rather than a flat line at the top of a 0–100 axis when all runs pass at 96%.
|
|
282
|
+
// Pick the step FIRST, then snap the bounds to multiples of that step, so every
|
|
283
|
+
// tick (min, min+step, … max) is a round value — never 45% / 70% from a min that
|
|
284
|
+
// isn't itself a multiple of the step. Clamps to 0/100 keep the bounds round too.
|
|
285
|
+
var minRate = Math.min.apply(null, trendRates);
|
|
286
|
+
var maxRate = Math.max.apply(null, trendRates);
|
|
287
|
+
var yPad = Math.max(8, Math.round((maxRate - minRate) * 0.4));
|
|
288
|
+
var rawMin = Math.max(0, minRate - yPad);
|
|
289
|
+
var rawMax = Math.min(100, maxRate + yPad);
|
|
290
|
+
var yStep = (rawMax - rawMin) <= 20 ? 5 : (rawMax - rawMin) <= 50 ? 10 : 25;
|
|
291
|
+
var yMin = Math.max(0, Math.floor(rawMin / yStep) * yStep);
|
|
292
|
+
var yMax = Math.min(100, Math.ceil(rawMax / yStep) * yStep);
|
|
293
|
+
|
|
294
|
+
// Data-label plugin: renders pass-rate % above each point.
|
|
295
|
+
// Canvas ID guard prevents firing on the doughnut and bar charts.
|
|
296
|
+
var trendLabelPlugin = {
|
|
297
|
+
id: 'trendLabels',
|
|
298
|
+
afterDatasetsDraw: function(chart) {
|
|
299
|
+
if (chart.canvas.id !== 'trendChart') return;
|
|
300
|
+
var ctx2d = chart.ctx;
|
|
301
|
+
var meta = chart.getDatasetMeta(0);
|
|
302
|
+
var ds = chart.data.datasets[0];
|
|
303
|
+
meta.data.forEach(function(pt, i) {
|
|
304
|
+
ctx2d.save();
|
|
305
|
+
ctx2d.font = '700 8.5px Inter, sans-serif';
|
|
306
|
+
ctx2d.fillStyle = trendPointColors[i];
|
|
307
|
+
ctx2d.textAlign = 'center';
|
|
308
|
+
ctx2d.textBaseline = 'bottom';
|
|
309
|
+
ctx2d.fillText(ds.data[i] + '%', pt.x, pt.y - 7);
|
|
310
|
+
ctx2d.restore();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
Chart.register(trendLabelPlugin);
|
|
315
|
+
|
|
316
|
+
var trendCanvas = document.getElementById('trendChart');
|
|
317
|
+
var trendCtx2d = trendCanvas.getContext('2d');
|
|
318
|
+
var trendGrad = trendCtx2d.createLinearGradient(0, 0, 0, 160);
|
|
319
|
+
trendGrad.addColorStop(0, 'rgba(204,120,92,0.22)');
|
|
320
|
+
trendGrad.addColorStop(1, 'rgba(204,120,92,0.02)');
|
|
321
|
+
|
|
322
|
+
remaining += 1;
|
|
323
|
+
new Chart(trendCanvas, {
|
|
324
|
+
type: 'line',
|
|
325
|
+
data: {
|
|
326
|
+
labels: trendLabels,
|
|
327
|
+
datasets: [{
|
|
328
|
+
data: trendRates,
|
|
329
|
+
borderColor: '#CC785C',
|
|
330
|
+
backgroundColor: trendGrad,
|
|
331
|
+
fill: true,
|
|
332
|
+
tension: 0.35,
|
|
333
|
+
pointRadius: 5,
|
|
334
|
+
pointBackgroundColor: trendPointColors,
|
|
335
|
+
pointBorderColor: '#FFFFFF',
|
|
336
|
+
pointBorderWidth: 2,
|
|
337
|
+
borderWidth: 2,
|
|
338
|
+
}]
|
|
339
|
+
},
|
|
340
|
+
options: {
|
|
341
|
+
animation: { onComplete: chartDone },
|
|
342
|
+
responsive: false,
|
|
343
|
+
layout: { padding: { top: 22, right: 10, bottom: 4, left: 4 } },
|
|
344
|
+
scales: {
|
|
345
|
+
y: {
|
|
346
|
+
min: yMin,
|
|
347
|
+
max: yMax,
|
|
348
|
+
ticks: {
|
|
349
|
+
callback: function(v) { return v + '%'; },
|
|
350
|
+
font: { size: 8.5, family: 'Inter, sans-serif' },
|
|
351
|
+
color: '#8C8B82',
|
|
352
|
+
stepSize: yStep,
|
|
353
|
+
},
|
|
354
|
+
grid: { color: 'rgba(31,31,28,0.07)' },
|
|
355
|
+
border: { color: 'transparent' },
|
|
356
|
+
},
|
|
357
|
+
x: {
|
|
358
|
+
ticks: {
|
|
359
|
+
font: { size: 8, family: 'Inter, sans-serif' },
|
|
360
|
+
color: '#8C8B82',
|
|
361
|
+
maxRotation: 0,
|
|
362
|
+
},
|
|
363
|
+
grid: { display: false },
|
|
364
|
+
border: { color: '#E5E3D8' },
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
plugins: { legend: { display: false } },
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Run history table — oldest-first (top→bottom matches chart left→right).
|
|
372
|
+
// Built via DOM methods (not innerHTML) so no HTML-injection risk.
|
|
373
|
+
var histEl = document.getElementById('run-history-table');
|
|
374
|
+
if (histEl) {
|
|
375
|
+
trendData.forEach(function(e) {
|
|
376
|
+
var d = new Date(e.timestamp);
|
|
377
|
+
var dateStr = trendMonths[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear() +
|
|
378
|
+
' · ' +
|
|
379
|
+
String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
380
|
+
var durStr = e.duration >= 60000
|
|
381
|
+
? Math.floor(e.duration / 60000) + 'm ' + Math.round((e.duration % 60000) / 1000) + 's'
|
|
382
|
+
: (e.duration / 1000).toFixed(1) + 's';
|
|
383
|
+
var dotCls = e.verdict === 'passed' ? 'verdict-passed' :
|
|
384
|
+
e.verdict === 'failed' ? 'verdict-failed' :
|
|
385
|
+
e.verdict === 'partial' ? 'verdict-partial' : 'verdict-unknown';
|
|
386
|
+
var rateCls = e.verdict === 'passed' ? 'rh-rate--pass' :
|
|
387
|
+
e.verdict === 'failed' ? 'rh-rate--fail' :
|
|
388
|
+
e.verdict === 'partial' ? 'rh-rate--partial' : '';
|
|
389
|
+
|
|
390
|
+
var row = document.createElement('div');
|
|
391
|
+
row.className = 'rh-item rh-item--' + e.verdict;
|
|
392
|
+
|
|
393
|
+
var dot = document.createElement('span');
|
|
394
|
+
dot.className = 'rh-dot verdict-dot ' + dotCls;
|
|
395
|
+
row.appendChild(dot);
|
|
396
|
+
|
|
397
|
+
var dateEl = document.createElement('span');
|
|
398
|
+
dateEl.className = 'rh-date';
|
|
399
|
+
dateEl.textContent = dateStr;
|
|
400
|
+
row.appendChild(dateEl);
|
|
401
|
+
|
|
402
|
+
var rateEl = document.createElement('span');
|
|
403
|
+
rateEl.className = 'rh-rate' + (rateCls ? ' ' + rateCls : '');
|
|
404
|
+
rateEl.textContent = e.passRate + '%';
|
|
405
|
+
row.appendChild(rateEl);
|
|
406
|
+
|
|
407
|
+
var testsEl = document.createElement('span');
|
|
408
|
+
testsEl.className = 'rh-tests';
|
|
409
|
+
testsEl.textContent = e.total + ' tests';
|
|
410
|
+
row.appendChild(testsEl);
|
|
411
|
+
|
|
412
|
+
var durEl = document.createElement('span');
|
|
413
|
+
durEl.className = 'rh-dur';
|
|
414
|
+
durEl.textContent = durStr;
|
|
415
|
+
row.appendChild(durEl);
|
|
416
|
+
|
|
417
|
+
histEl.appendChild(row);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
{{/if}}{{/if}}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (document.fonts && document.fonts.ready && typeof document.fonts.ready.then === 'function') {
|
|
424
|
+
document.fonts.ready.then(initCharts, initCharts);
|
|
425
|
+
setTimeout(initCharts, 3000);
|
|
426
|
+
} else {
|
|
427
|
+
initCharts();
|
|
428
|
+
}
|
|
429
|
+
})();
|
|
430
|
+
</script>
|
|
431
|
+
{{else}}
|
|
432
|
+
<script>window.__chartsReady = true;</script>
|
|
433
|
+
{{/if}}
|