diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dcc591..f8eb7ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs-site/docs/reporting/html-report.md b/docs-site/docs/reporting/html-report.md index 905fc6c..5fd9f24 100644 --- a/docs-site/docs/reporting/html-report.md +++ b/docs-site/docs/reporting/html-report.md @@ -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 @@ -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) | @@ -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. diff --git a/src/main/java/com/seleniumboot/cucumber/CucumberHooks.java b/src/main/java/com/seleniumboot/cucumber/CucumberHooks.java index 0b488b6..d2bbefa 100644 --- a/src/main/java/com/seleniumboot/cucumber/CucumberHooks.java +++ b/src/main/java/com/seleniumboot/cucumber/CucumberHooks.java @@ -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 scenarioTags = scenario.getSourceTagNames(); + if (scenarioTags != null && !scenarioTags.isEmpty()) { + ExecutionMetrics.recordTags(testId, scenarioTags); + } // 6. Create WebDriver (acquires session semaphore slot) DriverManager.createDriver(); diff --git a/src/main/java/com/seleniumboot/junit5/SeleniumBootExtension.java b/src/main/java/com/seleniumboot/junit5/SeleniumBootExtension.java index 5862f56..9ccf772 100644 --- a/src/main/java/com/seleniumboot/junit5/SeleniumBootExtension.java +++ b/src/main/java/com/seleniumboot/junit5/SeleniumBootExtension.java @@ -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 tags = context.getTags(); + if (tags != null && !tags.isEmpty()) { + ExecutionMetrics.recordTags(testId, tags); + } // Quarantine check — abort before any resource is allocated checkQuarantine(context); diff --git a/src/main/java/com/seleniumboot/listeners/TestExecutionListener.java b/src/main/java/com/seleniumboot/listeners/TestExecutionListener.java index 5205f0b..d765c84 100644 --- a/src/main/java/com/seleniumboot/listeners/TestExecutionListener.java +++ b/src/main/java/com/seleniumboot/listeners/TestExecutionListener.java @@ -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"); @@ -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"); diff --git a/src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java b/src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java index 2d62449..e083efd 100644 --- a/src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java +++ b/src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java @@ -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 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 errors) { if (testId == null || errors == null || errors.isEmpty()) return; TIMINGS.computeIfAbsent(testId, @@ -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) { diff --git a/src/main/java/com/seleniumboot/metrics/TestTiming.java b/src/main/java/com/seleniumboot/metrics/TestTiming.java index b67f96a..f3f66b8 100644 --- a/src/main/java/com/seleniumboot/metrics/TestTiming.java +++ b/src/main/java/com/seleniumboot/metrics/TestTiming.java @@ -145,4 +145,30 @@ public java.util.List getConsoleErrors() { public java.util.List getSteps() { return java.util.Collections.unmodifiableList(steps); } + + private final java.util.List tags = + new java.util.concurrent.CopyOnWriteArrayList<>(); + + public void setTags(java.util.Collection values) { + tags.clear(); + if (values != null) { + java.util.LinkedHashSet 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 getTags() { + return java.util.Collections.unmodifiableList(tags); + } } diff --git a/src/main/java/com/seleniumboot/reporting/AllureReportAdapter.java b/src/main/java/com/seleniumboot/reporting/AllureReportAdapter.java index 09564e1..d1f5a6d 100644 --- a/src/main/java/com/seleniumboot/reporting/AllureReportAdapter.java +++ b/src/main/java/com/seleniumboot/reporting/AllureReportAdapter.java @@ -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"); diff --git a/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java b/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java index b6cffb5..f95ef32 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 tagResults = tests != null ? buildTagResultsSection(tests) : ""; String flakinessSection = buildFlakinessSection() + buildTestStatusSection(tests); String failureRows = buildFailureRows(tests, isMatrixRun); String failureGroups = buildFailureGroups(tests); @@ -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)) @@ -417,6 +419,34 @@ 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("") + .append(escapeHtml(tag)) + .append(""); + } + } + + String testCellContent; + if (tagChips.length() > 0) { + testCellContent = "
" + + "
" + retryBadge + healedBadge + methodName + "
" + + "
" + tagChips + "
" + + "
"; + } else { + testCellContent = retryBadge + healedBadge + methodName; + } + String browserCell = showBrowser ? "" + escapeHtml(browser) + "" : ""; return "" - + "" + retryBadge + healedBadge + methodName + "" + + "" + testCellContent + "" + "" + description + "" + browserCell + "" + status + "" @@ -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 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 sortedTags = new java.util.ArrayList<>(statsMap.keySet()); + sortedTags.sort(String.CASE_INSENSITIVE_ORDER); + + StringBuilder sb = new StringBuilder(); + sb.append("
\n") + .append("
\n") + .append(" Results by Tag\n") + .append(" ").append(sortedTags.size()) + .append(" tag").append(sortedTags.size() != 1 ? "s" : "").append("\n") + .append("
\n") + .append("
\n") + .append("
\n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \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 + ? "" + stat.passed + " passed" + : "0"; + String failedBadge = stat.failed > 0 + ? "" + stat.failed + " failed" + : "0"; + String skippedBadge = stat.skipped > 0 + ? "" + stat.skipped + " skipped" + : "0"; + + sb.append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n"); + } + + sb.append(" \n") + .append("
TagTotalPassedFailedSkippedPass Rate
\n") + .append(" ") + .append(escapedTag) + .append("\n") + .append(" ").append(stat.total).append("").append(passedBadge).append("").append(failedBadge).append("").append(skippedBadge).append("") + .append(String.format(java.util.Locale.US, "%.1f%%", rate)) + .append("
\n") + .append("
\n") + .append("
\n") + .append("
\n"); + + return sb.toString(); + } + private static String escapeHtml(String value) { if (value == null) return ""; return value.replace("&", "&") diff --git a/src/main/resources/report-template.html b/src/main/resources/report-template.html index f5cfcd5..63b7063 100644 --- a/src/main/resources/report-template.html +++ b/src/main/resources/report-template.html @@ -320,6 +320,46 @@ .footer { text-align: center; padding: 20px; font-size: 12px; color: var(--text-secondary); } + /* ── Tag Chips & Tag Table ── */ + .tag-chips-wrap { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; } + .tag-chip { + display: inline-flex; align-items: center; + background: var(--surface2); color: var(--primary); + border: 1px solid var(--border); border-radius: 12px; + padding: 1px 8px; font-size: 11px; font-weight: 500; + cursor: pointer; transition: all .15s ease; + font-family: 'Roboto', sans-serif; + user-select: none; -webkit-user-select: none; + } + .tag-chip:hover { + background: var(--primary); color: #fff; border-color: var(--primary); + } + .active-tag-pill { + display: inline-flex; align-items: center; gap: 6px; + background: var(--primary-light); color: var(--primary); + border: 1px solid var(--primary); border-radius: 14px; + padding: 2px 10px; font-size: 12px; font-weight: 600; + } + .tag-clear-btn { + background: none; border: none; color: inherit; + font-size: 14px; line-height: 1; cursor: pointer; + padding: 0; font-weight: 700; opacity: .7; + } + .tag-clear-btn:hover { opacity: 1; } + .tag-summary-table { width: 100%; border-collapse: collapse; } + .tag-summary-table th { + background: transparent; color: var(--text-secondary); + font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: .5px; padding: 8px 12px; border-bottom: 2px solid var(--border); + text-align: left; + } + .tag-summary-table td { + padding: 10px 12px; font-size: 13px; border-bottom: 1px solid var(--border); + vertical-align: middle; + } + .tag-summary-table tr:hover { background: var(--hover); } + .tag-rate { font-weight: 700; font-size: 13px; } + /* ── Responsive ── */ @media (max-width: 768px) { .layout { flex-direction: column; } @@ -461,6 +501,8 @@

