diff --git a/CHANGELOG.md b/CHANGELOG.md
index 547420a..62f8dee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
### Unreleased
+- **HTML report: "Copy failure details" button** — failed tests with an error now have a button in the expanded detail row (and on the Failures tab) that copies the test id, error message and stack trace to the clipboard, with brief "Copied" feedback. Falls back to `execCommand('copy')` when `navigator.clipboard` is unavailable (e.g. some `file://` setups). (#216)
+
- **Fix: `@PreCondition` session restore on a fresh browser** — the cached cookies and localStorage were silently dropped when the next test started on `about:blank`, so only the first test per thread was logged in. Restore now navigates to the origin the session was captured on first.
- **LambdaTest cloud provider** — `execution.mode: lambdatest` now creates W3C
diff --git a/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java b/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java
index 0e79d58..13d3a6c 100644
--- a/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java
+++ b/src/main/java/com/seleniumboot/reporting/HtmlReportGenerator.java
@@ -402,6 +402,10 @@ private static String buildRow(JsonNode test, int rowIndex, String groupId, bool
+ (test.has("failureCategory") ? buildFailureWhy(test) : "");
String errorHtml = whyHtml + (errorMsg != null ? "
" + escapeHtml(errorMsg) + "
" : "");
String traceHtml = stackTrace != null ? "" + escapeHtml(stackTrace) + " " : "";
+ String copyBtn = "FAILED".equals(status) && (errorMsg != null || stackTrace != null)
+ ? "Copy failure details "
+ : "";
String recordingHtml = recordingPath != null ? buildRecordingCell(recordingPath) : "";
String traceLink = tracePath != null ? buildTraceLink(tracePath) : "";
String sessionLink = sessionUrl != null ? buildSessionLink(sessionUrl) : "";
@@ -415,7 +419,7 @@ private static String buildRow(JsonNode test, int rowIndex, String groupId, bool
String detailDisplay = detailHidden ? " style=\"display:none\"" : "";
detailRow = ""
+ ""
- + failureDetail + perfHtml + sessionLink + traceLink + stepsSection + recordingHtml + aiHtml + errorHtml + traceHtml
+ + copyBtn + failureDetail + perfHtml + sessionLink + traceLink + stepsSection + recordingHtml + aiHtml + errorHtml + traceHtml
+ "
"
+ " ";
}
diff --git a/src/main/resources/report/report.css b/src/main/resources/report/report.css
index c2fee47..a4d17f2 100644
--- a/src/main/resources/report/report.css
+++ b/src/main/resources/report/report.css
@@ -298,6 +298,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); }
.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; }
diff --git a/src/main/resources/report/report.js b/src/main/resources/report/report.js
index e69e7f9..81947ec 100644
--- a/src/main/resources/report/report.js
+++ b/src/main/resources/report/report.js
@@ -164,3 +164,39 @@ function applySearch() {
hdr.style.display = visibleGroups.has(groupId) ? '' : 'none';
});
}
+
+// ── Copy failure details ──
+function copyFailure(btn, event) {
+ if (event) event.stopPropagation();
+ const panel = btn.closest('.detail-panel');
+ const err = panel.querySelector('.error-msg');
+ const trace = panel.querySelector('.stack-trace');
+ const text = [
+ 'Test: ' + btn.getAttribute('data-test-id'),
+ err ? 'Error: ' + err.textContent : '',
+ trace ? '\n' + trace.textContent : ''
+ ].filter(Boolean).join('\n');
+ const done = ok => {
+ const label = btn.getAttribute('data-label') || btn.textContent;
+ btn.setAttribute('data-label', label);
+ btn.textContent = ok ? 'Copied' : 'Copy failed';
+ setTimeout(() => { btn.textContent = label; }, 1500);
+ };
+ const fallback = () => {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ let ok = false;
+ try { ok = document.execCommand('copy'); } catch (e) {}
+ document.body.removeChild(ta);
+ done(ok);
+ };
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(text).then(() => done(true), fallback);
+ } else {
+ fallback();
+ }
+}
diff --git a/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java b/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java
index b8a4af1..16a3d5d 100644
--- a/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java
+++ b/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java
@@ -283,4 +283,21 @@ public void buildHtml_tagWithQuote_renderedSafelyWithDataTag() throws Exception
assertFalse(reportHtml.contains("filterByTag('it's'"));
assertFalse(reportHtml.contains("filterByTag('x');alert(1);//'"));
}
+
+ @Test
+ public void copyFailureButton_onlyOnFailedTestsWithErrorDetail() 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("