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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

### Unreleased

- **Results by Tag (TestNG groups, Cucumber tags, JUnit 5 @Tag)** — capture test tags across TestNG groups, Cucumber scenario tags, and JUnit 5 `@Tag`, export them in `selenium-boot-metrics.json`, and render a "Results by Tag" card (passed/failed/skipped/pass rate per tag), tag chips on test table rows, and interactive click-to-filter in the HTML report. Absent when no tests have tags. (#167)

- **HTML report: tab and failure fixes** — the report always opens on the Dashboard (it no longer restores the last-open tab), failure details start collapsed, and the Failures tab no longer goes empty after you expand or collapse a class in Test Cases (both tabs shared one group id).

- **Failure Intelligence: high-level triage and suggested actions** — each failure is classified as `Product bug`, `Test bug`, `Environment`, `Flaky` or `Unknown` (flaky keeps its cause, e.g. `Flaky (Locator not found)`), with a rule-based suggested action. Shown in the HTML report, as `failureType` / `suggestedAction` in the metrics JSON, and in the JUnit XML; opt-in AI analysis uses it as a baseline. No API key needed. (#138)
Expand Down
18 changes: 17 additions & 1 deletion docs-site/docs/reporting/html-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ High-level summary of the run:
- **Total / Passed / Failed / Skipped** counts
- **Duration** — total wall-clock time
- **Pass Rate** — percentage of passing tests, colour-coded (green / orange / red)
- **Results by Tag** — breakdown of tests, passed, failed, skipped and pass rate per tag (TestNG groups, Cucumber tags, JUnit 5 `@Tag`), with click-to-filter
- **Retry Summary** — retried, recovered, still-failing counts (shown only when retries occurred)
- **Slowest 5 Tests** — ranked by total duration
- **Driver Startup** percentile chart
Expand All @@ -43,7 +44,7 @@ Full table of all tests with:
| Column | Description |
|---|---|
| Class | Simple class name |
| Test | Test method name |
| Test | Test method name and tag chips |
| Status | PASSED / FAILED / SKIPPED badge |
| Duration | ms |
| Retries | Badge showing retry count (hidden when 0) |
Expand All @@ -59,6 +60,21 @@ Same detail panels as Test Cases tab, but only for failed tests. Detail panels a

---

## Results by tag

Selenium Boot automatically captures test tags across runners with zero configuration and zero runtime overhead:
- **TestNG**: groups defined on `@Test(groups = {"smoke", "regression"})`
- **Cucumber**: scenario tags defined in `.feature` files (e.g. `@smoke`, `@checkout` — leading `@` is stripped for consistency)
- **JUnit 5**: tags defined via `@Tag("smoke")`

When any test has tags:
1. The **Dashboard tab** displays a **Results by Tag** card listing total tests, passed, failed, skipped, and a visual pass-rate progress bar for each tag.
2. Clicking any tag in the "Results by Tag" table or clicking a tag chip on a test case row navigates directly to the **Test Cases** table, sets an active tag filter pill, and filters the table to matching tests.
3. Tags are exported under the `tags` array for each test in `target/selenium-boot-metrics.json` and as `tag` labels in Allure 2 results.
4. When no tests have tags in a run, the "Results by Tag" card is cleanly omitted.

---

## Failure classification & intelligence

Each failed test is tagged with a high-level triage classification (`Product bug`, `Test bug`, `Environment`, `Flaky`), a fine-grained category, and the evidence for it, worked out from the exception class chain and message the framework already records. It also provides actionable remediation advice ("Action: ...") right out of the box with no AI, no API key, and no network. The triage badge, category, and action callout appear at the top of the failure's detail panel; the raw error and stack trace stay right below.
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/seleniumboot/cucumber/CucumberHooks.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ public void beforeScenario(Scenario scenario) {
ExecutionMetrics.markStart(testId);
ExecutionMetrics.recordTestClass(testId, featureTitle(scenario.getUri()));
ExecutionMetrics.recordDescription(testId, scenario.getName());
java.util.Collection<String> scenarioTags = scenario.getSourceTagNames();
if (scenarioTags != null && !scenarioTags.isEmpty()) {
ExecutionMetrics.recordTags(testId, scenarioTags);
}

// 6. Create WebDriver (acquires session semaphore slot)
DriverManager.createDriver();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ public void beforeEach(ExtensionContext context) {
ExecutionMetrics.markStart(testId);
ExecutionMetrics.recordTestClass(testId, context.getRequiredTestClass().getSimpleName());
ExecutionMetrics.recordDescription(testId, context.getDisplayName());
java.util.Set<String> tags = context.getTags();
if (tags != null && !tags.isEmpty()) {
ExecutionMetrics.recordTags(testId, tags);
}

// Quarantine check — abort before any resource is allocated
checkQuarantine(context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ public void onTestStart(ITestResult result) {
ExecutionMetrics.markStart(testId);
ExecutionMetrics.recordTestClass(testId, result.getTestClass().getRealClass().getSimpleName());
ExecutionMetrics.recordDescription(testId, result.getMethod().getDescription());
String[] groups = result.getMethod().getGroups();
if (groups != null && groups.length > 0) {
ExecutionMetrics.recordTags(testId, java.util.Arrays.asList(groups));
}
// Set browser override BEFORE creating driver so DriverProviderFactory can read it
String browserOverride = result.getTestContext().getCurrentXmlTest()
.getParameter("selenium.boot.browser");
Expand Down Expand Up @@ -211,6 +215,10 @@ public void onTestFailure(ITestResult result) {
public void onTestSkipped(ITestResult result) {
if (isCucumberScenario(result)) return;
String testId = result.getMethod().getQualifiedName();
String[] groups = result.getMethod().getGroups();
if (groups != null && groups.length > 0) {
ExecutionMetrics.recordTags(testId, java.util.Arrays.asList(groups));
}
ExecutionMetrics.recordStatus(testId, "SKIPPED");
ExecutionMetrics.markEnd(testId);
HookRegistry.onTestEnd(testId, "SKIPPED");
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@ public static void recordPageContext(String testId, String pageUrl, String pageT
if (pageTitle != null) timing.setPageTitle(pageTitle);
}

public static void recordTags(String testId, java.util.Collection<String> tags) {
if (testId == null || tags == null || tags.isEmpty()) return;
TIMINGS.computeIfAbsent(testId,
id -> new TestTiming(id, Thread.currentThread().getName()))
.setTags(tags);
}

public static void recordConsoleErrors(String testId, List<String> errors) {
if (testId == null || errors == null || errors.isEmpty()) return;
TIMINGS.computeIfAbsent(testId,
Expand Down Expand Up @@ -409,6 +416,7 @@ public static void exportToJson() {
testEntry.put("testClassName", timing.getTestClassName() != null ? timing.getTestClassName() : "");
testEntry.put("thread", timing.getThreadName());
testEntry.put("status", timing.getStatus() != null ? timing.getStatus() : "UNKNOWN");
testEntry.put("tags", timing.getTags());
testEntry.put("retryCount", timing.getRetryCount());
com.seleniumboot.flakiness.TestStatus testStatus = testStatuses.get(timing.getTestId());
if (testStatus != null) {
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/com/seleniumboot/metrics/TestTiming.java
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,30 @@ public java.util.List<String> getConsoleErrors() {
public java.util.List<com.seleniumboot.steps.StepRecord> getSteps() {
return java.util.Collections.unmodifiableList(steps);
}

private final java.util.List<String> tags =
new java.util.concurrent.CopyOnWriteArrayList<>();

public void setTags(java.util.Collection<String> values) {
tags.clear();
if (values != null) {
java.util.LinkedHashSet<String> deduplicated = new java.util.LinkedHashSet<>();
for (String v : values) {
if (v != null && !v.isBlank()) {
String normalized = v.trim();
if (normalized.startsWith("@")) {
normalized = normalized.substring(1).trim();
}
if (!normalized.isEmpty()) {
deduplicated.add(normalized);
}
}
}
tags.addAll(deduplicated);
}
}

public java.util.List<String> getTags() {
return java.util.Collections.unmodifiableList(tags);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ private void writeTestResult(JsonNode test, File outputDir, long suiteEnd) throw
if (test.path("retryCount").asInt(0) > 0) {
addLabel(labels, "flaky", "true");
}
if (test.has("tags")) {
for (JsonNode tagNode : test.path("tags")) {
addLabel(labels, "tag", tagNode.asText());
}
}

// Steps
ArrayNode stepsNode = result.putArray("steps");
Expand Down
129 changes: 128 additions & 1 deletion 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 tagResults = tests != null ? buildTagResultsSection(tests) : "";
String flakinessSection = buildFlakinessSection() + buildTestStatusSection(tests);
String failureRows = buildFailureRows(tests, isMatrixRun);
String failureGroups = buildFailureGroups(tests);
Expand Down Expand Up @@ -299,6 +300,7 @@ static String buildHtml(JsonNode root) {
.replace("{{AI_RUN_SUMMARY}}", aiRunSummary)
.replace("{{RETRY_SECTION}}", retrySection)
.replace("{{SLOWEST_TESTS}}", slowestSection)
.replace("{{TAG_RESULTS}}", tagResults)
.replace("{{FLAKINESS_SECTION}}", flakinessSection)
.replace("{{DONUT_DATA}}", donutData)
.replace("{{TREND_DATA}}", buildTrendData(20))
Expand Down Expand Up @@ -417,15 +419,44 @@ private static String buildRow(JsonNode test, int rowIndex, String groupId, bool
String memberStyle = collapsed ? " style=\"display:none\"" : "";
String iconOpen = (!detailHidden && hasDetail) ? " open" : "";

StringBuilder tagChips = new StringBuilder();
StringBuilder tagDataAttr = new StringBuilder();
if (test.has("tags") && test.get("tags").isArray()) {
for (JsonNode tagNode : test.get("tags")) {
String tag = tagNode.asText().trim();
if (tag.isEmpty()) continue;
if (tagDataAttr.length() > 0) tagDataAttr.append(",");
tagDataAttr.append(escapeHtml(tag.toLowerCase()));
tagChips.append("<span class=\"tag-chip\" data-tag=\"")
.append(escapeHtml(tag))
.append("\" onclick=\"filterByTag(this.dataset.tag, event)\" title=\"Filter by tag: ")
.append(escapeHtml(tag))
.append("\">")
.append(escapeHtml(tag))
.append("</span>");
}
}

String testCellContent;
if (tagChips.length() > 0) {
testCellContent = "<div class=\"test-name-cell\">"
+ "<div class=\"test-name-line\">" + retryBadge + healedBadge + methodName + "</div>"
+ "<div class=\"tag-chips-wrap\">" + tagChips + "</div>"
+ "</div>";
} else {
testCellContent = retryBadge + healedBadge + methodName;
}

String browserCell = showBrowser ? "<td class=\"browser-cell\">" + escapeHtml(browser) + "</td>" : "";

return "<tr class=\"" + rowClass + "\""
+ clickAttr
+ memberStyle
+ " data-group=\"" + groupKey + "\""
+ " data-status=\"" + status + "\""
+ " data-tags=\"" + tagDataAttr + "\""
+ " data-test=\"" + methodName.toLowerCase() + "\">"
+ "<td class=\"test-id\">" + retryBadge + healedBadge + methodName + "</td>"
+ "<td class=\"test-id\">" + testCellContent + "</td>"
+ "<td class=\"desc-cell\">" + description + "</td>"
+ browserCell
+ "<td><span class=\"status-badge " + statusClass + "\">" + status + "</span></td>"
Expand Down Expand Up @@ -880,6 +911,102 @@ private static String buildStepTimeline(JsonNode test) {
return sb.toString();
}


private static class TagStat {
int total = 0;
int passed = 0;
int failed = 0;
int skipped = 0;
}

private static String buildTagResultsSection(JsonNode tests) {
if (tests == null) return "";
java.util.Map<String, TagStat> statsMap = new java.util.LinkedHashMap<>();
for (JsonNode test : tests) {
if (!test.has("tags") || !test.get("tags").isArray()) continue;
String status = test.has("status") ? test.get("status").asText() : "UNKNOWN";
for (JsonNode tagNode : test.get("tags")) {
String tag = tagNode.asText().trim();
if (tag.isEmpty()) continue;
TagStat stat = statsMap.computeIfAbsent(tag, k -> new TagStat());
stat.total++;
if ("PASSED".equalsIgnoreCase(status)) stat.passed++;
else if ("FAILED".equalsIgnoreCase(status)) stat.failed++;
else if ("SKIPPED".equalsIgnoreCase(status)) stat.skipped++;
}
}
if (statsMap.isEmpty()) return "";

java.util.List<String> sortedTags = new java.util.ArrayList<>(statsMap.keySet());
sortedTags.sort(String.CASE_INSENSITIVE_ORDER);

StringBuilder sb = new StringBuilder();
sb.append("<div class=\"card section-mb\" id=\"tagResultsCard\">\n")
.append(" <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center;\">\n")
.append(" <span>Results by Tag</span>\n")
.append(" <span class=\"group-count\">").append(sortedTags.size())
.append(" tag").append(sortedTags.size() != 1 ? "s" : "").append("</span>\n")
.append(" </div>\n")
.append(" <div class=\"card-body\" style=\"padding:16px 24px;\">\n")
.append(" <div class=\"table-wrap\">\n")
.append(" <table class=\"tag-summary-table\">\n")
.append(" <thead>\n")
.append(" <tr>\n")
.append(" <th>Tag</th>\n")
.append(" <th>Total</th>\n")
.append(" <th>Passed</th>\n")
.append(" <th>Failed</th>\n")
.append(" <th>Skipped</th>\n")
.append(" <th>Pass Rate</th>\n")
.append(" </tr>\n")
.append(" </thead>\n")
.append(" <tbody>\n");

for (String tag : sortedTags) {
TagStat stat = statsMap.get(tag);
double rate = stat.total == 0 ? 0.0 : Math.round((stat.passed * 1000.0) / stat.total) / 10.0;
String rateClass = rate >= 80.0 ? "rate-good" : rate >= 60.0 ? "rate-warn" : "rate-bad";
String escapedTag = escapeHtml(tag);

String passedBadge = stat.passed > 0
? "<span class=\"status-badge status-passed\">" + stat.passed + " passed</span>"
: "<span style=\"color:var(--text-secondary);\">0</span>";
String failedBadge = stat.failed > 0
? "<span class=\"status-badge status-failed\">" + stat.failed + " failed</span>"
: "<span style=\"color:var(--text-secondary);\">0</span>";
String skippedBadge = stat.skipped > 0
? "<span class=\"status-badge status-skipped\">" + stat.skipped + " skipped</span>"
: "<span style=\"color:var(--text-secondary);\">0</span>";

sb.append(" <tr>\n")
.append(" <td>\n")
.append(" <span class=\"tag-chip\" data-tag=\"")
.append(escapedTag)
.append("\" onclick=\"filterByTag(this.dataset.tag, event)\" title=\"Filter by tag: ")
.append(escapedTag)
.append("\">")
.append(escapedTag)
.append("</span>\n")
.append(" </td>\n")
.append(" <td><strong>").append(stat.total).append("</strong></td>\n")
.append(" <td>").append(passedBadge).append("</td>\n")
.append(" <td>").append(failedBadge).append("</td>\n")
.append(" <td>").append(skippedBadge).append("</td>\n")
.append(" <td><span class=\"tag-rate ").append(rateClass).append("\">")
.append(String.format(java.util.Locale.US, "%.1f%%", rate))
.append("</span></td>\n")
.append(" </tr>\n");
}

sb.append(" </tbody>\n")
.append(" </table>\n")
.append(" </div>\n")
.append(" </div>\n")
.append("</div>\n");

return sb.toString();
}

private static String escapeHtml(String value) {
if (value == null) return "";
return value.replace("&", "&amp;")
Expand Down
Loading
Loading