diff --git a/src/ApiTestRunner.App/wwwroot/app.js b/src/ApiTestRunner.App/wwwroot/app.js
index b7cedb6..d188b03 100644
--- a/src/ApiTestRunner.App/wwwroot/app.js
+++ b/src/ApiTestRunner.App/wwwroot/app.js
@@ -6,6 +6,9 @@ const expandSelectionButton = document.getElementById("expandSelectionButton");
const collapseSelectionButton = document.getElementById("collapseSelectionButton");
const expandResultsButton = document.getElementById("expandResultsButton");
const collapseResultsButton = document.getElementById("collapseResultsButton");
+const showAllResultsButton = document.getElementById("showAllResultsButton");
+const showPassingResultsButton = document.getElementById("showPassingResultsButton");
+const showFailingResultsButton = document.getElementById("showFailingResultsButton");
const selectionSearchInput = document.getElementById("selectionSearchInput");
const resultsSearchInput = document.getElementById("resultsSearchInput");
const selectionContainer = document.getElementById("selectionContainer");
@@ -21,6 +24,8 @@ let selectedTestIds = new Set();
let lastRunState = null;
let selectionSearchTerm = "";
let resultsSearchTerm = "";
+let resultsStatusFilter = "all";
+let resultExpansionOverride = null;
const selectionExpansionState = {
environments: new Map(),
@@ -337,7 +342,9 @@ function renderState(state) {
lastRunState = state;
const run = state.lastRun;
const searchDisplayTerm = resultsSearchInput.value.trim();
+ const autoExpandResults = Boolean(resultsSearchTerm) || resultsStatusFilter !== "all";
setStatusError(Boolean(state.lastError));
+ updateResultFilterButtons();
document.getElementById("runStatus").textContent = buildStatusText(state);
document.getElementById("startedAt").textContent = `Started: ${formatDate(state.lastStartedAtUtc)}`;
@@ -361,21 +368,32 @@ function renderState(state) {
return;
}
- const filteredEnvironments = filterRunEnvironments(run, resultsSearchTerm);
+ const filteredEnvironments = filterRunEnvironments(run, resultsSearchTerm, resultsStatusFilter);
const visibleEndpointCount = filteredEnvironments.reduce((count, environmentEntry) => count + environmentEntry.endpoints.length, 0);
const visibleTestCount = filteredEnvironments.reduce(
(count, environmentEntry) => count + environmentEntry.endpoints.reduce((endpointCount, endpointEntry) => endpointCount + endpointEntry.tests.length, 0),
0
);
-
- resultsSummary.textContent = resultsSearchTerm
- ? `${run.passedTests} passed, ${run.failedTests} failed overall | ${visibleEndpointCount} endpoints and ${visibleTestCount} tests shown for "${searchDisplayTerm}"`
- : `${run.passedTests} passed, ${run.failedTests} failed across ${run.environments.length} environments.`;
+ const visiblePassedTestCount = filteredEnvironments.reduce(
+ (count, environmentEntry) => count + environmentEntry.endpoints.reduce(
+ (endpointCount, endpointEntry) => endpointCount + endpointEntry.tests.filter((test) => test.isSuccess).length,
+ 0),
+ 0
+ );
+ const visibleFailedTestCount = visibleTestCount - visiblePassedTestCount;
+
+ resultsSummary.textContent = buildResultsSummary(
+ run,
+ visibleEndpointCount,
+ visibleTestCount,
+ visiblePassedTestCount,
+ visibleFailedTestCount,
+ searchDisplayTerm);
updateResultButtons(true);
synchronizeResultExpansionState(run);
if (filteredEnvironments.length === 0) {
- environmentContainer.innerHTML = `No matching results
No APIs, endpoints, tests, assertions, or errors match "${escapeHtml(searchDisplayTerm)}".
`;
+ environmentContainer.innerHTML = `No matching results
${buildEmptyResultsMessage(searchDisplayTerm)}
`;
return;
}
@@ -387,19 +405,26 @@ function renderState(state) {
const visibleFailedTests = visibleTests.length - visiblePassedTests;
const environmentNode = environmentTemplate.content.firstElementChild.cloneNode(true);
- environmentNode.open = resultsSearchTerm ? true : resultExpansionState.environments.get(environmentKey) ?? environment.failedTests > 0;
+ environmentNode.open = resultExpansionOverride ?? (autoExpandResults ? true : resultExpansionState.environments.get(environmentKey) ?? environment.failedTests > 0);
environmentNode.addEventListener("toggle", () => {
resultExpansionState.environments.set(environmentKey, environmentNode.open);
});
environmentNode.querySelector(".environment-name").innerHTML = highlightMatch(environment.name, resultsSearchTerm);
environmentNode.querySelector(".environment-url").innerHTML = highlightMatch(environment.baseUrl, resultsSearchTerm);
- environmentNode.querySelector(".environment-stats").textContent = resultsSearchTerm && !environmentMatches
- ? `${visiblePassedTests} passed, ${visibleFailedTests} failed, ${visibleTests.length} matching tests`
- : `${environment.passedTests} passed, ${environment.failedTests} failed, ${environment.totalTests} total`;
+ const environmentStats = environmentNode.querySelector(".environment-stats");
+ const statsPassedCount = (resultsSearchTerm || resultsStatusFilter !== "all") ? visiblePassedTests : environment.passedTests;
+ const statsFailedCount = (resultsSearchTerm || resultsStatusFilter !== "all") ? visibleFailedTests : environment.failedTests;
+ const statsTotalCount = (resultsSearchTerm || resultsStatusFilter !== "all") ? visibleTests.length : environment.totalTests;
+ const statsTotalLabel = (resultsSearchTerm || resultsStatusFilter !== "all") ? "matching tests" : "total";
+ environmentStats.innerHTML = [
+ `${statsPassedCount} passed`,
+ `${statsFailedCount} failed`,
+ `${statsTotalCount} ${statsTotalLabel}`
+ ].join(", ");
const environmentBadge = environmentNode.querySelector(".environment-badge");
- const environmentIsPassing = resultsSearchTerm ? visibleFailedTests === 0 : environment.failedTests === 0;
+ const environmentIsPassing = (resultsSearchTerm || resultsStatusFilter !== "all") ? visibleFailedTests === 0 : environment.failedTests === 0;
environmentBadge.textContent = environmentIsPassing ? "Passing" : "Issues";
environmentBadge.className = `environment-badge ${environmentIsPassing ? "passing" : "failing"}`;
@@ -409,7 +434,7 @@ function renderState(state) {
const { endpoint, endpointMatches, tests } = endpointEntry;
const endpointKey = getResultEndpointKey(environment, endpoint);
const endpointNode = endpointTemplate.content.firstElementChild.cloneNode(true);
- endpointNode.open = resultsSearchTerm ? true : resultExpansionState.endpoints.get(endpointKey) ?? !endpoint.isSuccess;
+ endpointNode.open = resultExpansionOverride ?? (autoExpandResults ? true : resultExpansionState.endpoints.get(endpointKey) ?? !endpoint.isSuccess);
endpointNode.addEventListener("toggle", () => {
resultExpansionState.endpoints.set(endpointKey, endpointNode.open);
});
@@ -420,7 +445,7 @@ function renderState(state) {
const endpointBadge = endpointNode.querySelector(".endpoint-badge");
const endpointVisibleFailedTests = tests.filter((test) => !test.isSuccess).length;
- const endpointIsPassing = resultsSearchTerm ? endpointVisibleFailedTests === 0 : endpoint.isSuccess;
+ const endpointIsPassing = (resultsSearchTerm || resultsStatusFilter !== "all") ? endpointVisibleFailedTests === 0 : endpoint.isSuccess;
endpointBadge.textContent = endpointIsPassing ? "Pass" : "Fail";
endpointBadge.className = `endpoint-badge ${endpointIsPassing ? "passing" : "failing"}`;
@@ -434,7 +459,7 @@ function renderState(state) {
tests.forEach((test, testIndex) => {
const testKey = getResultTestKey(environment, endpoint, test, testIndex);
const testNode = testTemplate.content.firstElementChild.cloneNode(true);
- testNode.open = resultsSearchTerm ? true : resultExpansionState.tests.get(testKey) ?? !test.isSuccess;
+ testNode.open = resultExpansionOverride ?? (autoExpandResults ? true : resultExpansionState.tests.get(testKey) ?? !test.isSuccess);
testNode.addEventListener("toggle", () => {
resultExpansionState.tests.set(testKey, testNode.open);
});
@@ -498,8 +523,8 @@ function initializeResponsePreview(endpointNode, responseText) {
});
}
-function filterRunEnvironments(run, searchTerm) {
- if (!searchTerm) {
+function filterRunEnvironments(run, searchTerm, statusFilter) {
+ if (!searchTerm && statusFilter === "all") {
return run.environments.map((environment) => ({
environment,
environmentMatches: false,
@@ -520,17 +545,18 @@ function filterRunEnvironments(run, searchTerm) {
const endpoints = environment.endpoints
.map((endpoint) => {
const endpointMatches = environmentMatches || endpointMatchesRunSearch(endpoint, searchTerm);
+ const visibleTests = endpoint.tests.filter((test) => shouldIncludeResultTest(test, statusFilter));
return {
endpoint,
endpointMatches,
tests: endpointMatches
- ? endpoint.tests
- : endpoint.tests.filter((test) => testMatchesRunSearch(test, searchTerm))
+ ? visibleTests
+ : visibleTests.filter((test) => testMatchesRunSearch(test, searchTerm))
};
})
- .filter((endpointEntry) => endpointEntry.endpointMatches || endpointEntry.tests.length > 0);
+ .filter((endpointEntry) => endpointEntry.tests.length > 0);
- if (!environmentMatches && endpoints.length === 0) {
+ if (endpoints.length === 0) {
return null;
}
@@ -543,6 +569,18 @@ function filterRunEnvironments(run, searchTerm) {
.filter((entry) => entry !== null);
}
+function shouldIncludeResultTest(test, statusFilter) {
+ if (statusFilter === "passing") {
+ return test.isSuccess;
+ }
+
+ if (statusFilter === "failing") {
+ return !test.isSuccess;
+ }
+
+ return true;
+}
+
function endpointMatchesRunSearch(endpoint, searchTerm) {
return (
matchesSearch(endpoint.name, searchTerm) ||
@@ -693,6 +731,15 @@ function updateSelectionButtons(isEnabled) {
function updateResultButtons(isEnabled) {
expandResultsButton.disabled = !isEnabled;
collapseResultsButton.disabled = !isEnabled;
+ showAllResultsButton.disabled = !isEnabled;
+ showPassingResultsButton.disabled = !isEnabled;
+ showFailingResultsButton.disabled = !isEnabled;
+}
+
+function updateResultFilterButtons() {
+ showAllResultsButton.classList.toggle("is-active", resultsStatusFilter === "all");
+ showPassingResultsButton.classList.toggle("is-active", resultsStatusFilter === "passing");
+ showFailingResultsButton.classList.toggle("is-active", resultsStatusFilter === "failing");
}
function setStatusError(hasError) {
@@ -733,6 +780,8 @@ function setResultExpansion(isOpen) {
return;
}
+ resultExpansionOverride = isOpen;
+
for (const environment of run.environments) {
resultExpansionState.environments.set(getResultEnvironmentKey(environment), isOpen);
@@ -748,6 +797,53 @@ function setResultExpansion(isOpen) {
renderState(lastRunState);
}
+function setResultsStatusFilter(filter) {
+ resultsStatusFilter = filter;
+ resultExpansionOverride = null;
+ updateResultFilterButtons();
+
+ if (lastRunState) {
+ renderState(lastRunState);
+ }
+}
+
+function buildResultsSummary(run, visibleEndpointCount, visibleTestCount, visiblePassedTestCount, visibleFailedTestCount, searchDisplayTerm) {
+ if (!resultsSearchTerm && resultsStatusFilter === "all") {
+ return `${run.passedTests} passed, ${run.failedTests} failed across ${run.environments.length} environments.`;
+ }
+
+ const filterDescription = resultsStatusFilter === "passing"
+ ? "passing tests"
+ : resultsStatusFilter === "failing"
+ ? "failing tests"
+ : "tests";
+ const searchDescription = resultsSearchTerm ? ` for "${searchDisplayTerm}"` : "";
+
+ return `${run.passedTests} passed, ${run.failedTests} failed overall | showing ${visiblePassedTestCount} passed and ${visibleFailedTestCount} failed across ${visibleEndpointCount} endpoints and ${visibleTestCount} ${filterDescription}${searchDescription}`;
+}
+
+function buildEmptyResultsMessage(searchDisplayTerm) {
+ const filterDescription = resultsStatusFilter === "passing"
+ ? "passing"
+ : resultsStatusFilter === "failing"
+ ? "failing"
+ : "visible";
+
+ if (resultsSearchTerm) {
+ return `No ${filterDescription} APIs, endpoints, tests, assertions, or errors match "${escapeHtml(searchDisplayTerm)}".`;
+ }
+
+ if (resultsStatusFilter === "passing") {
+ return "No passing tests are visible in the latest results.";
+ }
+
+ if (resultsStatusFilter === "failing") {
+ return "No failing tests are visible in the latest results.";
+ }
+
+ return "No results are visible in the latest run.";
+}
+
function matchesSearch(value, searchTerm) {
return typeof value === "string" && value.toLowerCase().includes(searchTerm);
}
@@ -800,6 +896,9 @@ expandSelectionButton.addEventListener("click", () => setSelectionExpansion(true
collapseSelectionButton.addEventListener("click", () => setSelectionExpansion(false));
expandResultsButton.addEventListener("click", () => setResultExpansion(true));
collapseResultsButton.addEventListener("click", () => setResultExpansion(false));
+showAllResultsButton.addEventListener("click", () => setResultsStatusFilter("all"));
+showPassingResultsButton.addEventListener("click", () => setResultsStatusFilter("passing"));
+showFailingResultsButton.addEventListener("click", () => setResultsStatusFilter("failing"));
selectionSearchInput.addEventListener("input", () => {
selectionSearchTerm = selectionSearchInput.value.trim().toLowerCase();
@@ -809,6 +908,7 @@ selectionSearchInput.addEventListener("input", () => {
});
resultsSearchInput.addEventListener("input", () => {
resultsSearchTerm = resultsSearchInput.value.trim().toLowerCase();
+ resultExpansionOverride = null;
if (lastRunState) {
renderState(lastRunState);
diff --git a/src/ApiTestRunner.App/wwwroot/index.html b/src/ApiTestRunner.App/wwwroot/index.html
index 802a587..5c7b988 100644
--- a/src/ApiTestRunner.App/wwwroot/index.html
+++ b/src/ApiTestRunner.App/wwwroot/index.html
@@ -184,6 +184,9 @@
Use the controls to expand or collapse the latest results.
+
+
+
diff --git a/src/ApiTestRunner.App/wwwroot/styles.css b/src/ApiTestRunner.App/wwwroot/styles.css
index 44cc957..6834045 100644
--- a/src/ApiTestRunner.App/wwwroot/styles.css
+++ b/src/ApiTestRunner.App/wwwroot/styles.css
@@ -332,6 +332,25 @@ body.app-shell {
transform: translateY(-1px);
}
+.ghost-button.is-active {
+ background: var(--results-panel-accent);
+ border-color: rgba(29, 78, 216, 0.22);
+ color: #fff;
+ box-shadow: 0 10px 22px rgba(29, 78, 216, 0.18);
+}
+
+#showPassingResultsButton.is-active {
+ background: linear-gradient(135deg, #16a34a, #166534);
+ border-color: rgba(22, 163, 74, 0.24);
+ box-shadow: 0 10px 22px rgba(22, 163, 74, 0.18);
+}
+
+#showFailingResultsButton.is-active {
+ background: linear-gradient(135deg, #dc2626, #991b1b);
+ border-color: rgba(220, 38, 38, 0.24);
+ box-shadow: 0 10px 22px rgba(220, 38, 38, 0.18);
+}
+
.primary-button:disabled,
.ghost-button:disabled {
opacity: 0.65;
@@ -616,6 +635,20 @@ body.app-shell {
color: #667085;
}
+.environment-stats .stats-pass {
+ color: var(--app-pass);
+ font-weight: 700;
+}
+
+.environment-stats .stats-fail {
+ color: var(--app-fail);
+ font-weight: 700;
+}
+
+.environment-stats .stats-total {
+ color: inherit;
+}
+
.selection-label-stack span {
font-size: 0.85rem;
}