diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62f8dee..c5863b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
### Unreleased
+- **HTML report: status and tag filter persistence** — the active status filter (All, Passed, Failed, Skipped, Flaky) and tag filter in the Test Cases tab are now persisted across page reloads and reopens using `localStorage` (`sb-status-filter` and `sb-tag-filter`). Storage reads and writes are safely guarded against restricted environments (private browsing, disabled storage, quota limits); if a previously stored tag is not present in the current report, it is gracefully ignored without restricting rows, and restoring filters preserves the default Dashboard tab view. (#215)
+
- **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.
diff --git a/src/main/resources/report/report.js b/src/main/resources/report/report.js
index 81947ec..f6bcd85 100644
--- a/src/main/resources/report/report.js
+++ b/src/main/resources/report/report.js
@@ -118,12 +118,22 @@ function filterByTag(tagName, event) {
pill.style.display = 'none';
}
}
+ try {
+ if (activeTagFilter) {
+ localStorage.setItem('sb-tag-filter', tagName);
+ } else {
+ localStorage.removeItem('sb-tag-filter');
+ }
+ } catch (e) {}
applySearch();
}
function clearTagFilter() {
activeTagFilter = '';
const pill = document.getElementById('activeTagPill');
if (pill) pill.style.display = 'none';
+ try {
+ localStorage.removeItem('sb-tag-filter');
+ } catch (e) {}
applySearch();
}
@@ -133,6 +143,13 @@ function setFilter(status, btn) {
activeFilter = status;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
+ try {
+ if (status === 'ALL') {
+ localStorage.removeItem('sb-status-filter');
+ } else {
+ localStorage.setItem('sb-status-filter', status);
+ }
+ } catch (e) {}
applySearch();
}
function applySearch() {
@@ -200,3 +217,61 @@ function copyFailure(btn, event) {
fallback();
}
}
+
+// ── Filter restoration on load ──
+(function() {
+ try {
+ // Restore status filter
+ const savedStatus = localStorage.getItem('sb-status-filter');
+ if (savedStatus && ['PASSED', 'FAILED', 'SKIPPED', 'FLAKY'].includes(savedStatus)) {
+ const btn = document.querySelector(".filter-btn[onclick*=\"'" + savedStatus + "'\"]");
+ if (btn) {
+ activeFilter = savedStatus;
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ }
+ } else if (savedStatus === 'ALL') {
+ const btn = document.querySelector(".filter-btn[onclick*=\"'ALL'\"]");
+ if (btn) {
+ activeFilter = 'ALL';
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ }
+ }
+
+ // Restore tag filter
+ const savedTag = localStorage.getItem('sb-tag-filter');
+ if (savedTag) {
+ const tagToMatch = savedTag.toLowerCase().trim();
+ if (tagToMatch) {
+ const rows = document.querySelectorAll('#testTable tbody tr.group-member');
+ let tagFound = false;
+ for (let i = 0; i < rows.length; i++) {
+ const rawTags = rows[i].getAttribute('data-tags');
+ if (rawTags) {
+ const tags = rawTags.split(',').map(t => t.trim().toLowerCase());
+ if (tags.includes(tagToMatch)) {
+ tagFound = true;
+ break;
+ }
+ }
+ }
+ if (tagFound) {
+ activeTagFilter = tagToMatch;
+ const pill = document.getElementById('activeTagPill');
+ const nameSpan = document.getElementById('activeTagName');
+ if (pill && nameSpan) {
+ nameSpan.textContent = 'Tag: ' + savedTag;
+ pill.style.display = 'inline-flex';
+ }
+ }
+ }
+ }
+
+ // Apply filters to table if any restored
+ if (activeFilter !== 'ALL' || activeTagFilter) {
+ applySearch();
+ }
+ } catch (e) {}
+})();
+
diff --git a/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java b/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java
index 16a3d5d..25e6606 100644
--- a/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java
+++ b/src/test/java/com/seleniumboot/reporting/HtmlReportGeneratorTest.java
@@ -300,4 +300,114 @@ public void copyFailureButton_onlyOnFailedTestsWithErrorDetail() throws Exceptio
assertTrue(reportHtml.contains("function copyFailure("));
assertTrue(reportHtml.contains("document.execCommand('copy')"), "file:// fallback present");
}
+
+ @Test
+ public void buildHtml_statusFilterPersistence_persistsAndRestoresFilter() throws Exception {
+ String reportHtml = html(1, 0);
+
+ // 1. Canonical storage key
+ assertTrue(reportHtml.contains("sb-status-filter"),
+ "Report must use the canonical 'sb-status-filter' storage key");
+
+ // 2. setFilter persistence and removal on 'ALL'
+ assertTrue(reportHtml.contains("localStorage.setItem('sb-status-filter'"),
+ "setFilter must persist non-ALL status filter to localStorage");
+ assertTrue(reportHtml.contains("localStorage.removeItem('sb-status-filter'"),
+ "setFilter must remove stored status filter when 'ALL' is selected");
+
+ // 3. Status filter restoration on page load
+ assertTrue(reportHtml.contains("localStorage.getItem('sb-status-filter'"),
+ "Report initialization must retrieve persisted status from localStorage");
+ assertTrue(reportHtml.contains("['PASSED', 'FAILED', 'SKIPPED', 'FLAKY']"),
+ "Status restoration must validate against allowed status values");
+ assertTrue(reportHtml.contains(".filter-btn"),
+ "Status restoration must update the active class on filter buttons");
+ assertTrue(reportHtml.contains("activeFilter"),
+ "Status restoration must set activeFilter variable");
+ }
+
+ @Test
+ public void buildHtml_tagFilterPersistence_persistsAndClearsFilter() throws Exception {
+ String reportHtml = html(1, 0);
+
+ // 1. Canonical storage key
+ assertTrue(reportHtml.contains("sb-tag-filter"),
+ "Report must use the canonical 'sb-tag-filter' storage key");
+
+ // 2. filterByTag persistence & clearTagFilter removal
+ assertTrue(reportHtml.contains("localStorage.setItem('sb-tag-filter'"),
+ "filterByTag must persist selected tag to localStorage");
+ assertTrue(reportHtml.contains("localStorage.removeItem('sb-tag-filter'"),
+ "clearTagFilter must remove stored tag filter from localStorage");
+
+ // 3. Tag filter restoration on page load
+ assertTrue(reportHtml.contains("localStorage.getItem('sb-tag-filter'"),
+ "Report initialization must retrieve persisted tag from localStorage");
+ assertTrue(reportHtml.contains("activeTagPill"),
+ "Tag restoration must manage activeTagPill visibility");
+ assertTrue(reportHtml.contains("activeTagName"),
+ "Tag restoration must update activeTagName display");
+ }
+
+ @Test
+ public void buildHtml_tagFilterRestoration_guardsAgainstMissingTags() throws Exception {
+ String reportHtml = html(1, 0);
+
+ // Verify missing tag validation inspects table rows before applying filter
+ assertTrue(reportHtml.contains("#testTable tbody tr.group-member"),
+ "Tag restoration must query test table member rows to locate matching tags");
+ assertTrue(reportHtml.contains("data-tags"),
+ "Tag restoration must inspect data-tags on table rows before activating filter");
+ assertTrue(reportHtml.contains("tagFound"),
+ "Tag restoration must track whether the stored tag actually exists in the report");
+ }
+
+ @Test
+ public void buildHtml_filterStorageAccess_isGuardedByTryCatch() throws Exception {
+ String reportHtml = html(1, 0);
+
+ int scriptStart = reportHtml.lastIndexOf("");
+ assertTrue(scriptStart > 0 && scriptEnd > scriptStart, "Generated HTML must contain embedded script block");
+ String js = reportHtml.substring(scriptStart, scriptEnd);
+
+ // General error guarding
+ assertTrue(js.contains("try {") || js.contains("try{"), "Storage access must use try blocks");
+ assertTrue(js.contains("catch (e)") || js.contains("catch(e)") || js.contains("catch (err)"),
+ "Storage access must catch storage exceptions");
+
+ // Specific function error handling checks
+ assertTrue(js.matches("(?s).*function setFilter[^{]*\\{.*?try\\s*\\{.*?localStorage\\.(setItem|removeItem)\\('sb-status-filter'.*?\\}\\s*catch.*"),
+ "setFilter localStorage writes must be enclosed in try/catch");
+ assertTrue(js.matches("(?s).*function filterByTag[^{]*\\{.*?try\\s*\\{.*?localStorage\\.(setItem|removeItem)\\('sb-tag-filter'.*?\\}\\s*catch.*"),
+ "filterByTag localStorage writes must be enclosed in try/catch");
+ assertTrue(js.matches("(?s).*function clearTagFilter[^{]*\\{.*?try\\s*\\{.*?localStorage\\.removeItem\\('sb-tag-filter'.*?\\}\\s*catch.*"),
+ "clearTagFilter localStorage removal must be enclosed in try/catch");
+ assertTrue(js.matches("(?s).*\\(function\\(\\)\\s*\\{.*?try\\s*\\{.*?sb-status-filter.*?sb-tag-filter.*?\\}\\s*catch.*"),
+ "Filter restoration routine on load must be enclosed in try/catch");
+ }
+
+ @Test
+ public void buildHtml_filterRestoration_preservesDashboardTabWithoutTabPersistence() throws Exception {
+ String reportHtml = html(1, 0);
+
+ // Default markup states
+ assertTrue(reportHtml.contains("id=\"tab-dashboard\" class=\"tab-pane active\""),
+ "Dashboard tab pane must be active by default");
+ assertTrue(reportHtml.contains("nav-item active\" onclick=\"showTab('dashboard', this)\""),
+ "Dashboard navigation item must be active by default");
+
+ // Must not introduce any tab persistence key
+ assertFalse(reportHtml.contains("sb-tab"),
+ "Generated report must not introduce sb-tab persistence keys");
+
+ // Filter restoration block must not switch tabs
+ int scriptStart = reportHtml.lastIndexOf("