Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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());
}
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/seleniumboot/metrics/TestTiming.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
155 changes: 155 additions & 0 deletions src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<TimelineEntry> 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<String, java.util.List<TimelineEntry>> lanes =
new java.util.LinkedHashMap<>();
for (TimelineEntry e : entries) {
lanes.computeIfAbsent(e.thread, k -> new java.util.ArrayList<>()).add(e);
}
for (java.util.List<TimelineEntry> 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("<div class=\"card section-mb timeline-card\">\n");
sb.append(" <div class=\"card-header timeline-header\"><span>Timeline</span>")
.append("<span class=\"timeline-legend\">")
.append("<span class=\"tl-key tl-key-passed\">Passed</span>")
.append("<span class=\"tl-key tl-key-failed\">Failed</span>")
.append("<span class=\"tl-key tl-key-skipped\">Skipped</span>")
.append("</span></div>\n");
sb.append(" <div class=\"card-body timeline-body\">\n");
sb.append(" <div class=\"gantt-wrap\"><div class=\"gantt\">\n");

// Time axis (0..100% of the run span)
sb.append(" <div class=\"gantt-row gantt-axis-row\">")
.append("<div class=\"gantt-lane-name\"></div>")
.append("<div class=\"gantt-track gantt-axis-track\">");
for (int i = 0; i <= 4; i++) {
long offset = Math.round(span * i / 4.0);
sb.append("<span class=\"gantt-tick\" style=\"left:").append(timelinePct(i * 25.0)).append("%\">")
.append(escapeHtml(formatTimelineTick(offset)))
.append("</span>");
}
sb.append("</div></div>\n");

// One lane per thread
for (java.util.Map.Entry<String, java.util.List<TimelineEntry>> lane : lanes.entrySet()) {
sb.append(" <div class=\"gantt-row gantt-lane\">")
.append("<div class=\"gantt-lane-name\" title=\"").append(escapeHtml(lane.getKey())).append("\">")
.append(escapeHtml(lane.getKey())).append("</div>")
.append("<div class=\"gantt-track\">");

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)
+ "&#10;Thread: " + escapeHtml(e.thread)
+ "&#10;Status: " + escapeHtml(e.status)
+ "&#10;Duration: " + formatTimelineDuration(e.duration)
+ "&#10;Started: +" + formatTimelineDuration(Math.max(0, e.start - t0));

sb.append("<div class=\"gantt-bar gantt-bar-").append(timelineStatusSlug(e.status))
.append("\" style=\"left:").append(timelinePct(leftPct))
.append("%;width:").append(timelinePct(widthPct))
.append("%\" title=\"").append(title).append("\">")
.append("<span class=\"gantt-bar-label\">").append(escapeHtml(e.shortName)).append("</span>")
.append("</div>");
}
sb.append("</div></div>\n");
}

sb.append(" </div></div>\n");
sb.append(" </div>\n</div>\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();
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/report/partials/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
</div>
</div>

{{TIMELINE_SECTION}}

<!-- Retry summary -->
{{RETRY_SECTION}}

Expand Down
42 changes: 42 additions & 0 deletions src/main/resources/report/report.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
31 changes: 31 additions & 0 deletions src/test/java/com/seleniumboot/unit/ExecutionMetricsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading