Merge pull request #5438 from Pragadesh-45/feat/multiline-values-for-env-vars

Feat/ Add Multiline Support for Enviroment Variables
This commit is contained in:
Pragadesh-45
2025-09-06 17:17:51 +05:30
committed by GitHub
parent 1902329226
commit db35e7059c
21 changed files with 596 additions and 41 deletions

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "multiline-variables",
"type": "collection"
}

View File

@@ -0,0 +1,5 @@
meta {
name: multiline-variables
type: collection
version: 1.0.0
}

View File

@@ -0,0 +1,8 @@
vars {
host: https://www.httpfaker.org
multiline_data: '''
line1
line2
line3
'''
}

View File

@@ -0,0 +1,44 @@
meta {
name: multiline-test
type: http
seq: 2
}
post {
url: {{host}}/api/echo
body: json
auth: none
}
body:json {
{{multiline_data_json}}
}
tests {
test("should post multiline data successfully", function() {
expect(res.getStatus()).to.equal(200);
});
test("should resolve multiline_data_json variable correctly", function() {
const body = res.getBody();
// HTTP Faker echo endpoint returns the request body in body.body
// Verify the multiline JSON variable was resolved and parsed correctly
expect(body.body.user.name).to.equal("John Doe");
expect(body.body.user.email).to.equal("john@example.com");
expect(body.body.user.preferences.theme).to.equal("dark");
expect(body.body.user.preferences.notifications).to.equal(true);
});
test("should preserve JSON structure from multiline variable", function() {
const body = res.getBody();
// Verify the complete JSON structure was preserved
expect(body.body.metadata.created).to.equal("2025-09-03");
expect(body.body.metadata.version).to.equal("1.0");
});
test("should resolve host variable in URL", function() {
const body = res.getBody();
// Verify the host variable was resolved in the request URL
expect(body.url).to.equal("https://www.httpfaker.org/api/echo");
});
}

View File

@@ -0,0 +1,38 @@
meta {
name: request
type: http
seq: 1
}
post {
url: {{host}}/api/echo
body: text
auth: none
}
body:json {
Ping Test Request
Host: {{host}}
Multiline Data:
{{multiline_data}}
End of multiline content.
}
body:text {
{{host}}
{{multiline_data}}
}
tests {
test("should get 200 response", function() {
expect(res.getStatus()).to.equal(200);
});
test("should resolve multiline_data variable correctly", function() {
const body = res.getBody();
// Verify the multiline variable was resolved and contains all three lines
expect(body.body).to.equal("https://www.httpfaker.org\nline1\nline2\nline3");
});
}

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/e2e-tests/environments/multiline-variables/collection",
"securityConfig": {
"jsSandboxMode": "developer"
}
}
]
}

View File

@@ -0,0 +1,28 @@
{
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/e2e-tests/environments/multiline-variables/collection"
],
"request": {
"sslVerification": false,
"customCaCertificate": {
"enabled": false,
"filePath": null
}
},
"font": {
"codeFont": "default"
},
"proxy": {
"enabled": false,
"protocol": "http",
"hostname": "",
"port": "",
"auth": {
"enabled": false,
"username": "",
"password": ""
},
"bypassProxy": ""
}
}

View File

@@ -0,0 +1,35 @@
import { test, expect } from '../../../playwright';
test.describe('Multiline Variables - Read Environment Test', () => {
test('should read existing multiline environment variables', async ({ pageWithUserData: page }) => {
test.setTimeout(30 * 1000);
// open the collection
await expect(page.getByTitle('multiline-variables')).toBeVisible();
await page.getByTitle('multiline-variables').click();
// open request
await expect(page.getByTitle('request', { exact: true })).toBeVisible();
await page.getByTitle('request', { exact: true }).click();
// open environment dropdown
await expect(page.getByTitle('No Environment')).toBeVisible();
await page.getByTitle('No Environment').click();
// select test environment
await expect(page.locator('.dropdown-item').filter({ hasText: 'Test' })).toBeVisible();
await page.locator('.dropdown-item').filter({ hasText: 'Test' }).click();
await expect(page.locator('.current-environment').filter({ hasText: /Test/ })).toBeVisible();
// send request
const sendButton = page.locator('#send-request').getByRole('img').nth(2);
await expect(sendButton).toBeVisible();
await sendButton.click();
await expect(page.locator('.response-status-code.text-ok')).toBeVisible();
await expect(page.locator('.response-status-code')).toContainText('200');
// response pane should contain the expected multiline text in JSON body
const responsePane = page.locator('.response-pane');
await expect(responsePane).toContainText('"body": "https://www.httpfaker.org\\nline1\\nline2\\nline3"');
});
});

