diff --git a/src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java b/src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java index c756668..f9fe6f1 100644 --- a/src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java +++ b/src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java @@ -274,6 +274,7 @@ public static void markEnd(String testId) { ); timing.setTotalTime(total); + timing.setStartedAtMs(start); long logicTime = total - timing.getDriverStartupTime(); timing.setTestExecutionTime(Math.max(logicTime, 0)); @@ -455,6 +456,9 @@ public static void exportToJson() { testEntry.put("driverStartupMs", timing.getDriverStartupTime()); testEntry.put("testLogicMs", timing.getTestExecutionTime()); testEntry.put("totalMs", timing.getTotalTime()); + if (timing.getStartedAtMs() > 0) { + testEntry.put("startedAtMs", timing.getStartedAtMs()); + } if (timing.getScreenshotPath() != null) { testEntry.put("screenshotPath", timing.getScreenshotPath()); } diff --git a/src/main/java/com/seleniumboot/metrics/TestTiming.java b/src/main/java/com/seleniumboot/metrics/TestTiming.java index bcdd80d..94ef92f 100644 --- a/src/main/java/com/seleniumboot/metrics/TestTiming.java +++ b/src/main/java/com/seleniumboot/metrics/TestTiming.java @@ -5,6 +5,7 @@ public class TestTiming { private final String threadName; private long testExecutionTime; private long totalTime; + private long startedAtMs; private String status; private String screenshotPath; @@ -51,6 +52,15 @@ public void setTotalTime(long totalTime) { this.totalTime = totalTime; } + /** Wall-clock start of the test (epoch ms); 0 when not recorded. */ + public long getStartedAtMs() { + return startedAtMs; + } + + public void setStartedAtMs(long startedAtMs) { + this.startedAtMs = startedAtMs; + } + public String getStatus() { return status; } diff --git a/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java b/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java index e9afb06..241d2c2 100644 --- a/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java +++ b/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java @@ -268,6 +268,7 @@ static String buildHtml(JsonNode root) { : ""; String retrySection = buildRetrySection(flakyTests, recoveredTests); String slowestSection = tests != null ? buildSlowestTests(tests) : ""; + String timelineSection = buildTimelineSection(tests); String tagResults = tests != null ? buildTagResultsSection(tests) : ""; String flakinessSection = buildFlakinessSection() + buildTestStatusSection(tests); String failureRows = buildFailureRows(tests, isMatrixRun); @@ -304,6 +305,7 @@ static String buildHtml(JsonNode root) { .replace("{{AI_RUN_SUMMARY}}", aiRunSummary) .replace("{{RETRY_SECTION}}", retrySection) .replace("{{SLOWEST_TESTS}}", slowestSection) + .replace("{{TIMELINE_SECTION}}", timelineSection) .replace("{{TAG_RESULTS}}", tagResults) .replace("{{FLAKINESS_SECTION}}", flakinessSection) .replace("{{DONUT_DATA}}", donutData) @@ -761,6 +763,159 @@ private static String buildSlowestTests(JsonNode tests) { return sb.toString(); } + /** Minimum bar width in percent, so very short tests stay visible. */ + private static final double MIN_TIMELINE_BAR_PCT = 0.4; + + private static final class TimelineEntry { + String testId; + String shortName; + String thread; + String status; + long start; + long end; + long duration; + } + + /** + * Gantt-style timeline: one lane per thread, one bar per test positioned by + * {@code startedAtMs} offset from the earliest start. {@code end = startedAtMs + totalMs}. + * Tests without {@code startedAtMs} are left out; the card is hidden when none have it (e.g. old history files). + * Handles single-thread runs and degrades cleanly. + */ + static String buildTimelineSection(JsonNode tests) { + if (tests == null || !tests.isArray() || tests.size() == 0) return ""; + + java.util.List entries = new java.util.ArrayList<>(); + for (JsonNode test : tests) { + // Tests that never started (e.g. skipped by dependency) have no start time; leave them out + long startedAt = test.hasNonNull("startedAtMs") ? test.get("startedAtMs").asLong() : 0L; + if (startedAt <= 0) continue; + long totalMs = test.has("totalMs") ? test.get("totalMs").asLong() : 0L; + if (totalMs <= 0) continue; + + TimelineEntry e = new TimelineEntry(); + String rawId = test.has("testId") ? test.get("testId").asText() : "unknown"; + int dot = rawId.lastIndexOf('.'); + e.testId = rawId; + e.shortName = dot >= 0 ? rawId.substring(dot + 1) : rawId; + String thread = test.hasNonNull("thread") ? test.get("thread").asText("") : ""; + e.thread = thread.isBlank() ? "unknown" : thread; + e.status = test.has("status") ? test.get("status").asText() : "UNKNOWN"; + e.duration = totalMs; + e.start = startedAt; + e.end = startedAt + totalMs; + entries.add(e); + } + + if (entries.isEmpty()) return ""; + + // Group by thread name; one lane per thread + java.util.Map> lanes = + new java.util.LinkedHashMap<>(); + for (TimelineEntry e : entries) { + lanes.computeIfAbsent(e.thread, k -> new java.util.ArrayList<>()).add(e); + } + for (java.util.List lane : lanes.values()) { + lane.sort(java.util.Comparator.comparingLong(e -> e.start)); + } + + long t0 = Long.MAX_VALUE; + long t1 = Long.MIN_VALUE; + for (TimelineEntry e : entries) { + t0 = Math.min(t0, e.start); + t1 = Math.max(t1, e.end); + } + long span = Math.max(1L, t1 - t0); + + StringBuilder sb = new StringBuilder(); + sb.append("
\n"); + sb.append("
Timeline") + .append("") + .append("Passed") + .append("Failed") + .append("Skipped") + .append("
\n"); + sb.append("
\n"); + sb.append("
\n"); + + // Time axis (0..100% of the run span) + sb.append("
") + .append("
") + .append("
"); + for (int i = 0; i <= 4; i++) { + long offset = Math.round(span * i / 4.0); + sb.append("") + .append(escapeHtml(formatTimelineTick(offset))) + .append(""); + } + sb.append("
\n"); + + // One lane per thread + for (java.util.Map.Entry> lane : lanes.entrySet()) { + sb.append("
") + .append("
") + .append(escapeHtml(lane.getKey())).append("
") + .append("
"); + + for (TimelineEntry e : lane.getValue()) { + double leftPct = (e.start - t0) * 100.0 / span; + double widthPct = e.duration * 100.0 / span; + if (widthPct < MIN_TIMELINE_BAR_PCT) widthPct = MIN_TIMELINE_BAR_PCT; + if (leftPct < 0) leftPct = 0; + if (leftPct + widthPct > 100) leftPct = Math.max(0, 100 - widthPct); + + String title = escapeHtml(e.testId) + + " Thread: " + escapeHtml(e.thread) + + " Status: " + escapeHtml(e.status) + + " Duration: " + formatTimelineDuration(e.duration) + + " Started: +" + formatTimelineDuration(Math.max(0, e.start - t0)); + + sb.append("
") + .append("").append(escapeHtml(e.shortName)).append("") + .append("
"); + } + sb.append("
\n"); + } + + sb.append("
\n"); + sb.append("
\n
\n"); + return sb.toString(); + } + + private static String timelineStatusSlug(String status) { + if (status == null) return "unknown"; + switch (status.toUpperCase(java.util.Locale.ROOT)) { + case "PASSED": return "passed"; + case "FAILED": return "failed"; + case "SKIPPED": return "skipped"; + default: return "unknown"; + } + } + + private static String timelinePct(double value) { + return String.format(java.util.Locale.ROOT, "%.3f", Math.max(0, value)); + } + + private static String formatTimelineTick(long ms) { + if (ms <= 0) return "0s"; + if (ms < 1000) return ms + "ms"; + if (ms < 60_000) return String.format(java.util.Locale.ROOT, "%.1fs", ms / 1000.0); + long totalSeconds = ms / 1000; + return (totalSeconds / 60) + "m " + + String.format(java.util.Locale.ROOT, "%02d", totalSeconds % 60) + "s"; + } + + private static String formatTimelineDuration(long ms) { + if (ms < 1000) return ms + " ms"; + if (ms < 60_000) return String.format(java.util.Locale.ROOT, "%.1f s", ms / 1000.0); + long totalSeconds = ms / 1000; + return (totalSeconds / 60) + "m " + + String.format(java.util.Locale.ROOT, "%02d", totalSeconds % 60) + "s"; + } + private static String buildRecordingCell(String recordingPath) { File gif = new File(recordingPath); String label = gif.getName(); diff --git a/src/main/resources/report/partials/dashboard.html b/src/main/resources/report/partials/dashboard.html index 356559c..6481345 100644 --- a/src/main/resources/report/partials/dashboard.html +++ b/src/main/resources/report/partials/dashboard.html @@ -65,6 +65,8 @@ + {{TIMELINE_SECTION}} + {{RETRY_SECTION}} diff --git a/src/main/resources/report/report.css b/src/main/resources/report/report.css index 4b36b0e..d4d96bc 100644 --- a/src/main/resources/report/report.css +++ b/src/main/resources/report/report.css @@ -164,6 +164,48 @@ .slow-bar { height: 8px; background: var(--primary); border-radius: 4px; } .slow-ms { font-size: 12px; color: var(--text-secondary); width: 70px; text-align: right; flex-shrink: 0; } + /* ── Timeline (Gantt) ── */ + .timeline-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } + .timeline-legend { display: inline-flex; align-items: center; gap: 12px; font-size: 11px; font-weight: 500; color: var(--text-secondary); } + .gantt-wrap { overflow-x: auto; } + .gantt { min-width: 640px; padding: 4px 0; } + .gantt-row { display: grid; grid-template-columns: 130px 1fr; align-items: center; } + .gantt-axis-row { margin-bottom: 6px; } + .gantt-lane-name { + font-size: 12px; color: var(--text-secondary); padding-right: 12px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-family: 'Roboto Mono', monospace; + } + .gantt-track { + position: relative; height: 30px; + background-image: linear-gradient(to right, var(--border) 1px, transparent 1px); + background-size: 25% 100%; + } + .gantt-axis-track { height: 18px; background-image: none; } + .gantt-tick { + position: absolute; top: 0; transform: translateX(-50%); + font-size: 10px; color: var(--text-secondary); white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .gantt-tick:first-child { transform: none; } + .gantt-tick:last-child { transform: translateX(-100%); } + .gantt-bar { + position: absolute; top: 50%; transform: translateY(-50%); + height: 18px; border-radius: 3px; overflow: hidden; min-width: 2px; + display: flex; align-items: center; cursor: default; + box-shadow: 0 1px 2px rgba(0,0,0,.15); + } + .gantt-bar:hover { filter: brightness(1.12); } + .gantt-bar-passed { background: var(--good); } + .gantt-bar-failed { background: var(--bad); } + .gantt-bar-skipped { background: var(--warn); } + .gantt-bar-unknown { background: var(--text-secondary); } + .gantt-bar-label { + font-size: 10px; color: #fff; padding: 0 6px; line-height: 1; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + pointer-events: none; + } + /* ── Layout helpers ── */ .section-mb { margin-bottom: 20px; } .retry-summary-row { margin-bottom: 0; } diff --git a/src/test/java/com/seleniumboot/unit/ExecutionMetricsTest.java b/src/test/java/com/seleniumboot/unit/ExecutionMetricsTest.java index 09c294b..af0cf94 100644 --- a/src/test/java/com/seleniumboot/unit/ExecutionMetricsTest.java +++ b/src/test/java/com/seleniumboot/unit/ExecutionMetricsTest.java @@ -209,6 +209,37 @@ public void exportToJson_includesFlakyCount() throws IOException { assertTrue(json.contains("\"recoveredTests\""), "JSON must include top-level recoveredTests"); } + @Test + public void exportToJson_includesStartAndEndTimestamps() throws Exception { + ExecutionMetrics.markStart("timing-test"); + Thread.sleep(30); + ExecutionMetrics.markEnd("timing-test"); + ExecutionMetrics.recordStatus("timing-test", "PASSED"); + ExecutionMetrics.exportToJson(); + + com.fasterxml.jackson.databind.JsonNode test = new com.fasterxml.jackson.databind.ObjectMapper() + .readTree(new File("target/selenium-boot-metrics.json")) + .path("tests").get(0); + assertTrue(test.has("startedAtMs"), "per-test export must include startedAtMs"); + long start = test.get("startedAtMs").asLong(); + assertTrue(start > 0, "start timestamp must be an absolute epoch value"); + long total = test.get("totalMs").asLong(); + assertTrue(total >= 30, "totalMs must cover the 30 ms sleep"); + assertTrue(System.currentTimeMillis() >= start, "startedAtMs must be <= current time"); + assertTrue(total <= 10_000, "test duration should be reasonable for this test"); + } + + @Test + public void exportToJson_omitsTimestampsWhenTestNeverStarted() throws Exception { + ExecutionMetrics.recordStatus("no-start", "SKIPPED"); + ExecutionMetrics.exportToJson(); + + com.fasterxml.jackson.databind.JsonNode test = new com.fasterxml.jackson.databind.ObjectMapper() + .readTree(new File("target/selenium-boot-metrics.json")) + .path("tests").get(0); + assertFalse(test.has("startedAtMs"), "unstarted test must omit startedAtMs"); + } + @Test public void exportToJson_suiteDurationIsWallClockNotSumOfTests() throws Exception { // two tests running in parallel for ~300ms: summed test time ~600ms, wall-clock ~300ms diff --git a/src/test/java/com/seleniumboot/unit/TimelineReportTest.java b/src/test/java/com/seleniumboot/unit/TimelineReportTest.java new file mode 100644 index 0000000..c1a6d14 --- /dev/null +++ b/src/test/java/com/seleniumboot/unit/TimelineReportTest.java @@ -0,0 +1,149 @@ +package com.seleniumboot.unit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.lang.reflect.Method; + +public class TimelineReportTest { + + private static String timeline(String json) throws Exception { + JsonNode tests = new ObjectMapper().readTree(json); + return timelineNode(tests); + } + + private static String timelineNode(JsonNode tests) throws Exception { + Method m = com.seleniumboot.reporting.HtmlReportGenerator.class + .getDeclaredMethod("buildTimelineSection", JsonNode.class); + m.setAccessible(true); + return (String) m.invoke(null, tests); + } + + private static int count(String haystack, String needle) { + int count = 0; + int from = 0; + while ((from = haystack.indexOf(needle, from)) >= 0) { + count++; + from += needle.length(); + } + return count; + } + + @Test + public void rendersOneLanePerThreadUsingStartedAtMs() throws Exception { + String html = timeline(""" + [ + {"testId":"com.x.A.fast","thread":"pool-1-thread-1","status":"PASSED", + "totalMs":100,"startedAtMs":1000}, + {"testId":"com.x.A.slow","thread":"pool-1-thread-1","status":"FAILED", + "totalMs":400,"startedAtMs":1100}, + {"testId":"com.x.B.other","thread":"pool-1-thread-2","status":"PASSED", + "totalMs":250,"startedAtMs":1050} + ] + """); + + Assert.assertTrue(html.contains("timeline-card"), html); + Assert.assertTrue(html.contains("pool-1-thread-1"), html); + Assert.assertTrue(html.contains("pool-1-thread-2"), html); + Assert.assertEquals(count(html, "gantt-row gantt-lane\""), 2, html); + Assert.assertTrue(html.contains("gantt-bar-passed"), html); + Assert.assertTrue(html.contains("gantt-bar-failed"), html); + Assert.assertTrue(html.contains("left:"), html); + Assert.assertTrue(html.contains("width:"), html); + Assert.assertFalse(html.contains("timeline-note"), html); + } + + @Test + public void overlappingThreadsArePositionedOnTheSameAxis() throws Exception { + String html = timeline(""" + [ + {"testId":"a","thread":"t1","status":"PASSED","totalMs":100,"startedAtMs":1000}, + {"testId":"b","thread":"t2","status":"PASSED","totalMs":100,"startedAtMs":1000} + ] + """); + // Both start at the same instant, so both bars sit at the left edge. + Assert.assertEquals(count(html, "gantt-bar-passed\" style=\"left:0.000%"), 2, html); + } + + @Test + public void oldHistoryWithoutStartedAtMs_hidesCard() throws Exception { + String html = timeline(""" + [ + {"testId":"a","thread":"main","status":"PASSED","totalMs":100}, + {"testId":"b","thread":"main","status":"PASSED","totalMs":300} + ] + """); + + Assert.assertEquals(html, ""); + } + + @Test + public void testWithoutStartedAtMs_isSkippedButOthersStillRender() throws Exception { + String html = timeline(""" + [ + {"testId":"a","thread":"main","status":"PASSED","totalMs":100,"startedAtMs":1000}, + {"testId":"b","thread":"main","status":"SKIPPED"} + ] + """); + Assert.assertTrue(html.contains("timeline-card"), html); + Assert.assertEquals(count(html, "gantt-bar "), 1, html); + } + + @Test + public void skippedTestWithoutStartedAtMs_doesNotHideTimeline() throws Exception { + String html = timeline(""" + [ + {"testId":"com.x.A.ran","thread":"pool-1-thread-1","status":"PASSED","totalMs":100,"startedAtMs":1000}, + {"testId":"com.x.B.skipped","thread":"pool-1-thread-1","status":"SKIPPED","totalMs":0} + ] + """); + + Assert.assertTrue(html.contains("timeline-card"), html); + Assert.assertTrue(html.contains("ran"), html); + Assert.assertFalse(html.contains("com.x.B.skipped"), html); + Assert.assertEquals(count(html, "gantt-row gantt-lane\""), 1, html); + } + + @Test + public void handlesSingleThreadRun() throws Exception { + String html = timeline(""" + [ + {"testId":"a","thread":"main","status":"PASSED","totalMs":100,"startedAtMs":1000}, + {"testId":"b","thread":"main","status":"PASSED","totalMs":200,"startedAtMs":1100} + ] + """); + + Assert.assertEquals(count(html, "gantt-row gantt-lane\""), 1, html); + Assert.assertEquals(count(html, "gantt-bar "), 2, html); + Assert.assertFalse(html.contains("timeline-note"), html); + } + + @Test + public void hidesCardWhenNoTestHasDuration() throws Exception { + String html = timeline(""" + [ + {"testId":"a","status":"PASSED","totalMs":0,"startedAtMs":1000}, + {"testId":"b","thread":"main","status":"SKIPPED","startedAtMs":1000} + ] + """); + + Assert.assertEquals(html, ""); + } + + @Test + public void missingThreadUsesUnknownLane() throws Exception { + String html = timeline(""" + [{"testId":"a","status":"PASSED","totalMs":100,"startedAtMs":1000}] + """); + + Assert.assertTrue(html.contains(">unknown<"), html); + } + + @Test + public void nullOrEmptyTestsRenderNothing() throws Exception { + Assert.assertEquals(timelineNode(null), ""); + Assert.assertEquals(timeline("[]"), ""); + } +}