diff --git a/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java b/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java index ef49a99..038c4d0 100644 --- a/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java +++ b/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java @@ -163,6 +163,37 @@ static String formatDuration(long ms) { return String.format("%02d:%02d:%02d", totalSeconds / 3600, (totalSeconds % 3600) / 60, totalSeconds % 60); } + /** + * Pass-rate and duration of the last {@code maxRuns} runs from the metrics history, oldest + * first, as a JSON array for the trend charts. Empty array when there is no history. + */ + static String buildTrendData(int maxRuns) { + File dir = ReportPaths.metricsHistoryDir(); + File[] files = dir.isDirectory() ? dir.listFiles((d, n) -> n.endsWith(".json")) : null; + if (files == null || files.length == 0) return "[]"; + java.util.Arrays.sort(files, java.util.Comparator.comparing(File::getName)); + int from = Math.max(0, files.length - maxRuns); + ObjectMapper mapper = new ObjectMapper(); + com.fasterxml.jackson.databind.node.ArrayNode out = mapper.createArrayNode(); + for (int i = from; i < files.length; i++) { + try { + JsonNode n = mapper.readTree(files[i]); + com.fasterxml.jackson.databind.node.ObjectNode run = out.addObject(); + String name = files[i].getName().replaceAll("^.*metrics-|\\.json$", ""); + run.put("label", name.length() >= 13 + ? name.substring(4, 6) + "-" + name.substring(6, 8) + " " + name.substring(9, 11) + ":" + name.substring(11, 13) + : name); + run.put("passRate", n.path("passRate").asDouble(0)); + run.put("failed", n.path("failedTests").asInt(0)); + long ms = n.path("suiteDurationMs").asLong(0); + run.put("durationSec", Math.round((ms > 0 ? ms : n.path("totalTimeMs").asLong(0)) / 100.0) / 10.0); + } catch (Exception ignored) { + // unreadable history file: skip it + } + } + return out.toString(); + } + static String buildHtml(JsonNode root) { String executionPercentiles = @@ -264,6 +295,7 @@ static String buildHtml(JsonNode root) { .replace("{{SLOWEST_TESTS}}", slowestSection) .replace("{{FLAKINESS_SECTION}}", flakinessSection) .replace("{{DONUT_DATA}}", donutData) + .replace("{{TREND_DATA}}", buildTrendData(20)) .replace("{{ROWS}}", rows.toString()) .replace("{{FAILURE_ROWS}}", failureRows) .replace("{{FAILURE_GROUPS}}", failureGroups) diff --git a/src/main/resources/report-template.html b/src/main/resources/report-template.html index 150aed7..bc3445a 100644 --- a/src/main/resources/report-template.html +++ b/src/main/resources/report-template.html @@ -427,6 +427,18 @@

Selenium Boot Report

+ + + {{RETRY_SECTION}} @@ -585,6 +597,23 @@

Selenium Boot Report

}); } +// ── Trend over runs ── +const trendData = {{TREND_DATA}}; +if (window.Chart && trendData.length >= 2) { + document.getElementById('trendRow').style.display = ''; + const labels = trendData.map(r => r.label); + const line = (el, label, data, color, max) => new Chart(document.getElementById(el), { + type: 'line', + data: { labels, datasets: [{ label, data, borderColor: color, backgroundColor: color + '33', + fill: true, tension: .3, pointRadius: 3 }] }, + options: { responsive: true, maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { x: { grid: { display: false } }, y: { beginAtZero: true, max } } } + }); + line('trendRateChart', 'Pass rate (%)', trendData.map(r => r.passRate), '#43a047', 100); + line('trendTimeChart', 'Duration (s)', trendData.map(r => r.durationSec), '#303f9f'); +} + // ── Collapse / expand class groups ── function toggleGroup(groupId) { const members = document.querySelectorAll(`.group-member[data-group="${groupId}"]`); diff --git a/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java b/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java index 9ad1047..341c570 100644 --- a/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java +++ b/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java @@ -101,4 +101,41 @@ public void reportShowsSuiteWallClockInHhMmSs() throws Exception { assertTrue(html.contains("
01:01:01
"), "stat tile shows suite time"); assertFalse(html.contains("Avg Time (ms)"), "per-test average tile is gone"); } + + @Test + public void trendData_isChronological_andLimitedToLastRuns() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("sb-history"); + System.setProperty("seleniumboot.history.dir", dir.toString()); + try { + for (int i = 1; i <= 3; i++) { + java.nio.file.Files.writeString(dir.resolve("selenium-boot-metrics-2026101" + i + "-10300" + i + ".json"), + "{\"passRate\":" + (i * 30) + ".0,\"failedTests\":" + i + ",\"suiteDurationMs\":" + (i * 2000) + "}"); + } + java.nio.file.Files.writeString(dir.resolve("selenium-boot-metrics-20261019-103000.json"), "not json"); + + JsonNode trend = new ObjectMapper().readTree(HtmlReportGenerator.buildTrendData(2)); + assertEquals(trend.size(), 1, "last 2 files taken, the unreadable one skipped"); + assertEquals(trend.get(0).get("passRate").asDouble(), 90.0); + assertEquals(trend.get(0).get("durationSec").asDouble(), 6.0); + assertEquals(trend.get(0).get("label").asText(), "10-13 10:30"); + + JsonNode all = new ObjectMapper().readTree(HtmlReportGenerator.buildTrendData(20)); + assertEquals(all.size(), 3); + assertEquals(all.get(0).get("passRate").asDouble(), 30.0, "oldest first"); + } finally { + System.clearProperty("seleniumboot.history.dir"); + } + } + + @Test + public void trendData_emptyWithoutHistory() throws Exception { + System.setProperty("seleniumboot.history.dir", + java.nio.file.Files.createTempDirectory("sb-empty").toString()); + try { + assertEquals(HtmlReportGenerator.buildTrendData(20), "[]"); + assertTrue(html(1, 0).contains("const trendData = [];")); + } finally { + System.clearProperty("seleniumboot.history.dir"); + } + } }