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:
Sid
2026-07-07 21:43:39 +05:30
committed by GitHub
parent 958ca49156
commit 0bba66a590
177 changed files with 6570 additions and 482 deletions

View File

@@ -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}

View File

@@ -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 } : {})
};

View File

@@ -14,6 +14,11 @@ const StyledWrapper = styled.div`
.caret-icon {
opacity: 0.7;
}
.type-icon {
opacity: 0.7;
flex-shrink: 0;
}
`;
export default StyledWrapper;

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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` } : {})}
/>
)
});

View File

@@ -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"

View File

@@ -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%;
}
}

View File

@@ -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 &ldquo;{searchQuery.trim()}&rdquo;</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 && (

View File

@@ -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 = {

View File

@@ -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 } : {})
};

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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}

View File

@@ -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={() => {}}

View File

@@ -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}

View File

@@ -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 } : {})
};

View File

@@ -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>

View File

@@ -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;

View 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;

View File

@@ -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
}));
}
};

View File

@@ -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
}));
};

View File

@@ -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 } : {}),

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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', () => {

View File

@@ -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': {

View File

@@ -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' })
])
);
});
});

View File

@@ -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();

View File

@@ -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', () => {

View File

@@ -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');
});
});

View File

@@ -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;
})
};
}

View File

@@ -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 => {

View File

@@ -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 => {

View File

@@ -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

View File

@@ -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 })
};

View File

@@ -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');
});
});
});

View File

@@ -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,

View File

@@ -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;
});

View File

@@ -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 = {};

View File

@@ -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;
};

View File

@@ -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 = {};

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;
});
};

View File

@@ -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;
});

View File

@@ -13,7 +13,6 @@
"test": "jest"
},
"dependencies": {
"@usebruno/common": "0.1.0",
"arcsecond": "^5.0.0",
"dotenv": "^16.3.1",
"lodash": "4.18.1",

View File

@@ -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;

View File

@@ -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 '';

View File

@@ -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('|');

View File

@@ -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 '';
},

View File

@@ -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')
)}`;
}

View File

@@ -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')
)}
}

View File

@@ -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
};

View File

@@ -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', () => {

View File

@@ -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'
});
});
});
});

View File

@@ -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');
});
});

View File

@@ -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 {

View File

@@ -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' };

View File

@@ -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: [

View File

@@ -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);
});
});

View File

@@ -9,6 +9,7 @@ export interface EnvironmentVariable {
type: 'text';
enabled?: boolean;
secret?: boolean;
description?: string | null;
dataType?: EnvironmentVariableDataType;
annotations?: Annotation[] | null;
}

View File

@@ -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;

View File

@@ -11,7 +11,6 @@
"test": "jest"
},
"dependencies": {
"@usebruno/common": "0.1.0",
"nanoid": "3.3.8",
"yup": "^0.32.11"
}

View File

@@ -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'),

View File

@@ -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
}