diff --git a/package-lock.json b/package-lock.json index 926530868..eab4aa9b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36170,7 +36170,6 @@ "version": "0.12.0", "license": "MIT", "dependencies": { - "@usebruno/common": "0.1.0", "arcsecond": "^5.0.0", "dotenv": "^16.3.1", "lodash": "4.18.1", @@ -36397,7 +36396,6 @@ "version": "0.7.0", "license": "MIT", "dependencies": { - "@usebruno/common": "0.1.0", "nanoid": "3.3.8", "yup": "^0.32.11" } diff --git a/packages/bruno-app/src/components/CollectionSettings/Headers/index.js b/packages/bruno-app/src/components/CollectionSettings/Headers/index.js index df2047c33..3ac5ec361 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Headers/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Headers/index.js @@ -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 }) => ( { placeholder={!value ? 'Value' : ''} /> ) - } + }, + descriptionColumn ]; const defaultRow = { @@ -131,6 +140,7 @@ const Headers = ({ collection }) => { { 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 }) => { ), placeholder: varType === 'request' ? 'Value' : 'Expr', - render: ({ row, value, onChange, isLastEmptyRow }) => ( -
-
+ render: ({ row, value, onChange, isLastEmptyRow, rowIndex }) => ( + -
- {/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */} - {!isLastEmptyRow && varType === 'request' && ( - { - const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v); - handleVarsChange(updated); - }} - /> )} -
+ renderTypeSelector={!isLastEmptyRow && varType === 'request' + ? ({ compact }) => ( + { + 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 } : {}) }; diff --git a/packages/bruno-app/src/components/DataTypeSelector/StyledWrapper.js b/packages/bruno-app/src/components/DataTypeSelector/StyledWrapper.js index 3eb46d752..f8e45321a 100644 --- a/packages/bruno-app/src/components/DataTypeSelector/StyledWrapper.js +++ b/packages/bruno-app/src/components/DataTypeSelector/StyledWrapper.js @@ -14,6 +14,11 @@ const StyledWrapper = styled.div` .caret-icon { opacity: 0.7; } + + .type-icon { + opacity: 0.7; + flex-shrink: 0; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/DataTypeSelector/index.js b/packages/bruno-app/src/components/DataTypeSelector/index.js index 4bfafa4b6..3b5b6aca3 100644 --- a/packages/bruno-app/src/components/DataTypeSelector/index.js +++ b/packages/bruno-app/src/components/DataTypeSelector/index.js @@ -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 ( -
+
document.body} + data-testid="datatype-selector-trigger" > -
- {selectedType} +
+ {compact ? ( + + ) : ( + {selectedType} + )}
diff --git a/packages/bruno-app/src/components/EditableTable/StyledWrapper.js b/packages/bruno-app/src/components/EditableTable/StyledWrapper.js index c15cde52a..dd59fc690 100644 --- a/packages/bruno-app/src/components/EditableTable/StyledWrapper.js +++ b/packages/bruno-app/src/components/EditableTable/StyledWrapper.js @@ -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; } } } diff --git a/packages/bruno-app/src/components/EditableTable/descriptionColumn.js b/packages/bruno-app/src/components/EditableTable/descriptionColumn.js new file mode 100644 index 000000000..9fc96cec2 --- /dev/null +++ b/packages/bruno-app/src/components/EditableTable/descriptionColumn.js @@ -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 }) => ( + onDescriptionChange(newValue, { row, onChange }) + : onChange + } + {...(onRun ? { onRun } : {})} + {...(collection ? { collection } : {})} + {...(item ? { item } : {})} + {...(nameFromRowIndex && rowIndex !== undefined ? { name: `${rowIndex}.description` } : {})} + /> + ) +}); diff --git a/packages/bruno-app/src/components/EditableTable/index.js b/packages/bruno-app/src/components/EditableTable/index.js index cc69495e0..214b2afd0 100644 --- a/packages/bruno-app/src/components/EditableTable/index.js +++ b/packages/bruno-app/src/components/EditableTable/index.js @@ -380,7 +380,11 @@ const EditableTable = ({ ))} {showDelete && ( - + + )} ), [showCheckbox, checkboxLabel, columns, getColumnWidth, resizing, tableHeight, handleResizeStart, showDelete]); @@ -426,7 +430,19 @@ const EditableTable = ({ ))} {showDelete && ( - + { + 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 && (
), placeholder: varType === 'request' ? 'Value' : 'Expr', - render: ({ row, value, onChange, isLastEmptyRow }) => ( -
-
+ render: ({ row, value, onChange, isLastEmptyRow, rowIndex }) => ( + item={folder} placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''} /> -
- {/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */} - {!isLastEmptyRow && varType === 'request' && ( - { - const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v); - handleVarsChange(updated); - }} - /> )} -
+ renderTypeSelector={!isLastEmptyRow && varType === 'request' + ? ({ compact }) => ( + { + 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 } : {}) }; diff --git a/packages/bruno-app/src/components/MultiLineEditor/SecretEyeButton.js b/packages/bruno-app/src/components/MultiLineEditor/SecretEyeButton.js new file mode 100644 index 000000000..76a99b2d8 --- /dev/null +++ b/packages/bruno-app/src/components/MultiLineEditor/SecretEyeButton.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { IconEye, IconEyeOff } from '@tabler/icons'; + +const SecretEyeButton = ({ masked, onToggle }) => ( + +); + +export default SecretEyeButton; diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js index f9636b6f1..1113ece03 100644 --- a/packages/bruno-app/src/components/MultiLineEditor/index.js +++ b/packages/bruno-app/src/components/MultiLineEditor/index.js @@ -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 ( -
+
- {this.secretEye(this.props.isSecret)} + {!this.props.hideSecretEye && this.secretEye(this.props.isSecret)}
); } diff --git a/packages/bruno-app/src/components/RequestPane/Assertions/index.js b/packages/bruno-app/src/components/RequestPane/Assertions/index.js index a7cdc19d2..246176f26 100644 --- a/packages/bruno-app/src/components/RequestPane/Assertions/index.js +++ b/packages/bruno-app/src/components/RequestPane/Assertions/index.js @@ -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 ( diff --git a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js index 411e6e8bf..84332a899 100644 --- a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js @@ -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 }) => { { 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 ( { })); }, [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 (
-
Query
+
+ Query +
{ {pathParams && pathParams.length > 0 ? ( {}} diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js index f867772f8..07dbc36ad 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js @@ -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 }) => ( { placeholder={!value ? 'Value' : ''} /> ) - } + }, + descriptionColumn ]; const defaultRow = { @@ -140,6 +150,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => { { 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 }) => {
), placeholder: varType === 'request' ? 'Value' : 'Expr', - render: ({ row, value, onChange, isLastEmptyRow }) => ( -
-
+ render: ({ row, value, onChange, isLastEmptyRow, rowIndex }) => ( + { item={item} placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''} /> -
- {/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */} - {!isLastEmptyRow && varType === 'request' && ( - { - const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v); - handleVarsChange(updated); - }} - /> )} -
+ renderTypeSelector={!isLastEmptyRow && varType === 'request' + ? ({ compact }) => ( + { + 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 } : {}) }; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 6727efaa9..2795bb453 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -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 && (
{ - e.preventDefault(); - resetPaneBoundaries(); - }} - onMouseDown={startDragging} + onMouseDown={handleDragbarMouseDown} >
diff --git a/packages/bruno-app/src/components/VarValueCell/StyledWrapper.js b/packages/bruno-app/src/components/VarValueCell/StyledWrapper.js new file mode 100644 index 000000000..dcc62a868 --- /dev/null +++ b/packages/bruno-app/src/components/VarValueCell/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/VarValueCell/index.js b/packages/bruno-app/src/components/VarValueCell/index.js new file mode 100644 index 000000000..bca981e98 --- /dev/null +++ b/packages/bruno-app/src/components/VarValueCell/index.js @@ -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 ? ( +
+ {renderTypeSelector({ compact: true })} +
+ ) : null; + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > +
+ {editor} +
+ {compact && trailingContent ? ( +
+ {typeSelectorOverlay} + {trailingContent} +
+ ) : ( + <> + {typeSelectorOverlay} + {!compact && renderTypeSelector && renderTypeSelector({ compact: false })} + + )} +
+ ); +}; + +export default VarValueCell; diff --git a/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js b/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js index 02d18d40f..030b23ec3 100644 --- a/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js +++ b/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js @@ -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 })); } }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js index 55f38e1a2..3ca512d05 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/exampleReducers.js @@ -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 })); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 178c1182d..d73fbe328 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -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 } : {}), diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 370243ab5..8ee1dc0f1 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -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, diff --git a/packages/bruno-app/src/utils/environments.js b/packages/bruno-app/src/utils/environments.js index 2ee5276a4..76c151034 100644 --- a/packages/bruno-app/src/utils/environments.js +++ b/packages/bruno-app/src/utils/environments.js @@ -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; } diff --git a/packages/bruno-app/src/utils/environments.spec.js b/packages/bruno-app/src/utils/environments.spec.js index 6118fa4e4..c3f47f126 100644 --- a/packages/bruno-app/src/utils/environments.spec.js +++ b/packages/bruno-app/src/utils/environments.spec.js @@ -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', () => { diff --git a/packages/bruno-app/src/utils/exporters/openapi-spec.js b/packages/bruno-app/src/utils/exporters/openapi-spec.js index 4613add40..4d16555d0 100644 --- a/packages/bruno-app/src/utils/exporters/openapi-spec.js +++ b/packages/bruno-app/src/utils/exporters/openapi-spec.js @@ -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': { diff --git a/packages/bruno-app/src/utils/exporters/openapi-spec.spec.js b/packages/bruno-app/src/utils/exporters/openapi-spec.spec.js index 6cbb122d8..c5539c033 100644 --- a/packages/bruno-app/src/utils/exporters/openapi-spec.spec.js +++ b/packages/bruno-app/src/utils/exporters/openapi-spec.spec.js @@ -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' }) + ]) + ); + }); +}); diff --git a/packages/bruno-app/src/utils/importers/common.js b/packages/bruno-app/src/utils/importers/common.js index 1643b96b7..b177a8ef7 100644 --- a/packages/bruno-app/src/utils/importers/common.js +++ b/packages/bruno-app/src/utils/importers/common.js @@ -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(); diff --git a/packages/bruno-app/src/utils/tests/collections/datatype-export-import.spec.js b/packages/bruno-app/src/utils/tests/collections/datatype-export-import.spec.js index e7d8c82f2..64e21c616 100644 --- a/packages/bruno-app/src/utils/tests/collections/datatype-export-import.spec.js +++ b/packages/bruno-app/src/utils/tests/collections/datatype-export-import.spec.js @@ -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', () => { diff --git a/packages/bruno-app/src/utils/tests/collections/examples-export-import.spec.js b/packages/bruno-app/src/utils/tests/collections/examples-export-import.spec.js index 25eac27b0..f85f9d7cf 100644 --- a/packages/bruno-app/src/utils/tests/collections/examples-export-import.spec.js +++ b/packages/bruno-app/src/utils/tests/collections/examples-export-import.spec.js @@ -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'); + }); +}); diff --git a/packages/bruno-converters/src/opencollection/common/body.ts b/packages/bruno-converters/src/opencollection/common/body.ts index 2b473530e..aae0a1909 100644 --- a/packages/bruno-converters/src/opencollection/common/body.ts +++ b/packages/bruno-converters/src/opencollection/common/body.ts @@ -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; + }) }; } diff --git a/packages/bruno-converters/src/opencollection/common/headers.ts b/packages/bruno-converters/src/opencollection/common/headers.ts index d845ecc2e..49b3d2855 100644 --- a/packages/bruno-converters/src/opencollection/common/headers.ts +++ b/packages/bruno-converters/src/opencollection/common/headers.ts @@ -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 => { diff --git a/packages/bruno-converters/src/opencollection/common/params.ts b/packages/bruno-converters/src/opencollection/common/params.ts index 53a69b6d2..bc827ad06 100644 --- a/packages/bruno-converters/src/opencollection/common/params.ts +++ b/packages/bruno-converters/src/opencollection/common/params.ts @@ -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 => { diff --git a/packages/bruno-converters/src/opencollection/environment.ts b/packages/bruno-converters/src/opencollection/environment.ts index 954625dd6..3f09bbe30 100644 --- a/packages/bruno-converters/src/opencollection/environment.ts +++ b/packages/bruno-converters/src/opencollection/environment.ts @@ -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 diff --git a/packages/bruno-converters/src/postman/bruno-to-postman.js b/packages/bruno-converters/src/postman/bruno-to-postman.js index d99d9fd11..042a2c8e8 100644 --- a/packages/bruno-converters/src/postman/bruno-to-postman.js +++ b/packages/bruno-converters/src/postman/bruno-to-postman.js @@ -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 }) }; diff --git a/packages/bruno-converters/tests/opencollection/description.spec.js b/packages/bruno-converters/tests/opencollection/description.spec.js new file mode 100644 index 000000000..996f3ddcb --- /dev/null +++ b/packages/bruno-converters/tests/opencollection/description.spec.js @@ -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'); + }); + }); +}); diff --git a/packages/bruno-converters/tests/postman/bruno-to-postman.spec.js b/packages/bruno-converters/tests/postman/bruno-to-postman.spec.js index c2b372d38..6a17f4272 100644 --- a/packages/bruno-converters/tests/postman/bruno-to-postman.spec.js +++ b/packages/bruno-converters/tests/postman/bruno-to-postman.spec.js @@ -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, diff --git a/packages/bruno-filestore/src/formats/yml/common/headers.ts b/packages/bruno-filestore/src/formats/yml/common/headers.ts index 0f5e3b354..f7be581ba 100644 --- a/packages/bruno-filestore/src/formats/yml/common/headers.ts +++ b/packages/bruno-filestore/src/formats/yml/common/headers.ts @@ -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; }); diff --git a/packages/bruno-filestore/src/formats/yml/items/parseGraphQLRequest.ts b/packages/bruno-filestore/src/formats/yml/items/parseGraphQLRequest.ts index dc698bc20..5540537af 100644 --- a/packages/bruno-filestore/src/formats/yml/items/parseGraphQLRequest.ts +++ b/packages/bruno-filestore/src/formats/yml/items/parseGraphQLRequest.ts @@ -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 = {}; diff --git a/packages/bruno-filestore/src/formats/yml/items/parseGrpcRequest.ts b/packages/bruno-filestore/src/formats/yml/items/parseGrpcRequest.ts index 9ed478f7d..c8903155d 100644 --- a/packages/bruno-filestore/src/formats/yml/items/parseGrpcRequest.ts +++ b/packages/bruno-filestore/src/formats/yml/items/parseGrpcRequest.ts @@ -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; }; diff --git a/packages/bruno-filestore/src/formats/yml/items/parseHttpRequest.ts b/packages/bruno-filestore/src/formats/yml/items/parseHttpRequest.ts index ce502af10..a261ee36c 100644 --- a/packages/bruno-filestore/src/formats/yml/items/parseHttpRequest.ts +++ b/packages/bruno-filestore/src/formats/yml/items/parseHttpRequest.ts @@ -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 = {}; diff --git a/packages/bruno-filestore/src/formats/yml/items/parseWebsocketRequest.ts b/packages/bruno-filestore/src/formats/yml/items/parseWebsocketRequest.ts index 79cceb416..cdf5a0cb4 100644 --- a/packages/bruno-filestore/src/formats/yml/items/parseWebsocketRequest.ts +++ b/packages/bruno-filestore/src/formats/yml/items/parseWebsocketRequest.ts @@ -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; }; diff --git a/packages/bruno-filestore/src/formats/yml/items/stringifyGraphQLRequest.ts b/packages/bruno-filestore/src/formats/yml/items/stringifyGraphQLRequest.ts index 1e620223c..7fa5f7063 100644 --- a/packages/bruno-filestore/src/formats/yml/items/stringifyGraphQLRequest.ts +++ b/packages/bruno-filestore/src/formats/yml/items/stringifyGraphQLRequest.ts @@ -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 diff --git a/packages/bruno-filestore/src/formats/yml/items/stringifyGrpcRequest.ts b/packages/bruno-filestore/src/formats/yml/items/stringifyGrpcRequest.ts index 469584618..3f0b7dbf9 100644 --- a/packages/bruno-filestore/src/formats/yml/items/stringifyGrpcRequest.ts +++ b/packages/bruno-filestore/src/formats/yml/items/stringifyGrpcRequest.ts @@ -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 diff --git a/packages/bruno-filestore/src/formats/yml/items/stringifyHttpRequest.ts b/packages/bruno-filestore/src/formats/yml/items/stringifyHttpRequest.ts index a23305b4b..041a40704 100644 --- a/packages/bruno-filestore/src/formats/yml/items/stringifyHttpRequest.ts +++ b/packages/bruno-filestore/src/formats/yml/items/stringifyHttpRequest.ts @@ -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 diff --git a/packages/bruno-filestore/src/formats/yml/items/stringifyWebsocketRequest.ts b/packages/bruno-filestore/src/formats/yml/items/stringifyWebsocketRequest.ts index 0d3ead549..c77b00be6 100644 --- a/packages/bruno-filestore/src/formats/yml/items/stringifyWebsocketRequest.ts +++ b/packages/bruno-filestore/src/formats/yml/items/stringifyWebsocketRequest.ts @@ -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 diff --git a/packages/bruno-filestore/src/formats/yml/parseEnvironment.ts b/packages/bruno-filestore/src/formats/yml/parseEnvironment.ts index f66cf56fc..95c382392 100644 --- a/packages/bruno-filestore/src/formats/yml/parseEnvironment.ts +++ b/packages/bruno-filestore/src/formats/yml/parseEnvironment.ts @@ -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; }); }; diff --git a/packages/bruno-filestore/src/formats/yml/stringifyEnvironment.ts b/packages/bruno-filestore/src/formats/yml/stringifyEnvironment.ts index 6a0ca84e1..4d4257cde 100644 --- a/packages/bruno-filestore/src/formats/yml/stringifyEnvironment.ts +++ b/packages/bruno-filestore/src/formats/yml/stringifyEnvironment.ts @@ -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; }); diff --git a/packages/bruno-lang/package.json b/packages/bruno-lang/package.json index b03d8f045..51973a1db 100644 --- a/packages/bruno-lang/package.json +++ b/packages/bruno-lang/package.json @@ -13,7 +13,6 @@ "test": "jest" }, "dependencies": { - "@usebruno/common": "0.1.0", "arcsecond": "^5.0.0", "dotenv": "^16.3.1", "lodash": "4.18.1", diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index 67c70e032..ddc2a35fc 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -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; diff --git a/packages/bruno-lang/v2/src/collectionBruToJson.js b/packages/bruno-lang/v2/src/collectionBruToJson.js index b53d490ad..8ae6a66de 100644 --- a/packages/bruno-lang/v2/src/collectionBruToJson.js +++ b/packages/bruno-lang/v2/src/collectionBruToJson.js @@ -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 ''; diff --git a/packages/bruno-lang/v2/src/common/semantic-utils.js b/packages/bruno-lang/v2/src/common/semantic-utils.js index 7e87f87e2..380c74a32 100644 --- a/packages/bruno-lang/v2/src/common/semantic-utils.js +++ b/packages/bruno-lang/v2/src/common/semantic-utils.js @@ -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('|'); diff --git a/packages/bruno-lang/v2/src/envToJson.js b/packages/bruno-lang/v2/src/envToJson.js index 680fd1dcc..c93010b57 100644 --- a/packages/bruno-lang/v2/src/envToJson.js +++ b/packages/bruno-lang/v2/src/envToJson.js @@ -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 ''; }, diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index da2cdfa50..bd627c6d0 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -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') )}`; } diff --git a/packages/bruno-lang/v2/src/jsonToCollectionBru.js b/packages/bruno-lang/v2/src/jsonToCollectionBru.js index 2bb5c646f..ebd16ada6 100644 --- a/packages/bruno-lang/v2/src/jsonToCollectionBru.js +++ b/packages/bruno-lang/v2/src/jsonToCollectionBru.js @@ -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') )} } diff --git a/packages/bruno-lang/v2/src/utils.js b/packages/bruno-lang/v2/src/utils.js index 95ca31e1a..50591940f 100644 --- a/packages/bruno-lang/v2/src/utils.js +++ b/packages/bruno-lang/v2/src/utils.js @@ -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 }; diff --git a/packages/bruno-lang/v2/tests/annotations.spec.js b/packages/bruno-lang/v2/tests/annotations.spec.js index 5f6a86031..ace21fa5e 100644 --- a/packages/bruno-lang/v2/tests/annotations.spec.js +++ b/packages/bruno-lang/v2/tests/annotations.spec.js @@ -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', () => { diff --git a/packages/bruno-lang/v2/tests/bruToJson.spec.js b/packages/bruno-lang/v2/tests/bruToJson.spec.js index e623edf0f..fdd0f4709 100644 --- a/packages/bruno-lang/v2/tests/bruToJson.spec.js +++ b/packages/bruno-lang/v2/tests/bruToJson.spec.js @@ -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' + }); + }); + }); }); diff --git a/packages/bruno-lang/v2/tests/collection.spec.js b/packages/bruno-lang/v2/tests/collection.spec.js index 4bdb7f9dc..b7a76073f 100644 --- a/packages/bruno-lang/v2/tests/collection.spec.js +++ b/packages/bruno-lang/v2/tests/collection.spec.js @@ -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'); + }); +}); diff --git a/packages/bruno-lang/v2/tests/envToJson.spec.js b/packages/bruno-lang/v2/tests/envToJson.spec.js index dfb345926..ebd7f1068 100644 --- a/packages/bruno-lang/v2/tests/envToJson.spec.js +++ b/packages/bruno-lang/v2/tests/envToJson.spec.js @@ -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 { diff --git a/packages/bruno-lang/v2/tests/jsonToBru.spec.js b/packages/bruno-lang/v2/tests/jsonToBru.spec.js index 40720f4a0..c72ecdd52 100644 --- a/packages/bruno-lang/v2/tests/jsonToBru.spec.js +++ b/packages/bruno-lang/v2/tests/jsonToBru.spec.js @@ -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' }; diff --git a/packages/bruno-lang/v2/tests/jsonToEnv.spec.js b/packages/bruno-lang/v2/tests/jsonToEnv.spec.js index 551dfbd23..a5a738e1e 100644 --- a/packages/bruno-lang/v2/tests/jsonToEnv.spec.js +++ b/packages/bruno-lang/v2/tests/jsonToEnv.spec.js @@ -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: [ diff --git a/packages/bruno-lang/v2/tests/utils.spec.js b/packages/bruno-lang/v2/tests/utils.spec.js index 1a6fab3e4..a3e4dc850 100644 --- a/packages/bruno-lang/v2/tests/utils.spec.js +++ b/packages/bruno-lang/v2/tests/utils.spec.js @@ -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); + }); }); diff --git a/packages/bruno-schema-types/src/collection/environment.ts b/packages/bruno-schema-types/src/collection/environment.ts index 8f92c88d1..96d3344d4 100644 --- a/packages/bruno-schema-types/src/collection/environment.ts +++ b/packages/bruno-schema-types/src/collection/environment.ts @@ -9,6 +9,7 @@ export interface EnvironmentVariable { type: 'text'; enabled?: boolean; secret?: boolean; + description?: string | null; dataType?: EnvironmentVariableDataType; annotations?: Annotation[] | null; } diff --git a/packages/bruno-schema-types/src/collection/item.ts b/packages/bruno-schema-types/src/collection/item.ts index 0e0f9d850..54ec6051c 100644 --- a/packages/bruno-schema-types/src/collection/item.ts +++ b/packages/bruno-schema-types/src/collection/item.ts @@ -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; diff --git a/packages/bruno-schema/package.json b/packages/bruno-schema/package.json index bbd92b4e8..afb4f06bb 100644 --- a/packages/bruno-schema/package.json +++ b/packages/bruno-schema/package.json @@ -11,7 +11,6 @@ "test": "jest" }, "dependencies": { - "@usebruno/common": "0.1.0", "nanoid": "3.3.8", "yup": "^0.32.11" } diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index d3c98f3ca..58b54dc85 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -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'), diff --git a/packages/bruno-tests/collection/collection.bru b/packages/bruno-tests/collection/collection.bru index 2b0a76272..79343ba9e 100644 --- a/packages/bruno-tests/collection/collection.bru +++ b/packages/bruno-tests/collection/collection.bru @@ -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 } diff --git a/tests/collection/description-yml/fixtures/collection/bruno.json b/tests/collection/description-yml/fixtures/collection/bruno.json new file mode 100644 index 000000000..3c747aaf3 --- /dev/null +++ b/tests/collection/description-yml/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "col-description-yml", + "type": "collection" +} diff --git a/tests/collection/description-yml/fixtures/collection/opencollection.yml b/tests/collection/description-yml/fixtures/collection/opencollection.yml new file mode 100644 index 000000000..65027c508 --- /dev/null +++ b/tests/collection/description-yml/fixtures/collection/opencollection.yml @@ -0,0 +1,29 @@ +opencollection: "1.0.0" +info: + name: col-description-yml + +request: + headers: + - name: X-Version + value: "2.0" + description: Single-line header desc + - name: X-Multi + value: value + description: |- + Header line one + Header line two + - name: X-Plain + value: plain-value + variables: + - name: baseUrl + value: https://example.com + description: Single-line var desc + - name: apiKey + value: abc123 + description: |- + Var line one + Var line two + - name: plain + value: no-desc + +bundled: false diff --git a/tests/collection/description-yml/init-user-data/collection-security.json b/tests/collection/description-yml/init-user-data/collection-security.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/tests/collection/description-yml/init-user-data/collection-security.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/collection/description-yml/init-user-data/preferences.json b/tests/collection/description-yml/init-user-data/preferences.json new file mode 100644 index 000000000..6b2a2d602 --- /dev/null +++ b/tests/collection/description-yml/init-user-data/preferences.json @@ -0,0 +1,28 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{collectionPath}}" + ], + "request": { + "sslVerification": false, + "customCaCertificate": { + "enabled": false, + "filePath": null + } + }, + "font": { + "codeFont": "default" + }, + "proxy": { + "enabled": false, + "protocol": "http", + "hostname": "", + "port": "", + "auth": { + "enabled": false, + "username": "", + "password": "" + }, + "bypassProxy": "" + } +} diff --git a/tests/collection/description-yml/read-collection-description.spec.ts b/tests/collection/description-yml/read-collection-description.spec.ts new file mode 100644 index 000000000..e277f4969 --- /dev/null +++ b/tests/collection/description-yml/read-collection-description.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from '../../../playwright'; +import { openCollectionSettings, focusCollectionSettingsTab, selectCollectionPaneTab } from '../../utils/page'; + +test.describe('Collection Settings Descriptions (YAML) - Read', () => { + test('reads descriptions from headers and vars in a pre-existing opencollection.yml', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openCollectionSettings(page, 'col-description-yml'); + await focusCollectionSettingsTab(page); + + await selectCollectionPaneTab(page, 'headers'); + + const headerRows = page.getByTestId('collection-headers').locator('tbody tr'); + + const versionDescEditor = headerRows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(versionDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line header desc'); + + const multiDescEditor = headerRows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(multiDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Header line one'); + await expect(multiDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Header line two'); + + const plainDescEditor = headerRows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + + await selectCollectionPaneTab(page, 'vars'); + + const varRows = page.getByTestId('collection-vars-req').locator('tbody tr'); + + const baseUrlDescEditor = varRows.nth(0).locator('.CodeMirror').nth(1); + await expect(baseUrlDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line var desc'); + + const apiKeyDescEditor = varRows.nth(1).locator('.CodeMirror').nth(1); + await expect(apiKeyDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Var line one'); + await expect(apiKeyDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Var line two'); + + const plainVarDescEditor = varRows.nth(2).locator('.CodeMirror').nth(1); + await expect(plainVarDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); +}); diff --git a/tests/collection/description-yml/write-collection-headers-description.spec.ts b/tests/collection/description-yml/write-collection-headers-description.spec.ts new file mode 100644 index 000000000..fbf58773d --- /dev/null +++ b/tests/collection/description-yml/write-collection-headers-description.spec.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; + +test.describe('Collection Settings Descriptions (YAML) - Write (Headers)', () => { + test('writes a multiline description to a header and persists it to opencollection.yml', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await page.locator('#sidebar-collection-name').filter({ hasText: 'col-description-yml' }).click(); + await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'Collection' })).toBeVisible(); + + await page.getByTestId('collection-settings-tab-headers').click(); + + const headersTable = page.getByTestId('collection-headers'); + const xPlainRow = headersTable.locator('tbody tr').filter({ + has: page.locator('[data-testid="column-name"] .CodeMirror-line', { hasText: 'X-Plain' }) + }); + const descCell = xPlainRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in X-Plain description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('First line\nSecond line'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Collection Settings saved successfully')).toBeVisible({ timeout: 5000 }); + + const ymlPath = path.join(collectionFixturePath!, 'opencollection.yml'); + const fileContent = fs.readFileSync(ymlPath, 'utf8'); + + expect(fileContent).toContain('description:'); + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/collection/description-yml/write-collection-vars-description.spec.ts b/tests/collection/description-yml/write-collection-vars-description.spec.ts new file mode 100644 index 000000000..feaca0788 --- /dev/null +++ b/tests/collection/description-yml/write-collection-vars-description.spec.ts @@ -0,0 +1,51 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; + +test.describe('Collection Settings Descriptions (YAML) - Write (Vars)', () => { + test('writes a multiline description to a pre-request var and persists it to opencollection.yml', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await page.locator('#sidebar-collection-name').filter({ hasText: 'col-description-yml' }).click(); + await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'Collection' })).toBeVisible(); + + await page.getByTestId('collection-settings-tab-vars').click(); + await expect(page.getByTestId('collection-vars-req').locator('tbody tr').first()).toBeVisible(); + + await page.evaluate(() => { + const table = document.querySelector('[data-testid="collection-vars-req"]'); + const rows = table?.querySelectorAll('tbody tr') ?? []; + const targetRow = Array.from(rows).find((row) => { + const input = row.querySelector('[data-testid="column-name"] input') as HTMLInputElement; + return input?.value === 'plain'; + }); + if (!targetRow) throw new Error('\'plain\' var row not found in pre-request vars table'); + + const cms = targetRow.querySelectorAll('.CodeMirror'); + const cm = (cms[1] as any)?.CodeMirror; + if (!cm) throw new Error('Description CodeMirror not found in plain row'); + + cm.setValue('First line\nSecond line'); + }); + + const varsTable = page.getByTestId('collection-vars-req'); + const plainRowIndex = await varsTable.locator('[data-testid="column-name"] input').evaluateAll( + (inputs) => inputs.findIndex((el) => (el as HTMLInputElement).value === 'plain') + ); + if (plainRowIndex === -1) throw new Error('\'plain\' var not found for assertion'); + + const descCell = varsTable.locator('tbody tr').nth(plainRowIndex).getByTestId('column-description'); + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Collection Settings saved successfully')).toBeVisible({ timeout: 5000 }); + + const ymlPath = path.join(collectionFixturePath!, 'opencollection.yml'); + const fileContent = fs.readFileSync(ymlPath, 'utf8'); + + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/collection/description/fixtures/collection/bruno.json b/tests/collection/description/fixtures/collection/bruno.json new file mode 100644 index 000000000..411a78461 --- /dev/null +++ b/tests/collection/description/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "col-description", + "type": "collection" +} diff --git a/tests/collection/description/fixtures/collection/collection.bru b/tests/collection/description/fixtures/collection/collection.bru new file mode 100644 index 000000000..ca6165ea2 --- /dev/null +++ b/tests/collection/description/fixtures/collection/collection.bru @@ -0,0 +1,27 @@ +meta { + name: col-description + type: collection + version: 1.0.0 +} + +headers { + @description('''Single-line header desc''') + X-Version: 2.0 + @description(''' + Header line one + Header line two + ''') + X-Multi: value + X-Plain: plain-value +} + +vars:pre-request { + @description('''Single-line var desc''') + baseUrl: https://example.com + @description(''' + Var line one + Var line two + ''') + apiKey: abc123 + plain: no-desc +} diff --git a/tests/collection/description/init-user-data/collection-security.json b/tests/collection/description/init-user-data/collection-security.json new file mode 100644 index 000000000..89dc2bfff --- /dev/null +++ b/tests/collection/description/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{collectionPath}}", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} diff --git a/tests/collection/description/init-user-data/preferences.json b/tests/collection/description/init-user-data/preferences.json new file mode 100644 index 000000000..6b2a2d602 --- /dev/null +++ b/tests/collection/description/init-user-data/preferences.json @@ -0,0 +1,28 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{collectionPath}}" + ], + "request": { + "sslVerification": false, + "customCaCertificate": { + "enabled": false, + "filePath": null + } + }, + "font": { + "codeFont": "default" + }, + "proxy": { + "enabled": false, + "protocol": "http", + "hostname": "", + "port": "", + "auth": { + "enabled": false, + "username": "", + "password": "" + }, + "bypassProxy": "" + } +} diff --git a/tests/collection/description/read-collection-description.spec.ts b/tests/collection/description/read-collection-description.spec.ts new file mode 100644 index 000000000..1ca8ca246 --- /dev/null +++ b/tests/collection/description/read-collection-description.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from '../../../playwright'; +import { openCollectionSettings, focusCollectionSettingsTab, selectCollectionPaneTab } from '../../utils/page'; + +test.describe('Collection Settings Descriptions - Read', () => { + test('reads descriptions from headers and vars in a pre-existing collection.bru', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openCollectionSettings(page, 'col-description'); + await focusCollectionSettingsTab(page); + + await selectCollectionPaneTab(page, 'headers'); + + const headerRows = page.getByTestId('collection-headers').locator('tbody tr'); + + const versionDescEditor = headerRows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(versionDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line header desc'); + + const multiDescEditor = headerRows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(multiDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Header line one'); + await expect(multiDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Header line two'); + + const plainDescEditor = headerRows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + + await selectCollectionPaneTab(page, 'vars'); + + const varRows = page.getByTestId('collection-vars-req').locator('tbody tr'); + + const baseUrlDescEditor = varRows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(baseUrlDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line var desc'); + + const apiKeyDescEditor = varRows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(apiKeyDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Var line one'); + await expect(apiKeyDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Var line two'); + + const plainVarDescEditor = varRows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainVarDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); +}); diff --git a/tests/collection/description/write-collection-headers-description.spec.ts b/tests/collection/description/write-collection-headers-description.spec.ts new file mode 100644 index 000000000..e1d21b16d --- /dev/null +++ b/tests/collection/description/write-collection-headers-description.spec.ts @@ -0,0 +1,53 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; + +test.describe('Collection Settings Descriptions - Write (Headers)', () => { + test('writes a multiline description to a header and persists it to collection.bru', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + // Open collection settings by clicking the collection name in the sidebar + await page + .locator('#sidebar-collection-name') + .filter({ hasText: 'col-description' }) + .click(); + // The tab label always reads "Collection" regardless of the collection name + await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'Collection' })).toBeVisible(); + + await page.locator('.tab.headers').click(); + + // Find the X-Plain row by its header name, not by position + const headersTable = page.getByTestId('collection-headers'); + const xPlainRow = headersTable.locator('tbody tr').filter({ + has: page.locator('[data-testid="column-name"] .CodeMirror-line', { hasText: 'X-Plain' }) + }); + const descCell = xPlainRow.getByTestId('column-description'); + + // Use CodeMirror's JS API via locator.evaluate() so the `change` event fires synchronously + // and the EditableTable onChange chain updates Redux state before we click Save. + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in X-Plain description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('First line\nSecond line'); + }); + + // Wait for CodeMirror DOM to reflect both lines before saving + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + // Save and confirm + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Collection Settings saved successfully')).toBeVisible({ timeout: 5000 }); + + // Verify the description was written to collection.bru + const collectionBruPath = path.join(collectionFixturePath!, 'collection.bru'); + const fileContent = fs.readFileSync(collectionBruPath, 'utf8'); + + expect(fileContent).toContain('@description'); + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/collection/description/write-collection-vars-description.spec.ts b/tests/collection/description/write-collection-vars-description.spec.ts new file mode 100644 index 000000000..877385d4c --- /dev/null +++ b/tests/collection/description/write-collection-vars-description.spec.ts @@ -0,0 +1,64 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; + +test.describe('Collection Settings Descriptions - Write (Vars)', () => { + test('writes a multiline description to a pre-request var and persists it to collection.bru', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + // Open collection settings + await page + .locator('#sidebar-collection-name') + .filter({ hasText: 'col-description' }) + .click(); + // The tab label always reads "Collection" regardless of the collection name + await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'Collection' })).toBeVisible(); + + await page.locator('.tab.vars').click(); + + // Wait for the pre-request vars table to render its rows + await expect(page.locator('table:first-of-type tbody tr').first()).toBeVisible(); + + // Find the 'plain' row by checking name input values, not by position. + // Use CodeMirror's JS API so the `change` event fires synchronously and + // the EditableTable onChange chain updates Redux state before we click Save. + await page.evaluate(() => { + const rows = document.querySelectorAll('table:first-of-type tbody tr'); + const targetRow = Array.from(rows).find((row) => { + const input = row.querySelector('[data-testid="column-name"] input') as HTMLInputElement; + return input?.value === 'plain'; + }); + if (!targetRow) throw new Error('\'plain\' var row not found in pre-request vars table'); + + // Var rows have two CodeMirrors: value (index 0) and description (index 1) + const cms = targetRow.querySelectorAll('.CodeMirror'); + const cm = (cms[1] as any)?.CodeMirror; + if (!cm) throw new Error('Description CodeMirror not found in plain row'); + + cm.setValue('First line\nSecond line'); + }); + + // Find the 'plain' row in Playwright to assert both CM lines are reflected + const varsTable = page.getByTestId('collection-vars-req'); + const plainRowIndex = await varsTable.locator('[data-testid="column-name"] input').evaluateAll( + (inputs) => inputs.findIndex((el) => (el as HTMLInputElement).value === 'plain') + ); + if (plainRowIndex === -1) throw new Error('\'plain\' var not found for assertion'); + + const descCell = varsTable.locator('tbody tr').nth(plainRowIndex).getByTestId('column-description'); + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + // The vars section has a single "Save" button shared by pre and post tables + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Collection Settings saved successfully')).toBeVisible({ timeout: 5000 }); + + // Verify the description was written to collection.bru + const collectionBruPath = path.join(collectionFixturePath!, 'collection.bru'); + const fileContent = fs.readFileSync(collectionBruPath, 'utf8'); + + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/collection/draft/draft-indicator.spec.ts b/tests/collection/draft/draft-indicator.spec.ts index 48ccc96e1..973320588 100644 --- a/tests/collection/draft/draft-indicator.spec.ts +++ b/tests/collection/draft/draft-indicator.spec.ts @@ -193,8 +193,7 @@ test.describe.serial('Draft indicator in collection and folder settings', () => const varNameInput = varRow.locator('input[type="text"]'); await varNameInput.click(); await varNameInput.fill('testVar'); - - const varValueEditor = varRow.locator('.CodeMirror'); + const varValueEditor = varRow.getByTestId(/^test-multiline-editor-\d+\.value$/); await varValueEditor.click(); await page.keyboard.type('testValue'); @@ -308,7 +307,7 @@ test.describe.serial('Draft indicator in collection and folder settings', () => await varNameInput.click(); await varNameInput.fill('folderVar'); - const folderVarValueEditor = varRow.locator('.CodeMirror'); + const folderVarValueEditor = varRow.getByTestId(/^test-multiline-editor-\d+\.value$/); await folderVarValueEditor.click(); await page.keyboard.type('folderValue'); diff --git a/tests/environments/api-setEnvVar-secret/api-setEnvVar-secret.spec.ts b/tests/environments/api-setEnvVar-secret/api-setEnvVar-secret.spec.ts index 4bfabd493..030e86b27 100644 --- a/tests/environments/api-setEnvVar-secret/api-setEnvVar-secret.spec.ts +++ b/tests/environments/api-setEnvVar-secret/api-setEnvVar-secret.spec.ts @@ -115,9 +115,9 @@ test.describe('bru.setEnvVar(name, value) - secret variable persistence', () => await expect(locators.environment.varRow('apiToken')).toBeVisible(); await locators.environment.varRowEyeToggle('apiToken').click(); - await expect(locators.environment.varRow('apiToken').locator('.CodeMirror').first()) + await expect(locators.environment.varRowValueEditor('apiToken')) .toHaveClass(/CodeMirror-empty/); - await expect(locators.environment.varRow('apiToken').locator('.CodeMirror')) + await expect(locators.environment.varRowValueEditor('apiToken')) .not.toContainText(NEW_VALUE); await closeEnvironmentPanel(page, 'collection'); @@ -158,7 +158,7 @@ test.describe('bru.setEnvVar(name, value) - secret variable persistence', () => await expect(envTab.locator('.close-gradient')).not.toHaveClass(/has-changes/); await locators.environment.varRowEyeToggle('apiToken').click(); - await expect(locators.environment.varRow('apiToken').locator('.CodeMirror')) + await expect(locators.environment.varRowValueEditor('apiToken')) .toContainText(NEW_VALUE); await closeEnvironmentPanel(page, 'collection'); @@ -213,7 +213,7 @@ test.describe('bru.setEnvVar(name, value) - secret variable persistence', () => await locators.environment.secretsTab().click(); await expect(locators.environment.varRow('apiToken')).toBeVisible(); await locators.environment.varRowEyeToggle('apiToken').click(); - await expect(locators.environment.varRow('apiToken').locator('.CodeMirror')) + await expect(locators.environment.varRowValueEditor('apiToken')) .toContainText(INITIAL_VALUE); await closeEnvironmentPanel(page, 'collection'); @@ -244,9 +244,9 @@ test.describe('bru.setEnvVar(name, value) - secret variable persistence', () => await expect(envTab.locator('.close-gradient')).not.toHaveClass(/has-changes/); await locators.environment.varRowEyeToggle('apiToken').click(); - await expect(locators.environment.varRow('apiToken').locator('.CodeMirror')) + await expect(locators.environment.varRowValueEditor('apiToken')) .toContainText(NEW_VALUE); - await expect(locators.environment.varRow('apiToken').locator('.CodeMirror')) + await expect(locators.environment.varRowValueEditor('apiToken')) .not.toContainText(INITIAL_VALUE); await closeEnvironmentPanel(page, 'collection'); diff --git a/tests/environments/api-setEnvVar/api-setEnvVar-with-persist-typed.spec.ts b/tests/environments/api-setEnvVar/api-setEnvVar-with-persist-typed.spec.ts index 9c4e34bbd..917b42cdc 100644 --- a/tests/environments/api-setEnvVar/api-setEnvVar-with-persist-typed.spec.ts +++ b/tests/environments/api-setEnvVar/api-setEnvVar-with-persist-typed.spec.ts @@ -74,17 +74,17 @@ test.describe.serial('bru.setEnvVar(name, value, { persist: true }) — typed va await expect(envTab).toBeVisible(); await expect( - newPage.locator('[data-testid="env-var-row-typed_num"] .type-label').first() - ).toHaveText('number', { timeout: 5000 }); + newPage.locator('[data-testid="env-var-row-typed_num"] [data-testid="datatype-selector-trigger"]').first() + ).toHaveAttribute('data-selected-type', 'number', { timeout: 5000 }); await expect( - newPage.locator('[data-testid="env-var-row-typed_bool"] .type-label').first() - ).toHaveText('boolean'); + newPage.locator('[data-testid="env-var-row-typed_bool"] [data-testid="datatype-selector-trigger"]').first() + ).toHaveAttribute('data-selected-type', 'boolean'); await expect( - newPage.locator('[data-testid="env-var-row-typed_obj"] .type-label').first() - ).toHaveText('object'); + newPage.locator('[data-testid="env-var-row-typed_obj"] [data-testid="datatype-selector-trigger"]').first() + ).toHaveAttribute('data-selected-type', 'object'); await expect( - newPage.locator('[data-testid="env-var-row-typed_str"] .type-label').first() - ).toHaveText('string'); + newPage.locator('[data-testid="env-var-row-typed_str"] [data-testid="datatype-selector-trigger"]').first() + ).toHaveAttribute('data-selected-type', 'string'); await newPage.getByTestId('responsive-tab-secrets').click(); @@ -93,8 +93,8 @@ test.describe.serial('bru.setEnvVar(name, value, { persist: true }) — typed va ).toBeVisible(); // Reloaded with the inferred @number type — the selector shows 'number'. await expect( - newPage.locator('[data-testid="env-var-row-existing_secret"] .type-label') - ).toHaveText('number'); + newPage.locator('[data-testid="env-var-row-existing_secret"] [data-testid="datatype-selector-trigger"]') + ).toHaveAttribute('data-selected-type', 'number'); await envTab.hover(); await envTab.getByTestId('request-tab-close-icon').click({ force: true }); diff --git a/tests/environments/api-setGlobalEnvVar-secret/api-setGlobalEnvVar-secret.spec.ts b/tests/environments/api-setGlobalEnvVar-secret/api-setGlobalEnvVar-secret.spec.ts index a16c29c1b..3301e1819 100644 --- a/tests/environments/api-setGlobalEnvVar-secret/api-setGlobalEnvVar-secret.spec.ts +++ b/tests/environments/api-setGlobalEnvVar-secret/api-setGlobalEnvVar-secret.spec.ts @@ -72,9 +72,9 @@ test.describe('bru.setGlobalEnvVar(name, value) - secret variable persistence (w await expect(locators.environment.varRow('apiToken')).toBeVisible(); await locators.environment.varRowEyeToggle('apiToken').click(); - await expect(locators.environment.varRow('apiToken').locator('.CodeMirror').first()) + await expect(locators.environment.varRowValueEditor('apiToken')) .toHaveClass(/CodeMirror-empty/); - await expect(locators.environment.varRow('apiToken').locator('.CodeMirror')) + await expect(locators.environment.varRowValueEditor('apiToken')) .not.toContainText(NEW_VALUE); await closeEnvironmentPanel(page, 'global'); @@ -117,7 +117,7 @@ test.describe('bru.setGlobalEnvVar(name, value) - secret variable persistence (w await expect(envTab.locator('.close-gradient')).not.toHaveClass(/has-changes/); await locators.environment.varRowEyeToggle('apiToken').click(); - await expect(locators.environment.varRow('apiToken').locator('.CodeMirror')) + await expect(locators.environment.varRowValueEditor('apiToken')) .toContainText(NEW_VALUE); await closeEnvironmentPanel(page, 'global'); @@ -171,7 +171,7 @@ test.describe('bru.setGlobalEnvVar(name, value) - secret variable persistence (w await locators.environment.secretsTab().click(); await expect(locators.environment.varRow('apiToken')).toBeVisible(); await locators.environment.varRowEyeToggle('apiToken').click(); - await expect(locators.environment.varRow('apiToken').locator('.CodeMirror')) + await expect(locators.environment.varRowValueEditor('apiToken')) .toContainText(INITIAL_VALUE); await closeEnvironmentPanel(page, 'global'); @@ -202,9 +202,9 @@ test.describe('bru.setGlobalEnvVar(name, value) - secret variable persistence (w await expect(envTab.locator('.close-gradient')).not.toHaveClass(/has-changes/); await locators.environment.varRowEyeToggle('apiToken').click(); - await expect(locators.environment.varRow('apiToken').locator('.CodeMirror')) + await expect(locators.environment.varRowValueEditor('apiToken')) .toContainText(NEW_VALUE); - await expect(locators.environment.varRow('apiToken').locator('.CodeMirror')) + await expect(locators.environment.varRowValueEditor('apiToken')) .not.toContainText(INITIAL_VALUE); await closeEnvironmentPanel(page, 'global'); diff --git a/tests/environments/datatype-preservation/datatype-preservation.spec.ts b/tests/environments/datatype-preservation/datatype-preservation.spec.ts index 8f01ad6d5..001055c3a 100644 --- a/tests/environments/datatype-preservation/datatype-preservation.spec.ts +++ b/tests/environments/datatype-preservation/datatype-preservation.spec.ts @@ -61,7 +61,7 @@ const expectTypeLabel = async (page: Page, name: string, label: string) => { const locators = buildCommonLocators(page); const row = locators.environment.varRow(name); await scrollVirtuosoRowIntoView(page, row); - await expect(locators.dataTypeSelector.typeLabel(row)).toHaveText(label, { timeout: SLOW_RENDER_TIMEOUT_MS }); + await expect(locators.dataTypeSelector.typeLabel(row)).toHaveAttribute('data-selected-type', label, { timeout: SLOW_RENDER_TIMEOUT_MS }); }; const expectMismatchVisible = async (page: Page, name: string) => { diff --git a/tests/environments/description-yml/fixtures/collection/bruno.json b/tests/environments/description-yml/fixtures/collection/bruno.json new file mode 100644 index 000000000..56580d621 --- /dev/null +++ b/tests/environments/description-yml/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "env-description-yml", + "type": "collection" +} diff --git a/tests/environments/description-yml/fixtures/collection/environments/WithDescriptions.yml b/tests/environments/description-yml/fixtures/collection/environments/WithDescriptions.yml new file mode 100644 index 000000000..87799a102 --- /dev/null +++ b/tests/environments/description-yml/fixtures/collection/environments/WithDescriptions.yml @@ -0,0 +1,13 @@ +name: WithDescriptions + +variables: + - name: host + value: http://localhost:3000 + description: Single-line desc + - name: token + value: abc123 + description: |- + Line one + Line two + - name: plain + value: no-description diff --git a/tests/environments/description-yml/fixtures/collection/opencollection.yml b/tests/environments/description-yml/fixtures/collection/opencollection.yml new file mode 100644 index 000000000..5e5abee6c --- /dev/null +++ b/tests/environments/description-yml/fixtures/collection/opencollection.yml @@ -0,0 +1,5 @@ +opencollection: "1.0.0" +info: + name: env-description-yml + +bundled: false diff --git a/tests/environments/description-yml/init-user-data/collection-security.json b/tests/environments/description-yml/init-user-data/collection-security.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/tests/environments/description-yml/init-user-data/collection-security.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/environments/description-yml/init-user-data/preferences.json b/tests/environments/description-yml/init-user-data/preferences.json new file mode 100644 index 000000000..6b2a2d602 --- /dev/null +++ b/tests/environments/description-yml/init-user-data/preferences.json @@ -0,0 +1,28 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{collectionPath}}" + ], + "request": { + "sslVerification": false, + "customCaCertificate": { + "enabled": false, + "filePath": null + } + }, + "font": { + "codeFont": "default" + }, + "proxy": { + "enabled": false, + "protocol": "http", + "hostname": "", + "port": "", + "auth": { + "enabled": false, + "username": "", + "password": "" + }, + "bypassProxy": "" + } +} diff --git a/tests/environments/description-yml/read-environment-description.spec.ts b/tests/environments/description-yml/read-environment-description.spec.ts new file mode 100644 index 000000000..a10ce59b3 --- /dev/null +++ b/tests/environments/description-yml/read-environment-description.spec.ts @@ -0,0 +1,46 @@ +import { test, expect, Page } from '../../../playwright'; +import { buildCommonLocators } from '../../utils/page/locators'; + +const openEnvironmentConfigure = async (page: Page, envName: string) => { + const locators = buildCommonLocators(page); + await locators.environment.currentEnvironment().click(); + await expect(locators.environment.envOption(envName)).toBeVisible(); + await locators.environment.envOption(envName).click(); + await expect(locators.environment.currentEnvironment().filter({ hasText: envName })).toBeVisible(); + await locators.environment.currentEnvironment().click(); + await expect(locators.dropdown.item('Configure')).toBeVisible(); + await locators.dropdown.item('Configure').click(); + await expect(locators.tabs.requestTab('Environments')).toBeVisible(); +}; + +test.describe('Environment Variable Descriptions (YAML) - Read', () => { + test('reads single-line and multiline descriptions from opencollection.yml environments', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + const collection = page + .getByTestId('collections') + .locator('#sidebar-collection-name') + .filter({ hasText: 'env-description-yml' }); + await expect(collection).toBeVisible(); + await collection.click(); + + await openEnvironmentConfigure(page, 'WithDescriptions'); + + const locators = buildCommonLocators(page); + + await expect(page.locator('input[name="0.name"]')).toHaveValue('host'); + const hostDesc = locators.environment.variableDescriptionEditor(0); + await expect(hostDesc.locator('.CodeMirror-line').first()).toHaveText('Single-line desc'); + + await expect(page.locator('input[name="1.name"]')).toHaveValue('token'); + const tokenDesc = locators.environment.variableDescriptionEditor(1); + await expect(tokenDesc.locator('.CodeMirror-line').nth(0)).toHaveText('Line one'); + await expect(tokenDesc.locator('.CodeMirror-line').nth(1)).toHaveText('Line two'); + + await expect(page.locator('input[name="2.name"]')).toHaveValue('plain'); + const plainDesc = locators.environment.variableDescriptionEditor(2); + await expect(plainDesc.locator('.CodeMirror-line').first()).toHaveText(''); + }); +}); diff --git a/tests/environments/description-yml/write-environment-description-yml.spec.ts b/tests/environments/description-yml/write-environment-description-yml.spec.ts new file mode 100644 index 000000000..de7b60e6f --- /dev/null +++ b/tests/environments/description-yml/write-environment-description-yml.spec.ts @@ -0,0 +1,59 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect, Page } from '../../../playwright'; +import { buildCommonLocators } from '../../utils/page/locators'; + +const openEnvironmentConfigure = async (page: Page, envName: string) => { + const locators = buildCommonLocators(page); + await locators.environment.currentEnvironment().click(); + await expect(locators.environment.envOption(envName)).toBeVisible(); + await locators.environment.envOption(envName).click(); + await expect(locators.environment.currentEnvironment().filter({ hasText: envName })).toBeVisible(); + await locators.environment.currentEnvironment().click(); + await expect(locators.dropdown.item('Configure')).toBeVisible(); + await locators.dropdown.item('Configure').click(); + await expect(locators.tabs.requestTab('Environments')).toBeVisible(); +}; + +test.describe('Environment Variable Descriptions (YAML) - Write', () => { + test('writes a multiline description and persists it to the .yml file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + test.setTimeout(30_000); + + const collection = page + .getByTestId('collections') + .locator('#sidebar-collection-name') + .filter({ hasText: 'env-description-yml' }); + await expect(collection).toBeVisible(); + await collection.click(); + + await openEnvironmentConfigure(page, 'WithDescriptions'); + + await page.evaluate((rowIndex) => { + const rows = document.querySelectorAll('tr'); + for (const row of rows) { + if (row.querySelector(`input[name="${rowIndex}.name"]`)) { + const cms = row.querySelectorAll('.CodeMirror'); + const cm = (cms[1] as any)?.CodeMirror; + if (cm) cm.setValue('First line\nSecond line'); + break; + } + } + }, 2); + + const plainDescEditor = buildCommonLocators(page).environment.variableDescriptionEditor(2); + await expect(plainDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await page.waitForTimeout(200); + + await page.getByTestId('save-env').click(); + await expect(page.getByText('Changes saved successfully')).toBeVisible({ timeout: 5000 }); + + const envFilePath = path.join(collectionFixturePath!, 'environments', 'WithDescriptions.yml'); + const fileContent = fs.readFileSync(envFilePath, 'utf8'); + + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/environments/description/fixtures/collection/bruno.json b/tests/environments/description/fixtures/collection/bruno.json new file mode 100644 index 000000000..b94271a2f --- /dev/null +++ b/tests/environments/description/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "env-description", + "type": "collection" +} diff --git a/tests/environments/description/fixtures/collection/environments/WithDescriptions.bru b/tests/environments/description/fixtures/collection/environments/WithDescriptions.bru new file mode 100644 index 000000000..2f7139498 --- /dev/null +++ b/tests/environments/description/fixtures/collection/environments/WithDescriptions.bru @@ -0,0 +1,13 @@ +vars { + @description('''Single-line desc''') + host: http://localhost:3000 + @description('''empty description''') + @description(''' + Line one + Line two + ''') + token: abc123 + plain: no-description + @description("has ''' triple quotes inside") + tricky: value +} diff --git a/tests/environments/description/init-user-data/collection-security.json b/tests/environments/description/init-user-data/collection-security.json new file mode 100644 index 000000000..ef0e089b1 --- /dev/null +++ b/tests/environments/description/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{collectionPath}}", + "securityConfig": { + "jsSandboxMode": "developer" + } + } + ] +} diff --git a/tests/environments/description/init-user-data/preferences.json b/tests/environments/description/init-user-data/preferences.json new file mode 100644 index 000000000..6b2a2d602 --- /dev/null +++ b/tests/environments/description/init-user-data/preferences.json @@ -0,0 +1,28 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{collectionPath}}" + ], + "request": { + "sslVerification": false, + "customCaCertificate": { + "enabled": false, + "filePath": null + } + }, + "font": { + "codeFont": "default" + }, + "proxy": { + "enabled": false, + "protocol": "http", + "hostname": "", + "port": "", + "auth": { + "enabled": false, + "username": "", + "password": "" + }, + "bypassProxy": "" + } +} diff --git a/tests/environments/description/read-environment-description.spec.ts b/tests/environments/description/read-environment-description.spec.ts new file mode 100644 index 000000000..d00f4e9ab --- /dev/null +++ b/tests/environments/description/read-environment-description.spec.ts @@ -0,0 +1,64 @@ +import { test, expect, Page } from '../../../playwright'; +import { buildCommonLocators } from '../../utils/page/locators'; + +const openEnvironmentConfigure = async (page: Page, envName: string) => { + const locators = buildCommonLocators(page); + await locators.environment.currentEnvironment().click(); + await expect(locators.environment.envOption(envName)).toBeVisible(); + await locators.environment.envOption(envName).click(); + await expect(locators.environment.currentEnvironment().filter({ hasText: envName })).toBeVisible(); + await locators.environment.currentEnvironment().click(); + await expect(locators.dropdown.item('Configure')).toBeVisible(); + await locators.dropdown.item('Configure').click(); + await expect(locators.tabs.requestTab('Environments')).toBeVisible(); +}; + +test.describe('Environment Variable Descriptions - Read', () => { + test('reads single-line and multiline descriptions from a pre-existing environment file', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + const collection = page + .getByTestId('collections') + .locator('#sidebar-collection-name') + .filter({ hasText: 'env-description' }); + await expect(collection).toBeVisible(); + await collection.click(); + + await openEnvironmentConfigure(page, 'WithDescriptions'); + + const locators = buildCommonLocators(page); + + // row 0: host — single-line description + await expect(page.locator('input[name="0.name"]')).toHaveValue('host'); + const hostDesc = locators.environment.variableDescriptionEditor(0); + await expect(hostDesc).toBeVisible(); + await expect(hostDesc.locator('.CodeMirror-line').first()).toHaveText('Single-line desc'); + + // row 1: orphaned @description('''empty description''') — empty name/value, description is 'empty description' + await expect(page.locator('input[name="1.name"]')).toHaveValue(''); + const orphanDesc = locators.environment.variableDescriptionEditor(1); + await expect(orphanDesc).toBeVisible(); + await expect(orphanDesc.locator('.CodeMirror-line').first()).toHaveText('empty description'); + + // row 2: token — gets the last (multiline) description; the first was consumed by the orphaned row + await expect(page.locator('input[name="2.name"]')).toHaveValue('token'); + const tokenDesc = locators.environment.variableDescriptionEditor(2); + await expect(tokenDesc).toBeVisible(); + await expect(tokenDesc.locator('.CodeMirror-line').nth(0)).toHaveText('Line one'); + await expect(tokenDesc.locator('.CodeMirror-line').nth(1)).toHaveText('Line two'); + + // row 3: plain — no description (editor is empty) + await expect(page.locator('input[name="3.name"]')).toHaveValue('plain'); + const plainDesc = locators.environment.variableDescriptionEditor(3); + await expect(plainDesc).toBeVisible(); + await expect(plainDesc.locator('.CodeMirror-line').first()).toHaveText(''); + + // row 4: tricky — description containing ''' (stored as triple-quoted @description('''...''')) + await expect(page.locator('input[name="4.name"]')).toHaveValue('tricky'); + const trickyDesc = locators.environment.variableDescriptionEditor(4); + await expect(trickyDesc).toBeVisible(); + await expect(trickyDesc.locator('.CodeMirror-line').first()).toHaveText('has \'\'\' triple quotes inside'); + }); +}); diff --git a/tests/environments/description/write-environment-description.spec.ts b/tests/environments/description/write-environment-description.spec.ts new file mode 100644 index 000000000..b565ba022 --- /dev/null +++ b/tests/environments/description/write-environment-description.spec.ts @@ -0,0 +1,65 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect, Page } from '../../../playwright'; +import { buildCommonLocators } from '../../utils/page/locators'; + +const openEnvironmentConfigure = async (page: Page, envName: string) => { + const locators = buildCommonLocators(page); + await locators.environment.currentEnvironment().click(); + await expect(locators.environment.envOption(envName)).toBeVisible(); + await locators.environment.envOption(envName).click(); + await expect(locators.environment.currentEnvironment().filter({ hasText: envName })).toBeVisible(); + await locators.environment.currentEnvironment().click(); + await expect(locators.dropdown.item('Configure')).toBeVisible(); + await locators.dropdown.item('Configure').click(); + await expect(locators.tabs.requestTab('Environments')).toBeVisible(); +}; + +test.describe('Environment Variable Descriptions - Write', () => { + test('writes a multiline description and persists it to the .bru file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + test.setTimeout(30_000); + + const collection = page + .getByTestId('collections') + .locator('#sidebar-collection-name') + .filter({ hasText: 'env-description' }); + await expect(collection).toBeVisible(); + await collection.click(); + + await openEnvironmentConfigure(page, 'WithDescriptions'); + + // Set the description value directly via CodeMirror's JS API so that the `change` + // event fires synchronously and formik receives the update before we save. + // keyboard.type / insertText are unreliable in Electron because synthetic key events + // don't always go through CodeMirror's textarea input handler. + await page.evaluate((rowIndex) => { + const rows = document.querySelectorAll('tr'); + for (const row of rows) { + if (row.querySelector(`input[name="${rowIndex}.name"]`)) { + const cms = row.querySelectorAll('.CodeMirror'); + const cm = (cms[1] as any)?.CodeMirror; // description editor is the 2nd CodeMirror + if (cm) cm.setValue('First line\nSecond line'); + break; + } + } + }, 2); + + // Wait for the CodeMirror DOM to reflect the value, then give React a tick to flush formik + const plainDescEditor = buildCommonLocators(page).environment.variableDescriptionEditor(2); + await expect(plainDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await page.waitForTimeout(200); + + await page.getByTestId('save-env').click(); + await expect(page.getByText('Changes saved successfully')).toBeVisible({ timeout: 5000 }); + + // The IPC write completes before the toast resolves, so the file is ready to read. + const envFilePath = path.join(collectionFixturePath!, 'environments', 'WithDescriptions.bru'); + const fileContent = fs.readFileSync(envFilePath, 'utf8'); + + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/environments/export-environment/collection-env-export/fixtures/collection/environments/local.bru b/tests/environments/export-environment/collection-env-export/fixtures/collection/environments/local.bru index 8e5eb4a6a..b29296432 100644 --- a/tests/environments/export-environment/collection-env-export/fixtures/collection/environments/local.bru +++ b/tests/environments/export-environment/collection-env-export/fixtures/collection/environments/local.bru @@ -1,7 +1,12 @@ vars { + @description('''Single-line host desc''') host: http://localhost:3000 } vars:secret [ + @description(''' + Secret line one + Secret line two + ''') secretToken -] \ No newline at end of file +] diff --git a/tests/environments/export-environment/collection-env-export/fixtures/collection/environments/prod.bru b/tests/environments/export-environment/collection-env-export/fixtures/collection/environments/prod.bru index 54d3bbe1b..5f1ee5e05 100644 --- a/tests/environments/export-environment/collection-env-export/fixtures/collection/environments/prod.bru +++ b/tests/environments/export-environment/collection-env-export/fixtures/collection/environments/prod.bru @@ -1,7 +1,12 @@ vars { + @description('''Single-line host desc''') host: https://echo.usebruno.com } vars:secret [ + @description(''' + Secret line one + Secret line two + ''') secretToken -] \ No newline at end of file +] diff --git a/tests/environments/export-environment/global-env-export/init-user-data/global-environments.json b/tests/environments/export-environment/global-env-export/init-user-data/global-environments.json index bc7b25381..6c3d5d7e3 100644 --- a/tests/environments/export-environment/global-env-export/init-user-data/global-environments.json +++ b/tests/environments/export-environment/global-env-export/init-user-data/global-environments.json @@ -10,7 +10,8 @@ "value": "http://localhost:3000", "type": "text", "enabled": true, - "secret": false + "secret": false, + "description": "Single-line host desc" }, { "uid": "3WEAWueN0Ov99uOX0uuuM", @@ -18,7 +19,8 @@ "value": "", "type": "text", "enabled": true, - "secret": true + "secret": true, + "description": "Secret line one\nSecret line two" } ] }, @@ -32,7 +34,8 @@ "value": "https://echo.usebruno.com", "type": "text", "enabled": true, - "secret": false + "secret": false, + "description": "Single-line host desc" }, { "uid": "fSxTRpngl8fxkhrl3z7hA", @@ -40,7 +43,8 @@ "value": "", "type": "text", "enabled": true, - "secret": true + "secret": true, + "description": "Secret line one\nSecret line two" } ] } diff --git a/tests/environments/fixtures/environment-exports/bruno-collection-environments.json b/tests/environments/fixtures/environment-exports/bruno-collection-environments.json index fbd41dd27..3c4bfd430 100644 --- a/tests/environments/fixtures/environment-exports/bruno-collection-environments.json +++ b/tests/environments/fixtures/environment-exports/bruno-collection-environments.json @@ -13,14 +13,16 @@ "value": "http://localhost:3000", "type": "text", "enabled": true, - "secret": false + "secret": false, + "description": "Single-line host desc" }, { "name": "secretToken", "value": "", "type": "text", "enabled": true, - "secret": true + "secret": true, + "description": "Secret line one\nSecret line two" } ] }, @@ -32,14 +34,16 @@ "value": "https://echo.usebruno.com", "type": "text", "enabled": true, - "secret": false + "secret": false, + "description": "Single-line host desc" }, { "name": "secretToken", "value": "", "type": "text", "enabled": true, - "secret": true + "secret": true, + "description": "Secret line one\nSecret line two" } ] } diff --git a/tests/environments/fixtures/environment-exports/bruno-collection-environments/local.json b/tests/environments/fixtures/environment-exports/bruno-collection-environments/local.json index af3df6bc8..514b2c911 100644 --- a/tests/environments/fixtures/environment-exports/bruno-collection-environments/local.json +++ b/tests/environments/fixtures/environment-exports/bruno-collection-environments/local.json @@ -6,14 +6,16 @@ "value": "http://localhost:3000", "type": "text", "enabled": true, - "secret": false + "secret": false, + "description": "Single-line host desc" }, { "name": "secretToken", "value": "", "type": "text", "enabled": true, - "secret": true + "secret": true, + "description": "Secret line one\nSecret line two" } ], "info": { diff --git a/tests/environments/fixtures/environment-exports/bruno-collection-environments/prod.json b/tests/environments/fixtures/environment-exports/bruno-collection-environments/prod.json index 0a60aaa43..436cd3286 100644 --- a/tests/environments/fixtures/environment-exports/bruno-collection-environments/prod.json +++ b/tests/environments/fixtures/environment-exports/bruno-collection-environments/prod.json @@ -6,14 +6,16 @@ "value": "https://echo.usebruno.com", "type": "text", "enabled": true, - "secret": false + "secret": false, + "description": "Single-line host desc" }, { "name": "secretToken", "value": "", "type": "text", "enabled": true, - "secret": true + "secret": true, + "description": "Secret line one\nSecret line two" } ], "info": { diff --git a/tests/environments/fixtures/environment-exports/bruno-global-environments.json b/tests/environments/fixtures/environment-exports/bruno-global-environments.json index fbd41dd27..3c4bfd430 100644 --- a/tests/environments/fixtures/environment-exports/bruno-global-environments.json +++ b/tests/environments/fixtures/environment-exports/bruno-global-environments.json @@ -13,14 +13,16 @@ "value": "http://localhost:3000", "type": "text", "enabled": true, - "secret": false + "secret": false, + "description": "Single-line host desc" }, { "name": "secretToken", "value": "", "type": "text", "enabled": true, - "secret": true + "secret": true, + "description": "Secret line one\nSecret line two" } ] }, @@ -32,14 +34,16 @@ "value": "https://echo.usebruno.com", "type": "text", "enabled": true, - "secret": false + "secret": false, + "description": "Single-line host desc" }, { "name": "secretToken", "value": "", "type": "text", "enabled": true, - "secret": true + "secret": true, + "description": "Secret line one\nSecret line two" } ] } diff --git a/tests/environments/fixtures/environment-exports/bruno-global-environments/local.json b/tests/environments/fixtures/environment-exports/bruno-global-environments/local.json index af3df6bc8..514b2c911 100644 --- a/tests/environments/fixtures/environment-exports/bruno-global-environments/local.json +++ b/tests/environments/fixtures/environment-exports/bruno-global-environments/local.json @@ -6,14 +6,16 @@ "value": "http://localhost:3000", "type": "text", "enabled": true, - "secret": false + "secret": false, + "description": "Single-line host desc" }, { "name": "secretToken", "value": "", "type": "text", "enabled": true, - "secret": true + "secret": true, + "description": "Secret line one\nSecret line two" } ], "info": { diff --git a/tests/environments/fixtures/environment-exports/bruno-global-environments/prod.json b/tests/environments/fixtures/environment-exports/bruno-global-environments/prod.json index 0a60aaa43..436cd3286 100644 --- a/tests/environments/fixtures/environment-exports/bruno-global-environments/prod.json +++ b/tests/environments/fixtures/environment-exports/bruno-global-environments/prod.json @@ -6,14 +6,16 @@ "value": "https://echo.usebruno.com", "type": "text", "enabled": true, - "secret": false + "secret": false, + "description": "Single-line host desc" }, { "name": "secretToken", "value": "", "type": "text", "enabled": true, - "secret": true + "secret": true, + "description": "Secret line one\nSecret line two" } ], "info": { diff --git a/tests/environments/fixtures/environment-exports/local.json b/tests/environments/fixtures/environment-exports/local.json index af3df6bc8..514b2c911 100644 --- a/tests/environments/fixtures/environment-exports/local.json +++ b/tests/environments/fixtures/environment-exports/local.json @@ -6,14 +6,16 @@ "value": "http://localhost:3000", "type": "text", "enabled": true, - "secret": false + "secret": false, + "description": "Single-line host desc" }, { "name": "secretToken", "value": "", "type": "text", "enabled": true, - "secret": true + "secret": true, + "description": "Secret line one\nSecret line two" } ], "info": { diff --git a/tests/environments/import-environment/bruno-env-import/collection-env-import/collection-env-import.spec.ts b/tests/environments/import-environment/bruno-env-import/collection-env-import/collection-env-import.spec.ts index 634cd1530..752bffe2c 100644 --- a/tests/environments/import-environment/bruno-env-import/collection-env-import/collection-env-import.spec.ts +++ b/tests/environments/import-environment/bruno-env-import/collection-env-import/collection-env-import.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '../../../../../playwright'; import path from 'path'; import fs from 'fs'; +import { buildCommonLocators } from '../../../../utils/page/locators'; test.describe.serial('Collection Environment Import Tests', () => { test('should import single collection environment', async ({ pageWithUserData: page }) => { @@ -43,9 +44,17 @@ test.describe.serial('Collection Environment Import Tests', () => { const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); await expect(envTab).toBeVisible(); - await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible(); + const locators = buildCommonLocators(page); + await expect(locators.environment.varRowValueCell('host')).toBeVisible(); + const hostDesc = locators.environment.varRowDescriptionEditor('host'); + await expect(hostDesc.locator('.CodeMirror-line').first()).toHaveText('Single-line host desc'); + await page.getByTestId('responsive-tab-secrets').click(); - await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible(); + await expect(locators.environment.varRowValueCell('secretToken')).toBeVisible(); + + const secretDesc = locators.environment.varRowDescriptionEditor('secretToken'); + await expect(secretDesc.locator('.CodeMirror-line').nth(0)).toHaveText('Secret line one'); + await expect(secretDesc.locator('.CodeMirror-line').nth(1)).toHaveText('Secret line two'); await envTab.hover(); await envTab.getByTestId('request-tab-close-icon').click({ force: true }); @@ -125,11 +134,12 @@ test.describe.serial('Collection Environment Import Tests', () => { await expect(envTab).toBeVisible(); // Verify prod environment variables - await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible(); + const locators = buildCommonLocators(page); + await expect(locators.environment.varRowValueCell('host')).toBeVisible(); // secretToken was imported as a secret, so it lives on the Secrets tab, not Variables. await page.getByTestId('responsive-tab-secrets').click(); - await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible(); + await expect(locators.environment.varRowValueCell('secretToken')).toBeVisible(); await envTab.hover(); await envTab.getByTestId('request-tab-close-icon').click({ force: true }); diff --git a/tests/environments/import-environment/bruno-env-import/global-env-import/global-env-import.spec.ts b/tests/environments/import-environment/bruno-env-import/global-env-import/global-env-import.spec.ts index a1f157861..acecbadac 100644 --- a/tests/environments/import-environment/bruno-env-import/global-env-import/global-env-import.spec.ts +++ b/tests/environments/import-environment/bruno-env-import/global-env-import/global-env-import.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../../../../../playwright'; import path from 'path'; +import { buildCommonLocators } from '../../../../utils/page/locators'; test.describe.serial('Global Environment Import Tests', () => { test('should import single global environment', async ({ pageWithUserData: page }) => { @@ -58,9 +59,10 @@ test.describe.serial('Global Environment Import Tests', () => { await expect(envTab).toBeVisible(); // Verify imported variables - await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible(); + const locators = buildCommonLocators(page); + await expect(locators.environment.varRowValueCell('host')).toBeVisible(); await page.getByTestId('responsive-tab-secrets').click(); - await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible(); + await expect(locators.environment.varRowValueCell('secretToken')).toBeVisible(); await envTab.hover(); await envTab.getByTestId('request-tab-close-icon').click({ force: true }); @@ -142,11 +144,12 @@ test.describe.serial('Global Environment Import Tests', () => { await expect(envTab).toBeVisible(); // Verify imported variables - await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible(); + const locators = buildCommonLocators(page); + await expect(locators.environment.varRowValueCell('host')).toBeVisible(); // secretToken was imported as a secret, so it lives on the Secrets tab, not Variables. await page.getByTestId('responsive-tab-secrets').click(); - await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible(); + await expect(locators.environment.varRowValueCell('secretToken')).toBeVisible(); await envTab.hover(); await envTab.getByTestId('request-tab-close-icon').click({ force: true }); diff --git a/tests/environments/multiline-variables/write-multiline-variable.spec.ts b/tests/environments/multiline-variables/write-multiline-variable.spec.ts index 9cabd127f..bacffa672 100644 --- a/tests/environments/multiline-variables/write-multiline-variable.spec.ts +++ b/tests/environments/multiline-variables/write-multiline-variable.spec.ts @@ -37,7 +37,7 @@ test.describe('Multiline Variables - Write Test', () => { // Use force:true to bypass Playwright's stability check on the CodeMirror click. const variableRow = page.locator('tbody tr').filter({ has: page.locator('input[value="multiline_data_json"]') }); await expect(variableRow).toBeVisible(); - const codeMirror = variableRow.locator('.CodeMirror'); + const codeMirror = variableRow.getByTestId(/^test-multiline-editor-\d+\.value$/); const jsonValue = `{ "user": { diff --git a/tests/folder/description-yml/fixtures/collection/api/folder.yml b/tests/folder/description-yml/fixtures/collection/api/folder.yml new file mode 100644 index 000000000..0216b15b2 --- /dev/null +++ b/tests/folder/description-yml/fixtures/collection/api/folder.yml @@ -0,0 +1,28 @@ +info: + name: api + type: folder + seq: 1 + +request: + headers: + - name: X-Version + value: "2.0" + description: Single-line header desc + - name: X-Multi + value: value + description: |- + Header line one + Header line two + - name: X-Plain + value: plain-value + variables: + - name: baseUrl + value: https://example.com + description: Single-line var desc + - name: apiKey + value: abc123 + description: |- + Var line one + Var line two + - name: plain + value: no-desc diff --git a/tests/folder/description-yml/fixtures/collection/api/ping.yml b/tests/folder/description-yml/fixtures/collection/api/ping.yml new file mode 100644 index 000000000..6df6ced7f --- /dev/null +++ b/tests/folder/description-yml/fixtures/collection/api/ping.yml @@ -0,0 +1,14 @@ +info: + name: ping + type: http + seq: 1 + +http: + method: GET + url: https://example.com/ping + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/tests/folder/description-yml/fixtures/collection/bruno.json b/tests/folder/description-yml/fixtures/collection/bruno.json new file mode 100644 index 000000000..f6fe2a440 --- /dev/null +++ b/tests/folder/description-yml/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "fold-description-yml", + "type": "collection" +} diff --git a/tests/folder/description-yml/fixtures/collection/opencollection.yml b/tests/folder/description-yml/fixtures/collection/opencollection.yml new file mode 100644 index 000000000..2a7c59106 --- /dev/null +++ b/tests/folder/description-yml/fixtures/collection/opencollection.yml @@ -0,0 +1,5 @@ +opencollection: "1.0.0" +info: + name: fold-description-yml + +bundled: false diff --git a/tests/folder/description-yml/init-user-data/collection-security.json b/tests/folder/description-yml/init-user-data/collection-security.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/tests/folder/description-yml/init-user-data/collection-security.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/folder/description-yml/init-user-data/preferences.json b/tests/folder/description-yml/init-user-data/preferences.json new file mode 100644 index 000000000..6b2a2d602 --- /dev/null +++ b/tests/folder/description-yml/init-user-data/preferences.json @@ -0,0 +1,28 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{collectionPath}}" + ], + "request": { + "sslVerification": false, + "customCaCertificate": { + "enabled": false, + "filePath": null + } + }, + "font": { + "codeFont": "default" + }, + "proxy": { + "enabled": false, + "protocol": "http", + "hostname": "", + "port": "", + "auth": { + "enabled": false, + "username": "", + "password": "" + }, + "bypassProxy": "" + } +} diff --git a/tests/folder/description-yml/read-folder-description.spec.ts b/tests/folder/description-yml/read-folder-description.spec.ts new file mode 100644 index 000000000..ac2da82e4 --- /dev/null +++ b/tests/folder/description-yml/read-folder-description.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '../../../playwright'; +import { openFolderSettings } from '../../utils/page'; + +test.describe('Folder Settings Descriptions (YAML) - Read', () => { + test('reads descriptions from headers and vars in a pre-existing folder.yml', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openFolderSettings(page, 'fold-description-yml'); + + await page.getByTestId('folder-settings-tab-headers').click(); + + const headerRows = page.locator('table').first().locator('tbody tr'); + + const versionDescEditor = headerRows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(versionDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line header desc'); + + const multiDescEditor = headerRows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(multiDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Header line one'); + await expect(multiDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Header line two'); + + const plainDescEditor = headerRows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + + await page.getByTestId('folder-settings-tab-vars').click(); + + const varRows = page.getByTestId('folder-vars-req').locator('tbody tr'); + + const baseUrlDescEditor = varRows.nth(0).locator('.CodeMirror').nth(1); + await expect(baseUrlDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line var desc'); + + const apiKeyDescEditor = varRows.nth(1).locator('.CodeMirror').nth(1); + await expect(apiKeyDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Var line one'); + await expect(apiKeyDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Var line two'); + + const plainVarDescEditor = varRows.nth(2).locator('.CodeMirror').nth(1); + await expect(plainVarDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); +}); diff --git a/tests/folder/description-yml/write-folder-headers-description.spec.ts b/tests/folder/description-yml/write-folder-headers-description.spec.ts new file mode 100644 index 000000000..3fa65edd1 --- /dev/null +++ b/tests/folder/description-yml/write-folder-headers-description.spec.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openFolderSettings, setTableRowDescriptionValue } from '../../utils/page'; + +test.describe('Folder Settings Descriptions (YAML) - Write (Headers)', () => { + test('writes a multiline description to a header and persists it to folder.yml', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openFolderSettings(page, 'fold-description-yml'); + + await page.getByTestId('folder-settings-tab-headers').click(); + + const headersTable = page.locator('table').first(); + const xPlainRow = headersTable.locator('tbody tr').filter({ + has: page.locator('[data-testid="column-name"] .CodeMirror-line', { hasText: 'X-Plain' }) + }); + const descCell = xPlainRow.getByTestId('column-description'); + + await setTableRowDescriptionValue(xPlainRow, 'First line\nSecond line'); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Folder Settings saved successfully')).toBeVisible({ timeout: 5000 }); + + const folderYmlPath = path.join(collectionFixturePath!, 'api', 'folder.yml'); + const fileContent = fs.readFileSync(folderYmlPath, 'utf8'); + + expect(fileContent).toContain('description:'); + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/folder/description-yml/write-folder-vars-description.spec.ts b/tests/folder/description-yml/write-folder-vars-description.spec.ts new file mode 100644 index 000000000..b756759be --- /dev/null +++ b/tests/folder/description-yml/write-folder-vars-description.spec.ts @@ -0,0 +1,39 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openFolderSettings, setTableRowDescriptionValue } from '../../utils/page'; + +test.describe('Folder Settings Descriptions (YAML) - Write (Vars)', () => { + test('writes a multiline description to a pre-request var and persists it to folder.yml', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openFolderSettings(page, 'fold-description-yml'); + + await page.getByTestId('folder-settings-tab-vars').click(); + + const varsTable = page.getByTestId('folder-vars-req'); + await expect(varsTable.locator('tbody tr').first()).toBeVisible(); + + const plainRowIndex = await varsTable.locator('[data-testid="column-name"] input').evaluateAll( + (inputs) => inputs.findIndex((el) => (el as HTMLInputElement).value === 'plain') + ); + if (plainRowIndex === -1) throw new Error('\'plain\' var not found in pre-request vars table'); + + const plainRow = varsTable.locator('tbody tr').nth(plainRowIndex); + await setTableRowDescriptionValue(plainRow, 'First line\nSecond line'); + + const descCell = plainRow.getByTestId('column-description'); + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Folder Settings saved successfully')).toBeVisible({ timeout: 5000 }); + + const folderYmlPath = path.join(collectionFixturePath!, 'api', 'folder.yml'); + const fileContent = fs.readFileSync(folderYmlPath, 'utf8'); + + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/folder/description/fixtures/collection/api/folder.bru b/tests/folder/description/fixtures/collection/api/folder.bru new file mode 100644 index 000000000..35a7bac3f --- /dev/null +++ b/tests/folder/description/fixtures/collection/api/folder.bru @@ -0,0 +1,25 @@ +meta { + name: api +} + +headers { + @description('''Single-line header desc''') + X-Version: 2.0 + @description(''' + Header line one + Header line two + ''') + X-Multi: value + X-Plain: plain-value +} + +vars:pre-request { + @description('''Single-line var desc''') + baseUrl: https://example.com + @description(''' + Var line one + Var line two + ''') + apiKey: abc123 + plain: no-desc +} diff --git a/tests/folder/description/fixtures/collection/api/ping.bru b/tests/folder/description/fixtures/collection/api/ping.bru new file mode 100644 index 000000000..b956109bd --- /dev/null +++ b/tests/folder/description/fixtures/collection/api/ping.bru @@ -0,0 +1,11 @@ +meta { + name: ping + type: http + seq: 1 +} + +get { + url: https://example.com/ping + body: none + auth: none +} diff --git a/tests/folder/description/fixtures/collection/bruno.json b/tests/folder/description/fixtures/collection/bruno.json new file mode 100644 index 000000000..4e54cb584 --- /dev/null +++ b/tests/folder/description/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "fold-description", + "type": "collection" +} diff --git a/tests/folder/description/fixtures/collection/collection.bru b/tests/folder/description/fixtures/collection/collection.bru new file mode 100644 index 000000000..1ab43aaa5 --- /dev/null +++ b/tests/folder/description/fixtures/collection/collection.bru @@ -0,0 +1,5 @@ +meta { + name: fold-description + type: collection + version: 1.0.0 +} diff --git a/tests/folder/description/init-user-data/collection-security.json b/tests/folder/description/init-user-data/collection-security.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/tests/folder/description/init-user-data/collection-security.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/folder/description/init-user-data/preferences.json b/tests/folder/description/init-user-data/preferences.json new file mode 100644 index 000000000..6b2a2d602 --- /dev/null +++ b/tests/folder/description/init-user-data/preferences.json @@ -0,0 +1,28 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{collectionPath}}" + ], + "request": { + "sslVerification": false, + "customCaCertificate": { + "enabled": false, + "filePath": null + } + }, + "font": { + "codeFont": "default" + }, + "proxy": { + "enabled": false, + "protocol": "http", + "hostname": "", + "port": "", + "auth": { + "enabled": false, + "username": "", + "password": "" + }, + "bypassProxy": "" + } +} diff --git a/tests/folder/description/read-folder-description.spec.ts b/tests/folder/description/read-folder-description.spec.ts new file mode 100644 index 000000000..560889cb8 --- /dev/null +++ b/tests/folder/description/read-folder-description.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '../../../playwright'; +import { openFolderSettings } from '../../utils/page'; + +test.describe('Folder Settings Descriptions - Read', () => { + test('reads descriptions from headers and vars in a pre-existing folder.bru', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openFolderSettings(page, 'fold-description'); + + await page.getByTestId('folder-settings-tab-headers').click(); + + const headerRows = page.locator('table').first().locator('tbody tr'); + + const versionDescEditor = headerRows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(versionDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line header desc'); + + const multiDescEditor = headerRows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(multiDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Header line one'); + await expect(multiDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Header line two'); + + const plainDescEditor = headerRows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + + await page.getByTestId('folder-settings-tab-vars').click(); + + const varRows = page.getByTestId('folder-vars-req').locator('tbody tr'); + + const baseUrlDescEditor = varRows.nth(0).locator('.CodeMirror').nth(1); + await expect(baseUrlDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line var desc'); + + const apiKeyDescEditor = varRows.nth(1).locator('.CodeMirror').nth(1); + await expect(apiKeyDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Var line one'); + await expect(apiKeyDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Var line two'); + + const plainVarDescEditor = varRows.nth(2).locator('.CodeMirror').nth(1); + await expect(plainVarDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); +}); diff --git a/tests/folder/description/write-folder-headers-description.spec.ts b/tests/folder/description/write-folder-headers-description.spec.ts new file mode 100644 index 000000000..5fe6f2223 --- /dev/null +++ b/tests/folder/description/write-folder-headers-description.spec.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openFolderSettings, setTableRowDescriptionValue } from '../../utils/page'; + +test.describe('Folder Settings Descriptions - Write (Headers)', () => { + test('writes a multiline description to a header and persists it to folder.bru', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openFolderSettings(page, 'fold-description'); + + await page.getByTestId('folder-settings-tab-headers').click(); + + const headersTable = page.locator('table').first(); + const xPlainRow = headersTable.locator('tbody tr').filter({ + has: page.locator('[data-testid="column-name"] .CodeMirror-line', { hasText: 'X-Plain' }) + }); + const descCell = xPlainRow.getByTestId('column-description'); + + await setTableRowDescriptionValue(xPlainRow, 'First line\nSecond line'); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Folder Settings saved successfully')).toBeVisible({ timeout: 5000 }); + + const folderBruPath = path.join(collectionFixturePath!, 'api', 'folder.bru'); + const fileContent = fs.readFileSync(folderBruPath, 'utf8'); + + expect(fileContent).toContain('@description'); + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/folder/description/write-folder-vars-description.spec.ts b/tests/folder/description/write-folder-vars-description.spec.ts new file mode 100644 index 000000000..6a454ba6d --- /dev/null +++ b/tests/folder/description/write-folder-vars-description.spec.ts @@ -0,0 +1,39 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openFolderSettings, setTableRowDescriptionValue } from '../../utils/page'; + +test.describe('Folder Settings Descriptions - Write (Vars)', () => { + test('writes a multiline description to a pre-request var and persists it to folder.bru', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openFolderSettings(page, 'fold-description'); + + await page.getByTestId('folder-settings-tab-vars').click(); + + const varsTable = page.getByTestId('folder-vars-req'); + await expect(varsTable.locator('tbody tr').first()).toBeVisible(); + + const plainRowIndex = await varsTable.locator('[data-testid="column-name"] input').evaluateAll( + (inputs) => inputs.findIndex((el) => (el as HTMLInputElement).value === 'plain') + ); + if (plainRowIndex === -1) throw new Error('\'plain\' var not found in pre-request vars table'); + + const plainRow = varsTable.locator('tbody tr').nth(plainRowIndex); + await setTableRowDescriptionValue(plainRow, 'First line\nSecond line'); + + const descCell = plainRow.getByTestId('column-description'); + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Folder Settings saved successfully')).toBeVisible({ timeout: 5000 }); + + const folderBruPath = path.join(collectionFixturePath!, 'api', 'folder.bru'); + const fileContent = fs.readFileSync(folderBruPath, 'utf8'); + + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/import/bruno/fixtures/descriptions-collection-bru.json b/tests/import/bruno/fixtures/descriptions-collection-bru.json new file mode 100644 index 000000000..2cfe35600 --- /dev/null +++ b/tests/import/bruno/fixtures/descriptions-collection-bru.json @@ -0,0 +1,312 @@ +{ + "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": "Single-line folder header desc" + }, + { + "name": "X-Folder-Multi", + "value": "value", + "enabled": true, + "description": "Folder header line one\nFolder header line two" + }, + { + "name": "X-Folder-Plain", + "value": "plain", + "enabled": true + } + ], + "vars": { + "req": [ + { + "name": "folderBaseUrl", + "value": "https://folder.example.com", + "enabled": true, + "description": "Single-line folder var desc" + }, + { + "name": "folderApiKey", + "value": "folder-key", + "enabled": true, + "description": "Folder var line one\nFolder var line two" + }, + { + "name": "folderPlain", + "value": "no-desc", + "enabled": true + } + ], + "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": "Single-line header desc" + }, + { + "name": "X-Multi", + "value": "value", + "enabled": true, + "description": "Header line one\nHeader line two" + }, + { + "name": "X-Plain", + "value": "plain-value", + "enabled": true + } + ], + "params": [ + { + "name": "q", + "value": "search", + "type": "query", + "enabled": true, + "description": "Single-line query desc" + }, + { + "name": "page", + "value": "1", + "type": "query", + "enabled": true, + "description": "Multi-line query desc line one\nMulti-line query desc line two" + }, + { + "name": "plain-query", + "value": "value", + "type": "query", + "enabled": true + } + ], + "body": { + "mode": "multipartForm", + "formUrlEncoded": [ + { + "name": "username", + "value": "alice", + "enabled": true, + "description": "Single-line form desc" + }, + { + "name": "email", + "value": "alice@example.com", + "enabled": true, + "description": "Multi-line form desc line one\nMulti-line form desc line two" + }, + { + "name": "plain-form", + "value": "value", + "enabled": true + } + ], + "multipartForm": [ + { + "type": "text", + "name": "username", + "value": "alice", + "enabled": true, + "description": "Single-line field desc" + }, + { + "type": "text", + "name": "email", + "value": "alice@example.com", + "enabled": true, + "description": "Multi-line field desc line one\nMulti-line field desc line two" + }, + { + "type": "text", + "name": "plain-field", + "value": "value", + "enabled": true + } + ], + "file": [] + }, + "script": {}, + "vars": { + "req": [ + { + "name": "reqBaseUrl", + "value": "https://example.com", + "enabled": true, + "description": "Single-line req var desc" + }, + { + "name": "reqApiKey", + "value": "req-key", + "enabled": true, + "description": "Req var line one\nReq var line two" + }, + { + "name": "reqPlain", + "value": "no-desc", + "enabled": true + } + ], + "res": [] + }, + "assertions": [], + "tests": "", + "docs": "", + "auth": { + "mode": "none" + } + } + }, + { + "type": "http", + "name": "form-request", + "seq": 2, + "request": { + "url": "https://example.com/form", + "method": "POST", + "headers": [], + "params": [], + "body": { + "mode": "formUrlEncoded", + "formUrlEncoded": [ + { + "name": "username", + "value": "alice", + "enabled": true, + "description": "Single-line form desc" + }, + { + "name": "email", + "value": "alice@example.com", + "enabled": true, + "description": "Multi-line form desc line one\nMulti-line form desc line two" + }, + { + "name": "plain-form", + "value": "value", + "enabled": true + } + ], + "multipartForm": [], + "file": [] + }, + "script": {}, + "vars": {}, + "assertions": [], + "tests": "", + "docs": "", + "auth": { + "mode": "none" + } + } + } + ] + } + ], + "environments": [ + { + "name": "test_env", + "variables": [ + { + "name": "envHost", + "value": "http://localhost:3000", + "type": "text", + "enabled": true, + "secret": false, + "description": "Single-line env desc" + }, + { + "name": "envToken", + "value": "abc123", + "type": "text", + "enabled": true, + "secret": false, + "description": "Env line one\nEnv line two" + }, + { + "name": "envPlain", + "value": "no-desc", + "type": "text", + "enabled": true, + "secret": false + } + ] + } + ], + "root": { + "request": { + "headers": [ + { + "name": "X-Collection-Version", + "value": "2.0", + "enabled": true, + "description": "Single-line collection header desc" + }, + { + "name": "X-Collection-Multi", + "value": "value", + "enabled": true, + "description": "Collection header line one\nCollection header line two" + }, + { + "name": "X-Collection-Plain", + "value": "plain-value", + "enabled": true + } + ], + "vars": { + "req": [ + { + "name": "baseUrl", + "value": "https://example.com", + "enabled": true, + "description": "Single-line collection var desc" + }, + { + "name": "apiKey", + "value": "abc123", + "enabled": true, + "description": "Collection var line one\nCollection var line two" + }, + { + "name": "plain", + "value": "no-desc", + "enabled": true + } + ], + "res": [] + }, + "script": {}, + "tests": null + } + }, + "brunoConfig": { + "version": "1", + "name": "descriptions-imported-bru", + "type": "collection" + } +} diff --git a/tests/import/bruno/fixtures/descriptions-collection.yml b/tests/import/bruno/fixtures/descriptions-collection.yml new file mode 100644 index 000000000..639bd6625 --- /dev/null +++ b/tests/import/bruno/fixtures/descriptions-collection.yml @@ -0,0 +1,154 @@ +opencollection: "1.0.0" +info: + name: descriptions-imported-yml + +config: + environments: + - name: test_env + variables: + - name: envHost + value: http://localhost:3000 + description: Single-line env desc + - name: envToken + value: abc123 + description: |- + Env line one + Env line two + - name: envPlain + value: no-desc + +request: + headers: + - name: X-Collection-Version + value: "2.0" + description: Single-line collection header desc + - name: X-Collection-Multi + value: value + description: |- + Collection header line one + Collection header line two + - name: X-Collection-Plain + value: plain-value + variables: + - name: baseUrl + value: https://example.com + description: Single-line collection var desc + - name: apiKey + value: abc123 + description: |- + Collection var line one + Collection var line two + - name: plain + value: no-desc + +items: + - info: + type: folder + name: folder + seq: 1 + request: + headers: + - name: X-Folder-Version + value: "1.0" + description: Single-line folder header desc + - name: X-Folder-Multi + value: value + description: |- + Folder header line one + Folder header line two + - name: X-Folder-Plain + value: plain + variables: + - name: folderBaseUrl + value: https://folder.example.com + description: Single-line folder var desc + - name: folderApiKey + value: folder-key + description: |- + Folder var line one + Folder var line two + - name: folderPlain + value: no-desc + items: + - info: + type: http + name: request + seq: 1 + http: + method: POST + url: https://example.com/api + headers: + - name: X-Version + value: "2.0" + description: Single-line header desc + - name: X-Multi + value: value + description: |- + Header line one + Header line two + - name: X-Plain + value: plain-value + params: + - name: q + value: search + type: query + description: Single-line query desc + - name: page + value: "1" + type: query + description: |- + Multi-line query desc line one + Multi-line query desc line two + - name: plain-query + value: value + type: query + body: + type: multipart-form + data: + - name: username + type: text + value: alice + description: Single-line field desc + - name: email + type: text + value: alice@example.com + description: |- + Multi-line field desc line one + Multi-line field desc line two + - name: plain-field + type: text + value: value + runtime: + variables: + - name: reqBaseUrl + value: https://example.com + description: Single-line req var desc + - name: reqApiKey + value: req-key + description: |- + Req var line one + Req var line two + - name: reqPlain + value: no-desc + - info: + type: http + name: form-request + seq: 2 + http: + method: POST + url: https://example.com/form + body: + type: form-urlencoded + data: + - name: username + value: alice + description: Single-line form desc + - name: email + value: alice@example.com + description: |- + Multi-line form desc line one + Multi-line form desc line two + - name: plain-form + value: value + +bundled: true diff --git a/tests/import/bruno/import-bruno-descriptions.spec.ts b/tests/import/bruno/import-bruno-descriptions.spec.ts new file mode 100644 index 000000000..87eb39149 --- /dev/null +++ b/tests/import/bruno/import-bruno-descriptions.spec.ts @@ -0,0 +1,254 @@ +import { test, expect, Page, Locator } from '../../../playwright'; +import path from 'path'; +import { + closeAllCollections, + expandFolder, + importCollection, + selectRequestPaneTab +} from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; + +type DescriptionRow = { + singleLine?: string; + multiLine?: [string, string]; + empty?: boolean; +}; + +const expectDescriptionCell = async (descCell: Locator, expected: DescriptionRow) => { + const cm = descCell.getByTestId('column-description').locator('.CodeMirror'); + if (expected.empty) { + await expect(cm.locator('.CodeMirror-line').first()).toHaveText(''); + return; + } + if (expected.singleLine) { + await expect(cm.locator('.CodeMirror-line').first()).toHaveText(expected.singleLine); + return; + } + if (expected.multiLine) { + await expect(cm.locator('.CodeMirror-line').nth(0)).toHaveText(expected.multiLine[0]); + await expect(cm.locator('.CodeMirror-line').nth(1)).toHaveText(expected.multiLine[1]); + } +}; + +const expectVarDescriptionCell = async (row: Locator, expected: DescriptionRow) => { + const cm = row.getByTestId('column-description').locator('.CodeMirror'); + if (expected.empty) { + await expect(cm.locator('.CodeMirror-line').first()).toHaveText(''); + return; + } + if (expected.singleLine) { + await expect(cm.locator('.CodeMirror-line').first()).toHaveText(expected.singleLine); + return; + } + if (expected.multiLine) { + await expect(cm.locator('.CodeMirror-line').nth(0)).toHaveText(expected.multiLine[0]); + await expect(cm.locator('.CodeMirror-line').nth(1)).toHaveText(expected.multiLine[1]); + } +}; + +const openImportedRequest = async (page: Page, collectionName: string, requestName = 'request') => { + const locators = buildCommonLocators(page); + const collectionScope = locators.sidebar.collectionScope(collectionName); + const collectionRow = locators.sidebar.collection(collectionName); + await expect(collectionRow).toBeVisible(); + + const folderRow = collectionScope.locator('.collection-item-name').filter({ hasText: 'folder' }); + if (!(await folderRow.isVisible().catch(() => false))) { + await collectionRow.click(); + await expect(folderRow).toBeVisible(); + } + + await expandFolder(page, 'folder'); + + const requestLink = collectionScope + .getByTestId('sidebar-collection-item-row') + .filter({ has: page.getByText(requestName, { exact: true }) }); + await expect(requestLink).toBeVisible(); + + await requestLink.click(); + await expect(locators.tabs.activeRequestTab()).toContainText(requestName); +}; + +const openCollectionSettings = async (page: Page, collectionName: string) => { + await page.locator('#sidebar-collection-name').filter({ hasText: collectionName }).click(); + await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'Collection' })).toBeVisible(); +}; + +const openFolderSettings = async (page: Page, collectionName: string) => { + const locators = buildCommonLocators(page); + const collectionScope = locators.sidebar.collectionScope(collectionName); + const collectionRow = locators.sidebar.collection(collectionName); + await expect(collectionRow).toBeVisible(); + + const folderRow = collectionScope.locator('.collection-item-name').filter({ hasText: 'folder' }); + if (!(await folderRow.isVisible().catch(() => false))) { + await collectionRow.click(); + await expect(folderRow).toBeVisible(); + } + + await folderRow.dblclick(); + await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'folder' })).toBeVisible(); +}; + +const openEnvironmentConfigure = async (page: Page, collectionName: string, envName: string) => { + const locators = buildCommonLocators(page); + await locators.sidebar.collection(collectionName).click(); + await locators.environment.currentEnvironment().click(); + await expect(locators.environment.envOption(envName)).toBeVisible(); + await locators.environment.envOption(envName).click(); + await expect(locators.environment.currentEnvironment().filter({ hasText: envName })).toBeVisible(); + await locators.environment.currentEnvironment().click(); + await expect(locators.dropdown.item('Configure')).toBeVisible(); + await locators.dropdown.item('Configure').click(); + await expect(locators.tabs.requestTab('Environments')).toBeVisible(); +}; + +const assertRequestDescriptions = async (page: Page, collectionName: string) => { + await openImportedRequest(page, collectionName, 'request'); + + await selectRequestPaneTab(page, 'Headers'); + const headerRows = page.getByTestId('request-headers-table').locator('tbody tr'); + await expectDescriptionCell(headerRows.nth(0), { singleLine: 'Single-line header desc' }); + await expectDescriptionCell(headerRows.nth(1), { multiLine: ['Header line one', 'Header line two'] }); + await expectDescriptionCell(headerRows.nth(2), { empty: true }); + + await selectRequestPaneTab(page, 'Params'); + const paramRows = page.getByTestId('query-params-table').locator('tbody tr'); + await expectDescriptionCell(paramRows.nth(0), { singleLine: 'Single-line query desc' }); + await expectDescriptionCell(paramRows.nth(1), { + multiLine: ['Multi-line query desc line one', 'Multi-line query desc line two'] + }); + await expectDescriptionCell(paramRows.nth(2), { empty: true }); + + await selectRequestPaneTab(page, 'Vars'); + const reqVarRows = page.getByTestId('request-vars-req').locator('tbody tr'); + await expectVarDescriptionCell(reqVarRows.nth(0), { singleLine: 'Single-line req var desc' }); + await expectVarDescriptionCell(reqVarRows.nth(1), { multiLine: ['Req var line one', 'Req var line two'] }); + await expectVarDescriptionCell(reqVarRows.nth(2), { empty: true }); + + await selectRequestPaneTab(page, 'Body'); + const multipartRows = page.getByTestId('multipart-form-table').locator('tbody tr'); + await expectDescriptionCell(multipartRows.nth(0), { singleLine: 'Single-line field desc' }); + await expectDescriptionCell(multipartRows.nth(1), { + multiLine: ['Multi-line field desc line one', 'Multi-line field desc line two'] + }); + await expectDescriptionCell(multipartRows.nth(2), { empty: true }); + + await openImportedRequest(page, collectionName, 'form-request'); + await selectRequestPaneTab(page, 'Body'); + const formRows = page.getByTestId('form-urlencoded-table').locator('tbody tr'); + await expectDescriptionCell(formRows.nth(0), { singleLine: 'Single-line form desc' }); + await expectDescriptionCell(formRows.nth(1), { + multiLine: ['Multi-line form desc line one', 'Multi-line form desc line two'] + }); + await expectDescriptionCell(formRows.nth(2), { empty: true }); +}; + +const assertCollectionDescriptions = async (page: Page, collectionName: string) => { + await openCollectionSettings(page, collectionName); + await page.getByTestId('collection-settings-tab-headers').click(); + + const headerTable = page.getByTestId('collection-headers'); + const headerRows = headerTable.locator('tbody tr'); + await expectDescriptionCell(headerRows.nth(0), { singleLine: 'Single-line collection header desc' }); + await expectDescriptionCell(headerRows.nth(1), { + multiLine: ['Collection header line one', 'Collection header line two'] + }); + await expectDescriptionCell(headerRows.nth(2), { empty: true }); + + await page.getByTestId('collection-settings-tab-vars').click(); + const varRows = page.getByTestId('collection-vars-req').locator('tbody tr'); + await expectVarDescriptionCell(varRows.nth(0), { singleLine: 'Single-line collection var desc' }); + await expectVarDescriptionCell(varRows.nth(1), { + multiLine: ['Collection var line one', 'Collection var line two'] + }); + await expectVarDescriptionCell(varRows.nth(2), { empty: true }); +}; + +const assertFolderDescriptions = async (page: Page, collectionName: string) => { + await openFolderSettings(page, collectionName); + await page.getByTestId('folder-settings-tab-headers').click(); + + const headerRows = page.locator('table').first().locator('tbody tr'); + await expectDescriptionCell(headerRows.nth(0), { singleLine: 'Single-line folder header desc' }); + await expectDescriptionCell(headerRows.nth(1), { + multiLine: ['Folder header line one', 'Folder header line two'] + }); + await expectDescriptionCell(headerRows.nth(2), { empty: true }); + + await page.getByTestId('folder-settings-tab-vars').click(); + const varRows = page.getByTestId('folder-vars-req').locator('tbody tr'); + await expectVarDescriptionCell(varRows.nth(0), { singleLine: 'Single-line folder var desc' }); + await expectVarDescriptionCell(varRows.nth(1), { multiLine: ['Folder var line one', 'Folder var line two'] }); + await expectVarDescriptionCell(varRows.nth(2), { empty: true }); +}; + +const assertEnvironmentDescriptions = async (page: Page, collectionName: string) => { + const locators = buildCommonLocators(page); + await openEnvironmentConfigure(page, collectionName, 'test_env'); + + const hostDesc = locators.environment.variableDescriptionEditor(0); + await expect(hostDesc.locator('.CodeMirror-line').first()).toHaveText('Single-line env desc'); + + const tokenDesc = locators.environment.variableDescriptionEditor(1); + await expect(tokenDesc.locator('.CodeMirror-line').nth(0)).toHaveText('Env line one'); + await expect(tokenDesc.locator('.CodeMirror-line').nth(1)).toHaveText('Env line two'); + + const plainDesc = locators.environment.variableDescriptionEditor(2); + await expect(plainDesc.locator('.CodeMirror-line').first()).toHaveText(''); +}; + +const runDescriptionImportAssertions = async (page: Page, collectionName: string) => { + await assertRequestDescriptions(page, collectionName); + await assertCollectionDescriptions(page, collectionName); + await assertFolderDescriptions(page, collectionName); + await assertEnvironmentDescriptions(page, collectionName); +}; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Description import — Bruno JSON (.bru) collection', () => { + test.afterEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test('preserves descriptions across request, collection, folder, and environment surfaces', async ({ + page, + createTmpDir + }, testInfo) => { + testInfo.setTimeout(120_000); + + const fixtureFile = path.resolve(__dirname, 'fixtures', 'descriptions-collection-bru.json'); + const collectionLocation = await createTmpDir('descriptions-imported-bru'); + + await importCollection(page, fixtureFile, collectionLocation, { + expectedCollectionName: 'descriptions-imported-bru', + sidebarTimeout: 15000 + }); + + await runDescriptionImportAssertions(page, 'descriptions-imported-bru'); + }); +}); + +test.describe('Description import — OpenCollection (.yml) collection', () => { + test.afterEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test('preserves descriptions across request, collection, folder, and environment surfaces', async ({ + page, + createTmpDir + }, testInfo) => { + testInfo.setTimeout(120_000); + + const fixtureFile = path.resolve(__dirname, 'fixtures', 'descriptions-collection.yml'); + const collectionLocation = await createTmpDir('descriptions-imported-yml'); + + await importCollection(page, fixtureFile, collectionLocation, { + expectedCollectionName: 'descriptions-imported-yml', + sidebarTimeout: 15000 + }); + + await runDescriptionImportAssertions(page, 'descriptions-imported-yml'); + }); +}); diff --git a/tests/request/body-scroll/scroll-persistent.spec.ts b/tests/request/body-scroll/scroll-persistent.spec.ts index 9f9b6a773..b39428ec8 100644 --- a/tests/request/body-scroll/scroll-persistent.spec.ts +++ b/tests/request/body-scroll/scroll-persistent.spec.ts @@ -458,7 +458,7 @@ test.describe('Scroll Position Persistence', () => { test('Request Headers - scroll persists with many headers across tab switches', async ({ pageWithUserData: page }) => { await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); const scrollContainer = '.flex-boundary'; - const firstVisibleRowLocator = () => page.getByTestId('editable-table').locator('table > tbody > tr:nth-child(2)'); + const firstVisibleRowLocator = () => page.getByTestId('request-headers-table').locator('table > tbody > tr[data-index]').first(); await test.step('Setup request and navigate to Headers tab', async () => { await openCollection(page, 'scroll-fixtures'); @@ -1174,7 +1174,7 @@ test.describe('Scroll Position Persistence', () => { await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); const locators = buildCommonLocators(page); const scrollContainer = '.collection-settings-content'; - const firstVisibleRowLocator = () => page.getByTestId('editable-table').locator('table > tbody > tr:nth-child(2)'); + const firstVisibleRowLocator = () => page.getByTestId('collection-headers').locator('table > tbody > tr[data-index]').first(); await test.step('Open collection settings and navigate to Headers tab', async () => { await openCollection(page, 'scroll-fixtures'); diff --git a/tests/request/description-yml/fixtures/collection/bruno.json b/tests/request/description-yml/fixtures/collection/bruno.json new file mode 100644 index 000000000..4e801042e --- /dev/null +++ b/tests/request/description-yml/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "req-description-yml", + "type": "collection" +} diff --git a/tests/request/description-yml/fixtures/collection/formurlencoded-with-descriptions.yml b/tests/request/description-yml/fixtures/collection/formurlencoded-with-descriptions.yml new file mode 100644 index 000000000..329eeb5eb --- /dev/null +++ b/tests/request/description-yml/fixtures/collection/formurlencoded-with-descriptions.yml @@ -0,0 +1,27 @@ +info: + name: formurlencoded-with-descriptions + type: http + seq: 2 + +http: + method: POST + url: https://example.com/form + body: + type: form-urlencoded + data: + - name: username + value: alice + description: Single-line form desc + - name: password + value: secret + description: |- + Multi-line form desc line one + Multi-line form desc line two + - name: plain-field + value: no-desc + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/tests/request/description-yml/fixtures/collection/multipart-with-descriptions.yml b/tests/request/description-yml/fixtures/collection/multipart-with-descriptions.yml new file mode 100644 index 000000000..c18f741d7 --- /dev/null +++ b/tests/request/description-yml/fixtures/collection/multipart-with-descriptions.yml @@ -0,0 +1,30 @@ +info: + name: multipart-with-descriptions + type: http + seq: 3 + +http: + method: POST + url: https://example.com/upload + body: + type: multipart-form + data: + - name: username + type: text + value: alice + description: Single-line field desc + - name: email + type: text + value: alice@example.com + description: |- + Multi-line field desc line one + Multi-line field desc line two + - name: plain-field + type: text + value: no-desc + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/tests/request/description-yml/fixtures/collection/opencollection.yml b/tests/request/description-yml/fixtures/collection/opencollection.yml new file mode 100644 index 000000000..bcb5d60ac --- /dev/null +++ b/tests/request/description-yml/fixtures/collection/opencollection.yml @@ -0,0 +1,3 @@ +opencollection: "1.0.0" +info: + name: req-description-yml diff --git a/tests/request/description-yml/fixtures/collection/request-with-descriptions.yml b/tests/request/description-yml/fixtures/collection/request-with-descriptions.yml new file mode 100644 index 000000000..11fe60541 --- /dev/null +++ b/tests/request/description-yml/fixtures/collection/request-with-descriptions.yml @@ -0,0 +1,66 @@ +info: + name: request-with-descriptions + type: http + seq: 1 + +http: + method: GET + url: https://example.com/api + headers: + - name: X-Version + value: "2.0" + description: Single-line header desc + - name: X-Multi + value: value + description: |- + Header line one + Header line two + - name: X-Plain + value: plain-value + params: + - name: q + value: search + type: query + description: Single-line query desc + - name: page + value: "1" + type: query + description: |- + Multi-line query desc line one + Multi-line query desc line two + - name: plain-query + value: value + type: query + +runtime: + variables: + - name: apiKey + value: secret + description: Single-line var desc + - name: baseUrl + value: https://example.com + description: |- + Multi-line var desc line one + Multi-line var desc line two + - name: plainVar + value: value + assertions: + - expression: res.status + operator: eq + value: "200" + description: Single-line assert desc + - expression: res.body.ok + operator: eq + value: "true" + description: |- + Multi-line assert line one + Multi-line assert line two + - expression: plainAssert + operator: eq + value: foo + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/tests/request/description-yml/init-user-data/collection-security.json b/tests/request/description-yml/init-user-data/collection-security.json new file mode 100644 index 000000000..89dc2bfff --- /dev/null +++ b/tests/request/description-yml/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{collectionPath}}", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} diff --git a/tests/request/description-yml/init-user-data/preferences.json b/tests/request/description-yml/init-user-data/preferences.json new file mode 100644 index 000000000..6b2a2d602 --- /dev/null +++ b/tests/request/description-yml/init-user-data/preferences.json @@ -0,0 +1,28 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{collectionPath}}" + ], + "request": { + "sslVerification": false, + "customCaCertificate": { + "enabled": false, + "filePath": null + } + }, + "font": { + "codeFont": "default" + }, + "proxy": { + "enabled": false, + "protocol": "http", + "hostname": "", + "port": "", + "auth": { + "enabled": false, + "username": "", + "password": "" + }, + "bypassProxy": "" + } +} diff --git a/tests/request/description-yml/read-request-description.spec.ts b/tests/request/description-yml/read-request-description.spec.ts new file mode 100644 index 000000000..2afe66928 --- /dev/null +++ b/tests/request/description-yml/read-request-description.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; + +const COLLECTION = 'req-description-yml'; + +test.describe('Request Description (YAML) - Read', () => { + test('reads descriptions from request headers in a pre-existing .yml file', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Headers'); + + const headersTable = page.getByTestId('request-headers-table'); + const rows = headersTable.locator('tbody tr'); + + // row 0: X-Version — single-line description + const versionDescEditor = rows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(versionDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line header desc'); + + // row 1: X-Multi — multiline description + const multiDescEditor = rows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(multiDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Header line one'); + await expect(multiDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Header line two'); + + // row 2: X-Plain — no description (editor is empty) + const plainDescEditor = rows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); + + test('reads descriptions from request query params in a pre-existing .yml file', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Params'); + + const queryTable = page.getByTestId('query-params-table'); + const rows = queryTable.locator('tbody tr'); + + // row 0: q — single-line description + const qDescEditor = rows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(qDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line query desc'); + + // row 1: page — multiline description + const pageDescEditor = rows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(pageDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Multi-line query desc line one'); + await expect(pageDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Multi-line query desc line two'); + + // row 2: plain-query — no description + const plainDescEditor = rows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); + + test('reads descriptions from multipart form fields in a pre-existing .yml file', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openRequest(page, COLLECTION, 'multipart-with-descriptions'); + await selectRequestPaneTab(page, 'Body'); + + const multipartTable = page.getByTestId('multipart-form-table'); + const rows = multipartTable.locator('tbody tr'); + + // row 0: username — single-line description + const usernameDescEditor = rows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(usernameDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line field desc'); + + // row 1: email — multiline description + const emailDescEditor = rows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(emailDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Multi-line field desc line one'); + await expect(emailDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Multi-line field desc line two'); + + // row 2: plain-field — no description + const plainDescEditor = rows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); + + test('reads descriptions from form-urlencoded fields in a pre-existing .yml file', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openRequest(page, COLLECTION, 'formurlencoded-with-descriptions'); + await selectRequestPaneTab(page, 'Body'); + + const formTable = page.getByTestId('form-urlencoded-table'); + const rows = formTable.locator('tbody tr'); + + // row 0: username — single-line description + const usernameDescEditor = rows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(usernameDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line form desc'); + + // row 1: password — multiline description + const passwordDescEditor = rows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(passwordDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Multi-line form desc line one'); + await expect(passwordDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Multi-line form desc line two'); + + // row 2: plain-field — no description + const plainDescEditor = rows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); +}); diff --git a/tests/request/description-yml/write-request-assertions-description.spec.ts b/tests/request/description-yml/write-request-assertions-description.spec.ts new file mode 100644 index 000000000..fefc6817e --- /dev/null +++ b/tests/request/description-yml/write-request-assertions-description.spec.ts @@ -0,0 +1,43 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; + +const COLLECTION = 'req-description-yml'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description (YAML) - Write (Assertions)', () => { + test('writes a multiline description to a request assertion and persists it to the .yml file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Assert'); + + const assertionsTable = buildCommonLocators(page).table('assertions-table'); + const plainAssertRow = assertionsTable.rowByName('plainAssert'); + const descCell = plainAssertRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in plainAssert description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('First line\nSecond line'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const ymlPath = path.join(collectionFixturePath!, 'request-with-descriptions.yml'); + const fileContent = fs.readFileSync(ymlPath, 'utf8'); + + expect(fileContent).toContain('description:'); + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/request/description-yml/write-request-body-form-urlencoded-description.spec.ts b/tests/request/description-yml/write-request-body-form-urlencoded-description.spec.ts new file mode 100644 index 000000000..f5e608a46 --- /dev/null +++ b/tests/request/description-yml/write-request-body-form-urlencoded-description.spec.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; + +const COLLECTION = 'req-description-yml'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description (YAML) - Write (Body: form-urlencoded)', () => { + test('writes a multiline description to a form field and persists it to the .yml file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'formurlencoded-with-descriptions'); + await selectRequestPaneTab(page, 'Body'); + + const formTable = page.getByTestId('form-urlencoded-table'); + const plainRow = formTable.locator('tbody tr').nth(2); + const descCell = plainRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in plain-field description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('Form line one\nForm line two'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('Form line one'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Form line two'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const ymlPath = path.join(collectionFixturePath!, 'formurlencoded-with-descriptions.yml'); + const fileContent = fs.readFileSync(ymlPath, 'utf8'); + + expect(fileContent).toContain('description:'); + expect(fileContent).toContain('Form line one'); + expect(fileContent).toContain('Form line two'); + }); +}); diff --git a/tests/request/description-yml/write-request-body-multipart-description.spec.ts b/tests/request/description-yml/write-request-body-multipart-description.spec.ts new file mode 100644 index 000000000..dc8a7e204 --- /dev/null +++ b/tests/request/description-yml/write-request-body-multipart-description.spec.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; + +const COLLECTION = 'req-description-yml'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description (YAML) - Write (Body: multipart-form)', () => { + test('writes a multiline description to a multipart field and persists it to the .yml file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'multipart-with-descriptions'); + await selectRequestPaneTab(page, 'Body'); + + const multipartTable = page.getByTestId('multipart-form-table'); + const plainRow = multipartTable.locator('tbody tr').nth(2); + const descCell = plainRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in plain-field description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('Field line one\nField line two'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('Field line one'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Field line two'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const ymlPath = path.join(collectionFixturePath!, 'multipart-with-descriptions.yml'); + const fileContent = fs.readFileSync(ymlPath, 'utf8'); + + expect(fileContent).toContain('description:'); + expect(fileContent).toContain('Field line one'); + expect(fileContent).toContain('Field line two'); + }); +}); diff --git a/tests/request/description-yml/write-request-headers-description.spec.ts b/tests/request/description-yml/write-request-headers-description.spec.ts new file mode 100644 index 000000000..cd6235638 --- /dev/null +++ b/tests/request/description-yml/write-request-headers-description.spec.ts @@ -0,0 +1,44 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; + +const COLLECTION = 'req-description-yml'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description (YAML) - Write (Headers)', () => { + test('writes a multiline description to a request header and persists it to the .yml file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Headers'); + + const headersTable = page.getByTestId('request-headers-table'); + const xPlainRow = headersTable.locator('tbody tr').filter({ + has: page.locator('[data-testid="column-name"] .CodeMirror-line', { hasText: 'X-Plain' }) + }); + const descCell = xPlainRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in X-Plain description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('First line\nSecond line'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const ymlPath = path.join(collectionFixturePath!, 'request-with-descriptions.yml'); + const fileContent = fs.readFileSync(ymlPath, 'utf8'); + + expect(fileContent).toContain('description:'); + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/request/description-yml/write-request-params-description.spec.ts b/tests/request/description-yml/write-request-params-description.spec.ts new file mode 100644 index 000000000..29769648d --- /dev/null +++ b/tests/request/description-yml/write-request-params-description.spec.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; + +const COLLECTION = 'req-description-yml'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description (YAML) - Write (Query Params)', () => { + test('writes a multiline description to a query param and persists it to the .yml file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Params'); + + const queryTable = page.getByTestId('query-params-table'); + const plainRow = queryTable.locator('tbody tr').nth(2); + const descCell = plainRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in plain-query description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('Query line one\nQuery line two'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('Query line one'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Query line two'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const ymlPath = path.join(collectionFixturePath!, 'request-with-descriptions.yml'); + const fileContent = fs.readFileSync(ymlPath, 'utf8'); + + expect(fileContent).toContain('description:'); + expect(fileContent).toContain('Query line one'); + expect(fileContent).toContain('Query line two'); + }); +}); diff --git a/tests/request/description-yml/write-request-vars-description.spec.ts b/tests/request/description-yml/write-request-vars-description.spec.ts new file mode 100644 index 000000000..f55f746c7 --- /dev/null +++ b/tests/request/description-yml/write-request-vars-description.spec.ts @@ -0,0 +1,43 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; + +const COLLECTION = 'req-description-yml'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description (YAML) - Write (Vars)', () => { + test('writes a multiline description to a request var and persists it to the .yml file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Vars'); + + const varsTable = buildCommonLocators(page).table('request-vars-req'); + const plainVarRow = varsTable.rowByName('plainVar'); + const descCell = plainVarRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in plainVar description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('First line\nSecond line'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const ymlPath = path.join(collectionFixturePath!, 'request-with-descriptions.yml'); + const fileContent = fs.readFileSync(ymlPath, 'utf8'); + + expect(fileContent).toContain('description:'); + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/request/description/fixtures/collection/bruno.json b/tests/request/description/fixtures/collection/bruno.json new file mode 100644 index 000000000..856c752f4 --- /dev/null +++ b/tests/request/description/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "req-description", + "type": "collection" +} diff --git a/tests/request/description/fixtures/collection/collection.bru b/tests/request/description/fixtures/collection/collection.bru new file mode 100644 index 000000000..e9f3e6442 --- /dev/null +++ b/tests/request/description/fixtures/collection/collection.bru @@ -0,0 +1,5 @@ +meta { + name: req-description + type: collection + version: 1.0.0 +} diff --git a/tests/request/description/fixtures/collection/formurlencoded-with-descriptions.bru b/tests/request/description/fixtures/collection/formurlencoded-with-descriptions.bru new file mode 100644 index 000000000..c3bf25268 --- /dev/null +++ b/tests/request/description/fixtures/collection/formurlencoded-with-descriptions.bru @@ -0,0 +1,22 @@ +meta { + name: formurlencoded-with-descriptions + type: http + seq: 3 +} + +post { + url: https://example.com/form + body: formUrlEncoded + auth: none +} + +body:form-urlencoded { + @description('''Single-line form desc''') + username: alice + @description(''' + Multi-line form desc line one + Multi-line form desc line two + ''') + password: secret + plain-field: no-desc +} diff --git a/tests/request/description/fixtures/collection/multipart-with-descriptions.bru b/tests/request/description/fixtures/collection/multipart-with-descriptions.bru new file mode 100644 index 000000000..18cb1b767 --- /dev/null +++ b/tests/request/description/fixtures/collection/multipart-with-descriptions.bru @@ -0,0 +1,22 @@ +meta { + name: multipart-with-descriptions + type: http + seq: 2 +} + +post { + url: https://example.com/upload + body: multipartForm + auth: none +} + +body:multipart-form { + @description('''Single-line field desc''') + username: alice + @description(''' + Multi-line field desc line one + Multi-line field desc line two + ''') + email: alice@example.com + plain-field: no-desc +} diff --git a/tests/request/description/fixtures/collection/request-with-descriptions.bru b/tests/request/description/fixtures/collection/request-with-descriptions.bru new file mode 100644 index 000000000..5fc55678e --- /dev/null +++ b/tests/request/description/fixtures/collection/request-with-descriptions.bru @@ -0,0 +1,55 @@ +meta { + name: request-with-descriptions + type: http + seq: 1 +} + +get { + url: https://example.com/api + body: none + auth: none +} + +params:query { + @description('''Single-line query desc''') + q: search + @description(''' + Multi-line query desc line one + Multi-line query desc line two + ''') + page: 1 + plain-query: value +} + +headers { + @description('''Single-line header desc''') + X-Version: 2.0 + @description(''' + Header line one + Header line two + ''') + X-Multi: value + X-Plain: plain-value +} + +vars:pre-request { + @description('''Single-line var desc''') + apiKey: secret + @description(''' + Multi-line var desc line one + Multi-line var desc line two + ''') + baseUrl: https://example.com + plainVar: value +} + +assert { + @description('''Single-line assert desc''') + res.status: eq 200 + @description(''' + Multi-line assert line one + Multi-line assert line two + ''') + res.body.ok: eq true + plainAssert: eq foo +} diff --git a/tests/request/description/init-user-data/collection-security.json b/tests/request/description/init-user-data/collection-security.json new file mode 100644 index 000000000..89dc2bfff --- /dev/null +++ b/tests/request/description/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{collectionPath}}", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} diff --git a/tests/request/description/init-user-data/preferences.json b/tests/request/description/init-user-data/preferences.json new file mode 100644 index 000000000..6b2a2d602 --- /dev/null +++ b/tests/request/description/init-user-data/preferences.json @@ -0,0 +1,28 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{collectionPath}}" + ], + "request": { + "sslVerification": false, + "customCaCertificate": { + "enabled": false, + "filePath": null + } + }, + "font": { + "codeFont": "default" + }, + "proxy": { + "enabled": false, + "protocol": "http", + "hostname": "", + "port": "", + "auth": { + "enabled": false, + "username": "", + "password": "" + }, + "bypassProxy": "" + } +} diff --git a/tests/request/description/read-request-description.spec.ts b/tests/request/description/read-request-description.spec.ts new file mode 100644 index 000000000..498c70747 --- /dev/null +++ b/tests/request/description/read-request-description.spec.ts @@ -0,0 +1,150 @@ +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; + +const COLLECTION = 'req-description'; + +test.describe('Request Description - Read', () => { + test('reads descriptions from request headers in a pre-existing .bru file', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Headers'); + + const headersTable = page.getByTestId('request-headers-table'); + const rows = headersTable.locator('tbody tr'); + + // row 0: X-Version — single-line description + const versionDescEditor = rows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(versionDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line header desc'); + + // row 1: X-Multi — multiline description + const multiDescEditor = rows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(multiDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Header line one'); + await expect(multiDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Header line two'); + + // row 2: X-Plain — no description (editor is empty) + const plainDescEditor = rows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); + + test('reads descriptions from request query params in a pre-existing .bru file', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Params'); + + const queryTable = page.getByTestId('query-params-table'); + const rows = queryTable.locator('tbody tr'); + + // row 0: q — single-line description + const qDescEditor = rows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(qDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line query desc'); + + // row 1: page — multiline description + const pageDescEditor = rows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(pageDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Multi-line query desc line one'); + await expect(pageDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Multi-line query desc line two'); + + // row 2: plain-query — no description + const plainDescEditor = rows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); + + test('reads descriptions from multipart form fields in a pre-existing .bru file', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openRequest(page, COLLECTION, 'multipart-with-descriptions'); + await selectRequestPaneTab(page, 'Body'); + + const multipartTable = page.getByTestId('multipart-form-table'); + const rows = multipartTable.locator('tbody tr'); + + // row 0: username — single-line description + const usernameDescEditor = rows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(usernameDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line field desc'); + + // row 1: email — multiline description + const emailDescEditor = rows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(emailDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Multi-line field desc line one'); + await expect(emailDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Multi-line field desc line two'); + + // row 2: plain-field — no description + const plainDescEditor = rows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); + + test('reads descriptions from form-urlencoded fields in a pre-existing .bru file', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openRequest(page, COLLECTION, 'formurlencoded-with-descriptions'); + await selectRequestPaneTab(page, 'Body'); + + const formTable = page.getByTestId('form-urlencoded-table'); + const rows = formTable.locator('tbody tr'); + + // row 0: username — single-line description + const usernameDescEditor = rows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(usernameDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line form desc'); + + // row 1: password — multiline description + const passwordDescEditor = rows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(passwordDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Multi-line form desc line one'); + await expect(passwordDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Multi-line form desc line two'); + + // row 2: plain-field — no description + const plainDescEditor = rows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); + + test('reads descriptions from request vars in a pre-existing .bru file', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Vars'); + + const varsTable = page.getByTestId('request-vars-req'); + const rows = varsTable.locator('tbody tr'); + + const apiKeyDescEditor = rows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(apiKeyDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line var desc'); + + const baseUrlDescEditor = rows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(baseUrlDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Multi-line var desc line one'); + await expect(baseUrlDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Multi-line var desc line two'); + + const plainDescEditor = rows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); + + test('reads descriptions from request assertions in a pre-existing .bru file', async ({ + pageWithUserData: page + }) => { + test.setTimeout(30_000); + + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Assert'); + + const assertionsTable = page.getByTestId('assertions-table'); + const rows = assertionsTable.locator('tbody tr'); + + const statusDescEditor = rows.nth(0).getByTestId('column-description').locator('.CodeMirror'); + await expect(statusDescEditor.locator('.CodeMirror-line').first()).toHaveText('Single-line assert desc'); + + const bodyDescEditor = rows.nth(1).getByTestId('column-description').locator('.CodeMirror'); + await expect(bodyDescEditor.locator('.CodeMirror-line').nth(0)).toHaveText('Multi-line assert line one'); + await expect(bodyDescEditor.locator('.CodeMirror-line').nth(1)).toHaveText('Multi-line assert line two'); + + const plainDescEditor = rows.nth(2).getByTestId('column-description').locator('.CodeMirror'); + await expect(plainDescEditor.locator('.CodeMirror-line').first()).toHaveText(''); + }); +}); diff --git a/tests/request/description/write-request-assertions-description.spec.ts b/tests/request/description/write-request-assertions-description.spec.ts new file mode 100644 index 000000000..6b94a71f4 --- /dev/null +++ b/tests/request/description/write-request-assertions-description.spec.ts @@ -0,0 +1,43 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; + +const COLLECTION = 'req-description'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description - Write (Assertions)', () => { + test('writes a multiline description to a request assertion and persists it to the .bru file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Assert'); + + const assertionsTable = buildCommonLocators(page).table('assertions-table'); + const plainAssertRow = assertionsTable.rowByName('plainAssert'); + const descCell = plainAssertRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in plainAssert description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('First line\nSecond line'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const bruPath = path.join(collectionFixturePath!, 'request-with-descriptions.bru'); + const fileContent = fs.readFileSync(bruPath, 'utf8'); + + expect(fileContent).toContain('@description'); + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/request/description/write-request-body-form-urlencoded-description.spec.ts b/tests/request/description/write-request-body-form-urlencoded-description.spec.ts new file mode 100644 index 000000000..7d468f261 --- /dev/null +++ b/tests/request/description/write-request-body-form-urlencoded-description.spec.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; + +const COLLECTION = 'req-description'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description - Write (Body: form-urlencoded)', () => { + test('writes a multiline description to a form field and persists it to the .bru file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'formurlencoded-with-descriptions'); + await selectRequestPaneTab(page, 'Body'); + + const formTable = page.getByTestId('form-urlencoded-table'); + const plainRow = formTable.locator('tbody tr').nth(2); + const descCell = plainRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in plain-field description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('Form line one\nForm line two'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('Form line one'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Form line two'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const bruPath = path.join(collectionFixturePath!, 'formurlencoded-with-descriptions.bru'); + const fileContent = fs.readFileSync(bruPath, 'utf8'); + + expect(fileContent).toContain('@description'); + expect(fileContent).toContain('Form line one'); + expect(fileContent).toContain('Form line two'); + }); +}); diff --git a/tests/request/description/write-request-body-multipart-description.spec.ts b/tests/request/description/write-request-body-multipart-description.spec.ts new file mode 100644 index 000000000..bfb1a8c16 --- /dev/null +++ b/tests/request/description/write-request-body-multipart-description.spec.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; + +const COLLECTION = 'req-description'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description - Write (Body: multipart-form)', () => { + test('writes a multiline description to a multipart field and persists it to the .bru file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'multipart-with-descriptions'); + await selectRequestPaneTab(page, 'Body'); + + const multipartTable = page.getByTestId('multipart-form-table'); + const plainRow = multipartTable.locator('tbody tr').nth(2); + const descCell = plainRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in plain-field description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('Field line one\nField line two'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('Field line one'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Field line two'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const bruPath = path.join(collectionFixturePath!, 'multipart-with-descriptions.bru'); + const fileContent = fs.readFileSync(bruPath, 'utf8'); + + expect(fileContent).toContain('@description'); + expect(fileContent).toContain('Field line one'); + expect(fileContent).toContain('Field line two'); + }); +}); diff --git a/tests/request/description/write-request-headers-description-escaping.spec.ts b/tests/request/description/write-request-headers-description-escaping.spec.ts new file mode 100644 index 000000000..d8a7ef67e --- /dev/null +++ b/tests/request/description/write-request-headers-description-escaping.spec.ts @@ -0,0 +1,63 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page'; + +const COLLECTION = 'req-description'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description - Write (Headers, quote escaping)', () => { + test('escapes embedded triple quotes and backslash-quotes in a multiline description and round-trips through disk', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Headers'); + + const headersTable = page.getByTestId('request-headers-table'); + const xPlainRow = headersTable.locator('tbody tr').filter({ + has: page.locator('[data-testid="column-name"] .CodeMirror-line', { hasText: 'X-Plain' }) + }); + const descCell = xPlainRow.getByTestId('column-description'); + + const description = 'Backslash-quote: \\\'end\nTriple quote: \'\'\' embedded\nFinal line'; + + await descCell.evaluate((el, value) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in X-Plain description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue(value); + }, description); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('Backslash-quote: \\\'end'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Triple quote: \'\'\' embedded'); + await expect(descCell.locator('.CodeMirror-line').nth(2)).toHaveText('Final line'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const bruPath = path.join(collectionFixturePath!, 'request-with-descriptions.bru'); + const fileContent = fs.readFileSync(bruPath, 'utf8'); + + // the ''' delimiter is escaped so it can't be mistaken for the closing block delimiter + expect(fileContent).toContain('\\\'\\\'\\\' embedded'); + // the pre-existing backslash-quote is doubled so decoding can tell it apart from the above + expect(fileContent).toContain('\\\\\'end'); + + // reopen the request from disk to verify the escaped value round-trips back exactly + await buildCommonLocators(page).tabs.closeTab('request-with-descriptions').click({ force: true }); + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Headers'); + + const reopenedRow = page.getByTestId('request-headers-table').locator('tbody tr').filter({ + has: page.locator('[data-testid="column-name"] .CodeMirror-line', { hasText: 'X-Plain' }) + }); + const reopenedDescCell = reopenedRow.getByTestId('column-description'); + + await expect(reopenedDescCell.locator('.CodeMirror-line').nth(0)).toHaveText('Backslash-quote: \\\'end'); + await expect(reopenedDescCell.locator('.CodeMirror-line').nth(1)).toHaveText('Triple quote: \'\'\' embedded'); + await expect(reopenedDescCell.locator('.CodeMirror-line').nth(2)).toHaveText('Final line'); + }); +}); diff --git a/tests/request/description/write-request-headers-description.spec.ts b/tests/request/description/write-request-headers-description.spec.ts new file mode 100644 index 000000000..9a4bc9e88 --- /dev/null +++ b/tests/request/description/write-request-headers-description.spec.ts @@ -0,0 +1,44 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; + +const COLLECTION = 'req-description'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description - Write (Headers)', () => { + test('writes a multiline description to a request header and persists it to the .bru file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Headers'); + + const headersTable = page.getByTestId('request-headers-table'); + const xPlainRow = headersTable.locator('tbody tr').filter({ + has: page.locator('[data-testid="column-name"] .CodeMirror-line', { hasText: 'X-Plain' }) + }); + const descCell = xPlainRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in X-Plain description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('First line\nSecond line'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const bruPath = path.join(collectionFixturePath!, 'request-with-descriptions.bru'); + const fileContent = fs.readFileSync(bruPath, 'utf8'); + + expect(fileContent).toContain('@description'); + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/request/description/write-request-params-description.spec.ts b/tests/request/description/write-request-params-description.spec.ts new file mode 100644 index 000000000..4da3db869 --- /dev/null +++ b/tests/request/description/write-request-params-description.spec.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; + +const COLLECTION = 'req-description'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description - Write (Query Params)', () => { + test('writes a multiline description to a query param and persists it to the .bru file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Params'); + + const queryTable = page.getByTestId('query-params-table'); + const plainRow = queryTable.locator('tbody tr').nth(2); + const descCell = plainRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in plain-query description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('Query line one\nQuery line two'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('Query line one'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Query line two'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const bruPath = path.join(collectionFixturePath!, 'request-with-descriptions.bru'); + const fileContent = fs.readFileSync(bruPath, 'utf8'); + + expect(fileContent).toContain('@description'); + expect(fileContent).toContain('Query line one'); + expect(fileContent).toContain('Query line two'); + }); +}); diff --git a/tests/request/description/write-request-vars-description.spec.ts b/tests/request/description/write-request-vars-description.spec.ts new file mode 100644 index 000000000..6f3dd1796 --- /dev/null +++ b/tests/request/description/write-request-vars-description.spec.ts @@ -0,0 +1,43 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from '../../../playwright'; +import { openRequest, selectRequestPaneTab } from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; + +const COLLECTION = 'req-description'; +const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + +test.describe('Request Description - Write (Vars)', () => { + test('writes a multiline description to a request var and persists it to the .bru file', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + await openRequest(page, COLLECTION, 'request-with-descriptions'); + await selectRequestPaneTab(page, 'Vars'); + + const varsTable = buildCommonLocators(page).table('request-vars-req'); + const plainVarRow = varsTable.rowByName('plainVar'); + const descCell = plainVarRow.getByTestId('column-description'); + + await descCell.evaluate((el) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in plainVar description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue('First line\nSecond line'); + }); + + await expect(descCell.locator('.CodeMirror-line').nth(0)).toHaveText('First line'); + await expect(descCell.locator('.CodeMirror-line').nth(1)).toHaveText('Second line'); + + await page.keyboard.press(saveShortcut); + await expect(page.getByText('Request saved successfully')).toBeVisible({ timeout: 5000 }); + + const bruPath = path.join(collectionFixturePath!, 'request-with-descriptions.bru'); + const fileContent = fs.readFileSync(bruPath, 'utf8'); + + expect(fileContent).toContain('@description'); + expect(fileContent).toContain('First line'); + expect(fileContent).toContain('Second line'); + }); +}); diff --git a/tests/request/headers/header-validation.spec.ts b/tests/request/headers/header-validation.spec.ts index 50cc4e1e1..b8f9216ec 100644 --- a/tests/request/headers/header-validation.spec.ts +++ b/tests/request/headers/header-validation.spec.ts @@ -53,9 +53,10 @@ test.describe.serial('Header Validation', () => { const headerRow = page.locator('table tbody tr').first(); const nameCell = getTableCell(headerRow, 0); - // Clear and enter a valid header name - use triple-click to select all (works cross-platform) - await nameCell.locator('.CodeMirror').click({ clickCount: 3 }); - await nameCell.locator('textarea').fill('Valid-Header'); + // Clear and enter a valid header name. + await nameCell.locator('.CodeMirror').click(); + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A'); + await page.keyboard.insertText('Valid-Header'); // Verify the error icon is not visible const errorIcon = headerRow.locator('.text-red-600'); diff --git a/tests/request/multipart-boundary/multipart-boundary.spec.ts b/tests/request/multipart-boundary/multipart-boundary.spec.ts index 8f7b193c2..b3840f9df 100644 --- a/tests/request/multipart-boundary/multipart-boundary.spec.ts +++ b/tests/request/multipart-boundary/multipart-boundary.spec.ts @@ -148,7 +148,7 @@ value1\r // The multipart form has an editable table - find and fill the first row // The name column has placeholder "Key" (defined in MultipartFormParams columns) - const nameInput = page.locator('[data-testid="editable-table"] input[placeholder="Key"]').first(); + const nameInput = page.locator('[data-testid="multipart-form-table"] input[placeholder="Key"]').first(); await nameInput.waitFor({ state: 'visible', timeout: 5000 }); await nameInput.click(); await nameInput.fill('testField'); diff --git a/tests/request/multipart-form/multipart-form-file-chips.spec.ts b/tests/request/multipart-form/multipart-form-file-chips.spec.ts index 638b5cd08..915aabbb6 100644 --- a/tests/request/multipart-form/multipart-form-file-chips.spec.ts +++ b/tests/request/multipart-form/multipart-form-file-chips.spec.ts @@ -67,7 +67,7 @@ test.describe('Multipart Form - Multiple File Upload', () => { // Reset the form to a single empty row before each test. test.beforeEach(async ({ page }) => { - const table = buildCommonLocators(page).table('editable-table'); + const table = buildCommonLocators(page).table('multipart-form-table'); await expect(table.container()).toBeVisible(); let rowCount = await table.allRows().count(); @@ -86,7 +86,7 @@ test.describe('Multipart Form - Multiple File Upload', () => { (global as any).__mockFilePaths = paths; }, files); - const table = buildCommonLocators(page).table('editable-table'); + const table = buildCommonLocators(page).table('multipart-form-table'); await table.allRows().last().getByTestId('multipart-file-upload').click(); }; diff --git a/tests/request/multipart-form/multipart-form-file-select.spec.ts b/tests/request/multipart-form/multipart-form-file-select.spec.ts index 9f369014c..b3023d4b9 100644 --- a/tests/request/multipart-form/multipart-form-file-select.spec.ts +++ b/tests/request/multipart-form/multipart-form-file-select.spec.ts @@ -57,11 +57,11 @@ test.describe.serial('Multipart Form - File Select Without Key', () => { }); test('file select should work on empty row without a key', async ({ page }) => { - const table = buildCommonLocators(page).table('editable-table'); + const table = buildCommonLocators(page).table('multipart-form-table'); await test.step('Click upload on empty last row (no key entered)', async () => { const lastRow = table.allRows().last(); - const uploadBtn = lastRow.locator('.upload-btn'); + const uploadBtn = lastRow.getByTestId('multipart-file-upload'); await expect(uploadBtn).toBeVisible(); await uploadBtn.click(); }); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 9316846d7..e8c03dcd3 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -475,6 +475,7 @@ const deleteCollectionFromOverview = async (page: Page, collectionName: string) type ImportCollectionOptions = { expectedCollectionName?: string; expectIssues?: boolean; + sidebarTimeout?: number; }; const importCollection = async ( @@ -515,7 +516,7 @@ const importCollection = async ( if (options.expectedCollectionName) { await expect( page.locator('#sidebar-collection-name').filter({ hasText: options.expectedCollectionName }) - ).toBeVisible(); + ).toBeVisible({ timeout: options.sidebarTimeout ?? 5000 }); } // Wait for import issues toast if expected @@ -802,7 +803,7 @@ const addRowToActiveTab = async (page: Page, name: string, value: string) => { const row = page.getByTestId(`env-var-row-${name}`); await row.waitFor({ state: 'visible' }); - const codeMirror = row.locator('.CodeMirror'); + const codeMirror = row.getByTestId(/^test-multiline-editor-\d+\.value$/).locator('.CodeMirror').first(); await codeMirror.scrollIntoViewIfNeeded(); await codeMirror.click(); await page.keyboard.type(value); @@ -1356,25 +1357,30 @@ const addMultipartFileToLastRow = async (page: Page, electronApp: ElectronApplic await test.step(`Add multipart file "${path.basename(filePath)}"`, async () => { await mockBrowseFiles(electronApp, [filePath]); - const table = buildCommonLocators(page).table('editable-table'); + const table = buildCommonLocators(page).table('multipart-form-table'); // The last row is the empty "add" row. Capture its index now, because once // we set a file the table appends a new empty row — so `.last()` would jump // to that new row instead of staying on the one we just filled. - const rowIndex = (await table.allRows().count()) - 1; + let rowIndex = (await table.allRows().count()) - 1; const targetRow = table.allRows().nth(rowIndex); - await expect(targetRow.locator('.upload-btn')).toBeVisible(); - await targetRow.locator('.upload-btn').click(); - await expect(targetRow.locator('.file-value-cell')).toBeVisible(); - const inlineChip = targetRow.getByTestId('multipart-file-chip').filter({ hasText: path.basename(filePath) }); - const summary = targetRow.getByTestId('multipart-file-summary'); + if (rowIndex < 0) { + rowIndex = 0; + } + + await expect(targetRow.getByTestId('multipart-file-upload')).toBeVisible(); + await targetRow.getByTestId('multipart-file-upload').click(); + const specificRow = table.allRows().nth(rowIndex); + await expect(specificRow.locator('.file-value-cell')).toBeVisible({ timeout: 10000 }); + const inlineChip = specificRow.getByTestId('multipart-file-chip').filter({ hasText: path.basename(filePath) }); + const summary = specificRow.getByTestId('multipart-file-summary'); await expect(inlineChip.or(summary)).toBeVisible(); }); }; const removeFirstMultipartFile = async (page: Page) => { await test.step('Remove first multipart file', async () => { - const table = buildCommonLocators(page).table('editable-table'); + const table = buildCommonLocators(page).table('multipart-form-table'); const firstRow = table.allRows().first(); await expect(firstRow.locator('.file-value-cell')).toBeVisible(); @@ -1869,6 +1875,36 @@ const readField = async (page: Page, labelText: string): Promise => { return editor.evaluate((el: any) => (el as any).CodeMirror?.getValue() ?? ''); }; +const openFolderSettings = async (page: Page, collectionName: string, folderName = 'api') => { + await test.step(`Open folder settings for "${folderName}" in collection "${collectionName}"`, async () => { + const collectionRow = page.locator('#sidebar-collection-name').filter({ hasText: collectionName }); + await expect(collectionRow).toBeVisible(); + + const folderRow = page + .getByTestId('collections') + .locator('.collection-item-name') + .filter({ hasText: folderName }); + if (!(await folderRow.isVisible().catch(() => false))) { + await collectionRow.click(); + await expect(folderRow).toBeVisible(); + } + + await folderRow.dblclick(); + await expect(page.locator('.request-tab .tab-label').filter({ hasText: folderName })).toBeVisible(); + }); +}; + +const setTableRowDescriptionValue = async (rowLocator: Locator, value: string) => { + const descCell = rowLocator.getByTestId('column-description'); + await descCell.evaluate((el: any, val: string) => { + const cmEl = el.querySelector('.CodeMirror'); + if (!cmEl) throw new Error('No CodeMirror in description cell'); + const cm = (cmEl as any).CodeMirror; + if (!cm) throw new Error('CodeMirror instance not found'); + cm.setValue(val); + }, value); +}; + const createExampleFromSidebar = async (page: Page, requestName: string, exampleName: string, description: string = '') => { const requestRow = page.locator('.collection-item-name').filter({ hasText: requestName }).first(); @@ -2343,6 +2379,8 @@ export { openRequestInFolder, setUrlEncoding, generateCollectionDocs, + openFolderSettings, + setTableRowDescriptionValue, setAppCode, enableApp, exitApp, diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index 887eda02e..25f106c36 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -87,23 +87,34 @@ export const buildCommonLocators = (page: Page) => ({ // share a name (e.g. enabled + disabled twins after a script write). varRowsByValue: (name: string, value: string | RegExp) => page.getByTestId(`env-var-row-${name}`) - .filter({ has: page.locator('.CodeMirror-line', { hasText: value }) }), + .filter({ has: page.getByTestId(/^test-multiline-editor-\d+\.value$/).locator('.CodeMirror-line', { hasText: value }) }), // Each env-var row has an `enabled` and a `secret` checkbox; target the latter // by its `.secret` name (the formik index is dynamic). varRowSecretCheckbox: (name: string) => page.getByTestId(`env-var-row-${name}`).locator('input[name$=".secret"]'), // Eye icon that masks/reveals a secret variable's value. varRowEyeToggle: (name: string) => page.getByTestId(`env-var-row-${name}`).getByTestId('secret-reveal-toggle'), - varRowLine: (name: string) => page.getByTestId(`env-var-row-${name}`).locator('.CodeMirror-line').first(), + varRowValueCell: (name: string) => page.getByTestId(`env-var-row-${name}`).getByTestId(/^test-multiline-editor-\d+\.value$/), + varRowValueEditor: (name: string) => + page.getByTestId(`env-var-row-${name}`).getByTestId(/^test-multiline-editor-\d+\.value$/).locator('.CodeMirror').first(), + varRowValueLine: (name: string) => + page.getByTestId(`env-var-row-${name}`).getByTestId(/^test-multiline-editor-\d+\.value$/).locator('.CodeMirror-line').first(), + varRowLine: (name: string) => + page.getByTestId(`env-var-row-${name}`).getByTestId(/^test-multiline-editor-\d+\.value$/).locator('.CodeMirror-line').first(), addVariableButton: () => page.getByTestId('add-variable'), variableNameInput: (index: number) => page.locator(`input[name="${index}.name"]`), variableSecretCheckbox: (index: number) => page.locator(`input[name="${index}.secret"]`), variableRow: (index: number) => page.locator('tr').filter({ has: page.locator(`input[name="${index}.name"]`) }), + variableDescriptionEditor: (index: number) => + page.locator(`[data-testid="test-multiline-editor-${index}.description"]`).locator('.CodeMirror'), + varRowDescriptionEditor: (name: string) => + page.getByTestId(`env-var-row-${name}`).getByTestId(/^test-multiline-editor-\d+\.description$/).locator('.CodeMirror').first(), variableRowByName: (name: string) => page.locator('tbody tr').filter({ has: page.locator(`input[value="${name}"]`) }), // Targets the `.CodeMirror` wrapper (not `.CodeMirror-line`) so single-line and // multi-line values (e.g. formatted JSON for @object vars) are both covered — // CodeMirror renders each visual line as a separate `.CodeMirror-line`, so // matching on the wrapper is the only way to get the full concatenated text. - variableValue: (name: string) => page.locator('tbody tr').filter({ has: page.locator(`input[value="${name}"]`) }).locator('.CodeMirror').first(), + variableValue: (name: string) => + page.locator('tbody tr').filter({ has: page.locator(`input[value="${name}"]`) }).getByTestId(/^test-multiline-editor-\d+\.value$/).locator('.CodeMirror').first(), createEnvButton: () => page.locator('button[id="create-env"]'), settingsCreateButton: () => page.locator('.environments-container .sidebar button[title="Create environment"]'), @@ -130,10 +141,11 @@ export const buildCommonLocators = (page: Page) => ({ codeMirror: { byTestId: (testId: string) => page.getByTestId(testId).locator('.CodeMirror').first() }, - // The DataTypeSelector renders a `.type-label` trigger per row (request/folder/ - // collection vars + env vars) and a MenuDropdown (role=menu) at page scope. + // The DataTypeSelector exposes a stable trigger per row (request/folder/collection + // vars + env vars). Compact mode shows an icon; full mode shows `.type-label`. dataTypeSelector: { - typeLabel: (row: Locator) => row.locator('.type-label').first(), + trigger: (row: Locator) => row.getByTestId('datatype-selector-trigger'), + typeLabel: (row: Locator) => row.getByTestId('datatype-selector-trigger'), // Yellow warning icon shown when a value can't be coerced to its dataType. mismatchIcon: (row: Locator) => row.locator('svg.text-yellow-600'), menuItem: (type: string) => page.locator('[role="menu"]').last().getByText(type, { exact: true }) diff --git a/tests/variable-datatypes/create-via-ui.spec.ts b/tests/variable-datatypes/create-via-ui.spec.ts index d70a0b82b..39e31edd3 100644 --- a/tests/variable-datatypes/create-via-ui.spec.ts +++ b/tests/variable-datatypes/create-via-ui.spec.ts @@ -31,7 +31,7 @@ const tableRowByName = (page: Page, tableId: string, name: string) => buildCommonLocators(page).table(tableId).rowByName(name); const expectTypeLabel = async (row: Locator, label: string) => { - await expect(buildCommonLocators(row.page()).dataTypeSelector.typeLabel(row)).toHaveText(label); + await expect(buildCommonLocators(row.page()).dataTypeSelector.typeLabel(row)).toHaveAttribute('data-selected-type', label); }; // Add a row to the Vars table and pick `dataType` from the DataTypeSelector. @@ -64,6 +64,7 @@ const addTypedVarRow = async ( await valueEditor.click({ force: true }); await expect(valueEditor).toHaveClass(/CodeMirror-focused/); await page.keyboard.insertText(value); + await valueEditor.hover(); // Pick dataType from the selector menu. const typeTrigger = locators.dataTypeSelector.typeLabel(namedRow); @@ -71,7 +72,7 @@ const addTypedVarRow = async ( const menuItem = locators.dataTypeSelector.menuItem(dataType); await expect(menuItem).toBeVisible(); await menuItem.click(); - await expect(typeTrigger).toHaveText(dataType); + await expect(typeTrigger).toHaveAttribute('data-selected-type', dataType); // Let the dispatched Redux mutation settle before the next interaction. await page.waitForTimeout(200); }); @@ -192,7 +193,7 @@ test.describe('DataType selector — new collection created via UI', () => { // DataTypeSelector too. const addEnvVar = async (name: string, dataType: NonDefaultDataType, { secret = false } = {}) => { await test.step(`add ${secret ? 'secret ' : ''}${dataType} env var "${name}"`, async () => { - const emptyRow = page.locator('tbody tr').last(); + const emptyRow = envRows.last(); await emptyRow.locator('input[placeholder="Name"]').fill(name); const namedRow = locators.environment.varRow(name); await expect(namedRow).toBeVisible(); @@ -202,14 +203,16 @@ test.describe('DataType selector — new collection created via UI', () => { tabRowCount++; await expect(envRows).toHaveCount(tabRowCount + 1); - const valueEditor = namedRow.locator('.CodeMirror').first(); + const valueEditor = locators.environment.varRowValueEditor(name); + await valueEditor.hover(); await valueEditor.click({ force: true }); await expect(valueEditor).toHaveClass(/CodeMirror-focused/); await page.keyboard.insertText(VALUE_FOR_DATATYPE[dataType]); + await valueEditor.hover(); await locators.dataTypeSelector.typeLabel(namedRow).click(); await locators.dataTypeSelector.menuItem(dataType).click(); - await expect(locators.dataTypeSelector.typeLabel(namedRow)).toHaveText(dataType); + await expect(locators.dataTypeSelector.typeLabel(namedRow)).toHaveAttribute('data-selected-type', dataType); await page.waitForTimeout(200); }); }; @@ -237,7 +240,13 @@ test.describe('DataType selector — new collection created via UI', () => { // Switch back to the Variables tab to verify the non-secret rows. await locators.environment.variablesTab().click(); for (const dt of TYPED_DATATYPES) { - await expect(locators.dataTypeSelector.typeLabel(locators.environment.varRow(`env_${dt}`))).toHaveText(dt); + await expect(locators.dataTypeSelector.typeLabel(locators.environment.varRow(`env_${dt}`))).toHaveAttribute('data-selected-type', dt); + } + + // Each secret var keeps its dataType after save. + await locators.environment.secretsTab().click(); + for (const dt of TYPED_DATATYPES) { + await expect(locators.dataTypeSelector.typeLabel(locators.environment.varRow(`env_secret_${dt}`))).toHaveAttribute('data-selected-type', dt); } }); }); diff --git a/tests/variable-datatypes/import.spec.ts b/tests/variable-datatypes/import.spec.ts index 07d32d7b6..9ffb8eae9 100644 --- a/tests/variable-datatypes/import.spec.ts +++ b/tests/variable-datatypes/import.spec.ts @@ -53,7 +53,7 @@ const tableRowByName = (table: ReturnType table.rowByName(name); const expectTypeLabel = async (row: Locator, label: string) => { - await expect(buildCommonLocators(row.page()).dataTypeSelector.typeLabel(row)).toHaveText(label); + await expect(buildCommonLocators(row.page()).dataTypeSelector.typeLabel(row)).toHaveAttribute('data-selected-type', label); }; const openImportedRequest = async (page: Page) => { @@ -143,7 +143,7 @@ test.describe('DataType selector — imported OpenCollection (.yml) collection', await expect(locators.tabs.activeRequestTab()).toContainText('Environments'); for (const [name, label] of ENV_ROWS) { - await expect(locators.dataTypeSelector.typeLabel(locators.environment.varRow(name))).toHaveText(label); + await expect(locators.dataTypeSelector.typeLabel(locators.environment.varRow(name))).toHaveAttribute('data-selected-type', label); } }); }); diff --git a/tests/variable-datatypes/parsed-from-fixture.spec.ts b/tests/variable-datatypes/parsed-from-fixture.spec.ts index 1ee9a6374..46eb2fc2a 100644 --- a/tests/variable-datatypes/parsed-from-fixture.spec.ts +++ b/tests/variable-datatypes/parsed-from-fixture.spec.ts @@ -115,7 +115,7 @@ const SLOW_RENDER_TIMEOUT_MS = 15_000; const expectTypeLabel = async (row: Locator, label: string) => { await scrollVirtuosoRowIntoView(row.page(), row); const { dataTypeSelector } = buildCommonLocators(row.page()); - await expect(dataTypeSelector.typeLabel(row)).toHaveText(label, { timeout: SLOW_RENDER_TIMEOUT_MS }); + await expect(dataTypeSelector.typeLabel(row)).toHaveAttribute('data-selected-type', label, { timeout: SLOW_RENDER_TIMEOUT_MS }); }; /** @@ -126,13 +126,15 @@ const expectTypeLabel = async (row: Locator, label: string) => { const changeRowDataType = async (page: Page, row: Locator, newType: string) => { const { dataTypeSelector } = buildCommonLocators(page); const trigger = dataTypeSelector.typeLabel(row); + // Hover the row first so the compact overlay's pointer-events become 'auto'. + await row.hover(); await trigger.click(); const menuItem = dataTypeSelector.menuItem(newType); await expect(menuItem).toBeVisible(); await menuItem.click(); - await expect(trigger).toHaveText(newType); + await expect(trigger).toHaveAttribute('data-selected-type', newType); // Let the dispatched Redux mutation propagate before the caller saves. await page.waitForTimeout(300); }; @@ -278,7 +280,7 @@ const expectEnvVarTypeLabel = async (page: Page, name: string, label: string, { await (secret ? environment.secretsTab() : environment.variablesTab()).click(); const row = environment.varRow(name); await scrollVirtuosoRowIntoView(page, row); - await expect(dataTypeSelector.typeLabel(row)).toHaveText(label, { timeout: SLOW_RENDER_TIMEOUT_MS }); + await expect(dataTypeSelector.typeLabel(row)).toHaveAttribute('data-selected-type', label, { timeout: SLOW_RENDER_TIMEOUT_MS }); }; /** @@ -507,11 +509,11 @@ const runDataTypeSelectorTests = ( await openRequestInCollection(page, collectionName); await selectRequestPaneTab(page, 'Params'); const queryTable = buildCommonLocators(page).table('query-params'); - await expect(queryTable.container().locator('.type-label')).toHaveCount(0); + await expect(queryTable.container().getByTestId('datatype-selector-trigger')).toHaveCount(0); await selectRequestPaneTab(page, 'Headers'); const headersTable = buildCommonLocators(page).table('request-headers'); - await expect(headersTable.container().locator('.type-label')).toHaveCount(0); + await expect(headersTable.container().getByTestId('datatype-selector-trigger')).toHaveCount(0); }); test('test script: every typed var across scopes asserts true', async ({ pageWithUserData: page }) => {