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");
+ }
+ }
}