View File

@@ -0,0 +1,94 @@
import { test, expect } from '../../../playwright';
test.describe('Multiline Variables - Write Test', () => {
test('should create and use multiline environment variable dynamically', async ({ pageWithUserData: page }) => {
test.setTimeout(60 * 1000);
// open the collection
await expect(page.getByTitle('multiline-variables')).toBeVisible();
await page.getByTitle('multiline-variables').click();
// open request
await expect(page.getByTitle('multiline-test', { exact: true })).toBeVisible();
await page.getByTitle('multiline-test', { exact: true }).click();
// open environment dropdown
await expect(page.getByTitle('No Environment')).toBeVisible();
await page.getByTitle('No Environment').click();
// select test environment
await expect(page.locator('.dropdown-item').filter({ hasText: 'Test' })).toBeVisible();
await page.locator('.dropdown-item').filter({ hasText: 'Test' }).click();
await expect(page.locator('.current-environment').filter({ hasText: /Test/ })).toBeVisible();
// select configure button from environment dropdown
await expect(page.getByTitle('Test', { exact: true })).toBeVisible();
await page.getByTitle('Test', { exact: true }).click();
// open environment configuration
await expect(page.locator('#Configure')).toBeVisible();
await page.locator('#Configure').click();
// add variable
await page.getByRole('button', { name: /Add.*Variable/i }).click();
const valueTextarea = page.locator('.bruno-modal-card textarea').last();
await expect(valueTextarea).toBeVisible();
const jsonValue = `{
"user": {
"name": "John Doe",
"email": "john@example.com",
"preferences": {
"theme": "dark",
"notifications": true
}
},
"metadata": {
"created": "2025-09-03",
"version": "1.0"
}
}`;
// fill variable value
await valueTextarea.fill(jsonValue);
await page.keyboard.press('Shift+Tab');
await page.keyboard.type('multiline_data_json');
// save variable and close config
const saveVarButton = page.getByRole('button', { name: /Save/i });
await expect(saveVarButton).toBeVisible();
await saveVarButton.click();
await expect(page.locator('.close.cursor-pointer')).toBeVisible();
await page.locator('.close.cursor-pointer').click();
// send request
const sendButton = page.locator('#send-request').getByRole('img').nth(2);
await expect(sendButton).toBeVisible();
await sendButton.click();
// wait for response status
await expect(page.locator('.response-status-code.text-ok')).toBeVisible();
await expect(page.locator('.response-status-code')).toContainText('200');
// verify multiline JSON variable resolution in response
const expectedBody =
'{\n "user": {\n "name": "John Doe",\n "email": "john@example.com",\n "preferences": {\n "theme": "dark",\n "notifications": true\n }\n },\n "metadata": {\n "created": "2025-09-03",\n "version": "1.0"\n }\n}';
await expect(page.locator('.response-pane')).toContainText(`"body": ${JSON.stringify(expectedBody)}`);
});
// clean up created variable after test
test.afterEach(async () => {
const fs = require('fs');
const path = require('path');
const testBruPath = path.join(__dirname, 'collection/environments/Test.bru');
let content = fs.readFileSync(testBruPath, 'utf8');
// remove the multiline_data_json variable and its content
content = content.replace(/\s*multiline_data_json:\s*'''\s*[\s\S]*?\s*'''/g, '');
fs.writeFileSync(testBruPath, content);
});
});

View File