Selenium Boot Report

{{SLOWEST_TESTS}}
+ {{TAG_RESULTS}} + {{FLAKINESS_SECTION}} @@ -480,9 +522,13 @@

Selenium Boot Report

+
🔍 - +
@@ -635,6 +681,31 @@

Selenium Boot Report

if (icon) icon.classList.toggle('open', !isOpen); } +// ── Tag filtering ── +let activeTagFilter = ''; +function filterByTag(tagName, event) { + if (event) event.stopPropagation(); + activeTagFilter = (tagName || '').toLowerCase().trim(); + showTab('tests', document.querySelector('.nav-item[onclick*=\'tests\']')); + const pill = document.getElementById('activeTagPill'); + const nameSpan = document.getElementById('activeTagName'); + if (pill && nameSpan) { + if (activeTagFilter) { + nameSpan.textContent = 'Tag: ' + tagName; + pill.style.display = 'inline-flex'; + } else { + pill.style.display = 'none'; + } + } + applySearch(); +} +function clearTagFilter() { + activeTagFilter = ''; + const pill = document.getElementById('activeTagPill'); + if (pill) pill.style.display = 'none'; + applySearch(); +} + // ── Filter + search (Test Cases tab) ── let activeFilter = 'ALL'; function setFilter(status, btn) { @@ -651,12 +722,17 @@

