feat: support newlines in headers, params, and variables (#5795)

* feat: support newlines in headers, params, and variables

* add: collectin unit test

* fix: assertion and additional header multiline

* fix: assert

* rm: useEffect for header validation

* rm: comments

* fix: already encoded url

* rm: new line changes

* handle new line in url

* fix: lint error

* add: unit test for multi line test

* change: unit test

* mv: functions in util

* fix: drag icon position

* improve: arrow height

* improvements

* rm: getKeyString from assert

* fix: single line editor

* fix: import MultiLineEditor

* import getKeyString and getValueUrl

* add: getTableCell in utils

* rm: multiline key logic

* fix

* mv: getTableCell in locators.ts
This commit is contained in:
Pooja
2025-11-17 13:27:00 +05:30
committed by GitHub
parent 2be602d16c
commit 8c7888533a
20 changed files with 537 additions and 83 deletions

View File

@@ -45,7 +45,8 @@ const Headers = ({ collection }) => {
const header = cloneDeep(_header);
switch (type) {
case 'name': {
header.name = e.target.value;
// Strip newlines from header keys
header.name = e.target.value.replace(/[\r\n]/g, '');
break;
}
case 'value': {

View File

@@ -4,7 +4,7 @@ import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -114,7 +114,7 @@ const VarsTable = ({ collection, vars, varType }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={_var.value}
theme={storedTheme}
onSave={onSave}

View File

@@ -41,7 +41,8 @@ const Headers = ({ collection, folder }) => {
const header = cloneDeep(_header);
switch (type) {
case 'name': {
header.name = e.target.value;
// Strip newlines from header keys
header.name = e.target.value.replace(/[\r\n]/g, '');
break;
}
case 'value': {

View File

@@ -4,7 +4,7 @@ import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -113,7 +113,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={_var.value}
theme={storedTheme}
onSave={onSave}

View File

@@ -79,7 +79,7 @@ const ReorderTable = ({ children, updateReorderedItem }) => {
<>
<div
draggable
className="group drag-handle absolute z-10 left-[-17px] p-3.5 py-3.5 px-2.5 top-[3px] cursor-grab"
className="group drag-handle absolute z-10 left-[-17px] top-1/2 -translate-y-[80%] p-2.5 cursor-grab"
>
{hoveredRow === index && (
<>

View File

@@ -4,7 +4,8 @@ import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { IconPlus, IconTrash, IconAdjustmentsHorizontal } from '@tabler/icons';
import { cloneDeep } from "lodash";
import SingleLineEditor from "components/SingleLineEditor/index";
import SingleLineEditor from 'components/SingleLineEditor/index';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from "./StyledWrapper";
import Table from "components/Table/index";
@@ -205,7 +206,7 @@ const AdditionalParams = ({ item = {}, request, updateAuth, collection, handleS
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={param?.value || ''}
theme={storedTheme}
onChange={(value) => handleUpdateAdditionalParam({

View File

@@ -13,7 +13,7 @@ import {
updatePathParam,
setQueryParams
} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
@@ -168,7 +168,7 @@ const QueryParams = ({ item, collection }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={param.value}
theme={storedTheme}
onSave={onSave}
@@ -244,7 +244,7 @@ const QueryParams = ({ item, collection }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={path.value}
theme={storedTheme}
onSave={onSave}

View File

@@ -116,6 +116,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
collection={collection}
highlightPathParams={true}
item={item}
showNewlineArrow={true}
/>
<div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}>
<div

View File

@@ -20,7 +20,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const addHeader = () => {
@@ -36,9 +36,11 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleHeaderValueChange = (e, _header, type) => {
const header = cloneDeep(_header);
switch (type) {
case 'name': {
header.name = e.target.value;
// Strip newlines from header keys
header.name = e.target.value.replace(/[\r\n]/g, '');
break;
}
case 'value': {
@@ -50,6 +52,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
break;
}
}
dispatch(
updateRequestHeader({
header: header,
@@ -154,7 +157,6 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
}
onRun={handleRun}
autocomplete={MimeTypes}
allowNewlines={true}
collection={collection}
item={item}
/>

View File

@@ -5,7 +5,7 @@ import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addVar, updateVar, deleteVar, moveVar } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -122,7 +122,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={_var.value}
theme={storedTheme}
onSave={onSave}

View File

@@ -99,6 +99,11 @@ class SingleLineEditor extends Component {
this.addOverlay(variables);
this._enableMaskedEditor(this.props.isSecret);
this.setState({ maskInput: this.props.isSecret });
// Add newline arrow markers if enabled
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
}
/** Enable or disable masking the rendered content of the editor */
@@ -123,6 +128,11 @@ class SingleLineEditor extends Component {
if (this.props.onChange && (this.props.value !== this.cachedValue)) {
this.props.onChange(this.cachedValue);
}
// Update newline markers after edit
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
}
};
@@ -145,6 +155,11 @@ class SingleLineEditor extends Component {
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value ?? ''));
// Update newline markers after value change
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change
@@ -162,6 +177,7 @@ class SingleLineEditor extends Component {
if (this.editor) {
this.editor.off('change', this._onEdit);
this.editor.off('paste', this._onPaste);
this._clearNewlineMarkers();
this.editor.getWrapperElement().remove();
this.editor = null;
}
@@ -180,6 +196,63 @@ class SingleLineEditor extends Component {
this.editor.setOption('mode', 'brunovariables');
};
/**
* Update markers to show arrows for newlines
*/
_updateNewlineMarkers = () => {
if (!this.editor) return;
// Clear existing markers
this._clearNewlineMarkers();
this.newlineMarkers = [];
const content = this.editor.getValue();
// Find all newlines and replace them with arrow widgets
for (let i = 0; i < content.length; i++) {
if (content[i] === '\n') {
const pos = this.editor.posFromIndex(i);
const nextPos = this.editor.posFromIndex(i + 1);
// Create a widget to display the arrow
const arrow = document.createElement('span');
arrow.className = 'newline-arrow';
arrow.textContent = '↲';
arrow.style.cssText = `
color: #888;
font-size: 8px;
margin: 0 2px;
vertical-align: middle;
display: inline-block;
`;
// Mark the newline character and replace it with the arrow widget
const marker = this.editor.markText(pos, nextPos, {
replacedWith: arrow,
handleMouseEvents: true
});
this.newlineMarkers.push(marker);
}
}
};
/**
* Clear all newline markers
*/
_clearNewlineMarkers = () => {
if (this.newlineMarkers) {
this.newlineMarkers.forEach((marker) => {
try {
marker.clear();
} catch (e) {
// Marker might already be cleared
}
});
this.newlineMarkers = [];
}
};
toggleVisibleSecret = () => {
const isVisible = !this.state.maskInput;
this.setState({ maskInput: isVisible });
@@ -204,13 +277,15 @@ class SingleLineEditor extends Component {
render() {
return (
<div className={`flex flex-row justify-between w-full overflow-x-auto ${this.props.className}`}>
<div className={`flex flex-row items-center w-full overflow-x-auto ${this.props.className}`}>
<StyledWrapper
ref={this.editorRef}
className={`single-line-editor grow ${this.props.readOnly ? 'read-only' : ''}`}
{...(this.props['data-testid'] ? { 'data-testid': this.props['data-testid'] } : {})}
/>
{this.secretEye(this.props.isSecret)}
<div className="flex items-center">
{this.secretEye(this.props.isSecret)}
</div>
</div>
);
}

View File

@@ -20,12 +20,22 @@ const grammar = ohm.grammar(`Bru {
keychar = ~(tagend | st | nl | ":") any
valuechar = ~(nl | tagend) any
// Multiline text block surrounded by '''
multilinetextblockdelimiter = "'''"
multilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter
// Dictionary Blocks
dictionary = st* "{" pairlist? tagend
pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)*
pair = st* key st* ":" st* value st*
pair = st* (quoted_key | key) st* ":" st* value st*
disable_char = "~"
quote_char = "\\""
esc_char = "\\\\"
esc_quote_char = esc_char quote_char
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 = valuechar*
value = multilinetextblock | valuechar*
// Text Blocks
textblock = textline (~tagend nl textline)*
@@ -137,10 +147,38 @@ const sem = grammar.createSemantics().addAttribute('ast', {
res[key.ast] = value.ast ? value.ast.trim() : '';
return res;
},
quoted_key(disabled, _1, chars, _2) {
// unquote and handle disabled prefix
return (disabled ? disabled.sourceString : '') + chars.ast.join('');
},
esc_quote_char(_1, quote) {
// unescape
return quote.sourceString;
},
quoted_key_char(char) {
// return the character itself
return char.sourceString;
},
key(chars) {
return chars.sourceString ? chars.sourceString.trim() : '';
},
value(chars) {
if (chars.ctorName === 'list') {
return chars.ast;
}
try {
let isMultiline = chars.sourceString?.startsWith(`'''`) && chars.sourceString?.endsWith(`'''`);
if (isMultiline) {
const multilineString = chars.sourceString?.replace(/^'''|'''$/g, '');
return multilineString
.split('\n')
.map((line) => line.slice(4))
.join('\n');
}
return chars.sourceString ? chars.sourceString.trim() : '';
} catch (err) {
console.error(err);
}
return chars.sourceString ? chars.sourceString.trim() : '';
},
textblock(line, _1, rest) {
@@ -152,6 +190,10 @@ const sem = grammar.createSemantics().addAttribute('ast', {
textchar(char) {
return char.sourceString;
},
multilinetextblock(_1, content, _2) {
// Join all the content between the triple quotes and trim it
return content.sourceString.trim();
},
nl(_1, _2) {
return '';
},

View File

@@ -1,14 +1,10 @@
const _ = require('lodash');
const { indentString, getValueString } = require('./utils');
const { indentString, getValueString, getKeyString, getValueUrl } = require('./utils');
const jsonToExampleBru = require('./example/jsonToBru');
const enabled = (items = [], key = "enabled") => items.filter((item) => item[key]);
const disabled = (items = [], key = "enabled") => items.filter((item) => !item[key]);
const quoteKey = (key) => {
const quotableChars = [':', '"', '{', '}', ' '];
return quotableChars.some(char => key.includes(char)) ? ('"' + key.replaceAll('"', '\\"') + '"') : key;
}
// remove the last line if two new lines are found
const stripLastLine = (text) => {
@@ -50,7 +46,7 @@ const jsonToBru = (json) => {
const isStandard = standardMethods.has(method);
bru += isStandard ? `${method} {` : `http {\n method: ${method}`;
bru += `\n url: ${url}`;
bru += `\n url: ${getValueUrl(url)}`;
if (body?.length) {
bru += `\n body: ${body}`;
@@ -133,7 +129,7 @@ const jsonToBru = (json) => {
if (enabled(queryParams).length) {
bru += `\n${indentString(
enabled(queryParams)
.map((item) => `${quoteKey(item.name)}: ${item.value}`)
.map((item) => `${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}`;
}
@@ -141,7 +137,7 @@ const jsonToBru = (json) => {
if (disabled(queryParams).length) {
bru += `\n${indentString(
disabled(queryParams)
.map((item) => `~${quoteKey(item.name)}: ${item.value}`)
.map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}`;
}
@@ -152,7 +148,7 @@ const jsonToBru = (json) => {
if (pathParams.length) {
bru += 'params:path {';
bru += `\n${indentString(pathParams.map((item) => `${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(pathParams.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += '\n}\n\n';
}
@@ -163,7 +159,7 @@ const jsonToBru = (json) => {
if (enabled(headers).length) {
bru += `\n${indentString(
enabled(headers)
.map((item) => `${quoteKey(item.name)}: ${item.value}`)
.map((item) => `${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}`;
}
@@ -171,7 +167,7 @@ const jsonToBru = (json) => {
if (disabled(headers).length) {
bru += `\n${indentString(
disabled(headers)
.map((item) => `~${quoteKey(item.name)}: ${item.value}`)
.map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}`;
}
@@ -184,7 +180,7 @@ const jsonToBru = (json) => {
if (enabled(metadata).length) {
bru += `\n${indentString(
enabled(metadata)
.map((item) => `${item.name}: ${item.value}`)
.map((item) => `${item.name}: ${getValueString(item.value)}`)
.join('\n')
)}`;
}
@@ -192,7 +188,7 @@ const jsonToBru = (json) => {
if (disabled(metadata).length) {
bru += `\n${indentString(
disabled(metadata)
.map((item) => `~${item.name}: ${item.value}`)
.map((item) => `~${item.name}: ${getValueString(item.value)}`)
.join('\n')
)}`;
}
@@ -360,7 +356,7 @@ ${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toStr
${indentString(
authorizationHeaders
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -373,7 +369,7 @@ ${indentString(
${indentString(
authorizationQueryParams
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -386,7 +382,7 @@ ${indentString(
${indentString(
tokenHeaders
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -399,7 +395,7 @@ ${indentString(
${indentString(
tokenQueryParams
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -412,7 +408,7 @@ ${indentString(
${indentString(
tokenBodyValues
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -425,7 +421,7 @@ ${indentString(
${indentString(
refreshHeaders
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -438,7 +434,7 @@ ${indentString(
${indentString(
refreshQueryParams
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -451,7 +447,7 @@ ${indentString(
${indentString(
refreshBodyValues
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -508,14 +504,14 @@ ${indentString(body.sparql)}
if (enabled(body.formUrlEncoded).length) {
const enabledValues = enabled(body.formUrlEncoded)
.map((item) => `${quoteKey(item.name)}: ${getValueString(item.value)}`)
.map((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) => `~${quoteKey(item.name)}: ${getValueString(item.value)}`)
.map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n');
bru += `${indentString(disabledValues)}\n`;
}
@@ -536,7 +532,7 @@ ${indentString(body.sparql)}
item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
if (item.type === 'text') {
return `${enabled}${quoteKey(item.name)}: ${getValueString(item.value)}${contentType}`;
return `${enabled}${getKeyString(item.name)}: ${getValueString(item.value)}${contentType}`;
}
if (item.type === 'file') {
@@ -544,7 +540,7 @@ ${indentString(body.sparql)}
const filestr = filepaths.join('|');
const value = `@file(${filestr})`;
return `${enabled}${quoteKey(item.name)}: ${value}${contentType}`;
return `${enabled}${getKeyString(item.name)}: ${value}${contentType}`;
}
})
.join('\n')
@@ -644,19 +640,19 @@ ${indentString(body.sparql)}
bru += `vars:pre-request {`;
if (varsEnabled.length) {
bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
if (varsLocalEnabled.length) {
bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
if (varsDisabled.length) {
bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
if (varsLocalDisabled.length) {
bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
bru += '\n}\n\n';
@@ -670,19 +666,19 @@ ${indentString(body.sparql)}
bru += `vars:post-response {`;
if (varsEnabled.length) {
bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
if (varsLocalEnabled.length) {
bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
if (varsDisabled.length) {
bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
if (varsLocalDisabled.length) {
bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
bru += '\n}\n\n';
@@ -694,7 +690,7 @@ ${indentString(body.sparql)}
if (enabled(assertions).length) {
bru += `\n${indentString(
enabled(assertions)
.map((item) => `${item.name}: ${item.value}`)
.map((item) => `${item.name}: ${getValueString(item.value)}`)
.join('\n')
)}`;
}
@@ -702,7 +698,7 @@ ${indentString(body.sparql)}
if (disabled(assertions).length) {
bru += `\n${indentString(
disabled(assertions)
.map((item) => `~${item.name}: ${item.value}`)
.map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}`;
}

View File

@@ -1,6 +1,6 @@
const _ = require('lodash');
const { indentString } = require('./utils');
const { indentString, getValueString, getKeyString } = 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) => `${item.name}: ${item.value}`)
.map((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) => `~${item.name}: ${item.value}`)
.map((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) => `${item.name}: ${item.value}`)
.map((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) => `~${item.name}: ${item.value}`)
.map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}`;
}
@@ -243,7 +243,7 @@ ${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).
${indentString(
authorizationHeaders
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -256,7 +256,7 @@ ${indentString(
${indentString(
authorizationQueryParams
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -269,7 +269,7 @@ ${indentString(
${indentString(
tokenHeaders
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -282,9 +282,8 @@ ${indentString(
${indentString(
tokenQueryParams
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.join('\n')
)}
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n'))}
}
`;
@@ -295,7 +294,7 @@ ${indentString(
${indentString(
tokenBodyValues
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -308,7 +307,7 @@ ${indentString(
${indentString(
refreshHeaders
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -321,7 +320,7 @@ ${indentString(
${indentString(
refreshQueryParams
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -334,7 +333,7 @@ ${indentString(
${indentString(
refreshBodyValues
.filter(item => item?.name?.length)
.map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`)
.map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`)
.join('\n')
)}
}
@@ -355,19 +354,19 @@ ${indentString(
bru += `vars:pre-request {`;
if (varsEnabled.length) {
bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
if (varsLocalEnabled.length) {
bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
if (varsDisabled.length) {
bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
if (varsLocalDisabled.length) {
bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
bru += '\n}\n\n';
@@ -381,19 +380,19 @@ ${indentString(
bru += `vars:post-response {`;
if (varsEnabled.length) {
bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
if (varsLocalEnabled.length) {
bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
if (varsDisabled.length) {
bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
if (varsLocalDisabled.length) {
bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${item.value}`).join('\n'))}`;
bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
}
bru += '\n}\n\n';

View File

@@ -8,14 +8,15 @@ const safeParseJson = (json) => {
};
const indentString = (str) => {
const indentString = (str, levels = 1) => {
if (!str || !str.length) {
return str || '';
}
const indent = ' '.repeat(levels);
return str
.split(/\r\n|\r|\n/)
.map((line) => ' ' + line)
.map((line) => indent + line)
.join('\n');
};
@@ -37,7 +38,7 @@ const getValueString = (value) => {
return '';
}
const hasNewLines = value?.includes('\n') || value?.includes('\r');
const hasNewLines = value.includes('\n') || value.includes('\r');
if (!hasNewLines) {
return value;
@@ -47,9 +48,32 @@ const getValueString = (value) => {
return `'''\n${indentString(value)}\n'''`;
};
const getKeyString = (key) => {
const quotableChars = [':', '"', '{', '}', ' '];
return quotableChars.some((char) => key.includes(char)) ? ('"' + key.replaceAll('"', '\\"') + '"') : key;
};
const getValueUrl = (url) => {
// Handle null, undefined, and empty strings
if (!url) {
return '';
}
const hasNewLines = url.includes('\n') || url.includes('\r');
if (!hasNewLines) {
return url;
}
// Wrap multiline values in triple quotes with 4-space indentation (2 levels)
return `'''\n${indentString(url, 2)}\n'''`;
};
module.exports = {
safeParseJson,
indentString,
outdentString,
getValueString
getValueString,
getKeyString,
getValueUrl
};

View File

@@ -38,4 +38,88 @@ settings {
expect(output).toEqual(expected);
});
});
describe('multi-line values', () => {
it('parses multi-line values in URL, headers, params, and vars', () => {
const input = `
meta {
name: new-line
type: http
seq: 1
}
get {
url: '''
https://httpbin.io/anything?foo=hello
world
'''
body: none
auth: oauth2
}
params:query {
foo: '''
hello
world
'''
}
headers {
"test header": '''
t1
t2
'''
}
vars:pre-request {
test-var: '''
t1
t2
'''
}
`;
const expected = {
meta: {
name: 'new-line',
type: 'http',
seq: '1'
},
http: {
method: 'get',
url: 'https://httpbin.io/anything?foo=hello\nworld',
body: 'none',
auth: 'oauth2'
},
params: [
{
name: 'foo',
value: 'hello\nworld',
enabled: true,
type: 'query'
}
],
headers: [
{
name: 'test header',
value: 't1\nt2',
enabled: true
}
],
vars: {
req: [
{
name: 'test-var',
value: 't1\nt2',
enabled: true,
local: false
}
]
}
};
const output = parser(input);
expect(output).toEqual(expected);
});
});
});

View File

@@ -0,0 +1,56 @@
const { getKeyString } = require('../src/utils');
describe('getKeyString', () => {
describe('should not quote keys without special characters', () => {
it('should return simple alphanumeric keys as-is', () => {
expect(getKeyString('hello')).toBe('hello');
expect(getKeyString('world123')).toBe('world123');
expect(getKeyString('API')).toBe('API');
});
it('should return keys with hyphens as-is', () => {
expect(getKeyString('api-key')).toBe('api-key');
expect(getKeyString('content-type')).toBe('content-type');
});
it('should return keys with underscores as-is', () => {
expect(getKeyString('api_key')).toBe('api_key');
expect(getKeyString('user_name')).toBe('user_name');
});
});
describe('should quote keys with special characters', () => {
it('should quote keys with colons', () => {
expect(getKeyString('key:value')).toBe('"key:value"');
expect(getKeyString('disabled:colon:header')).toBe('"disabled:colon:header"');
expect(getKeyString(':startsWithColon')).toBe('":startsWithColon"');
expect(getKeyString('endsWithColon:')).toBe('"endsWithColon:"');
});
it('should quote keys with spaces', () => {
expect(getKeyString('key with spaces')).toBe('"key with spaces"');
expect(getKeyString(' leadingSpace')).toBe('" leadingSpace"');
expect(getKeyString('trailingSpace ')).toBe('"trailingSpace "');
expect(getKeyString('multiple spaces')).toBe('"multiple spaces"');
});
it('should quote keys with curly braces', () => {
expect(getKeyString('{braces}')).toBe('"{braces}"');
expect(getKeyString('{only-open')).toBe('"{only-open"');
expect(getKeyString('only-close}')).toBe('"only-close}"');
expect(getKeyString('nested{brace}here')).toBe('"nested{brace}here"');
});
it('should quote keys with double quotes and escape them', () => {
expect(getKeyString('nested "quote"')).toBe('"nested \\"quote\\""');
expect(getKeyString('"quoted"')).toBe('"\\"quoted\\""');
expect(getKeyString('multiple "quotes" here "too"')).toBe('"multiple \\"quotes\\" here \\"too\\""');
});
it('should quote keys with multiple special characters', () => {
expect(getKeyString('key: value')).toBe('"key: value"');
expect(getKeyString('{key}: "value"')).toBe('"{key}: \\"value\\""');
expect(getKeyString('complex:key with {braces}')).toBe('"complex:key with {braces}"');
});
});
});

View File

@@ -53,4 +53,87 @@ describe('jsonToBru stringify', () => {
expect(output).toMatch(new RegExp(`timeout: ${input.settings.timeout}`));
});
});
describe('multi-line values', () => {
it('handles multi-line values in URL, headers, params, and vars', () => {
const input = {
meta: {
name: 'new-line',
type: 'http',
seq: 1
},
http: {
method: 'get',
url: 'https://httpbin.io/anything?foo=hello\nworld',
body: 'none',
auth: 'oauth2'
},
params: [
{
name: 'foo',
value: 'hello\nworld',
enabled: true,
type: 'query'
}
],
headers: [
{
name: 'test header',
value: 't1\nt2',
enabled: true
}
],
vars: {
req: [
{
name: 'test-var',
value: 't1\nt2',
enabled: true
}
]
}
};
const output = stringify(input);
expect(output).toMatchInlineSnapshot(`
"meta {
name: new-line
type: http
seq: 1
}
get {
url: '''
https://httpbin.io/anything?foo=hello
world
'''
body: none
auth: oauth2
}
params:query {
foo: '''
hello
world
'''
}
headers {
"test header": '''
t1
t2
'''
}
vars:pre-request {
test-var: '''
t1
t2
'''
}
"
`);
});
});
});

View File

@@ -0,0 +1,87 @@
import { test, expect } from '../../../playwright';
import { openCollectionAndAcceptSandbox } from '../../utils/page/actions';
import { getTableCell } from '../../utils/page/locators';
test('should persist request with newlines across app restarts', async ({ createTmpDir, launchElectronApp }) => {
const userDataPath = await createTmpDir('newlines-persistence-userdata');
const collectionPath = await createTmpDir('newlines-persistence-collection');
// Create collection and request
const app1 = await launchElectronApp({ userDataPath });
const page = await app1.firstWindow();
await page.locator('.dropdown-icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
await page.getByLabel('Name').fill('newlines-persistence');
await page.getByLabel('Location').fill(collectionPath);
await page.getByRole('button', { name: 'Create', exact: true }).click();
const collection = page.locator('.collection-name').filter({ hasText: 'newlines-persistence' });
await collection.locator('.collection-actions').hover();
await collection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByPlaceholder('Request Name').fill('persistence-test');
await page.locator('#new-request-url').locator('.CodeMirror').click();
await page.locator('#new-request-url').locator('textarea').fill('https://httpbin.org/get');
await page.getByRole('button', { name: 'Create', exact: true }).click();
await openCollectionAndAcceptSandbox(page, 'newlines-persistence', 'safe');
await page.locator('.collection-item-name').filter({ hasText: 'persistence-test' }).dblclick();
// Add query param
await page.getByRole('tab', { name: 'Params' }).click();
await page.getByRole('button', { name: /Add.*Param/i }).click();
const paramRow = page.locator('table tbody tr').last();
await getTableCell(paramRow, 0).locator('input[type="text"]').fill('queryParamKey');
// Add header with newlines
await page.getByRole('tab', { name: 'Headers' }).click();
await page.getByRole('button', { name: /Add.*Header/i }).click();
const headerRow = page.locator('table tbody tr').last();
await getTableCell(headerRow, 0).locator('.CodeMirror').click();
await getTableCell(headerRow, 0).locator('textarea').fill('headerKey');
await getTableCell(headerRow, 1).locator('.CodeMirror').click();
await getTableCell(headerRow, 1).locator('textarea').fill('header\nValue');
// Add Pre Request var with newlines
await page.getByRole('tab', { name: 'Vars' }).click();
await page.locator('.btn-add-var').first().click();
const preReqRow = page.locator('table').first().locator('tbody tr').first();
await getTableCell(preReqRow, 0).locator('input[type="text"]').fill('preRequestVar');
await getTableCell(preReqRow, 1).locator('.CodeMirror').click();
await getTableCell(preReqRow, 1).locator('textarea').fill('pre\nRequest\nValue');
// Add Post Response var with newlines
await page.locator('.btn-add-var').last().click();
const postResRow = page.locator('table').nth(1).locator('tbody tr').first();
await getTableCell(postResRow, 0).locator('input[type="text"]').fill('postResponseVar');
await getTableCell(postResRow, 1).locator('.CodeMirror').click();
await getTableCell(postResRow, 1).locator('textarea').fill('post\nResponse\nValue');
await page.keyboard.press('Meta+s');
await app1.close();
// Verify persistence after restart
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.locator('.collection-name').filter({ hasText: 'newlines-persistence' }).click();
await page2.locator('.collection-item-name').filter({ hasText: 'persistence-test' }).dblclick();
// Verify params persisted
await page2.getByRole('tab', { name: 'Params' }).click();
await expect(page2.locator('table tbody tr')).toHaveCount(1);
// Verify headers persisted
await page2.getByRole('tab', { name: 'Headers' }).click();
await expect(page2.locator('table tbody tr')).toHaveCount(1);
// Verify vars persisted
await page2.getByRole('tab', { name: 'Vars' }).click();
await expect(page2.locator('table').first().locator('tbody tr')).toHaveCount(1);
await expect(page2.locator('table').nth(1).locator('tbody tr')).toHaveCount(1);
await app2.close();
});

View File

@@ -72,6 +72,8 @@ export const buildWebsocketCommonLocators = (page: Page) => ({
}
});
export const getTableCell = (row, index) => row.locator('td').nth(index);
export const buildGrpcCommonLocators = (page: Page) => ({
...buildCommonLocators(page),
method: {