mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-09 06:55:03 +00:00
feat: variable descriptions (#8269)
* feat: add description column to various tables and enhance UI interactions - Introduced a description column in Headers, VarsTable, and other components to allow multi-line descriptions. - Added toggle buttons to show/hide the description column in relevant tables. - Updated EditableTable component to support dynamic row addition with customizable labels. - Enhanced UI for better user experience with consistent button styles and spacing adjustments. * chore: resize fix handles * fix: restrict column check for last index * refactor: remove unwanted style and fix bottom padding of the last child in the table row * tests: improve testability and fix impacted locators * tests: fix locators for value fields * fix: improve newline handling in description prefix * fix: ts changes * chore: remove redundant code and fix description roundtrip * chore: remove redundant check * tests(bru-lang): tests for description and annotation * test: improve locators and tests * tests: descriptions * chore: re-add description setup * fix: re-add description cols * chore: fix the double click reset * fix: account for hidden sidebar in request pane width calculation * refactor: ui/ux fixes for data type toggle * chore: inline common deps into bruno-lang and bruno-schema * chore: tests * chore: fix ux for descriptions * fix: layout for environment vars table * fix: ensure correct row selection for multipart file upload in tests * feat: enhance UID assignment for request headers and variables in collections * chore: simplify * chore: update pkg * chore: abstract the e2e utils * chore: fix exports * tests: fix locators for datatype vars * fix: ux issue with secrets in environment table * tests: update locators for collection headers and vars descriptions * tests: update locators to use data attributes for datatype selectors * chore: update default css width for actions column * chore: remove tooltip for string type warnings * fix: reduce name widths * refactor: update annotation serialization to use single-quote delimiters for descriptions * refactor(tests): simplify description formatting in jsonToEnv tests * feat: add multiline description escaping and unescaping functions with tests * refactor: clean up imports and improve multiline text block handling * refactor: update locators to use new test IDs for environment variable editors * refactor: streamline header input handling and improve environment variable row access * refactor: update e2e-test job to support self-hosted runners * refactor: update E2E test runner configuration to include e2e and macOS environments --------- Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import { createDescriptionColumn } from 'components/EditableTable/descriptionColumn';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
@@ -65,13 +66,20 @@ const Headers = ({ collection }) => {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const descriptionColumn = createDescriptionColumn({
|
||||
theme: storedTheme,
|
||||
onSave: handleSave,
|
||||
collection,
|
||||
nameFromRowIndex: true
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%',
|
||||
width: '20%',
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
@@ -99,7 +107,8 @@ const Headers = ({ collection }) => {
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
descriptionColumn
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
@@ -131,6 +140,7 @@ const Headers = ({ collection }) => {
|
||||
</div>
|
||||
<EditableTable
|
||||
tableId="collection-headers"
|
||||
testId="collection-headers"
|
||||
columns={columns}
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import DataTypeSelector from 'components/DataTypeSelector';
|
||||
import VarValueCell from 'components/VarValueCell';
|
||||
import { valueToString } from '@usebruno/common/utils';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import { createDescriptionColumn } from 'components/EditableTable/descriptionColumn';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
@@ -42,13 +44,20 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const descriptionColumn = createDescriptionColumn({
|
||||
theme: storedTheme,
|
||||
onSave,
|
||||
collection,
|
||||
nameFromRowIndex: true
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '40%'
|
||||
width: '25%'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
@@ -59,38 +68,43 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
render: ({ row, value, onChange, isLastEmptyRow, rowIndex }) => (
|
||||
<VarValueCell
|
||||
editor={(
|
||||
<MultiLineEditor
|
||||
value={valueToString(value)}
|
||||
name={`${rowIndex}.value`}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
collection={collection}
|
||||
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
</div>
|
||||
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
|
||||
{!isLastEmptyRow && varType === 'request' && (
|
||||
<DataTypeSelector
|
||||
variable={row}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
|
||||
handleVarsChange(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
renderTypeSelector={!isLastEmptyRow && varType === 'request'
|
||||
? ({ compact }) => (
|
||||
<DataTypeSelector
|
||||
compact={compact}
|
||||
variable={row}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
|
||||
handleVarsChange(updated);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
descriptionColumn
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
description: '',
|
||||
...(varType === 'response' ? { local: false } : {})
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ const StyledWrapper = styled.div`
|
||||
.caret-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import React from 'react';
|
||||
import { IconAlertCircle, IconCaretDown } from '@tabler/icons';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBraces,
|
||||
IconCaretDown,
|
||||
IconNumbers,
|
||||
IconLetterT,
|
||||
IconToggleRight
|
||||
} from '@tabler/icons';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { BRUNO_VARIABLE_DATATYPES, parseValueByDataType, validateDataTypeValue } from '@usebruno/common/utils';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const DataTypeSelector = ({ variable, onChange }) => {
|
||||
const TYPE_ICONS = {
|
||||
string: IconLetterT,
|
||||
number: IconNumbers,
|
||||
boolean: IconToggleRight,
|
||||
object: IconBraces
|
||||
};
|
||||
|
||||
const DataTypeSelector = ({ variable, onChange, compact = false }) => {
|
||||
const selectedType = variable.dataType || 'string';
|
||||
const coercedValue = parseValueByDataType(variable.value, selectedType);
|
||||
const typeError = validateDataTypeValue(coercedValue, selectedType);
|
||||
@@ -20,18 +34,29 @@ const DataTypeSelector = ({ variable, onChange }) => {
|
||||
onClick: () => handleTypeChange(type)
|
||||
}));
|
||||
|
||||
const TypeIcon = TYPE_ICONS[selectedType] || IconLetterT;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center relative">
|
||||
<div className="flex items-center relative shrink-0">
|
||||
<MenuDropdown
|
||||
items={items}
|
||||
selectedItemId={selectedType}
|
||||
placement="bottom-end"
|
||||
showTickMark={true}
|
||||
appendTo={() => document.body}
|
||||
data-testid="datatype-selector-trigger"
|
||||
>
|
||||
<div className="flex items-center cursor-pointer select-none">
|
||||
<span className="type-label">{selectedType}</span>
|
||||
<div
|
||||
className="flex items-center cursor-pointer select-none"
|
||||
data-selected-type={selectedType}
|
||||
aria-label={`Data type: ${selectedType}`}
|
||||
>
|
||||
{compact ? (
|
||||
<TypeIcon className="type-icon" size={12} strokeWidth={2} />
|
||||
) : (
|
||||
<span className="type-label">{selectedType}</span>
|
||||
)}
|
||||
<IconCaretDown className="caret-icon ml-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
|
||||
@@ -107,27 +107,53 @@ const StyledWrapper = styled.div`
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Handle CodeMirror editors overflow */
|
||||
.cm-editor {
|
||||
/* Single-line CodeMirror editors: clip overflow to one row */
|
||||
.single-line-editor .CodeMirror {
|
||||
max-width: 100%;
|
||||
height: 33px !important;
|
||||
max-height: 33px !important;
|
||||
|
||||
.cm-scroller {
|
||||
.CodeMirror-scroll {
|
||||
overflow: hidden !important;
|
||||
max-height: 33px;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
.CodeMirror-vscrollbar,
|
||||
.CodeMirror-hscrollbar,
|
||||
.CodeMirror-scrollbar-filler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.CodeMirror-lines {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
.CodeMirror-line {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(.multi-line-editor) {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
white-space: normal;
|
||||
text-overflow: clip;
|
||||
|
||||
> div:not(.drag-handle) {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:has(.multi-line-editor) {
|
||||
height: auto;
|
||||
max-height: calc(35px * 3);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
|
||||
export const createDescriptionColumn = ({
|
||||
theme,
|
||||
onSave,
|
||||
onRun,
|
||||
collection,
|
||||
item,
|
||||
nameFromRowIndex = false,
|
||||
onDescriptionChange
|
||||
}) => ({
|
||||
key: 'description',
|
||||
name: 'Description',
|
||||
placeholder: 'Description',
|
||||
width: '25%',
|
||||
render: ({ row, value, onChange, rowIndex }) => (
|
||||
<MultiLineEditor
|
||||
value={value || ''}
|
||||
theme={theme}
|
||||
onSave={onSave}
|
||||
onChange={
|
||||
onDescriptionChange
|
||||
? (newValue) => onDescriptionChange(newValue, { row, onChange })
|
||||
: onChange
|
||||
}
|
||||
{...(onRun ? { onRun } : {})}
|
||||
{...(collection ? { collection } : {})}
|
||||
{...(item ? { item } : {})}
|
||||
{...(nameFromRowIndex && rowIndex !== undefined ? { name: `${rowIndex}.description` } : {})}
|
||||
/>
|
||||
)
|
||||
});
|
||||
@@ -380,7 +380,11 @@ const EditableTable = ({
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
<td style={{ width: '60px' }}></td>
|
||||
<td
|
||||
id="delete-column-header"
|
||||
style={{ width: '57px' }}
|
||||
>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
), [showCheckbox, checkboxLabel, columns, getColumnWidth, resizing, tableHeight, handleResizeStart, showDelete]);
|
||||
@@ -426,7 +430,19 @@ const EditableTable = ({
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
<td>
|
||||
<td ref={(node) => {
|
||||
if (!node) return;
|
||||
const width = node.scrollWidth;
|
||||
const parent = node.parentElement.parentElement.parentElement.querySelector('#delete-column-header');
|
||||
if (parent) {
|
||||
const parentWidth = parseInt(parent.style.width);
|
||||
const nextWidth = Math.max(parentWidth, width);
|
||||
if (nextWidth !== parentWidth) {
|
||||
parent.style.width = nextWidth + 'px';
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isEmpty && (
|
||||
<button
|
||||
data-testid="column-delete"
|
||||
|
||||
@@ -32,11 +32,17 @@ const Wrapper = styled.div`
|
||||
width: 25px;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
&:nth-child(5) {
|
||||
width: 60px;
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
&:nth-child(6) {
|
||||
width: 5%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useTheme } from 'providers/Theme';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import SecretEyeButton from 'components/MultiLineEditor/SecretEyeButton';
|
||||
import DataTypeSelector from 'components/DataTypeSelector';
|
||||
import VarValueCell from 'components/VarValueCell';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -50,6 +52,95 @@ const TableRow = React.memo(
|
||||
}
|
||||
);
|
||||
|
||||
const columns = ['name', 'value', 'description'];
|
||||
|
||||
const EnvVarValueCell = ({
|
||||
variable,
|
||||
actualIndex,
|
||||
isLastRow,
|
||||
isLastEmptyRow,
|
||||
storedTheme,
|
||||
collection,
|
||||
formik,
|
||||
handleRowFocus,
|
||||
handleSave,
|
||||
renderExtraValueContent
|
||||
}) => {
|
||||
const editorRef = useRef(null);
|
||||
const [compact, setCompact] = useState(true);
|
||||
const [masked, setMasked] = useState(variable.secret);
|
||||
|
||||
useEffect(() => {
|
||||
setMasked(variable.secret);
|
||||
}, [variable.secret]);
|
||||
|
||||
return (
|
||||
<VarValueCell
|
||||
onCompactChange={setCompact}
|
||||
trailingContent={variable.secret && compact ? (
|
||||
<SecretEyeButton
|
||||
masked={masked}
|
||||
onToggle={() => editorRef.current?.toggleVisibleSecret()}
|
||||
/>
|
||||
) : null}
|
||||
editor={(
|
||||
<div
|
||||
className="relative flex flex-col"
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
>
|
||||
<MultiLineEditor
|
||||
ref={editorRef}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
name={`${actualIndex}.value`}
|
||||
value={valueToString(variable.value, 2)}
|
||||
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
hideSecretEye={variable.secret && compact}
|
||||
onMaskChange={setMasked}
|
||||
onChange={(newValue) => {
|
||||
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
|
||||
if (variable.ephemeral) {
|
||||
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
|
||||
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
|
||||
}
|
||||
if (isLastRow) {
|
||||
setTimeout(() => {
|
||||
formik.setFieldValue(formik.values.length, {
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
description: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}, false);
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
{renderExtraValueContent && renderExtraValueContent(variable)}
|
||||
</div>
|
||||
)}
|
||||
renderTypeSelector={!isLastEmptyRow
|
||||
? ({ compact: isCompact }) => (
|
||||
<DataTypeSelector
|
||||
compact={isCompact}
|
||||
variable={variable}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
Object.entries(fields).forEach(([key, val]) => {
|
||||
formik.setFieldValue(`${actualIndex}.${key}`, val, true);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const EnvironmentVariablesTable = ({
|
||||
environment,
|
||||
collection,
|
||||
@@ -98,7 +189,7 @@ const EnvironmentVariablesTable = ({
|
||||
|
||||
// Local state initialized from Redux (computed once on mount/environment change via key)
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
return storedColumnWidths || { name: '30%', value: 'auto' };
|
||||
return storedColumnWidths || { name: '20%', value: 'auto', description: '35%' };
|
||||
});
|
||||
|
||||
const [resizing, setResizing] = useState(null);
|
||||
@@ -122,7 +213,12 @@ const EnvironmentVariablesTable = ({
|
||||
|
||||
const startX = e.clientX;
|
||||
const startWidth = currentCell.offsetWidth;
|
||||
const nextColumnKey = 'value';
|
||||
|
||||
const colIndex = columns.indexOf(columnKey) + 1;
|
||||
if (colIndex >= columns.length - 1) return;
|
||||
|
||||
const matchedNextCol = columns[colIndex];
|
||||
const nextColumnKey = matchedNextCol ?? columns.at(-1);
|
||||
const nextColumnStartWidth = nextCell.offsetWidth;
|
||||
|
||||
setResizing(columnKey);
|
||||
@@ -164,7 +260,6 @@ const EnvironmentVariablesTable = ({
|
||||
}, [searchQuery]);
|
||||
|
||||
const prevEnvUidRef = useRef(null);
|
||||
const prevEnvVariablesRef = useRef(environment.variables);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
@@ -190,14 +285,15 @@ const EnvironmentVariablesTable = ({
|
||||
const initialValues = useMemo(() => {
|
||||
const vars = environment.variables || [];
|
||||
const next = [
|
||||
...vars,
|
||||
...vars.map((v) => ({ ...v, description: v.description ?? '' })),
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
description: ''
|
||||
}
|
||||
];
|
||||
const prev = initialValuesRef.current;
|
||||
@@ -230,6 +326,7 @@ const EnvironmentVariablesTable = ({
|
||||
type: Yup.string(),
|
||||
uid: Yup.string(),
|
||||
value: Yup.mixed().nullable(),
|
||||
description: Yup.string().nullable(),
|
||||
dataType: Yup.string().oneOf(BRUNO_VARIABLE_DATATYPES).nullable(),
|
||||
annotations: Yup.array().nullable()
|
||||
})
|
||||
@@ -258,30 +355,29 @@ const EnvironmentVariablesTable = ({
|
||||
onSubmit: () => {}
|
||||
});
|
||||
|
||||
// Restore draft values on mount or environment switch
|
||||
// Restore draft values on mount or environment switch (not on external filesystem reloads)
|
||||
useEffect(() => {
|
||||
const isMount = !mountedRef.current;
|
||||
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
|
||||
const variablesReloaded = !isMount && !envChanged && prevEnvVariablesRef.current !== environment.variables;
|
||||
|
||||
prevEnvUidRef.current = environment.uid;
|
||||
prevEnvVariablesRef.current = environment.variables;
|
||||
mountedRef.current = true;
|
||||
|
||||
if ((isMount || envChanged || variablesReloaded) && hasDraftForThisEnv && draft?.variables) {
|
||||
if ((isMount || envChanged) && hasDraftForThisEnv && draft?.variables) {
|
||||
formik.setValues([
|
||||
...draft.variables,
|
||||
...draft.variables.map((v) => ({ ...v, description: v.description ?? '' })),
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: isSecretTab,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
description: ''
|
||||
}
|
||||
]);
|
||||
}
|
||||
}, [environment.uid, environment.variables, hasDraftForThisEnv, draft?.variables]);
|
||||
}, [environment.uid, hasDraftForThisEnv, draft?.variables]);
|
||||
|
||||
const savedValuesJson = useMemo(() => {
|
||||
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
|
||||
@@ -388,7 +484,8 @@ const EnvironmentVariablesTable = ({
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: isSecretTab,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
description: ''
|
||||
}
|
||||
];
|
||||
|
||||
@@ -412,7 +509,8 @@ const EnvironmentVariablesTable = ({
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: isSecretTab,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
description: ''
|
||||
};
|
||||
setTimeout(() => {
|
||||
formik.setFieldValue(formik.values.length, newVariable, false);
|
||||
@@ -651,14 +749,19 @@ const EnvironmentVariablesTable = ({
|
||||
? String(variable.value)
|
||||
: '';
|
||||
const valueMatch = valueText.toLowerCase().includes(query);
|
||||
return !!(nameMatch || valueMatch);
|
||||
const descriptionMatch
|
||||
= variable.description && typeof variable.description === 'string'
|
||||
? variable.description.toLowerCase().includes(query)
|
||||
: false;
|
||||
|
||||
return !!(nameMatch || valueMatch || descriptionMatch);
|
||||
});
|
||||
}, [formik.values, searchQuery, pinnedData, isSecretTab]);
|
||||
|
||||
const isSearchActive = !!searchQuery?.trim();
|
||||
|
||||
return (
|
||||
<StyledWrapper className={resizing ? 'is-resizing' : ''}>
|
||||
<StyledWrapper className={`${resizing ? 'is-resizing' : ''} has-description-column`.trim()}>
|
||||
{isSearchActive && filteredVariables.length === 0 ? (
|
||||
<div className="no-results">No results found for “{searchQuery.trim()}”</div>
|
||||
) : (
|
||||
@@ -682,8 +785,16 @@ const EnvironmentVariablesTable = ({
|
||||
onMouseDown={(e) => handleResizeStart(e, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ width: columnWidths.value }}>Value</td>
|
||||
<td></td>
|
||||
<td style={{ width: columnWidths.value }}>
|
||||
Value
|
||||
<div
|
||||
className={`resize-handle ${resizing === 'value' ? 'resizing' : ''}`}
|
||||
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
|
||||
onMouseDown={(e) => handleResizeStart(e, 'value')}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ width: columnWidths.description }}>Description</td>
|
||||
<td className="actions-column"></td>
|
||||
</tr>
|
||||
)}
|
||||
defaultItemHeight={35}
|
||||
@@ -731,55 +842,44 @@ const EnvironmentVariablesTable = ({
|
||||
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="flex flex-row flex-nowrap items-center gap-2"
|
||||
style={{ width: columnWidths.value }}
|
||||
>
|
||||
<div
|
||||
className="flex-1 min-w-0 relative"
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
>
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${actualIndex}.value`}
|
||||
value={valueToString(variable.value, 2)}
|
||||
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
onChange={(newValue) => {
|
||||
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
|
||||
// Append a new empty row when editing value on the last row
|
||||
if (isLastRow) {
|
||||
setTimeout(() => {
|
||||
formik.setFieldValue(formik.values.length, {
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: isSecretTab,
|
||||
enabled: true
|
||||
}, false);
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
{!isLastEmptyRow && (
|
||||
<span>
|
||||
<DataTypeSelector
|
||||
variable={variable}
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
onChange={(fields) => {
|
||||
Object.entries(fields).forEach(([key, val]) => {
|
||||
formik.setFieldValue(`${actualIndex}.${key}`, val, true);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{renderExtraValueContent && renderExtraValueContent(variable)}
|
||||
<td style={{ width: columnWidths.value }}>
|
||||
<EnvVarValueCell
|
||||
variable={variable}
|
||||
actualIndex={actualIndex}
|
||||
isLastRow={isLastRow}
|
||||
isLastEmptyRow={isLastEmptyRow}
|
||||
storedTheme={storedTheme}
|
||||
collection={_collection}
|
||||
formik={formik}
|
||||
handleRowFocus={handleRowFocus}
|
||||
handleSave={handleSave}
|
||||
renderExtraValueContent={renderExtraValueContent}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ width: columnWidths.description }}>
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${actualIndex}.description`}
|
||||
value={variable.description ?? ''}
|
||||
placeholder={!variable.description || (typeof variable.description === 'string' && variable.description.trim() === '') ? 'Description' : ''}
|
||||
onChange={(newValue) => {
|
||||
formik.setFieldValue(`${actualIndex}.description`, newValue, true);
|
||||
if (isLastRow) {
|
||||
setTimeout(() => {
|
||||
formik.setFieldValue(formik.values.length, {
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: isSecretTab,
|
||||
enabled: true
|
||||
}, false);
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import { createDescriptionColumn } from 'components/EditableTable/descriptionColumn';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
@@ -69,13 +70,20 @@ const Headers = ({ collection, folder }) => {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const descriptionColumn = createDescriptionColumn({
|
||||
theme: storedTheme,
|
||||
onSave: handleSave,
|
||||
collection,
|
||||
item: folder
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%',
|
||||
width: '20%',
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
@@ -104,7 +112,8 @@ const Headers = ({ collection, folder }) => {
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
descriptionColumn
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
|
||||
@@ -6,8 +6,10 @@ import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import DataTypeSelector from 'components/DataTypeSelector';
|
||||
import VarValueCell from 'components/VarValueCell';
|
||||
import { valueToString } from '@usebruno/common/utils';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import { createDescriptionColumn } from 'components/EditableTable/descriptionColumn';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
@@ -47,13 +49,21 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const descriptionColumn = createDescriptionColumn({
|
||||
theme: storedTheme,
|
||||
onSave,
|
||||
collection,
|
||||
item: folder,
|
||||
nameFromRowIndex: true
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '40%'
|
||||
width: '25%'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
@@ -64,11 +74,12 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
render: ({ row, value, onChange, isLastEmptyRow, rowIndex }) => (
|
||||
<VarValueCell
|
||||
editor={(
|
||||
<MultiLineEditor
|
||||
value={valueToString(value)}
|
||||
name={`${rowIndex}.value`}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
@@ -76,27 +87,31 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
|
||||
item={folder}
|
||||
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
</div>
|
||||
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
|
||||
{!isLastEmptyRow && varType === 'request' && (
|
||||
<DataTypeSelector
|
||||
variable={row}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
|
||||
handleVarsChange(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
renderTypeSelector={!isLastEmptyRow && varType === 'request'
|
||||
? ({ compact }) => (
|
||||
<DataTypeSelector
|
||||
compact={compact}
|
||||
variable={row}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
|
||||
handleVarsChange(updated);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
descriptionColumn
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
description: '',
|
||||
...(varType === 'response' ? { local: false } : {})
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
|
||||
const SecretEyeButton = ({ masked, onToggle }) => (
|
||||
<button type="button" className="mx-2 shrink-0" onClick={onToggle}>
|
||||
{masked ? (
|
||||
<IconEyeOff size={18} strokeWidth={2} />
|
||||
) : (
|
||||
<IconEye size={18} strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default SecretEyeButton;
|
||||
@@ -94,7 +94,9 @@ class MultiLineEditor extends Component {
|
||||
this.addOverlay(variables);
|
||||
|
||||
// Initialize masking if this is a secret field
|
||||
this.setState({ maskInput: this.props.isSecret });
|
||||
this.setState({ maskInput: this.props.isSecret }, () => {
|
||||
this.props.onMaskChange?.(this.state.maskInput);
|
||||
});
|
||||
this._enableMaskedEditor(this.props.isSecret);
|
||||
}
|
||||
|
||||
@@ -110,6 +112,7 @@ class MultiLineEditor extends Component {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(this.cachedValue);
|
||||
}
|
||||
requestAnimationFrame(() => this.editor?.refresh());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -167,12 +170,15 @@ class MultiLineEditor extends Component {
|
||||
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
|
||||
this.maskedEditor.update();
|
||||
}
|
||||
requestAnimationFrame(() => this.editor?.refresh());
|
||||
}
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
// If the secret flag has changed, update the editor to reflect the change
|
||||
this._enableMaskedEditor(this.props.isSecret);
|
||||
// also set the maskInput flag to the new value
|
||||
this.setState({ maskInput: this.props.isSecret });
|
||||
this.setState({ maskInput: this.props.isSecret }, () => {
|
||||
this.props.onMaskChange?.(this.state.maskInput);
|
||||
});
|
||||
}
|
||||
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
|
||||
this.editor.setOption('readOnly', this.props.readOnly || false);
|
||||
@@ -208,9 +214,11 @@ class MultiLineEditor extends Component {
|
||||
* @brief Toggle the visibility of the secret value
|
||||
*/
|
||||
toggleVisibleSecret = () => {
|
||||
const isVisible = !this.state.maskInput;
|
||||
this.setState({ maskInput: isVisible });
|
||||
this._enableMaskedEditor(isVisible);
|
||||
const maskInput = !this.state.maskInput;
|
||||
this.setState({ maskInput }, () => {
|
||||
this._enableMaskedEditor(maskInput);
|
||||
this.props.onMaskChange?.(maskInput);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -231,10 +239,11 @@ class MultiLineEditor extends Component {
|
||||
|
||||
render() {
|
||||
const wrapperClass = `multi-line-editor grow ${this.props.readOnly ? 'read-only' : ''}`;
|
||||
const testId = this.props.testId ?? (this.props.name ? `test-multiline-editor-${this.props.name}` : undefined);
|
||||
return (
|
||||
<div className={`flex flex-row justify-between w-full overflow-x-auto ${this.props.className}`}>
|
||||
<div data-testid={testId} className={`flex flex-row justify-between w-full overflow-x-auto ${this.props.className}`}>
|
||||
<StyledWrapper ref={this.editorRef} className={wrapperClass} />
|
||||
{this.secretEye(this.props.isSecret)}
|
||||
{!this.props.hideSecretEye && this.secretEye(this.props.isSecret)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import AssertionOperator from './AssertionOperator';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import { createDescriptionColumn } from 'components/EditableTable/descriptionColumn';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
@@ -91,13 +92,22 @@ const Assertions = ({ item, collection }) => {
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const descriptionColumn = createDescriptionColumn({
|
||||
theme: storedTheme,
|
||||
onSave,
|
||||
onRun: handleRun,
|
||||
collection,
|
||||
item,
|
||||
nameFromRowIndex: true
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Expr',
|
||||
isKeyField: true,
|
||||
placeholder: 'Expr',
|
||||
width: '30%'
|
||||
width: '20%'
|
||||
},
|
||||
{
|
||||
key: 'operator',
|
||||
@@ -161,13 +171,15 @@ const Assertions = ({ item, collection }) => {
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
descriptionColumn
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: 'eq ',
|
||||
operator: 'eq'
|
||||
operator: 'eq',
|
||||
description: ''
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import { createDescriptionColumn } from 'components/EditableTable/descriptionColumn';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
@@ -51,13 +52,21 @@ const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const descriptionColumn = createDescriptionColumn({
|
||||
theme: storedTheme,
|
||||
onSave,
|
||||
onRun: handleRun,
|
||||
collection,
|
||||
item
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Key',
|
||||
isKeyField: true,
|
||||
placeholder: 'Key',
|
||||
width: '30%'
|
||||
width: '20%'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
@@ -76,7 +85,8 @@ const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
descriptionColumn
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
@@ -89,6 +99,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<EditableTable
|
||||
tableId="form-url-encoded"
|
||||
testId="form-urlencoded-table"
|
||||
columns={columns}
|
||||
rows={params || []}
|
||||
onChange={handleParamsChange}
|
||||
|
||||
@@ -15,6 +15,7 @@ import MultipartFileChipsCell from 'components/MultipartFileChipsCell';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import { createDescriptionColumn } from 'components/EditableTable/descriptionColumn';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import path, { getRelativePathWithinBasePath, normalizePath } from 'utils/common/path';
|
||||
import { getMultipartAutoContentType } from 'utils/common/multipartContentType';
|
||||
@@ -167,7 +168,7 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
name: 'Key',
|
||||
isKeyField: true,
|
||||
placeholder: 'Key',
|
||||
width: '30%'
|
||||
width: '20%'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
@@ -229,20 +230,29 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
collection={collection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
createDescriptionColumn({
|
||||
theme: storedTheme,
|
||||
onSave,
|
||||
onRun: handleRun,
|
||||
collection,
|
||||
item
|
||||
})
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
contentType: '',
|
||||
type: 'text'
|
||||
type: 'text',
|
||||
description: ''
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<EditableTable
|
||||
tableId="multipart-form"
|
||||
testId="multipart-form-table"
|
||||
columns={columns}
|
||||
rows={params || []}
|
||||
onChange={handleParamsChange}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collection
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import { createDescriptionColumn } from 'components/EditableTable/descriptionColumn';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import BulkEditor from '../../BulkEditor';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
@@ -52,16 +53,21 @@ const QueryParams = ({ item, collection }) => {
|
||||
}));
|
||||
}, [dispatch, collection.uid, item.uid]);
|
||||
|
||||
const handlePathParamChange = useCallback((rowUid, key, value) => {
|
||||
const pathParam = pathParams.find((p) => p.uid === rowUid);
|
||||
if (pathParam) {
|
||||
dispatch(updatePathParam({
|
||||
pathParam: { ...pathParam, [key]: value },
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
}
|
||||
}, [dispatch, pathParams, item.uid, collection.uid]);
|
||||
const handlePathParamChange = useCallback(
|
||||
(rowUid, key, value) => {
|
||||
const pathParam = pathParams.find((p) => p.uid === rowUid);
|
||||
if (pathParam) {
|
||||
dispatch(
|
||||
updatePathParam({
|
||||
pathParam: { ...pathParam, [key]: value },
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, pathParams, item.uid, collection.uid]
|
||||
);
|
||||
|
||||
const handleQueryParamDrag = useCallback(({ updateReorderedItem }) => {
|
||||
dispatch(moveQueryParam({
|
||||
@@ -75,13 +81,30 @@ const QueryParams = ({ item, collection }) => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
|
||||
const descriptionColumnQuery = createDescriptionColumn({
|
||||
theme: storedTheme,
|
||||
onSave,
|
||||
onRun: handleRun,
|
||||
collection,
|
||||
item
|
||||
});
|
||||
|
||||
const descriptionColumnPath = createDescriptionColumn({
|
||||
theme: storedTheme,
|
||||
onSave,
|
||||
onRun: handleRun,
|
||||
collection,
|
||||
item,
|
||||
onDescriptionChange: (newValue, { row }) => handlePathParamChange(row.uid, 'description', newValue)
|
||||
});
|
||||
|
||||
const queryColumns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%'
|
||||
width: '20%'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
@@ -100,7 +123,8 @@ const QueryParams = ({ item, collection }) => {
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
descriptionColumnQuery
|
||||
];
|
||||
|
||||
const pathColumns = [
|
||||
@@ -108,7 +132,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
width: '30%',
|
||||
width: '20%',
|
||||
readOnly: true
|
||||
},
|
||||
{
|
||||
@@ -126,7 +150,8 @@ const QueryParams = ({ item, collection }) => {
|
||||
item={item}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
descriptionColumnPath
|
||||
];
|
||||
|
||||
const defaultQueryRow = {
|
||||
@@ -153,9 +178,12 @@ const QueryParams = ({ item, collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
|
||||
<div className="flex-1">
|
||||
<div className="mb-3 title text-xs">Query</div>
|
||||
<div className="mb-3 title text-xs">
|
||||
<span>Query</span>
|
||||
</div>
|
||||
<EditableTable
|
||||
tableId="query-params"
|
||||
testId="query-params-table"
|
||||
columns={queryColumns}
|
||||
rows={queryParams || []}
|
||||
onChange={handleQueryParamsChange}
|
||||
@@ -188,6 +216,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
{pathParams && pathParams.length > 0 ? (
|
||||
<EditableTable
|
||||
tableId="path-params"
|
||||
testId="path-params-table"
|
||||
columns={pathColumns}
|
||||
rows={pathParams}
|
||||
onChange={() => {}}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import { createDescriptionColumn } from 'components/EditableTable/descriptionColumn';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
@@ -75,13 +76,21 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
|
||||
const descriptionColumn = createDescriptionColumn({
|
||||
theme: storedTheme,
|
||||
onSave,
|
||||
onRun: handleRun,
|
||||
collection,
|
||||
item
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '30%',
|
||||
width: '20%',
|
||||
render: ({ value, onChange }) => (
|
||||
<SingleLineEditor
|
||||
value={value || ''}
|
||||
@@ -113,7 +122,8 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
placeholder={!value ? 'Value' : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
descriptionColumn
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
@@ -140,6 +150,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<EditableTable
|
||||
tableId="request-headers"
|
||||
testId="request-headers-table"
|
||||
columns={columns}
|
||||
rows={headers || []}
|
||||
onChange={handleHeadersChange}
|
||||
|
||||
@@ -7,8 +7,10 @@ import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import DataTypeSelector from 'components/DataTypeSelector';
|
||||
import VarValueCell from 'components/VarValueCell';
|
||||
import { valueToString } from '@usebruno/common/utils';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import { createDescriptionColumn } from 'components/EditableTable/descriptionColumn';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
@@ -57,13 +59,22 @@ const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const descriptionColumn = createDescriptionColumn({
|
||||
theme: storedTheme,
|
||||
onSave,
|
||||
onRun: handleRun,
|
||||
collection,
|
||||
item,
|
||||
nameFromRowIndex: true
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
isKeyField: true,
|
||||
placeholder: 'Name',
|
||||
width: '35%'
|
||||
width: '20%'
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
@@ -74,11 +85,12 @@ const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
|
||||
</div>
|
||||
),
|
||||
placeholder: varType === 'request' ? 'Value' : 'Expr',
|
||||
render: ({ row, value, onChange, isLastEmptyRow }) => (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
render: ({ row, value, onChange, isLastEmptyRow, rowIndex }) => (
|
||||
<VarValueCell
|
||||
editor={(
|
||||
<MultiLineEditor
|
||||
value={valueToString(value)}
|
||||
name={`${rowIndex}.value`}
|
||||
theme={storedTheme}
|
||||
onSave={onSave}
|
||||
onChange={onChange}
|
||||
@@ -87,27 +99,31 @@ const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
|
||||
item={item}
|
||||
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
|
||||
/>
|
||||
</div>
|
||||
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
|
||||
{!isLastEmptyRow && varType === 'request' && (
|
||||
<DataTypeSelector
|
||||
variable={row}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
|
||||
handleVarsChange(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
renderTypeSelector={!isLastEmptyRow && varType === 'request'
|
||||
? ({ compact }) => (
|
||||
<DataTypeSelector
|
||||
compact={compact}
|
||||
variable={row}
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
onChange={(fields) => {
|
||||
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
|
||||
handleVarsChange(updated);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
descriptionColumn
|
||||
];
|
||||
|
||||
const defaultRow = {
|
||||
name: '',
|
||||
value: '',
|
||||
description: '',
|
||||
...(varType === 'response' ? { local: false } : {})
|
||||
};
|
||||
|
||||
|
||||
@@ -239,6 +239,17 @@ const RequestTabPanel = () => {
|
||||
setDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragbarMouseDown = useCallback((e) => {
|
||||
if (e.detail > 1) {
|
||||
e.preventDefault();
|
||||
stopDragging();
|
||||
resetPaneBoundaries();
|
||||
return;
|
||||
}
|
||||
|
||||
startDragging(e);
|
||||
}, [resetPaneBoundaries, startDragging, stopDragging]);
|
||||
|
||||
const applyPointerResize = useCallback((e) => {
|
||||
if (!mainSectionRef.current) return;
|
||||
const mainRect = mainSectionRef.current.getBoundingClientRect();
|
||||
@@ -643,11 +654,7 @@ const RequestTabPanel = () => {
|
||||
{!requestPaneCollapsed && !responsePaneCollapsed && (
|
||||
<div
|
||||
className="dragbar-wrapper"
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetPaneBoundaries();
|
||||
}}
|
||||
onMouseDown={startDragging}
|
||||
onMouseDown={handleDragbarMouseDown}
|
||||
>
|
||||
<div className="dragbar-handle" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
width: 100%;
|
||||
|
||||
&.var-value-compact .trailing-area {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.var-value-compact .type-selector-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&.var-value-compact .trailing-area .type-selector-overlay {
|
||||
right: 100%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.var-value-compact > .type-selector-overlay {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.var-value-compact:hover .type-selector-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Always show if a type-error warning icon is present */
|
||||
&.var-value-compact .type-selector-overlay:has(.text-yellow-600) {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
61
packages/bruno-app/src/components/VarValueCell/index.js
Normal file
61
packages/bruno-app/src/components/VarValueCell/index.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const COMPACT_WIDTH_THRESHOLD = 150;
|
||||
|
||||
const VarValueCell = ({ editor, renderTypeSelector, trailingContent, onCompactChange }) => {
|
||||
const [compact, setCompact] = useState(true);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const observerRef = useRef(null);
|
||||
|
||||
const containerRef = useCallback((node) => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
if (node) {
|
||||
observerRef.current = new ResizeObserver(([entry]) => {
|
||||
const isCompact = entry.contentRect.width < COMPACT_WIDTH_THRESHOLD;
|
||||
setCompact(isCompact);
|
||||
onCompactChange?.(isCompact);
|
||||
});
|
||||
observerRef.current.observe(node);
|
||||
}
|
||||
}, [onCompactChange]);
|
||||
|
||||
const typeSelectorOverlay = renderTypeSelector && compact ? (
|
||||
<div
|
||||
className="type-selector-overlay"
|
||||
style={{ pointerEvents: hovered ? 'auto' : 'none' }}
|
||||
>
|
||||
{renderTypeSelector({ compact: true })}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<StyledWrapper
|
||||
ref={containerRef}
|
||||
className={`relative flex items-center w-full${compact ? ' var-value-compact' : ' gap-2'}`}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div style={{ flex: '1 1 0', minWidth: 0 }}>
|
||||
{editor}
|
||||
</div>
|
||||
{compact && trailingContent ? (
|
||||
<div className="trailing-area">
|
||||
{typeSelectorOverlay}
|
||||
{trailingContent}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{typeSelectorOverlay}
|
||||
{!compact && renderTypeSelector && renderTypeSelector({ compact: false })}
|
||||
</>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default VarValueCell;
|
||||
@@ -18,6 +18,7 @@ export function useTabPaneBoundaries(activeTabUid) {
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const screenWidth = useSelector((state) => state.app.screenWidth);
|
||||
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
|
||||
const isSidebarHidden = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const left = focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / DEFAULT_PANE_WIDTH_DIVISOR;
|
||||
const top = focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT;
|
||||
const requestPaneCollapsed = focusedTab?.requestPaneCollapsed || false;
|
||||
@@ -55,6 +56,7 @@ export function useTabPaneBoundaries(activeTabUid) {
|
||||
dispatch(expandResponsePane({ uid: activeTabUid }));
|
||||
},
|
||||
reset() {
|
||||
let usableAsideWidth = isSidebarHidden ? 0 : asideWidth;
|
||||
dispatch(expandRequestPane({ uid: activeTabUid }));
|
||||
dispatch(expandResponsePane({ uid: activeTabUid }));
|
||||
dispatch(updateRequestPaneTabHeight({
|
||||
@@ -63,7 +65,7 @@ export function useTabPaneBoundaries(activeTabUid) {
|
||||
}));
|
||||
dispatch(updateRequestPaneTabWidth({
|
||||
uid: activeTabUid,
|
||||
requestPaneWidth: (screenWidth - asideWidth) / DEFAULT_PANE_WIDTH_DIVISOR
|
||||
requestPaneWidth: (screenWidth - usableAsideWidth) / DEFAULT_PANE_WIDTH_DIVISOR
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -329,11 +329,11 @@ export const setResponseExampleHeaders = (state, action) => {
|
||||
const example = item.draft.examples.find((e) => e.uid === exampleUid);
|
||||
if (!example) return;
|
||||
|
||||
example.response.headers = map(headers, ({ uid, name = '', value = '', enabled = true }) => ({
|
||||
example.response.headers = map(headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
|
||||
uid: uid || uuid(),
|
||||
name: name,
|
||||
value: value,
|
||||
description: '',
|
||||
description,
|
||||
enabled: enabled
|
||||
}));
|
||||
};
|
||||
@@ -927,11 +927,11 @@ export const setResponseExampleRequestHeaders = (state, action) => {
|
||||
const example = item.draft.examples.find((e) => e.uid === exampleUid);
|
||||
if (!example) return;
|
||||
|
||||
example.request.headers = map(headers, ({ uid, name = '', value = '', enabled = true }) => ({
|
||||
example.request.headers = map(headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
|
||||
uid: uid || uuid(),
|
||||
name: name,
|
||||
value: value,
|
||||
description: '',
|
||||
description,
|
||||
enabled: enabled
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -1316,6 +1316,12 @@ export const collectionsSlice = createSlice({
|
||||
if (param) {
|
||||
param.name = action.payload.pathParam.name;
|
||||
param.value = action.payload.pathParam.value;
|
||||
if ('description' in action.payload.pathParam) {
|
||||
param.description = action.payload.pathParam.description;
|
||||
}
|
||||
if ('enabled' in action.payload.pathParam) {
|
||||
param.enabled = action.payload.pathParam.enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1649,13 +1655,14 @@ export const collectionsSlice = createSlice({
|
||||
if (!item.draft) {
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
item.draft.request.body.multipartForm = map(params, ({ uid, name = '', value = '', contentType = '', type = 'text', enabled = true }) => ({
|
||||
item.draft.request.body.multipartForm = map(params, ({ uid, name = '', value = '', contentType = '', type = 'text', enabled = true, description = '' }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
contentType,
|
||||
type,
|
||||
enabled
|
||||
enabled,
|
||||
description
|
||||
}));
|
||||
},
|
||||
moveMultipartFormParam: (state, action) => {
|
||||
@@ -2006,12 +2013,13 @@ export const collectionsSlice = createSlice({
|
||||
if (!item.draft) {
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
item.draft.request.assertions = map(assertions, ({ uid, name = '', value = '', operator = 'eq', enabled = true }) => ({
|
||||
item.draft.request.assertions = map(assertions, ({ uid, name = '', value = '', operator = 'eq', enabled = true, description = '' }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
operator,
|
||||
enabled
|
||||
enabled,
|
||||
description
|
||||
}));
|
||||
},
|
||||
moveAssertion: (state, action) => {
|
||||
@@ -2141,10 +2149,11 @@ export const collectionsSlice = createSlice({
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
item.draft.request.vars = item.draft.request.vars || {};
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false, dataType, annotations }) => ({
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', description = '', enabled = true, local = false, dataType, annotations }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
enabled,
|
||||
...(dataType && dataType !== 'string' ? { dataType } : {}),
|
||||
...(annotations?.length ? { annotations } : {}),
|
||||
@@ -2500,10 +2509,13 @@ export const collectionsSlice = createSlice({
|
||||
if (!folder.draft) {
|
||||
folder.draft = cloneDeep(folder.root);
|
||||
}
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false, dataType, annotations }) => ({
|
||||
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', description = '', enabled = true, local = false, dataType, annotations }) => ({
|
||||
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
enabled,
|
||||
...(dataType && dataType !== 'string' ? { dataType } : {}),
|
||||
...(annotations?.length ? { annotations } : {}),
|
||||
@@ -2743,10 +2755,11 @@ export const collectionsSlice = createSlice({
|
||||
root: cloneDeep(collection.root)
|
||||
};
|
||||
}
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false, dataType, annotations }) => ({
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', description = '', enabled = true, local = false, dataType, annotations }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
enabled,
|
||||
...(dataType && dataType !== 'string' ? { dataType } : {}),
|
||||
...(annotations?.length ? { annotations } : {}),
|
||||
|
||||
@@ -732,6 +732,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
|
||||
uid: _item.uid,
|
||||
type: _item.type,
|
||||
name: _item.name,
|
||||
description: _item.description,
|
||||
seq: _item.seq,
|
||||
settings: _item.settings,
|
||||
tags: _item.tags,
|
||||
|
||||
@@ -15,6 +15,10 @@ export const buildEnvVariable = ({ envVariable: obj, withUuid = false }) => {
|
||||
envVariable.dataType = obj.dataType;
|
||||
}
|
||||
|
||||
if (obj.description !== undefined && obj.description !== '') {
|
||||
envVariable.description = obj.description;
|
||||
}
|
||||
|
||||
if (!withUuid) {
|
||||
return envVariable;
|
||||
}
|
||||
@@ -104,8 +108,9 @@ export const getScriptModifiedKeys = (scriptVars, baseline, { skipKeys = [] } =
|
||||
* This is useful when comparing variables where UIDs may differ but the actual data is the same.
|
||||
*/
|
||||
export const stripEnvVarUid = (variable) => {
|
||||
const { name, value, type, enabled, secret, dataType } = variable;
|
||||
const { name, value, type, enabled, secret, description, dataType } = variable;
|
||||
const result = { name, value, type, enabled, secret };
|
||||
if (description !== undefined && description !== '') result.description = description;
|
||||
if (dataType && dataType !== 'string') {
|
||||
result.dataType = dataType;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,40 @@ describe('buildEnvVariable — dataType preservation for env export/import', ()
|
||||
expect(out.uid).toEqual(expect.any(String));
|
||||
expect(out).toMatchObject({ name: 'count', value: 42, dataType: 'number' });
|
||||
});
|
||||
|
||||
it('preserves non-empty descriptions on export', () => {
|
||||
expect(buildEnvVariable({
|
||||
envVariable: { name: 'host', value: 'http://localhost', secret: false, description: 'Single-line host desc' }
|
||||
})).toEqual({
|
||||
name: 'host',
|
||||
value: 'http://localhost',
|
||||
type: 'text',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
description: 'Single-line host desc'
|
||||
});
|
||||
|
||||
expect(buildEnvVariable({
|
||||
envVariable: {
|
||||
name: 'secretToken',
|
||||
value: 'shh',
|
||||
secret: true,
|
||||
description: 'Secret line one\nSecret line two'
|
||||
}
|
||||
})).toEqual({
|
||||
name: 'secretToken',
|
||||
value: '',
|
||||
type: 'text',
|
||||
enabled: true,
|
||||
secret: true,
|
||||
description: 'Secret line one\nSecret line two'
|
||||
});
|
||||
});
|
||||
|
||||
it('omits empty descriptions', () => {
|
||||
const out = buildEnvVariable({ envVariable: { name: 'plain', value: 'x', secret: false, description: '' } });
|
||||
expect(out.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripEnvVarUid — datatype-aware comparison key', () => {
|
||||
@@ -53,6 +87,25 @@ describe('stripEnvVarUid — datatype-aware comparison key', () => {
|
||||
expect(stripEnvVarUid({ uid: 'u', name: 'token', value: '', type: 'text', enabled: true, secret: true, dataType: 'number' }))
|
||||
.toEqual({ name: 'token', value: '', type: 'text', enabled: true, secret: true, dataType: 'number' });
|
||||
});
|
||||
|
||||
it('keeps non-empty descriptions', () => {
|
||||
expect(stripEnvVarUid({
|
||||
uid: 'u',
|
||||
name: 'host',
|
||||
value: 'http://localhost',
|
||||
type: 'text',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
description: 'Single-line host desc'
|
||||
})).toEqual({
|
||||
name: 'host',
|
||||
value: 'http://localhost',
|
||||
type: 'text',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
description: 'Single-line host desc'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyScriptEnvVars', () => {
|
||||
|
||||
@@ -171,14 +171,14 @@ export const exportApiSpec = ({ variables, items, name, environments }) => {
|
||||
...params?.filter((p) => p?.type !== 'path').map((param) => ({
|
||||
name: param?.name,
|
||||
in: 'query',
|
||||
description: '',
|
||||
description: param?.description || '',
|
||||
required: param?.enabled,
|
||||
example: param?.value
|
||||
})),
|
||||
...headers?.map((header) => ({
|
||||
name: header?.name,
|
||||
in: 'header',
|
||||
description: '',
|
||||
description: header?.description || '',
|
||||
required: header?.enabled,
|
||||
example: header?.value
|
||||
})),
|
||||
@@ -306,11 +306,15 @@ export const exportApiSpec = ({ variables, items, name, environments }) => {
|
||||
case 'multipartForm':
|
||||
if (!body?.multipartForm) break;
|
||||
const multipartFormComponentId = getItemComponentId();
|
||||
let multipartFormToKeyValue = body?.multipartForm.reduce((acc, f) => {
|
||||
acc[f?.name] = f.value;
|
||||
return acc;
|
||||
}, {});
|
||||
components.schemas[multipartFormComponentId] = generateProperyShape(multipartFormToKeyValue);
|
||||
{
|
||||
const multipartFormProps = {};
|
||||
body.multipartForm.forEach((f) => {
|
||||
const prop = generateProperyShape(f.value);
|
||||
if (f.description) prop.description = f.description;
|
||||
multipartFormProps[f.name] = prop;
|
||||
});
|
||||
components.schemas[multipartFormComponentId] = { type: 'object', properties: multipartFormProps };
|
||||
}
|
||||
components.requestBodies[multipartFormComponentId] = {
|
||||
content: {
|
||||
'multipart/form-data': {
|
||||
@@ -329,11 +333,15 @@ export const exportApiSpec = ({ variables, items, name, environments }) => {
|
||||
case 'formUrlEncoded':
|
||||
if (!body?.formUrlEncoded) break;
|
||||
const formUrlEncodedComponentId = getItemComponentId();
|
||||
let formUrlEncodedToKeyValue = body?.formUrlEncoded.reduce((acc, f) => {
|
||||
acc[f?.name] = f.value;
|
||||
return acc;
|
||||
}, {});
|
||||
components.schemas[formUrlEncodedComponentId] = generateProperyShape(formUrlEncodedToKeyValue);
|
||||
{
|
||||
const formUrlEncodedProps = {};
|
||||
body.formUrlEncoded.forEach((f) => {
|
||||
const prop = generateProperyShape(f.value);
|
||||
if (f.description) prop.description = f.description;
|
||||
formUrlEncodedProps[f.name] = prop;
|
||||
});
|
||||
components.schemas[formUrlEncodedComponentId] = { type: 'object', properties: formUrlEncodedProps };
|
||||
}
|
||||
components.requestBodies[formUrlEncodedComponentId] = {
|
||||
content: {
|
||||
'application/x-www-form-urlencoded': {
|
||||
|
||||
@@ -968,3 +968,37 @@ describe('exportApiSpec - non-HTTP request type filtering', () => {
|
||||
expect(spec.paths['/transient']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportApiSpec - descriptions', () => {
|
||||
it('includes header and query param descriptions in the OpenAPI output', () => {
|
||||
const items = [
|
||||
{
|
||||
name: 'Described Request',
|
||||
type: 'http-request',
|
||||
request: {
|
||||
url: 'https://example.com/users',
|
||||
method: 'GET',
|
||||
params: [
|
||||
{ name: 'q', value: 'search', enabled: true, type: 'query', description: 'Search query' }
|
||||
],
|
||||
headers: [
|
||||
{ name: 'X-Version', value: '2', enabled: true, description: 'API version header' }
|
||||
],
|
||||
body: {},
|
||||
auth: {}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const { content } = exportApiSpec({ variables: {}, items, name: 'Described API' });
|
||||
const spec = require('js-yaml').load(content);
|
||||
const operation = spec.paths['/users'].get;
|
||||
|
||||
expect(operation.parameters).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'q', in: 'query', description: 'Search query' }),
|
||||
expect.objectContaining({ name: 'X-Version', in: 'header', description: 'API version header' })
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,10 @@ export const updateUidsInCollection = (_collection) => {
|
||||
each(get(example, 'response.headers'), (header) => (header.uid = uuid()));
|
||||
});
|
||||
|
||||
each(get(item, 'root.request.headers'), (header) => (header.uid = header.uid || uuid()));
|
||||
each(get(item, 'root.request.vars.req'), (v) => (v.uid = v.uid || uuid()));
|
||||
each(get(item, 'root.request.vars.res'), (v) => (v.uid = v.uid || uuid()));
|
||||
|
||||
if (item.items && item.items.length) {
|
||||
updateItemUids(item.items);
|
||||
}
|
||||
@@ -65,6 +69,14 @@ export const updateUidsInCollection = (_collection) => {
|
||||
};
|
||||
updateItemUids(collection.items);
|
||||
|
||||
const updateRootUids = (root) => {
|
||||
if (!root) return;
|
||||
each(get(root, 'request.headers'), (header) => (header.uid = header.uid || uuid()));
|
||||
each(get(root, 'request.vars.req'), (v) => (v.uid = v.uid || uuid()));
|
||||
each(get(root, 'request.vars.res'), (v) => (v.uid = v.uid || uuid()));
|
||||
};
|
||||
updateRootUids(collection.root);
|
||||
|
||||
const updateEnvUids = (envs = []) => {
|
||||
each(envs, (env) => {
|
||||
env.uid = uuid();
|
||||
|
||||
@@ -117,6 +117,84 @@ describe('Bruno JSON export/import — dataType preservation', () => {
|
||||
const secretEnv = envVars.find((v) => v.name === 'token');
|
||||
expect(secretEnv).toMatchObject({ secret: true, value: '', dataType: 'number' });
|
||||
});
|
||||
|
||||
it('imports Bruno JSON with root descriptions when collection and folder root UIDs are absent', async () => {
|
||||
const imported = await processBrunoCollection({
|
||||
name: 'descriptions-imported-bru',
|
||||
version: '1',
|
||||
items: [
|
||||
{
|
||||
type: 'folder',
|
||||
name: 'folder',
|
||||
seq: 1,
|
||||
root: {
|
||||
meta: { name: 'folder' },
|
||||
request: {
|
||||
headers: [
|
||||
{ name: 'X-Folder-Version', value: '1.0', enabled: true, description: 'Folder header desc' }
|
||||
],
|
||||
vars: {
|
||||
req: [{ name: 'folderBaseUrl', value: 'https://folder.example.com', enabled: true, description: 'Folder var desc' }],
|
||||
res: []
|
||||
},
|
||||
script: {},
|
||||
tests: null
|
||||
}
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'http',
|
||||
name: 'request',
|
||||
seq: 1,
|
||||
request: {
|
||||
url: 'https://example.com/api',
|
||||
method: 'POST',
|
||||
headers: [{ name: 'X-Version', value: '2.0', enabled: true, description: 'Header desc' }],
|
||||
params: [],
|
||||
body: { mode: 'none', formUrlEncoded: [], multipartForm: [], file: [] },
|
||||
script: {},
|
||||
vars: { req: [], res: [] },
|
||||
assertions: [],
|
||||
tests: '',
|
||||
docs: '',
|
||||
auth: { mode: 'none' }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
environments: [
|
||||
{
|
||||
name: 'test_env',
|
||||
variables: [
|
||||
{ name: 'envHost', value: 'http://localhost:3000', type: 'text', enabled: true, secret: false, description: 'Env desc' }
|
||||
]
|
||||
}
|
||||
],
|
||||
root: {
|
||||
request: {
|
||||
headers: [
|
||||
{ name: 'X-Collection-Version', value: '2.0', enabled: true, description: 'Collection header desc' }
|
||||
],
|
||||
vars: {
|
||||
req: [{ name: 'baseUrl', value: 'https://example.com', enabled: true, description: 'Collection var desc' }],
|
||||
res: []
|
||||
},
|
||||
script: {},
|
||||
tests: null
|
||||
}
|
||||
},
|
||||
brunoConfig: { version: '1', name: 'descriptions-imported-bru', type: 'collection' }
|
||||
});
|
||||
|
||||
expect(imported.root.request.headers[0].description).toBe('Collection header desc');
|
||||
expect(imported.root.request.vars.req[0].description).toBe('Collection var desc');
|
||||
|
||||
const folder = imported.items.find((item) => item.type === 'folder');
|
||||
expect(folder.root.request.headers[0].description).toBe('Folder header desc');
|
||||
expect(folder.root.request.vars.req[0].description).toBe('Folder var desc');
|
||||
expect(imported.environments[0].variables[0].description).toBe('Env desc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSecretsInEnvs', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { transformCollectionToSaveToExportAsFile, transformRequestToSaveToFilesystem } from '../../collections/index';
|
||||
import { transformItemsInCollection } from '../../importers/common';
|
||||
import { deleteUidsInItems, transformItem } from '../../collections/export';
|
||||
import { deleteUidsInItems, transformItem, prepareCollectionForExport } from '../../collections/export';
|
||||
|
||||
describe('Examples Export/Import', () => {
|
||||
describe('transformCollectionToSaveToExportAsFile', () => {
|
||||
@@ -582,3 +582,184 @@ describe('Examples Export/Import', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Description export/import preservation', () => {
|
||||
const UID = 'aaaaaaaaaaaaaaaaaaaa1';
|
||||
|
||||
const buildDescriptionCollection = () => ({
|
||||
uid: UID,
|
||||
name: 'Description Collection',
|
||||
version: '1',
|
||||
items: [
|
||||
{
|
||||
uid: UID,
|
||||
type: 'folder',
|
||||
name: 'folder',
|
||||
seq: 1,
|
||||
root: {
|
||||
request: {
|
||||
headers: [
|
||||
{
|
||||
uid: UID,
|
||||
name: 'X-Folder-Header',
|
||||
value: 'folder',
|
||||
enabled: true,
|
||||
description: 'Folder header desc'
|
||||
}
|
||||
],
|
||||
script: { req: null, res: null },
|
||||
vars: {
|
||||
req: [
|
||||
{
|
||||
uid: UID,
|
||||
name: 'folderVar',
|
||||
value: 'folder-value',
|
||||
enabled: true,
|
||||
description: 'Folder var desc'
|
||||
}
|
||||
],
|
||||
res: []
|
||||
},
|
||||
tests: null
|
||||
}
|
||||
},
|
||||
items: [
|
||||
{
|
||||
uid: UID,
|
||||
type: 'http-request',
|
||||
name: 'request',
|
||||
seq: 1,
|
||||
request: {
|
||||
url: 'https://example.com/api',
|
||||
method: 'POST',
|
||||
headers: [
|
||||
{
|
||||
uid: UID,
|
||||
name: 'X-Version',
|
||||
value: '2.0',
|
||||
enabled: true,
|
||||
description: 'Single-line header desc'
|
||||
}
|
||||
],
|
||||
params: [
|
||||
{
|
||||
uid: UID,
|
||||
name: 'q',
|
||||
value: 'search',
|
||||
type: 'query',
|
||||
enabled: true,
|
||||
description: 'Single-line query desc'
|
||||
}
|
||||
],
|
||||
body: {
|
||||
mode: 'multipartForm',
|
||||
formUrlEncoded: [
|
||||
{
|
||||
uid: UID,
|
||||
name: 'username',
|
||||
value: 'alice',
|
||||
enabled: true,
|
||||
description: 'Single-line form desc'
|
||||
}
|
||||
],
|
||||
multipartForm: [
|
||||
{
|
||||
uid: UID,
|
||||
type: 'text',
|
||||
name: 'username',
|
||||
value: 'alice',
|
||||
enabled: true,
|
||||
description: 'Single-line field desc'
|
||||
}
|
||||
],
|
||||
file: []
|
||||
},
|
||||
auth: { mode: 'none' },
|
||||
script: { req: null, res: null },
|
||||
vars: {
|
||||
req: [
|
||||
{
|
||||
uid: UID,
|
||||
name: 'reqVar',
|
||||
value: 'req-value',
|
||||
enabled: true,
|
||||
description: 'Req var desc'
|
||||
}
|
||||
],
|
||||
res: []
|
||||
},
|
||||
assertions: [],
|
||||
tests: null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
root: {
|
||||
request: {
|
||||
headers: [
|
||||
{
|
||||
uid: UID,
|
||||
name: 'X-Collection-Header',
|
||||
value: 'collection',
|
||||
enabled: true,
|
||||
description: 'Collection header desc'
|
||||
}
|
||||
],
|
||||
script: { req: null, res: null },
|
||||
vars: {
|
||||
req: [
|
||||
{
|
||||
uid: UID,
|
||||
name: 'baseUrl',
|
||||
value: 'https://example.com',
|
||||
enabled: true,
|
||||
description: 'Collection var desc'
|
||||
}
|
||||
],
|
||||
res: []
|
||||
},
|
||||
tests: null
|
||||
}
|
||||
},
|
||||
environments: [
|
||||
{
|
||||
uid: UID,
|
||||
name: 'test_env',
|
||||
variables: [
|
||||
{
|
||||
uid: UID,
|
||||
name: 'envHost',
|
||||
value: 'http://localhost:3000',
|
||||
type: 'text',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
description: 'Single-line env desc'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
brunoConfig: { version: '1', name: 'Description Collection' }
|
||||
});
|
||||
|
||||
it('preserves descriptions on all surfaces through export preparation', () => {
|
||||
const collection = buildDescriptionCollection();
|
||||
const exported = prepareCollectionForExport(transformCollectionToSaveToExportAsFile(collection));
|
||||
|
||||
expect(exported.root.request.headers[0].description).toBe('Collection header desc');
|
||||
expect(exported.root.request.vars.req[0].description).toBe('Collection var desc');
|
||||
|
||||
const folder = exported.items.find((item) => item.type === 'folder');
|
||||
expect(folder.root.request.headers[0].description).toBe('Folder header desc');
|
||||
expect(folder.root.request.vars.req[0].description).toBe('Folder var desc');
|
||||
|
||||
const request = folder.items.find((item) => item.type === 'http' || item.type === 'http-request');
|
||||
expect(request.request.headers[0].description).toBe('Single-line header desc');
|
||||
expect(request.request.params[0].description).toBe('Single-line query desc');
|
||||
expect(request.request.body.multipartForm[0].description).toBe('Single-line field desc');
|
||||
expect(request.request.body.formUrlEncoded[0].description).toBe('Single-line form desc');
|
||||
expect(request.request.vars.req[0].description).toBe('Req var desc');
|
||||
|
||||
expect(exported.environments[0].variables[0].description).toBe('Single-line env desc');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,13 +90,17 @@ export const fromOpenCollectionBody = (body: HttpRequestBody | GraphQLBody | und
|
||||
return {
|
||||
...defaultBody,
|
||||
mode: 'formUrlEncoded',
|
||||
formUrlEncoded: (formBody.data || []).map((field): BrunoKeyValue => ({
|
||||
uid: uuid(),
|
||||
name: field.name || '',
|
||||
value: field.value || '',
|
||||
description: typeof field.description === 'string' ? field.description : field.description?.content || null,
|
||||
enabled: field.disabled !== true
|
||||
}))
|
||||
formUrlEncoded: (formBody.data || []).map((field): BrunoKeyValue => {
|
||||
const entry: BrunoKeyValue = {
|
||||
uid: uuid(),
|
||||
name: field.name || '',
|
||||
value: field.value || '',
|
||||
enabled: field.disabled !== true
|
||||
};
|
||||
const desc = typeof field.description === 'string' ? field.description : field.description?.content;
|
||||
if (desc && desc.trim().length) entry.description = desc;
|
||||
return entry;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,15 +109,19 @@ export const fromOpenCollectionBody = (body: HttpRequestBody | GraphQLBody | und
|
||||
return {
|
||||
...defaultBody,
|
||||
mode: 'multipartForm',
|
||||
multipartForm: (multipartBody.data || []).map((field): BrunoMultipartFormEntry => ({
|
||||
uid: uuid(),
|
||||
type: field.type || 'text',
|
||||
name: field.name || '',
|
||||
value: Array.isArray(field.value) ? field.value : (field.value || ''),
|
||||
description: typeof field.description === 'string' ? field.description : field.description?.content || null,
|
||||
contentType: null,
|
||||
enabled: field.disabled !== true
|
||||
}))
|
||||
multipartForm: (multipartBody.data || []).map((field): BrunoMultipartFormEntry => {
|
||||
const entry: BrunoMultipartFormEntry = {
|
||||
uid: uuid(),
|
||||
type: field.type || 'text',
|
||||
name: field.name || '',
|
||||
value: Array.isArray(field.value) ? field.value : (field.value || ''),
|
||||
contentType: null,
|
||||
enabled: field.disabled !== true
|
||||
};
|
||||
const desc = typeof field.description === 'string' ? field.description : field.description?.content;
|
||||
if (desc && desc.trim().length) entry.description = desc;
|
||||
return entry;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,17 @@ export const fromOpenCollectionHeaders = (headers: HttpRequestHeader[] | undefin
|
||||
return [];
|
||||
}
|
||||
|
||||
return headers.map((header): BrunoKeyValue => ({
|
||||
uid: uuid(),
|
||||
name: header.name || '',
|
||||
value: header.value || '',
|
||||
description: typeof header.description === 'string' ? header.description : header.description?.content || null,
|
||||
enabled: header.disabled !== true
|
||||
}));
|
||||
return headers.map((header): BrunoKeyValue => {
|
||||
const entry: BrunoKeyValue = {
|
||||
uid: uuid(),
|
||||
name: header.name || '',
|
||||
value: header.value || '',
|
||||
enabled: header.disabled !== true
|
||||
};
|
||||
const desc = typeof header.description === 'string' ? header.description : header.description?.content;
|
||||
if (desc && desc.trim().length) entry.description = desc;
|
||||
return entry;
|
||||
});
|
||||
};
|
||||
|
||||
export const toOpenCollectionHeaders = (headers: BrunoKeyValue[] | null | undefined): HttpRequestHeader[] | undefined => {
|
||||
|
||||
@@ -10,14 +10,18 @@ export const fromOpenCollectionParams = (params: HttpRequestParam[] | undefined)
|
||||
return [];
|
||||
}
|
||||
|
||||
return params.map((param): BrunoHttpRequestParam => ({
|
||||
uid: uuid(),
|
||||
name: param.name || '',
|
||||
value: param.value || '',
|
||||
description: typeof param.description === 'string' ? param.description : param.description?.content || null,
|
||||
type: (param.type || 'query') as BrunoHttpRequestParamType,
|
||||
enabled: param.disabled !== true
|
||||
}));
|
||||
return params.map((param): BrunoHttpRequestParam => {
|
||||
const entry: BrunoHttpRequestParam = {
|
||||
uid: uuid(),
|
||||
name: param.name || '',
|
||||
value: param.value || '',
|
||||
type: (param.type || 'query') as BrunoHttpRequestParamType,
|
||||
enabled: param.disabled !== true
|
||||
};
|
||||
const desc = typeof param.description === 'string' ? param.description : param.description?.content;
|
||||
if (desc && desc.trim().length) entry.description = desc;
|
||||
return entry;
|
||||
});
|
||||
};
|
||||
|
||||
export const toOpenCollectionParams = (params: BrunoHttpRequestParam[] | null | undefined): HttpRequestParam[] | undefined => {
|
||||
|
||||
@@ -56,6 +56,12 @@ export const fromOpenCollectionEnvironments = (environments: Environment[] | und
|
||||
result.value = variable.value;
|
||||
}
|
||||
|
||||
if (variable.description) {
|
||||
result.description = typeof variable.description === 'string'
|
||||
? variable.description
|
||||
: (variable.description as { content?: string })?.content || '';
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
color: env.color || null
|
||||
|
||||
@@ -281,6 +281,7 @@ export const brunoToPostman = (collection) => {
|
||||
return {
|
||||
key: item.name || '',
|
||||
value: item.value || '',
|
||||
description: item.description || '',
|
||||
disabled: !item.enabled,
|
||||
type: 'default'
|
||||
};
|
||||
@@ -304,7 +305,8 @@ export const brunoToPostman = (collection) => {
|
||||
key: bodyItem.name || '',
|
||||
value: bodyItem.value || '',
|
||||
disabled: !bodyItem.enabled,
|
||||
type: 'default'
|
||||
type: 'default',
|
||||
description: bodyItem.description || ''
|
||||
};
|
||||
})
|
||||
};
|
||||
@@ -327,6 +329,7 @@ export const brunoToPostman = (collection) => {
|
||||
key: bodyItem.name || '',
|
||||
disabled: !bodyItem.enabled,
|
||||
type: isFile ? 'file' : 'text',
|
||||
description: bodyItem.description || '',
|
||||
...(isFile ? { src: getSrc() } : { value: bodyItem.value || '' }),
|
||||
...(bodyItem.contentType && { contentType: bodyItem.contentType })
|
||||
};
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { brunoToOpenCollection, openCollectionToBruno } from '../../dist/esm/index.js';
|
||||
|
||||
describe('opencollection description round-trip', () => {
|
||||
it('preserves description on headers and vars when converting Bruno -> OpenCollection -> Bruno', () => {
|
||||
const brunoCollection = {
|
||||
uid: 'c1',
|
||||
name: 'Test',
|
||||
version: '1',
|
||||
items: [],
|
||||
root: {
|
||||
request: {
|
||||
headers: [
|
||||
{ uid: 'h1', name: 'X-API-Key', value: 'secret', enabled: true, description: 'API key for auth' }
|
||||
],
|
||||
vars: {
|
||||
req: [
|
||||
{ uid: 'v1', name: 'baseUrl', value: 'https://api.example.com', enabled: true, description: 'Base API URL' }
|
||||
],
|
||||
res: []
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openCollection = brunoToOpenCollection(brunoCollection);
|
||||
|
||||
expect(openCollection.request).toBeDefined();
|
||||
expect(openCollection.request.headers).toHaveLength(1);
|
||||
expect(openCollection.request.headers[0]).toMatchObject({
|
||||
name: 'X-API-Key',
|
||||
value: 'secret',
|
||||
description: 'API key for auth'
|
||||
});
|
||||
expect(openCollection.request.variables).toHaveLength(1);
|
||||
expect(openCollection.request.variables[0]).toMatchObject({
|
||||
name: 'baseUrl',
|
||||
value: 'https://api.example.com',
|
||||
description: 'Base API URL'
|
||||
});
|
||||
|
||||
const backToBruno = openCollectionToBruno(openCollection);
|
||||
|
||||
expect(backToBruno.root.request.headers).toHaveLength(1);
|
||||
expect(backToBruno.root.request.headers[0].description).toBe('API key for auth');
|
||||
expect(backToBruno.root.request.vars.req).toHaveLength(1);
|
||||
expect(backToBruno.root.request.vars.req[0].description).toBe('Base API URL');
|
||||
});
|
||||
|
||||
it('does not set description when input has no description', () => {
|
||||
const brunoCollection = {
|
||||
uid: 'c2',
|
||||
name: 'No Desc',
|
||||
version: '1',
|
||||
items: [],
|
||||
root: {
|
||||
request: {
|
||||
headers: [
|
||||
{ uid: 'h2', name: 'Content-Type', value: 'application/json', enabled: true }
|
||||
],
|
||||
vars: {
|
||||
req: [{ uid: 'v2', name: 'port', value: '3000', enabled: true }],
|
||||
res: []
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openCollection = brunoToOpenCollection(brunoCollection);
|
||||
expect(openCollection.request.headers[0]).not.toHaveProperty('description');
|
||||
expect(openCollection.request.variables[0]).not.toHaveProperty('description');
|
||||
|
||||
const backToBruno = openCollectionToBruno(openCollection);
|
||||
expect(backToBruno.root.request.headers[0]).not.toHaveProperty('description');
|
||||
expect(backToBruno.root.request.vars.req[0]).not.toHaveProperty('description');
|
||||
});
|
||||
|
||||
it('preserves description when converting OpenCollection -> Bruno -> OpenCollection', () => {
|
||||
const openCollection = {
|
||||
opencollection: '1.0.0',
|
||||
info: { name: 'OC Test' },
|
||||
request: {
|
||||
headers: [
|
||||
{ name: 'Authorization', value: 'Bearer x', description: 'Auth header' }
|
||||
],
|
||||
variables: [
|
||||
|
||||
{ name: 'token', value: 'abc', description: 'Request token' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const brunoCollection = openCollectionToBruno(openCollection);
|
||||
|
||||
expect(brunoCollection.root.request.headers).toHaveLength(1);
|
||||
expect(brunoCollection.root.request.headers[0].description).toBe('Auth header');
|
||||
expect(brunoCollection.root.request.vars.req).toHaveLength(1);
|
||||
expect(brunoCollection.root.request.vars.req[0].description).toBe('Request token');
|
||||
|
||||
const backToOC = brunoToOpenCollection(brunoCollection);
|
||||
|
||||
expect(backToOC.request.headers[0].description).toBe('Auth header');
|
||||
expect(backToOC.request.variables[0].description).toBe('Request token');
|
||||
});
|
||||
|
||||
describe('request params description', () => {
|
||||
it('Bruno→OC→Bruno: preserves description, omits when absent or whitespace-only', () => {
|
||||
const brunoCollection = {
|
||||
uid: 'c-p1',
|
||||
name: 'Test',
|
||||
version: '1',
|
||||
items: [
|
||||
{
|
||||
uid: 'i-p1',
|
||||
type: 'http-request',
|
||||
name: 'R',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: 'https://example.com',
|
||||
params: [
|
||||
{ uid: 'p1', name: 'q', value: 'search', type: 'query', enabled: true, description: 'Search term' },
|
||||
{ uid: 'p2', name: 'id', value: '42', type: 'path', enabled: true, description: 'Resource ID' },
|
||||
{ uid: 'p3', name: 'nod', value: 'v', type: 'query', enabled: true },
|
||||
{ uid: 'p4', name: 'ws', value: 'v', type: 'query', enabled: true, description: ' ' }
|
||||
],
|
||||
headers: []
|
||||
}
|
||||
}
|
||||
],
|
||||
root: {}
|
||||
};
|
||||
|
||||
const oc = brunoToOpenCollection(brunoCollection);
|
||||
const ocParams = oc.items[0].http.params;
|
||||
expect(ocParams).toHaveLength(4);
|
||||
expect(ocParams[0]).toMatchObject({ name: 'q', description: 'Search term' });
|
||||
expect(ocParams[1]).toMatchObject({ name: 'id', description: 'Resource ID' });
|
||||
expect(ocParams[2]).not.toHaveProperty('description');
|
||||
expect(ocParams[3]).not.toHaveProperty('description');
|
||||
|
||||
const back = openCollectionToBruno(oc);
|
||||
const backParams = back.items[0].request.params;
|
||||
expect(backParams[0]).toMatchObject({ name: 'q', description: 'Search term' });
|
||||
expect(backParams[1]).toMatchObject({ name: 'id', description: 'Resource ID' });
|
||||
expect(backParams[2]).not.toHaveProperty('description');
|
||||
expect(backParams[3]).not.toHaveProperty('description');
|
||||
});
|
||||
|
||||
it('OC→Bruno→OC: preserves description, omits when absent or whitespace-only', () => {
|
||||
const openCollection = {
|
||||
opencollection: '1.0.0',
|
||||
info: { name: 'Test' },
|
||||
items: [
|
||||
{
|
||||
info: { name: 'R', type: 'http' },
|
||||
http: {
|
||||
method: 'GET',
|
||||
url: 'https://example.com',
|
||||
params: [
|
||||
{ name: 'q', value: 'search', type: 'query', description: 'Search term' },
|
||||
{ name: 'id', value: '42', type: 'path', description: 'Resource ID' },
|
||||
{ name: 'nod', value: 'v', type: 'query' },
|
||||
{ name: 'ws', value: 'v', type: 'query', description: ' ' }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const bruno = openCollectionToBruno(openCollection);
|
||||
const brunoParams = bruno.items[0].request.params;
|
||||
expect(brunoParams[0]).toMatchObject({ name: 'q', description: 'Search term' });
|
||||
expect(brunoParams[1]).toMatchObject({ name: 'id', description: 'Resource ID' });
|
||||
expect(brunoParams[2]).not.toHaveProperty('description');
|
||||
expect(brunoParams[3]).not.toHaveProperty('description');
|
||||
|
||||
const backOC = brunoToOpenCollection(bruno);
|
||||
const backParams = backOC.items[0].http.params;
|
||||
expect(backParams[0]).toMatchObject({ name: 'q', description: 'Search term' });
|
||||
expect(backParams[1]).toMatchObject({ name: 'id', description: 'Resource ID' });
|
||||
expect(backParams[2]).not.toHaveProperty('description');
|
||||
expect(backParams[3]).not.toHaveProperty('description');
|
||||
});
|
||||
});
|
||||
|
||||
describe('form-urlencoded body description', () => {
|
||||
it('Bruno→OC→Bruno: preserves description, omits when absent or whitespace-only', () => {
|
||||
const brunoCollection = {
|
||||
uid: 'c-f1',
|
||||
name: 'Test',
|
||||
version: '1',
|
||||
items: [
|
||||
{
|
||||
uid: 'i-f1',
|
||||
type: 'http-request',
|
||||
name: 'R',
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: 'https://example.com',
|
||||
headers: [],
|
||||
body: {
|
||||
mode: 'formUrlEncoded',
|
||||
formUrlEncoded: [
|
||||
{ uid: 'f1', name: 'field', value: 'val', enabled: true, description: 'A form field' },
|
||||
{ uid: 'f2', name: 'nod', value: 'v', enabled: true },
|
||||
{ uid: 'f3', name: 'ws', value: 'v', enabled: true, description: ' ' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
root: {}
|
||||
};
|
||||
|
||||
const oc = brunoToOpenCollection(brunoCollection);
|
||||
const ocBody = oc.items[0].http.body;
|
||||
expect(ocBody.type).toBe('form-urlencoded');
|
||||
expect(ocBody.data).toHaveLength(3);
|
||||
expect(ocBody.data[0]).toMatchObject({ name: 'field', description: 'A form field' });
|
||||
expect(ocBody.data[1]).not.toHaveProperty('description');
|
||||
expect(ocBody.data[2]).not.toHaveProperty('description');
|
||||
|
||||
const back = openCollectionToBruno(oc);
|
||||
const backForm = back.items[0].request.body.formUrlEncoded;
|
||||
expect(backForm[0]).toMatchObject({ name: 'field', description: 'A form field' });
|
||||
expect(backForm[1]).not.toHaveProperty('description');
|
||||
expect(backForm[2]).not.toHaveProperty('description');
|
||||
});
|
||||
|
||||
it('OC→Bruno→OC: preserves description, omits when absent or whitespace-only', () => {
|
||||
const openCollection = {
|
||||
opencollection: '1.0.0',
|
||||
info: { name: 'Test' },
|
||||
items: [
|
||||
{
|
||||
info: { name: 'R', type: 'http' },
|
||||
http: {
|
||||
method: 'POST',
|
||||
url: 'https://example.com',
|
||||
body: {
|
||||
type: 'form-urlencoded',
|
||||
data: [
|
||||
{ name: 'field', value: 'val', description: 'A form field' },
|
||||
{ name: 'nod', value: 'v' },
|
||||
{ name: 'ws', value: 'v', description: ' ' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const bruno = openCollectionToBruno(openCollection);
|
||||
const brunoForm = bruno.items[0].request.body.formUrlEncoded;
|
||||
expect(brunoForm[0]).toMatchObject({ name: 'field', description: 'A form field' });
|
||||
expect(brunoForm[1]).not.toHaveProperty('description');
|
||||
expect(brunoForm[2]).not.toHaveProperty('description');
|
||||
|
||||
const backOC = brunoToOpenCollection(bruno);
|
||||
const backData = backOC.items[0].http.body.data;
|
||||
expect(backData[0]).toMatchObject({ name: 'field', description: 'A form field' });
|
||||
expect(backData[1]).not.toHaveProperty('description');
|
||||
expect(backData[2]).not.toHaveProperty('description');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multipart-form body description', () => {
|
||||
it('Bruno→OC→Bruno: preserves description, omits when absent or whitespace-only', () => {
|
||||
const brunoCollection = {
|
||||
uid: 'c-m1',
|
||||
name: 'Test',
|
||||
version: '1',
|
||||
items: [
|
||||
{
|
||||
uid: 'i-m1',
|
||||
type: 'http-request',
|
||||
name: 'R',
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: 'https://example.com',
|
||||
headers: [],
|
||||
body: {
|
||||
mode: 'multipartForm',
|
||||
multipartForm: [
|
||||
{ uid: 'm1', name: 'file', value: '', type: 'text', enabled: true, description: 'Upload field' },
|
||||
{ uid: 'm2', name: 'nod', value: '', type: 'text', enabled: true },
|
||||
{ uid: 'm3', name: 'ws', value: '', type: 'text', enabled: true, description: ' ' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
root: {}
|
||||
};
|
||||
|
||||
const oc = brunoToOpenCollection(brunoCollection);
|
||||
const ocBody = oc.items[0].http.body;
|
||||
expect(ocBody.type).toBe('multipart-form');
|
||||
expect(ocBody.data).toHaveLength(3);
|
||||
expect(ocBody.data[0]).toMatchObject({ name: 'file', description: 'Upload field' });
|
||||
expect(ocBody.data[1]).not.toHaveProperty('description');
|
||||
expect(ocBody.data[2]).not.toHaveProperty('description');
|
||||
|
||||
const back = openCollectionToBruno(oc);
|
||||
const backMultipart = back.items[0].request.body.multipartForm;
|
||||
expect(backMultipart[0]).toMatchObject({ name: 'file', description: 'Upload field' });
|
||||
expect(backMultipart[1]).not.toHaveProperty('description');
|
||||
expect(backMultipart[2]).not.toHaveProperty('description');
|
||||
});
|
||||
|
||||
it('OC→Bruno→OC: preserves description, omits when absent or whitespace-only', () => {
|
||||
const openCollection = {
|
||||
opencollection: '1.0.0',
|
||||
info: { name: 'Test' },
|
||||
items: [
|
||||
{
|
||||
info: { name: 'R', type: 'http' },
|
||||
http: {
|
||||
method: 'POST',
|
||||
url: 'https://example.com',
|
||||
body: {
|
||||
type: 'multipart-form',
|
||||
data: [
|
||||
{ name: 'file', type: 'text', value: '', description: 'Upload field' },
|
||||
{ name: 'nod', type: 'text', value: '' },
|
||||
{ name: 'ws', type: 'text', value: '', description: ' ' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const bruno = openCollectionToBruno(openCollection);
|
||||
const brunoMultipart = bruno.items[0].request.body.multipartForm;
|
||||
expect(brunoMultipart[0]).toMatchObject({ name: 'file', description: 'Upload field' });
|
||||
expect(brunoMultipart[1]).not.toHaveProperty('description');
|
||||
expect(brunoMultipart[2]).not.toHaveProperty('description');
|
||||
|
||||
const backOC = brunoToOpenCollection(bruno);
|
||||
const backData = backOC.items[0].http.body.data;
|
||||
expect(backData[0]).toMatchObject({ name: 'file', description: 'Upload field' });
|
||||
expect(backData[1]).not.toHaveProperty('description');
|
||||
expect(backData[2]).not.toHaveProperty('description');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -120,8 +120,8 @@ describe('brunoToPostman null checks and fallbacks', () => {
|
||||
|
||||
const result = brunoToPostman(simpleCollection);
|
||||
expect(result.item[0].request.header).toEqual([
|
||||
{ key: '', value: 'test-value', disabled: false, type: 'default' },
|
||||
{ key: 'Content-Type', value: '', disabled: false, type: 'default' }
|
||||
{ key: '', value: 'test-value', description: '', disabled: false, type: 'default' },
|
||||
{ key: 'Content-Type', value: '', description: '', disabled: false, type: 'default' }
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -241,8 +241,8 @@ describe('brunoToPostman null checks and fallbacks', () => {
|
||||
|
||||
const result = brunoToPostman(simpleCollection);
|
||||
expect(result.item[0].request.body.urlencoded).toEqual([
|
||||
{ key: '', value: 'test-value', disabled: false, type: 'default' },
|
||||
{ key: 'field', value: '', disabled: false, type: 'default' }
|
||||
{ key: '', value: 'test-value', disabled: false, type: 'default', description: '' },
|
||||
{ key: 'field', value: '', disabled: false, type: 'default', description: '' }
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -324,6 +324,38 @@ describe('brunoToPostman null checks and fallbacks', () => {
|
||||
expect(result.item[0].request.description).toBe('');
|
||||
});
|
||||
|
||||
it('should pass through header and form field descriptions', () => {
|
||||
const simpleCollection = {
|
||||
items: [
|
||||
{
|
||||
name: 'Test Request',
|
||||
type: 'http-request',
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: 'https://example.com',
|
||||
headers: [
|
||||
{ name: 'X-Custom', value: 'v', enabled: true, description: 'Header note' }
|
||||
],
|
||||
body: {
|
||||
mode: 'formUrlEncoded',
|
||||
formUrlEncoded: [
|
||||
{ name: 'field', value: 'val', enabled: true, description: 'Field note' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = brunoToPostman(simpleCollection);
|
||||
expect(result.item[0].request.header).toEqual([
|
||||
expect.objectContaining({ key: 'X-Custom', value: 'v', description: 'Header note' })
|
||||
]);
|
||||
expect(result.item[0].request.body.urlencoded).toEqual([
|
||||
expect.objectContaining({ key: 'field', value: 'val', description: 'Field note' })
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle null or undefined folder name', () => {
|
||||
const simpleCollection = {
|
||||
items: [
|
||||
@@ -524,6 +556,7 @@ describe('brunoToPostman multipartForm handling', () => {
|
||||
mode: 'formdata',
|
||||
formdata: [
|
||||
{
|
||||
description: '',
|
||||
key: 'myFile',
|
||||
src: ['/path/to/file1.txt', '/path/to/file2.txt'],
|
||||
disabled: false,
|
||||
@@ -563,6 +596,7 @@ describe('brunoToPostman multipartForm handling', () => {
|
||||
mode: 'formdata',
|
||||
formdata: [
|
||||
{
|
||||
description: '',
|
||||
key: 'myField',
|
||||
value: 'some text value',
|
||||
disabled: false,
|
||||
@@ -603,6 +637,7 @@ describe('brunoToPostman multipartForm handling', () => {
|
||||
mode: 'formdata',
|
||||
formdata: [
|
||||
{
|
||||
description: '',
|
||||
key: 'myFile',
|
||||
src: '/path/to/file.json',
|
||||
disabled: false,
|
||||
@@ -649,12 +684,14 @@ describe('brunoToPostman multipartForm handling', () => {
|
||||
mode: 'formdata',
|
||||
formdata: [
|
||||
{
|
||||
description: '',
|
||||
key: 'textField',
|
||||
value: 'hello',
|
||||
disabled: false,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
description: '',
|
||||
key: 'fileField',
|
||||
src: '/path/to/file.txt',
|
||||
disabled: true,
|
||||
@@ -691,6 +728,7 @@ describe('brunoToPostman multipartForm handling', () => {
|
||||
|
||||
const result = brunoToPostman(simpleCollection);
|
||||
expect(result.item[0].request.body.formdata[0]).toEqual({
|
||||
description: '',
|
||||
key: 'myFile',
|
||||
src: '/single/file/path.txt',
|
||||
disabled: false,
|
||||
@@ -725,6 +763,7 @@ describe('brunoToPostman multipartForm handling', () => {
|
||||
|
||||
const result = brunoToPostman(simpleCollection);
|
||||
expect(result.item[0].request.body.formdata[0]).toEqual({
|
||||
description: '',
|
||||
key: 'myFile',
|
||||
src: null,
|
||||
disabled: false,
|
||||
|
||||
@@ -51,6 +51,10 @@ export const toBrunoHttpHeaders = (headers: HttpRequestHeader[] | HttpResponseHe
|
||||
enabled: ('disabled' in header) ? header.disabled !== true : true
|
||||
};
|
||||
|
||||
if ('description' in header && header.description) {
|
||||
brunoHeader.description = typeof header.description === 'string' ? header.description : (header.description as any)?.content || '';
|
||||
}
|
||||
|
||||
return brunoHeader;
|
||||
});
|
||||
|
||||
|
||||
@@ -98,6 +98,14 @@ const parseGraphQLRequest = (ocRequest: GraphQLRequest): BrunoItem => {
|
||||
pathname: null
|
||||
};
|
||||
|
||||
// description
|
||||
if (info?.description) {
|
||||
const desc = typeof info.description === 'string' ? info.description : (info.description as any)?.content || '';
|
||||
if (desc.trim().length) {
|
||||
brunoItem.description = desc;
|
||||
}
|
||||
}
|
||||
|
||||
// settings
|
||||
if (ocRequest.settings) {
|
||||
const settings: BrunoHttpItemSettings = {};
|
||||
|
||||
@@ -21,6 +21,13 @@ const toBrunoGrpcMetadata = (metadata: GrpcMetadata[] | null | undefined): Bruno
|
||||
enabled: meta.disabled !== true
|
||||
};
|
||||
|
||||
if (meta.description) {
|
||||
const desc = typeof meta.description === 'string' ? meta.description : (meta.description as any)?.content || '';
|
||||
if (desc.trim().length) {
|
||||
brunoMeta.description = desc;
|
||||
}
|
||||
}
|
||||
|
||||
return brunoMeta;
|
||||
});
|
||||
|
||||
@@ -116,6 +123,14 @@ const parseGrpcRequest = (ocRequest: GrpcRequest): BrunoItem => {
|
||||
pathname: null
|
||||
};
|
||||
|
||||
// description
|
||||
if (info?.description) {
|
||||
const desc = typeof info.description === 'string' ? info.description : (info.description as any)?.content || '';
|
||||
if (desc.trim().length) {
|
||||
brunoItem.description = desc;
|
||||
}
|
||||
}
|
||||
|
||||
return brunoItem;
|
||||
};
|
||||
|
||||
|
||||
@@ -101,6 +101,14 @@ const parseHttpRequest = (ocRequest: HttpRequest): BrunoItem => {
|
||||
pathname: null
|
||||
};
|
||||
|
||||
// description
|
||||
if (info?.description) {
|
||||
const desc = typeof info.description === 'string' ? info.description : (info.description as any)?.content || '';
|
||||
if (desc.trim().length) {
|
||||
brunoItem.description = desc;
|
||||
}
|
||||
}
|
||||
|
||||
// settings
|
||||
if (ocRequest.settings) {
|
||||
const settings: BrunoHttpItemSettings = {};
|
||||
|
||||
@@ -113,6 +113,14 @@ const parseWebsocketRequest = (ocRequest: WebSocketRequest): BrunoItem => {
|
||||
pathname: null
|
||||
};
|
||||
|
||||
// description
|
||||
if (info?.description) {
|
||||
const desc = typeof info.description === 'string' ? info.description : (info.description as any)?.content || '';
|
||||
if (desc.trim().length) {
|
||||
brunoItem.description = desc;
|
||||
}
|
||||
}
|
||||
|
||||
return brunoItem;
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ const stringifyGraphQLRequest = (item: BrunoItem): string => {
|
||||
if (item.tags?.length) {
|
||||
info.tags = item.tags;
|
||||
}
|
||||
if (isNonEmptyString(item.description)) {
|
||||
info.description = item.description;
|
||||
}
|
||||
ocRequest.info = info;
|
||||
|
||||
// graphql block
|
||||
|
||||
@@ -29,6 +29,9 @@ const stringifyGrpcRequest = (item: BrunoItem): string => {
|
||||
if (item.tags?.length) {
|
||||
info.tags = item.tags;
|
||||
}
|
||||
if (isNonEmptyString(item.description)) {
|
||||
info.description = item.description;
|
||||
}
|
||||
ocRequest.info = info;
|
||||
|
||||
// grpc block
|
||||
|
||||
@@ -35,6 +35,9 @@ const stringifyHttpRequest = (item: BrunoItem): string => {
|
||||
if (item.tags?.length) {
|
||||
info.tags = item.tags;
|
||||
}
|
||||
if (isNonEmptyString(item.description)) {
|
||||
info.description = item.description;
|
||||
}
|
||||
ocRequest.info = info;
|
||||
|
||||
// http block
|
||||
|
||||
@@ -28,6 +28,9 @@ const stringifyWebsocketRequest = (item: BrunoItem): string => {
|
||||
if (item.tags?.length) {
|
||||
info.tags = item.tags;
|
||||
}
|
||||
if (isNonEmptyString(item.description)) {
|
||||
info.description = item.description;
|
||||
}
|
||||
ocRequest.info = info;
|
||||
|
||||
// websocket block
|
||||
|
||||
@@ -29,6 +29,10 @@ export const toBrunoEnvironmentVariables = (variables: (Variable | SecretVariabl
|
||||
variable.dataType = v.type;
|
||||
}
|
||||
|
||||
if (v.description) {
|
||||
variable.description = typeof v.description === 'string' ? v.description : (v.description as any)?.content || '';
|
||||
}
|
||||
|
||||
return variable;
|
||||
}
|
||||
|
||||
@@ -47,6 +51,10 @@ export const toBrunoEnvironmentVariables = (variables: (Variable | SecretVariabl
|
||||
variable.value = ensureString(v.value);
|
||||
}
|
||||
|
||||
if (v.description) {
|
||||
variable.description = typeof v.description === 'string' ? v.description : (v.description as any)?.content || '';
|
||||
}
|
||||
|
||||
return variable;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -22,6 +22,9 @@ export const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironment
|
||||
if (v.enabled === false) {
|
||||
secretVar.disabled = true;
|
||||
}
|
||||
if (v.description !== undefined) {
|
||||
secretVar.description = v.description;
|
||||
}
|
||||
return secretVar;
|
||||
}
|
||||
|
||||
@@ -36,6 +39,10 @@ export const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironment
|
||||
variable.disabled = true;
|
||||
}
|
||||
|
||||
if (v.description !== undefined) {
|
||||
variable.description = v.description;
|
||||
}
|
||||
|
||||
return variable;
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@usebruno/common": "0.1.0",
|
||||
"arcsecond": "^5.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"lodash": "4.18.1",
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
const ohm = require('ohm-js');
|
||||
const _ = require('lodash');
|
||||
const { safeParseJson, outdentString, extractTypedAnnotations } = require('./utils');
|
||||
const {
|
||||
safeParseJson,
|
||||
outdentString,
|
||||
unescapeAnnotationDoubleQuotedArg,
|
||||
parseAnnotationMultilineTextBlock,
|
||||
applyDescriptionFromAnnotations,
|
||||
extractTypedAnnotations
|
||||
} = require('./utils');
|
||||
const parseExample = require('./example/bruToJson');
|
||||
|
||||
// this is done to avoid breaking existing pairlist mapping so
|
||||
@@ -39,13 +46,13 @@ const grammar = ohm.grammar(`Bru {
|
||||
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body | bodygrpc | bodyws
|
||||
bodyforms = bodyformurlencoded | bodymultipart | bodyfile
|
||||
params = paramspath | paramsquery
|
||||
|
||||
|
||||
// Oauth2 additional parameters
|
||||
authOauth2Configs = oauth2AuthReqConfig | oauth2AccessTokenReqConfig | oauth2RefreshTokenReqConfig
|
||||
oauth2AuthReqConfig = oauth2AuthReqHeaders | oauth2AuthReqQueryParams
|
||||
oauth2AuthReqConfig = oauth2AuthReqHeaders | oauth2AuthReqQueryParams
|
||||
oauth2AccessTokenReqConfig = oauth2AccessTokenReqHeaders | oauth2AccessTokenReqQueryParams | oauth2AccessTokenReqBody
|
||||
oauth2RefreshTokenReqConfig = oauth2RefreshTokenReqHeaders | oauth2RefreshTokenReqQueryParams | oauth2RefreshTokenReqBody
|
||||
|
||||
|
||||
nl = "\\r"? "\\n"
|
||||
st = " " | "\\t"
|
||||
stnl = st | nl
|
||||
@@ -64,7 +71,9 @@ const grammar = ohm.grammar(`Bru {
|
||||
annotationchar = ~("(" | ")" | " " | "\\t" | "\\r" | "\\n" | ":") any
|
||||
annotationsinglequotedargchar = ~"'" any
|
||||
annotationsinglequotedarg = "'" annotationsinglequotedargchar* "'"
|
||||
annotationdoublequotedargchar = ~"\\"" any
|
||||
annotationdoublequotedargchar = annotationdoublequotedargesc | annotationdoublequotedargnorm
|
||||
annotationdoublequotedargesc = "\\\\" any
|
||||
annotationdoublequotedargnorm = ~"\\"" any
|
||||
annotationdoublequotedarg = "\\"" annotationdoublequotedargchar* "\\""
|
||||
annotationunquotedargchar = ~")" any
|
||||
annotationunquotedarg = annotationunquotedargchar*
|
||||
@@ -176,7 +185,7 @@ const grammar = ohm.grammar(`Bru {
|
||||
// Examples - multiple example blocks
|
||||
example = "example" st* "{" nl* examplecontent tagend
|
||||
examplecontent = (~tagend any)*
|
||||
|
||||
|
||||
script = scriptreq | scriptres
|
||||
scriptreq = "script:pre-request" st* "{" nl* textblock tagend
|
||||
scriptres = "script:post-response" st* "{" nl* textblock tagend
|
||||
@@ -207,7 +216,10 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true, extractTyp
|
||||
}
|
||||
|
||||
const result = { name, value, enabled };
|
||||
if (rawAnnotations && rawAnnotations.length) result.annotations = rawAnnotations;
|
||||
if (rawAnnotations && rawAnnotations.length) {
|
||||
result.annotations = rawAnnotations;
|
||||
applyDescriptionFromAnnotations(result, rawAnnotations);
|
||||
}
|
||||
if (extractTypes) extractTypedAnnotations(rawAnnotations, result);
|
||||
return result;
|
||||
});
|
||||
@@ -228,7 +240,10 @@ const mapRequestParams = (pairList = [], type) => {
|
||||
}
|
||||
|
||||
const result = { name, value, enabled, type };
|
||||
if (rawAnnotations && rawAnnotations.length) result.annotations = rawAnnotations;
|
||||
if (rawAnnotations && rawAnnotations.length) {
|
||||
result.annotations = rawAnnotations;
|
||||
applyDescriptionFromAnnotations(result, rawAnnotations);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
};
|
||||
@@ -262,9 +277,10 @@ const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) =
|
||||
|
||||
return pairs.map((pair) => {
|
||||
pair.type = 'text';
|
||||
// Description is already handled inside mapPairListToKeyValPairs
|
||||
multipartExtractContentType(pair);
|
||||
|
||||
if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) {
|
||||
if (_.isString(pair.value) && pair.value.startsWith('@file(') && pair.value.endsWith(')')) {
|
||||
let filestr = pair.value.replace(/^@file\(/, '').replace(/\)$/, '');
|
||||
pair.type = 'file';
|
||||
pair.value = filestr.split('|').filter(Boolean);
|
||||
@@ -394,7 +410,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
return chars.sourceString;
|
||||
},
|
||||
annotationdoublequotedarg(_open, chars, _close) {
|
||||
return chars.sourceString;
|
||||
return unescapeAnnotationDoubleQuotedArg(chars.sourceString);
|
||||
},
|
||||
annotationunquotedarg(chars) {
|
||||
return chars.sourceString;
|
||||
@@ -403,13 +419,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
return alt.ast;
|
||||
},
|
||||
annotationmultilinetextblock(_1, content, _2) {
|
||||
const lines = content.sourceString.split('\n');
|
||||
// NOTE: the number 4 is taken from the `multilinetextblock` implementation
|
||||
let minIndent = 4;
|
||||
const dedented = lines.map((line) => (line.trim() === '' ? '' : line.substring(minIndent)));
|
||||
if (dedented.length > 0 && dedented[0] === '') dedented.shift();
|
||||
if (dedented.length > 0 && dedented[dedented.length - 1] === '') dedented.pop();
|
||||
return dedented.join('\n');
|
||||
return parseAnnotationMultilineTextBlock(content.sourceString);
|
||||
},
|
||||
annotationargscontents(alt) {
|
||||
return alt.ast;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
const ohm = require('ohm-js');
|
||||
const _ = require('lodash');
|
||||
const { safeParseJson, outdentString, extractTypedAnnotations } = require('./utils');
|
||||
const {
|
||||
safeParseJson,
|
||||
outdentString,
|
||||
parseAnnotationMultilineTextBlock,
|
||||
unescapeAnnotationDoubleQuotedArg,
|
||||
applyDescriptionFromAnnotations,
|
||||
extractTypedAnnotations
|
||||
} = require('./utils');
|
||||
|
||||
// this is done to avoid breaking existing pairlist mapping so
|
||||
// the key is hidden and not added into the json automatically
|
||||
@@ -12,7 +19,7 @@ const grammar = ohm.grammar(`Bru {
|
||||
|
||||
// Oauth2 additional parameters
|
||||
authOauth2Configs = oauth2AuthReqConfig | oauth2AccessTokenReqConfig | oauth2RefreshTokenReqConfig
|
||||
oauth2AuthReqConfig = oauth2AuthReqHeaders | oauth2AuthReqQueryParams
|
||||
oauth2AuthReqConfig = oauth2AuthReqHeaders | oauth2AuthReqQueryParams
|
||||
oauth2AccessTokenReqConfig = oauth2AccessTokenReqHeaders | oauth2AccessTokenReqQueryParams | oauth2AccessTokenReqBody
|
||||
oauth2RefreshTokenReqConfig = oauth2RefreshTokenReqHeaders | oauth2RefreshTokenReqQueryParams | oauth2RefreshTokenReqBody
|
||||
|
||||
@@ -33,7 +40,9 @@ const grammar = ohm.grammar(`Bru {
|
||||
annotationchar = ~("(" | ")" | " " | "\\t" | "\\r" | "\\n" | ":") any
|
||||
annotationsinglequotedargchar = ~"'" any
|
||||
annotationsinglequotedarg = "'" annotationsinglequotedargchar* "'"
|
||||
annotationdoublequotedargchar = ~"\\"" any
|
||||
annotationdoublequotedargchar = annotationdoublequotedargesc | annotationdoublequotedargnorm
|
||||
annotationdoublequotedargesc = "\\\\" any
|
||||
annotationdoublequotedargnorm = ~"\\"" any
|
||||
annotationdoublequotedarg = "\\"" annotationdoublequotedargchar* "\\""
|
||||
annotationunquotedargchar = ~")" any
|
||||
annotationunquotedarg = annotationunquotedargchar*
|
||||
@@ -56,13 +65,14 @@ const grammar = ohm.grammar(`Bru {
|
||||
quoted_key_char = ~(quote_char | esc_quote_char | nl) any
|
||||
quoted_key = disable_char? quote_char (esc_quote_char | quoted_key_char)* quote_char
|
||||
key = keychar*
|
||||
value = multilinetextblock | valuechar*
|
||||
value = multilinetextblock | singlelinevalue
|
||||
singlelinevalue = valuechar*
|
||||
|
||||
// Text Blocks
|
||||
textblock = textline (~tagend nl textline)*
|
||||
textline = textchar*
|
||||
textchar = ~nl any
|
||||
|
||||
|
||||
meta = "meta" dictionary
|
||||
|
||||
auth = "auth" dictionary
|
||||
@@ -127,6 +137,7 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true, extractTyp
|
||||
const result = { name, value, enabled };
|
||||
if (rawAnnotations && rawAnnotations.length) {
|
||||
result.annotations = rawAnnotations;
|
||||
applyDescriptionFromAnnotations(result, rawAnnotations);
|
||||
}
|
||||
if (extractTypes) extractTypedAnnotations(rawAnnotations, result);
|
||||
return result;
|
||||
@@ -188,7 +199,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
return chars.sourceString;
|
||||
},
|
||||
annotationdoublequotedarg(_open, chars, _close) {
|
||||
return chars.sourceString;
|
||||
return unescapeAnnotationDoubleQuotedArg(chars.sourceString);
|
||||
},
|
||||
annotationunquotedarg(chars) {
|
||||
return chars.sourceString;
|
||||
@@ -197,12 +208,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
return alt.ast;
|
||||
},
|
||||
annotationmultilinetextblock(_1, content, _2) {
|
||||
const lines = content.sourceString.split('\n');
|
||||
let minIndent = 4;
|
||||
const dedented = lines.map((line) => (line.trim() === '' ? '' : line.substring(minIndent)));
|
||||
if (dedented.length > 0 && dedented[0] === '') dedented.shift();
|
||||
if (dedented.length > 0 && dedented[dedented.length - 1] === '') dedented.pop();
|
||||
return dedented.join('\n');
|
||||
return parseAnnotationMultilineTextBlock(content.sourceString);
|
||||
},
|
||||
annotationargscontents(alt) {
|
||||
return alt.ast;
|
||||
@@ -253,6 +259,9 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
}
|
||||
return chars.sourceString ? chars.sourceString.trim() : '';
|
||||
},
|
||||
singlelinevalue(chars) {
|
||||
return chars.sourceString?.trim() || '';
|
||||
},
|
||||
textblock(line, _1, rest) {
|
||||
return [line.ast, ...rest.ast].join('\n');
|
||||
},
|
||||
@@ -263,8 +272,11 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
return char.sourceString;
|
||||
},
|
||||
multilinetextblock(_1, content, _2) {
|
||||
// Join all the content between the triple quotes and trim it
|
||||
return content.sourceString.trim();
|
||||
return content.sourceString
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((line) => line.slice(4))
|
||||
.join('\n')
|
||||
.trim();
|
||||
},
|
||||
nl(_1, _2) {
|
||||
return '';
|
||||
|
||||
@@ -27,11 +27,13 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => {
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
name,
|
||||
value,
|
||||
enabled
|
||||
};
|
||||
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -54,12 +56,14 @@ const mapRequestParams = (pairList = [], type) => {
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
name,
|
||||
value,
|
||||
enabled,
|
||||
type
|
||||
};
|
||||
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -108,7 +112,7 @@ const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) =
|
||||
pair.type = 'text';
|
||||
multipartExtractContentType(pair);
|
||||
|
||||
if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) {
|
||||
if (_.isString(pair.value) && pair.value.startsWith('@file(') && pair.value.endsWith(')')) {
|
||||
let filestr = pair.value.replace(/^@file\(/, '').replace(/\)$/, '');
|
||||
pair.type = 'file';
|
||||
pair.value = filestr.split('|');
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
const ohm = require('ohm-js');
|
||||
const _ = require('lodash');
|
||||
const { extractTypedAnnotations } = require('./utils');
|
||||
const {
|
||||
parseAnnotationMultilineTextBlock,
|
||||
unescapeAnnotationDoubleQuotedArg,
|
||||
applyDescriptionFromAnnotations,
|
||||
extractTypedAnnotations
|
||||
} = require('./utils');
|
||||
|
||||
// this is done to avoid breaking existing pairlist mapping so
|
||||
// the key is hidden and not added into the json automatically
|
||||
@@ -38,7 +43,9 @@ const grammar = ohm.grammar(`Bru {
|
||||
annotationchar = ~("(" | ")" | " " | "\\t" | "\\r" | "\\n" | ":") any
|
||||
annotationsinglequotedargchar = ~"'" any
|
||||
annotationsinglequotedarg = "'" annotationsinglequotedargchar* "'"
|
||||
annotationdoublequotedargchar = ~"\\"" any
|
||||
annotationdoublequotedargchar = annotationdoublequotedargesc | annotationdoublequotedargnorm
|
||||
annotationdoublequotedargesc = "\\\\" any
|
||||
annotationdoublequotedargnorm = ~"\\"" any
|
||||
annotationdoublequotedarg = "\\"" annotationdoublequotedargchar* "\\""
|
||||
annotationunquotedargchar = ~")" any
|
||||
annotationunquotedarg = annotationunquotedargchar*
|
||||
@@ -55,7 +62,8 @@ const grammar = ohm.grammar(`Bru {
|
||||
pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)*
|
||||
pair = st* pairannotations st* key st* ":" st* value st*
|
||||
key = keychar*
|
||||
value = multilinetextblock | valuechar*
|
||||
value = multilinetextblock | singlelinevalue
|
||||
singlelinevalue = valuechar*
|
||||
|
||||
// Array Blocks
|
||||
array = st* "[" stnl* valuelist stnl* "]"
|
||||
@@ -76,7 +84,7 @@ const mapPairListToKeyValPairs = (pairList = []) => {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.map(pairList[0], (pair) => {
|
||||
return _.flatMap(pairList[0], (pair) => {
|
||||
let name = _.keys(pair)[0];
|
||||
let value = pair[name];
|
||||
const rawAnnotations = pair[ANNOTATIONS_KEY];
|
||||
@@ -89,14 +97,44 @@ const mapPairListToKeyValPairs = (pairList = []) => {
|
||||
const result = { name, value, enabled };
|
||||
if (rawAnnotations && rawAnnotations.length) {
|
||||
result.annotations = rawAnnotations;
|
||||
applyDescriptionFromAnnotations(result, rawAnnotations);
|
||||
}
|
||||
|
||||
extractTypedAnnotations(rawAnnotations, result);
|
||||
|
||||
return result;
|
||||
return expandDescriptionOrphanRows(result);
|
||||
});
|
||||
};
|
||||
|
||||
// When multiple @description annotations stack on one var, all but the last
|
||||
// render as description-only rows in the environment editor.
|
||||
const expandDescriptionOrphanRows = (variable) => {
|
||||
const annotations = variable.annotations || [];
|
||||
const descriptionAnnotations = annotations.filter((a) => a.name === 'description');
|
||||
const otherAnnotations = annotations.filter((a) => a.name !== 'description');
|
||||
|
||||
if (descriptionAnnotations.length <= 1) {
|
||||
return [variable];
|
||||
}
|
||||
|
||||
const orphanRows = descriptionAnnotations.slice(0, -1).map((descAnnotation) => ({
|
||||
name: '',
|
||||
value: '',
|
||||
enabled: variable.enabled,
|
||||
annotations: [descAnnotation],
|
||||
description: descAnnotation.value ?? ''
|
||||
}));
|
||||
|
||||
const lastDescription = descriptionAnnotations[descriptionAnnotations.length - 1];
|
||||
const mainRow = {
|
||||
...variable,
|
||||
annotations: [...otherAnnotations, lastDescription],
|
||||
description: lastDescription.value ?? ''
|
||||
};
|
||||
|
||||
return [...orphanRows, mainRow];
|
||||
};
|
||||
|
||||
const mapArrayListToKeyValPairs = (arrayList = []) => {
|
||||
arrayList = arrayList.filter((item) => item && item.name && item.name.length);
|
||||
|
||||
@@ -115,6 +153,7 @@ const mapArrayListToKeyValPairs = (arrayList = []) => {
|
||||
const result = { name, value: '', enabled };
|
||||
if (item.annotations && item.annotations.length) {
|
||||
result.annotations = item.annotations;
|
||||
applyDescriptionFromAnnotations(result, item.annotations);
|
||||
}
|
||||
|
||||
extractTypedAnnotations(item.annotations, result);
|
||||
@@ -186,7 +225,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
return chars.sourceString;
|
||||
},
|
||||
annotationdoublequotedarg(_open, chars, _close) {
|
||||
return chars.sourceString;
|
||||
return unescapeAnnotationDoubleQuotedArg(chars.sourceString);
|
||||
},
|
||||
annotationunquotedarg(chars) {
|
||||
return chars.sourceString;
|
||||
@@ -195,12 +234,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
return alt.ast;
|
||||
},
|
||||
annotationmultilinetextblock(_1, content, _2) {
|
||||
const lines = content.sourceString.split('\n');
|
||||
let minIndent = 4;
|
||||
const dedented = lines.map((line) => (line.trim() === '' ? '' : line.substring(minIndent)));
|
||||
if (dedented.length > 0 && dedented[0] === '') dedented.shift();
|
||||
if (dedented.length > 0 && dedented[dedented.length - 1] === '') dedented.pop();
|
||||
return dedented.join('\n');
|
||||
return parseAnnotationMultilineTextBlock(content.sourceString);
|
||||
},
|
||||
annotationargscontents(alt) {
|
||||
return alt.ast;
|
||||
@@ -227,6 +261,9 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
}
|
||||
return chars.sourceString ? chars.sourceString.trim() : '';
|
||||
},
|
||||
singlelinevalue(chars) {
|
||||
return chars.sourceString?.trim() || '';
|
||||
},
|
||||
multilinetextblockstart(_1, _2) {
|
||||
return '';
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const _ = require('lodash');
|
||||
|
||||
const { indentString, getValueString, getKeyString, getValueUrl, serializeAnnotations, serializeVar } = require('./utils');
|
||||
const { indentString, getValueString, getKeyString, getValueUrl, serializeVar, serializeAnnotations, buildAnnotationsFromKVItem } = require('./utils');
|
||||
const jsonToExampleBru = require('./example/jsonToBru');
|
||||
|
||||
const enabled = (items = [], key = 'enabled') => items.filter((item) => item[key]);
|
||||
@@ -128,7 +127,7 @@ const jsonToBru = (json) => {
|
||||
if (enabled(queryParams).length) {
|
||||
bru += `\n${indentString(
|
||||
enabled(queryParams)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -136,7 +135,7 @@ const jsonToBru = (json) => {
|
||||
if (disabled(queryParams).length) {
|
||||
bru += `\n${indentString(
|
||||
disabled(queryParams)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}~${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}~${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -147,7 +146,7 @@ const jsonToBru = (json) => {
|
||||
if (pathParams.length) {
|
||||
bru += 'params:path {';
|
||||
|
||||
bru += `\n${indentString(pathParams.map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
|
||||
bru += `\n${indentString(pathParams.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
|
||||
|
||||
bru += '\n}\n\n';
|
||||
}
|
||||
@@ -158,7 +157,7 @@ const jsonToBru = (json) => {
|
||||
if (enabled(headers).length) {
|
||||
bru += `\n${indentString(
|
||||
enabled(headers)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -166,7 +165,7 @@ const jsonToBru = (json) => {
|
||||
if (disabled(headers).length) {
|
||||
bru += `\n${indentString(
|
||||
disabled(headers)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}~${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}~${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -179,7 +178,7 @@ const jsonToBru = (json) => {
|
||||
if (enabled(metadata).length) {
|
||||
bru += `\n${indentString(
|
||||
enabled(metadata)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.name}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -187,7 +186,7 @@ const jsonToBru = (json) => {
|
||||
if (disabled(metadata).length) {
|
||||
bru += `\n${indentString(
|
||||
disabled(metadata)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}~${item.name}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}~${item.name}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -379,7 +378,7 @@ ${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toStr
|
||||
${indentString(
|
||||
authorizationHeaders
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -392,7 +391,7 @@ ${indentString(
|
||||
${indentString(
|
||||
authorizationQueryParams
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -405,7 +404,7 @@ ${indentString(
|
||||
${indentString(
|
||||
tokenHeaders
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -418,7 +417,7 @@ ${indentString(
|
||||
${indentString(
|
||||
tokenQueryParams
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -431,7 +430,7 @@ ${indentString(
|
||||
${indentString(
|
||||
tokenBodyValues
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -444,7 +443,7 @@ ${indentString(
|
||||
${indentString(
|
||||
refreshHeaders
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -457,7 +456,7 @@ ${indentString(
|
||||
${indentString(
|
||||
refreshQueryParams
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -470,7 +469,7 @@ ${indentString(
|
||||
${indentString(
|
||||
refreshBodyValues
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -542,14 +541,14 @@ ${indentString(body.sparql)}
|
||||
|
||||
if (enabled(body.formUrlEncoded).length) {
|
||||
const enabledValues = enabled(body.formUrlEncoded)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n');
|
||||
bru += `${indentString(enabledValues)}\n`;
|
||||
}
|
||||
|
||||
if (disabled(body.formUrlEncoded).length) {
|
||||
const disabledValues = disabled(body.formUrlEncoded)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}~${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}~${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n');
|
||||
bru += `${indentString(disabledValues)}\n`;
|
||||
}
|
||||
@@ -565,13 +564,12 @@ ${indentString(body.sparql)}
|
||||
bru += `\n${indentString(
|
||||
multipartForms
|
||||
.map((item) => {
|
||||
const enabled = item.enabled ? '' : '~';
|
||||
const enabledPrefix = item.enabled ? '' : '~';
|
||||
const contentType
|
||||
= item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
|
||||
|
||||
const annotPrefix = serializeAnnotations(item.annotations);
|
||||
const annotPrefix = serializeAnnotations(buildAnnotationsFromKVItem(item));
|
||||
if (item.type === 'text') {
|
||||
return `${annotPrefix}${enabled}${getKeyString(item.name)}: ${getValueString(item.value)}${contentType}`;
|
||||
return `${annotPrefix}${enabledPrefix}${getKeyString(item.name)}: ${getValueString(item.value)}${contentType}`;
|
||||
}
|
||||
|
||||
if (item.type === 'file') {
|
||||
@@ -579,7 +577,7 @@ ${indentString(body.sparql)}
|
||||
const filestr = filepaths.join('|');
|
||||
|
||||
const value = `@file(${filestr})`;
|
||||
return `${annotPrefix}${enabled}${getKeyString(item.name)}: ${value}${contentType}`;
|
||||
return `${annotPrefix}${enabledPrefix}${getKeyString(item.name)}: ${value}${contentType}`;
|
||||
}
|
||||
})
|
||||
.join('\n')
|
||||
@@ -600,7 +598,7 @@ ${indentString(body.sparql)}
|
||||
const selected = item.selected ? '' : '~';
|
||||
const contentType
|
||||
= item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
|
||||
const annotPrefix = serializeAnnotations(item.annotations);
|
||||
const annotPrefix = serializeAnnotations(buildAnnotationsFromKVItem(item));
|
||||
const filePath = item.filePath || '';
|
||||
const value = `@file(${filePath})`;
|
||||
const itemName = 'file';
|
||||
@@ -732,7 +730,7 @@ ${indentString(body.sparql)}
|
||||
if (enabled(assertions).length) {
|
||||
bru += `\n${indentString(
|
||||
enabled(assertions)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.name}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -740,7 +738,7 @@ ${indentString(body.sparql)}
|
||||
if (disabled(assertions).length) {
|
||||
bru += `\n${indentString(
|
||||
disabled(assertions)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}~${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}~${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const _ = require('lodash');
|
||||
|
||||
const { indentString, getValueString, getKeyString, serializeAnnotations, serializeVar } = require('./utils');
|
||||
const { indentString, getValueString, getKeyString, serializeVar, serializeAnnotations, buildAnnotationsFromKVItem } = require('./utils');
|
||||
|
||||
const enabled = (items = []) => items.filter((item) => item.enabled);
|
||||
const disabled = (items = []) => items.filter((item) => !item.enabled);
|
||||
@@ -30,7 +30,7 @@ const jsonToCollectionBru = (json) => {
|
||||
if (enabled(query).length) {
|
||||
bru += `\n${indentString(
|
||||
enabled(query)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const jsonToCollectionBru = (json) => {
|
||||
if (disabled(query).length) {
|
||||
bru += `\n${indentString(
|
||||
disabled(query)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}~${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}~${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ const jsonToCollectionBru = (json) => {
|
||||
if (enabled(headers).length) {
|
||||
bru += `\n${indentString(
|
||||
enabled(headers)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -59,7 +59,7 @@ const jsonToCollectionBru = (json) => {
|
||||
if (disabled(headers).length) {
|
||||
bru += `\n${indentString(
|
||||
disabled(headers)
|
||||
.map((item) => `${serializeAnnotations(item.annotations)}~${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}~${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -283,7 +283,7 @@ ${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).
|
||||
${indentString(
|
||||
authorizationHeaders
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -296,7 +296,7 @@ ${indentString(
|
||||
${indentString(
|
||||
authorizationQueryParams
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -309,7 +309,7 @@ ${indentString(
|
||||
${indentString(
|
||||
tokenHeaders
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -322,7 +322,7 @@ ${indentString(
|
||||
${indentString(
|
||||
tokenQueryParams
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n'))}
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ ${indentString(
|
||||
${indentString(
|
||||
tokenBodyValues
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -347,7 +347,7 @@ ${indentString(
|
||||
${indentString(
|
||||
refreshHeaders
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -360,7 +360,7 @@ ${indentString(
|
||||
${indentString(
|
||||
refreshQueryParams
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
@@ -373,7 +373,7 @@ ${indentString(
|
||||
${indentString(
|
||||
refreshBodyValues
|
||||
.filter((item) => item?.name?.length)
|
||||
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${serializeAnnotations(buildAnnotationsFromKVItem(item))}${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n')
|
||||
)}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
const { parseValueByDataType, BRUNO_VARIABLE_DATATYPES } = require('@usebruno/common/utils');
|
||||
const BRUNO_VARIABLE_DATATYPES = ['string', 'number', 'boolean', 'object'];
|
||||
|
||||
const parseValueByDataType = (value, dataType) => {
|
||||
if (!dataType || dataType === 'string') return value;
|
||||
try {
|
||||
if (dataType === 'number') {
|
||||
if (typeof value === 'number') return value;
|
||||
const trimmed = typeof value === 'string' ? value.trim() : value;
|
||||
if (trimmed === '' || trimmed == null) return value;
|
||||
const num = Number(trimmed);
|
||||
if (!Number.isNaN(num)) return num;
|
||||
} else if (dataType === 'boolean') {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
} else if (dataType === 'object') {
|
||||
if (typeof value === 'object' && value !== null) return value;
|
||||
const trimmed = typeof value === 'string' ? value.trim() : value;
|
||||
if (trimmed === '' || trimmed == null) return value;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed !== null && typeof parsed === 'object') return parsed;
|
||||
} catch (_) {
|
||||
// not JSON — fall through
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// fall through
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// safely parse json
|
||||
const safeParseJson = (json) => {
|
||||
@@ -33,6 +63,61 @@ const outdentString = (str, spaces = 2) => {
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const parseAnnotationMultilineTextBlock = (content) => {
|
||||
if (!content || !content.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!content.includes('\n') && !content.includes('\r')) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const lineEnding = content.includes('\r\n') ? '\r\n' : content.includes('\r') ? '\r' : '\n';
|
||||
const lines = content.split(/\r\n|\r|\n/);
|
||||
|
||||
if (lines.length > 0 && lines[0] === '') lines.shift();
|
||||
if (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop();
|
||||
|
||||
const nonEmptyLines = lines.filter((line) => line.trim() !== '');
|
||||
const minIndent = nonEmptyLines.length
|
||||
? Math.min(...nonEmptyLines.map((line) => line.match(/^[ \t]*/)[0].length))
|
||||
: 0;
|
||||
|
||||
return lines
|
||||
.map((line) => (line.trim() === '' ? '' : line.substring(minIndent)))
|
||||
.join(lineEnding);
|
||||
};
|
||||
|
||||
const unescapeAnnotationDoubleQuotedArg = (value) =>
|
||||
value.replace(/\\(r|n|t|"|\\)/g, (_, char) => {
|
||||
switch (char) {
|
||||
case 'r':
|
||||
return '\r';
|
||||
case 'n':
|
||||
return '\n';
|
||||
case 't':
|
||||
return '\t';
|
||||
case '"':
|
||||
return '"';
|
||||
case '\\':
|
||||
return '\\';
|
||||
default:
|
||||
return char;
|
||||
}
|
||||
});
|
||||
|
||||
const escapeAnnotationDoubleQuotedArg = (value) =>
|
||||
value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
|
||||
// Escapes a multiline description's own delimiter (''') so it can safely round-trip
|
||||
// inside a '''...''' block. Any pre-existing \' must be doubled first so decoding
|
||||
// can tell it apart from the backslashes introduced by escaping '''.
|
||||
const escapeMultilineDescription = (value) =>
|
||||
value.split('\\\'').join('\\\\\'').split('\'\'\'').join('\\\'\\\'\\\'');
|
||||
|
||||
const unescapeMultilineDescription = (value) =>
|
||||
value.split('\\\'\\\'\\\'').join('\'\'\'').split('\\\\\'').join('\\\'');
|
||||
|
||||
const getValueString = (value) => {
|
||||
// Handle null, undefined, and empty strings
|
||||
if (!value && value !== 0 && value !== false) {
|
||||
@@ -75,6 +160,13 @@ const getValueUrl = (url) => {
|
||||
return `'''\n${indentString(url, 2)}\n'''`;
|
||||
};
|
||||
|
||||
const formatAnnotationArg = (strValue) => {
|
||||
if (strValue.includes('\'\'\'') || strValue.includes('\'')) {
|
||||
return `"${escapeAnnotationDoubleQuotedArg(strValue)}"`;
|
||||
}
|
||||
return `'${strValue}'`;
|
||||
};
|
||||
|
||||
function serializeAnnotations(annotations) {
|
||||
if (!annotations?.length) return '';
|
||||
return (
|
||||
@@ -83,25 +175,38 @@ function serializeAnnotations(annotations) {
|
||||
if (a.value === undefined) return `@${a.name}`;
|
||||
const strValue = String(a.value);
|
||||
if (strValue.includes('\n')) {
|
||||
return `@${a.name}('''\n${indentString(strValue)}\n''')`;
|
||||
const blockValue = a.name === 'description' ? escapeMultilineDescription(strValue) : strValue;
|
||||
return `@${a.name}('''\n${indentString(blockValue)}\n''')`;
|
||||
}
|
||||
const quote = strValue.includes('\'') ? '"' : '\'';
|
||||
return `@${a.name}(${quote}${strValue}${quote})`;
|
||||
return `@${a.name}(${formatAnnotationArg(strValue)})`;
|
||||
})
|
||||
.join('\n') + '\n'
|
||||
);
|
||||
};
|
||||
|
||||
const resolveDescriptionAnnotations = (annotations, description) => {
|
||||
const other = (annotations || []).filter((a) => a.name !== 'description');
|
||||
if (description !== undefined && description !== null) {
|
||||
if (description !== '') {
|
||||
return { descArr: [{ name: 'description', value: description }], other };
|
||||
}
|
||||
return { descArr: [], other };
|
||||
}
|
||||
const descAnnotation = (annotations || []).find((a) => a.name === 'description');
|
||||
const descArr = descAnnotation !== undefined ? [descAnnotation] : [];
|
||||
return { descArr, other };
|
||||
};
|
||||
|
||||
const buildAnnotationsFromVariable = (variable) => {
|
||||
const { annotations = [], dataType } = variable;
|
||||
// Drop any dataType annotations from the existing list; they'll be rebuilt from the dataType field
|
||||
const other = annotations.filter((a) => !BRUNO_VARIABLE_DATATYPES.includes(a.name));
|
||||
const { annotations = [], dataType, description } = variable;
|
||||
const dataTypeFiltered = annotations.filter((a) => !BRUNO_VARIABLE_DATATYPES.includes(a.name));
|
||||
const { descArr, other } = resolveDescriptionAnnotations(dataTypeFiltered, description);
|
||||
|
||||
if (dataType && dataType !== 'string') {
|
||||
return [{ name: dataType }, ...other];
|
||||
return [{ name: dataType }, ...descArr, ...other];
|
||||
}
|
||||
|
||||
return other;
|
||||
return [...descArr, ...other];
|
||||
};
|
||||
|
||||
const extractTypedAnnotations = (rawAnnotations, result) => {
|
||||
@@ -119,15 +224,35 @@ const serializeVar = (item, prefix = '') => {
|
||||
return `${serializeAnnotations(buildAnnotationsFromVariable(item))}${prefix}${item.name}: ${getValueString(item.value)}`;
|
||||
};
|
||||
|
||||
const applyDescriptionFromAnnotations = (result, annotations) => {
|
||||
if (!annotations?.length) return;
|
||||
const descAnnotation = annotations.find((a) => a.name === 'description');
|
||||
if (descAnnotation !== undefined) {
|
||||
const value = descAnnotation.value ?? '';
|
||||
result.description = typeof value === 'string' && value.includes('\n') ? unescapeMultilineDescription(value) : value;
|
||||
}
|
||||
};
|
||||
|
||||
const buildAnnotationsFromKVItem = (item) => {
|
||||
const { descArr, other } = resolveDescriptionAnnotations(item.annotations, item.description);
|
||||
return [...descArr, ...other];
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
safeParseJson,
|
||||
indentString,
|
||||
outdentString,
|
||||
unescapeAnnotationDoubleQuotedArg,
|
||||
escapeMultilineDescription,
|
||||
unescapeMultilineDescription,
|
||||
parseAnnotationMultilineTextBlock,
|
||||
getValueString,
|
||||
getKeyString,
|
||||
getValueUrl,
|
||||
serializeAnnotations,
|
||||
extractTypedAnnotations,
|
||||
buildAnnotationsFromVariable,
|
||||
serializeVar
|
||||
serializeVar,
|
||||
applyDescriptionFromAnnotations,
|
||||
buildAnnotationsFromKVItem
|
||||
};
|
||||
|
||||
@@ -17,7 +17,13 @@ assert {
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.assertions).toEqual([
|
||||
{ name: 'res.status', value: 'eq 200', enabled: true, annotations: [{ name: 'description', value: 'hello' }] }
|
||||
{
|
||||
name: 'res.status',
|
||||
value: 'eq 200',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'description', value: 'hello' }],
|
||||
description: 'hello'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -30,7 +36,13 @@ headers {
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.headers).toEqual([
|
||||
{ name: 'key', value: 'value', enabled: true, annotations: [{ name: 'description', value: 'hello' }] }
|
||||
{
|
||||
name: 'key',
|
||||
value: 'value',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'description', value: 'hello' }],
|
||||
description: 'hello'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -61,7 +73,8 @@ headers {
|
||||
name: 'key',
|
||||
value: 'value',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'string' }, { name: 'description', value: 'x' }]
|
||||
annotations: [{ name: 'string' }, { name: 'description', value: 'x' }],
|
||||
description: 'x'
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -80,7 +93,8 @@ headers {
|
||||
name: 'key',
|
||||
value: 'value',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'string' }, { name: 'description', value: 'hello' }]
|
||||
annotations: [{ name: 'string' }, { name: 'description', value: 'hello' }],
|
||||
description: 'hello'
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -288,6 +302,39 @@ headers {
|
||||
expect(parsed.headers[0].annotations).toEqual([{ name: 'description', value: 'line one\nline two' }]);
|
||||
});
|
||||
|
||||
it('serializeAnnotations — multiline value with embedded triple quotes roundtrips correctly', () => {
|
||||
const json = {
|
||||
meta: { name: 'test', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com' },
|
||||
headers: [{
|
||||
name: 'x-key',
|
||||
value: 'val',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'description', value: 'line one\nline two with \'\'\' inside\nline three' }]
|
||||
}]
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('@description(\'\'\'\n line one\n line two with \\\'\\\'\\\' inside\n line three\n \'\'\')');
|
||||
const parsed = parser(bru);
|
||||
expect(parsed.headers[0].description).toBe('line one\nline two with \'\'\' inside\nline three');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — multiline value with backslash-quote and triple quotes roundtrips correctly', () => {
|
||||
const json = {
|
||||
meta: { name: 'test', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com' },
|
||||
headers: [{
|
||||
name: 'x-key',
|
||||
value: 'val',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'description', value: 'it\\\'s multiline\nand has \'\'\' too' }]
|
||||
}]
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
const parsed = parser(bru);
|
||||
expect(parsed.headers[0].description).toBe('it\\\'s multiline\nand has \'\'\' too');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — empty string value roundtrips correctly', () => {
|
||||
const json = {
|
||||
meta: { name: 'test', type: 'http', seq: 1 },
|
||||
@@ -371,7 +418,14 @@ params:path {
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.params).toEqual([
|
||||
{ name: 'userId', value: '123', enabled: true, type: 'path', annotations: [{ name: 'description', value: 'user id' }] }
|
||||
{
|
||||
name: 'userId',
|
||||
value: '123',
|
||||
enabled: true,
|
||||
type: 'path',
|
||||
annotations: [{ name: 'description', value: 'user id' }],
|
||||
description: 'user id'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -384,7 +438,13 @@ metadata {
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.metadata).toEqual([
|
||||
{ name: 'trace-id', value: 'abc123', enabled: true, annotations: [{ name: 'description', value: 'trace id' }] }
|
||||
{
|
||||
name: 'trace-id',
|
||||
value: 'abc123',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'description', value: 'trace id' }],
|
||||
description: 'trace id'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -397,7 +457,13 @@ body:form-urlencoded {
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.body.formUrlEncoded).toEqual([
|
||||
{ name: 'username', value: 'alice', enabled: true, annotations: [{ name: 'description', value: 'username field' }] }
|
||||
{
|
||||
name: 'username',
|
||||
value: 'alice',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'description', value: 'username field' }],
|
||||
description: 'username field'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -415,7 +481,8 @@ vars:pre-request {
|
||||
value: 'http://localhost',
|
||||
enabled: true,
|
||||
local: false,
|
||||
annotations: [{ name: 'description', value: 'base url' }]
|
||||
annotations: [{ name: 'description', value: 'base url' }],
|
||||
description: 'base url'
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -434,7 +501,8 @@ vars:post-response {
|
||||
value: 'abc123',
|
||||
enabled: true,
|
||||
local: false,
|
||||
annotations: [{ name: 'description', value: 'auth token' }]
|
||||
annotations: [{ name: 'description', value: 'auth token' }],
|
||||
description: 'auth token'
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -453,7 +521,8 @@ vars:pre-request {
|
||||
value: 'http://localhost',
|
||||
enabled: true,
|
||||
local: true,
|
||||
annotations: [{ name: 'description', value: 'local base url' }]
|
||||
annotations: [{ name: 'description', value: 'local base url' }],
|
||||
description: 'local base url'
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -472,7 +541,8 @@ vars:post-response {
|
||||
value: 'abc123',
|
||||
enabled: true,
|
||||
local: true,
|
||||
annotations: [{ name: 'description', value: 'local token' }]
|
||||
annotations: [{ name: 'description', value: 'local token' }],
|
||||
description: 'local token'
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -492,7 +562,8 @@ body:multipart-form {
|
||||
enabled: true,
|
||||
type: 'text',
|
||||
contentType: 'text/plain',
|
||||
annotations: [{ name: 'description', value: 'plain field' }]
|
||||
annotations: [{ name: 'description', value: 'plain field' }],
|
||||
description: 'plain field'
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -512,7 +583,8 @@ body:multipart-form {
|
||||
enabled: true,
|
||||
type: 'file',
|
||||
contentType: 'image/png',
|
||||
annotations: [{ name: 'description', value: 'upload image' }]
|
||||
annotations: [{ name: 'description', value: 'upload image' }],
|
||||
description: 'upload image'
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -530,7 +602,8 @@ body:file {
|
||||
filePath: '/tmp/readme.pdf',
|
||||
selected: true,
|
||||
contentType: 'application/pdf',
|
||||
annotations: [{ name: 'description', value: 'upload doc' }]
|
||||
annotations: [{ name: 'description', value: 'upload doc' }],
|
||||
description: 'upload doc'
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -545,7 +618,8 @@ body:file {
|
||||
enabled: true,
|
||||
type: 'text',
|
||||
contentType: 'text/plain',
|
||||
annotations: [{ name: 'description', value: 'plain field' }]
|
||||
annotations: [{ name: 'description', value: 'plain field' }],
|
||||
description: 'plain field'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -609,7 +683,15 @@ body:file {
|
||||
it('serializeAnnotations — body:file with annotations', () => {
|
||||
const json = {
|
||||
body: {
|
||||
file: [{ filePath: '/tmp/readme.pdf', selected: true, contentType: 'application/pdf', annotations: [{ name: 'description', value: 'upload doc' }] }]
|
||||
file: [
|
||||
{
|
||||
filePath: '/tmp/readme.pdf',
|
||||
selected: true,
|
||||
contentType: 'application/pdf',
|
||||
annotations: [{ name: 'description', value: 'upload doc' }],
|
||||
description: 'upload doc'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
@@ -629,7 +711,8 @@ body:file {
|
||||
enabled: true,
|
||||
type: 'file',
|
||||
contentType: 'image/png',
|
||||
annotations: [{ name: 'description', value: 'upload image' }]
|
||||
annotations: [{ name: 'description', value: 'upload image' }],
|
||||
description: 'upload image'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -711,19 +794,28 @@ headers {
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - headers', () => {
|
||||
const input = `headers {
|
||||
@description('hello')
|
||||
@description('''hello''')
|
||||
key: value
|
||||
}
|
||||
`;
|
||||
const parsed = parser(input);
|
||||
const output = jsonToBru(parsed);
|
||||
|
||||
expect(input).toEqual(output);
|
||||
expect(parser(output)).toEqual(parsed);
|
||||
expect(output).toContain('@description(\'hello\')');
|
||||
});
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - headers', () => {
|
||||
const input = {
|
||||
headers: [{ name: 'x-key', value: 'val', enabled: true, annotations: [{ name: 'description', value: 'say "hello"' }] }]
|
||||
headers: [
|
||||
{
|
||||
name: 'x-key',
|
||||
value: 'val',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'description', value: 'say "hello"' }],
|
||||
description: 'say "hello"'
|
||||
}
|
||||
]
|
||||
};
|
||||
const stringified = jsonToBru(input);
|
||||
const output = parser(stringified);
|
||||
@@ -733,7 +825,7 @@ headers {
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - asserts', () => {
|
||||
const input = `assert {
|
||||
@description('make it rain')
|
||||
@description('''make it rain''')
|
||||
res.status: eq 200
|
||||
}
|
||||
`;
|
||||
@@ -741,7 +833,8 @@ headers {
|
||||
const parsed = parser(input);
|
||||
const output = jsonToBru(parsed);
|
||||
|
||||
expect(input).toEqual(output);
|
||||
expect(parser(output)).toEqual(parsed);
|
||||
expect(output).toContain('@description(\'make it rain\')');
|
||||
});
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - asserts', () => {
|
||||
@@ -749,7 +842,11 @@ headers {
|
||||
assertions: [
|
||||
{
|
||||
annotations: [{ name: 'description', value: 'hello' }],
|
||||
name: 'res.status', value: 'eq 200', enabled: true }
|
||||
name: 'res.status',
|
||||
value: 'eq 200',
|
||||
enabled: true,
|
||||
description: 'hello'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -802,6 +899,217 @@ headers {
|
||||
expect(parsed.headers[0].annotations).toEqual([{ name: 'description', value: 'Token (JWT)' }]);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - params:query', () => {
|
||||
const input = `params:query {
|
||||
@description('''search term''')
|
||||
q: hello
|
||||
}
|
||||
`;
|
||||
const parsed = parser(input);
|
||||
const output = jsonToBru(parsed);
|
||||
expect(parser(output)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - params:query', () => {
|
||||
const input = {
|
||||
params: [
|
||||
{
|
||||
name: 'q',
|
||||
value: 'hello',
|
||||
enabled: true,
|
||||
type: 'query',
|
||||
annotations: [{ name: 'description', value: 'search term' }],
|
||||
description: 'search term'
|
||||
}
|
||||
]
|
||||
};
|
||||
const bru = jsonToBru(input);
|
||||
const output = parser(bru);
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - params:path', () => {
|
||||
const input = `params:path {
|
||||
@description('''user id''')
|
||||
userId: 123
|
||||
}
|
||||
`;
|
||||
const parsed = parser(input);
|
||||
const output = jsonToBru(parsed);
|
||||
expect(parser(output)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - params:path', () => {
|
||||
const input = {
|
||||
params: [
|
||||
{
|
||||
name: 'userId',
|
||||
value: '123',
|
||||
enabled: true,
|
||||
type: 'path',
|
||||
annotations: [{ name: 'description', value: 'user id' }],
|
||||
description: 'user id'
|
||||
}
|
||||
]
|
||||
};
|
||||
const bru = jsonToBru(input);
|
||||
const output = parser(bru);
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - metadata', () => {
|
||||
const input = `metadata {
|
||||
@description('''trace id''')
|
||||
trace-id: abc123
|
||||
}
|
||||
`;
|
||||
const parsed = parser(input);
|
||||
const output = jsonToBru(parsed);
|
||||
expect(parser(output)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - metadata', () => {
|
||||
const input = {
|
||||
metadata: [
|
||||
{
|
||||
name: 'trace-id',
|
||||
value: 'abc123',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'description', value: 'trace id' }],
|
||||
description: 'trace id'
|
||||
}
|
||||
]
|
||||
};
|
||||
const bru = jsonToBru(input);
|
||||
const output = parser(bru);
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - body:form-urlencoded', () => {
|
||||
const input = `body:form-urlencoded {
|
||||
@description('''username field''')
|
||||
username: alice
|
||||
}
|
||||
`;
|
||||
const parsed = parser(input);
|
||||
const output = jsonToBru(parsed);
|
||||
expect(parser(output)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - body:form-urlencoded', () => {
|
||||
const input = {
|
||||
body: {
|
||||
formUrlEncoded: [
|
||||
{
|
||||
name: 'username',
|
||||
value: 'alice',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'description', value: 'username field' }],
|
||||
description: 'username field'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(input);
|
||||
const output = parser(bru);
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - body:multipart-form text field', () => {
|
||||
const input = `body:multipart-form {
|
||||
@description('''plain field''')
|
||||
field: value @contentType(text/plain)
|
||||
}
|
||||
`;
|
||||
const parsed = parser(input);
|
||||
const output = jsonToBru(parsed);
|
||||
expect(parser(output)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - body:multipart-form file field', () => {
|
||||
const input = `body:multipart-form {
|
||||
@description('''upload image''')
|
||||
upload: @file(/tmp/a.png|/tmp/b.png) @contentType(image/png)
|
||||
}
|
||||
`;
|
||||
const parsed = parser(input);
|
||||
const output = jsonToBru(parsed);
|
||||
expect(parser(output)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - body:file', () => {
|
||||
const input = `body:file {
|
||||
@description('''upload doc''')
|
||||
file: @file(/tmp/readme.pdf) @contentType(application/pdf)
|
||||
}
|
||||
`;
|
||||
const parsed = parser(input);
|
||||
const output = jsonToBru(parsed);
|
||||
expect(parser(output)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - vars:pre-request', () => {
|
||||
const input = `vars:pre-request {
|
||||
@description('''base url''')
|
||||
BASE_URL: http://localhost
|
||||
}
|
||||
`;
|
||||
const parsed = parser(input);
|
||||
const output = jsonToBru(parsed);
|
||||
expect(parser(output)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - vars:pre-request', () => {
|
||||
const input = {
|
||||
vars: {
|
||||
req: [
|
||||
{
|
||||
name: 'BASE_URL',
|
||||
value: 'http://localhost',
|
||||
enabled: true,
|
||||
local: false,
|
||||
annotations: [{ name: 'description', value: 'base url' }],
|
||||
description: 'base url'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(input);
|
||||
const output = parser(bru);
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - vars:post-response', () => {
|
||||
const input = `vars:post-response {
|
||||
@description('''auth token''')
|
||||
token: abc123
|
||||
}
|
||||
`;
|
||||
const parsed = parser(input);
|
||||
const output = jsonToBru(parsed);
|
||||
expect(parser(output)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - vars:post-response', () => {
|
||||
const input = {
|
||||
vars: {
|
||||
res: [
|
||||
{
|
||||
name: 'token',
|
||||
value: 'abc123',
|
||||
enabled: true,
|
||||
local: false,
|
||||
annotations: [{ name: 'description', value: 'auth token' }],
|
||||
description: 'auth token'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(input);
|
||||
const output = parser(bru);
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
it('inline annotation on a header is rejected', () => {
|
||||
const input = `
|
||||
headers {
|
||||
@@ -821,7 +1129,14 @@ describe('env pair annotations', () => {
|
||||
`;
|
||||
const output = envParser(input);
|
||||
expect(output.variables).toEqual([
|
||||
{ name: 'API_KEY', value: 'abc123', enabled: true, secret: false, annotations: [{ name: 'description', value: 'my api key' }] }
|
||||
{
|
||||
name: 'API_KEY',
|
||||
value: 'abc123',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
annotations: [{ name: 'description', value: 'my api key' }],
|
||||
description: 'my api key'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -951,7 +1266,16 @@ describe('env pair annotations', () => {
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - secret env vars', () => {
|
||||
const input = {
|
||||
variables: [{ name: 'SECRET_KEY', value: '', enabled: true, secret: true, annotations: [{ name: 'description', value: 'my secret key' }] }]
|
||||
variables: [
|
||||
{
|
||||
name: 'SECRET_KEY',
|
||||
value: '',
|
||||
enabled: true,
|
||||
secret: true,
|
||||
annotations: [{ name: 'description', value: 'my secret key' }],
|
||||
description: 'my secret key'
|
||||
}
|
||||
]
|
||||
};
|
||||
const bru = jsonToEnv(input);
|
||||
const output = envParser(bru);
|
||||
@@ -984,18 +1308,27 @@ describe('env pair annotations', () => {
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - env vars', () => {
|
||||
const input = `vars {
|
||||
@description('api key')
|
||||
@description('''api key''')
|
||||
API_KEY: abc123
|
||||
}
|
||||
`;
|
||||
const parsed = envParser(input);
|
||||
const output = jsonToEnv(parsed);
|
||||
expect(output).toEqual(input);
|
||||
expect(envParser(output)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - env vars', () => {
|
||||
const input = {
|
||||
variables: [{ name: 'API_KEY', value: 'abc123', enabled: true, secret: false, annotations: [{ name: 'description', value: 'api key' }] }]
|
||||
variables: [
|
||||
{
|
||||
name: 'API_KEY',
|
||||
value: 'abc123',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
annotations: [{ name: 'description', value: 'api key' }],
|
||||
description: 'api key'
|
||||
}
|
||||
]
|
||||
};
|
||||
const bru = jsonToEnv(input);
|
||||
const output = envParser(bru);
|
||||
@@ -1058,7 +1391,13 @@ describe('collection pair annotations', () => {
|
||||
`;
|
||||
const output = collectionParser(input);
|
||||
expect(output.headers).toEqual([
|
||||
{ name: 'content-type', value: 'application/json', enabled: true, annotations: [{ name: 'description', value: 'content type' }] }
|
||||
{
|
||||
name: 'content-type',
|
||||
value: 'application/json',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'description', value: 'content type' }],
|
||||
description: 'content type'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1106,7 +1445,14 @@ describe('collection pair annotations', () => {
|
||||
`;
|
||||
const output = collectionParser(input);
|
||||
expect(output.vars.req).toEqual([
|
||||
{ name: 'BASE_URL', value: 'http://localhost', enabled: true, local: false, annotations: [{ name: 'description', value: 'base url' }] }
|
||||
{
|
||||
name: 'BASE_URL',
|
||||
value: 'http://localhost',
|
||||
enabled: true,
|
||||
local: false,
|
||||
annotations: [{ name: 'description', value: 'base url' }],
|
||||
description: 'base url'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1221,18 +1567,26 @@ describe('collection pair annotations', () => {
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - collection headers', () => {
|
||||
const input = `headers {
|
||||
@description('content type')
|
||||
@description('''content type''')
|
||||
content-type: application/json
|
||||
}
|
||||
`;
|
||||
const parsed = collectionParser(input);
|
||||
const output = jsonToCollectionBru(parsed);
|
||||
expect(output).toEqual(input);
|
||||
expect(collectionParser(output)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - collection headers', () => {
|
||||
const input = {
|
||||
headers: [{ name: 'content-type', value: 'application/json', enabled: true, annotations: [{ name: 'description', value: 'content type' }] }]
|
||||
headers: [
|
||||
{
|
||||
name: 'content-type',
|
||||
value: 'application/json',
|
||||
enabled: true,
|
||||
annotations: [{ name: 'description', value: 'content type' }],
|
||||
description: 'content type'
|
||||
}
|
||||
]
|
||||
};
|
||||
const bru = jsonToCollectionBru(input);
|
||||
const output = collectionParser(bru);
|
||||
@@ -1241,13 +1595,13 @@ describe('collection pair annotations', () => {
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - collection vars:pre-request', () => {
|
||||
const input = `vars:pre-request {
|
||||
@description('base url')
|
||||
@description('''base url''')
|
||||
BASE_URL: http://localhost
|
||||
}
|
||||
`;
|
||||
const parsed = collectionParser(input);
|
||||
const output = jsonToCollectionBru(parsed);
|
||||
expect(output).toEqual(input);
|
||||
expect(collectionParser(output)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('inline annotation on a collection header is rejected', () => {
|
||||
|
||||
@@ -194,7 +194,7 @@ body:grpc {
|
||||
name: message 1
|
||||
content: '''
|
||||
{"foo":"bar"}
|
||||
'''
|
||||
'''
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -220,7 +220,7 @@ body:grpc {
|
||||
name: message 1
|
||||
content: '''
|
||||
{"id":{{userId}},"name":"{{userName}}"}
|
||||
'''
|
||||
'''
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -283,7 +283,7 @@ vars:pre-request {
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.vars.req).toEqual([
|
||||
{ name: 'certificate', value: 'some-value', enabled: true, local: false, annotations: [{ name: 'description', value: 'This is a certificate\nUse this when making request' }] },
|
||||
{ name: 'certificate', value: 'some-value', enabled: true, local: false, annotations: [{ name: 'description', value: 'This is a certificate\nUse this when making request' }], description: 'This is a certificate\nUse this when making request' },
|
||||
{ name: 'url', value: 'https://example.com', enabled: true, local: false }
|
||||
]);
|
||||
});
|
||||
@@ -321,7 +321,8 @@ vars:pre-request {
|
||||
{ name: 'object' },
|
||||
{ name: 'description', value: 'This is a certificate\nUse this when making request' }
|
||||
],
|
||||
dataType: 'object'
|
||||
dataType: 'object',
|
||||
description: 'This is a certificate\nUse this when making request'
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
@@ -332,7 +333,8 @@ vars:pre-request {
|
||||
{ name: 'number' },
|
||||
{ name: 'description', value: 'server port' }
|
||||
],
|
||||
dataType: 'number'
|
||||
dataType: 'number',
|
||||
description: 'server port'
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -536,4 +538,511 @@ body:multipart-form {
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('description annotation', () => {
|
||||
it('parses @description in headers', () => {
|
||||
const input = `
|
||||
headers {
|
||||
@description('''API key for auth.''')
|
||||
Authorization: Bearer xxx
|
||||
@description("Single-line desc")
|
||||
X-Custom: val
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.headers).toHaveLength(2);
|
||||
expect(output.headers[0]).toMatchObject({
|
||||
name: 'Authorization',
|
||||
value: 'Bearer xxx',
|
||||
enabled: true,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'API key for auth.'
|
||||
}
|
||||
],
|
||||
description: 'API key for auth.'
|
||||
});
|
||||
expect(output.headers[1]).toMatchObject({
|
||||
name: 'X-Custom',
|
||||
value: 'val',
|
||||
enabled: true,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Single-line desc'
|
||||
}
|
||||
],
|
||||
description: 'Single-line desc'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses @description in params and body form-urlencoded', () => {
|
||||
const input = `
|
||||
params:query {
|
||||
@description('''Search term.''')
|
||||
q: search
|
||||
}
|
||||
body:form-urlencoded {
|
||||
@description("Field description")
|
||||
field: value
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.params).toHaveLength(1);
|
||||
expect(output.params[0]).toMatchObject({
|
||||
name: 'q',
|
||||
value: 'search',
|
||||
type: 'query',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Search term.'
|
||||
}
|
||||
],
|
||||
description: 'Search term.'
|
||||
});
|
||||
expect(output.body.formUrlEncoded).toHaveLength(1);
|
||||
expect(output.body.formUrlEncoded[0]).toMatchObject({
|
||||
name: 'field',
|
||||
value: 'value',
|
||||
enabled: true,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Field description'
|
||||
}
|
||||
],
|
||||
description: 'Field description'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses @description in vars:pre-request and vars:post-response', () => {
|
||||
const input = `
|
||||
vars:pre-request {
|
||||
@description("Pre-request auth token")
|
||||
token: secret
|
||||
}
|
||||
vars:post-response {
|
||||
@description("Saved ID from response")
|
||||
saved: res.body.id
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.vars).toBeDefined();
|
||||
expect(output.vars.req).toHaveLength(1);
|
||||
expect(output.vars.req[0]).toMatchObject({
|
||||
name: 'token',
|
||||
value: 'secret',
|
||||
enabled: true,
|
||||
local: false,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Pre-request auth token'
|
||||
}
|
||||
],
|
||||
description: 'Pre-request auth token'
|
||||
});
|
||||
expect(output.vars.res).toHaveLength(1);
|
||||
expect(output.vars.res[0]).toMatchObject({
|
||||
name: 'saved',
|
||||
value: 'res.body.id',
|
||||
enabled: true,
|
||||
local: false,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Saved ID from response'
|
||||
}
|
||||
],
|
||||
description: 'Saved ID from response'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses @description in assert', () => {
|
||||
const input = `
|
||||
assert {
|
||||
@description("Expect success status")
|
||||
res.body.status: eq 200
|
||||
@description("Response must have data")
|
||||
res.body.data: isDefined
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.assertions).toHaveLength(2);
|
||||
expect(output.assertions[0]).toMatchObject({
|
||||
name: 'res.body.status',
|
||||
value: 'eq 200',
|
||||
enabled: true,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Expect success status'
|
||||
}
|
||||
],
|
||||
description: 'Expect success status'
|
||||
});
|
||||
expect(output.assertions[1]).toMatchObject({
|
||||
name: 'res.body.data',
|
||||
value: 'isDefined',
|
||||
enabled: true,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Response must have data'
|
||||
}
|
||||
],
|
||||
description: 'Response must have data'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses double-quoted @description with escaped newline', () => {
|
||||
const input = `
|
||||
headers {
|
||||
@description("Line one\\nLine two")
|
||||
X-Note: v
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.headers).toHaveLength(1);
|
||||
expect(output.headers[0]).toMatchObject({
|
||||
name: 'X-Note',
|
||||
value: 'v',
|
||||
enabled: true,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Line one\nLine two'
|
||||
}
|
||||
],
|
||||
description: 'Line one\nLine two'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses triple-quoted @description with literal newlines', () => {
|
||||
const input = `
|
||||
headers {
|
||||
@description('''
|
||||
Line one
|
||||
Line two
|
||||
''')
|
||||
X-Note: v
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.headers).toHaveLength(1);
|
||||
expect(output.headers[0]).toMatchObject({
|
||||
name: 'X-Note',
|
||||
value: 'v',
|
||||
enabled: true,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Line one\nLine two'
|
||||
}
|
||||
],
|
||||
description: 'Line one\nLine two'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses escaped characters in descriptions', () => {
|
||||
const input = `
|
||||
headers {
|
||||
@description("Say \\"hello\\"")
|
||||
X-Quote: val
|
||||
@description("Path: \\\\usr\\\\bin")
|
||||
X-Backslash: val
|
||||
@description("Line1\\nLine2")
|
||||
X-Newline: val
|
||||
}
|
||||
params:query {
|
||||
@description("Escaped \\" quote")
|
||||
q: x
|
||||
}
|
||||
body:form-urlencoded {
|
||||
@description("\\\\ and \\" and \\\\n")
|
||||
f: v
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.headers).toHaveLength(3);
|
||||
expect(output.headers[0]).toMatchObject({
|
||||
name: 'X-Quote',
|
||||
value: 'val',
|
||||
enabled: true,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Say "hello"'
|
||||
}
|
||||
],
|
||||
description: 'Say "hello"'
|
||||
});
|
||||
expect(output.headers[1]).toMatchObject({
|
||||
name: 'X-Backslash',
|
||||
value: 'val',
|
||||
enabled: true,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Path: \\usr\\bin'
|
||||
}
|
||||
],
|
||||
description: 'Path: \\usr\\bin'
|
||||
});
|
||||
expect(output.headers[2]).toMatchObject({
|
||||
name: 'X-Newline',
|
||||
value: 'val',
|
||||
enabled: true,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Line1\nLine2'
|
||||
}
|
||||
],
|
||||
description: 'Line1\nLine2'
|
||||
});
|
||||
expect(output.params[0]).toMatchObject({
|
||||
name: 'q',
|
||||
value: 'x',
|
||||
type: 'query',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Escaped " quote'
|
||||
}
|
||||
],
|
||||
description: 'Escaped " quote'
|
||||
});
|
||||
expect(output.body.formUrlEncoded[0]).toMatchObject({
|
||||
name: 'f',
|
||||
value: 'v',
|
||||
enabled: true,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: '\\ and " and \\n'
|
||||
}
|
||||
],
|
||||
description: '\\ and " and \\n'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses emoji in triple-quoted prefix description', () => {
|
||||
const input = `
|
||||
headers {
|
||||
@description('''Auth token 🔑''')
|
||||
Authorization: Bearer xxx
|
||||
@description('''Region 🌍 selector''')
|
||||
X-Region: us-east
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.headers[0]).toMatchObject({
|
||||
name: 'Authorization',
|
||||
value: 'Bearer xxx',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Auth token 🔑'
|
||||
}
|
||||
],
|
||||
description: 'Auth token 🔑'
|
||||
});
|
||||
expect(output.headers[1]).toMatchObject({
|
||||
name: 'X-Region',
|
||||
value: 'us-east',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Region 🌍 selector'
|
||||
}
|
||||
],
|
||||
description: 'Region 🌍 selector'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses emoji in double-quoted description', () => {
|
||||
const input = `
|
||||
vars:pre-request {
|
||||
@description("API key 🔐 required")
|
||||
token: secret
|
||||
}
|
||||
assert {
|
||||
@description("Status check ✅")
|
||||
res.status: eq 200
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.vars.req[0]).toMatchObject({
|
||||
name: 'token',
|
||||
value: 'secret',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'API key 🔐 required'
|
||||
}
|
||||
],
|
||||
description: 'API key 🔐 required'
|
||||
});
|
||||
expect(output.assertions[0]).toMatchObject({
|
||||
name: 'res.status',
|
||||
value: 'eq 200',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Status check ✅'
|
||||
}
|
||||
],
|
||||
description: 'Status check ✅'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses emoji in multiline triple-quoted prefix description', () => {
|
||||
const input = `
|
||||
headers {
|
||||
@description('''
|
||||
Launch 🚀
|
||||
Second line
|
||||
''')
|
||||
X-Launch: val
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.headers[0]).toMatchObject({
|
||||
name: 'X-Launch',
|
||||
value: 'val',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Launch 🚀\nSecond line'
|
||||
}
|
||||
],
|
||||
description: 'Launch 🚀\nSecond line'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses \\r\\n escape sequence in double-quoted description as CRLF', () => {
|
||||
const input = `
|
||||
headers {
|
||||
@description("Line one\\r\\nLine two")
|
||||
X-Note: v
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.headers[0]).toMatchObject({
|
||||
name: 'X-Note',
|
||||
value: 'v',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Line one\r\nLine two'
|
||||
}
|
||||
],
|
||||
description: 'Line one\r\nLine two'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses \\n escape sequence in double-quoted description as LF', () => {
|
||||
const input = `
|
||||
headers {
|
||||
@description("First\\nSecond\\nThird")
|
||||
X-Note: v
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.headers[0]).toMatchObject({
|
||||
name: 'X-Note',
|
||||
value: 'v',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'First\nSecond\nThird'
|
||||
}
|
||||
],
|
||||
description: 'First\nSecond\nThird'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses triple-quoted prefix with CRLF file line endings', () => {
|
||||
// Simulate a .bru file saved with Windows CRLF line endings
|
||||
const input = 'headers {\r\n @description(\'\'\'Line one\'\'\')\r\n X-Note: val\r\n}';
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.headers[0]).toMatchObject({
|
||||
name: 'X-Note',
|
||||
value: 'val',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Line one'
|
||||
}
|
||||
],
|
||||
description: 'Line one'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses multiline triple-quoted prefix with CRLF file line endings', () => {
|
||||
const input = 'headers {\r\n @description(\'\'\'\r\n Line one\r\n Line two\r\n \'\'\')\r\n X-Note: val\r\n}';
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.headers[0]).toMatchObject({
|
||||
name: 'X-Note',
|
||||
value: 'val',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Line one\r\nLine two'
|
||||
}
|
||||
],
|
||||
description: 'Line one\r\nLine two'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses multiline triple-quoted prefix with CRLF across three lines', () => {
|
||||
const input = 'headers {\r\n @description(\'\'\'\r\n Line one\r\n Line two\r\n Line three\r\n \'\'\')\r\n X-Note: val\r\n}';
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.headers[0]).toMatchObject({
|
||||
name: 'X-Note',
|
||||
value: 'val',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Line one\r\nLine two\r\nLine three'
|
||||
}
|
||||
],
|
||||
description: 'Line one\r\nLine two\r\nLine three'
|
||||
});
|
||||
});
|
||||
|
||||
it('multiple consecutive @description prefixes stack as annotations on the next row', () => {
|
||||
const input = `
|
||||
headers {
|
||||
@description('''hello''')
|
||||
@description('''hi''')
|
||||
a: b
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.headers).toHaveLength(1);
|
||||
expect(output.headers[0]).toMatchObject({
|
||||
name: 'a',
|
||||
value: 'b',
|
||||
enabled: true,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'hello'
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
value: 'hi'
|
||||
}
|
||||
],
|
||||
description: 'hello'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,3 +22,36 @@ describe('jsonToCollectionBru', () => {
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('description round-trip in collection.bru', () => {
|
||||
it('should round-trip a multiline description on a header', () => {
|
||||
const json = {
|
||||
headers: [{ name: 'Authorization', value: 'Bearer token', enabled: true, description: 'Line 1\nLine 2' }]
|
||||
};
|
||||
const bru = jsonToCollectionBru(json);
|
||||
const parsed = collectionBruToJson(bru);
|
||||
expect(parsed.headers[0].description).toBe('Line 1\nLine 2');
|
||||
expect(parsed.headers[0].value).toBe('Bearer token');
|
||||
});
|
||||
|
||||
it('should round-trip a description on a var with a multiline value', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
req: [{ name: 'myVar', value: 'line1\nline2', enabled: true, description: 'my desc' }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToCollectionBru(json);
|
||||
const parsed = collectionBruToJson(bru);
|
||||
expect(parsed.vars.req[0].description).toBe('my desc');
|
||||
expect(parsed.vars.req[0].value).toBe('line1\nline2');
|
||||
});
|
||||
|
||||
it('should round-trip a description containing triple-quotes', () => {
|
||||
const json = {
|
||||
headers: [{ name: 'X-Token', value: 'abc', enabled: true, description: 'has \'\'\' quotes' }]
|
||||
};
|
||||
const bru = jsonToCollectionBru(json);
|
||||
const parsed = collectionBruToJson(bru);
|
||||
expect(parsed.headers[0].description).toBe('has \'\'\' quotes');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,79 @@ vars {
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should parse @description in vars', () => {
|
||||
const input = `
|
||||
vars {
|
||||
@description('''Base API URL.''')
|
||||
url: http://localhost:3000
|
||||
@description("Server port")
|
||||
port: 3000
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
const expected = {
|
||||
variables: [
|
||||
{
|
||||
name: 'url',
|
||||
value: 'http://localhost:3000',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Base API URL.'
|
||||
}
|
||||
],
|
||||
description: 'Base API URL.'
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
value: '3000',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Server port'
|
||||
}
|
||||
],
|
||||
description: 'Server port'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should parse disabled variable with @description', () => {
|
||||
const input = `
|
||||
vars {
|
||||
@description("Disabled base URL")
|
||||
~url: http://localhost:3000
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
const expected = {
|
||||
variables: [
|
||||
{
|
||||
name: 'url',
|
||||
value: 'http://localhost:3000',
|
||||
enabled: false,
|
||||
secret: false,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Disabled base URL'
|
||||
}
|
||||
],
|
||||
description: 'Disabled base URL'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should parse multiple var lines', () => {
|
||||
const input = `
|
||||
vars {
|
||||
@@ -391,6 +464,116 @@ vars {
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should parse @description with emoji', () => {
|
||||
const input = `
|
||||
vars {
|
||||
@description('''API key 🔐 required''')
|
||||
token: secret
|
||||
@description('''Region 🌍 selector''')
|
||||
region: us-east
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
const expected = {
|
||||
variables: [
|
||||
{
|
||||
name: 'token',
|
||||
value: 'secret',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'API key 🔐 required'
|
||||
}
|
||||
],
|
||||
description: 'API key 🔐 required'
|
||||
},
|
||||
{
|
||||
name: 'region',
|
||||
value: 'us-east',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Region 🌍 selector'
|
||||
}
|
||||
],
|
||||
description: 'Region 🌍 selector'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should parse @description with double-quoted \\n escape sequence as LF', () => {
|
||||
const input = `
|
||||
vars {
|
||||
@description("First\\nSecond\\nThird")
|
||||
note: val
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.variables[0]).toMatchObject({
|
||||
name: 'note',
|
||||
value: 'val',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'First\nSecond\nThird'
|
||||
}
|
||||
],
|
||||
description: 'First\nSecond\nThird'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse @description with double-quoted \\r\\n escape sequence as CRLF', () => {
|
||||
const input = `
|
||||
vars {
|
||||
@description("Line one\\r\\nLine two")
|
||||
note: val
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.variables[0]).toMatchObject({
|
||||
name: 'note',
|
||||
value: 'val',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Line one\r\nLine two'
|
||||
}
|
||||
],
|
||||
description: 'Line one\r\nLine two'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse triple-quoted @description with literal newlines', () => {
|
||||
const input = `
|
||||
vars {
|
||||
@description('''
|
||||
Line one
|
||||
Line two
|
||||
''')
|
||||
note: val
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
expect(output.variables[0]).toMatchObject({
|
||||
name: 'note',
|
||||
value: 'val',
|
||||
annotations: [
|
||||
{
|
||||
name: 'description',
|
||||
value: 'Line one\nLine two'
|
||||
}
|
||||
],
|
||||
description: 'Line one\nLine two'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse multiple multiline variables', () => {
|
||||
const input = `
|
||||
vars {
|
||||
|
||||
@@ -179,6 +179,171 @@ describe('jsonToBru stringify', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('description annotation', () => {
|
||||
it('emits @description with single quotes for headers, params, vars, and assertions when present in JSON', () => {
|
||||
const input = {
|
||||
meta: { name: 'desc-test', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com', body: 'none' },
|
||||
headers: [
|
||||
{ name: 'X-Custom', value: 'val', enabled: true, description: 'Custom header note' }
|
||||
],
|
||||
params: [
|
||||
{ name: 'q', value: 'search', enabled: true, type: 'query', description: 'Query param hint' }
|
||||
],
|
||||
vars: {
|
||||
req: [
|
||||
{ name: 'apiKey', value: 'key123', enabled: true, description: 'Pre-request API key' }
|
||||
]
|
||||
},
|
||||
assertions: [
|
||||
{ name: 'res.status', value: 'eq 200', enabled: true, description: 'Expect OK' }
|
||||
]
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
|
||||
expect(output).toMatch(/@description\('Custom header note'\)\n X-Custom: val/);
|
||||
expect(output).toMatch(/@description\('Query param hint'\)\n q: search/);
|
||||
expect(output).toMatch(/@description\('Pre-request API key'\)\n apiKey: key123/);
|
||||
expect(output).toMatch(/@description\('Expect OK'\)\n res\.status: eq 200/);
|
||||
});
|
||||
|
||||
it('emits triple-quoted description with literal newlines when description is multiline', () => {
|
||||
const input = {
|
||||
meta: { name: 'ml', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com', body: 'none' },
|
||||
headers: [
|
||||
{ name: 'X-Note', value: 'v', enabled: true, description: 'Line one\nLine two' }
|
||||
]
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
|
||||
expect(output).toContain('@description(\'\'\'\n Line one\n Line two\n \'\'\')\n X-Note: v');
|
||||
});
|
||||
|
||||
it('emits double-quoted description when description contains triple quote', () => {
|
||||
const input = {
|
||||
meta: { name: 'tq', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com', body: 'none' },
|
||||
headers: [
|
||||
{ name: 'X-Desc', value: 'v', enabled: true, description: 'Say \'\'\'triple\'\'\'' }
|
||||
]
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
|
||||
expect(output).toMatch(/@description\("Say '''triple'''"\)/);
|
||||
});
|
||||
|
||||
it('emits single-quoted description when value contains double quotes', () => {
|
||||
const input = {
|
||||
meta: { name: 'esc', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com', body: 'none' },
|
||||
headers: [
|
||||
{ name: 'X-Desc', value: 'v', enabled: true, description: 'Say "hello"' }
|
||||
]
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
expect(output).toMatch(/@description\('Say "hello"'\)/);
|
||||
});
|
||||
|
||||
it('emits triple-quoted description with emoji', () => {
|
||||
const input = {
|
||||
meta: { name: 'emoji', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com', body: 'none' },
|
||||
headers: [
|
||||
{ name: 'Authorization', value: 'Bearer xxx', enabled: true, description: 'Auth token 🔑' },
|
||||
{ name: 'X-Region', value: 'us-east', enabled: true, description: 'Region 🌍 selector' }
|
||||
]
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
expect(output).toMatch(/@description\('Auth token 🔑'\)/);
|
||||
expect(output).toMatch(/@description\('Region 🌍 selector'\)/);
|
||||
});
|
||||
|
||||
it('emits multiline triple-quoted description with emoji', () => {
|
||||
const input = {
|
||||
meta: { name: 'emoji-ml', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com', body: 'none' },
|
||||
headers: [
|
||||
{ name: 'X-Launch', value: 'val', enabled: true, description: 'Launch 🚀\nSecond line' }
|
||||
]
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
expect(output).toContain('@description(\'\'\'\n Launch 🚀\n Second line\n \'\'\')\n X-Launch: val');
|
||||
});
|
||||
|
||||
it('emits multiline triple-quoted description with \\n (LF)', () => {
|
||||
const input = {
|
||||
meta: { name: 'lf', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com', body: 'none' },
|
||||
headers: [
|
||||
{ name: 'X-Note', value: 'v', enabled: true, description: 'First\nSecond\nThird' }
|
||||
]
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
expect(output).toContain('@description(\'\'\'\n First\n Second\n Third\n \'\'\')\n X-Note: v');
|
||||
});
|
||||
|
||||
it('emits multiline triple-quoted description with \\r\\n (CRLF) normalized to LF', () => {
|
||||
const input = {
|
||||
meta: { name: 'crlf', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com', body: 'none' },
|
||||
headers: [
|
||||
{ name: 'X-Note', value: 'v', enabled: true, description: 'Line one\r\nLine two' }
|
||||
]
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
// indentString splits on \r\n|\r|\n and rejoins with \n, normalizing Windows CRLF to LF
|
||||
expect(output).toContain('@description(\'\'\'\n Line one\n Line two\n \'\'\')\n X-Note: v');
|
||||
});
|
||||
|
||||
it('emits multiline triple-quoted description with multiple \\r\\n lines normalized to LF', () => {
|
||||
const input = {
|
||||
meta: { name: 'crlf-multi', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com', body: 'none' },
|
||||
headers: [
|
||||
{ name: 'X-Note', value: 'v', enabled: true, description: 'Line one\r\nLine two\r\nLine three' }
|
||||
]
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
expect(output).toContain('@description(\'\'\'\n Line one\n Line two\n Line three\n \'\'\')\n X-Note: v');
|
||||
});
|
||||
|
||||
it('emits single line single-quoted description when single lined', () => {
|
||||
const input = {
|
||||
meta: { name: 'single-line-single-quoted', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com', body: 'none' },
|
||||
headers: [
|
||||
{ name: 'X-Note', value: 'v', enabled: true, description: 'Line one' }
|
||||
]
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
expect(output).toContain('@description(\'Line one\'');
|
||||
});
|
||||
|
||||
it('emits multiline triple-quoted description when multi line description', () => {
|
||||
const input = {
|
||||
meta: { name: 'multi-line-triple-quoted', type: 'http', seq: 1 },
|
||||
http: { method: 'get', url: 'https://example.com', body: 'none' },
|
||||
headers: [
|
||||
{ name: 'X-Note', value: 'v', enabled: true, description: 'Line one\nLine two\nLine three' }
|
||||
]
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
expect(output).toContain('@description(\'\'\'\n Line one\n Line two\n Line three\n \'\'\')\n X-Note: v');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vars:pre-request dataType decorators', () => {
|
||||
const baseMeta = { name: 'test', type: 'http', seq: 1 };
|
||||
const baseHttp = { method: 'get', url: 'http://localhost' };
|
||||
|
||||
@@ -58,6 +58,108 @@ describe('jsonToEnv', () => {
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should stringify description in vars', () => {
|
||||
const input = {
|
||||
variables: [
|
||||
{
|
||||
name: 'url',
|
||||
value: 'http://localhost:3000',
|
||||
enabled: true,
|
||||
description: 'Base API URL.'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const output = parser(input);
|
||||
expect(output).toEqual(`vars {
|
||||
@description('Base API URL.')
|
||||
url: http://localhost:3000
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should stringify multiline description with triple-quoted literal newlines', () => {
|
||||
const input = {
|
||||
variables: [
|
||||
{
|
||||
name: 'url',
|
||||
value: 'http://localhost:3000',
|
||||
enabled: true,
|
||||
description: 'Line one\nLine two'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const output = parser(input);
|
||||
expect(output).toContain('@description(\'\'\'\n Line one\n Line two\n \'\'\')');
|
||||
});
|
||||
|
||||
it('should stringify description with emoji', () => {
|
||||
const input = {
|
||||
variables: [
|
||||
{
|
||||
name: 'token',
|
||||
value: 'secret',
|
||||
enabled: true,
|
||||
description: 'API key 🔐 required'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const output = parser(input);
|
||||
expect(output).toContain('@description(\'API key 🔐 required\')');
|
||||
expect(output).toContain('token: secret');
|
||||
});
|
||||
|
||||
it('should stringify multiline description with emoji using triple-quoted literal newlines', () => {
|
||||
const input = {
|
||||
variables: [
|
||||
{
|
||||
name: 'note',
|
||||
value: 'val',
|
||||
enabled: true,
|
||||
description: 'Launch 🚀\nSecond line'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const output = parser(input);
|
||||
expect(output).toContain('@description(\'\'\'\n Launch 🚀\n Second line\n \'\'\')');
|
||||
});
|
||||
|
||||
it('should stringify description with LF (\\n) as multiline triple-quoted', () => {
|
||||
const input = {
|
||||
variables: [
|
||||
{
|
||||
name: 'note',
|
||||
value: 'val',
|
||||
enabled: true,
|
||||
description: 'First\nSecond\nThird'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const output = parser(input);
|
||||
expect(output).toContain('@description(\'\'\'\n First\n Second\n Third\n \'\'\')');
|
||||
});
|
||||
|
||||
it('should stringify description with CRLF (\\r\\n) as multiline triple-quoted with LF normalization', () => {
|
||||
const input = {
|
||||
variables: [
|
||||
{
|
||||
name: 'note',
|
||||
value: 'val',
|
||||
enabled: true,
|
||||
description: 'Line one\r\nLine two'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const output = parser(input);
|
||||
// indentString normalizes \r\n to \n when serializing
|
||||
expect(output).toContain('@description(\'\'\'\n Line one\n Line two\n \'\'\')');
|
||||
});
|
||||
|
||||
it('should stringify secret vars', () => {
|
||||
const input = {
|
||||
variables: [
|
||||
@@ -141,6 +243,30 @@ vars:secret [
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should emit @description prefix even for multiline variables', () => {
|
||||
const input = {
|
||||
variables: [
|
||||
{
|
||||
name: 'json_data',
|
||||
value: '{\n "name": "test"\n}',
|
||||
enabled: true,
|
||||
description: 'A multiline JSON blob'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const output = parser(input);
|
||||
expect(output).toEqual(`vars {
|
||||
@description('A multiline JSON blob')
|
||||
json_data: '''
|
||||
{
|
||||
"name": "test"
|
||||
}
|
||||
'''
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should stringify multiline variables', () => {
|
||||
const input = {
|
||||
variables: [
|
||||
|
||||
@@ -2,7 +2,9 @@ const {
|
||||
getValueString,
|
||||
extractTypedAnnotations,
|
||||
buildAnnotationsFromVariable,
|
||||
serializeAnnotations
|
||||
serializeAnnotations,
|
||||
escapeMultilineDescription,
|
||||
unescapeMultilineDescription
|
||||
} = require('../src/utils');
|
||||
|
||||
describe('getValueString', () => {
|
||||
@@ -142,15 +144,15 @@ describe('serializeAnnotations', () => {
|
||||
expect(serializeAnnotations([{ name: 'number' }])).toBe('@number\n');
|
||||
});
|
||||
|
||||
it('serializes a string-valued annotation using single-quote delimiters by default', () => {
|
||||
it('serializes a non-empty description annotation using single-quote delimiters', () => {
|
||||
expect(serializeAnnotations([{ name: 'description', value: 'a doc' }])).toBe('@description(\'a doc\')\n');
|
||||
});
|
||||
|
||||
it('switches to double-quote delimiters when the value contains a single quote', () => {
|
||||
it('uses double-quote delimiters for description when value contains a single quote', () => {
|
||||
expect(serializeAnnotations([{ name: 'description', value: 'O\'Reilly' }])).toBe('@description("O\'Reilly")\n');
|
||||
});
|
||||
|
||||
it('keeps single-quote delimiters when the value contains a double quote', () => {
|
||||
it('uses single-quote delimiters for description when value contains a double quote', () => {
|
||||
expect(serializeAnnotations([{ name: 'description', value: 'say "hi"' }])).toBe('@description(\'say "hi"\')\n');
|
||||
});
|
||||
|
||||
@@ -175,9 +177,53 @@ describe('serializeAnnotations', () => {
|
||||
});
|
||||
|
||||
it('treats null/empty-string values as present (not as missing)', () => {
|
||||
// `a.value === undefined` is the only branch that renders without parentheses,
|
||||
// so null and '' both serialize as quoted empty-ish values.
|
||||
// null coerces to 'null' (non-empty), so gets single-quote format
|
||||
expect(serializeAnnotations([{ name: 'description', value: null }])).toBe('@description(\'null\')\n');
|
||||
// empty string falls through to standard single-quote format
|
||||
expect(serializeAnnotations([{ name: 'description', value: '' }])).toBe('@description(\'\')\n');
|
||||
});
|
||||
|
||||
it('escapes embedded triple quotes in a multiline description', () => {
|
||||
expect(serializeAnnotations([{ name: 'description', value: 'line1\nline2 with \'\'\' inside\nline3' }])).toBe(
|
||||
'@description(\'\'\'\n line1\n line2 with \\\'\\\'\\\' inside\n line3\n\'\'\')\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('doubles a pre-existing backslash-quote before escaping triple quotes in a multiline description', () => {
|
||||
expect(serializeAnnotations([{ name: 'description', value: 'it\\\'s multiline\nand has \'\'\' too' }])).toBe(
|
||||
'@description(\'\'\'\n it\\\\\'s multiline\n and has \\\'\\\'\\\' too\n\'\'\')\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not escape triple quotes on other (non-description) multiline annotations', () => {
|
||||
expect(serializeAnnotations([{ name: 'note', value: 'line1 \'\'\'\nline2' }])).toBe(
|
||||
'@note(\'\'\'\n line1 \'\'\'\n line2\n\'\'\')\n'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeMultilineDescription / unescapeMultilineDescription', () => {
|
||||
it('escapes a literal triple-quote sequence', () => {
|
||||
expect(escapeMultilineDescription('has \'\'\' inside')).toBe('has \\\'\\\'\\\' inside');
|
||||
});
|
||||
|
||||
it('doubles a pre-existing backslash-quote sequence', () => {
|
||||
expect(escapeMultilineDescription('it\\\'s here')).toBe('it\\\\\'s here');
|
||||
});
|
||||
|
||||
it('round-trips a value containing both a backslash-quote and a triple-quote', () => {
|
||||
const original = 'it\\\'s \'\'\' here';
|
||||
const escaped = escapeMultilineDescription(original);
|
||||
expect(unescapeMultilineDescription(escaped)).toBe(original);
|
||||
});
|
||||
|
||||
it('leaves plain text untouched', () => {
|
||||
expect(escapeMultilineDescription('plain text')).toBe('plain text');
|
||||
expect(unescapeMultilineDescription('plain text')).toBe('plain text');
|
||||
});
|
||||
|
||||
it('unescapeMultilineDescription reverses escapeMultilineDescription', () => {
|
||||
const original = 'line1\nline2 with \'\'\' and \\\'quoted\\\' bits\nline3';
|
||||
expect(unescapeMultilineDescription(escapeMultilineDescription(original))).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface EnvironmentVariable {
|
||||
type: 'text';
|
||||
enabled?: boolean;
|
||||
secret?: boolean;
|
||||
description?: string | null;
|
||||
dataType?: EnvironmentVariableDataType;
|
||||
annotations?: Annotation[] | null;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface Item {
|
||||
type: ItemType;
|
||||
seq?: number | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
tags?: string[] | null;
|
||||
request?: Request | null;
|
||||
settings?: ItemSettings;
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@usebruno/common": "0.1.0",
|
||||
"nanoid": "3.3.8",
|
||||
"yup": "^0.32.11"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const Yup = require('yup');
|
||||
const { BRUNO_VARIABLE_DATATYPES } = require('@usebruno/common/utils');
|
||||
const { uidSchema } = require('../common');
|
||||
|
||||
const BRUNO_VARIABLE_DATATYPES = ['string', 'number', 'boolean', 'object'];
|
||||
|
||||
const annotationSchema = Yup.object({
|
||||
name: Yup.string().min(1).required('annotation name is required'),
|
||||
value: Yup.string().nullable()
|
||||
@@ -21,6 +22,7 @@ const environmentVariablesSchema = Yup.object({
|
||||
type: Yup.string().oneOf(['text']).required('type is required'),
|
||||
enabled: Yup.boolean().defined(),
|
||||
secret: Yup.boolean(),
|
||||
description: Yup.string().nullable(),
|
||||
dataType: Yup.string().oneOf(BRUNO_VARIABLE_DATATYPES).nullable()
|
||||
})
|
||||
.noUnknown(true)
|
||||
@@ -654,6 +656,7 @@ const itemSchema = Yup.object({
|
||||
seq: Yup.number().min(1),
|
||||
name: Yup.string().min(1, 'name must be at least 1 character').required('name is required'),
|
||||
tags: Yup.array().of(Yup.string().min(1, 'tag must not be empty')),
|
||||
description: Yup.string().nullable(),
|
||||
request: Yup.mixed().when('type', {
|
||||
is: (type) => type === 'grpc-request',
|
||||
then: grpcRequestSchema.required('request is required when item-type is grpc-request'),
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
headers {
|
||||
@description('''asd''')
|
||||
check: again
|
||||
@description('''
|
||||
asd
|
||||
asd
|
||||
asd
|
||||
''')
|
||||
token: {{collection_pre_var_token}}
|
||||
@description('''asd''')
|
||||
collection-header: collection-header-value
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user