Test Runner UI Revamp (#6011)

* Moved collection results to runner title bar so they are move visible.
Added breakdown of test results within collection.
Added filtering based on passing/failing requests and tests by click on results text.

* feat: revamp Test Runner UI with unified filter and improved layout

- Add unified filter bar (All/Passed/Failed/Skipped) with counts and active indicator
- Implement filtering that filters both requests and tests within requests
- Move action buttons to top bar, prevent filter wrapping
- Add close button and placeholder to response view
- Update styling for light/dark modes with proper colors and typography

* refactored the RunnerResults component to be more clear and readable

* refactor: revert formatting changes while preserving new UI and filtering logic

- Restore original function formatting with return statements and braces
- Restore removed input attributes (autoCorrect, autoCapitalize, spellCheck)
- Revert ternary operator changes to match original code style
- Restore original variable names (savedConfiguration) and comments
- Restore original test results rendering structure
- Preserve new filter bar UI, filtering logic, and response view improvements

* fix: implement smart auto-scroll behavior in test runner

- Only auto-scroll when user is near the bottom (within 100px)
- Preserve user's scroll position if they've scrolled up to view content
- Move ref to actual scrollable container for proper scroll detection

* Update RunnerResults component

* chore: reformat

---------

Co-authored-by: Morgan English <morgan.english@canterbury.ac.nz>
Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
Chirag Chandrashekhar
2025-11-12 14:10:36 +05:30
committed by GitHub
parent 45cfbc5c49
commit 44ed0b01d8

View File

@@ -3,16 +3,13 @@ import path from 'utils/common/path';
import { useDispatch } from 'react-redux';
import { get, cloneDeep } from 'lodash';
import { runCollectionFolder, cancelRunnerExecution, mountCollection, updateRunnerConfiguration } from 'providers/ReduxStore/slices/collections/actions';
import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections';
import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun, IconLoader2 } from '@tabler/icons';
import { resetCollectionRunner, updateRunnerTagsDetails } from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, getTotalRequestCountInCollection, areItemsLoading, getRequestItemsForCollectionRun } from 'utils/collections';
import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun, IconExternalLink } from '@tabler/icons';
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
import RunnerTags from './RunnerTags/index';
import RunConfigurationPanel from './RunConfigurationPanel';
import { getRequestItemsForCollectionRun } from 'utils/collections/index';
import { updateRunnerTagsDetails } from 'providers/ReduxStore/slices/collections/index';
const getDisplayName = (fullPath, pathname, name = '') => {
let relativePath = path.relative(fullPath, pathname);
@@ -42,67 +39,61 @@ const anyTestFailed = (item) => {
item.postResponseTestStatus === 'fail';
};
// === Centralized filters definition ===
const FILTERS = {
all: {
label: 'All',
predicate: () => true,
resultFilter: (results) => results
},
passed: {
label: 'Passed',
predicate: (item) => allTestsPassed(item),
resultFilter: (results) => results?.filter((r) => r.status === 'pass')
},
failed: {
label: 'Failed',
predicate: (item) => anyTestFailed(item),
resultFilter: (results) => results?.filter((r) => ['fail', 'error'].includes(r.status))
},
skipped: {
label: 'Skipped',
predicate: (item) => item.status === 'skipped',
resultFilter: (results) => results
}
};
// === Reusable filter button ===
const FilterButton = ({ label, count, active, onClick }) => (
<button
onClick={onClick}
className={`font-medium transition-colors cursor-pointer flex items-center gap-1.5 border-b-2 pb-2 ${
active
? 'text-[#343434] dark:text-[#CCCCCC] border-[#F59E0B]'
: 'text-[#989898] dark:text-[#CCCCCC80] border-transparent'
}`}
style={{ fontFamily: 'Inter', fontSize: '14px', fontWeight: 500, lineHeight: '100%', letterSpacing: '0%' }}
>
{label}
<span
className="px-[4.5px] py-[2px] rounded-[2px] bg-[#F7F7F7] dark:bg-[#242424] border border-[#EFEFEF] dark:border-[#92929233] text-[#989898] dark:text-inherit"
style={{ borderWidth: '1px', fontFamily: 'Inter', fontSize: '10px', fontWeight: 500, lineHeight: '100%', letterSpacing: '0%' }}
>
{count}
</span>
</button>
);
export default function RunnerResults({ collection }) {
const dispatch = useDispatch();
const [selectedItem, setSelectedItem] = useState(null);
const [delay, setDelay] = useState(null);
const [activeFilter, setActiveFilter] = useState('all');
const getActiveFilterPredicate = () => {
switch (activeFilter) {
case 'passing_requests':
return (item) => item.status !== 'error' && item.testStatus === 'pass' && item.assertionStatus === 'pass';
case 'failing_requests':
return (item) => (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail';
case 'passing_tests':
return (item) => item.testResults?.some((result) => result.status === 'pass');
case 'failing_tests':
return (item) => item.testResults?.some((result) => result.status === 'fail' || result.status === 'error');
default:
return () => true
}
}
const [selectedRequestItems, setSelectedRequestItems] = useState([]);
const [configureMode, setConfigureMode] = useState(false);
// ref for the runner output body
const runnerBodyRef = useRef();
const autoScrollRunnerBody = () => {
if (runnerBodyRef?.current) {
// mimics the native terminal scroll style
runnerBodyRef.current.scrollTo(0, 100000);
}
};
useEffect(() => {
if (!collection.runnerResult) {
setSelectedItem(null);
}
autoScrollRunnerBody();
}, [collection, setSelectedItem]);
useEffect(() => {
const runnerInfo = get(collection, 'runnerResult.info', {});
if (runnerInfo.status === 'running') {
setConfigureMode(false);
}
}, [collection.runnerResult]);
useEffect(() => {
const savedConfiguration = get(collection, 'runnerConfiguration', null);
if (savedConfiguration) {
if (savedConfiguration.selectedRequestItems && configureMode) {
setSelectedRequestItems(savedConfiguration.selectedRequestItems);
}
if (savedConfiguration.delay !== undefined && delay === null) {
setDelay(savedConfiguration.delay);
}
}
}, [collection.runnerConfiguration, configureMode, delay]);
const collectionCopy = cloneDeep(collection);
const runnerInfo = get(collection, 'runnerResult.info', {});
@@ -144,6 +135,63 @@ export default function RunnerResults({ collection }) {
})
.filter(Boolean);
const activeFilterConfig = FILTERS[activeFilter];
const filteredItems = items.filter(activeFilterConfig.predicate);
const filterTestResults = (results) => {
if (!results || !Array.isArray(results)) return [];
return activeFilterConfig.resultFilter(results);
};
const autoScrollRunnerBody = () => {
if (runnerBodyRef?.current) {
const element = runnerBodyRef.current;
const scrollThreshold = 100; // pixels from bottom to consider "at bottom"
const isNearBottom
= element.scrollHeight - element.scrollTop - element.clientHeight < scrollThreshold;
// Only auto-scroll if user is already near the bottom
if (isNearBottom) {
// mimics the native terminal scroll style
element.scrollTo(0, 100000);
}
}
};
useEffect(() => {
if (!collection.runnerResult) {
setSelectedItem(null);
}
autoScrollRunnerBody();
}, [collection, setSelectedItem]);
useEffect(() => {
// Auto-scroll when items are added or updated during execution
// Only scrolls if user is already at/near the bottom
if (filteredItems.length > 0) {
autoScrollRunnerBody();
}
}, [filteredItems]);
useEffect(() => {
const runnerInfo = get(collection, 'runnerResult.info', {});
if (runnerInfo.status === 'running') {
setConfigureMode(false);
}
}, [collection.runnerResult]);
useEffect(() => {
const savedConfiguration = get(collection, 'runnerConfiguration', null);
if (savedConfiguration) {
if (savedConfiguration.selectedRequestItems && configureMode) {
setSelectedRequestItems(savedConfiguration.selectedRequestItems);
}
if (savedConfiguration.delay !== undefined && delay === null) {
setDelay(savedConfiguration.delay);
}
}
}, [collection.runnerConfiguration, configureMode, delay]);
const ensureCollectionIsMounted = () => {
if(collection.mountStatus === 'mounted'){
return;
@@ -210,52 +258,14 @@ export default function RunnerResults({ collection }) {
}, [tagsEnabled]);
const totalRequestsInCollection = getTotalRequestCountInCollection(collectionCopy);
const filterCounts = {
all: items.length,
passed: items.filter(allTestsPassed).length,
failed: items.filter(anyTestFailed).length,
skipped: items.filter((i) => i.status === 'skipped').length
};
const displayCollectionResults = () => {
let passedRequests = 0;
let failedRequests = 0;
let totalTestsInCollection = 0;
let passedTests = 0;
let failedTests = 0;
items.forEach(item => {
const isPassedRequest = item.status !== 'error' && item.testStatus === 'pass' && item.assertionStatus === 'pass';
const isFailedRequest = (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail';
if (isPassedRequest) passedRequests++;
if (isFailedRequest) failedRequests++;
const testResults = Array.isArray(item?.testResults) ? item.testResults : [];
totalTestsInCollection += testResults.length;
testResults.forEach(result => {
if (result.status === 'pass') passedTests++;
if (result.status === 'fail' || result.status === 'error') failedTests++;
});
});
return (
<div className="pb-2 font-medium test-summary flex flex-col items-start justify-center mx-2">
<div>
<span onClick={() => setActiveFilter('all')} className={`cursor-pointer ${activeFilter === 'all' ? 'underline font-semibold' : ''} hover:font-semibold`}>Total Requests: {items.length}, </span>
<span onClick={() => setActiveFilter('passing_requests')} className={`cursor-pointer ${activeFilter === 'passing_requests' ? 'underline font-semibold' : ''} hover:font-semibold`}>Passed: {passedRequests}, </span>
<span onClick={() => setActiveFilter('failing_requests') } className={`cursor-pointer ${activeFilter === 'failing_requests' ? 'underline font-semibold' : ''} hover:font-semibold`}>Failed: {failedRequests}</span>
</div>
<div>
<span onClick={() => setActiveFilter('all')} className={`cursor-pointer ${activeFilter === 'all' ? 'underline font-semibold' : ''} hover:font-semibold`}>Total Tests: {totalTestsInCollection}, </span>
<span onClick={() => setActiveFilter('passing_tests')} className={`cursor-pointer ${activeFilter === 'passing_tests' ? 'underline font-semibold' : ''} hover:font-semibold`}>Passed: {passedTests}, </span>
<span onClick={() => setActiveFilter('failing_tests')} className={`cursor-pointer ${activeFilter === 'failing_tests' ? 'underline font-semibold' : ''} hover:font-semibold`}>Failed: {failedTests}</span>
</div>
</div>
)
}
const passedRequests = items.filter(allTestsPassed);
const failedRequests = items.filter(anyTestFailed);
const skippedRequests = items.filter((item) => {
return item.status === 'skipped';
});
let isCollectionLoading = areItemsLoading(collection);
if (!items || !items.length) {
return (
<StyledWrapper className="pl-4 overflow-hidden h-full">
@@ -341,35 +351,57 @@ export default function RunnerResults({ collection }) {
return (
<StyledWrapper className="px-4 pb-4 flex flex-grow flex-col relative overflow-auto">
<div className="flex items-center my-6 flex-row">
<div className="font-medium title flex items-center">
Runner
<IconRun size={20} strokeWidth={1.5} className="ml-2" />
{/* Filter Bar and Actions */}
<div className="flex items-center justify-between mb-4 pt-[14px] gap-4">
<div className="flex items-stretch rounded-lg border border-[#EFEFEF] dark:border-[#92929233] max-h-[35px] flex-shrink-0" style={{ borderWidth: '1px' }}>
<div className="flex items-center px-3 py-2 rounded-l-lg bg-[#F3F3F3] dark:bg-[#2B2D2F]">
<span className="text-gray-600 dark:text-gray-400" style={{ fontFamily: 'Inter', fontSize: '14px', fontWeight: 400 }}>
Filter by:
</span>
</div>
<div className="flex items-center gap-5 px-3 pt-2 pb-0 rounded-r-lg bg-transparent dark:bg-transparent">
{Object.entries(FILTERS).map(([key, { label }]) => (
<FilterButton
key={key}
label={label}
count={filterCounts[key]}
active={activeFilter === key}
onClick={() => setActiveFilter(key)}
/>
))}
</div>
</div>
{displayCollectionResults()}
{runnerInfo.status !== 'ended' && runnerInfo.cancelTokenUid && (
<button className="btn btn-sm btn-danger" onClick={cancelExecution}>
Cancel Execution
</button>
)}
{runnerInfo.status !== 'ended' && runnerInfo.cancelTokenUid ? (
<div className="flex items-center flex-shrink-0">
<button className="btn btn-sm btn-danger" onClick={cancelExecution}>Cancel Execution</button>
</div>
) : runnerInfo.status === 'ended' ? (
<div className="flex items-center gap-3 flex-shrink-0">
<button
type="button"
className="px-3 py-1.5 rounded-md bg-transparent border border-[#989898] dark:border-[#444444] text-[#989898] hover:opacity-80 transition-colors"
style={{ fontFamily: 'Inter', fontSize: '12px', fontWeight: 500 }}
onClick={runAgain}
>
Run Again
</button>
<button
type="button"
className="px-3 py-1.5 rounded-md bg-transparent border border-[#989898] dark:border-[#444444] text-[#989898] hover:opacity-80 transition-colors"
style={{ fontFamily: 'Inter', fontSize: '12px', fontWeight: 500 }}
onClick={resetRunner}
>
Reset
</button>
</div>
) : null}
</div>
<div className="flex gap-4 h-[calc(100vh_-_10rem)] overflow-hidden">
<div
className={`flex flex-col overflow-y-auto ${selectedItem || (configureMode && !selectedItem && !runnerInfo.status === 'running') ? 'w-1/2' : 'w-full'}`}
ref={runnerBodyRef}
className="flex flex-col w-1/2"
>
{runnerInfo?.statusText ?
<div className="pb-2 font-medium danger">
{runnerInfo?.statusText}
</div>
: null}
<div className="pb-2 font-medium test-summary">
Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}, Skipped:{' '}
{skippedRequests.length}
</div>
{tagsEnabled && areTagsAdded && (
<div className="pb-2 text-xs flex flex-row gap-1">
Tags:
@@ -383,9 +415,15 @@ export default function RunnerResults({ collection }) {
</div>
</div>
)}
{runnerInfo?.statusText ?
<div className="pb-2 font-medium danger">
{runnerInfo?.statusText}
</div>
: null}
{/* Items list */}
<div className="overflow-y-auto flex-1">
{items.filter(getActiveFilterPredicate()).map((item) => {
<div className="overflow-y-auto flex-1 " ref={runnerBodyRef}>
{filteredItems.map((item) => {
return (
<div key={item.uid}>
<div className="item-path mt-2">
@@ -429,7 +467,7 @@ export default function RunnerResults({ collection }) {
<ul className="pl-8">
{item.preRequestTestResults
? item.preRequestTestResults.map((result) => (
? filterTestResults(item.preRequestTestResults).map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -449,7 +487,7 @@ export default function RunnerResults({ collection }) {
))
: null}
{item.postResponseTestResults
? item.postResponseTestResults.map((result) => (
? filterTestResults(item.postResponseTestResults).map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -469,7 +507,7 @@ export default function RunnerResults({ collection }) {
))
: null}
{item.testResults
? item.testResults.map((result) => (
? filterTestResults(item.testResults).map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -488,7 +526,7 @@ export default function RunnerResults({ collection }) {
</li>
))
: null}
{item.assertionResults?.map((result) => (
{filterTestResults(item.assertionResults).map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -512,43 +550,51 @@ export default function RunnerResults({ collection }) {
);
})}
</div>
{runnerInfo.status === 'ended' ? (
<div className="mt-2 mb-4">
<button type="submit" className="submit btn btn-sm btn-secondary mt-6" onClick={runAgain}>
Run Again
</button>
<button type="submit" className="submit btn btn-sm btn-secondary mt-6 ml-3" disabled={shouldDisableCollectionRun} onClick={runCollection}>
Run Collection
</button>
<button className="btn btn-sm btn-close mt-6 ml-3" onClick={resetRunner}>
Reset
</button>
</div>
) : null}
</div>
{selectedItem ? (
<div className="flex flex-1 w-[50%] overflow-y-auto">
<div className="flex flex-col w-full overflow-hidden">
<div className="flex items-center mb-4 font-medium">
<span className="mr-2">{selectedItem.displayName}</span>
<span>
{allTestsPassed(selectedItem) ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{anyTestFailed(selectedItem) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
: null}
{selectedItem.status === 'skipped' ?
<IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
: null}
</span>
<div className="flex items-center justify-between mb-4 font-medium">
<div className="flex items-center">
<span className="mr-2">{selectedItem.displayName}</span>
<span>
{allTestsPassed(selectedItem)
? <IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{anyTestFailed(selectedItem)
? <IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
: null}
{selectedItem.status === 'skipped'
? <IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
: null}
</span>
</div>
<button
onClick={() => setSelectedItem(null)}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer flex items-center justify-center"
title="Close"
aria-label="Close response view"
>
<IconX size={16} strokeWidth={1.5} />
</button>
</div>
<ResponsePane item={selectedItem} collection={collection} />
</div>
</div>
) : null}
) : (
<div className="flex flex-1 w-[50%] overflow-y-auto">
<div className="flex flex-col w-full h-full items-center justify-center text-center">
<div className="mb-4 text-gray-400 dark:text-gray-500">
<IconExternalLink size={64} strokeWidth={1.5} />
</div>
<p className="text-gray-600 dark:text-gray-400 text-sm">
Click on the status code to view the response
</p>
</div>
</div>
)}
</div>
</StyledWrapper>
);
}
}