From 643d13350484b324fa0ad4d8dcafa5bf7384e27a Mon Sep 17 00:00:00 2001
From: Pushpak Jaiswal <149317014+PUSHPAK-JAISWAL@users.noreply.github.com>
Date: Tue, 10 Nov 2026 16:47:50 +0530
Subject: [PATCH 1/3] feat(report): export single test as standalone HTML file
(#220)
---
.../reporting/HtmlReportGenerator.java | 11 +-
src/main/resources/report/report.css | 5 +-
src/main/resources/report/report.js | 226 ++++++++++++++++++
3 files changed, 237 insertions(+), 5 deletions(-)
diff --git a/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java b/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java
index 888075f..89dd062 100644
--- a/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java
+++ b/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java
@@ -289,6 +289,7 @@ static String buildHtml(JsonNode root) {
.replace("{{REPORT_CSS}}", loadResource("report/report.css"))
.replace("{{REPORT_JS}}", loadResource("report/report.js"))
.replace("{{REPORT_MARKUP}}", loadMarkup())
+ .replace("{{BROWSER_HEADER}}", isMatrixRun ? "
Browser | " : "")
.replace("{{RUN_SUMMARY}}", metadata[0])
.replace("{{METADATA}}", metadata[1])
.replace("{{PASSED}}", String.valueOf(passedTests))
@@ -395,7 +396,7 @@ private static String buildRow(JsonNode test, int rowIndex, String groupId, bool
String stepsHtml = buildStepTimeline(test);
boolean hasDetail = errorMsg != null || stackTrace != null || test.has("failureCategory") || !stepsHtml.isEmpty()
|| recordingPath != null || aiAnalysis != null || perfNode != null || test.has("testStatus")
- || test.has("failureDetail");
+ || test.has("failureDetail") || "FAILED".equals(status);
String detailRow = "";
if (hasDetail) {
String whyHtml = buildTestStatusBadge(test)
@@ -406,6 +407,8 @@ private static String buildRow(JsonNode test, int rowIndex, String groupId, bool
? ""
: "";
+ String downloadBtn = "";
String recordingHtml = recordingPath != null ? buildRecordingCell(recordingPath) : "";
String traceLink = tracePath != null ? buildTraceLink(tracePath) : "";
String sessionLink = sessionUrl != null ? buildSessionLink(sessionUrl) : "";
@@ -419,7 +422,7 @@ private static String buildRow(JsonNode test, int rowIndex, String groupId, bool
String detailDisplay = detailHidden ? " style=\"display:none\"" : "";
detailRow = ""
+ ""
- + copyBtn + failureDetail + perfHtml + sessionLink + traceLink + stepsSection + recordingHtml + aiHtml + errorHtml + traceHtml
+ + copyBtn + downloadBtn + failureDetail + perfHtml + sessionLink + traceLink + stepsSection + recordingHtml + aiHtml + errorHtml + traceHtml
+ " | "
+ "
";
}
@@ -458,6 +461,7 @@ private static String buildRow(JsonNode test, int rowIndex, String groupId, bool
}
String browserCell = showBrowser ? "" + escapeHtml(browser) + " | " : "";
+ String browserAttr = !browser.isEmpty() ? " data-browser=\"" + escapeHtml(browser) + "\"" : "";
return ""
+ + " data-total-ms=\"" + totalMs + "\""
+ + browserAttr + ">"
+ "| " + testCellContent + " | "
+ "" + description + " | "
+ browserCell
diff --git a/src/main/resources/report/report.css b/src/main/resources/report/report.css
index 751a40f..2c94570 100644
--- a/src/main/resources/report/report.css
+++ b/src/main/resources/report/report.css
@@ -343,8 +343,8 @@
/* ── Detail / error panel ── */
.detail-row td { padding: 0; }
.detail-panel { padding: 16px 24px; background: var(--surface2); border-top: 2px solid var(--bad); }
- .copy-failure-btn { float: right; margin: 0 0 8px 12px; border: 1px solid var(--border); background: var(--surface); color: var(--text-secondary); border-radius: 12px; padding: 2px 12px; font-size: 12px; cursor: pointer; }
- .copy-failure-btn:hover { background: var(--primary); color: #fff; border-color: var(--primary); }
+ .copy-failure-btn, .download-test-btn { float: right; margin: 0 0 8px 12px; border: 1px solid var(--border); background: var(--surface); color: var(--text-secondary); border-radius: 12px; padding: 2px 12px; font-size: 12px; cursor: pointer; transition: background .15s, color .15s, border-color .15s; }
+ .copy-failure-btn:hover, .download-test-btn:hover { background: var(--primary); color: #fff; border-color: var(--primary); }
.error-msg { font-size: 13px; font-weight: 600; color: var(--bad); margin-bottom: 10px; }
.failure-group { padding: 10px 0; border-bottom: 1px solid var(--border); }
.failure-group:last-child { border-bottom: 0; }
@@ -523,6 +523,7 @@
.search-wrap,
.dark-toggle,
.copy-failure-btn,
+ .download-test-btn,
.tag-clear-btn,
.active-tag-pill,
.failures-shortcut,
diff --git a/src/main/resources/report/report.js b/src/main/resources/report/report.js
index 214f128..12915e8 100644
--- a/src/main/resources/report/report.js
+++ b/src/main/resources/report/report.js
@@ -444,6 +444,232 @@ function copyFailure(btn, event) {
}
}
+// ── Export single test as standalone HTML ──
+function escapeHtmlForExport(str) {
+ if (!str) return '';
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function buildStandaloneTestHtml(testId, testRow, panel, activeTheme) {
+ const styleTag = document.querySelector('style');
+ const css = styleTag ? styleTag.textContent : '';
+
+ const hasBrowser = testRow && testRow.querySelector('.browser-cell') !== null;
+ const colspan = hasBrowser ? 8 : 7;
+ const browserTh = hasBrowser ? 'Browser | ' : '';
+
+ const rawGroupName = testRow ? (testRow.getAttribute('data-group') || '').replace(/^failures:/, '') : '';
+ const browser = testRow ? (testRow.getAttribute('data-browser') || (testRow.querySelector('.browser-cell') ? testRow.querySelector('.browser-cell').textContent.trim() : '')) : '';
+
+ let groupTitle = rawGroupName || 'Test Details';
+ if (browser && !groupTitle.includes(browser)) {
+ groupTitle = groupTitle + ' [' + browser + ']';
+ }
+
+ const lastDot = testId.lastIndexOf('.');
+ const methodName = lastDot >= 0 ? testId.substring(lastDot + 1) : testId;
+
+ // Clone test row
+ let testRowHtml = '';
+ if (testRow) {
+ const testRowClone = testRow.cloneNode(true);
+ testRowClone.style.display = '';
+ testRowClone.setAttribute('onclick', 'toggleStandaloneDetail()');
+ const icon = testRowClone.querySelector('.expand-icon');
+ if (icon) {
+ icon.classList.add('open');
+ }
+ testRowHtml = testRowClone.outerHTML;
+ }
+
+ // Clone detail row
+ let detailRowHtml = '';
+ const detailRow = testRow ? testRow.nextElementSibling : null;
+ if (detailRow && detailRow.classList.contains('detail-row')) {
+ const detailRowClone = detailRow.cloneNode(true);
+ detailRowClone.style.display = '';
+ detailRowClone.id = 'standaloneDetailRow';
+ const panelClone = detailRowClone.querySelector('.detail-panel');
+ if (panelClone) {
+ panelClone.querySelectorAll('.copy-failure-btn, .download-test-btn').forEach(function(b) { b.remove(); });
+ }
+ detailRowHtml = detailRowClone.outerHTML;
+ } else if (panel) {
+ const panelClone = panel.cloneNode(true);
+ panelClone.querySelectorAll('.copy-failure-btn, .download-test-btn').forEach(function(b) { b.remove(); });
+ detailRowHtml = '
| '
+ + ' ' + panelClone.innerHTML + ' '
+ + ' |
';
+ }
+
+ const groupHeaderHtml = '';
+
+ const toggleText = activeTheme === 'dark' ? '☀ Light Mode' : '☾ Dark Mode';
+
+ const lightboxHtml = ''
+ + '
×'
+ + '
'
+ + '
![Screenshot]()
'
+ + '
'
+ + '
'
+ + '
';
+
+ const offlineScript = 'var lightboxImgs = [];\n'
+ + 'var lightboxIdx = 0;\n'
+ + 'function openLightbox(img) {\n'
+ + ' lightboxImgs = Array.from(document.querySelectorAll("img.screenshot-thumb, img.step-thumb, img.screenshot-main"));\n'
+ + ' lightboxIdx = lightboxImgs.indexOf(img);\n'
+ + ' if (lightboxIdx < 0) lightboxIdx = 0;\n'
+ + ' showLightbox();\n'
+ + ' document.getElementById("lightbox").classList.add("open");\n'
+ + ' document.body.style.overflow = "hidden";\n'
+ + '}\n'
+ + 'function showLightbox() {\n'
+ + ' if (!lightboxImgs.length) return;\n'
+ + ' document.getElementById("lightbox-img").src = lightboxImgs[lightboxIdx].src;\n'
+ + ' var multi = lightboxImgs.length > 1;\n'
+ + ' var counter = document.getElementById("lightbox-counter");\n'
+ + ' if (counter) counter.textContent = multi ? (lightboxIdx + 1) + " / " + lightboxImgs.length : "";\n'
+ + ' var prev = document.getElementById("lightbox-prev");\n'
+ + ' var next = document.getElementById("lightbox-next");\n'
+ + ' if (prev) { prev.style.display = multi ? "" : "none"; prev.disabled = lightboxIdx === 0; }\n'
+ + ' if (next) { next.style.display = multi ? "" : "none"; next.disabled = lightboxIdx === lightboxImgs.length - 1; }\n'
+ + '}\n'
+ + 'function stepLightbox(delta, e) {\n'
+ + ' if (e) e.stopPropagation();\n'
+ + ' var next = lightboxIdx + delta;\n'
+ + ' if (next < 0 || next >= lightboxImgs.length) return;\n'
+ + ' lightboxIdx = next;\n'
+ + ' showLightbox();\n'
+ + '}\n'
+ + 'function closeLightbox() {\n'
+ + ' var lb = document.getElementById("lightbox");\n'
+ + ' if (lb) lb.classList.remove("open");\n'
+ + ' var img = document.getElementById("lightbox-img");\n'
+ + ' if (img) img.src = "";\n'
+ + ' document.body.style.overflow = "";\n'
+ + '}\n'
+ + 'document.addEventListener("keydown", function(e) {\n'
+ + ' var lb = document.getElementById("lightbox");\n'
+ + ' if (!lb || !lb.classList.contains("open")) return;\n'
+ + ' if (e.key === "Escape") closeLightbox();\n'
+ + ' else if (e.key === "ArrowLeft") stepLightbox(-1);\n'
+ + ' else if (e.key === "ArrowRight") stepLightbox(1);\n'
+ + '});\n'
+ + 'function toggleDark() {\n'
+ + ' var isDark = document.documentElement.getAttribute("data-theme") === "dark";\n'
+ + ' var newTheme = isDark ? "light" : "dark";\n'
+ + ' document.documentElement.setAttribute("data-theme", newTheme);\n'
+ + ' var btn = document.querySelector(".dark-toggle");\n'
+ + ' if (btn) btn.innerHTML = newTheme === "dark" ? "☀ Light Mode" : "☾ Dark Mode";\n'
+ + '}\n'
+ + 'function toggleStandaloneDetail() {\n'
+ + ' var row = document.getElementById("standaloneDetailRow");\n'
+ + ' var icon = document.querySelector(".expand-icon");\n'
+ + ' if (!row) return;\n'
+ + ' var isHidden = row.style.display === "none";\n'
+ + ' row.style.display = isHidden ? "" : "none";\n'
+ + ' if (icon) icon.classList.toggle("open", isHidden);\n'
+ + '}\n';
+
+ return '\n'
+ + '\n'
+ + '\n'
+ + ' \n'
+ + ' \n'
+ + ' ' + escapeHtmlForExport(testId || methodName) + ' - Single Test Report\n'
+ + ' <' + 'style>\n' + css + '\n <' + '/style>\n'
+ + '\n'
+ + '\n'
+ + ' \n'
+ + '
\n'
+ + '
Selenium Boot Report
\n'
+ + '
Single Test Export — ' + escapeHtmlForExport(testId || methodName) + '
\n'
+ + '
\n'
+ + '
\n'
+ + '
\n'
+ + ' \n'
+ + '
\n'
+ + ' Test Cases
\n'
+ + ' \n'
+ + '
\n'
+ + '
\n'
+ + ' \n'
+ + ' \n'
+ + ' | Test | \n'
+ + ' Description | \n'
+ + ' ' + browserTh + '\n'
+ + ' Status | \n'
+ + ' Logic (ms) | \n'
+ + ' Total (ms) | \n'
+ + ' Screenshot | \n'
+ + ' Detail | \n'
+ + '
\n'
+ + ' \n'
+ + ' \n'
+ + ' ' + groupHeaderHtml + '\n'
+ + ' ' + testRowHtml + '\n'
+ + ' ' + detailRowHtml + '\n'
+ + ' \n'
+ + '
\n'
+ + '
\n'
+ + '
\n'
+ + ' \n'
+ + '
\n'
+ + ' ' + lightboxHtml + '\n'
+ + ' <' + 'script>\n' + offlineScript + ' <' + '/script>\n'
+ + '\n'
+ + '';
+}
+
+function downloadTest(btn, event) {
+ if (event) event.stopPropagation();
+ const panel = btn.closest('.detail-panel');
+ const detailRow = btn.closest('tr.detail-row');
+ const testRow = detailRow ? detailRow.previousElementSibling : null;
+ if (!panel || !testRow) return;
+
+ const label = btn.getAttribute('data-label') || btn.textContent;
+ btn.setAttribute('data-label', label);
+ btn.textContent = 'Exporting...';
+ btn.disabled = true;
+
+ try {
+ const testId = btn.getAttribute('data-download-id') || btn.getAttribute('data-test-id') || testRow.getAttribute('data-test') || 'test';
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
+ const activeTheme = isDark ? 'dark' : 'light';
+ const htmlDoc = buildStandaloneTestHtml(testId, testRow, panel, activeTheme);
+
+ const filename = (testId || 'test').replace(/[^a-zA-Z0-9._-]/g, '_') + '.html';
+ const blob = new Blob([htmlDoc], { type: 'text/html;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
+ btn.textContent = 'Downloaded';
+ } catch (e) {
+ console.error('Failed to export test:', e);
+ btn.textContent = 'Export failed';
+ } finally {
+ setTimeout(function() {
+ btn.textContent = label;
+ btn.disabled = false;
+ }, 1500);
+ }
+}
+
// ── Filter restoration on load ──
(function() {
try {
From a63400a06221216a569567a17c4d9afce9446259 Mon Sep 17 00:00:00 2001
From: Pushpak Jaiswal <149317014+PUSHPAK-JAISWAL@users.noreply.github.com>
Date: Tue, 10 Nov 2026 16:48:30 +0530
Subject: [PATCH 2/3] fix(report): align table header columns on matrix runs
(#220)
---
src/main/resources/report/partials/failures.html | 1 +
src/main/resources/report/partials/tests.html | 1 +
2 files changed, 2 insertions(+)
diff --git a/src/main/resources/report/partials/failures.html b/src/main/resources/report/partials/failures.html
index ede0306..c754df5 100644
--- a/src/main/resources/report/partials/failures.html
+++ b/src/main/resources/report/partials/failures.html
@@ -8,6 +8,7 @@
| Test |
Description |
+ {{BROWSER_HEADER}}
Status |
Logic (ms) |
Total (ms) |
diff --git a/src/main/resources/report/partials/tests.html b/src/main/resources/report/partials/tests.html
index c8880bd..919f04f 100644
--- a/src/main/resources/report/partials/tests.html
+++ b/src/main/resources/report/partials/tests.html
@@ -26,6 +26,7 @@
| Test |
Description |
+ {{BROWSER_HEADER}}
Status |
Logic (ms) |
Total (ms) |
From 81f62e46bc277d8b31666cf24885436a39466b8e Mon Sep 17 00:00:00 2001
From: Pushpak Jaiswal <149317014+PUSHPAK-JAISWAL@users.noreply.github.com>
Date: Tue, 10 Nov 2026 16:49:05 +0530
Subject: [PATCH 3/3] test(report): add tests for single test export and matrix
header alignment
---
.../reporting/HtmlReportGeneratorTest.java | 197 ++++++++++++++++++
1 file changed, 197 insertions(+)
diff --git a/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java b/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java
index cadda16..c6db472 100644
--- a/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java
+++ b/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java
@@ -603,6 +603,7 @@ public void buildHtml_matrixRun_preservesSortingDataAttributesAndBrowserColumn()
assertTrue(reportHtml.contains("MatrixTest [Firefox]"), "Matrix run must include browser in group header");
// Verify browser cells
+ assertTrue(reportHtml.contains("Browser | "), "Matrix run must include Browser | in thead");
assertTrue(reportHtml.contains("Chrome | "));
assertTrue(reportHtml.contains("Firefox | "));
@@ -694,4 +695,200 @@ public void generate_writesValidReportWithSortableColumnsToDisk() throws Excepti
}
}
}
+
+ @Test
+ public void downloadTestButton_presentInDetailRow() throws Exception {
+ String json = """
+ {"totalTests":3,"passedTests":1,"failedTests":1,"skippedTests":1,"passRate":33.3,"tests":[
+ {"testId":"a.A.ok","status":"PASSED","testLogicMs":1,"totalMs":1,"stepsNote":"x","description":"d","performanceMetrics":{"pageLoadMs":1}},
+ {"testId":"a.A.bad","status":"FAILED","testLogicMs":1,"totalMs":1,"errorMessage":"boom","stackTrace":"at a.b"},
+ {"testId":"a.A.skip","status":"SKIPPED","testLogicMs":1,"totalMs":1,"errorMessage":"skipped because"}
+ ]}""";
+ String reportHtml = HtmlReportGenerator.buildHtml(new ObjectMapper().readTree(json));
+ String body = reportHtml.substring(reportHtml.indexOf("");
+ int styleEnd = reportHtml.indexOf("");
+ String css = reportHtml.substring(styleStart, styleEnd);
+ assertFalse(css.contains("http://"), "CSS must not contain external HTTP references");
+ assertFalse(css.contains("https://"), "CSS must not contain external HTTPS references");
+ assertFalse(css.contains("@import"), "CSS must not contain @import rules");
+
+ // Verify standalone generator template in report.js does not fetch external resources
+ int scriptStart = reportHtml.lastIndexOf("");
+ String js = reportHtml.substring(scriptStart, scriptEnd);
+ assertFalse(js.contains("fetch("), "Export logic must not perform network fetch requests");
+ assertFalse(js.contains("XMLHttpRequest"), "Export logic must not perform XHR requests");
+ assertTrue(js.contains("new Blob("), "Export must generate file in-memory using client-side Blob");
+ assertTrue(js.contains("buildStandaloneTestHtml"), "Export must include standalone HTML builder");
+ }
+
+ @Test
+ public void generate_writesSampleReportToTargetForManualVerification() throws Exception {
+ java.nio.file.Path targetDir = java.nio.file.Paths.get("target");
+ if (!java.nio.file.Files.exists(targetDir)) {
+ java.nio.file.Files.createDirectories(targetDir);
+ }
+ String pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
+ java.nio.file.Path sampleScreenshot = targetDir.resolve("sample-fail.png");
+ java.nio.file.Files.write(sampleScreenshot, java.util.Base64.getDecoder().decode(pngBase64));
+
+ String json = String.format("""
+ {
+ "totalTests": 3,
+ "passedTests": 1,
+ "failedTests": 1,
+ "skippedTests": 1,
+ "passRate": 33.3,
+ "suiteDurationMs": 5200,
+ "tests": [
+ {
+ "testId": "com.example.cart.CheckoutTest.successfulCheckout",
+ "testClassName": "CheckoutTest",
+ "status": "PASSED",
+ "browser": "chrome",
+ "tags": ["smoke", "checkout"],
+ "testLogicMs": 1400,
+ "totalMs": 2100,
+ "description": "Verify checkout completes with valid credentials",
+ "steps": [
+ {"name": "Open login page", "status": "PASS", "offsetMs": 200},
+ {"name": "Submit credentials", "status": "PASS", "offsetMs": 800},
+ {"name": "Place order", "status": "PASS", "offsetMs": 1400}
+ ]
+ },
+ {
+ "testId": "com.example.auth.LoginTest.invalidPasswordShowsError",
+ "testClassName": "LoginTest",
+ "status": "FAILED",
+ "browser": "firefox",
+ "tags": ["regression", "auth"],
+ "testLogicMs": 850,
+ "totalMs": 1600,
+ "description": "Verify alert banner is displayed when password is wrong",
+ "screenshotPath": "%s",
+ "errorMessage": "Expected error banner not visible after 5000ms",
+ "stackTrace": "org.openqa.selenium.TimeoutException: Expected condition failed: waiting for visibility of element located by By.id: alert-banner\\n\\tat com.example.auth.LoginTest.invalidPasswordShowsError(LoginTest.java:42)",
+ "failureCategory": "TIMEOUT",
+ "failureType": "PRODUCT_BUG",
+ "suggestedAction": "Check login service latency or increase banner wait timeout",
+ "failureDetail": {
+ "failingLocator": "By.id: alert-banner",
+ "applicationFrame": "LoginTest.java:42",
+ "lastSuccessfulStep": "Enter password",
+ "pageUrl": "https://example.com/login",
+ "pageTitle": "Login - Example App",
+ "consoleErrors": ["401 Unauthorized: Invalid credentials"]
+ },
+ "steps": [
+ {"name": "Navigate to /login", "status": "PASS", "offsetMs": 150},
+ {"name": "Enter wrong password", "status": "PASS", "offsetMs": 400},
+ {"name": "Wait for banner", "status": "FAIL", "offsetMs": 850, "screenshotBase64": "%s"}
+ ]
+ },
+ {
+ "testId": "com.example.auth.LoginTest.ssoLoginBypassed",
+ "testClassName": "LoginTest",
+ "status": "SKIPPED",
+ "browser": "chrome",
+ "tags": ["sso"],
+ "testLogicMs": 0,
+ "totalMs": 10,
+ "errorMessage": "Skipped: SSO identity provider is temporarily disabled in test environment"
+ }
+ ]
+ }
+ """, sampleScreenshot.toString().replace("\\", "\\\\"), pngBase64);
+
+ String reportHtml = HtmlReportGenerator.buildHtml(new ObjectMapper().readTree(json));
+ java.nio.file.Path sampleReportPath = targetDir.resolve("sample-verification-report.html");
+ java.nio.file.Files.writeString(sampleReportPath, reportHtml);
+ assertTrue(java.nio.file.Files.exists(sampleReportPath), "Sample verification report must be written to target");
+ }
}