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

---

### Unreleased

- **HTML report: sortable columns in Test Cases table** — clicking headers for Test, Status, Logic (ms), and Total (ms) sorts rows within each test class group ascending or descending with visual sort indicators, while preserving detail row attachments and cooperating seamlessly with active status, tag, and search filters. (#217)

### v3.10.0 — 2026-11-04

- **HTML report: print stylesheet for "Save as PDF"** — printing the report now produces a clean document: UI chrome is hidden, all sections print instead of only the active tab, collapsed groups and failed-test details (error and full stack trace) are expanded, the light palette is used even in dark mode, and your on-screen filters are restored afterwards. (#221, #236)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,8 @@ private static String buildRow(JsonNode test, int rowIndex, String groupId, bool
int lastDot = rawTestId.lastIndexOf('.');
String methodName = escapeHtml(lastDot >= 0 ? rawTestId.substring(lastDot + 1) : rawTestId);
String description = test.has("description") ? escapeHtml(test.get("description").asText()) : "";
long logicMs = test.has("testLogicMs") ? test.get("testLogicMs").asLong() : 0L;
long totalMs = test.has("totalMs") ? test.get("totalMs").asLong() : 0L;
long logicMs = test.hasNonNull("testLogicMs") ? test.get("testLogicMs").asLong(0L) : 0L;
long totalMs = test.hasNonNull("totalMs") ? test.get("totalMs").asLong(0L) : 0L;
int retryCount = test.has("retryCount") ? test.get("retryCount").asInt() : 0;
String errorMsg = test.has("errorMessage") ? test.get("errorMessage").asText() : null;
String stackTrace = test.has("stackTrace") ? test.get("stackTrace").asText() : null;
Expand Down Expand Up @@ -465,7 +465,9 @@ private static String buildRow(JsonNode test, int rowIndex, String groupId, bool
+ " data-group=\"" + groupKey + "\""
+ " data-status=\"" + status + "\""
+ " data-tags=\"" + tagDataAttr + "\""
+ " data-test=\"" + methodName.toLowerCase() + "\">"
+ " data-test=\"" + methodName.toLowerCase() + "\""
+ " data-logic-ms=\"" + logicMs + "\""
+ " data-total-ms=\"" + totalMs + "\">"
+ "<td class=\"test-id\">" + testCellContent + "</td>"
+ "<td class=\"desc-cell\">" + description + "</td>"
+ browserCell
Expand Down
8 changes: 4 additions & 4 deletions src/main/resources/report/partials/tests.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@
<table id="testTable">
<thead>
<tr>
<th>Test</th>
<th class="sortable" data-sort-key="test" onclick="sortTestTable('test')" title="Click to sort">Test <span class="sort-icon"></span></th>
<th>Description</th>
<th>Status</th>
<th>Logic (ms)</th>
<th>Total (ms)</th>
<th class="sortable" data-sort-key="status" onclick="sortTestTable('status')" title="Click to sort">Status <span class="sort-icon"></span></th>
<th class="sortable" data-sort-key="logic" onclick="sortTestTable('logic')" title="Click to sort">Logic (ms) <span class="sort-icon"></span></th>
<th class="sortable" data-sort-key="total" onclick="sortTestTable('total')" title="Click to sort">Total (ms) <span class="sort-icon"></span></th>
<th>Screenshot</th>
<th>Detail</th>
</tr>
Expand Down
43 changes: 43 additions & 0 deletions src/main/resources/report/report.css
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,44 @@
padding: 11px 14px; font-size: 12px; font-weight: 500;
text-transform: uppercase; letter-spacing: .5px; text-align: left; white-space: nowrap;
}

/* ── Sortable table headers ── */
th.sortable {
cursor: pointer;
user-select: none;
-webkit-user-select: none;
transition: background .15s ease, color .15s ease;
}
th.sortable:hover {
background: var(--primary-dark);
}
th.sortable.active {
background: var(--primary-dark);
}
.sort-icon {
display: inline-block;
margin-left: 6px;
font-size: 11px;
line-height: 1;
vertical-align: middle;
opacity: 0.4;
transition: opacity .15s ease;
}
.sort-icon::after {
content: '\21C5';
}
th.sortable:hover .sort-icon {
opacity: 0.8;
}
th.sortable.active .sort-icon {
opacity: 1;
}
.sort-icon.asc::after {
content: '\25B2';
}
.sort-icon.desc::after {
content: '\25BC';
}
tbody td { padding: 10px 14px; font-size: 13px; border-bottom: 1px solid var(--border); color: var(--text); vertical-align: middle; }
tbody tr:nth-child(even):not(.detail-row) { background: var(--surface2); }
tbody tr.expandable:hover { background: var(--hover); cursor: pointer; }
Expand Down Expand Up @@ -489,6 +527,7 @@
.active-tag-pill,
.failures-shortcut,
.lightbox,
.sort-icon,
.footer {
display: none !important;
}
Expand Down Expand Up @@ -577,6 +616,10 @@
display: table-header-group !important;
}

th.sortable {
cursor: default !important;
}

tr {
break-inside: avoid;
page-break-inside: avoid;
Expand Down
153 changes: 153 additions & 0 deletions src/main/resources/report/report.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,159 @@ function applySearch() {
});
}

// ── Column sorting (Test Cases tab) ──
let currentSortCol = null;
let currentSortDir = 'asc';