@@ -19,7 +19,7 @@ const EnvironmentSelector = ({ collection }) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="current-environment flex items-center justify-center pl-3 pr-2 py-1 select-none">
<p className="text-nowrap truncate max-w-32">{activeEnvironment ? activeEnvironment.name : 'No Environment'}</p>
<p className="text-nowrap truncate max-w-32" title={activeEnvironment ? activeEnvironment.name : 'No Environment'}>{activeEnvironment ? activeEnvironment.name : 'No Environment'}</p>
<IconCaretDown className="caret" size={14} strokeWidth={2} />
</div>
);
@@ -82,7 +82,7 @@ const EnvironmentSelector = ({ collection }) => {
handleSettingsIconClick();
dropdownTippyRef.current.hide();
}}>
<div className="pr-2 text-gray-600">
<div className="pr-2 text-gray-600" id="Configure">
<IconSettings size={18} strokeWidth={1.5} />
</div>
<span>Configure</span>

View File

@@ -5,7 +5,7 @@ import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCh
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
@@ -214,7 +214,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
@@ -253,6 +253,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
ref={addButtonRef}
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
onClick={addVariable}
id="add-variable"
>
+ Add Variable
</button>

View File

@@ -3,7 +3,7 @@ import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
@@ -147,7 +147,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
</td>
<td className="flex flex-row flex-nowrap">
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
<MultiLineEditor
theme={storedTheme}
name={`${index}.value`}
value={variable.value}

View File

@@ -16,7 +16,7 @@ const StatusCode = ({ status }) => {
};
return (
<StyledWrapper className={getTabClassname(status)}>
<StyledWrapper className={`response-status-code ${getTabClassname(status)}`}>
{status} {statusCodePhraseMap[status]}
</StyledWrapper>
);

View File

