Add Select/Deselect and Reorder Capabilities to Collection Runner (#5195)

This commit is contained in:
naman-bruno
2025-07-31 00:00:23 +05:30
committed by GitHub
parent 31027cb2e0
commit ec51ebba45
8 changed files with 854 additions and 166 deletions

View File

@@ -0,0 +1,231 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
background-color: ${props => props.theme.sidebar.bg};
height: 100%;
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid ${props => props.theme.sidebar.dragbar};
margin-bottom: 0.5rem;
.counter {
font-size: 0.875rem;
font-weight: 500;
}
.actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.btn-select-all,
.btn-reset {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: ${props => props.theme.textLink};
background: none;
border: none;
padding: 0.25rem 0.5rem;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
.request-list {
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: ${props => props.theme.console.scrollbarThumb};
border-radius: 3px;
}
.loading-message,
.empty-message {
padding: 0.75rem;
color: ${props => props.theme.colors.text.muted};
font-size: 0.875rem;
}
.requests-container {
padding: 0.5rem;
position: relative;
}
}
.request-item {
display: flex;
align-items: center;
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 0.25rem;
position: relative;
height: 2.5rem;
border: 1px solid transparent;
background-color: ${props => props.theme.sidebar.bg};
transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
&.is-selected {
background-color: ${props => props.theme.requestTabs.active.bg};
}
&.is-dragging {
opacity: 0.5;
background-color: ${props => props.theme.sidebar.bg};
border: 1px dashed ${props => props.theme.sidebar.dragbar};
transform: scale(0.98);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.12);
z-index: 5;
}
&::before,
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: ${props => props.theme.dragAndDrop?.border || props.theme.textLink};
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
&::before {
top: -1px;
}
&::after {
bottom: -1px;
}
&.drop-target-above {
&::before {
opacity: 1;
height: 2px;
background: ${props => props.theme.dragAndDrop?.border || props.theme.textLink};
}
}
&.drop-target-below {
&::after {
opacity: 1;
height: 2px;
background: ${props => props.theme.dragAndDrop?.border || props.theme.textLink};
}
}
.drag-handle {
cursor: grab;
margin-right: 0.25rem;
color: ${props => props.theme.sidebar.muted};
display: flex;
align-items: center;
transition: color 0.15s ease;
&:hover {
color: ${props => props.theme.text};
}
&:active {
cursor: grabbing;
color: ${props => props.theme.textLink};
}
}
.checkbox-container {
cursor: pointer;
margin-right: 0.5rem;
.checkbox {
width: 1rem;
height: 1rem;
border: 1px solid ${props => props.theme.sidebar.dragbar};
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s ease;
&:hover {
border-color: ${props => props.theme.textLink};
}
}
}
.method {
font-family: monospace;
font-size: 0.75rem;
font-weight: 500;
margin-right: 0.5rem;
min-width: 3rem;
color: ${props => props.theme.sidebar.muted}; // Default color for unknown methods
&.method-get {
color: ${props => props.theme.request.methods.get};
}
&.method-post {
color: ${props => props.theme.request.methods.post};
}
&.method-put {
color: ${props => props.theme.request.methods.put};
}
&.method-delete {
color: ${props => props.theme.request.methods.delete};
}
&.method-patch {
color: ${props => props.theme.request.methods.patch};
}
&.method-options {
color: ${props => props.theme.request.methods.options};
}
&.method-head {
color: ${props => props.theme.request.methods.head};
}
}
.request-name {
flex: 1;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.folder-path {
margin-left: 0.5rem;
font-size: 0.75rem;
color: ${props => props.theme.sidebar.muted};
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,327 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { getEmptyImage } from 'react-dnd-html5-backend';
import { IconGripVertical, IconCheck, IconAdjustmentsAlt } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { updateRunnerConfiguration } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { isItemARequest } from 'utils/collections';
import path from 'utils/common/path';
import { cloneDeep, get } from 'lodash';
const ItemTypes = {
REQUEST_ITEM: 'request-item'
};
const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) => {
const ref = useRef(null);
const [dropType, setDropType] = useState(null);
const determineDropType = (monitor) => {
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!hoverBoundingRect || !clientOffset) return null;
const clientY = clientOffset.y - hoverBoundingRect.top;
const middleY = hoverBoundingRect.height / 2;
return clientY < middleY ? 'above' : 'below';
};
const [{ isDragging }, drag, preview] = useDrag({
type: ItemTypes.REQUEST_ITEM,
item: { uid: item.uid, name: item.name, request: item.request, index },
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
options: {
dropEffect: "move"
},
end: (draggedItem, monitor) => {
if (monitor.didDrop()) {
onDrop();
}
},
});
const [{ isOver, canDrop }, drop] = useDrop({
accept: ItemTypes.REQUEST_ITEM,
hover: (draggedItem, monitor) => {
if (draggedItem.uid === item.uid) {
setDropType(null);
return;
}
const dropType = determineDropType(monitor);
setDropType(dropType);
},
drop: (draggedItem, monitor) => {
if (draggedItem.uid === item.uid) return;
const dropType = determineDropType(monitor);
let targetIndex = index;
if (dropType === 'below') {
targetIndex = index + 1;
}
if (draggedItem.index < targetIndex) {
targetIndex = targetIndex - 1;
}
moveItem(draggedItem.uid, targetIndex);
setDropType(null);
return { item: draggedItem };
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop()
}),
});
useEffect(() => {
preview(getEmptyImage(), { captureDraggingState: true });
}, []);
// Clear drop type when not hovering
useEffect(() => {
if (!isOver) {
setDropType(null);
}
}, [isOver]);
drag(drop(ref));
const itemClasses = [
'request-item',
isDragging ? 'is-dragging' : '',
isSelected ? 'is-selected' : '',
isOver && canDrop && dropType === 'above' ? 'drop-target-above' : '',
isOver && canDrop && dropType === 'below' ? 'drop-target-below' : ''
].filter(Boolean).join(' ');
return (
<div ref={ref} className={itemClasses}>
<div className="drag-handle">
<IconGripVertical size={16} strokeWidth={1.5} />
</div>
<div className="checkbox-container" onClick={() => onSelect(item)}>
<div className="checkbox">
{isSelected && <IconCheck size={12} />}
</div>
</div>
<div className={`method method-${item.request?.method.toLowerCase()}`}>
{item.request?.method.toUpperCase()}
</div>
<div className="request-name">
<span>{item.name}</span>
{item.folderPath && (
<span className="folder-path">{item.folderPath}</span>
)}
</div>
</div>
);
};
const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems }) => {
const dispatch = useDispatch();
const [flattenedRequests, setFlattenedRequests] = useState([]);
const [originalRequests, setOriginalRequests] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const flattenRequests = useCallback((collection) => {
const result = [];
const processItems = (items) => {
if (!items?.length) return;
items.forEach(item => {
if (isItemARequest(item) && !item.partial) {
const relativePath = path.relative(collection.pathname, path.dirname(item.pathname));
const folderPath = relativePath !== '.' ? relativePath : '';
result.push({
...item,
folderPath: folderPath.replace(/\\/g, '/')
});
}
if (item.items?.length) {
processItems(item.items);
}
});
};
processItems(collection.items);
return result;
}, []);
useEffect(() => {
setIsLoading(true);
try {
const structureCopy = cloneDeep(collection);
const requests = flattenRequests(structureCopy);
const savedConfiguration = get(collection, 'runnerConfiguration', null);
if (savedConfiguration?.requestItemsOrder?.length > 0) {
const orderedRequests = [];
const requestMap = new Map(requests.map(req => [req.uid, req]));
savedConfiguration.requestItemsOrder.forEach(uid => {
const request = requestMap.get(uid);
if (request) {
orderedRequests.push(request);
requestMap.delete(uid);
}
});
requestMap.forEach(request => {
orderedRequests.push(request);
});
setFlattenedRequests(orderedRequests);
} else {
setFlattenedRequests(requests);
}
setOriginalRequests(cloneDeep(requests));
} catch (error) {
console.error("Error loading collection structure:", error);
} finally {
setIsLoading(false);
}
}, [collection, flattenRequests]);
const moveItem = useCallback((draggedItemUid, hoverIndex) => {
setFlattenedRequests((prevRequests) => {
const dragIndex = prevRequests.findIndex(item => item.uid === draggedItemUid);
if (dragIndex === -1 || dragIndex === hoverIndex) {
return prevRequests;
}
const updatedRequests = [...prevRequests];
const [draggedItem] = updatedRequests.splice(dragIndex, 1);
updatedRequests.splice(hoverIndex, 0, draggedItem);
return updatedRequests;
});
}, []);
const handleDrop = useCallback(() => {
const selectedUids = new Set(selectedItems);
setFlattenedRequests(currentRequests => {
const newOrderedSelectedUids = currentRequests
.filter(item => selectedUids.has(item.uid))
.map(item => item.uid);
const allRequestUidsOrder = currentRequests.map(item => item.uid);
setSelectedItems(newOrderedSelectedUids);
dispatch(updateRunnerConfiguration(collection.uid, newOrderedSelectedUids, allRequestUidsOrder));
return currentRequests;
});
}, [selectedItems, collection.uid, dispatch, setSelectedItems]);
const handleRequestSelect = useCallback((item) => {
try {
if (selectedItems.includes(item.uid)) {
const newSelectedUids = selectedItems.filter(uid => uid !== item.uid);
setSelectedItems(newSelectedUids);
const allRequestUidsOrder = flattenedRequests.map(item => item.uid);
dispatch(updateRunnerConfiguration(collection.uid, newSelectedUids, allRequestUidsOrder));
} else {
const newSelectedUids = [...selectedItems, item.uid];
const orderedSelectedUids = flattenedRequests
.filter(req => newSelectedUids.includes(req.uid))
.map(req => req.uid);
setSelectedItems(orderedSelectedUids);
const allRequestUidsOrder = flattenedRequests.map(item => item.uid);
dispatch(updateRunnerConfiguration(collection.uid, orderedSelectedUids, allRequestUidsOrder));
}
} catch (error) {
console.error("Error selecting item:", error);
}
}, [selectedItems, setSelectedItems, flattenedRequests, dispatch, collection.uid]);
const handleSelectAll = useCallback(() => {
try {
if (selectedItems.length === flattenedRequests.length) {
setSelectedItems([]);
dispatch(updateRunnerConfiguration(collection.uid, [], []));
} else {
const allUids = flattenedRequests.map(item => item.uid);
setSelectedItems(allUids);
const allRequestUidsOrder = flattenedRequests.map(item => item.uid);
dispatch(updateRunnerConfiguration(collection.uid, allUids, allRequestUidsOrder));
}
} catch (error) {
console.error("Error selecting/deselecting all items:", error);
}
}, [flattenedRequests, selectedItems, setSelectedItems, dispatch, collection.uid]);
const handleReset = useCallback(() => {
try {
setFlattenedRequests(cloneDeep(originalRequests));
setSelectedItems([]);
dispatch(updateRunnerConfiguration(collection.uid, [], []));
} catch (error) {
console.error("Error resetting configuration:", error);
}
}, [originalRequests, setSelectedItems, collection.uid, dispatch]);
return (
<StyledWrapper>
<div className="header">
<div className="counter">
{selectedItems.length} of {flattenedRequests.length} selected
</div>
<div className="actions">
<button className="btn-select-all" onClick={handleSelectAll}>
{selectedItems.length === flattenedRequests.length ? "Deselect All" : "Select All"}
</button>
<button className="btn-reset" onClick={handleReset} title="Reset selection and order">
<IconAdjustmentsAlt size={16} strokeWidth={1.5} />
Reset
</button>
</div>
</div>
<div className="request-list">
{isLoading ? (
<div className="loading-message">Loading requests...</div>
) : flattenedRequests.length === 0 ? (
<div className="empty-message">No requests found in this collection</div>
) : (
<div className="requests-container">
{flattenedRequests.map((item, idx) => {
const isSelected = selectedItems.includes(item.uid);
return (
<RequestItem
key={item.uid}
item={item}
index={idx}
isSelected={isSelected}
onSelect={() => handleRequestSelect(item)}
moveItem={moveItem}
onDrop={handleDrop}
/>
);
})}
</div>
)}
</div>
</StyledWrapper>
);
};
export default RunConfigurationPanel;

