mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: revamp Runner UI with Timings and Filters sections (#7505)
* feat: revamp Runner UI with Timings and Filters sections * fix: use configurable radio name for runner tags * fix: update Run Collection modal ui * refactor: improve runner radios accessibility and ux * fix: address runner review nits * fix: update tag list hover styling * fix: add data-testid for runner button * fix: preserve runner delay when updating request selection config * fix: preserve runner requestItemsOrder on run --------- Co-authored-by: naman-bruno <naman@usebruno.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
background-color: ${(props) => props.theme.sidebar.bg};
|
||||
background-color: ${(props) => props.theme.bg};
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -12,13 +12,14 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.dragbar};
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: ${(props) => props.theme.background.mantle};
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border0};
|
||||
|
||||
.counter {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -66,11 +67,12 @@ const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
height: 2.5rem;
|
||||
border: 1px solid transparent;
|
||||
background-color: ${(props) => props.theme.sidebar.bg};
|
||||
background-color: ${(props) => props.theme.bg};
|
||||
transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&.is-selected {
|
||||
background-color: ${(props) => props.theme.background.surface0};
|
||||
background-color: ${(props) => props.theme.background.mantle};
|
||||
border-color: ${(props) => props.theme.border.border0};
|
||||
|
||||
.checkbox {
|
||||
background-color: ${(props) => props.theme.primary.solid};
|
||||
@@ -82,9 +84,32 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
.drag-handle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
cursor: default;
|
||||
|
||||
.checkbox {
|
||||
border-color: ${(props) => props.theme.border.border2};
|
||||
background-color: ${(props) => props.theme.background.surface0};
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.border.border2};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-dragging {
|
||||
opacity: 0.5;
|
||||
background-color: ${(props) => props.theme.sidebar.bg};
|
||||
background-color: ${(props) => props.theme.bg};
|
||||
border: 1px dashed ${(props) => props.theme.sidebar.dragbar};
|
||||
transform: scale(0.98);
|
||||
box-shadow: ${(props) => props.theme.shadow.md};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { IconGripVertical, IconCheck } from '@tabler/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateRunnerConfiguration } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -9,6 +9,23 @@ import { isItemARequest } from 'utils/collections';
|
||||
import path from 'utils/common/path';
|
||||
import { cloneDeep, get } from 'lodash';
|
||||
import Button from 'ui/Button/index';
|
||||
import { isRequestTagsIncluded } from '@usebruno/common';
|
||||
|
||||
const isRequestDisabled = (item, tags) => {
|
||||
// WS and gRPC are not supported by the collection runner
|
||||
if (item.type === 'ws-request' || item.type === 'grpc-request') return true;
|
||||
|
||||
// Check tag filtering
|
||||
const requestTags = item.draft?.tags || item.tags || [];
|
||||
const includeTags = tags?.include || [];
|
||||
const excludeTags = tags?.exclude || [];
|
||||
|
||||
if (includeTags.length > 0 || excludeTags.length > 0) {
|
||||
return !isRequestTagsIncluded(requestTags, includeTags, excludeTags);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const ItemTypes = {
|
||||
REQUEST_ITEM: 'request-item'
|
||||
@@ -40,7 +57,7 @@ const getMethodInfo = (item) => {
|
||||
return { methodText, methodClass };
|
||||
};
|
||||
|
||||
const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) => {
|
||||
const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop, isDisabled }) => {
|
||||
const ref = useRef(null);
|
||||
const [dropType, setDropType] = useState(null);
|
||||
|
||||
@@ -58,6 +75,7 @@ const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) =>
|
||||
const [{ isDragging }, drag, preview] = useDrag({
|
||||
type: ItemTypes.REQUEST_ITEM,
|
||||
item: { uid: item.uid, name: item.name, request: item.request, index },
|
||||
canDrag: !isDisabled,
|
||||
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
|
||||
options: {
|
||||
dropEffect: 'move'
|
||||
@@ -117,28 +135,30 @@ const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) =>
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
const methodInfo = getMethodInfo(item);
|
||||
const itemClasses = [
|
||||
'request-item',
|
||||
isDragging ? 'is-dragging' : '',
|
||||
isSelected ? 'is-selected' : '',
|
||||
isDisabled ? 'is-disabled' : '',
|
||||
isOver && canDrop && dropType === 'above' ? 'drop-target-above' : '',
|
||||
isOver && canDrop && dropType === 'below' ? 'drop-target-below' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div ref={ref} className={itemClasses}>
|
||||
<div ref={ref} className={itemClasses} data-testid="runner-request-item">
|
||||
<div className="drag-handle">
|
||||
<IconGripVertical size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<div className="checkbox-container" onClick={() => onSelect(item)}>
|
||||
<div className="checkbox-container" onClick={() => !isDisabled && onSelect(item)}>
|
||||
<div className="checkbox">
|
||||
{isSelected && <IconCheck className="checkbox-icon" size={12} strokeWidth={3} />}
|
||||
{isSelected && !isDisabled && <IconCheck className="checkbox-icon" size={12} strokeWidth={3} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`method ${getMethodInfo(item).methodClass}`}>
|
||||
{getMethodInfo(item).methodText}
|
||||
<div className={`method ${methodInfo.methodClass}`}>
|
||||
{methodInfo.methodText}
|
||||
</div>
|
||||
|
||||
<div className="request-name">
|
||||
@@ -151,11 +171,15 @@ const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) =>
|
||||
);
|
||||
};
|
||||
|
||||
const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems }) => {
|
||||
const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems, tags }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [flattenedRequests, setFlattenedRequests] = useState([]);
|
||||
const [originalRequests, setOriginalRequests] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// On first mount, ignore any stale saved config and auto-select all items
|
||||
const isInitialMountRef = useRef(true);
|
||||
// Track items that were auto-deselected due to tag filters, so we can re-select them when tags change back
|
||||
const pendingReselectRef = useRef(new Set());
|
||||
|
||||
const flattenRequests = useCallback((collection) => {
|
||||
const result = [];
|
||||
@@ -192,6 +216,7 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
|
||||
const requests = flattenRequests(structureCopy);
|
||||
|
||||
const savedConfiguration = get(collection, 'runnerConfiguration', null);
|
||||
let finalRequests;
|
||||
if (savedConfiguration?.requestItemsOrder?.length > 0) {
|
||||
const orderedRequests = [];
|
||||
const requestMap = new Map(requests.map((req) => [req.uid, req]));
|
||||
@@ -208,12 +233,21 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
|
||||
orderedRequests.push(request);
|
||||
});
|
||||
|
||||
setFlattenedRequests(orderedRequests);
|
||||
finalRequests = orderedRequests;
|
||||
} else {
|
||||
setFlattenedRequests(requests);
|
||||
finalRequests = requests;
|
||||
}
|
||||
|
||||
setFlattenedRequests(finalRequests);
|
||||
setOriginalRequests(cloneDeep(requests));
|
||||
|
||||
if (!savedConfiguration || isInitialMountRef.current) {
|
||||
isInitialMountRef.current = false;
|
||||
const enabledUids = finalRequests
|
||||
.filter((item) => !isRequestDisabled(item, tags))
|
||||
.map((item) => item.uid);
|
||||
setSelectedItems(enabledUids);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading collection structure:', error);
|
||||
} finally {
|
||||
@@ -221,6 +255,44 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
|
||||
}
|
||||
}, [collection, flattenRequests]);
|
||||
|
||||
// When tags change: disable newly-filtered items, re-select previously-filtered items that are now enabled again
|
||||
useEffect(() => {
|
||||
if (flattenedRequests.length === 0) return;
|
||||
|
||||
let newSelected = [...selectedItems];
|
||||
let changed = false;
|
||||
|
||||
flattenedRequests.forEach((item) => {
|
||||
const disabled = isRequestDisabled(item, tags);
|
||||
const isCurrentlySelected = selectedItems.includes(item.uid);
|
||||
const isPendingReselect = pendingReselectRef.current.has(item.uid);
|
||||
|
||||
if (disabled && isCurrentlySelected) {
|
||||
pendingReselectRef.current.add(item.uid);
|
||||
newSelected = newSelected.filter((uid) => uid !== item.uid);
|
||||
changed = true;
|
||||
} else if (!disabled && isPendingReselect) {
|
||||
pendingReselectRef.current.delete(item.uid);
|
||||
if (!newSelected.includes(item.uid)) {
|
||||
newSelected.push(item.uid);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
const ordered = flattenedRequests
|
||||
.filter((r) => newSelected.includes(r.uid))
|
||||
.map((r) => r.uid);
|
||||
setSelectedItems(ordered);
|
||||
const allRequestUidsOrder = flattenedRequests.map((item) => item.uid);
|
||||
dispatch(updateRunnerConfiguration(collection.uid, ordered, allRequestUidsOrder));
|
||||
}
|
||||
}, [tags, flattenedRequests]);
|
||||
|
||||
const enabledRequests = flattenedRequests.filter((item) => !isRequestDisabled(item, tags));
|
||||
const enabledCount = enabledRequests.length;
|
||||
|
||||
const moveItem = useCallback((draggedItemUid, hoverIndex) => {
|
||||
setFlattenedRequests((prevRequests) => {
|
||||
const dragIndex = prevRequests.findIndex((item) => item.uid === draggedItemUid);
|
||||
@@ -255,6 +327,8 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
|
||||
}, [selectedItems, collection.uid, dispatch, setSelectedItems]);
|
||||
|
||||
const handleRequestSelect = useCallback((item) => {
|
||||
if (isRequestDisabled(item, tags)) return;
|
||||
|
||||
try {
|
||||
if (selectedItems.includes(item.uid)) {
|
||||
const newSelectedUids = selectedItems.filter((uid) => uid !== item.uid);
|
||||
@@ -277,51 +351,61 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
|
||||
} catch (error) {
|
||||
console.error('Error selecting item:', error);
|
||||
}
|
||||
}, [selectedItems, setSelectedItems, flattenedRequests, dispatch, collection.uid]);
|
||||
}, [selectedItems, setSelectedItems, flattenedRequests, dispatch, collection.uid, tags]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
try {
|
||||
const allRequestUidsOrder = flattenedRequests.map((item) => item.uid);
|
||||
const enabledUids = enabledRequests.map((item) => item.uid);
|
||||
|
||||
if (selectedItems.length === flattenedRequests.length) {
|
||||
if (selectedItems.length === enabledCount) {
|
||||
pendingReselectRef.current.clear();
|
||||
setSelectedItems([]);
|
||||
dispatch(updateRunnerConfiguration(collection.uid, [], allRequestUidsOrder));
|
||||
} else {
|
||||
setSelectedItems(allRequestUidsOrder);
|
||||
dispatch(updateRunnerConfiguration(collection.uid, allRequestUidsOrder, allRequestUidsOrder));
|
||||
setSelectedItems(enabledUids);
|
||||
dispatch(updateRunnerConfiguration(collection.uid, enabledUids, allRequestUidsOrder));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error selecting/deselecting all items:', error);
|
||||
}
|
||||
}, [flattenedRequests, selectedItems, setSelectedItems, dispatch, collection.uid]);
|
||||
}, [flattenedRequests, enabledRequests, enabledCount, selectedItems, setSelectedItems, dispatch, collection.uid]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
try {
|
||||
setFlattenedRequests(cloneDeep(originalRequests));
|
||||
setSelectedItems([]);
|
||||
dispatch(updateRunnerConfiguration(collection.uid, [], []));
|
||||
pendingReselectRef.current.clear();
|
||||
const resetRequests = cloneDeep(originalRequests);
|
||||
setFlattenedRequests(resetRequests);
|
||||
const enabledUids = resetRequests
|
||||
.filter((item) => !isRequestDisabled(item, tags))
|
||||
.map((item) => item.uid);
|
||||
setSelectedItems(enabledUids);
|
||||
const allUidsOrder = resetRequests.map((item) => item.uid);
|
||||
dispatch(updateRunnerConfiguration(collection.uid, enabledUids, allUidsOrder));
|
||||
} catch (error) {
|
||||
console.error('Error resetting configuration:', error);
|
||||
}
|
||||
}, [originalRequests, setSelectedItems, collection.uid, dispatch]);
|
||||
}, [originalRequests, setSelectedItems, collection.uid, dispatch, tags]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<StyledWrapper data-testid="runner-config-panel">
|
||||
<div className="header">
|
||||
<div className="counter">
|
||||
{selectedItems.length} of {flattenedRequests.length} selected
|
||||
<div className="counter" data-testid="runner-config-counter">
|
||||
{selectedItems.length} of {enabledCount} selected
|
||||
</div>
|
||||
<div className="actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSelectAll}
|
||||
data-testid="runner-select-all"
|
||||
>
|
||||
{selectedItems.length === flattenedRequests.length ? 'Deselect All' : 'Select All'}
|
||||
{selectedItems.length === enabledCount ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleReset}
|
||||
title="Reset selection and order"
|
||||
data-testid="runner-config-reset"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
@@ -337,6 +421,7 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
|
||||
<div className="requests-container">
|
||||
{flattenedRequests.map((item, idx) => {
|
||||
const isSelected = selectedItems.includes(item.uid);
|
||||
const disabled = isRequestDisabled(item, tags);
|
||||
|
||||
return (
|
||||
<RequestItem
|
||||
@@ -344,6 +429,7 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
|
||||
item={item}
|
||||
index={idx}
|
||||
isSelected={isSelected}
|
||||
isDisabled={disabled}
|
||||
onSelect={() => handleRequestSelect(item)}
|
||||
moveItem={moveItem}
|
||||
onDrop={handleDrop}
|
||||
|
||||
@@ -9,13 +9,7 @@ const RunnerTags = ({ collectionUid, className = '' }) => {
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const collection = cloneDeep(find(collections, (c) => c.uid === collectionUid));
|
||||
|
||||
// tags for the collection run
|
||||
const tags = get(collection, 'runnerTags', { include: [], exclude: [] });
|
||||
|
||||
// have tags been enabled for the collection run
|
||||
const tagsEnabled = get(collection, 'runnerTagsEnabled', false);
|
||||
|
||||
// all available tags in the collection that can be used for filtering
|
||||
const availableTags = get(collection, 'allTags', []);
|
||||
const tagsHintList = availableTags.filter((t) => !tags.exclude.includes(t) && !tags.include.includes(t));
|
||||
|
||||
@@ -39,12 +33,9 @@ const RunnerTags = ({ collectionUid, className = '' }) => {
|
||||
const handleAddTag = ({ tag, to }) => {
|
||||
const trimmedTag = tag.trim();
|
||||
if (!trimmedTag) return;
|
||||
// add tag to the `include` list
|
||||
if (to === 'include') {
|
||||
if (tags.include.includes(trimmedTag) || tags.exclude.includes(trimmedTag)) return;
|
||||
if (!availableTags.includes(trimmedTag)) {
|
||||
return;
|
||||
}
|
||||
if (!availableTags.includes(trimmedTag)) return;
|
||||
const newTags = { ...tags, include: [...tags.include, trimmedTag].sort() };
|
||||
setTags(newTags);
|
||||
return;
|
||||
@@ -52,9 +43,7 @@ const RunnerTags = ({ collectionUid, className = '' }) => {
|
||||
// add tag to the `exclude` list
|
||||
if (to === 'exclude') {
|
||||
if (tags.include.includes(trimmedTag) || tags.exclude.includes(trimmedTag)) return;
|
||||
if (!availableTags.includes(trimmedTag)) {
|
||||
return;
|
||||
}
|
||||
if (!availableTags.includes(trimmedTag)) return;
|
||||
const newTags = { ...tags, exclude: [...tags.exclude, trimmedTag].sort() };
|
||||
setTags(newTags);
|
||||
}
|
||||
@@ -82,47 +71,30 @@ const RunnerTags = ({ collectionUid, className = '' }) => {
|
||||
dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tags }));
|
||||
};
|
||||
|
||||
const setTagsEnabled = (tagsEnabled) => {
|
||||
dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tagsEnabled }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`mt-6 flex flex-col ${className}`}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="cursor-pointer"
|
||||
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">
|
||||
<div className="w-1/2 flex flex-col gap-2 max-w-[400px]">
|
||||
<span>Included tags:</span>
|
||||
<TagList
|
||||
tags={tags.include}
|
||||
handleAddTag={(tag) => handleAddTag({ tag, to: 'include' })}
|
||||
handleRemoveTag={(tag) => handleRemoveTag({ tag, from: 'include' })}
|
||||
tagsHintList={tagsHintList}
|
||||
handleValidation={handleValidation}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2 flex flex-col gap-2 max-w-[400px]">
|
||||
<span>Excluded tags:</span>
|
||||
<TagList
|
||||
tags={tags.exclude}
|
||||
handleAddTag={(tag) => handleAddTag({ tag, to: 'exclude' })}
|
||||
handleRemoveTag={(tag) => handleRemoveTag({ tag, from: 'exclude' })}
|
||||
tagsHintList={tagsHintList}
|
||||
handleValidation={handleValidation}
|
||||
/>
|
||||
</div>
|
||||
<div className={`flex flex-col ${className}`}>
|
||||
<div className="flex flex-row gap-4 w-full">
|
||||
<div className="flex-1 flex flex-col gap-2 min-w-0">
|
||||
<span>Include tags</span>
|
||||
<TagList
|
||||
tags={tags.include}
|
||||
handleAddTag={(tag) => handleAddTag({ tag, to: 'include' })}
|
||||
handleRemoveTag={(tag) => handleRemoveTag({ tag, from: 'include' })}
|
||||
tagsHintList={tagsHintList}
|
||||
handleValidation={handleValidation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col gap-2 min-w-0">
|
||||
<span>Exclude tags</span>
|
||||
<TagList
|
||||
tags={tags.exclude}
|
||||
handleAddTag={(tag) => handleAddTag({ tag, to: 'exclude' })}
|
||||
handleRemoveTag={(tag) => handleRemoveTag({ tag, from: 'exclude' })}
|
||||
tagsHintList={tagsHintList}
|
||||
handleValidation={handleValidation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,18 +3,48 @@ import styled from 'styled-components';
|
||||
const Wrapper = styled.div`
|
||||
.textbox {
|
||||
padding: 0.2rem 0.5rem;
|
||||
box-shadow: none;
|
||||
border-radius: 0px;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
transition: border-color ease-in-out 0.1s;
|
||||
border-radius: 3px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
height: 1.875rem;
|
||||
|
||||
&:focus {
|
||||
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
|
||||
outline: none !important;
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Radio button styles */
|
||||
input[type='radio'] {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.bg};
|
||||
flex-shrink: 0;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.input.focusBorder};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
border: 1px solid ${(props) => props.theme.primary.solid};
|
||||
background-image: radial-gradient(circle, ${(props) => props.theme.primary.solid} 40%, ${(props) => props.theme.bg} 42%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +108,43 @@ const Wrapper = styled.div`
|
||||
border-color: ${(props) => props.theme.background.surface1};
|
||||
}
|
||||
|
||||
.runner-section-title {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.runner-section {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
|
||||
div:has(> .single-line-editor) {
|
||||
height: 1.875rem;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
div:has(> .single-line-editor):focus-within {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
.single-line-editor {
|
||||
height: 1.475rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
|
||||
.CodeMirror {
|
||||
height: 1.475rem;
|
||||
line-height: 1.475rem;
|
||||
}
|
||||
|
||||
.CodeMirror-cursor {
|
||||
height: 0.875rem !important;
|
||||
margin-top: 0.3rem !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -3,8 +3,8 @@ 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, updateRunnerTagsDetails } from 'providers/ReduxStore/slices/collections';
|
||||
import { findItemInCollection, getTotalRequestCountInCollection, areItemsLoading, getRequestItemsForCollectionRun } from 'utils/collections';
|
||||
import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections';
|
||||
import { findItemInCollection, getTotalRequestCountInCollection, areItemsLoading } from 'utils/collections';
|
||||
import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun, IconExternalLink } from '@tabler/icons';
|
||||
import ResponsePane from './ResponsePane';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -81,7 +81,6 @@ export default function RunnerResults({ collection }) {
|
||||
const [delay, setDelay] = useState(null);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
const [selectedRequestItems, setSelectedRequestItems] = useState([]);
|
||||
const [configureMode, setConfigureMode] = useState(false);
|
||||
// ref for the runner output body
|
||||
const runnerBodyRef = useRef();
|
||||
|
||||
@@ -91,16 +90,9 @@ export default function RunnerResults({ collection }) {
|
||||
// tags for the collection run
|
||||
const tags = get(collection, 'runnerTags', { include: [], exclude: [] });
|
||||
|
||||
// have tags been enabled for the collection run
|
||||
const tagsEnabled = get(collection, 'runnerTagsEnabled', false);
|
||||
|
||||
// have tags been added for the collection run
|
||||
const areTagsAdded = tags.include.length > 0 || tags.exclude.length > 0;
|
||||
|
||||
const requestItemsForCollectionRun = getRequestItemsForCollectionRun({ recursive: true, tags, items: collection.items });
|
||||
const totalRequestItemsCountForCollectionRun = requestItemsForCollectionRun.length;
|
||||
const shouldDisableCollectionRun = totalRequestItemsCountForCollectionRun <= 0;
|
||||
|
||||
const items = cloneDeep(get(collection, 'runnerResult.items', []))
|
||||
.map((item) => {
|
||||
const info = findItemInCollection(collectionCopy, item.uid);
|
||||
@@ -164,24 +156,14 @@ export default function RunnerResults({ collection }) {
|
||||
}
|
||||
}, [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]);
|
||||
}, [collection.runnerConfiguration, delay]);
|
||||
|
||||
const ensureCollectionIsMounted = () => {
|
||||
if (collection.mountStatus === 'mounted') {
|
||||
@@ -195,13 +177,9 @@ export default function RunnerResults({ collection }) {
|
||||
};
|
||||
|
||||
const runCollection = () => {
|
||||
if (configureMode && selectedRequestItems.length > 0) {
|
||||
dispatch(updateRunnerConfiguration(collection.uid, selectedRequestItems, selectedRequestItems, delay));
|
||||
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags, selectedRequestItems));
|
||||
} else {
|
||||
dispatch(updateRunnerConfiguration(collection.uid, [], [], delay));
|
||||
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags));
|
||||
}
|
||||
const savedOrder = get(collection, 'runnerConfiguration.requestItemsOrder', selectedRequestItems);
|
||||
dispatch(updateRunnerConfiguration(collection.uid, selectedRequestItems, savedOrder, delay));
|
||||
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tags, selectedRequestItems));
|
||||
};
|
||||
|
||||
const runAgain = () => {
|
||||
@@ -216,7 +194,7 @@ export default function RunnerResults({ collection }) {
|
||||
runnerInfo.folderUid,
|
||||
true,
|
||||
Number(savedDelay),
|
||||
tagsEnabled && tags,
|
||||
tags,
|
||||
savedSelectedItems
|
||||
)
|
||||
);
|
||||
@@ -228,8 +206,6 @@ export default function RunnerResults({ collection }) {
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
setSelectedRequestItems([]);
|
||||
setConfigureMode(false);
|
||||
setDelay(null);
|
||||
};
|
||||
|
||||
@@ -237,17 +213,6 @@ export default function RunnerResults({ collection }) {
|
||||
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 filterCounts = {
|
||||
all: items.length,
|
||||
@@ -261,13 +226,13 @@ export default function RunnerResults({ collection }) {
|
||||
return (
|
||||
<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="w-1/2 pr-4">
|
||||
<div className="font-medium mt-6 title flex items-center">
|
||||
<IconRun size={20} strokeWidth={1.5} className="mr-2" />
|
||||
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.
|
||||
<div className="mt-2">
|
||||
You have <span className="font-medium text-xs">{totalRequestsInCollection}</span> {totalRequestsInCollection === 1 ? 'request' : 'requests'} in this collection.
|
||||
{isCollectionLoading && (
|
||||
<span className="ml-2 text-muted">
|
||||
(Loading...)
|
||||
@@ -275,47 +240,40 @@ export default function RunnerResults({ collection }) {
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Timings */}
|
||||
<div className="runner-section-title mt-6">Timings</div>
|
||||
<div className="runner-section mt-2">
|
||||
<label>Delay between requests (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="block textbox mt-2 py-5"
|
||||
className="block textbox w-full mt-2"
|
||||
placeholder="e.g. 5"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
data-testid="runner-delay-input"
|
||||
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="run-config-option flex flex-col border-b pb-6 mb-6">
|
||||
<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>
|
||||
{/* Filters */}
|
||||
<div className="runner-section-title mt-6">Filters</div>
|
||||
<div className="runner-section mt-2 mb-6">
|
||||
{/* Tags for the collection run */}
|
||||
<RunnerTags collectionUid={collection.uid} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={shouldDisableCollectionRun || (configureMode && selectedRequestItems.length === 0) || isCollectionLoading}
|
||||
data-testid="runner-run-button"
|
||||
disabled={selectedRequestItems.length === 0 || isCollectionLoading}
|
||||
onClick={runCollection}
|
||||
>
|
||||
{configureMode && selectedRequestItems.length > 0
|
||||
? `Run ${selectedRequestItems.length} Selected Request${selectedRequestItems.length > 1 ? 's' : ''}`
|
||||
: 'Run Collection'}
|
||||
Run {selectedRequestItems.length} Request{selectedRequestItems.length !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
|
||||
<Button type="button" variant="ghost" onClick={resetRunner}>
|
||||
@@ -324,15 +282,14 @@ export default function RunnerResults({ collection }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{configureMode && (
|
||||
<div className="run-config-panel w-1/2 border-l">
|
||||
<RunConfigurationPanel
|
||||
collection={collection}
|
||||
selectedItems={selectedRequestItems}
|
||||
setSelectedItems={setSelectedRequestItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="run-config-panel w-1/2 border-l">
|
||||
<RunConfigurationPanel
|
||||
collection={collection}
|
||||
selectedItems={selectedRequestItems}
|
||||
setSelectedItems={setSelectedRequestItems}
|
||||
tags={tags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
@@ -399,7 +356,7 @@ export default function RunnerResults({ collection }) {
|
||||
<div
|
||||
className="flex flex-col w-1/2"
|
||||
>
|
||||
{tagsEnabled && areTagsAdded && (
|
||||
{areTagsAdded && (
|
||||
<div className="pb-2 text-xs flex flex-row gap-1">
|
||||
Tags:
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
@@ -457,7 +414,7 @@ export default function RunnerResults({ collection }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tagsEnabled && areTagsAdded && item?.tags?.length > 0 && (
|
||||
{areTagsAdded && item?.tags?.length > 0 && (
|
||||
<div className="pl-7 text-xs text-muted">
|
||||
Tags: {item.tags.filter((t) => tags.include.includes(t)).join(', ')}
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,93 @@ const Wrapper = styled.div`
|
||||
.bruno-modal-content {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid ${(props) => props.theme.input.border};
|
||||
margin: 1rem 0rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.textbox {
|
||||
padding: 0.2rem 0.5rem;
|
||||
outline: none;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
height: 1.875rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div:has(> .single-line-editor) {
|
||||
height: 1.875rem;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
div:has(> .single-line-editor):focus-within {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
.single-line-editor {
|
||||
height: 1.475rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
|
||||
.CodeMirror {
|
||||
height: 1.475rem;
|
||||
line-height: 1.475rem;
|
||||
}
|
||||
|
||||
.CodeMirror-cursor {
|
||||
height: 0.875rem !important;
|
||||
margin-top: 0.3rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.bg};
|
||||
flex-shrink: 0;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.input.focusBorder};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
border: 1px solid ${(props) => props.theme.primary.solid};
|
||||
background-image: radial-gradient(circle, ${(props) => props.theme.primary.solid} 40%, ${(props) => props.theme.bg} 42%);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { uuid } from 'utils/common';
|
||||
import Modal from 'components/Modal';
|
||||
@@ -14,6 +14,7 @@ import Button from 'ui/Button';
|
||||
|
||||
const RunCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [delay, setDelay] = useState('');
|
||||
|
||||
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
|
||||
const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended');
|
||||
@@ -21,9 +22,6 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
// tags for the collection run
|
||||
const tags = get(collection, 'runnerTags', { include: [], exclude: [] });
|
||||
|
||||
// have tags been enabled for the collection run
|
||||
const tagsEnabled = get(collection, 'runnerTagsEnabled', false);
|
||||
|
||||
const onSubmit = (recursive) => {
|
||||
dispatch(
|
||||
addTab({
|
||||
@@ -33,7 +31,7 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
})
|
||||
);
|
||||
if (!isCollectionRunInProgress) {
|
||||
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive, 0, tagsEnabled && tags));
|
||||
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive, delay ? Number(delay) : null, tags));
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
@@ -68,15 +66,34 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
<span className="font-medium">Run</span>
|
||||
<span className="ml-1 text-xs">({totalRequestItemsCountForFolderRun} requests)</span>
|
||||
</div>
|
||||
<div className="mb-8">This will only run the requests in this folder.</div>
|
||||
<div className="mb-3 description">This will only run the requests in this folder.</div>
|
||||
<div className="mb-1">
|
||||
<span className="font-medium">Recursive Run</span>
|
||||
<span className="ml-1 text-xs">({totalRequestItemsCountForRecursiveFolderRun} requests)</span>
|
||||
</div>
|
||||
<div className={isFolderLoading ? 'mb-2' : 'mb-8'}>This will run all the requests in this folder and all its subfolders.</div>
|
||||
<div className={`description ${isFolderLoading ? 'mb-2' : 'mb-6'}`}>This will run all the requests in this folder and all its subfolders.</div>
|
||||
{isFolderLoading ? <div className="mb-8 warning">Requests in this folder are still loading.</div> : null}
|
||||
{isCollectionRunInProgress ? <div className="mb-6 warning">A Collection Run is already in progress.</div> : null}
|
||||
|
||||
<hr className="divider" />
|
||||
|
||||
{/* Timings */}
|
||||
<div className="flex flex-col items-start gap-2 mb-8">
|
||||
<label htmlFor="runner-delay" className="block text-sm">Delay between requests (ms)</label>
|
||||
<input
|
||||
id="runner-delay"
|
||||
type="number"
|
||||
className="textbox w-1/2"
|
||||
placeholder="e.g. 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" />
|
||||
|
||||
|
||||
@@ -66,14 +66,12 @@ const StyledWrapper = styled.div`
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.danger};
|
||||
color: white;
|
||||
color: ${(props) => props.theme.text};
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.danger};
|
||||
outline: 2px solid ${(props) => props.theme.text};
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSav
|
||||
<SingleLineEditor
|
||||
className="border border-gray-500/50 px-2"
|
||||
value={text}
|
||||
placeholder="e.g., smoke, regression etc"
|
||||
placeholder="e.g., smoke, regression"
|
||||
autocomplete={tagsHintList}
|
||||
showHintsOnClick={true}
|
||||
showHintsFor={[]}
|
||||
|
||||
@@ -3167,9 +3167,10 @@ export const collectionsSlice = createSlice({
|
||||
const collection = findCollectionByUid(state.collections, collectionUid);
|
||||
if (collection) {
|
||||
collection.runnerConfiguration = {
|
||||
...collection.runnerConfiguration,
|
||||
selectedRequestItems: selectedRequestItems || [],
|
||||
requestItemsOrder: requestItemsOrder || [],
|
||||
delay: delay
|
||||
...(delay !== undefined && { delay })
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@ test.describe.parallel('Collection Run', () => {
|
||||
// Wait for the runner tab to open
|
||||
// If there are existing results, reset first, otherwise wait for Run Collection button
|
||||
const resetButton = page.getByRole('button', { name: 'Reset' });
|
||||
const runCollectionButton = page.getByRole('button', { name: 'Run Collection' });
|
||||
const runCollectionButton = page.getByTestId('runner-run-button');
|
||||
|
||||
// Check if Reset button is visible (means there are existing results)
|
||||
const resetVisible = await resetButton.isVisible().catch(() => false);
|
||||
|
||||
156
tests/runner/runner-configuration.spec.ts
Normal file
156
tests/runner/runner-configuration.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { test, expect } from '../../playwright';
|
||||
import { openRunnerTab, buildRunnerLocators } from '../utils/page/index';
|
||||
|
||||
const COLLECTION_NAME = 'bruno-testbench';
|
||||
|
||||
/**
|
||||
* Waits for the config panel to finish loading and initializing requests.
|
||||
* On first load, all enabled requests are auto-selected, so we wait until selected === total.
|
||||
*/
|
||||
const waitForRequestsInitialized = async (locators) => {
|
||||
await expect(async () => {
|
||||
const text = await locators.configCounter().innerText();
|
||||
const match = text.match(/(\d+) of (\d+) selected/);
|
||||
expect(match).toBeTruthy();
|
||||
const selected = parseInt(match![1]);
|
||||
const total = parseInt(match![2]);
|
||||
expect(total).toBeGreaterThan(0);
|
||||
expect(selected).toBe(total);
|
||||
}).toPass({ timeout: 30000 });
|
||||
};
|
||||
|
||||
test.describe('Runner Configuration Panel', () => {
|
||||
test('should display config panel with all requests selected by default', async ({ pageWithUserData: page }) => {
|
||||
const locators = buildRunnerLocators(page);
|
||||
await openRunnerTab(page, COLLECTION_NAME);
|
||||
await waitForRequestsInitialized(locators);
|
||||
|
||||
await test.step('Config panel is visible with request items', async () => {
|
||||
await expect(locators.configPanel()).toBeVisible();
|
||||
const itemCount = await locators.requestItems().count();
|
||||
expect(itemCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await test.step('Counter shows all enabled requests selected', async () => {
|
||||
const counterText = await locators.configCounter().innerText();
|
||||
const match = counterText.match(/(\d+) of (\d+) selected/);
|
||||
expect(match).toBeTruthy();
|
||||
expect(match![1]).toBe(match![2]);
|
||||
});
|
||||
|
||||
await test.step('Select All button shows "Deselect All" when all selected', async () => {
|
||||
await expect(locators.selectAllButton()).toContainText('Deselect All');
|
||||
});
|
||||
});
|
||||
|
||||
test('should toggle select all / deselect all', async ({ pageWithUserData: page }) => {
|
||||
const locators = buildRunnerLocators(page);
|
||||
await openRunnerTab(page, COLLECTION_NAME);
|
||||
await waitForRequestsInitialized(locators);
|
||||
|
||||
await test.step('Click Deselect All', async () => {
|
||||
await locators.selectAllButton().click();
|
||||
await expect(locators.selectAllButton()).toContainText('Select All', { timeout: 10000 });
|
||||
await expect(async () => {
|
||||
const counterText = await locators.configCounter().innerText();
|
||||
expect(counterText).toMatch(/^0 of \d+ selected$/);
|
||||
}).toPass({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Click Select All', async () => {
|
||||
await locators.selectAllButton().click();
|
||||
await expect(locators.selectAllButton()).toContainText('Deselect All', { timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('should deselect individual request items', async ({ pageWithUserData: page }) => {
|
||||
const locators = buildRunnerLocators(page);
|
||||
await openRunnerTab(page, COLLECTION_NAME);
|
||||
await waitForRequestsInitialized(locators);
|
||||
|
||||
await test.step('Deselect first item and verify count decreases', async () => {
|
||||
const counterText = await locators.configCounter().innerText();
|
||||
const match = counterText.match(/(\d+) of (\d+) selected/);
|
||||
expect(match).toBeTruthy();
|
||||
const initialSelected = parseInt(match![1]);
|
||||
expect(initialSelected).toBeGreaterThan(1);
|
||||
|
||||
// Click the checkbox area of the first request item to deselect it
|
||||
const firstItem = locators.requestItems().first();
|
||||
await firstItem.locator('.checkbox-container').click();
|
||||
|
||||
// Verify count decreased by 1
|
||||
await expect(async () => {
|
||||
const newCounterText = await locators.configCounter().innerText();
|
||||
const newMatch = newCounterText.match(/(\d+) of (\d+) selected/);
|
||||
expect(parseInt(newMatch![1])).toBe(initialSelected - 1);
|
||||
}).toPass({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Re-select item to restore state', async () => {
|
||||
const firstItem = locators.requestItems().first();
|
||||
await firstItem.locator('.checkbox-container').click();
|
||||
await waitForRequestsInitialized(locators);
|
||||
});
|
||||
});
|
||||
|
||||
test('should set delay value', async ({ pageWithUserData: page }) => {
|
||||
const locators = buildRunnerLocators(page);
|
||||
await openRunnerTab(page, COLLECTION_NAME);
|
||||
|
||||
await test.step('Enter delay value', async () => {
|
||||
const delayInput = locators.delayInput();
|
||||
await expect(delayInput).toBeVisible();
|
||||
await delayInput.fill('500');
|
||||
await expect(delayInput).toHaveValue('500');
|
||||
});
|
||||
});
|
||||
|
||||
test('should reset config panel to defaults', async ({ pageWithUserData: page }) => {
|
||||
const locators = buildRunnerLocators(page);
|
||||
await openRunnerTab(page, COLLECTION_NAME);
|
||||
await waitForRequestsInitialized(locators);
|
||||
|
||||
const counterText = await locators.configCounter().innerText();
|
||||
const initialMatch = counterText.match(/(\d+) of (\d+) selected/);
|
||||
const totalEnabled = parseInt(initialMatch![2]);
|
||||
|
||||
await test.step('Deselect all items first', async () => {
|
||||
await locators.selectAllButton().click();
|
||||
await expect(locators.selectAllButton()).toContainText('Select All', { timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Click config reset to restore defaults', async () => {
|
||||
await locators.configResetButton().click();
|
||||
|
||||
// After reset, all enabled items should be re-selected
|
||||
await expect(async () => {
|
||||
const text = await locators.configCounter().innerText();
|
||||
const match = text.match(/(\d+) of (\d+) selected/);
|
||||
expect(match).toBeTruthy();
|
||||
expect(parseInt(match![1])).toBe(totalEnabled);
|
||||
}).toPass({ timeout: 5000 });
|
||||
await expect(locators.selectAllButton()).toContainText('Deselect All');
|
||||
});
|
||||
});
|
||||
|
||||
test('should disable run button when no requests selected', async ({ pageWithUserData: page }) => {
|
||||
const locators = buildRunnerLocators(page);
|
||||
await openRunnerTab(page, COLLECTION_NAME);
|
||||
await waitForRequestsInitialized(locators);
|
||||
|
||||
await test.step('Deselect all and check run button is disabled', async () => {
|
||||
await locators.selectAllButton().click();
|
||||
await expect(locators.selectAllButton()).toContainText('Select All', { timeout: 10000 });
|
||||
const runButton = page.locator('button[type="submit"]');
|
||||
await expect(runButton).toBeDisabled({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Select all and check run button is enabled', async () => {
|
||||
await locators.selectAllButton().click();
|
||||
await expect(locators.selectAllButton()).toContainText('Deselect All', { timeout: 10000 });
|
||||
const runButton = page.locator('button[type="submit"]');
|
||||
await expect(runButton).toBeEnabled({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,14 @@ export const buildRunnerLocators = (page: Page) => ({
|
||||
failedButton: () => page.locator('button').filter({ hasText: /^Failed/ }),
|
||||
skippedButton: () => page.locator('button').filter({ hasText: /^Skipped/ }),
|
||||
resetButton: () => page.getByRole('button', { name: 'Reset' }),
|
||||
runCollectionButton: () => page.getByRole('button', { name: 'Run Collection' }),
|
||||
runAgainButton: () => page.getByRole('button', { name: 'Run Again' })
|
||||
runCollectionButton: () => page.getByTestId('runner-run-button'),
|
||||
runAgainButton: () => page.getByRole('button', { name: 'Run Again' }),
|
||||
configPanel: () => page.getByTestId('runner-config-panel'),
|
||||
configCounter: () => page.getByTestId('runner-config-counter'),
|
||||
selectAllButton: () => page.getByTestId('runner-select-all'),
|
||||
configResetButton: () => page.getByTestId('runner-config-reset'),
|
||||
requestItems: () => page.getByTestId('runner-request-item'),
|
||||
delayInput: () => page.getByTestId('runner-delay-input')
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -32,6 +38,35 @@ export const getRunnerResultCounts = async (page: Page) => {
|
||||
return { totalRequests, passed, failed, skipped };
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the runner tab for a collection without starting a run
|
||||
* @param page - The Playwright page object
|
||||
* @param collectionName - The name of the collection to open the runner for
|
||||
* @returns void
|
||||
*/
|
||||
export const openRunnerTab = async (page: Page, collectionName: string) => {
|
||||
await test.step(`Open runner tab for "${collectionName}"`, async () => {
|
||||
const collectionContainer = page.getByTestId('collections').locator('.collection-name').filter({ hasText: collectionName });
|
||||
await collectionContainer.waitFor({ state: 'visible' });
|
||||
|
||||
const actionsContainer = collectionContainer.locator('.collection-actions');
|
||||
await collectionContainer.hover();
|
||||
await actionsContainer.waitFor({ state: 'visible' });
|
||||
|
||||
const icon = actionsContainer.locator('.icon');
|
||||
await icon.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await icon.click();
|
||||
|
||||
const runMenuItem = page.getByText('Run', { exact: true });
|
||||
await runMenuItem.waitFor({ state: 'visible' });
|
||||
await runMenuItem.click();
|
||||
|
||||
// Wait for the config panel to load
|
||||
const locators = buildRunnerLocators(page);
|
||||
await locators.configPanel().waitFor({ state: 'visible', timeout: 10000 });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs a collection by clicking the Run menu item and handling the runner tab
|
||||
* Includes logic to reset existing results if present
|
||||
|
||||
Reference in New Issue
Block a user