function getNumericVal(row, attr, fallbackIndex) {
const val = row.getAttribute(attr);
if (val !== null && val !== '') {
const n = parseFloat(val);
if (!isNaN(n)) return n;
}
const numericCells = row.querySelectorAll('td.numeric');
if (numericCells.length > fallbackIndex) {
const n = parseFloat(numericCells[fallbackIndex].textContent.trim());
if (!isNaN(n)) return n;
}
return 0;
}

function compareTestRows(pairA, pairB, colKey, dir) {
const rowA = pairA.testRow;
const rowB = pairB.testRow;
let cmp = 0;

if (colKey === 'test') {
const a = (rowA.getAttribute('data-test') || '').toLowerCase();
const b = (rowB.getAttribute('data-test') || '').toLowerCase();
cmp = a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
} else if (colKey === 'status') {
const a = (rowA.getAttribute('data-status') || '').toUpperCase();
const b = (rowB.getAttribute('data-status') || '').toUpperCase();
cmp = a.localeCompare(b);
if (cmp === 0) {
const testA = (rowA.getAttribute('data-test') || '').toLowerCase();
const testB = (rowB.getAttribute('data-test') || '').toLowerCase();
cmp = testA.localeCompare(testB, undefined, { numeric: true, sensitivity: 'base' });
}
} else if (colKey === 'logic') {
const a = getNumericVal(rowA, 'data-logic-ms', 0);
const b = getNumericVal(rowB, 'data-logic-ms', 0);
cmp = a - b;
if (cmp === 0) {
const testA = (rowA.getAttribute('data-test') || '').toLowerCase();
const testB = (rowB.getAttribute('data-test') || '').toLowerCase();
cmp = testA.localeCompare(testB, undefined, { numeric: true, sensitivity: 'base' });
}
} else if (colKey === 'total') {
const a = getNumericVal(rowA, 'data-total-ms', 1);
const b = getNumericVal(rowB, 'data-total-ms', 1);
cmp = a - b;
if (cmp === 0) {
const testA = (rowA.getAttribute('data-test') || '').toLowerCase();
const testB = (rowB.getAttribute('data-test') || '').toLowerCase();
cmp = testA.localeCompare(testB, undefined, { numeric: true, sensitivity: 'base' });
}
}

return dir === 'desc' ? -cmp : cmp;
}

function updateSortHeaders(activeColKey, dir) {
const headers = document.querySelectorAll('#testTable thead th.sortable');
headers.forEach(th => {
const key = th.getAttribute('data-sort-key');
const icon = th.querySelector('.sort-icon');
if (key === activeColKey) {
th.classList.add('active');
th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending');
th.title = 'Sorted ' + (dir === 'asc' ? 'ascending' : 'descending') + '. Click to reverse.';
if (icon) {
icon.classList.remove('asc', 'desc');
icon.classList.add(dir);
}
} else {
th.classList.remove('active');
th.setAttribute('aria-sort', 'none');
th.title = 'Click to sort';
if (icon) {
icon.classList.remove('asc', 'desc');
}
}
});
}

function sortTestTable(colKey) {
const table = document.getElementById('testTable');
if (!table) return;

// Toggle direction if already active column; otherwise default to ascending
if (currentSortCol === colKey) {
currentSortDir = currentSortDir === 'asc' ? 'desc' : 'asc';
} else {
currentSortCol = colKey;
currentSortDir = 'asc';
}

const groupHeaders = document.querySelectorAll('#testTable tbody tr.group-header');

if (groupHeaders.length > 0) {
// Group-Preserving sorting: sort test rows within each group
const allMemberRows = Array.from(document.querySelectorAll('#testTable tbody tr.group-member:not(.detail-row)'));

groupHeaders.forEach(hdr => {
const groupId = hdr.getAttribute('data-group');
const groupMembers = allMemberRows.filter(r => r.getAttribute('data-group') === groupId);

// Collect atomic test row + detail row pairs
const pairs = groupMembers.map(testRow => {
const next = testRow.nextElementSibling;
const detailRow = (next && next.classList.contains('detail-row')) ? next : null;
return { testRow, detailRow };
});

// Sort pairs within group
pairs.sort((a, b) => compareTestRows(a, b, colKey, currentSortDir));

// Re-insert pairs in sorted order immediately after group header
let anchor = hdr;
pairs.forEach(pair => {
anchor.insertAdjacentElement('afterend', pair.testRow);
anchor = pair.testRow;
if (pair.detailRow) {
anchor.insertAdjacentElement('afterend', pair.detailRow);
anchor = pair.detailRow;
}
});
});
} else {
// Fallback for ungrouped tables
const tbody = table.querySelector('tbody');
if (tbody) {
const testRows = Array.from(tbody.querySelectorAll('tr.group-member:not(.detail-row)'));
const pairs = testRows.map(testRow => {
const next = testRow.nextElementSibling;
const detailRow = (next && next.classList.contains('detail-row')) ? next : null;
return { testRow, detailRow };
});

pairs.sort((a, b) => compareTestRows(a, b, colKey, currentSortDir));

pairs.forEach(pair => {
tbody.appendChild(pair.testRow);
if (pair.detailRow) tbody.appendChild(pair.detailRow);
});
}
}

// Update sort indicators on column headers
updateSortHeaders(currentSortCol, currentSortDir);

// Refresh visibility respecting active status filters, search query, and tag filters
applySearch();
}

// ── Copy failure details ──
function copyFailure(btn, event) {
if (event) event.stopPropagation();
Expand Down
Loading
Loading