Selenium Boot Report

const rowStatus = row.getAttribute('data-status') || ''; const rowTest = row.getAttribute('data-test') || ''; const groupId = row.getAttribute('data-group') || ''; + const rowTags = (row.getAttribute('data-tags') || '').split(',').map(t => t.trim().toLowerCase()).filter(Boolean); const hasRetry = row.querySelector('.retry-badge') !== null; const matchFilter = activeFilter === 'ALL' || activeFilter === rowStatus || (activeFilter === 'FLAKY' && hasRetry); - const matchSearch = !q || rowTest.includes(q) || groupId.toLowerCase().includes(q); - const show = matchFilter && matchSearch; + const matchTag = !activeTagFilter || rowTags.includes(activeTagFilter); + const matchSearch = !q + || rowTest.includes(q) + || groupId.toLowerCase().includes(q) + || rowTags.some(t => t.includes(q)); + const show = matchFilter && matchTag && matchSearch; row.style.display = show ? '' : 'none'; if (show) visibleGroups.add(groupId); const next = row.nextElementSibling; diff --git a/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java b/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java index ab36bf4..179399a 100644 --- a/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java +++ b/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java @@ -152,4 +152,106 @@ public void aiRunSummary_rendersEscaped_andIsAbsentWithout() throws Exception { assertTrue(html.contains("AI Run Summary")); assertTrue(html.contains("Most failures share <b>one</b> cause."), "summary text is HTML-escaped"); } + + @Test + public void resultsByTag_omittedWhenNoTags() throws Exception { + String reportHtml = html(3, 0); + assertFalse(reportHtml.contains("Results by Tag"), "card must be omitted when no tests have tags"); + assertFalse(reportHtml.contains("id=\"tagResultsCard\"")); + } + + @Test + public void resultsByTag_renderedWhenTagsPresent() throws Exception { + String json = """ +{ + "totalTests": 3, + "passedTests": 2, + "failedTests": 1, + "skippedTests": 0, + "passRate": 66.7, + "tests": [ + { + "testId": "com.example.LoginTest.validLogin", + "testClassName": "LoginTest", + "status": "PASSED", + "tags": ["smoke", "auth"], + "testLogicMs": 100, + "totalMs": 150 + }, + { + "testId": "com.example.LoginTest.invalidLogin", + "testClassName": "LoginTest", + "status": "FAILED", + "tags": ["auth"], + "testLogicMs": 200, + "totalMs": 250, + "errorMessage": "login failed" + }, + { + "testId": "com.example.CheckoutTest.placeOrder", + "testClassName": "CheckoutTest", + "status": "PASSED", + "tags": ["smoke"], + "testLogicMs": 300, + "totalMs": 350 + } + ] + }"""; + JsonNode root = new ObjectMapper().readTree(json); + String reportHtml = HtmlReportGenerator.buildHtml(root); + + // 1. Dashboard Results by Tag card + assertTrue(reportHtml.contains("id=\"tagResultsCard\""), "tag results card must be present"); + assertTrue(reportHtml.contains("Results by Tag")); + assertTrue(reportHtml.contains("2 tags"), "two unique tags: auth and smoke"); + + // 2. Auth tag stats: 2 total, 1 passed, 1 failed -> 50.0% + assertTrue(reportHtml.contains("data-tag=\"auth\"")); + assertTrue(reportHtml.contains("onclick=\"filterByTag(this.dataset.tag, event)\"")); + assertTrue(reportHtml.contains("50.0%")); + + // 3. Smoke tag stats: 2 total, 2 passed -> 100.0% + assertTrue(reportHtml.contains("data-tag=\"smoke\"")); + assertTrue(reportHtml.contains("100.0%")); + + // 4. Test Cases table rows have data-tags and chips + assertTrue(reportHtml.contains("data-tags=\"smoke,auth\"")); + assertTrue(reportHtml.contains("data-tags=\"auth\"")); + assertTrue(reportHtml.contains("data-tags=\"smoke\"")); + assertTrue(reportHtml.contains("class=\"tag-chip\"")); + + // 5. Active tag filter pill is present in the markup + assertTrue(reportHtml.contains("id=\"activeTagPill\"")); + } + + + @Test + public void buildHtml_tagWithQuote_renderedSafelyWithDataTag() throws Exception { + String json = """ + { + "totals": { "total": 1, "passed": 1, "failed": 0, "skipped": 0 }, + "tests": [ + { + "testId": "com.example.SpecialTagTest.testQuoteTag", + "testClassName": "SpecialTagTest", + "status": "PASSED", + "tags": ["it's", "x');alert(1);//"], + "testLogicMs": 100, + "totalMs": 120 + } + ] + } + """; + JsonNode root = new ObjectMapper().readTree(json); + String reportHtml = HtmlReportGenerator.buildHtml(root); + + // Tags with quotes and injection payloads must be safely placed in data-tag attribute + assertTrue(reportHtml.contains("data-tag=\"it's\""), "tag with quote must be safely in data-tag"); + assertTrue(reportHtml.contains("data-tag=\"x');alert(1);//\""), "tag with JS payload must be safely in data-tag"); + assertTrue(reportHtml.contains("onclick=\"filterByTag(this.dataset.tag, event)\"")); + + // Must NOT interpolate quotes into inline JavaScript calls + assertFalse(reportHtml.contains("filterByTag('it's'")); + assertFalse(reportHtml.contains("filterByTag('x');alert(1);//'")); + } } diff --git a/src/test/java/com/seleniumboot/unit/ExecutionMetricsTest.java b/src/test/java/com/seleniumboot/unit/ExecutionMetricsTest.java index 8a267d8..09c294b 100644 --- a/src/test/java/com/seleniumboot/unit/ExecutionMetricsTest.java +++ b/src/test/java/com/seleniumboot/unit/ExecutionMetricsTest.java @@ -229,6 +229,56 @@ public void exportToJson_suiteDurationIsWallClockNotSumOfTests() throws Exceptio assertTrue(suite >= 300 && suite < total, "wall-clock " + suite + " must be below summed " + total); } + // ---------------------------------------------------------- + // recordTags & tags export + // ---------------------------------------------------------- + + @Test + public void recordTags_normalizesAtPrefix_andDeduplicates() { + ExecutionMetrics.markStart("tag-test-1"); + ExecutionMetrics.markEnd("tag-test-1"); + ExecutionMetrics.recordTags("tag-test-1", List.of("@smoke", "smoke", "@regression", " @fast ", "")); + + TestTiming t = ExecutionMetrics.getTiming("tag-test-1"); + assertNotNull(t); + List tags = t.getTags(); + assertEquals(tags.size(), 3); + assertEquals(tags, List.of("smoke", "regression", "fast")); + } + + @Test + public void exportToJson_includesTagsField() throws IOException { + ExecutionMetrics.markStart("tag-test-2"); + ExecutionMetrics.markEnd("tag-test-2"); + ExecutionMetrics.recordStatus("tag-test-2", "PASSED"); + ExecutionMetrics.recordTags("tag-test-2", List.of("smoke", "checkout")); + ExecutionMetrics.exportToJson(); + + com.fasterxml.jackson.databind.JsonNode root = new com.fasterxml.jackson.databind.ObjectMapper() + .readTree(new File("target/selenium-boot-metrics.json")); + com.fasterxml.jackson.databind.JsonNode tests = root.path("tests"); + assertEquals(tests.size(), 1); + com.fasterxml.jackson.databind.JsonNode testTags = tests.get(0).path("tags"); + assertTrue(testTags.isArray()); + assertEquals(testTags.size(), 2); + assertEquals(testTags.get(0).asText(), "smoke"); + assertEquals(testTags.get(1).asText(), "checkout"); + } + + @Test + public void exportToJson_emptyTags_outputsEmptyArray() throws IOException { + ExecutionMetrics.markStart("tag-test-3"); + ExecutionMetrics.markEnd("tag-test-3"); + ExecutionMetrics.recordStatus("tag-test-3", "PASSED"); + ExecutionMetrics.exportToJson(); + + com.fasterxml.jackson.databind.JsonNode root = new com.fasterxml.jackson.databind.ObjectMapper() + .readTree(new File("target/selenium-boot-metrics.json")); + com.fasterxml.jackson.databind.JsonNode testTags = root.path("tests").get(0).path("tags"); + assertTrue(testTags.isArray()); + assertEquals(testTags.size(), 0); + } + // ---------------------------------------------------------- // reset // ---------------------------------------------------------- diff --git a/src/test/java/com/seleniumboot/unit/TestClockTest.java b/src/test/java/com/seleniumboot/unit/TestClockTest.java index e927c8a..7f71161 100644 --- a/src/test/java/com/seleniumboot/unit/TestClockTest.java +++ b/src/test/java/com/seleniumboot/unit/TestClockTest.java @@ -1,7 +1,9 @@ package com.seleniumboot.unit; import com.seleniumboot.clock.TestClock; +import com.seleniumboot.driver.DriverManager; import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; import java.time.Duration; @@ -15,10 +17,12 @@ */ public class TestClockTest { + @BeforeMethod @AfterMethod public void cleanup() { // Ensure no leftover mock time leaks between tests try { TestClock.autoReset(); } catch (Exception ignored) {} + try { DriverManager.quitDriver(); } catch (Exception ignored) {} } // ── factory ─────────────────────────────────────────────────────────── diff --git a/src/test/java/com/seleniumboot/unit/TestTagsCaptureTest.java b/src/test/java/com/seleniumboot/unit/TestTagsCaptureTest.java new file mode 100644 index 0000000..a25c191 --- /dev/null +++ b/src/test/java/com/seleniumboot/unit/TestTagsCaptureTest.java @@ -0,0 +1,186 @@ +package com.seleniumboot.unit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.seleniumboot.cucumber.CucumberHooks; +import com.seleniumboot.driver.DriverManager; +import org.mockito.MockedStatic; +import com.seleniumboot.junit5.SeleniumBootExtension; +import com.seleniumboot.listeners.TestExecutionListener; +import com.seleniumboot.metrics.ExecutionMetrics; +import com.seleniumboot.metrics.TestTiming; +import io.cucumber.java.Scenario; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.mockito.Mockito; +import org.testng.IClass; +import org.testng.ITestNGMethod; +import org.testng.ITestResult; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.io.File; +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import static org.mockito.Mockito.when; +import static org.testng.Assert.*; + +/** + * Unit tests verifying tag and group capture across TestNG, Cucumber, and JUnit 5. + */ +public class TestTagsCaptureTest { + + @BeforeMethod + @AfterMethod + public void cleanup() { + ExecutionMetrics.reset(); + try { DriverManager.quitDriver(); } catch (Exception ignored) {} + } + + @Test + public void testNg_onTestStart_capturesGroups() { + TestExecutionListener listener = new TestExecutionListener(); + ITestResult result = Mockito.mock(ITestResult.class); + ITestNGMethod method = Mockito.mock(ITestNGMethod.class); + IClass testClass = Mockito.mock(IClass.class); + org.testng.ITestContext context = Mockito.mock(org.testng.ITestContext.class); + org.testng.xml.XmlTest xmlTest = Mockito.mock(org.testng.xml.XmlTest.class); + + when(result.getMethod()).thenReturn(method); + when(result.getTestClass()).thenReturn(testClass); + when(result.getTestContext()).thenReturn(context); + when(context.getCurrentXmlTest()).thenReturn(xmlTest); + when(method.getQualifiedName()).thenReturn("com.example.TestNgSuite.loginTest"); + when(method.getMethodName()).thenReturn("loginTest"); + when(method.getConstructorOrMethod()).thenReturn(new org.testng.internal.ConstructorOrMethod(dummyMethod())); + when(testClass.getRealClass()).thenReturn((Class) DummyTestClass.class); + when(method.getGroups()).thenReturn(new String[]{"smoke", "regression"}); + + listener.onTestStart(result); + + TestTiming timing = ExecutionMetrics.getTiming("com.example.TestNgSuite.loginTest"); + assertNotNull(timing); + assertEquals(timing.getTags(), List.of("smoke", "regression")); + } + + @Test + public void testNg_onTestSkipped_capturesGroups() { + TestExecutionListener listener = new TestExecutionListener(); + ITestResult result = Mockito.mock(ITestResult.class); + ITestNGMethod method = Mockito.mock(ITestNGMethod.class); + IClass testClass = Mockito.mock(IClass.class); + + when(result.getMethod()).thenReturn(method); + when(result.getTestClass()).thenReturn(testClass); + when(testClass.getRealClass()).thenReturn((Class) DummyTestClass.class); + when(method.getQualifiedName()).thenReturn("com.example.TestNgSuite.skippedTest"); + when(method.getMethodName()).thenReturn("skippedTest"); + when(method.getConstructorOrMethod()).thenReturn(new org.testng.internal.ConstructorOrMethod(dummyMethod())); + when(method.getGroups()).thenReturn(new String[]{"nightly"}); + + listener.onTestSkipped(result); + + TestTiming timing = ExecutionMetrics.getTiming("com.example.TestNgSuite.skippedTest"); + assertNotNull(timing); + assertEquals(timing.getStatus(), "SKIPPED"); + assertEquals(timing.getTags(), List.of("nightly")); + } + + @Test + public void cucumber_beforeScenario_capturesAndNormalizesTags() { + CucumberHooks hooks = new CucumberHooks(); + Scenario scenario = Mockito.mock(Scenario.class); + + when(scenario.getUri()).thenReturn(URI.create("classpath:features/checkout.feature")); + when(scenario.getName()).thenReturn("Order item"); + when(scenario.getLine()).thenReturn(15); + when(scenario.getSourceTagNames()).thenReturn(Arrays.asList("@smoke", "@checkout", "fast")); + + try (MockedStatic mocked = Mockito.mockStatic(DriverManager.class)) { + hooks.beforeScenario(scenario); + } catch (Exception ignored) { + } + + String testId = "checkout.feature#Order item[L15]"; + TestTiming timing = ExecutionMetrics.getTiming(testId); + assertNotNull(timing); + assertEquals(timing.getTags(), List.of("smoke", "checkout", "fast")); + } + + @Test + public void junit5_beforeEach_capturesTags() throws Exception { + SeleniumBootExtension extension = new SeleniumBootExtension(); + ExtensionContext context = Mockito.mock(ExtensionContext.class); + + when(context.getRequiredTestClass()).thenReturn((Class) DummyTestClass.class); + when(context.getRequiredTestMethod()).thenReturn(dummyMethod()); + when(context.getDisplayName()).thenReturn("dummyMethod()"); + when(context.getTags()).thenReturn(Set.of("junit5-tag", "@annotated-tag")); + + try (MockedStatic mocked = Mockito.mockStatic(DriverManager.class)) { + extension.beforeEach(context); + } catch (Exception ignored) {} + + String testId = DummyTestClass.class.getName() + "#" + dummyMethod().getName(); + TestTiming timing = ExecutionMetrics.getTiming(testId); + assertNotNull(timing); + assertTrue(timing.getTags().contains("junit5-tag")); + assertTrue(timing.getTags().contains("annotated-tag")); + } + + @Test + public void endToEnd_tagsRecordedAndExportedToJson() throws Exception { + ExecutionMetrics.markStart("test1"); + ExecutionMetrics.recordTags("test1", List.of("@smoke", "api")); + ExecutionMetrics.recordStatus("test1", "PASSED"); + ExecutionMetrics.markEnd("test1"); + + ExecutionMetrics.markStart("test2"); + ExecutionMetrics.recordTags("test2", List.of("regression")); + ExecutionMetrics.recordStatus("test2", "FAILED"); + ExecutionMetrics.markEnd("test2"); + + ExecutionMetrics.exportToJson(); + + JsonNode root = new ObjectMapper().readTree(new File("target/selenium-boot-metrics.json")); + JsonNode tests = root.path("tests"); + assertEquals(tests.size(), 2); + + JsonNode t1Node = null; + JsonNode t2Node = null; + for (JsonNode t : tests) { + if ("test1".equals(t.path("testId").asText())) t1Node = t; + if ("test2".equals(t.path("testId").asText())) t2Node = t; + } + + assertNotNull(t1Node); + assertNotNull(t2Node); + + JsonNode t1Tags = t1Node.path("tags"); + assertEquals(t1Tags.size(), 2); + assertEquals(t1Tags.get(0).asText(), "smoke"); + assertEquals(t1Tags.get(1).asText(), "api"); + + JsonNode t2Tags = t2Node.path("tags"); + assertEquals(t2Tags.size(), 1); + assertEquals(t2Tags.get(0).asText(), "regression"); + } + + private static java.lang.reflect.Method dummyMethod() { + try { + return DummyTestClass.class.getDeclaredMethod("dummyMethod"); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + @com.seleniumboot.test.NoBrowser + static class DummyTestClass { + @com.seleniumboot.test.NoBrowser + void dummyMethod() {} + } +} +