@@ -232,7 +232,7 @@ const Collection = ({ collection, searchText }) => {
onClick={handleCollectionCollapse}
onDoubleClick={handleCollectionDoubleClick}
/>
<div className="ml-1 w-full" id="sidebar-collection-name">
<div className="ml-1 w-full" id="sidebar-collection-name" title={collection.name}>
{collection.name}
</div>
{isLoading ? <IconLoader2 className="animate-spin mx-1" size={18} strokeWidth={1.5} /> : null}

View File

@@ -1,6 +1,16 @@
const ohm = require('ohm-js');
const _ = require('lodash');
// Env files use 4-space indentation for multiline content
// vars {
// API_KEY: '''
// -----BEGIN PUBLIC KEY-----
// MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC8
// HMR5LXFFrwXQFE6xUVhXrxUpx1TtfoGkRcU7LEWV
// -----END PUBLIC KEY-----
// '''
// }
const indentLevel = 4;
const grammar = ohm.grammar(`Bru {
BruEnvFile = (vars | secretvars)*
@@ -10,14 +20,20 @@ const grammar = ohm.grammar(`Bru {
tagend = nl "}"
optionalnl = ~tagend nl
keychar = ~(tagend | st | nl | ":") any
valuechar = ~(nl | tagend) any
valuechar = ~(nl | tagend | multilinetextblockstart) any
multilinetextblockdelimiter = "'''"
multilinetextblockstart = "'''" nl
multilinetextblockend = nl st* "'''"
multilinetextblock = multilinetextblockstart multilinetextblockcontent multilinetextblockend
multilinetextblockcontent = (~multilinetextblockend any)*
// Dictionary Blocks
dictionary = st* "{" pairlist? tagend
pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)*
pair = st* key st* ":" st* value st*
key = keychar*
value = valuechar*
value = multilinetextblock | valuechar*
// Array Blocks
array = st* "[" stnl* valuelist stnl* "]"
@@ -120,8 +136,31 @@ const sem = grammar.createSemantics().addAttribute('ast', {
return chars.sourceString ? chars.sourceString.trim() : '';
},
value(chars) {
// .ctorName provides the name of the rule that matched the input
if (chars.ctorName === 'multilinetextblock') {
return chars.ast;
}
return chars.sourceString ? chars.sourceString.trim() : '';
},
multilinetextblockstart(_1, _2) {
return '';
},
multilinetextblockend(_1, _2, _3) {
return '';
},
multilinetextblockdelimiter(_) {
return '';
},
multilinetextblock(_1, content, _2) {
return content.ast
.split('\n')
.map((line) => line.slice(indentLevel)) // Remove 4-space indentation
.join('\n')
.trim();
},
multilinetextblockcontent(chars) {
return chars.sourceString;
},
nl(_1, _2) {
return '';
},

View File

@@ -1,6 +1,6 @@
const _ = require('lodash');
const { indentString } = require('./utils');
const { indentString, getValueString } = require('./utils');
const enabled = (items = [], key = "enabled") => items.filter((item) => item[key]);
const disabled = (items = [], key = "enabled") => items.filter((item) => !item[key]);
@@ -16,23 +16,6 @@ const stripLastLine = (text) => {
return text.replace(/(\r?\n)$/, '');
};
const getValueString = (value) => {
const hasNewLines = value?.includes('\n');
if (!hasNewLines) {
return value;
}
// Add one level of indentation to the contents of the multistring
const indentedLines = value
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
// Join the lines back together with newline characters and enclose them in triple single quotes
return `'''\n${indentedLines}\n'''`;
};
const jsonToBru = (json) => {
const { meta, http, grpc, params, headers, metadata, auth, body, script, tests, vars, assertions, settings, docs } = json;

View File

@@ -1,4 +1,5 @@
const _ = require('lodash');
const { getValueString, indentString } = require('./utils');
const envToJson = (json) => {
const variables = _.get(json, 'variables', []);
@@ -7,7 +8,8 @@ const envToJson = (json) => {
.map((variable) => {
const { name, value, enabled } = variable;
const prefix = enabled ? '' : '~';
return ` ${prefix}${name}: ${value}`;
return indentString(`${prefix}${name}: ${getValueString(value)}`);
});
const secretVars = variables
@@ -15,7 +17,7 @@ const envToJson = (json) => {
.map((variable) => {
const { name, enabled } = variable;
const prefix = enabled ? '' : '~';
return ` ${prefix}${name}`;
return indentString(`${prefix}${name}`);
});
if (!variables || !variables.length) {

View File

@@ -7,12 +7,21 @@ const safeParseJson = (json) => {
}
};
const normalizeNewlines = (str) => {
if (!str || typeof str !== 'string') {
return str || '';
}
// "\r\n" is windows, "\r" is old mac, "\n" is linux
return str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
};
const indentString = (str) => {
if (!str || !str.length) {
return str || '';
}
return str
return normalizeNewlines(str)
.split('\n')
.map((line) => ' ' + line)
.join('\n');
@@ -22,15 +31,33 @@ const outdentString = (str) => {
if (!str || !str.length) {
return str || '';
}
return str
return normalizeNewlines(str)
.split('\n')
.map((line) => line.replace(/^ /, ''))
.join('\n');
};
const getValueString = (value) => {
// Handle null, undefined, and empty strings
if (!value) {
return '';
}
const hasNewLines = value?.includes('\n') || value?.includes('\r');
if (!hasNewLines) {
return value;
}
// Wrap multiline values in triple quotes with 2-space indentation
return `'''\n${indentString(value)}\n'''`;
};
module.exports = {
safeParseJson,
normalizeNewlines,
indentString,
outdentString
outdentString,
getValueString
};

View File

@@ -313,4 +313,116 @@ vars:secret [access_key,access_secret, access_password ]
expect(output).toEqual(expected);
});
it('should parse multiline variable values', () => {
const input = `
vars {
json_data: '''
{
"name": "test",
"value": 123
}
'''
}`;
const output = parser(input);
const expected = {
variables: [
{
name: 'json_data',
value: '{\n "name": "test",\n "value": 123\n}',
enabled: true,
secret: false
}
]
};
expect(output).toEqual(expected);
});
it('should parse multiline variable that has indentation', () => {
const input = `
vars {
script: '''
function test() {
console.log("hello");
return true;
}
'''
}`;
const output = parser(input);
const expected = {
variables: [
{
name: 'script',
value: 'function test() {\n console.log("hello");\n return true;\n}',
enabled: true,
secret: false
}
]
};
expect(output).toEqual(expected);
});
it('should parse disabled multiline variable', () => {
const input = `
vars {
~disabled_multiline: '''
line 1
line 2
line 3
'''
}`;
const output = parser(input);
const expected = {
variables: [
{
name: 'disabled_multiline',
value: 'line 1\nline 2\nline 3',
enabled: false,
secret: false
}
]
};
expect(output).toEqual(expected);
});
it('should parse multiple multiline variables', () => {
const input = `
vars {
config: '''
debug=true
port=3000
'''
template: '''
<html>
<body>Hello World</body>
</html>
'''
}`;
const output = parser(input);
const expected = {
variables: [
{
name: 'config',
value: 'debug=true\nport=3000',
enabled: true,
secret: false
},
{
name: 'template',
value: '<html>\n <body>Hello World</body>\n</html>',
enabled: true,
secret: false
}
]
};
expect(output).toEqual(expected);
});
});

View File

@@ -1,7 +1,7 @@
const parser = require('../src/jsonToEnv');
describe('env parser', () => {
it('should parse empty vars', () => {
describe('jsonToEnv', () => {
it('should stringify empty vars', () => {
const input = {
variables: []
};
@@ -14,7 +14,7 @@ describe('env parser', () => {
expect(output).toEqual(expected);
});
it('should parse single var line', () => {
it('should stringify single var line', () => {
const input = {
variables: [
{
@@ -33,7 +33,7 @@ describe('env parser', () => {
expect(output).toEqual(expected);
});
it('should parse multiple var lines', () => {
it('should stringify multiple var lines', () => {
const input = {
variables: [
{
@@ -58,7 +58,7 @@ describe('env parser', () => {
expect(output).toEqual(expected);
});
it('should parse secret vars', () => {
it('should stringify secret vars', () => {
const input = {
variables: [
{
@@ -86,7 +86,7 @@ vars:secret [
expect(output).toEqual(expected);
});
it('should parse multiple secret vars', () => {
it('should stringify multiple secret vars', () => {
const input = {
variables: [
{
@@ -121,7 +121,7 @@ vars:secret [
expect(output).toEqual(expected);
});
it('should parse even if the only secret vars are present', () => {
it('should stringify even if the only secret vars are present', () => {
const input = {
variables: [
{
@@ -137,6 +137,109 @@ vars:secret [
const expected = `vars:secret [
token
]
`;
expect(output).toEqual(expected);
});
it('should stringify multiline variables', () => {
const input = {
variables: [
{
name: 'json_data',
value: '{\n "name": "test",\n "value": 123\n}',
enabled: true
}
]
};
const output = parser(input);
const expected = `vars {
json_data: '''
{
"name": "test",
"value": 123
}
'''
}
`;
expect(output).toEqual(expected);
});
it('should stringify multiline variables containing indentation', () => {
const input = {
variables: [
{
name: 'script',
value: 'function test() {\n console.log("hello");\n return true;\n}',
enabled: true
}
]
};
const output = parser(input);
const expected = `vars {
script: '''
function test() {
console.log("hello");
return true;
}
'''
}
`;
expect(output).toEqual(expected);
});
it('should stringify disabled multiline variable', () => {
const input = {
variables: [
{
name: 'disabled_multiline',
value: 'line 1\nline 2\nline 3',
enabled: false
}
]
};
const output = parser(input);
const expected = `vars {
~disabled_multiline: '''
line 1
line 2
line 3
'''
}
`;
expect(output).toEqual(expected);
});
it('should stringify multiple multiline variables', () => {
const input = {
variables: [
{
name: 'config',
value: 'debug=true\nport=3000',
enabled: true
},
{
name: 'template',
value: '<html>\n <body>Hello World</body>\n</html>',
enabled: true
}
]
};
const output = parser(input);
const expected = `vars {
config: '''
debug=true
port=3000
'''
template: '''
<html>
<body>Hello World</body>
</html>
'''
}
`;
expect(output).toEqual(expected);
});

View File

@@ -0,0 +1,21 @@
const { getValueString } = require('../src/utils');
describe('getValueString', () => {
it('returns single line value as-is', () => {
expect(getValueString('hello world')).toBe('hello world');
});
it('wraps multiline value in triple quotes with indentation', () => {
expect(getValueString('line1\nline2\nline3')).toBe("'''\n line1\n line2\n line3\n'''");
});
it('normalizes different newline types', () => {
expect(getValueString('line1\r\nline2\rline3\nline4')).toBe("'''\n line1\n line2\n line3\n line4\n'''");
});
it('returns empty string for empty/null/undefined', () => {
expect(getValueString('')).toBe('');
expect(getValueString(null)).toBe('');
expect(getValueString(undefined)).toBe('');
});
});