View File

@@ -89,13 +89,15 @@ const RunnerTags = ({ collectionUid, className = '' }) => {
return (
<div className={`mt-6 flex flex-col ${className}`}>
<div className="flex gap-2">
<label className="block font-medium">Filter requests with tags</label>
<input
className="cursor-pointer"
type="checkbox"
id="filter-tags"
type="radio"
name="filterMode"
checked={tagsEnabled}
onChange={() => setTagsEnabled(!tagsEnabled)}
/>
<label htmlFor="filter-tags" className="block font-medium">Filter requests with tags</label>
</div>
{tagsEnabled && (
<div className="flex flex-row mt-4 gap-4 w-full">

View File

@@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import path from 'utils/common/path';
import { useDispatch } from 'react-redux';
import { get, cloneDeep } from 'lodash';
import { runCollectionFolder, cancelRunnerExecution, mountCollection } from 'providers/ReduxStore/slices/collections/actions';
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';
@@ -10,7 +10,9 @@ 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);
@@ -25,25 +27,27 @@ const getTestStatus = (results) => {
};
const allTestsPassed = (item) => {
return item.status !== 'error' &&
item.testStatus === 'pass' &&
item.assertionStatus === 'pass' &&
item.preRequestTestStatus === 'pass' &&
item.postResponseTestStatus === 'pass';
return item.status !== 'error' &&
item.testStatus === 'pass' &&
item.assertionStatus === 'pass' &&
item.preRequestTestStatus === 'pass' &&
item.postResponseTestStatus === 'pass';
};
const anyTestFailed = (item) => {
return item.status === 'error' ||
item.testStatus === 'fail' ||
item.assertionStatus === 'fail' ||
item.preRequestTestStatus === 'fail' ||
item.postResponseTestStatus === 'fail';
return item.status === 'error' ||
item.testStatus === 'fail' ||
item.assertionStatus === 'fail' ||
item.preRequestTestStatus === 'fail' ||
item.postResponseTestStatus === 'fail';
};
export default function RunnerResults({ collection }) {
const dispatch = useDispatch();
const [selectedItem, setSelectedItem] = useState(null);
const [delay, setDelay] = useState(null);
const [selectedRequestItems, setSelectedRequestItems] = useState([]);
const [configureMode, setConfigureMode] = useState(false);
// ref for the runner output body
const runnerBodyRef = useRef();
@@ -62,6 +66,22 @@ export default function RunnerResults({ collection }) {
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 && configureMode) {
if (savedConfiguration.selectedRequestItems) {
setSelectedRequestItems(savedConfiguration.selectedRequestItems);
}
}
}, [collection.runnerConfiguration, configureMode]);
const collectionCopy = cloneDeep(collection);
const runnerInfo = get(collection, 'runnerResult.info', {});
@@ -115,19 +135,27 @@ export default function RunnerResults({ collection }) {
};
const runCollection = () => {
ensureCollectionIsMounted();
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags));
if (configureMode && selectedRequestItems.length > 0) {
dispatch(updateRunnerConfiguration(collection.uid, selectedRequestItems, selectedRequestItems));
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags, selectedRequestItems));
} else {
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags));
}
};
const runAgain = () => {
ensureCollectionIsMounted();
// Get the saved configuration to determine what to run
const savedConfiguration = get(collection, 'runnerConfiguration', null);
const savedSelectedItems = savedConfiguration?.selectedRequestItems || [];
dispatch(
runCollectionFolder(
collection.uid,
runnerInfo.folderUid,
runnerInfo.isRecursive,
true,
Number(delay),
tagsEnabled && tags
tagsEnabled && tags,
savedSelectedItems
)
);
};
@@ -138,12 +166,25 @@ export default function RunnerResults({ collection }) {
collectionUid: collection.uid
})
);
setSelectedRequestItems([]);
setConfigureMode(false);
};
const cancelExecution = () => {
dispatch(cancelRunnerExecution(runnerInfo.cancelTokenUid));
};
const toggleConfigureMode = () => {
dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tagsEnabled: false }));
setConfigureMode(!configureMode);
};
useEffect(() => {
if(tagsEnabled) {
setConfigureMode(false);
}
}, [tagsEnabled]);
const totalRequestsInCollection = getTotalRequestCountInCollection(collectionCopy);
const passedRequests = items.filter(allTestsPassed);
const failedRequests = items.filter(anyTestFailed);
@@ -155,66 +196,104 @@ export default function RunnerResults({ collection }) {
if (!items || !items.length) {
return (
<StyledWrapper className="px-4 pb-4">
<div className="font-medium mt-6 title flex items-center">
Runner
<IconRun size={20} strokeWidth={1.5} className="ml-2" />
</div>
<div className="mt-6">
You have <span className="font-medium">{totalRequestsInCollection}</span> requests in this collection.
{isCollectionLoading && (
<span className="ml-2 text-sm text-gray-500">
(Loading...)
</span>
<StyledWrapper className="pl-4 overflow-hidden h-full">
<div className="flex overflow-hidden max-h-full h-full">
<div className={`${configureMode ? 'w-1/2 pr-4' : 'w-full'}`}>
<div className="font-medium mt-6 title flex items-center">
Runner
<IconRun size={20} strokeWidth={1.5} className="ml-2" />
</div>
<div className="mt-6">
You have <span className="font-medium">{totalRequestsInCollection}</span> requests in this collection.
{isCollectionLoading && (
<span className="ml-2 text-sm text-gray-500">
(Loading...)
</span>
)}
</div>
{isCollectionLoading ? <div className='my-1 danger'>Requests in this collection are still loading.</div> : null}
<div className="mt-6">
<label>Delay (in ms)</label>
<input
type="number"
className="block textbox mt-2 py-5"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={delay}
onChange={(e) => setDelay(e.target.value)}
/>
</div>
{/* Tags for the collection run */}
<RunnerTags collectionUid={collection.uid} className='mb-6' />
{/* Configure requests option */}
<div className="flex flex-col border-b pb-6 mb-6 border-gray-200 dark:border-gray-700">
<div className="flex gap-2">
<input
className="cursor-pointer"
id="filter-config"
type="radio"
name="filterMode"
checked={configureMode}
onChange={toggleConfigureMode}
/>
<label htmlFor="filter-config" className="block font-medium">Configure requests to run</label>
</div>
</div>
<div className='flex flex-row gap-2'>
<button
type="submit"
className="submit btn btn-sm btn-secondary"
disabled={shouldDisableCollectionRun || (configureMode && selectedRequestItems.length === 0) || isCollectionLoading}
onClick={runCollection}
>
{configureMode && selectedRequestItems.length > 0
? `Run ${selectedRequestItems.length} Selected Request${selectedRequestItems.length > 1 ? 's' : ''}`
: "Run Collection"
}
</button>
<button className="submit btn btn-sm btn-close" onClick={resetRunner}>
Reset
</button>
</div>
</div>
{configureMode && (
<div className="w-1/2 border-l border-gray-200 dark:border-gray-700">
<RunConfigurationPanel
collection={collection}
selectedItems={selectedRequestItems}
setSelectedItems={setSelectedRequestItems}
/>
</div>
)}
</div>
<div className="mt-6">
<label>Delay (in ms)</label>
<input
type="number"
className="block textbox mt-2 py-5"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={delay}
onChange={(e) => setDelay(e.target.value)}
/>
</div>
{/* Tags for the collection run */}
<RunnerTags collectionUid={collection.uid} className='mb-6' />
<div className='flex flex-row gap-2'>
<button type="submit" className="submit btn btn-sm btn-secondary flex items-center gap-2" disabled={shouldDisableCollectionRun || isCollectionLoading} onClick={runCollection}>
{isCollectionLoading && <IconLoader2 size={16} className="animate-spin" />}
Run Collection
</button>
<button className="submit btn btn-sm btn-close" onClick={resetRunner}>
Reset
</button>
</div>
</StyledWrapper>
);
}
return (
<StyledWrapper className="px-4 pb-4 flex flex-grow flex-col relative overflow-auto">
<div className="flex flex-row">
<div className="font-medium my-6 title flex items-center">
<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" />
</div>
{runnerInfo.status !== 'ended' && runnerInfo.cancelTokenUid && (
<button className="btn ml-6 my-4 btn-sm btn-danger" onClick={cancelExecution}>
<button className="btn btn-sm btn-danger" onClick={cancelExecution}>
Cancel Execution
</button>
)}
</div>
<div className="flex flex-row gap-4 h-[calc(100%_-_4.375rem)]">
<div className="flex gap-4 h-[calc(100vh_-_10rem)] overflow-hidden">
<div
className="flex flex-col flex-1 overflow-y-auto w-full"
className={`flex flex-col overflow-y-auto ${selectedItem || (configureMode && !selectedItem && !runnerInfo.status === 'running') ? 'w-1/2' : 'w-full'}`}
ref={runnerBodyRef}
>
<div className="pb-2 font-medium test-summary">
@@ -234,57 +313,59 @@ export default function RunnerResults({ collection }) {
</div>
</div>
)}
{runnerInfo?.statusText ?
{runnerInfo?.statusText ?
<div className="pb-2 font-medium danger">
{runnerInfo?.statusText}
</div>
: null}
{items.map((item) => {
return (
<div key={item.uid}>
<div className="item-path mt-2">
<div className="flex items-center">
<span>
{allTestsPassed(item) ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{item.status === 'skipped' ?
<IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
:null}
{anyTestFailed(item) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
:null}
</span>
<span
className={`mr-1 ml-2 ${item.status == 'skipped' ? 'skipped-request' : anyTestFailed(item) ? 'danger' : ''}`}
>
{item.displayName}
</span>
{item.status !== 'error' && item.status !== 'skipped' && item.status !== 'completed' ? (
<IconRefresh className="animate-spin ml-1" size={18} strokeWidth={1.5} />
) : item.responseReceived?.status ? (
<span className="text-xs link cursor-pointer" onClick={() => setSelectedItem(item)}>
<span className="mr-1">{item.responseReceived?.status}</span>
-&nbsp;
<span>{item.responseReceived?.statusText}</span>
</span>
) : (
<span className="danger text-xs cursor-pointer" onClick={() => setSelectedItem(item)}>
(request failed)
</span>
)}
</div>
{tagsEnabled && areTagsAdded && item?.tags?.length > 0 && (
<div className="pl-7 text-xs text-gray-500">
Tags: {item.tags.filter(t => tags.include.includes(t)).join(', ')}
</div>
)}
{item.status == 'error' ? <div className="error-message pl-8 pt-2 text-xs">{item.error}</div> : null}
: null}
<ul className="pl-8">
{item.preRequestTestResults
? item.preRequestTestResults.map((result) => (
{/* Items list */}
<div className="overflow-y-auto flex-1">
{items.map((item) => {
return (
<div key={item.uid}>
<div className="item-path mt-2">
<div className="flex items-center">
<span>
{allTestsPassed(item) ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{item.status === 'skipped' ?
<IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
: null}
{anyTestFailed(item) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
: null}
</span>
<span
className={`mr-1 ml-2 ${item.status == 'skipped' ? 'skipped-request' : anyTestFailed(item) ? 'danger' : ''}`}
>
{item.displayName}
</span>
{item.status !== 'error' && item.status !== 'skipped' && item.status !== 'completed' ? (
<IconRefresh className="animate-spin ml-1" size={18} strokeWidth={1.5} />
) : item.responseReceived?.status ? (
<span className="text-xs link cursor-pointer" onClick={() => setSelectedItem(item)}>
<span className="mr-1">{item.responseReceived?.status}</span>
-&nbsp;
<span>{item.responseReceived?.statusText}</span>
</span>
) : (
<span className="danger text-xs cursor-pointer" onClick={() => setSelectedItem(item)}>
(request failed)
</span>
)}
</div>
{tagsEnabled && areTagsAdded && item?.tags?.length > 0 && (
<div className="pl-7 text-xs text-gray-500">
Tags: {item.tags.filter(t => tags.include.includes(t)).join(', ')}
</div>
)}
{item.status == 'error' ? <div className="error-message pl-8 pt-2 text-xs">{item.error}</div> : null}
<ul className="pl-8">
{item.preRequestTestResults
? item.preRequestTestResults.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -302,9 +383,9 @@ export default function RunnerResults({ collection }) {
)}
</li>
))
: null}
{item.postResponseTestResults
? item.postResponseTestResults.map((result) => (
: null}
{item.postResponseTestResults
? item.postResponseTestResults.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -322,9 +403,9 @@ export default function RunnerResults({ collection }) {
)}
</li>
))
: null}
{item.testResults
? item.testResults.map((result) => (
: null}
{item.testResults
? item.testResults.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -342,30 +423,32 @@ export default function RunnerResults({ collection }) {
)}
</li>
))
: null}
{item.assertionResults?.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
<IconCheck size={18} strokeWidth={2} className="mr-2" />
{result.lhsExpr}: {result.rhsExpr}
</span>
) : (
<>
<span className="test-failure flex items-center">
<IconX size={18} strokeWidth={2} className="mr-2" />
: null}
{item.assertionResults?.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
<IconCheck size={18} strokeWidth={2} className="mr-2" />
{result.lhsExpr}: {result.rhsExpr}
</span>
<span className="error-message pl-8 text-xs">{result.error}</span>
</>
)}
</li>
))}
</ul>
) : (
<>
<span className="test-failure flex items-center">
<IconX size={18} strokeWidth={2} className="mr-2" />
{result.lhsExpr}: {result.rhsExpr}
</span>
<span className="error-message pl-8 text-xs">{result.error}</span>
</>
)}
</li>
))}
</ul>
</div>
</div>
</div>
);
})}
);
})}
</div>
{runnerInfo.status === 'ended' ? (
<div className="mt-2 mb-4">
<button type="submit" className="submit btn btn-sm btn-secondary mt-6" onClick={runAgain}>
@@ -386,15 +469,15 @@ export default function RunnerResults({ collection }) {
<div className="flex items-center mb-4 font-medium">
<span className="mr-2">{selectedItem.displayName}</span>
<span>
{allTestsPassed(selectedItem) ?
{allTestsPassed(selectedItem) ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{anyTestFailed(selectedItem) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
: null}
: 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}
: null}
</span>
</div>
<ResponsePane item={selectedItem} collection={collection} />

View File

@@ -38,7 +38,8 @@ import {
setCollectionSecurityConfig,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl,
initRunRequestEvent
initRunRequestEvent,
updateRunnerConfiguration as _updateRunnerConfiguration
} from './index';
import { each } from 'lodash';
@@ -316,9 +317,9 @@ export const cancelRunnerExecution = (cancelTokenUid) => (dispatch) => {
cancelNetworkRequest(cancelTokenUid).catch((err) => console.log(err));
};
export const runCollectionFolder = (collectionUid, folderUid, recursive, delay, tags) => (dispatch, getState) => {
export const runCollectionFolder = (collectionUid, folderUid, recursive, delay, tags, selectedRequestUids) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
@@ -346,6 +347,26 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay,
})
);
// to only include those requests in the specified order while preserving folder data
if (selectedRequestUids && selectedRequestUids.length > 0) {
const newItems = [];
selectedRequestUids.forEach((uid, index) => {
const requestItem = findItemInCollection(collectionCopy, uid);
if (requestItem) {
const clonedRequest = cloneDeep(requestItem);
clonedRequest.seq = index + 1;
newItems.push(clonedRequest);
}
});
if (folder) {
folder.items = newItems;
} else {
collectionCopy.items = newItems;
}
}
const { ipcRenderer } = window;
ipcRenderer
.invoke(
@@ -1373,3 +1394,11 @@ export const mountCollection = ({ collectionUid, collectionPathname, brunoConfig
ipcRenderer.invoke('renderer:show-in-folder', collectionPath).then(resolve).catch(reject);
});
};
export const updateRunnerConfiguration = (collectionUid, selectedRequestItems, requestItemsOrder) => (dispatch) => {
dispatch(_updateRunnerConfiguration({
collectionUid,
selectedRequestItems,
requestItemsOrder
}));
};

View File

@@ -2238,6 +2238,7 @@ export const collectionsSlice = createSlice({
collection.runnerResult = null;
collection.runnerTags = { include: [], exclude: [] }
collection.runnerTagsEnabled = false;
collection.runnerConfiguration = null;
}
},
updateRunnerTagsDetails: (state, action) => {
@@ -2252,6 +2253,16 @@ export const collectionsSlice = createSlice({
}
}
},
updateRunnerConfiguration: (state, action) => {
const { collectionUid, selectedRequestItems, requestItemsOrder } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
collection.runnerConfiguration = {
selectedRequestItems: selectedRequestItems || [],
requestItemsOrder: requestItemsOrder || []
};
}
},
updateRequestDocs: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -2523,6 +2534,7 @@ export const {
runFolderEvent,
resetCollectionRunner,
updateRunnerTagsDetails,
updateRunnerConfiguration,
updateRequestDocs,
updateFolderDocs,
moveCollection,

View File

@@ -1228,7 +1228,7 @@ const registerNetworkIpc = (mainWindow) => {
} catch (error) {
// Skip further processing if request was cancelled
if (axios.isCancel(error)) {
throw Promise.reject(error);
throw error;
}
if (error?.response) {
@@ -1262,7 +1262,7 @@ const registerNetworkIpc = (mainWindow) => {
await executeRequestOnFailHandler(request, error);
// if it's not a network error, don't continue
throw Promise.reject(error);
throw error;
}
}

View File

@@ -51,27 +51,31 @@ const getWorkerInstance = (): BruParserWorker => {
// We handle termination in other events
});
process.on('SIGINT', async () => {
await cleanup();
process.exit(0);
});
process.on('SIGTERM', async () => {
await cleanup();
process.exit(0);
});
process.on('uncaughtException', async (error: Error) => {
console.error('Uncaught Exception:', error);
await cleanup();
process.exit(1);
});
process.on('unhandledRejection', async (reason: unknown) => {
console.error('Unhandled Rejection:', reason);
await cleanup();
process.exit(1);
});
// Only register signal handlers in the main thread, not in worker threads
// This prevents conflicts and SIGABRT during collection run cancellation
if (!process.env.WORKER_THREAD && typeof process.send === 'undefined') {
process.on('SIGINT', async () => {
await cleanup();
process.exit(0);
});
process.on('SIGTERM', async () => {
await cleanup();
process.exit(0);
});
process.on('uncaughtException', async (error: Error) => {
console.error('Uncaught Exception:', error);
await cleanup();
process.exit(1);
});
process.on('unhandledRejection', async (reason: unknown) => {
console.error('Unhandled Rejection:', reason);
await cleanup();
process.exit(1);
});
}
cleanupHandlersRegistered = true;
}