Compare commits

...

3 Commits

Author SHA1 Message Date
Bijin A B
aeb6e87860 test: fix test and update default retry in local to 0 2026-06-20 01:19:35 +05:30
Bijin A B
6136d3ac62 tests: run playwright e2e fully parallel (#8313) 2026-06-19 19:40:24 +05:30
lohit
942f995717 feat: variable data types support (#8046) 2026-06-19 19:36:59 +05:30
166 changed files with 7722 additions and 641 deletions

View File

@@ -49,7 +49,7 @@ jobs:
e2e-test:
name: Playwright E2E Tests (Linux)
timeout-minutes: 120
timeout-minutes: 240
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
@@ -70,7 +70,7 @@ jobs:
sudo chown root node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
- name: Run playwright Tests
- name: Run E2E Tests
uses: ./.github/actions/tests/run-e2e-tests
with:
os: ubuntu

View File

@@ -49,7 +49,7 @@ jobs:
e2e-test:
name: Playwright E2E Tests (macOS)
timeout-minutes: 150
timeout-minutes: 240
runs-on: macos-latest
steps:
- uses: actions/checkout@v6

View File

@@ -58,7 +58,7 @@ jobs:
e2e-test:
name: Playwright E2E Tests (Windows)
timeout-minutes: 120
timeout-minutes: 240
runs-on: windows-latest
steps:
- uses: actions/checkout@v6

7
package-lock.json generated
View File

@@ -30,7 +30,6 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.9.1",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -35083,6 +35082,7 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@usebruno/common": "^0.1.0",
"@usebruno/schema": "^0.7.0",
"js-yaml": "^4.1.1",
"jscodeshift": "^17.3.0",
@@ -35093,6 +35093,7 @@
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.25.0",
"@opencollection/types": "0.9.1",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^23.0.2",
@@ -35764,6 +35765,7 @@
"license": "MIT",
"dependencies": {
"@types/nanoid": "^2.1.0",
"@usebruno/common": "0.1.0",
"@usebruno/lang": "0.12.0",
"ajv": "^8.17.1",
"lodash": "^4.17.21",
@@ -35772,6 +35774,7 @@
"devDependencies": {
"@babel/preset-env": "^7.22.0",
"@babel/preset-typescript": "^7.22.0",
"@opencollection/types": "0.9.1",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.0.1",
@@ -36147,6 +36150,7 @@
"version": "0.12.0",
"license": "MIT",
"dependencies": {
"@usebruno/common": "0.1.0",
"arcsecond": "^5.0.0",
"dotenv": "^16.3.1",
"lodash": "^4.17.21",
@@ -36354,6 +36358,7 @@
"version": "0.7.0",
"license": "MIT",
"dependencies": {
"@usebruno/common": "0.1.0",
"nanoid": "3.3.8",
"yup": "^0.32.11"
}

View File

@@ -23,7 +23,6 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.9.1",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",

View File

@@ -5,6 +5,8 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import DataTypeSelector from 'components/DataTypeSelector';
import { valueToString } from '@usebruno/common/utils';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -57,15 +59,31 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
render: ({ row, value, onChange, isLastEmptyRow }) => (
<div className="flex items-center w-full gap-2">
<div className="flex-1 min-w-0">
<MultiLineEditor
value={valueToString(value)}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
</div>
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
{!isLastEmptyRow && varType === 'request' && (
<DataTypeSelector
variable={row}
theme={storedTheme}
collection={collection}
onChange={(fields) => {
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
handleVarsChange(updated);
}}
/>
)}
</div>
)
}
];
@@ -80,6 +98,7 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
<StyledWrapper className="w-full">
<EditableTable
tableId="collection-vars"
testId={`collection-vars-${varType === 'response' ? 'res' : 'req'}`}
columns={columns}
rows={vars}
onChange={handleVarsChange}

View File

@@ -0,0 +1,19 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.type-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
display: inline-block;
font-size: 0.75rem;
opacity: 0.7;
}
.caret-icon {
opacity: 0.7;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { IconAlertCircle, IconCaretDown } 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 selectedType = variable.dataType || 'string';
const coercedValue = parseValueByDataType(variable.value, selectedType);
const typeError = validateDataTypeValue(coercedValue, selectedType);
const handleTypeChange = (type) => {
onChange({ dataType: type === 'string' ? undefined : type });
};
const items = BRUNO_VARIABLE_DATATYPES.map((type) => ({
id: type,
label: type,
onClick: () => handleTypeChange(type)
}));
return (
<StyledWrapper>
<div className="flex items-center relative">
<MenuDropdown
items={items}
selectedItemId={selectedType}
placement="bottom-end"
showTickMark={true}
appendTo={() => document.body}
>
<div className="flex items-center cursor-pointer select-none">
<span className="type-label">{selectedType}</span>
<IconCaretDown className="caret-icon ml-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
{typeError && (
<span className="ml-1">
<IconAlertCircle
data-tooltip-id={`type-error-${variable.uid}`}
className="text-yellow-600 cursor-pointer"
size={16}
/>
<Tooltip
className="tooltip-mod"
id={`type-error-${variable.uid}`}
content={typeError}
place="top"
/>
</span>
)}
</div>
</StyledWrapper>
);
};
export default React.memo(DataTypeSelector);

View File

@@ -21,17 +21,19 @@ const findScrollParent = (element) => {
const TableRow = React.memo(
({ children, item, context, ...rest }) => {
const rowIndex = Number(rest['data-item-index']);
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave } = context;
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave, keyColumn } = context;
const isEmpty = isLastEmptyRow(item, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
const isDragOver = canDrag && dragOverRow === rowIndex;
const existingClass = rest.className || '';
const className = isDragOver ? `${existingClass} drag-over`.trim() : existingClass;
const rowName = keyColumn ? item?.[keyColumn.key] : undefined;
return (
<tr
{...rest}
className={className}
data-row-name={rowName || undefined}
draggable={canDrag}
onDragStart={canDrag ? (e) => onDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined}
@@ -342,17 +344,20 @@ const EditableTable = ({
);
}, [isLastEmptyRow, getRowError, handleValueChange]);
const keyColumn = useMemo(() => columns.find((col) => col.isKeyField), [columns]);
const virtuosoContext = useMemo(() => ({
reorderable,
reorderableRowCount,
isLastEmptyRow,
dragOverRow,
keyColumn,
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
onDragEnd: handleDragEnd
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, keyColumn, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
const fixedHeaderContent = useCallback(() => (
<tr>

View File

@@ -1,15 +1,17 @@
import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { IconTrash, IconAlertCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useSelector, useDispatch } from 'react-redux';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor/index';
import DataTypeSelector from 'components/DataTypeSelector';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { BRUNO_VARIABLE_DATATYPES, valueToString } from '@usebruno/common/utils';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
@@ -23,14 +25,17 @@ const MIN_COLUMN_WIDTH = 80;
const MIN_ROW_HEIGHT = 35;
const TableRow = React.memo(
({ children, item, style, ...rest }) => (
<tr key={item.uid} style={style} {...rest} data-testid={`env-var-row-${item?.name}`}>
{children}
</tr>
),
({ children, item, style, ...rest }) => {
const variable = item?.variable ?? item;
return (
<tr key={variable?.uid} style={style} {...rest} data-testid={`env-var-row-${variable?.name}`}>
{children}
</tr>
);
},
(prevProps, nextProps) => {
const prevUid = prevProps?.item?.uid;
const nextUid = nextProps?.item?.uid;
const prevUid = prevProps?.item?.variable?.uid ?? prevProps?.item?.uid;
const nextUid = nextProps?.item?.variable?.uid ?? nextProps?.item?.uid;
return prevUid === nextUid && prevProps.children === nextProps.children;
}
);
@@ -203,7 +208,9 @@ const EnvironmentVariablesTable = ({
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.mixed().nullable()
value: Yup.mixed().nullable(),
dataType: Yup.string().oneOf(BRUNO_VARIABLE_DATATYPES).nullable(),
annotations: Yup.array().nullable()
})
),
validate: (values) => {
@@ -391,8 +398,16 @@ const EnvironmentVariablesTable = ({
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
// Compare without UIDs since they can be different but the actual data is the same
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
// Compare against what's on disk: for an ephemeral overlay, that's
// `persistedValue`, not the scripted value Redux is holding.
const baselineForCompare = (v) => {
const stripped = stripEnvVarUid(v);
if (v?.ephemeral && v?.persistedValue !== undefined) {
stripped.value = v.persistedValue;
}
return stripped;
};
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(baselineForCompare));
if (!hasChanges) {
toast.error('No changes to save');
return;
@@ -524,6 +539,7 @@ const EnvironmentVariablesTable = ({
<td></td>
</tr>
)}
defaultItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
@@ -569,21 +585,20 @@ const EnvironmentVariablesTable = ({
</div>
</td>
<td
className="flex flex-row flex-nowrap items-center"
className="flex flex-row flex-nowrap items-center gap-2"
style={{ width: columnWidths.value }}
>
<div
className="overflow-hidden grow w-full relative"
className="flex-1 min-w-0 relative"
onFocus={() => handleRowFocus(variable.uid)}
>
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
value={valueToString(variable.value, 2)}
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
@@ -608,13 +623,17 @@ const EnvironmentVariablesTable = ({
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
{!isLastEmptyRow && (
<span>
<DataTypeSelector
variable={variable}
theme={storedTheme}
collection={_collection}
onChange={(fields) => {
Object.entries(fields).forEach(([key, val]) => {
formik.setFieldValue(`${actualIndex}.${key}`, val, true);
});
}}
/>
</span>
)}

View File

@@ -5,6 +5,8 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import DataTypeSelector from 'components/DataTypeSelector';
import { valueToString } from '@usebruno/common/utils';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -62,16 +64,32 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
item={folder}
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
render: ({ row, value, onChange, isLastEmptyRow }) => (
<div className="flex items-center w-full gap-2">
<div className="flex-1 min-w-0">
<MultiLineEditor
value={valueToString(value)}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
item={folder}
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
</div>
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
{!isLastEmptyRow && varType === 'request' && (
<DataTypeSelector
variable={row}
theme={storedTheme}
collection={collection}
onChange={(fields) => {
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
handleVarsChange(updated);
}}
/>
)}
</div>
)
}
];
@@ -86,6 +104,7 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
<StyledWrapper className="w-full">
<EditableTable
tableId="folder-vars"
testId={`folder-vars-${varType === 'response' ? 'res' : 'req'}`}
columns={columns}
rows={vars}
onChange={handleVarsChange}

View File

@@ -6,6 +6,8 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import DataTypeSelector from 'components/DataTypeSelector';
import { valueToString } from '@usebruno/common/utils';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -72,17 +74,33 @@ const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
onRun={handleRun}
collection={collection}
item={item}
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
render: ({ row, value, onChange, isLastEmptyRow }) => (
<div className="flex items-center w-full gap-2">
<div className="flex-1 min-w-0">
<MultiLineEditor
value={valueToString(value)}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
onRun={handleRun}
collection={collection}
item={item}
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
</div>
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
{!isLastEmptyRow && varType === 'request' && (
<DataTypeSelector
variable={row}
theme={storedTheme}
collection={collection}
onChange={(fields) => {
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
handleVarsChange(updated);
}}
/>
)}
</div>
)
}
];
@@ -97,6 +115,7 @@ const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
<StyledWrapper className="w-full">
<EditableTable
tableId="request-vars"
testId={`request-vars-${varType === 'response' ? 'res' : 'req'}`}
columns={columns}
rows={vars || []}
onChange={handleVarsChange}

View File

@@ -1,5 +1,5 @@
import { collectionSchema, environmentSchema, itemSchema } from '@usebruno/schema';
import { parseQueryParams, extractPromptVariables } from '@usebruno/common/utils';
import { parseQueryParams, extractPromptVariables, getDataTypeFromValue } from '@usebruno/common/utils';
import { REQUEST_TYPES, DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
import cloneDeep from 'lodash/cloneDeep';
import filter from 'lodash/filter';
@@ -2004,13 +2004,6 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
return reject(new Error('Environment not found'));
}
/*
Modal Save writes what the user sees:
- Non-ephemeral vars are saved as-is (without metadata)
- Ephemeral vars:
- if persistedValue exists, save that (explicit persisted case)
- otherwise save the current UI value (treat as user-authored)
*/
const persisted = buildPersistedEnvVariables(variables, { mode: 'save' });
environment.variables = persisted;
@@ -2021,8 +2014,6 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, envForValidation))
.then(() => {
// Immediately sync Redux to the saved (persisted) set so old ephemerals
// arent around when the watcher event arrives.
dispatch(_saveEnvironment({ variables: persisted, environmentUid, collectionUid }));
})
.then(resolve)
@@ -2346,21 +2337,24 @@ export const mergeAndPersistEnvironment
let existingVars = environment.variables || [];
let normalizedNewVars = Object.entries(persistentEnvVariables).map(([name, value]) => ({
uid: uuid(),
name,
value,
type: 'text',
enabled: true,
secret: false
}));
let normalizedNewVars = Object.entries(persistentEnvVariables).map(([name, value]) => {
const inferred = getDataTypeFromValue(value);
return {
uid: uuid(),
name,
value,
type: 'text',
enabled: true,
secret: false,
...(inferred !== 'string' ? { dataType: inferred } : {})
};
});
const merged = existingVars.map((v) => {
const found = normalizedNewVars.find((nv) => nv.name === v.name);
if (found) {
return { ...v, value: found.value };
}
return v;
if (!found) return v;
const { dataType: _oldDataType, ...rest } = v;
return { ...rest, value: found.value, ...(found.dataType ? { dataType: found.dataType } : {}) };
});
normalizedNewVars.forEach((nv) => {
if (!merged.some((v) => v.name === nv.name)) {

View File

@@ -17,9 +17,9 @@ const makeEnvironment = (overrides = {}) => ({
pathname: '/coll/environments/test_env.bru',
variables: [
{ uid: 'var-1', name: 'env_str', value: 'env_string', type: 'text', enabled: true, secret: false },
{ uid: 'var-2', name: 'env_num', value: '300', type: 'text', datatype: 'number', enabled: true, secret: false },
{ uid: 'var-3', name: 'env_bool', value: 'true', type: 'text', datatype: 'boolean', enabled: true, secret: false },
{ uid: 'var-4', name: 'env_obj', value: '{"scope":"env"}', type: 'text', datatype: 'object', enabled: true, secret: false }
{ uid: 'var-2', name: 'env_num', value: '300', type: 'text', dataType: 'number', enabled: true, secret: false },
{ uid: 'var-3', name: 'env_bool', value: 'true', type: 'text', dataType: 'boolean', enabled: true, secret: false },
{ uid: 'var-4', name: 'env_obj', value: '{"scope":"env"}', type: 'text', dataType: 'object', enabled: true, secret: false }
],
color: null,
...overrides
@@ -61,7 +61,7 @@ describe('collectionAddEnvFileEvent', () => {
expect(nextState.collections[0].environments[0].externalSecrets).toEqual(externalSecrets);
});
it('keeps variable datatype when a new environment is added', () => {
it('keeps variable dataType when a new environment is added', () => {
const state = makeInitialState();
const nextState = reducer(
@@ -70,13 +70,13 @@ describe('collectionAddEnvFileEvent', () => {
);
const variables = nextState.collections[0].environments[0].variables;
expect(variables.find((v) => v.name === 'env_num')).toMatchObject({ value: '300', datatype: 'number' });
expect(variables.find((v) => v.name === 'env_bool')).toMatchObject({ value: 'true', datatype: 'boolean' });
expect(variables.find((v) => v.name === 'env_obj')).toMatchObject({ value: '{"scope":"env"}', datatype: 'object' });
expect(variables.find((v) => v.name === 'env_str')).not.toHaveProperty('datatype');
expect(variables.find((v) => v.name === 'env_num')).toMatchObject({ value: '300', dataType: 'number' });
expect(variables.find((v) => v.name === 'env_bool')).toMatchObject({ value: 'true', dataType: 'boolean' });
expect(variables.find((v) => v.name === 'env_obj')).toMatchObject({ value: '{"scope":"env"}', dataType: 'object' });
expect(variables.find((v) => v.name === 'env_str')).not.toHaveProperty('dataType');
});
it('keeps variable datatype when an existing environment changes', () => {
it('keeps variable dataType when an existing environment changes', () => {
const state = makeInitialState([makeEnvironment({ variables: [] })]);
const nextState = reducer(
@@ -85,8 +85,8 @@ describe('collectionAddEnvFileEvent', () => {
);
const variables = nextState.collections[0].environments[0].variables;
expect(variables.find((v) => v.name === 'env_num')).toMatchObject({ value: '300', datatype: 'number' });
expect(variables.find((v) => v.name === 'env_str')).not.toHaveProperty('datatype');
expect(variables.find((v) => v.name === 'env_num')).toMatchObject({ value: '300', dataType: 'number' });
expect(variables.find((v) => v.name === 'env_str')).not.toHaveProperty('dataType');
});
it('clears externalSecrets when the block is removed from the file', () => {

View File

@@ -24,6 +24,7 @@ import mime from 'mime-types';
import path from 'utils/common/path';
import { getUniqueTagsFromItems } from 'utils/collections/index';
import { getCollectionEnvironmentPath } from 'utils/snapshot';
import { getDataTypeFromValue } from '@usebruno/common/utils';
import * as exampleReducers from './exampleReducers';
// gRPC status code meanings
@@ -412,16 +413,25 @@ export const collectionsSlice = createSlice({
save/persist uses that base unless the key is explicitly persisted.
*/
const previousValue = variable.value;
const wasEphemeral = !!variable.ephemeral;
variable.value = value;
variable.ephemeral = !isPersistent;
if (variable.persistedValue === undefined) {
// Capture the on-disk base only when shadowing a real (non-ephemeral) var for
// the first time. A script-created ephemeral has no on-disk value to restore,
// so giving it a persistedValue would leak its overlay value into the file.
if (variable.persistedValue === undefined && !wasEphemeral) {
variable.persistedValue = previousValue;
}
// Secrets carry a dataType too; infer it from the value like any other var.
const inferred = getDataTypeFromValue(value);
variable.dataType = inferred === 'string' ? undefined : inferred;
}
} else {
// __name__ is a private variable used to store the name of the environment
// this is not a user defined variable and hence should not be updated
if (key !== '__name__') {
const inferred = getDataTypeFromValue(value);
activeEnvironment.variables.push({
name: key,
value,
@@ -429,7 +439,8 @@ export const collectionsSlice = createSlice({
enabled: true,
type: 'text',
uid: uuid(),
ephemeral: !isPersistent
ephemeral: !isPersistent,
...(inferred !== 'string' ? { dataType: inferred } : {})
});
}
}
@@ -2068,12 +2079,12 @@ 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 = '', enabled = true, local = false, dataType, annotations }) => ({
uid: uid || uuid(),
name,
value,
enabled,
...(datatype ? { datatype } : {}),
...(dataType && dataType !== 'string' ? { dataType } : {}),
...(annotations?.length ? { annotations } : {}),
...(type === 'response' ? { local } : {})
}));
@@ -2424,12 +2435,12 @@ 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 = '', enabled = true, local = false, dataType, annotations }) => ({
uid: uid || uuid(),
name,
value,
enabled,
...(datatype ? { datatype } : {}),
...(dataType && dataType !== 'string' ? { dataType } : {}),
...(annotations?.length ? { annotations } : {}),
...(type === 'response' ? { local } : {})
}));
@@ -2664,12 +2675,12 @@ 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 = '', enabled = true, local = false, dataType, annotations }) => ({
uid: uid || uuid(),
name,
value,
enabled,
...(datatype ? { datatype } : {}),
...(dataType && dataType !== 'string' ? { dataType } : {}),
...(annotations?.length ? { annotations } : {}),
...(type === 'response' ? { local } : {})
}));
@@ -2938,6 +2949,13 @@ export const collectionsSlice = createSlice({
target.value = ev.value;
}
target.ephemeral = true;
} else if (ev.persistedValue === undefined) {
/*
No counterpart in the file. A script-created overlay (persistedValue undefined) never
existed on disk, so a sibling persist:true save must not erase it — keep it visible.
An ephemeral with persistedValue shadowed a now-absent disk var (deleted), so it drops.
*/
existingEnv.variables.push(ev);
}
});
} else {

View File

@@ -0,0 +1,185 @@
import { collectionsSlice } from './index';
const {
setRequestVars,
setFolderVars,
setCollectionVars,
collectionAddEnvFileEvent,
scriptEnvironmentUpdateEvent
} = collectionsSlice.actions;
const reducer = collectionsSlice.reducer;
const makeStateWith = (item) => ({
collections: [
{
uid: 'col1',
items: [item]
}
]
});
const inputVars = [
{ uid: 'v1', name: 'numeric', value: 42, enabled: true, dataType: 'number' },
{ uid: 'v2', name: 'explicit_string', value: 'hi', enabled: true, dataType: 'string' },
{ uid: 'v3', name: 'plain', value: 'hello', enabled: true }
];
const assertGuardedVars = (vars) => {
expect(vars).toHaveLength(3);
expect(vars[0]).toMatchObject({ name: 'numeric', value: 42, dataType: 'number' });
expect(vars[1]).toMatchObject({ name: 'explicit_string', value: 'hi' });
expect(vars[1].dataType).toBeUndefined();
expect(vars[2]).toMatchObject({ name: 'plain', value: 'hello' });
expect(vars[2].dataType).toBeUndefined();
};
describe('collectionAddEnvFileEvent — ephemeral env vars on disk reload', () => {
const stateWithEnv = (variables) => ({
collections: [
{
uid: 'col1',
items: [],
environments: [{ uid: 'env1', name: 'dev', pathname: '/dev.bru', variables }]
}
]
});
// A persist:true setEnvVar writes the file, which reloads only the persisted var.
const fileReload = reducer(
stateWithEnv([
{ uid: 'v1', name: 'test_env_var', value: 'test', ephemeral: true },
{ uid: 'v2', name: 'test_env_var_test', value: 'test', ephemeral: false }
]),
collectionAddEnvFileEvent({
collectionUid: 'col1',
environment: {
uid: 'env1',
name: 'dev',
pathname: '/dev.bru',
variables: [{ uid: 'v2', name: 'test_env_var_test', value: 'test' }]
}
})
);
const reloadedVars = fileReload.collections[0].environments[0].variables;
it('keeps a script-created ephemeral var absent from the reloaded file', () => {
const kept = reloadedVars.find((v) => v.name === 'test_env_var');
expect(kept).toMatchObject({ name: 'test_env_var', value: 'test', ephemeral: true });
});
it('drops an overlay ephemeral (persistedValue set) absent from the reloaded file', () => {
const next = reducer(
stateWithEnv([{ uid: 'v1', name: 'deleted_overlay', value: 'temp', ephemeral: true, persistedValue: 'orig' }]),
collectionAddEnvFileEvent({
collectionUid: 'col1',
environment: { uid: 'env1', name: 'dev', pathname: '/dev.bru', variables: [] }
})
);
expect(next.collections[0].environments[0].variables).toHaveLength(0);
});
});
describe('scriptEnvironmentUpdateEvent — re-updating a script-created ephemeral var', () => {
const stateWithEnvVar = (variable) => ({
collections: [
{
uid: 'col1',
items: [],
activeEnvironmentUid: 'env1',
environments: [{ uid: 'env1', name: 'dev', variables: [variable] }]
}
]
});
it('does not give a script-created ephemeral var a persistedValue when its value changes again', () => {
// First run left this in Redux: created by setEnvVar (persist:false), never on disk.
const existing = { uid: 'v1', name: 'test_env_var', value: 'test', enabled: true, ephemeral: true };
const next = reducer(
stateWithEnvVar(existing),
scriptEnvironmentUpdateEvent({
collectionUid: 'col1',
envVariables: { test_env_var: 'updated' },
runtimeVariables: {},
persistentEnvVariables: {}
})
);
const variable = next.collections[0].environments[0].variables[0];
expect(variable).toMatchObject({ name: 'test_env_var', value: 'updated', ephemeral: true });
expect(variable.persistedValue).toBeUndefined();
});
it('captures the on-disk base as persistedValue when first shadowing a real var', () => {
const onDisk = { uid: 'v1', name: 'api_url', value: 'https://disk', enabled: true };
const next = reducer(
stateWithEnvVar(onDisk),
scriptEnvironmentUpdateEvent({
collectionUid: 'col1',
envVariables: { api_url: 'https://overlay' },
runtimeVariables: {},
persistentEnvVariables: {}
})
);
const variable = next.collections[0].environments[0].variables[0];
expect(variable).toMatchObject({ value: 'https://overlay', ephemeral: true, persistedValue: 'https://disk' });
});
});
describe('setRequestVars — strips dataType: \'string\' (implicit default)', () => {
it('drops a stray string-dataType on request vars and preserves typed datatypes', () => {
const item = {
uid: 'item1',
type: 'http-request',
request: { vars: { req: [], res: [] } }
};
const next = reducer(
makeStateWith(item),
setRequestVars({ collectionUid: 'col1', itemUid: 'item1', vars: inputVars, type: 'request' })
);
assertGuardedVars(next.collections[0].items[0].draft.request.vars.req);
});
});
describe('setFolderVars — strips dataType: \'string\' (implicit default)', () => {
it('drops a stray string-dataType on folder vars and preserves typed datatypes', () => {
const folder = {
uid: 'folder1',
type: 'folder',
root: { request: { vars: { req: [], res: [] } } }
};
const next = reducer(
makeStateWith(folder),
setFolderVars({ collectionUid: 'col1', folderUid: 'folder1', vars: inputVars, type: 'request' })
);
assertGuardedVars(next.collections[0].items[0].draft.request.vars.req);
});
});
describe('setCollectionVars — strips dataType: \'string\' (implicit default)', () => {
it('drops a stray string-dataType on collection vars and preserves typed datatypes', () => {
const state = {
collections: [
{
uid: 'col1',
items: [],
root: { request: { vars: { req: [], res: [] } } }
}
]
};
const next = reducer(
state,
setCollectionVars({ collectionUid: 'col1', vars: inputVars, type: 'request' })
);
assertGuardedVars(next.collections[0].draft.root.request.vars.req);
});
});

View File

@@ -1,8 +1,14 @@
import { createSlice } from '@reduxjs/toolkit';
import { uuid } from 'utils/common/index';
import { environmentSchema } from '@usebruno/schema';
import { getDataTypeFromValue, valueToString } from '@usebruno/common/utils';
import { cloneDeep, has } from 'lodash';
const typedFieldsFor = (value) => {
const inferred = getDataTypeFromValue(value);
return inferred === 'string' ? { dataType: undefined } : { dataType: inferred };
};
const initialState = {
globalEnvironments: [],
activeGlobalEnvironmentUid: null,
@@ -281,14 +287,16 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) =>
let variables = cloneDeep(environment?.variables);
// "globalEnvironmentVariables" will include only the enabled variables and newly added variables created using the script.
// Update the value of each variable if it's present in "globalEnvironmentVariables", otherwise keep the existing value.
variables = variables?.map?.((variable) => ({
...variable,
value: has(globalEnvironmentVariables, variable?.name)
? globalEnvironmentVariables[variable?.name]
: variable?.value
}));
variables = variables?.map?.((variable) => {
if (!has(globalEnvironmentVariables, variable?.name)) return variable;
const newValue = globalEnvironmentVariables[variable?.name];
return {
...variable,
value: newValue,
...typedFieldsFor(newValue)
};
});
Object.entries(globalEnvironmentVariables)?.forEach?.(([key, value]) => {
const isAnExistingVariable = variables?.find((v) => v?.name == key);
@@ -299,7 +307,8 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) =>
value,
type: 'text',
secret: false,
enabled: true
enabled: true,
...typedFieldsFor(value)
});
}
});

View File

@@ -188,8 +188,10 @@ const STATIC_API_HINTS = {
// Mock data functions - prefixed with $
const MOCK_DATA_HINTS = Object.keys(mockDataFunctions).map((key) => `$${key}`);
// Constants for word pattern matching
const WORD_PATTERN = /[\w.$-/]/;
// Constants for word pattern matching.
// `-` is placed last so it is a literal hyphen, not a range operator — `$-/`
// would otherwise match `( ) % & ' * + ,`
const WORD_PATTERN = /[\w.$/-]/;
const VARIABLE_PATTERN = /\{\{([\w$.-]*)$/;
const NON_CHARACTER_KEYS = /^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/;
@@ -803,7 +805,7 @@ export const setupAutoComplete = (editor, options = {}) => {
};
// Exported for testing
export { extractNextSegmentSuggestions };
export { extractNextSegmentSuggestions, WORD_PATTERN };
// Initialize autocomplete command if not already present
if (!CodeMirror.commands.autocomplete) {

View File

@@ -19,7 +19,8 @@ jest.mock('codemirror', () => {
import {
getAutoCompleteHints,
setupAutoComplete,
extractNextSegmentSuggestions
extractNextSegmentSuggestions,
WORD_PATTERN
} from './autocomplete';
describe('Bruno Autocomplete', () => {
@@ -184,6 +185,32 @@ describe('Bruno Autocomplete', () => {
});
});
// The API token must be isolated from the surrounding code so the right
// context is detected — regression guard for the WORD_PATTERN range bug,
// where `( , + ...` glued the preceding code onto the token.
const embeddedCases = [
{ name: 'bru inside a function call', line: 'console.log(bru.get', expected: ['getEnvVar(key)', 'getAllEnvVars()'] },
{ name: 'bru after an assignment', line: 'const value = bru.get', expected: ['getEnvVar(key)', 'getAllEnvVars()'] },
{ name: 'bru as an argument after a comma', line: 'fn(arg,bru.get', expected: ['getEnvVar(key)', 'getAllEnvVars()'] },
{ name: 'req inside a function call', line: 'console.log(req.get', expected: ['getUrl()', 'getHeaders()'] },
{ name: 'res after a return', line: 'return res.get', expected: ['getStatus()', 'getBody()'] }
];
embeddedCases.forEach(({ name, line, expected }) => {
it(`should provide hints for ${name}`, () => {
mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: line.length });
mockedCodemirror.getLine.mockReturnValue(line);
mockedCodemirror.getRange.mockReturnValue(line);
const result = getAutoCompleteHints(mockedCodemirror, {}, [], {
showHintsFor: ['req', 'res', 'bru']
});
expect(result).toBeTruthy();
expect(result.list).toEqual(expect.arrayContaining(expected));
});
});
it('should provide method hints for nested req objects', () => {
mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 7 });
mockedCodemirror.getLine.mockReturnValue('req.get');
@@ -420,6 +447,16 @@ describe('Bruno Autocomplete', () => {
});
});
describe('WORD_PATTERN', () => {
it('matches token characters (word chars, . $ / -) and nothing else', () => {
const matching = [...'abcXYZ0189_', '.', '$', '/', '-'];
const nonMatching = [...'()%&\'*+,', ' ', '\t', '{', '}', '[', ']', '=', '@', '#', '!'];
matching.forEach((ch) => expect(WORD_PATTERN.test(ch)).toBe(true));
nonMatching.forEach((ch) => expect(WORD_PATTERN.test(ch)).toBe(false));
});
});
describe('extractNextSegmentSuggestions', () => {
describe('prefix matching', () => {
it('should extract the current segment for a partial prefix match', () => {

View File

@@ -278,8 +278,8 @@ export const renderVarInfo = (token, options) => {
// Check if variable is read-only (process.env, runtime, dynamic/faker, oauth2, and undefined variables cannot be edited)
const isReadOnly = scopeInfo.type === 'process.env' || scopeInfo.type === 'runtime' || scopeInfo.type === 'dynamic' || scopeInfo.type === 'oauth2' || scopeInfo.type === 'undefined' || hasRuntimeVariable;
// Get raw value from scope
const rawValue = scopeInfo.value || '';
// `??` preserves typed falsy values (false / 0); `||` would clobber them to ''.
const rawValue = scopeInfo.value ?? '';
// Check if variable should be masked:
const isSecret = scopeInfo.type !== 'undefined' ? isVariableSecret(scopeInfo) : false;
@@ -383,9 +383,10 @@ export const renderVarInfo = (token, options) => {
// Get all variables for syntax highlighting (but prevent recursive tooltips)
const allVariables = collection ? getAllVariables(collection, item) : {};
// Create CodeMirror instance
const editorInitialValue = typeof rawValue === 'string' ? rawValue : JSON.stringify(rawValue, null, 2);
const cmEditor = CodeMirror(editorContainer, {
value: typeof rawValue === 'string' ? rawValue : String(rawValue), // Use raw value (e.g., {{echo-host}} not resolved value) (ensure it's always a string for CodeMirror) #usebruno/bruno/#6265
value: editorInitialValue,
mode: 'brunovariables',
theme: cmTheme,
lineWrapping: true,
@@ -415,8 +416,8 @@ export const renderVarInfo = (token, options) => {
maskedEditor.enable();
}
// Store original value for comparison and track editing state
let originalValue = rawValue;
// Use the editor-formatted string so a no-op blur on a typed value doesn't dispatch.
let originalValue = editorInitialValue;
let isEditing = false;
// Latest resolved value and mask state used by the copy button, eye toggle, and
// error-revert path. Updated after each successful save so subsequent redraws
@@ -702,8 +703,10 @@ if (!SERVER_RENDERED) {
}
const box = target.getBoundingClientRect();
let point = { left: e.clientX, top: e.clientY };
const onMouseMove = function () {
const onMouseMove = function (moveEvent) {
point = { left: moveEvent.clientX, top: moveEvent.clientY };
clearTimeout(state.hoverTimeout);
state.hoverTimeout = setTimeout(onHover, hoverTime);
};
@@ -719,7 +722,7 @@ if (!SERVER_RENDERED) {
CodeMirror.off(document, 'mousemove', onMouseMove);
CodeMirror.off(cm.getWrapperElement(), 'mouseout', onMouseOut);
state.hoverTimeout = undefined;
onMouseHover(cm, box);
onMouseHover(cm, box, point);
};
const hoverTime = getHoverTime(cm);
@@ -729,8 +732,8 @@ if (!SERVER_RENDERED) {
CodeMirror.on(cm.getWrapperElement(), 'mouseout', onMouseOut);
}
function onMouseHover(cm, box) {
const pos = cm.coordsChar({
function onMouseHover(cm, box, point) {
const pos = cm.coordsChar(point || {
left: (box.left + box.right) / 2,
top: (box.top + box.bottom) / 2
});

View File

@@ -94,15 +94,11 @@ export const deleteSecretsInEnvs = (envs) => {
});
};
export const exportCollection = (collection, version) => {
// delete uids
export const prepareCollectionForExport = (collection, version) => {
delete collection.uid;
// delete process variables
delete collection.processEnvVariables;
delete collection.workspaceProcessEnvVariables;
// filter out transient items
collection.items = filterTransientItems(collection.items);
deleteUidsInItems(collection.items);
@@ -113,6 +109,12 @@ export const exportCollection = (collection, version) => {
collection.exportedAt = new Date().toISOString();
collection.exportedUsing = version ? `Bruno/${version}` : 'Bruno';
return collection;
};
export const exportCollection = (collection, version) => {
prepareCollectionForExport(collection, version);
const fileName = `${collection.name}.json`;
const fileBlob = new Blob([JSON.stringify(collection, null, 2)], { type: 'application/json' });

View File

@@ -1188,7 +1188,7 @@ export const getEnvironmentVariables = (collection) => {
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
if (environment) {
each(environment.variables, (variable) => {
if (variable.name && variable.value && variable.enabled) {
if (variable.name && variable.enabled) {
variables[variable.name] = variable.value;
}
});
@@ -1696,7 +1696,7 @@ export const getVariableScope = (variableName, collection, item) => {
// 5. Check Global Environment Variables
const { globalEnvironmentVariables = {} } = collection;
if (globalEnvironmentVariables && globalEnvironmentVariables[variableName]) {
if (variableName in globalEnvironmentVariables) {
return {
type: 'global',
value: globalEnvironmentVariables[variableName],
@@ -1706,7 +1706,7 @@ export const getVariableScope = (variableName, collection, item) => {
// 6. Check Runtime Variables (set during request execution via scripts)
const { runtimeVariables = {} } = collection;
if (runtimeVariables && runtimeVariables[variableName]) {
if (variableName in runtimeVariables) {
return {
type: 'runtime',
value: runtimeVariables[variableName],

View File

@@ -14,33 +14,35 @@ const toPersistedEnvVarForMerge = (persistedNames) => (v) => {
const toPersistedEnvVarForSave = (v) => {
const { ephemeral, persistedValue, ...rest } = v || {};
return v?.ephemeral ? (persistedValue !== undefined ? { ...rest, value: persistedValue } : rest) : rest;
return rest;
};
/*
High-level builder for persisted variables
- mode 'save': write what the user sees
- mode 'merge': write only allowed vars (non-ephemeral, ephemerals with persistedValue, or explicitly persisted this run)
*/
// mode 'save': commit the visible value (Save button).
// mode 'merge': commit only allowed vars — non-ephemeral, ephemerals with
// persistedValue, or names explicitly persisted this run.
export const buildPersistedEnvVariables = (variables, { mode, persistedNames } = {}) => {
const src = Array.isArray(variables) ? variables : [];
if (mode === 'merge') {
const names = persistedNames instanceof Set ? persistedNames : new Set();
return src.filter(isPersistableEnvVarForMerge(names)).map(toPersistedEnvVarForMerge(names));
}
// default to save mode
return src.map(toPersistedEnvVarForSave);
};
export const buildEnvVariable = ({ envVariable: obj, withUuid = false }) => {
const isSecret = !!obj.secret;
let envVariable = {
name: obj.name ?? '',
value: !!obj.secret ? '' : (obj.value ?? ''),
value: isSecret ? '' : (obj.value ?? ''),
type: 'text',
enabled: obj.enabled !== false,
secret: !!obj.secret
secret: isSecret
};
if (obj.dataType && obj.dataType !== 'string') {
envVariable.dataType = obj.dataType;
}
if (!withUuid) {
return envVariable;
}
@@ -51,11 +53,11 @@ export const buildEnvVariable = ({ envVariable: obj, withUuid = false }) => {
};
};
/**
* Strips the UID from an environment variable for comparison purposes.
* 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 } = variable;
return { name, value, type, enabled, secret };
const { name, value, type, enabled, secret, dataType } = variable;
const result = { name, value, type, enabled, secret };
if (dataType && dataType !== 'string') {
result.dataType = dataType;
}
return result;
};

View File

@@ -0,0 +1,149 @@
jest.mock('nanoid', () => ({
nanoid: () => 'aaaaaaaaaaaaaaaaaaaa1',
customAlphabet: () => () => 'aaaaaaaaaaaaaaaaaaaa1'
}));
import { buildEnvVariable, stripEnvVarUid, buildPersistedEnvVariables } from './environments';
describe('buildEnvVariable — dataType preservation for env export/import', () => {
it('preserves non-string datatypes on non-secret variables', () => {
expect(buildEnvVariable({ envVariable: { name: 'count', value: 42, secret: false, dataType: 'number' } }))
.toEqual({ name: 'count', value: 42, type: 'text', enabled: true, secret: false, dataType: 'number' });
expect(buildEnvVariable({ envVariable: { name: 'flag', value: true, secret: false, dataType: 'boolean' } }))
.toEqual({ name: 'flag', value: true, type: 'text', enabled: true, secret: false, dataType: 'boolean' });
expect(buildEnvVariable({ envVariable: { name: 'cfg', value: { k: 1 }, secret: false, dataType: 'object' } }))
.toEqual({ name: 'cfg', value: { k: 1 }, type: 'text', enabled: true, secret: false, dataType: 'object' });
});
it('drops `dataType: \'string\'` (the implicit default)', () => {
const out = buildEnvVariable({ envVariable: { name: 'greeting', value: 'hi', secret: false, dataType: 'string' } });
expect(out).toEqual({ name: 'greeting', value: 'hi', type: 'text', enabled: true, secret: false });
expect(out.dataType).toBeUndefined();
});
it('keeps dataType on secret variables but clears their value', () => {
const out = buildEnvVariable({ envVariable: { name: 'token', value: 'shh', secret: true, dataType: 'number' } });
expect(out).toEqual({ name: 'token', value: '', type: 'text', enabled: true, secret: true, dataType: 'number' });
});
it('attaches a uid when withUuid is true', () => {
const out = buildEnvVariable({
envVariable: { name: 'count', value: 42, secret: false, dataType: 'number' },
withUuid: true
});
expect(out.uid).toEqual(expect.any(String));
expect(out).toMatchObject({ name: 'count', value: 42, dataType: 'number' });
});
});
describe('stripEnvVarUid — datatype-aware comparison key', () => {
it('keeps non-string datatypes', () => {
expect(stripEnvVarUid({ uid: 'u', name: 'count', value: 42, type: 'text', enabled: true, secret: false, dataType: 'number' }))
.toEqual({ name: 'count', value: 42, type: 'text', enabled: true, secret: false, dataType: 'number' });
});
it('drops `dataType: \'string\'`', () => {
expect(stripEnvVarUid({ uid: 'u', name: 'greeting', value: 'hi', type: 'text', enabled: true, secret: false, dataType: 'string' }))
.toEqual({ name: 'greeting', value: 'hi', type: 'text', enabled: true, secret: false });
});
it('keeps dataType on secrets', () => {
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' });
});
});
describe('Env export → import round-trip via JSON', () => {
it('preserves dataType across export → JSON.stringify → JSON.parse → import for every supported type', () => {
const reduxEnvVars = [
{ uid: 'u1', name: 'count', value: 42, type: 'text', enabled: true, secret: false, dataType: 'number' },
{ uid: 'u2', name: 'flag', value: true, type: 'text', enabled: true, secret: false, dataType: 'boolean' },
{ uid: 'u3', name: 'cfg', value: { k: 1 }, type: 'text', enabled: true, secret: false, dataType: 'object' },
{ uid: 'u4', name: 'greeting', value: 'hi', type: 'text', enabled: true, secret: false, dataType: 'string' },
{ uid: 'u5', name: 'plain', value: 'hello', type: 'text', enabled: true, secret: false },
{ uid: 'u6', name: 'token', value: 'shh', type: 'text', enabled: true, secret: true, dataType: 'number' }
];
const exported = reduxEnvVars.map((envVariable) => buildEnvVariable({ envVariable }));
const onDisk = JSON.parse(JSON.stringify(exported));
expect(onDisk[0]).toMatchObject({ name: 'count', value: 42, dataType: 'number', secret: false });
expect(onDisk[1]).toMatchObject({ name: 'flag', value: true, dataType: 'boolean', secret: false });
expect(onDisk[2]).toMatchObject({ name: 'cfg', value: { k: 1 }, dataType: 'object', secret: false });
expect(onDisk[3]).toMatchObject({ name: 'greeting', value: 'hi', secret: false });
expect(onDisk[3].dataType).toBeUndefined();
expect(onDisk[4]).toMatchObject({ name: 'plain', value: 'hello', secret: false });
expect(onDisk[4].dataType).toBeUndefined();
expect(onDisk[5]).toMatchObject({ name: 'token', value: '', secret: true, dataType: 'number' });
const reimported = onDisk.map((envVariable) => buildEnvVariable({ envVariable, withUuid: true }));
expect(reimported[0]).toMatchObject({ name: 'count', value: 42, dataType: 'number', secret: false });
expect(reimported[1]).toMatchObject({ name: 'flag', value: true, dataType: 'boolean', secret: false });
expect(reimported[2]).toMatchObject({ name: 'cfg', value: { k: 1 }, dataType: 'object', secret: false });
expect(reimported[3]).toMatchObject({ name: 'greeting', value: 'hi', secret: false });
expect(reimported[3].dataType).toBeUndefined();
expect(reimported[4]).toMatchObject({ name: 'plain', value: 'hello', secret: false });
expect(reimported[4].dataType).toBeUndefined();
expect(reimported[5]).toMatchObject({ name: 'token', value: '', secret: true, dataType: 'number' });
});
});
describe('buildPersistedEnvVariables — save mode', () => {
// Regression guard: save mode must commit the visible value of an ephemeral
// var, not roll it back to persistedValue.
it('keeps the visible value when an ephemeral var has a persistedValue', () => {
const variables = [
{
name: 'apiKey',
value: 'testvalue',
type: 'text',
enabled: true,
secret: true,
ephemeral: true,
persistedValue: 'test'
}
];
expect(buildPersistedEnvVariables(variables, { mode: 'save' })).toEqual([
{ name: 'apiKey', value: 'testvalue', type: 'text', enabled: true, secret: true }
]);
});
it('keeps the visible value for a non-secret ephemeral var too', () => {
const variables = [
{
name: 'host',
value: 'localhost-from-script',
type: 'text',
enabled: true,
secret: false,
ephemeral: true,
persistedValue: 'localhost'
}
];
expect(buildPersistedEnvVariables(variables, { mode: 'save' })).toEqual([
{ name: 'host', value: 'localhost-from-script', type: 'text', enabled: true, secret: false }
]);
});
it('strips ephemeral/persistedValue from non-ephemeral vars', () => {
const variables = [
{
name: 'plain',
value: 'v',
type: 'text',
enabled: true,
secret: false,
ephemeral: false,
persistedValue: 'leftover'
}
];
expect(buildPersistedEnvVariables(variables, { mode: 'save' })).toEqual([
{ name: 'plain', value: 'v', type: 'text', enabled: true, secret: false }
]);
});
});

View File

@@ -0,0 +1,138 @@
// Stub both `nanoid` and `customAlphabet` — the import pipeline needs both.
jest.mock('nanoid', () => ({
nanoid: () => 'aaaaaaaaaaaaaaaaaaaa1',
customAlphabet: () => () => 'aaaaaaaaaaaaaaaaaaaa1'
}));
import { transformCollectionToSaveToExportAsFile } from '../../collections/index';
import { prepareCollectionForExport, deleteSecretsInEnvs } from '../../collections/export';
import { processBrunoCollection } from '../../importers/bruno-collection';
const UID = 'aaaaaaaaaaaaaaaaaaaa1';
const typedVars = () => [
{ uid: UID, name: 'count', value: 42, enabled: true, dataType: 'number' },
{ uid: UID, name: 'flag', value: true, enabled: true, dataType: 'boolean' },
{ uid: UID, name: 'config', value: { a: 1 }, enabled: true, dataType: 'object' },
{ uid: UID, name: 'plain', value: 'hello', enabled: true }
];
const buildCollection = () => ({
uid: UID,
name: 'Typed Collection',
version: '1',
items: [
{
uid: UID,
type: 'folder',
name: 'typed-folder',
seq: 1,
root: {
request: {
headers: [],
script: { req: null, res: null },
vars: { req: typedVars(), res: [] },
tests: null
}
},
items: [
{
uid: UID,
type: 'http-request',
name: 'typed-request',
seq: 1,
request: {
url: 'https://example.com',
method: 'GET',
headers: [],
params: [],
body: { mode: 'none' },
auth: { mode: 'none' },
script: { req: null, res: null },
vars: { req: typedVars(), res: [] },
assertions: [],
tests: null
}
}
]
}
],
root: {
request: {
headers: [],
script: { req: null, res: null },
vars: { req: typedVars(), res: [] },
tests: null
}
},
environments: [
{
uid: UID,
name: 'staging',
variables: [
{ uid: UID, name: 'port', value: 8080, type: 'text', enabled: true, secret: false, dataType: 'number' },
{ uid: UID, name: 'debug', value: true, type: 'text', enabled: true, secret: false, dataType: 'boolean' },
{ uid: UID, name: 'config', value: { region: 'us' }, type: 'text', enabled: true, secret: false, dataType: 'object' },
{ uid: UID, name: 'plain', value: 'hi', type: 'text', enabled: true, secret: false },
{ uid: UID, name: 'token', value: 'shh', type: 'text', enabled: true, secret: true, dataType: 'number' }
]
}
],
brunoConfig: { version: '1', name: 'Typed Collection' }
});
const assertTypedVars = (vars) => {
expect(vars).toHaveLength(4);
expect(vars[0]).toMatchObject({ name: 'count', value: 42, dataType: 'number' });
expect(vars[1]).toMatchObject({ name: 'flag', value: true, dataType: 'boolean' });
expect(vars[2]).toMatchObject({ name: 'config', value: { a: 1 }, dataType: 'object' });
expect(vars[3]).toMatchObject({ name: 'plain', value: 'hello' });
expect(vars[3].dataType).toBeUndefined();
};
describe('Bruno JSON export/import — dataType preservation', () => {
it('preserves dataType on collection / folder / request variables and env variables through a full round-trip', async () => {
const collection = buildCollection();
const exported = prepareCollectionForExport(transformCollectionToSaveToExportAsFile(collection));
const json = JSON.parse(JSON.stringify(exported));
const secretBeforeImport = json.environments[0].variables.find((v) => v.name === 'token');
expect(secretBeforeImport.value).toBe('');
expect(secretBeforeImport.dataType).toBe('number');
const imported = await processBrunoCollection(json);
assertTypedVars(imported.root.request.vars.req);
const folder = imported.items.find((i) => i.type === 'folder');
assertTypedVars(folder.root.request.vars.req);
const request = folder.items.find((i) => i.type === 'http-request');
assertTypedVars(request.request.vars.req);
const envVars = imported.environments[0].variables;
expect(envVars.find((v) => v.name === 'port')).toMatchObject({ value: 8080, dataType: 'number', secret: false });
expect(envVars.find((v) => v.name === 'debug')).toMatchObject({ value: true, dataType: 'boolean', secret: false });
expect(envVars.find((v) => v.name === 'config')).toMatchObject({ value: { region: 'us' }, dataType: 'object', secret: false });
const plainEnv = envVars.find((v) => v.name === 'plain');
expect(plainEnv).toMatchObject({ value: 'hi', secret: false });
expect(plainEnv.dataType).toBeUndefined();
const secretEnv = envVars.find((v) => v.name === 'token');
expect(secretEnv).toMatchObject({ secret: true, value: '', dataType: 'number' });
});
});
describe('deleteSecretsInEnvs', () => {
it('clears the value but preserves dataType on secret variables', () => {
const envs = [
{
variables: [
{ name: 'apiKey', value: 'shh', secret: true, dataType: 'number' },
{ name: 'visible', value: 42, secret: false, dataType: 'number' }
]
}
];
deleteSecretsInEnvs(envs);
expect(envs[0].variables[0]).toEqual({ name: 'apiKey', value: '', secret: true, dataType: 'number' });
expect(envs[0].variables[1]).toEqual({ name: 'visible', value: 42, secret: false, dataType: 'number' });
});
});

View File

@@ -0,0 +1,253 @@
import {
parseValueByDataType,
getDataTypeFromValue,
validateDataTypeValue,
valueToString,
BRUNO_VARIABLE_DATATYPES,
isBrunoVariableDataType
} from './datatype';
/*
* Canonical coercion test matrix. `@usebruno/lang` and `@usebruno/filestore`
* both delegate to `parseValueByDataType` from this package, so this is the
* only place the contract is exercised in detail.
*/
const parse = (value: any, dataType: any) => parseValueByDataType(value, dataType);
describe('parseValueByDataType — shared matrix', () => {
describe('passthrough cases', () => {
it('returns the raw value when dataType is missing or "string"', () => {
expect(parse('hi', undefined)).toBe('hi');
expect(parse('hi', 'string')).toBe('hi');
expect(parse(42, 'string')).toBe(42);
});
});
describe('number', () => {
it('coerces numeric strings', () => {
expect(parse('42', 'number')).toBe(42);
expect(parse(' 42 ', 'number')).toBe(42);
expect(parse('-3.14', 'number')).toBe(-3.14);
});
it('short-circuits when value is already a number', () => {
expect(parse(42, 'number')).toBe(42);
expect(parse(0, 'number')).toBe(0);
});
it('does NOT coerce empty/whitespace strings to 0', () => {
// Guard against `Number('')` returning 0.
expect(parse('', 'number')).toBe('');
expect(parse(' ', 'number')).toBe(' ');
});
it('falls back to the raw value when not numeric', () => {
expect(parse('abc', 'number')).toBe('abc');
expect(parse('1abc', 'number')).toBe('1abc');
});
it('falls back when value is null/undefined', () => {
expect(parse(null, 'number')).toBeNull();
expect(parse(undefined, 'number')).toBeUndefined();
});
});
describe('boolean', () => {
it('coerces "true"/"false" strings', () => {
expect(parse('true', 'boolean')).toBe(true);
expect(parse('false', 'boolean')).toBe(false);
});
it('short-circuits when value is already a boolean', () => {
expect(parse(true, 'boolean')).toBe(true);
expect(parse(false, 'boolean')).toBe(false);
});
it('falls back to the raw value for anything else', () => {
expect(parse('True', 'boolean')).toBe('True'); // strict — case-sensitive by design
expect(parse('1', 'boolean')).toBe('1');
expect(parse('yes', 'boolean')).toBe('yes');
});
});
describe('object', () => {
it('coerces JSON object strings', () => {
expect(parse('{"a":1}', 'object')).toEqual({ a: 1 });
expect(parse('[1,2,3]', 'object')).toEqual([1, 2, 3]);
});
it('short-circuits when value is already an object', () => {
const obj = { a: 1 };
expect(parse(obj, 'object')).toBe(obj);
});
it('falls back when JSON parses to a primitive', () => {
// Scalars (string/number/null/bool) must not be returned as "object",
// or validateDataTypeValue can't flag the mismatch.
expect(parse('"hello"', 'object')).toBe('"hello"');
expect(parse('42', 'object')).toBe('42');
expect(parse('null', 'object')).toBe('null');
expect(parse('true', 'object')).toBe('true');
});
it('falls back when value is not valid JSON', () => {
expect(parse('not json', 'object')).toBe('not json');
expect(parse('{bad}', 'object')).toBe('{bad}');
});
it('does NOT call JSON.parse on empty/whitespace strings or null/undefined', () => {
// Empty/whitespace short-circuit (matching the number branch) so we don't
// rely on JSON.parse to throw inside the try/catch.
expect(parse('', 'object')).toBe('');
expect(parse(' ', 'object')).toBe(' ');
expect(parse(null, 'object')).toBeNull();
expect(parse(undefined, 'object')).toBeUndefined();
});
});
describe('dataType param contract', () => {
// The `dataType` param is typed as `BrunoVariableDataType | undefined`.
// Anything outside the union is a type error at compile time. At runtime we
// pin down the passthrough behavior so a stray uppercase / unknown string
// never silently mis-coerces.
it('passes through unchanged when dataType is outside the union (runtime guard)', () => {
expect(parse('42', 'NUMBER' as any)).toBe('42');
expect(parse('42', 'Number' as any)).toBe('42');
expect(parse('true', 'bool' as any)).toBe('true');
expect(parse('{"a":1}', 'json' as any)).toBe('{"a":1}');
expect(parse('hi', '' as any)).toBe('hi');
});
});
});
describe('BRUNO_VARIABLE_DATATYPES / isBrunoVariableDataType', () => {
it('exposes exactly the four bruno variable datatypes in canonical order', () => {
expect(BRUNO_VARIABLE_DATATYPES).toEqual(['string', 'number', 'boolean', 'object']);
});
it('recognizes every member of the canonical list', () => {
for (const t of BRUNO_VARIABLE_DATATYPES) {
expect(isBrunoVariableDataType(t)).toBe(true);
}
});
it('rejects anything outside the canonical list', () => {
expect(isBrunoVariableDataType('null')).toBe(false);
expect(isBrunoVariableDataType('NUMBER')).toBe(false);
expect(isBrunoVariableDataType('')).toBe(false);
expect(isBrunoVariableDataType(undefined)).toBe(false);
expect(isBrunoVariableDataType(null)).toBe(false);
expect(isBrunoVariableDataType(42)).toBe(false);
});
});
describe('getDataTypeFromValue', () => {
it('returns "string" for null/undefined/empty', () => {
expect(getDataTypeFromValue(undefined)).toBe('string');
expect(getDataTypeFromValue(null)).toBe('string');
expect(getDataTypeFromValue('')).toBe('string');
});
it('maps native JS types to bruno datatypes', () => {
expect(getDataTypeFromValue(42)).toBe('number');
expect(getDataTypeFromValue(0)).toBe('number');
expect(getDataTypeFromValue(true)).toBe('boolean');
expect(getDataTypeFromValue(false)).toBe('boolean');
expect(getDataTypeFromValue({ a: 1 })).toBe('object');
expect(getDataTypeFromValue([1, 2])).toBe('object');
});
it('keeps strings as "string" regardless of content', () => {
expect(getDataTypeFromValue('42')).toBe('string');
expect(getDataTypeFromValue('true')).toBe('string');
expect(getDataTypeFromValue('{"a":1}')).toBe('string');
expect(getDataTypeFromValue(JSON.stringify({ x: 1 }))).toBe('string');
expect(getDataTypeFromValue('plain text')).toBe('string');
});
});
describe('validateDataTypeValue', () => {
it('returns null when dataType is missing or "string"', () => {
expect(validateDataTypeValue('anything', undefined)).toBeNull();
expect(validateDataTypeValue('anything', 'string')).toBeNull();
});
it('returns null for null/undefined values regardless of dataType', () => {
expect(validateDataTypeValue(null, 'number')).toBeNull();
expect(validateDataTypeValue(undefined, 'object')).toBeNull();
});
it('validates numbers', () => {
expect(validateDataTypeValue(42, 'number')).toBeNull();
expect(validateDataTypeValue('42', 'number')).toBe('Value is not a valid number');
expect(validateDataTypeValue('abc', 'number')).toBe('Value is not a valid number');
});
it('validates booleans', () => {
expect(validateDataTypeValue(true, 'boolean')).toBeNull();
expect(validateDataTypeValue('true', 'boolean')).toBe('Value is not a valid boolean');
expect(validateDataTypeValue(1, 'boolean')).toBe('Value is not a valid boolean');
});
it('validates objects', () => {
expect(validateDataTypeValue({ a: 1 }, 'object')).toBeNull();
expect(validateDataTypeValue([1, 2], 'object')).toBeNull();
expect(validateDataTypeValue('{"a":1}', 'object')).toBe('Value is not a valid object');
expect(validateDataTypeValue('not json', 'object')).toBe('Value is not a valid object');
});
});
describe('valueToString — round-trip with parseValueByDataType', () => {
it('stringifies typed values', () => {
expect(valueToString('hi')).toBe('hi');
expect(valueToString(42)).toBe('42');
expect(valueToString(true)).toBe('true');
expect(valueToString({ a: 1 })).toBe('{"a":1}');
expect(valueToString([1, 2])).toBe('[1,2]');
});
it('treats null/undefined as empty string', () => {
expect(valueToString(null)).toBe('');
expect(valueToString(undefined)).toBe('');
});
it('pretty-prints object/array values when given an indent', () => {
expect(valueToString({ a: 1 }, 2)).toBe('{\n "a": 1\n}');
expect(valueToString([1, 2], 2)).toBe('[\n 1,\n 2\n]');
// Primitives ignore the indent.
expect(valueToString(42, 2)).toBe('42');
expect(valueToString('hi', 2)).toBe('hi');
// Still round-trips when indented.
expect(parseValueByDataType(valueToString({ a: 1 }, 2), 'object')).toEqual({ a: 1 });
});
it('round-trips through parseValueByDataType for every supported dataType', () => {
expect(parseValueByDataType(valueToString(42), 'number')).toBe(42);
expect(parseValueByDataType(valueToString(true), 'boolean')).toBe(true);
expect(parseValueByDataType(valueToString({ a: 1 }), 'object')).toEqual({ a: 1 });
expect(parseValueByDataType(valueToString([1, 2]), 'object')).toEqual([1, 2]);
});
it('returns empty string for functions and symbols', () => {
expect(valueToString(() => 42)).toBe('');
expect(valueToString(function named() {})).toBe('');
expect(valueToString(Symbol('s'))).toBe('');
});
it('returns empty string for objects with circular references', () => {
const circular: any = { a: 1 };
circular.self = circular;
expect(valueToString(circular)).toBe('');
});
it('stringifies arrays containing undefined slots as null', () => {
expect(valueToString([undefined] as any)).toBe('[null]');
expect(valueToString([1, undefined, 3] as any)).toBe('[1,null,3]');
});
it('drops object properties whose values are functions or symbols (JSON.stringify behavior)', () => {
expect(valueToString({ a: 1, b: () => 2 })).toBe('{"a":1}');
expect(valueToString({ a: 1, b: Symbol('s') })).toBe('{"a":1}');
});
});

View File

@@ -0,0 +1,73 @@
export type BrunoVariableDataType = 'string' | 'number' | 'boolean' | 'object';
export const BRUNO_VARIABLE_DATATYPES: readonly BrunoVariableDataType[] = ['string', 'number', 'boolean', 'object'];
export const isBrunoVariableDataType = (t: unknown): t is BrunoVariableDataType =>
typeof t === 'string' && (BRUNO_VARIABLE_DATATYPES as readonly string[]).includes(t);
// string-form → typed JS value, or raw on failure.
export const parseValueByDataType = (value: any, dataType?: BrunoVariableDataType): any => {
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;
};
// Strict typeof — used by bru.set* so JSON / numeric / boolean strings stay strings.
export const getDataTypeFromValue = (value: unknown): BrunoVariableDataType => {
if (value === null || value === undefined) return 'string';
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'object') return 'object';
return 'string';
};
// Round-trip pair with parseValueByDataType.
export const valueToString = (value: unknown, indent?: number): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
if (typeof value === 'function' || typeof value === 'symbol') return '';
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, indent) ?? '';
} catch (_) {
return '';
}
}
return String(value);
};
// Returns an error message when post-coerce value's JS type doesn't match dataType.
export const validateDataTypeValue = (value: any, dataType?: BrunoVariableDataType): string | null => {
if (!dataType || dataType === 'string') return null;
if (value === undefined || value === null) return null;
if (dataType === 'number' && typeof value !== 'number') return `Value is not a valid ${dataType}`;
if (dataType === 'boolean' && typeof value !== 'boolean') return `Value is not a valid ${dataType}`;
if (dataType === 'object' && typeof value !== 'object') return `Value is not a valid ${dataType}`;
return null;
};

View File

@@ -28,3 +28,13 @@ export {
jsonToDotenv,
DotenvVariable
} from './jsonToDotenv';
export {
parseValueByDataType,
getDataTypeFromValue,
validateDataTypeValue,
valueToString,
BrunoVariableDataType,
BRUNO_VARIABLE_DATATYPES,
isBrunoVariableDataType
} from './datatype';

View File

@@ -1,3 +1,6 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]]
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript'
]
};

View File

@@ -1,6 +1,6 @@
module.exports = {
transform: {
'^.+\\.js$': 'babel-jest'
'^.+\\.[jt]s$': 'babel-jest'
},
setupFiles: ['<rootDir>/jest.setup.js'],
transformIgnorePatterns: [

View File

@@ -19,6 +19,7 @@
"prepack": "npm run test && npm run build"
},
"dependencies": {
"@usebruno/common": "^0.1.0",
"@usebruno/schema": "^0.7.0",
"js-yaml": "^4.1.1",
"jscodeshift": "^17.3.0",
@@ -29,6 +30,7 @@
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.25.0",
"@opencollection/types": "0.9.1",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^23.0.2",

View File

@@ -9,6 +9,8 @@ const path = require('path');
const packageJson = require('./package.json');
const externalDeps = [
'@usebruno/common',
'@usebruno/common/utils',
'@usebruno/schema',
'@usebruno/schema-types',
/@usebruno\/schema-types\/.*/,

View File

@@ -0,0 +1,57 @@
import type { VariableTypedValue } from '@opencollection/types/common/variables';
import {
parseValueByDataType,
BrunoVariableDataType,
isBrunoVariableDataType,
valueToString
} from '@usebruno/common/utils';
export { BrunoVariableDataType, isBrunoVariableDataType };
export const serializeVariableValue = (value: unknown): string => {
if (value !== null && typeof value === 'object') {
return JSON.stringify(value, null, 2);
}
return valueToString(value);
};
export const isTypedValue = (value: unknown): value is VariableTypedValue => {
return typeof value === 'object'
&& value !== null
&& !Array.isArray(value)
&& 'type' in value
&& 'data' in value;
};
export interface TypedVariableFields {
dataType?: BrunoVariableDataType;
}
export const hasTypedMetadata = (v: TypedVariableFields): boolean => {
return !!v.dataType && v.dataType !== 'string';
};
export const toOpenCollectionTypedValue = (
v: TypedVariableFields,
dataStr: string
): VariableTypedValue => {
return {
type: (v.dataType || 'string') as VariableTypedValue['type'],
data: dataStr
};
};
// `'string'` is the implicit default — omitted from the result.
export const fromOpenCollectionTypedValue = (
typed: VariableTypedValue
): { value: any } & TypedVariableFields => {
const dataStr = typeof typed.data === 'string' ? typed.data : String(typed.data ?? '');
const dataType: BrunoVariableDataType = isBrunoVariableDataType(typed.type) ? typed.type : 'string';
const result: { value: any } & TypedVariableFields = {
value: parseValueByDataType(dataStr, dataType)
};
if (dataType !== 'string') {
result.dataType = dataType;
}
return result;
};

View File

@@ -4,6 +4,13 @@ import type {
BrunoVariable,
BrunoVariables
} from '../types';
import {
isTypedValue,
hasTypedMetadata,
toOpenCollectionTypedValue,
fromOpenCollectionTypedValue,
serializeVariableValue
} from './datatype';
interface BrunoVars {
req: BrunoVariables;
@@ -18,23 +25,24 @@ export const fromOpenCollectionVariables = (variables: Variable[] | undefined):
const reqVars: BrunoVariable[] = [];
variables.forEach((v: Variable) => {
let value = '';
if (typeof v.value === 'string') {
value = v.value;
} else if (v.value && typeof v.value === 'object' && 'data' in v.value) {
value = (v.value as { data: string }).data || '';
}
const variable: BrunoVariable = {
uid: uuid(),
name: v.name || '',
value,
value: '',
enabled: v.disabled !== true,
local: false
};
if (isTypedValue(v.value)) {
Object.assign(variable, fromOpenCollectionTypedValue(v.value));
} else if (typeof v.value === 'string') {
variable.value = v.value;
}
if (v.description) {
variable.description = typeof v.description === 'string' ? v.description : (v.description as { content?: string })?.content || '';
variable.description = typeof v.description === 'string'
? v.description
: (v.description as { content?: string })?.content || '';
}
reqVars.push(variable);
@@ -55,9 +63,10 @@ export const toOpenCollectionVariables = (vars: BrunoVars | { req?: BrunoVariabl
}
const ocVariables: Variable[] = reqVarsArray.map((v: BrunoVariable): Variable => {
const valueStr = serializeVariableValue(v.value);
const variable: Variable = {
name: v.name || '',
value: v.value || ''
value: hasTypedMetadata(v) ? toOpenCollectionTypedValue(v, valueStr) : valueStr
};
if (v.description && typeof v.description === 'string' && v.description.trim().length) {

View File

@@ -1,4 +1,11 @@
import { uuid } from '../common/index.js';
import {
isTypedValue,
hasTypedMetadata,
toOpenCollectionTypedValue,
fromOpenCollectionTypedValue,
serializeVariableValue
} from './common/datatype';
import type {
Environment,
Variable,
@@ -8,7 +15,7 @@ import type {
interface OCVariable extends Omit<Variable, 'value'> {
name: string;
value?: string | { data: string };
value?: string | { type: string; data: unknown };
secret?: boolean;
disabled?: boolean;
}
@@ -25,23 +32,31 @@ export const fromOpenCollectionEnvironments = (environments: Environment[] | und
const variable = v as OCVariable;
const isSecret = variable.secret === true;
let value = '';
if (!isSecret && variable.value !== undefined) {
if (typeof variable.value === 'string') {
value = variable.value;
} else if (variable.value && typeof variable.value === 'object' && 'data' in variable.value) {
value = variable.value.data;
}
}
return {
const result: BrunoEnvironmentVariable = {
uid: uuid(),
name: variable.name || '',
value,
value: '',
type: 'text',
enabled: variable.disabled !== true,
secret: isSecret
};
if (isSecret) {
// Secret values are not present in the source; never carry a dataType.
return result;
}
if (variable.value === undefined) {
return result;
}
if (isTypedValue(variable.value)) {
Object.assign(result, fromOpenCollectionTypedValue(variable.value));
} else if (typeof variable.value === 'string') {
result.value = variable.value;
}
return result;
}),
color: env.color || null
}));
@@ -57,15 +72,14 @@ export const toOpenCollectionEnvironments = (environments: BrunoEnvironment[] |
name: env.name || 'Untitled Environment',
color: env.color ?? undefined,
variables: (env.variables || []).map((v): OCVariable => {
const ocVar: OCVariable = {
name: v.name || '',
value: typeof v.value === 'string' ? v.value : String(v.value ?? '')
};
const ocVar: OCVariable = { name: v.name || '' };
if (v.secret) {
ocVar.secret = true;
// Secret variables don't include the value in export
delete ocVar.value;
// Secret variables don't include the value in export.
} else {
const valueStr = serializeVariableValue(v.value);
ocVar.value = hasTypedMetadata(v) ? toOpenCollectionTypedValue(v, valueStr) : valueStr;
}
if (v.enabled === false) {

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from '@jest/globals';
import {
fromOpenCollectionEnvironments,
toOpenCollectionEnvironments
} from '../../src/opencollection/environment';
describe('fromOpenCollectionEnvironments — typed values', () => {
it('coerces typed values, omits dataType for the implicit string default, and drops dataType on secrets', () => {
const ocEnvs = [
{
name: 'staging',
variables: [
{ name: 'port', value: { type: 'number', data: '8080' } },
{ name: 'debug', value: { type: 'boolean', data: 'true' } },
{ name: 'config', value: { type: 'object', data: '{\n "region": "us"\n}' } },
{ name: 'greeting', value: { type: 'string', data: 'hi' } },
{ name: 'plain', value: 'hello' },
{ name: 'apiKey', secret: true }
]
}
];
const [env] = fromOpenCollectionEnvironments(ocEnvs);
expect(env.variables).toHaveLength(6);
expect(env.variables[0]).toMatchObject({ name: 'port', value: 8080, dataType: 'number', secret: false });
expect(env.variables[1]).toMatchObject({ name: 'debug', value: true, dataType: 'boolean', secret: false });
expect(env.variables[2]).toMatchObject({ name: 'config', value: { region: 'us' }, dataType: 'object', secret: false });
expect(env.variables[3]).toMatchObject({ name: 'greeting', value: 'hi', secret: false });
expect(env.variables[3].dataType).toBeUndefined();
expect(env.variables[4]).toMatchObject({ name: 'plain', value: 'hello', secret: false });
expect(env.variables[4].dataType).toBeUndefined();
expect(env.variables[5]).toMatchObject({ name: 'apiKey', value: '', secret: true });
expect(env.variables[5].dataType).toBeUndefined();
});
});
describe('toOpenCollectionEnvironments — typed values', () => {
it('serializes typed env vars as `{type, data}`, plain strings as raw, and never writes a value or dataType for secrets', () => {
const envs = [
{
uid: 'e1',
name: 'staging',
variables: [
{ uid: 'v1', name: 'port', value: 8080, type: 'text', enabled: true, secret: false, dataType: 'number' },
{ uid: 'v2', name: 'debug', value: true, type: 'text', enabled: true, secret: false, dataType: 'boolean' },
{ uid: 'v3', name: 'config', value: { region: 'us' }, type: 'text', enabled: true, secret: false, dataType: 'object' },
{ uid: 'v4', name: 'greeting', value: 'hi', type: 'text', enabled: true, secret: false, dataType: 'string' },
{ uid: 'v5', name: 'plain', value: 'hello', type: 'text', enabled: true, secret: false },
{ uid: 'v6', name: 'apiKey', value: '', type: 'text', enabled: true, secret: true, dataType: 'number' }
],
color: null
}
];
const out = toOpenCollectionEnvironments(envs);
expect(out).toEqual([
{
name: 'staging',
color: undefined,
variables: [
{ name: 'port', value: { type: 'number', data: '8080' } },
{ name: 'debug', value: { type: 'boolean', data: 'true' } },
{ name: 'config', value: { type: 'object', data: '{\n "region": "us"\n}' } },
{ name: 'greeting', value: 'hi' },
{ name: 'plain', value: 'hello' },
{ name: 'apiKey', secret: true }
]
}
]);
});
});
describe('OpenCollection environment round-trip', () => {
it('survives from→to→from for typed env vars and secrets', () => {
const ocEnvs = [
{
name: 'staging',
color: undefined,
variables: [
{ name: 'port', value: { type: 'number', data: '8080' } },
{ name: 'flag', value: { type: 'boolean', data: 'true' } },
{ name: 'config', value: { type: 'object', data: '{\n "region": "us"\n}' } },
{ name: 'plain', value: 'hello' },
{ name: 'apiKey', secret: true }
]
}
];
const fromOc = fromOpenCollectionEnvironments(ocEnvs);
const out = toOpenCollectionEnvironments(fromOc);
expect(out).toEqual(ocEnvs);
});
});

View File

@@ -0,0 +1,75 @@
import { describe, it, expect } from '@jest/globals';
import {
fromOpenCollectionVariables,
toOpenCollectionVariables
} from '../../src/opencollection/common/variables';
describe('fromOpenCollectionVariables — typed values', () => {
it('coerces typed values, omits dataType for the implicit string default, and preserves plain strings', () => {
const ocVars = [
{ name: 'count', value: { type: 'number', data: '42' } },
{ name: 'enabled', value: { type: 'boolean', data: 'true' } },
{ name: 'config', value: { type: 'object', data: '{"a":1}' } },
{ name: 'greeting', value: { type: 'string', data: 'hi' } },
{ name: 'plain', value: 'hello' }
];
const { req } = fromOpenCollectionVariables(ocVars);
expect(req).toHaveLength(5);
expect(req[0]).toMatchObject({ name: 'count', value: 42, dataType: 'number' });
expect(req[1]).toMatchObject({ name: 'enabled', value: true, dataType: 'boolean' });
expect(req[2]).toMatchObject({ name: 'config', value: { a: 1 }, dataType: 'object' });
expect(req[3]).toMatchObject({ name: 'greeting', value: 'hi' });
expect(req[3].dataType).toBeUndefined();
expect(req[4]).toMatchObject({ name: 'plain', value: 'hello' });
expect(req[4].dataType).toBeUndefined();
});
});
describe('toOpenCollectionVariables — typed values', () => {
it('serializes typed bruno vars as `{type, data}` and emits raw strings for the implicit default', () => {
const brunoVars = [
{ uid: 'u1', name: 'count', value: 42, enabled: true, dataType: 'number' },
{ uid: 'u2', name: 'enabled', value: true, enabled: true, dataType: 'boolean' },
{ uid: 'u3', name: 'config', value: { a: 1 }, enabled: true, dataType: 'object' },
{ uid: 'u4', name: 'greeting', value: 'hi', enabled: true, dataType: 'string' },
{ uid: 'u5', name: 'plain', value: 'hello', enabled: true }
];
const out = toOpenCollectionVariables(brunoVars);
expect(out).toEqual([
{ name: 'count', value: { type: 'number', data: '42' } },
{ name: 'enabled', value: { type: 'boolean', data: 'true' } },
{ name: 'config', value: { type: 'object', data: '{\n "a": 1\n}' } },
{ name: 'greeting', value: 'hi' },
{ name: 'plain', value: 'hello' }
]);
});
it('accepts the {req, res} folder shape and only serializes req vars', () => {
const out = toOpenCollectionVariables({
req: [{ uid: 'u1', name: 'a', value: 7, enabled: true, dataType: 'number' }],
res: [{ uid: 'u2', name: 'b', value: 'expr', enabled: true }]
});
expect(out).toEqual([{ name: 'a', value: { type: 'number', data: '7' } }]);
});
});
describe('OpenCollection variables round-trip', () => {
it('survives from→to→from for the full dataType matrix', () => {
const ocVars = [
{ name: 'count', value: { type: 'number', data: '42' } },
{ name: 'flag', value: { type: 'boolean', data: 'false' } },
{ name: 'cfg', value: { type: 'object', data: '{\n "k": 1\n}' } },
{ name: 'plain', value: 'hello' }
];
const { req } = fromOpenCollectionVariables(ocVars);
const out = toOpenCollectionVariables(req);
expect(out).toEqual(ocVars);
});
});

View File

@@ -22,6 +22,7 @@
"paths": {
"@usebruno/schema-types": ["packages/bruno-schema-types/dist/index.d.ts"],
"@usebruno/schema-types/*": ["packages/bruno-schema-types/dist/*"],
"@usebruno/common/utils": ["packages/bruno-common/dist/utils/index.d.ts"],
"@opencollection/types": ["node_modules/@opencollection/types/dist/opencollection.d.ts"],
"@opencollection/types/*": ["node_modules/@opencollection/types/dist/*"]
}

View File

@@ -18,6 +18,7 @@ const {
} = require('@usebruno/filestore');
const { uuid } = require('../utils/common');
const { parseValueByDataType } = require('@usebruno/common/utils');
const { getRequestUid } = require('../cache/requestUids');
const { decryptStringSafe } = require('../utils/encryption');
const { setBrunoConfig } = require('../store/bruno-config');
@@ -121,7 +122,7 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath)
const variable = _.find(file.data.variables, (v) => v.name === secret.name);
if (variable && secret.value) {
const decryptionResult = decryptStringSafe(secret.value);
variable.value = decryptionResult.value;
variable.value = parseValueByDataType(decryptionResult.value, variable.dataType);
}
});
}
@@ -161,7 +162,7 @@ const changeEnvironmentFile = async (win, pathname, collectionUid, collectionPat
const variable = _.find(file.data.variables, (v) => v.name === secret.name);
if (variable && secret.value) {
const decryptionResult = decryptStringSafe(secret.value);
variable.value = decryptionResult.value;
variable.value = parseValueByDataType(decryptionResult.value, variable.dataType);
}
});
}

View File

@@ -6,6 +6,7 @@ const yaml = require('js-yaml');
const { generateUidBasedOnHash, uuid } = require('../utils/common');
const { getWorkspaceUid, normalizeWorkspaceConfig } = require('../utils/workspace-config');
const { parseEnvironment } = require('@usebruno/filestore');
const { parseValueByDataType } = require('@usebruno/common/utils');
const EnvironmentSecretsStore = require('../store/env-secrets');
const { decryptStringSafe } = require('../utils/encryption');
const dotEnvWatcher = require('./dotenv-watcher');
@@ -78,7 +79,7 @@ const parseGlobalEnvironmentFile = async (pathname, workspacePath, workspaceUid)
const variable = _.find(file.data.variables, (v) => v.name === secret.name);
if (variable && secret.value) {
const decryptionResult = decryptStringSafe(secret.value);
variable.value = decryptionResult.value;
variable.value = parseValueByDataType(decryptionResult.value, variable.dataType);
}
});
}

View File

@@ -1,5 +1,6 @@
const _ = require('lodash');
const Store = require('electron-store');
const { valueToString } = require('@usebruno/common/utils');
const { encryptStringSafe } = require('../utils/encryption');
const posixifyPath = (p) => (p ? p.replace(/\\/g, '/') : p);
@@ -36,7 +37,7 @@ class EnvironmentSecretsStore {
if (v.secret) {
envVars.push({
name: v.name,
value: encryptStringSafe(v.value).value
value: encryptStringSafe(valueToString(v.value)).value
});
}
});

View File

@@ -1,5 +1,6 @@
const _ = require('lodash');
const Store = require('electron-store');
const { parseValueByDataType, valueToString } = require('@usebruno/common/utils');
const { encryptStringSafe, decryptStringSafe } = require('../utils/encryption');
const { environmentSchema } = require('@usebruno/schema');
const { posixifyPath } = require('../utils/filesystem');
@@ -38,7 +39,7 @@ class GlobalEnvironmentsStore {
return globalEnvironments?.map((env) => {
const variables = env.variables?.map((v) => ({
...v,
value: v?.secret ? encryptStringSafe(v.value).value : v?.value
value: v?.secret ? encryptStringSafe(valueToString(v.value)).value : v?.value
})) || [];
return {
@@ -52,7 +53,7 @@ class GlobalEnvironmentsStore {
return globalEnvironments?.map((env) => {
const variables = env.variables?.map((v) => ({
...v,
value: v?.secret ? decryptStringSafe(v.value).value : v?.value
value: v?.secret ? parseValueByDataType(decryptStringSafe(v.value).value, v.dataType) : v?.value
})) || [];
return {
@@ -73,6 +74,9 @@ class GlobalEnvironmentsStore {
if (!v.type) {
v.type = 'text';
}
if (v.dataType && v.dataType !== 'string' && !v.secret) {
v.value = parseValueByDataType(v.value, v.dataType);
}
});
});

View File

@@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path');
const _ = require('lodash');
const { parseEnvironment, stringifyEnvironment } = require('@usebruno/filestore');
const { parseValueByDataType } = require('@usebruno/common/utils');
const { writeFile, createDirectory } = require('../utils/filesystem');
const { generateUidBasedOnHash, uuid } = require('../utils/common');
const { decryptStringSafe } = require('../utils/encryption');
@@ -73,7 +74,7 @@ class GlobalEnvironmentsManager {
const variable = _.find(environment.variables, (v) => v.name === secret.name);
if (variable && secret.value) {
const decryptionResult = decryptStringSafe(secret.value);
variable.value = decryptionResult.value;
variable.value = parseValueByDataType(decryptionResult.value, variable.dataType);
}
});
}

View File

@@ -7,6 +7,15 @@ const os = require('os');
const { preferencesUtil } = require('../store/preferences');
const path = require('path');
const { DEFAULT_COLLECTION_FORMAT } = require('@usebruno/filestore');
const { parseValueByDataType } = require('@usebruno/common/utils');
/**
* Returns the variable's runtime value with datatype-driven coercion applied.
* The shared `parseValueByDataType` from `@usebruno/common/utils` honors
* draft dataType changes — e.g. a user picking `@number` on a "42" string
* via the UI takes effect at request-execution time without requiring a save.
*/
const resolveTypedValue = (v) => parseValueByDataType(v.value, v.dataType);
const FORMAT_CONFIG = {
yml: { ext: '.yml', collectionFile: 'opencollection.yml', folderFile: 'folder.yml' },
@@ -75,8 +84,9 @@ const mergeVars = (collection, request, requestTreePath = []) => {
let collectionVariables = {};
collectionRequestVars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
collectionVariables[_var.name] = _var.value;
const typed = resolveTypedValue(_var);
reqVars.set(_var.name, typed);
collectionVariables[_var.name] = typed;
}
});
let folderVariables = {};
@@ -87,16 +97,18 @@ const mergeVars = (collection, request, requestTreePath = []) => {
let vars = get(folderRoot, 'request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
folderVariables[_var.name] = _var.value;
const typed = resolveTypedValue(_var);
reqVars.set(_var.name, typed);
folderVariables[_var.name] = typed;
}
});
} else {
const vars = i?.draft ? get(i, 'draft.request.vars.req', []) : get(i, 'request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
requestVariables[_var.name] = _var.value;
const typed = resolveTypedValue(_var);
reqVars.set(_var.name, typed);
requestVariables[_var.name] = typed;
}
});
}
@@ -119,7 +131,7 @@ const mergeVars = (collection, request, requestTreePath = []) => {
let collectionResponseVars = get(collectionRoot, 'request.vars.res', []);
collectionResponseVars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
resVars.set(_var.name, resolveTypedValue(_var));
}
});
for (let i of requestTreePath) {
@@ -128,14 +140,14 @@ const mergeVars = (collection, request, requestTreePath = []) => {
let vars = get(folderRoot, 'request.vars.res', []);
vars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
resVars.set(_var.name, resolveTypedValue(_var));
}
});
} else {
const vars = i?.draft ? get(i, 'draft.request.vars.res', []) : get(i, 'request.vars.res', []);
vars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
resVars.set(_var.name, resolveTypedValue(_var));
}
});
}
@@ -776,7 +788,7 @@ const getEnvVars = (environment = {}) => {
const envVars = {};
each(variables, (variable) => {
if (variable.enabled) {
envVars[variable.name] = variable.value;
envVars[variable.name] = resolveTypedValue(variable);
}
});

View File

@@ -0,0 +1,126 @@
const { globalEnvironmentsStore } = require('../../src/store/global-environments');
const { encryptStringSafe } = require('../../src/utils/encryption');
// Previously, a bug caused environment variables to be saved without a type.
// Since that issue is now fixed, this code ensures that anyone who imported
// data before the fix will have the missing types added retroactively.
describe('global environment variable type backward compatibility', () => {
beforeEach(() => {
globalEnvironmentsStore.store.clear();
});
it('should add type field for existing global environments without type', () => {
// Mock global environments without type field
const mockGlobalEnvironments = [
{
uid: 'yDlwWe3qgimPG20G7AbF7',
name: 'Test Environment',
variables: [
{
uid: 'b6BIHGaCrm4m97YA2dIdx',
name: 'regular_var',
value: 'regular_value',
enabled: true,
secret: false
// Missing: type field
},
{
uid: 'yQTqanPoMdRjKnHyIOZNc',
name: 'secret_var',
value: 'secret_value',
enabled: true,
secret: true
// Missing: type field
}
]
}
];
globalEnvironmentsStore.store.set('environments', mockGlobalEnvironments);
const processedEnvironments = globalEnvironmentsStore.getGlobalEnvironments();
expect(processedEnvironments).toHaveLength(1);
expect(processedEnvironments[0].variables).toHaveLength(2);
const regularVar = processedEnvironments[0].variables.find((v) => v.name === 'regular_var');
const secretVar = processedEnvironments[0].variables.find((v) => v.name === 'secret_var');
expect(regularVar.name).toBe('regular_var');
expect(regularVar.type).toBe('text');
expect(secretVar.name).toBe('secret_var');
expect(secretVar.type).toBe('text');
});
});
describe('global environment variable read-time dataType parsing', () => {
beforeEach(() => {
globalEnvironmentsStore.store.clear();
});
const storedAs = (dataType, value) => ({
uid: 'yDlwWe3qgimPG20G7AbF7',
name: 'Test Environment',
variables: [
{
uid: 'b6BIHGaCrm4m97YA2dIdx',
name: 'v',
value,
dataType,
type: 'text',
enabled: true,
secret: false
}
]
});
const readVar = () => globalEnvironmentsStore.getGlobalEnvironments()[0].variables[0];
it('parses @number string values into JS numbers', () => {
globalEnvironmentsStore.store.set('environments', [storedAs('number', '42')]);
expect(readVar().value).toBe(42);
});
it('parses @boolean string values into JS booleans', () => {
globalEnvironmentsStore.store.set('environments', [storedAs('boolean', 'false')]);
expect(readVar().value).toBe(false);
});
it('parses @object string values via JSON.parse', () => {
globalEnvironmentsStore.store.set('environments', [storedAs('object', '{"a":1}')]);
expect(readVar().value).toEqual({ a: 1 });
});
it('passes string-typed values through unchanged', () => {
globalEnvironmentsStore.store.set('environments', [storedAs('string', 'hello')]);
expect(readVar().value).toBe('hello');
});
it('is idempotent — already-coerced values stay put', () => {
globalEnvironmentsStore.store.set('environments', [storedAs('number', 42)]);
expect(readVar().value).toBe(42);
});
it('parses secret variables by their dataType after decryption', () => {
// Encrypt so the decryption round-trip yields '42', which is then coerced.
const encrypted = encryptStringSafe('42').value;
const env = {
uid: 'yDlwWe3qgimPG20G7AbF7',
name: 'Test Environment',
variables: [
{
uid: 'b6BIHGaCrm4m97YA2dIdx',
name: 'sec',
value: encrypted,
dataType: 'number',
type: 'text',
enabled: true,
secret: true
}
]
};
globalEnvironmentsStore.store.set('environments', [env]);
expect(readVar().value).toBe(42);
});
});

View File

@@ -1,54 +0,0 @@
const { globalEnvironmentsStore } = require('../../src/store/global-environments');
// Previously, a bug caused environment variables to be saved without a type.
// Since that issue is now fixed, this code ensures that anyone who imported
// data before the fix will have the missing types added retroactively.
describe('global environment variable type backward compatibility', () => {
beforeEach(() => {
globalEnvironmentsStore.store.clear();
});
it('should add type field for existing global environments without type', () => {
// Mock global environments without type field
const mockGlobalEnvironments = [
{
uid: 'yDlwWe3qgimPG20G7AbF7',
name: 'Test Environment',
variables: [
{
uid: 'b6BIHGaCrm4m97YA2dIdx',
name: 'regular_var',
value: 'regular_value',
enabled: true,
secret: false
// Missing: type field
},
{
uid: 'yQTqanPoMdRjKnHyIOZNc',
name: 'secret_var',
value: 'secret_value',
enabled: true,
secret: true
// Missing: type field
}
]
}
];
globalEnvironmentsStore.store.set('environments', mockGlobalEnvironments);
const processedEnvironments = globalEnvironmentsStore.getGlobalEnvironments();
expect(processedEnvironments).toHaveLength(1);
expect(processedEnvironments[0].variables).toHaveLength(2);
const regularVar = processedEnvironments[0].variables.find((v) => v.name === 'regular_var');
const secretVar = processedEnvironments[0].variables.find((v) => v.name === 'secret_var');
expect(regularVar.name).toBe('regular_var');
expect(regularVar.type).toBe('text');
expect(secretVar.name).toBe('secret_var');
expect(secretVar.type).toBe('text');
});
});

View File

@@ -13,7 +13,7 @@
"scripts": {
"clean": "rimraf dist",
"prebuild": "npm run clean",
"build": "rollup -c && tsc --emitDeclarationOnly",
"build": "rollup -c && tsc --emitDeclarationOnly -p tsconfig.build.json",
"watch": "rollup -c -w",
"test": "jest",
"test:watch": "jest --watch",
@@ -29,6 +29,7 @@
"@types/jest": "^29.5.11",
"@types/lodash": "^4.14.191",
"@types/node": "^24.1.0",
"@opencollection/types": "0.9.1",
"@usebruno/schema-types": "0.0.1",
"babel-jest": "^29.7.0",
"jest": "^29.2.0",
@@ -45,6 +46,7 @@
},
"dependencies": {
"@types/nanoid": "^2.1.0",
"@usebruno/common": "0.1.0",
"@usebruno/lang": "0.12.0",
"ajv": "^8.17.1",
"lodash": "^4.17.21",

View File

@@ -11,6 +11,8 @@ const externalDeps = [
'@usebruno/lang',
'@usebruno/schema-types',
/@usebruno\/schema-types\/.*/,
'@usebruno/common',
/@usebruno\/common\/.*/,
'@opencollection/types',
/@opencollection\/types\/.*/,
// Runtime dependencies

View File

@@ -17,7 +17,7 @@ export const toOpenCollectionActions = (resVariables: BrunoVariables | null | un
type: 'set-variable',
phase: 'after-response',
selector: {
expression: v.value || '',
expression: ensureString(v.value),
method: 'jsonq'
},
variable: {

View File

@@ -0,0 +1,110 @@
import {
isBrunoVariableDataType,
isTypedValue,
hasTypedMetadata,
toOpenCollectionTypedValue,
fromOpenCollectionTypedValue
} from './datatype';
describe('dataType helpers', () => {
describe('isBrunoVariableDataType', () => {
it('returns true for supported Bruno datatypes', () => {
expect(isBrunoVariableDataType('string')).toBe(true);
expect(isBrunoVariableDataType('number')).toBe(true);
expect(isBrunoVariableDataType('boolean')).toBe(true);
expect(isBrunoVariableDataType('object')).toBe(true);
});
it('returns false for unsupported OpenCollection datatypes (e.g. null)', () => {
expect(isBrunoVariableDataType('null')).toBe(false);
});
it('returns false for non-string inputs', () => {
expect(isBrunoVariableDataType(undefined)).toBe(false);
expect(isBrunoVariableDataType(null)).toBe(false);
expect(isBrunoVariableDataType(42)).toBe(false);
expect(isBrunoVariableDataType({})).toBe(false);
});
});
describe('isTypedValue', () => {
it('returns true for OpenCollection typed-value shapes', () => {
expect(isTypedValue({ type: 'number', data: '42' })).toBe(true);
expect(isTypedValue({ type: 'object', data: '{}' })).toBe(true);
});
it('returns false for plain strings', () => {
expect(isTypedValue('hello')).toBe(false);
expect(isTypedValue('')).toBe(false);
});
it('returns false for arrays, null, and objects missing type/data', () => {
expect(isTypedValue(null)).toBe(false);
expect(isTypedValue([])).toBe(false);
expect(isTypedValue([{ type: 'number', data: '1' }])).toBe(false);
expect(isTypedValue({ type: 'number' })).toBe(false);
expect(isTypedValue({ data: '1' })).toBe(false);
expect(isTypedValue({})).toBe(false);
});
});
describe('hasTypedMetadata', () => {
it('returns false when dataType is missing', () => {
expect(hasTypedMetadata({})).toBe(false);
});
it('returns false when dataType is the string default', () => {
expect(hasTypedMetadata({ dataType: 'string' })).toBe(false);
});
it('returns true for non-string datatypes', () => {
expect(hasTypedMetadata({ dataType: 'number' })).toBe(true);
expect(hasTypedMetadata({ dataType: 'boolean' })).toBe(true);
expect(hasTypedMetadata({ dataType: 'object' })).toBe(true);
});
});
describe('toOpenCollectionTypedValue', () => {
it('builds a {type, data} struct from dataType + stringified value', () => {
expect(toOpenCollectionTypedValue({ dataType: 'number' }, '42'))
.toEqual({ type: 'number', data: '42' });
expect(toOpenCollectionTypedValue({ dataType: 'boolean' }, 'true'))
.toEqual({ type: 'boolean', data: 'true' });
expect(toOpenCollectionTypedValue({ dataType: 'object' }, '{"a":1}'))
.toEqual({ type: 'object', data: '{"a":1}' });
});
it('falls back to type "string" when dataType is missing', () => {
expect(toOpenCollectionTypedValue({}, 'hello'))
.toEqual({ type: 'string', data: 'hello' });
});
});
describe('fromOpenCollectionTypedValue', () => {
it('coerces typed values to their declared dataType', () => {
expect(fromOpenCollectionTypedValue({ type: 'number', data: '42' }))
.toEqual({ value: 42, dataType: 'number' });
expect(fromOpenCollectionTypedValue({ type: 'boolean', data: 'true' }))
.toEqual({ value: true, dataType: 'boolean' });
expect(fromOpenCollectionTypedValue({ type: 'object', data: '{"a":1}' }))
.toEqual({ value: { a: 1 }, dataType: 'object' });
});
it('falls back to string when the declared type is unsupported (e.g. null)', () => {
const result = fromOpenCollectionTypedValue({ type: 'null' as any, data: '' });
expect(result.dataType).toBeUndefined();
});
it('coerces non-string data to a string before coercing by dataType', () => {
const result = fromOpenCollectionTypedValue({ type: 'number', data: 42 as any });
expect(result.value).toBe(42);
expect(result.dataType).toBe('number');
});
it('returns the raw string when coercion fails (e.g. non-numeric @number)', () => {
const result = fromOpenCollectionTypedValue({ type: 'number', data: 'not-a-number' });
expect(result.value).toBe('not-a-number');
expect(result.dataType).toBe('number');
});
});
});

View File

@@ -0,0 +1,57 @@
import type { VariableTypedValue } from '@opencollection/types/common/variables';
import {
parseValueByDataType,
BrunoVariableDataType,
isBrunoVariableDataType,
valueToString
} from '@usebruno/common/utils';
export { BrunoVariableDataType, isBrunoVariableDataType };
export const serializeVariableValue = (value: unknown): string => {
if (value !== null && typeof value === 'object') {
return JSON.stringify(value, null, 2);
}
return valueToString(value);
};
export const isTypedValue = (value: unknown): value is VariableTypedValue => {
return typeof value === 'object'
&& value !== null
&& !Array.isArray(value)
&& 'type' in value
&& 'data' in value;
};
export interface TypedVariableFields {
dataType?: BrunoVariableDataType;
}
export const hasTypedMetadata = (v: TypedVariableFields): boolean => {
return !!v.dataType && v.dataType !== 'string';
};
export const toOpenCollectionTypedValue = (
v: TypedVariableFields,
dataStr: string
): VariableTypedValue => {
return {
type: (v.dataType || 'string') as VariableTypedValue['type'],
data: dataStr
};
};
// `'string'` is the implicit default — omitted from the result.
export const fromOpenCollectionTypedValue = (
typed: VariableTypedValue
): { value: any } & TypedVariableFields => {
const dataStr = typeof typed.data === 'string' ? typed.data : String(typed.data ?? '');
const dataType: BrunoVariableDataType = isBrunoVariableDataType(typed.type) ? typed.type : 'string';
const result: { value: any } & TypedVariableFields = {
value: parseValueByDataType(dataStr, dataType)
};
if (dataType !== 'string') {
result.dataType = dataType;
}
return result;
};

View File

@@ -0,0 +1,127 @@
import { toOpenCollectionVariables, toBrunoVariables } from './variables';
describe('toOpenCollectionVariables', () => {
it('returns undefined for null / empty input', () => {
expect(toOpenCollectionVariables(null)).toBeUndefined();
expect(toOpenCollectionVariables(undefined)).toBeUndefined();
expect(toOpenCollectionVariables([])).toBeUndefined();
expect(toOpenCollectionVariables({ req: [], res: [] })).toBeUndefined();
});
it('serializes plain string variables as a raw string value', () => {
const out = toOpenCollectionVariables([
{ uid: 'u1', name: 'apiKey', value: 'abc', enabled: true } as any
]);
expect(out).toEqual([{ name: 'apiKey', value: 'abc' }]);
});
it('serializes typed variables as a {type, data} struct', () => {
const out = toOpenCollectionVariables([
{ uid: 'u1', name: 'port', value: 3000, enabled: true, dataType: 'number' } as any,
{ uid: 'u2', name: 'flag', value: true, enabled: true, dataType: 'boolean' } as any,
{ uid: 'u3', name: 'config', value: { a: 1 }, enabled: true, dataType: 'object' } as any
]);
expect(out).toEqual([
{ name: 'port', value: { type: 'number', data: '3000' } },
{ name: 'flag', value: { type: 'boolean', data: 'true' } },
{ name: 'config', value: { type: 'object', data: '{\n "a": 1\n}' } }
]);
});
it('does not emit a typed struct for the string default dataType', () => {
const out = toOpenCollectionVariables([
{ uid: 'u1', name: 'apiKey', value: 'abc', enabled: true, dataType: 'string' } as any
]);
expect(out).toEqual([{ name: 'apiKey', value: 'abc' }]);
});
it('marks disabled variables and preserves description', () => {
const out = toOpenCollectionVariables([
{ uid: 'u1', name: 'apiKey', value: 'abc', enabled: false, description: 'auth key' } as any
]);
expect(out).toEqual([
{ name: 'apiKey', value: 'abc', description: 'auth key', disabled: true }
]);
});
it('accepts the folder { req, res } shape and only emits req vars', () => {
const out = toOpenCollectionVariables({
req: [{ uid: 'u1', name: 'a', value: '1', enabled: true } as any],
res: [{ uid: 'u2', name: 'b', value: '2', enabled: true } as any]
});
expect(out).toEqual([{ name: 'a', value: '1' }]);
});
});
describe('toBrunoVariables', () => {
it('returns empty req/res for null / empty input', () => {
expect(toBrunoVariables(null)).toEqual({ req: [], res: [] });
expect(toBrunoVariables(undefined)).toEqual({ req: [], res: [] });
expect(toBrunoVariables([])).toEqual({ req: [], res: [] });
});
it('parses plain string variables into Bruno req vars', () => {
const { req } = toBrunoVariables([
{ name: 'apiKey', value: 'abc' } as any
]);
expect(req).toHaveLength(1);
const v = req![0];
expect(v).toMatchObject({
name: 'apiKey',
value: 'abc',
enabled: true,
local: false
});
expect(v.uid).toEqual(expect.any(String));
expect(v.dataType).toBeUndefined();
});
it('parses typed-value variables into typed Bruno variables', () => {
const { req } = toBrunoVariables([
{ name: 'port', value: { type: 'number', data: '3000' } } as any,
{ name: 'flag', value: { type: 'boolean', data: 'true' } } as any,
{ name: 'config', value: { type: 'object', data: '{"a":1}' } } as any
]);
expect(req![0]).toMatchObject({ name: 'port', value: 3000, dataType: 'number' });
expect(req![1]).toMatchObject({ name: 'flag', value: true, dataType: 'boolean' });
expect(req![2]).toMatchObject({ name: 'config', value: { a: 1 }, dataType: 'object' });
});
it('falls back to the raw string when typed data fails to coerce', () => {
const { req } = toBrunoVariables([
{ name: 'port', value: { type: 'number', data: 'not-a-number' } } as any,
{ name: 'flag', value: { type: 'boolean', data: 'not-a-boolean' } } as any,
{ name: 'config', value: { type: 'object', data: 'not-a-object' } } as any
]);
expect(req![0]).toMatchObject({ name: 'port', value: 'not-a-number', dataType: 'number' });
expect(typeof req![0].value).toBe('string');
expect(req![1]).toMatchObject({ name: 'flag', value: 'not-a-boolean', dataType: 'boolean' });
expect(typeof req![1].value).toBe('string');
expect(req![2]).toMatchObject({ name: 'config', value: 'not-a-object', dataType: 'object' });
expect(typeof req![2].value).toBe('string');
});
it('respects the disabled flag', () => {
const { req } = toBrunoVariables([
{ name: 'a', value: '1', disabled: true } as any
]);
expect(req![0].enabled).toBe(false);
});
it('extracts description.content when description is a structured object', () => {
const { req } = toBrunoVariables([
{ name: 'a', value: '1', description: { content: 'note' } } as any
]);
expect(req![0].description).toBe('note');
});
});

View File

@@ -2,16 +2,13 @@ import { Variable, VariableTypedValue } from '@opencollection/types/common/varia
import { FolderRequest as BrunoFolderRequest } from '@usebruno/schema-types/collection/folder';
import { Variable as BrunoVariable, Variables as BrunoVariables } from '@usebruno/schema-types/common/variables';
import { uuid, ensureString } from '../../../utils';
export const isTypedValue = (value: unknown): value is VariableTypedValue => {
return (
typeof value === 'object'
&& value !== null
&& !Array.isArray(value)
&& 'type' in value
&& 'data' in value
);
};
import {
isTypedValue,
hasTypedMetadata,
toOpenCollectionTypedValue,
fromOpenCollectionTypedValue,
serializeVariableValue
} from './datatype';
/**
* Convert Bruno pre-request variables to OpenCollection variables format.
@@ -29,12 +26,10 @@ export const toOpenCollectionVariables = (variables: BrunoFolderRequest['vars']
}
const ocVariables: Variable[] = reqVarsArray.map((v: BrunoVariable): Variable => {
const valueStr = serializeVariableValue(v.value);
const variable: Variable = {
name: v.name || '',
value:
v.datatype && v.datatype !== 'string'
? { type: v.datatype, data: ensureString(v.value) }
: v.value || ''
value: hasTypedMetadata(v) ? toOpenCollectionTypedValue(v, valueStr) : valueStr
};
if (v?.description?.trim().length) {
@@ -62,7 +57,7 @@ export const toBrunoVariables = (variables: Variable[] | null | undefined): { re
const reqVars: BrunoVariables = [];
variables.forEach((v: Variable) => {
const variable: BrunoVariable = {
const base: BrunoVariable = {
uid: uuid(),
name: ensureString(v.name),
value: '',
@@ -71,19 +66,16 @@ export const toBrunoVariables = (variables: Variable[] | null | undefined): { re
};
if (isTypedValue(v.value)) {
variable.value = ensureString(v.value.data);
if (v.value.type !== 'string' && v.value.type !== 'null') {
variable.datatype = v.value.type;
}
Object.assign(base, fromOpenCollectionTypedValue(v.value));
} else {
variable.value = ensureString(v.value);
base.value = ensureString(v.value);
}
if (v.description) {
variable.description = typeof v.description === 'string' ? v.description : (v.description as any)?.content || '';
base.description = typeof v.description === 'string' ? v.description : (v.description as any)?.content || '';
}
reqVars.push(variable);
reqVars.push(base);
});
return { req: reqVars, res: [] };

View File

@@ -0,0 +1,53 @@
import parseCollection from './parseCollection';
// Typed `request.variables` propagate through parseCollection into the
// collectionRoot's vars.req with their dataType + coerced value.
describe('parseCollection — typed request.variables', () => {
it('coerces typed values, preserves raw value on un-coercible input, and treats explicit string as the implicit default', () => {
const yml = `opencollection: "1.0.0"
info:
name: c
request:
variables:
- name: count
value:
type: number
data: "42"
- name: enabled
value:
type: boolean
data: "true"
- name: config
value:
type: object
data: '{"a":1}'
- name: greeting
value:
type: string
data: hi
- name: plain
value: hello
- name: cfg
value:
type: object
data: 'not-json'
`;
const { collectionRoot } = parseCollection(yml);
const reqVars = collectionRoot.request!.vars!.req!;
expect(reqVars).toHaveLength(6);
expect(reqVars[0]).toMatchObject({ name: 'count', value: 42, dataType: 'number' });
expect(reqVars[1]).toMatchObject({ name: 'enabled', value: true, dataType: 'boolean' });
expect(reqVars[2]).toMatchObject({ name: 'config', value: { a: 1 }, dataType: 'object' });
// Explicit `type: string` is the implicit default — no dataType materialized.
expect(reqVars[3]).toMatchObject({ name: 'greeting', value: 'hi' });
expect(reqVars[3].dataType).toBeUndefined();
expect(reqVars[4]).toMatchObject({ name: 'plain', value: 'hello' });
expect(reqVars[4].dataType).toBeUndefined();
// Un-coercible: raw value preserved for the UI mismatch warning.
expect(reqVars[5]).toMatchObject({ name: 'cfg', value: 'not-json', dataType: 'object' });
});
});

View File

@@ -0,0 +1,99 @@
import parseEnvironment, { toBrunoEnvironmentVariables } from './parseEnvironment';
describe('toBrunoEnvironmentVariables', () => {
it('returns an empty array for null / empty input', () => {
expect(toBrunoEnvironmentVariables(null)).toEqual([]);
expect(toBrunoEnvironmentVariables(undefined)).toEqual([]);
expect(toBrunoEnvironmentVariables([])).toEqual([]);
});
it('parses a plain string variable as text with secret=false', () => {
const [variable] = toBrunoEnvironmentVariables([{ name: 'apiKey', value: 'abc' } as any]);
expect(variable).toMatchObject({
name: 'apiKey',
value: 'abc',
type: 'text',
enabled: true,
secret: false
});
expect(variable.uid).toEqual(expect.any(String));
expect(variable.dataType).toBeUndefined();
});
it('parses typed values and attaches the dataType field', () => {
const vars = toBrunoEnvironmentVariables([
{ name: 'port', value: { type: 'number', data: '300' } } as any,
{ name: 'flag', value: { type: 'boolean', data: 'true' } } as any,
{ name: 'config', value: { type: 'object', data: '{"a":1}' } } as any
]);
expect(vars[0]).toMatchObject({ name: 'port', value: 300, dataType: 'number', secret: false });
expect(vars[1]).toMatchObject({ name: 'flag', value: true, dataType: 'boolean', secret: false });
expect(vars[2]).toMatchObject({ name: 'config', value: { a: 1 }, dataType: 'object', secret: false });
});
it('parses secret variables with secret=true and attaches the dataType from `type`', () => {
const vars = toBrunoEnvironmentVariables([
{ secret: true, name: 'apiKey' } as any,
{ secret: true, name: 'port', type: 'number' } as any,
{ secret: true, name: 'flag', type: 'boolean', disabled: true } as any
]);
expect(vars[0]).toMatchObject({ name: 'apiKey', secret: true, enabled: true, value: '' });
// A bare secret has no `type`, so no dataType materializes.
expect(vars[0].dataType).toBeUndefined();
expect(vars[1]).toMatchObject({ name: 'port', secret: true, enabled: true, dataType: 'number' });
expect(vars[2]).toMatchObject({ name: 'flag', secret: true, enabled: false, dataType: 'boolean' });
});
it('honors the disabled flag on non-secret variables', () => {
const [variable] = toBrunoEnvironmentVariables([
{ name: 'apiKey', value: 'abc', disabled: true } as any
]);
expect(variable.enabled).toBe(false);
});
});
describe('parseEnvironment (full yml)', () => {
it('parses an environment with mixed plain, typed and secret variables', () => {
const yml = `name: test_env
variables:
- name: env_str
value: hello
- name: env_num
value:
type: number
data: "300"
- name: env_bool
value:
type: boolean
data: "true"
- name: env_obj
value:
type: object
data: '{"scope":"env"}'
- secret: true
name: env_secret
`;
const env = parseEnvironment(yml);
expect(env.name).toBe('test_env');
expect(env.variables).toHaveLength(5);
expect(env.variables[0]).toMatchObject({ name: 'env_str', value: 'hello', secret: false });
expect(env.variables[1]).toMatchObject({ name: 'env_num', value: 300, dataType: 'number', secret: false });
expect(env.variables[2]).toMatchObject({ name: 'env_bool', value: true, dataType: 'boolean', secret: false });
expect(env.variables[3]).toMatchObject({ name: 'env_obj', value: { scope: 'env' }, dataType: 'object', secret: false });
expect(env.variables[4]).toMatchObject({ name: 'env_secret', secret: true, value: '' });
expect(env.variables[4].dataType).toBeUndefined();
});
it('defaults environment name when missing', () => {
const env = parseEnvironment('variables: []');
expect(env.name).toBe('Untitled Environment');
expect(env.variables).toEqual([]);
});
});

View File

@@ -3,13 +3,13 @@ import type { Environment } from '@opencollection/types/config/environments';
import type { Variable, SecretVariable } from '@opencollection/types/common/variables';
import { parseYml } from './utils';
import { uuid, ensureString } from '../../utils';
import { isTypedValue } from './common/variables';
import { isTypedValue, fromOpenCollectionTypedValue } from './common/datatype';
const isSecretVariable = (v: Variable | SecretVariable): v is SecretVariable => {
return 'secret' in v && v.secret === true;
};
const toBrunoEnvironmentVariables = (variables: (Variable | SecretVariable)[] | null | undefined): BrunoEnvironmentVariable[] => {
export const toBrunoEnvironmentVariables = (variables: (Variable | SecretVariable)[] | null | undefined): BrunoEnvironmentVariable[] => {
if (!variables?.length) {
return [];
}
@@ -26,11 +26,12 @@ const toBrunoEnvironmentVariables = (variables: (Variable | SecretVariable)[] |
};
if (v.type && v.type !== 'string' && v.type !== 'null') {
variable.datatype = v.type;
variable.dataType = v.type;
}
return variable;
}
const variable: BrunoEnvironmentVariable = {
uid: uuid(),
name: ensureString(v.name),
@@ -41,10 +42,7 @@ const toBrunoEnvironmentVariables = (variables: (Variable | SecretVariable)[] |
};
if (isTypedValue(v.value)) {
variable.value = ensureString(v.value.data);
if (v.value.type !== 'string' && v.value.type !== 'null') {
variable.datatype = v.value.type;
}
Object.assign(variable, fromOpenCollectionTypedValue(v.value));
} else {
variable.value = ensureString(v.value);
}

View File

@@ -0,0 +1,52 @@
import parseFolder from './parseFolder';
// Typed `request.variables` propagate through parseFolder into the folder's
// vars.req with their dataType + coerced value.
describe('parseFolder — typed request.variables', () => {
it('coerces typed values, preserves raw value on un-coercible input, and treats explicit string as the implicit default', () => {
const yml = `info:
name: my-folder
request:
variables:
- name: count
value:
type: number
data: "42"
- name: enabled
value:
type: boolean
data: "true"
- name: config
value:
type: object
data: '{"a":1}'
- name: greeting
value:
type: string
data: hi
- name: plain
value: hello
- name: flag
value:
type: boolean
data: 'maybe'
`;
const folder = parseFolder(yml);
const reqVars = folder.request!.vars!.req!;
expect(reqVars).toHaveLength(6);
expect(reqVars[0]).toMatchObject({ name: 'count', value: 42, dataType: 'number' });
expect(reqVars[1]).toMatchObject({ name: 'enabled', value: true, dataType: 'boolean' });
expect(reqVars[2]).toMatchObject({ name: 'config', value: { a: 1 }, dataType: 'object' });
// Explicit `type: string` is the implicit default — no dataType materialized.
expect(reqVars[3]).toMatchObject({ name: 'greeting', value: 'hi' });
expect(reqVars[3].dataType).toBeUndefined();
expect(reqVars[4]).toMatchObject({ name: 'plain', value: 'hello' });
expect(reqVars[4].dataType).toBeUndefined();
// Un-coercible: raw value preserved for the UI mismatch warning.
expect(reqVars[5]).toMatchObject({ name: 'flag', value: 'maybe', dataType: 'boolean' });
});
});

View File

@@ -0,0 +1,147 @@
import parseItem from './parseItem';
// Typed `runtime.variables` propagate through parseItem with their dataType
// + coerced value. Helper-level coverage lives in the variables/datatype specs.
describe('parseItem — typed runtime.variables', () => {
it('coerces typed values, preserves raw value on un-coercible input, and treats explicit string as the implicit default', () => {
const yml = `info:
name: r
type: http
http:
url: https://example.com
method: GET
runtime:
variables:
- name: count
value:
type: number
data: "42"
- name: enabled
value:
type: boolean
data: "true"
- name: config
value:
type: object
data: '{"a":1}'
- name: greeting
value:
type: string
data: hi
- name: plain
value: hello
- name: bad
value:
type: number
data: 'not-a-number'
`;
const item = parseItem(yml);
const reqVars = item.request!.vars!.req!;
expect(reqVars).toHaveLength(6);
expect(reqVars[0]).toMatchObject({ name: 'count', value: 42, dataType: 'number' });
expect(reqVars[1]).toMatchObject({ name: 'enabled', value: true, dataType: 'boolean' });
expect(reqVars[2]).toMatchObject({ name: 'config', value: { a: 1 }, dataType: 'object' });
// Explicit `type: string` is the implicit default — no dataType materialized.
expect(reqVars[3]).toMatchObject({ name: 'greeting', value: 'hi' });
expect(reqVars[3].dataType).toBeUndefined();
expect(reqVars[4]).toMatchObject({ name: 'plain', value: 'hello' });
expect(reqVars[4].dataType).toBeUndefined();
// Un-coercible: raw value preserved for the UI mismatch warning.
expect(reqVars[5]).toMatchObject({ name: 'bad', value: 'not-a-number', dataType: 'number' });
});
it('normalizes raw YAML scalars in `data` to a string before coercing', () => {
// YAML parses unquoted scalars per their natural JS type. The `String(... ?? '')`
// cast in fromOpenCollectionTypedValue keeps coercion consistent.
const yml = `info:
name: r
type: http
http:
url: https://example.com
method: GET
runtime:
variables:
- name: stringy
value:
type: string
data: 42
- name: numeric
value:
type: number
data: 42
- name: nullish
value:
type: number
data: null
`;
const item = parseItem(yml);
const reqVars = item.request!.vars!.req!;
// type=string: raw YAML number → string; no dataType field.
expect(reqVars[0]).toMatchObject({ name: 'stringy', value: '42' });
expect(reqVars[0].dataType).toBeUndefined();
// type=number: '42' → 42.
expect(reqVars[1]).toMatchObject({ name: 'numeric', value: 42, dataType: 'number' });
// data: null → '' (the `?? ''` arm); coerce bails on empty → raw ''.
expect(reqVars[2]).toMatchObject({ name: 'nullish', value: '', dataType: 'number' });
});
it('propagates typed variables for graphql/grpc/websocket requests too', () => {
const graphqlYml = `info:
name: g
type: graphql
graphql:
url: https://example.com/graphql
method: POST
runtime:
variables:
- name: count
value: { type: number, data: '7' }
`;
expect(parseItem(graphqlYml).request!.vars!.req![0]).toMatchObject({
name: 'count', value: 7, dataType: 'number'
});
const grpcYml = `info:
name: gr
type: grpc
grpc:
url: localhost:50051
runtime:
variables:
- name: flag
value: { type: boolean, data: 'false' }
`;
expect(parseItem(grpcYml).request!.vars!.req![0]).toMatchObject({
name: 'flag', value: false, dataType: 'boolean'
});
const wsYml = `info:
name: ws
type: websocket
websocket:
url: wss://example.com/socket
runtime:
variables:
- name: payload
value: { type: object, data: '{"k":1}' }
`;
expect(parseItem(wsYml).request!.vars!.req![0]).toMatchObject({
name: 'payload', value: { k: 1 }, dataType: 'object'
});
});
});

View File

@@ -0,0 +1,49 @@
import stringifyCollection from './stringifyCollection';
import parseCollection from './parseCollection';
// Typed collection-root vars serialize to OC's `{ type, data }` struct.
// `dataType: 'string'` is the implicit default and stays a raw string.
describe('stringifyCollection — typed request.variables', () => {
it('round-trips typed values and omits a typed struct for the implicit string default', () => {
const collectionRoot = {
meta: null,
request: {
headers: [],
auth: { mode: 'none' },
script: { req: null, res: null },
tests: null,
vars: {
req: [
{ uid: 'v1', name: 'count', value: 42, enabled: true, dataType: 'number' },
{ uid: 'v2', name: 'enabled', value: true, enabled: true, dataType: 'boolean' },
{ uid: 'v3', name: 'config', value: { a: 1 }, enabled: true, dataType: 'object' },
{ uid: 'v4', name: 'greeting', value: 'hi', enabled: true, dataType: 'string' },
{ uid: 'v5', name: 'plain', value: 'hello', enabled: true }
],
res: []
}
},
docs: null
} as any;
const brunoConfig = { name: 'c' };
const yml = stringifyCollection(collectionRoot, brunoConfig);
// `type: string` is never written out.
expect(yml).not.toMatch(/type:\s*string/);
const { collectionRoot: reparsed } = parseCollection(yml);
const reqVars = reparsed.request!.vars!.req!;
expect(reqVars).toHaveLength(5);
expect(reqVars[0]).toMatchObject({ name: 'count', value: 42, dataType: 'number' });
expect(reqVars[1]).toMatchObject({ name: 'enabled', value: true, dataType: 'boolean' });
expect(reqVars[2]).toMatchObject({ name: 'config', value: { a: 1 }, dataType: 'object' });
expect(reqVars[3]).toMatchObject({ name: 'greeting', value: 'hi' });
expect(reqVars[3].dataType).toBeUndefined();
expect(reqVars[4]).toMatchObject({ name: 'plain', value: 'hello' });
expect(reqVars[4].dataType).toBeUndefined();
});
});

View File

@@ -0,0 +1,91 @@
import stringifyEnvironment, { toOpenCollectionEnvironmentVariables } from './stringifyEnvironment';
import parseEnvironment from './parseEnvironment';
describe('toOpenCollectionEnvironmentVariables', () => {
it('returns undefined for null / empty input', () => {
expect(toOpenCollectionEnvironmentVariables([])).toBeUndefined();
});
it('serializes plain string variables as raw strings', () => {
const out = toOpenCollectionEnvironmentVariables([
{ uid: 'u1', name: 'apiKey', value: 'abc', type: 'text', enabled: true, secret: false } as any
]);
expect(out).toEqual([{ name: 'apiKey', value: 'abc' }]);
});
it('serializes typed variables as a {type, data} struct', () => {
const out = toOpenCollectionEnvironmentVariables([
{ uid: 'u1', name: 'port', value: 300, type: 'text', enabled: true, secret: false, dataType: 'number' } as any,
{ uid: 'u2', name: 'flag', value: true, type: 'text', enabled: true, secret: false, dataType: 'boolean' } as any,
{ uid: 'u3', name: 'config', value: { a: 1 }, type: 'text', enabled: true, secret: false, dataType: 'object' } as any
]);
expect(out).toEqual([
{ name: 'port', value: { type: 'number', data: '300' } },
{ name: 'flag', value: { type: 'boolean', data: 'true' } },
{ name: 'config', value: { type: 'object', data: '{\n "a": 1\n}' } }
]);
});
it('serializes secret variables without a value but preserves the dataType as `type`', () => {
const out = toOpenCollectionEnvironmentVariables([
{ uid: 'u1', name: 'apiKey', value: '', type: 'text', enabled: true, secret: true } as any,
{ uid: 'u2', name: 'flag', value: '', type: 'text', enabled: false, secret: true, dataType: 'number' } as any
]);
expect(out).toEqual([
{ secret: true, name: 'apiKey' },
{ secret: true, name: 'flag', type: 'number', disabled: true }
]);
});
it('marks disabled non-secret variables', () => {
const out = toOpenCollectionEnvironmentVariables([
{ uid: 'u1', name: 'apiKey', value: 'abc', type: 'text', enabled: false, secret: false } as any
]);
expect(out).toEqual([{ name: 'apiKey', value: 'abc', disabled: true }]);
});
});
describe('stringifyEnvironment', () => {
it('round-trips through parseEnvironment for typed and secret variables', () => {
const env = {
uid: 'env-uid',
name: 'test_env',
variables: [
{ uid: 'u1', name: 'env_str', value: 'hello', type: 'text', enabled: true, secret: false },
{ uid: 'u2', name: 'env_num', value: 300, type: 'text', enabled: true, secret: false, dataType: 'number' as const },
{ uid: 'u3', name: 'env_bool', value: true, type: 'text', enabled: true, secret: false, dataType: 'boolean' as const },
{ uid: 'u4', name: 'env_obj', value: { scope: 'env' }, type: 'text', enabled: true, secret: false, dataType: 'object' as const },
{ uid: 'u5', name: 'env_secret', value: '', type: 'text', enabled: true, secret: true },
{ uid: 'u6', name: 'env_secret_num', value: '', type: 'text', enabled: true, secret: true, dataType: 'number' as const }
]
} as any;
const yml = stringifyEnvironment(env);
const reparsed = parseEnvironment(yml);
expect(reparsed.name).toBe('test_env');
expect(reparsed.variables[0]).toMatchObject({ name: 'env_str', value: 'hello', secret: false });
expect(reparsed.variables[1]).toMatchObject({ name: 'env_num', value: 300, dataType: 'number', secret: false });
expect(reparsed.variables[2]).toMatchObject({ name: 'env_bool', value: true, dataType: 'boolean', secret: false });
expect(reparsed.variables[3]).toMatchObject({ name: 'env_obj', value: { scope: 'env' }, dataType: 'object', secret: false });
expect(reparsed.variables[4]).toMatchObject({ name: 'env_secret', secret: true, value: '' });
expect(reparsed.variables[4].dataType).toBeUndefined();
expect(reparsed.variables[5]).toMatchObject({ name: 'env_secret_num', secret: true, value: '', dataType: 'number' });
});
it('preserves the color field when present', () => {
const env = {
uid: 'env-uid',
name: 'colorful',
color: '#ff0000',
variables: []
} as any;
const reparsed = parseEnvironment(stringifyEnvironment(env));
expect(reparsed.color).toBe('#ff0000');
});
});

View File

@@ -2,27 +2,22 @@ import type { Environment as BrunoEnvironment, EnvironmentVariable as BrunoEnvir
import type { Environment } from '@opencollection/types/config/environments';
import type { Variable, SecretVariable } from '@opencollection/types/common/variables';
import { stringifyYml } from './utils';
import { ensureString } from '../../utils';
import { hasTypedMetadata, toOpenCollectionTypedValue, serializeVariableValue } from './common/datatype';
const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironmentVariable[]): (Variable | SecretVariable)[] | undefined => {
export const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironmentVariable[]): (Variable | SecretVariable)[] | undefined => {
if (!variables?.length) {
return undefined;
}
const ocVariables: (Variable | SecretVariable)[] = variables
.filter((v: BrunoEnvironmentVariable) => {
// todo: currently neither bru lang nor bruno app supports non-string values
// update this when bruno app supports non-string values
return typeof v.value === 'string';
})
.map((v: BrunoEnvironmentVariable): Variable | SecretVariable => {
if (v.secret === true) {
const secretVar: SecretVariable = {
secret: true,
name: v.name || ''
};
if (v.datatype && v.datatype !== 'string') {
secretVar.type = v.datatype;
if (hasTypedMetadata(v)) {
secretVar.type = v.dataType;
}
if (v.enabled === false) {
secretVar.disabled = true;
@@ -30,12 +25,11 @@ const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironmentVariabl
return secretVar;
}
const valueStr = serializeVariableValue(v.value);
const variable: Variable = {
name: v.name || '',
value:
v.datatype && v.datatype !== 'string'
? { type: v.datatype, data: ensureString(v.value) }
: ensureString(v.value)
value: hasTypedMetadata(v) ? toOpenCollectionTypedValue(v, valueStr) : valueStr
};
if (v.enabled === false) {

View File

@@ -0,0 +1,47 @@
import stringifyFolder from './stringifyFolder';
import parseFolder from './parseFolder';
// Typed folder vars serialize to OC's `{ type, data }` struct.
// `dataType: 'string'` is the implicit default and stays a raw string.
describe('stringifyFolder — typed request.variables', () => {
it('round-trips typed values and omits a typed struct for the implicit string default', () => {
const folderRoot = {
meta: { name: 'my-folder', seq: 1 },
request: {
headers: [],
auth: { mode: 'none' },
script: { req: null, res: null },
tests: null,
vars: {
req: [
{ uid: 'v1', name: 'count', value: 42, enabled: true, dataType: 'number' },
{ uid: 'v2', name: 'enabled', value: true, enabled: true, dataType: 'boolean' },
{ uid: 'v3', name: 'config', value: { a: 1 }, enabled: true, dataType: 'object' },
{ uid: 'v4', name: 'greeting', value: 'hi', enabled: true, dataType: 'string' },
{ uid: 'v5', name: 'plain', value: 'hello', enabled: true }
],
res: []
}
},
docs: null
} as any;
const yml = stringifyFolder(folderRoot);
// `type: string` is never written out.
expect(yml).not.toMatch(/type:\s*string/);
const reparsed = parseFolder(yml);
const reqVars = reparsed.request!.vars!.req!;
expect(reqVars).toHaveLength(5);
expect(reqVars[0]).toMatchObject({ name: 'count', value: 42, dataType: 'number' });
expect(reqVars[1]).toMatchObject({ name: 'enabled', value: true, dataType: 'boolean' });
expect(reqVars[2]).toMatchObject({ name: 'config', value: { a: 1 }, dataType: 'object' });
expect(reqVars[3]).toMatchObject({ name: 'greeting', value: 'hi' });
expect(reqVars[3].dataType).toBeUndefined();
expect(reqVars[4]).toMatchObject({ name: 'plain', value: 'hello' });
expect(reqVars[4].dataType).toBeUndefined();
});
});

View File

@@ -0,0 +1,53 @@
import stringifyItem from './stringifyItem';
import parseItem from './parseItem';
// Typed bruno vars on `request.vars.req` serialize to OC's `{ type, data }`
// struct. `dataType: 'string'` is the implicit default and stays a raw string.
describe('stringifyItem — typed runtime.variables', () => {
it('round-trips typed values and omits a typed struct for the implicit string default', () => {
const item = {
uid: 'i1',
type: 'http-request',
name: 'r',
seq: 1,
request: {
url: 'https://example.com',
method: 'GET',
headers: [],
params: [],
body: { mode: 'none' },
auth: { mode: 'none' },
script: { req: null, res: null },
tests: null,
vars: {
req: [
{ uid: 'v1', name: 'count', value: 42, enabled: true, dataType: 'number' },
{ uid: 'v2', name: 'enabled', value: true, enabled: true, dataType: 'boolean' },
{ uid: 'v3', name: 'config', value: { a: 1 }, enabled: true, dataType: 'object' },
{ uid: 'v4', name: 'greeting', value: 'hi', enabled: true, dataType: 'string' },
{ uid: 'v5', name: 'plain', value: 'hello', enabled: true }
],
res: []
}
}
} as any;
const yml = stringifyItem(item);
// `type: string` is never written out.
expect(yml).not.toMatch(/type:\s*string/);
const reparsed = parseItem(yml);
const reqVars = reparsed.request!.vars!.req!;
expect(reqVars).toHaveLength(5);
expect(reqVars[0]).toMatchObject({ name: 'count', value: 42, dataType: 'number' });
expect(reqVars[1]).toMatchObject({ name: 'enabled', value: true, dataType: 'boolean' });
expect(reqVars[2]).toMatchObject({ name: 'config', value: { a: 1 }, dataType: 'object' });
expect(reqVars[3]).toMatchObject({ name: 'greeting', value: 'hi' });
expect(reqVars[3].dataType).toBeUndefined();
expect(reqVars[4]).toMatchObject({ name: 'plain', value: 'hello' });
expect(reqVars[4].dataType).toBeUndefined();
});
});

View File

@@ -48,46 +48,46 @@ variables:
const byName = (env) => Object.fromEntries(env.variables.map((v) => [v.name, v]));
describe('yml parseEnvironment - typed values', () => {
it('keeps the value as a string and preserves the type via datatype', () => {
it('coerces the value to its native JS type and preserves the type via dataType', () => {
const variables = byName(parseEnvironment(ENV_YML));
expect(variables.env_num).toMatchObject({ value: '300', type: 'text', datatype: 'number' });
expect(typeof variables.env_num.value).toBe('string');
expect(variables.env_num).toMatchObject({ value: 300, type: 'text', dataType: 'number' });
expect(typeof variables.env_num.value).toBe('number');
expect(variables.env_bool).toMatchObject({ value: 'true', datatype: 'boolean' });
expect(variables.falsy_num).toMatchObject({ value: '0', datatype: 'number' });
expect(variables.falsy_bool).toMatchObject({ value: 'false', datatype: 'boolean' });
expect(variables.env_bool).toMatchObject({ value: true, dataType: 'boolean' });
expect(variables.falsy_num).toMatchObject({ value: 0, dataType: 'number' });
expect(variables.falsy_bool).toMatchObject({ value: false, dataType: 'boolean' });
expect(variables.env_obj.datatype).toBe('object');
expect(typeof variables.env_obj.value).toBe('string');
expect(variables.env_obj.value).toContain('"scope": "env"');
expect(variables.env_obj.dataType).toBe('object');
expect(typeof variables.env_obj.value).toBe('object');
expect(variables.env_obj.value).toMatchObject({ scope: 'env' });
expect(variables.env_array_obj).toMatchObject({ value: '[1,2,3,4]', datatype: 'object' });
expect(variables.env_array_obj).toMatchObject({ value: [1, 2, 3, 4], dataType: 'object' });
});
it('does not set datatype for plain string values', () => {
it('does not set dataType for plain string values', () => {
const variables = byName(parseEnvironment(ENV_YML));
expect(variables.env_str).toMatchObject({ value: 'env_string', type: 'text', secret: false });
expect(variables.env_str).not.toHaveProperty('datatype');
expect(variables.env_str).not.toHaveProperty('dataType');
});
it('parses secret variables with no value or datatype', () => {
it('parses secret variables with no value or dataType', () => {
const variables = byName(parseEnvironment(ENV_YML));
expect(variables.env_secret_str).toMatchObject({ name: 'env_secret_str', value: '', secret: true });
expect(variables.env_secret_str).not.toHaveProperty('datatype');
expect(variables.env_secret_str).not.toHaveProperty('dataType');
});
it('parses secret variables with a type, keeping the value empty and the type in datatype', () => {
it('parses secret variables with a type, keeping the value empty and the type in dataType', () => {
const variables = byName(parseEnvironment(ENV_YML));
expect(variables.env_secret_num).toMatchObject({ value: '', secret: true, datatype: 'number' });
expect(variables.env_secret_bool).toMatchObject({ value: '', secret: true, datatype: 'boolean' });
expect(variables.env_secret_obj).toMatchObject({ value: '', secret: true, datatype: 'object' });
expect(variables.env_secret_num).toMatchObject({ value: '', secret: true, dataType: 'number' });
expect(variables.env_secret_bool).toMatchObject({ value: '', secret: true, dataType: 'boolean' });
expect(variables.env_secret_obj).toMatchObject({ value: '', secret: true, dataType: 'object' });
});
it('serializes secret variable datatype back to a type field, omitting it for plain secrets', () => {
it('serializes secret variable dataType back to a type field, omitting it for plain secrets', () => {
const yml = stringifyEnvironment(parseEnvironment(ENV_YML));
expect(yml).toContain('- secret: true\n name: env_secret_num\n type: number');
@@ -97,19 +97,19 @@ describe('yml parseEnvironment - typed values', () => {
expect(yml).not.toContain('name: env_secret_str\n type:');
});
it('serializes datatype back to an OpenCollection { type, data } value', () => {
it('serializes dataType back to an OpenCollection { type, data } value', () => {
const yml = stringifyEnvironment(parseEnvironment(ENV_YML));
expect(yml).toContain('type: number');
expect(yml).toContain('data: "300"');
expect(yml).toContain('type: boolean');
expect(yml).toContain('type: object');
// plain strings stay plain, never wrapped as a string datatype
// plain strings stay plain, never wrapped as a string dataType
expect(yml).toContain('value: env_string');
expect(yml).not.toContain('type: string');
});
it('round-trips value and datatype through parse -> stringify -> parse', () => {
it('round-trips value and dataType through parse -> stringify -> parse', () => {
const env = parseEnvironment(ENV_YML);
const reparsed = parseEnvironment(stringifyEnvironment(env));

View File

@@ -1,7 +1,7 @@
import { toBrunoVariables, toOpenCollectionVariables } from '../common/variables';
describe('yml variables - typed values (collection / folder / request vars)', () => {
it('reads a typed value keeping the value as a string and the type in datatype', () => {
it('reads a typed value coercing it to its native JS type and records the dataType', () => {
const ocVariables = [
{ name: 'var_str', value: 'plain' },
{ name: 'var_num', value: { type: 'number', data: '300' } },
@@ -12,16 +12,16 @@ describe('yml variables - typed values (collection / folder / request vars)', ()
const { req } = toBrunoVariables(ocVariables);
expect(req.find((v) => v.name === 'var_str')).toMatchObject({ value: 'plain' });
expect(req.find((v) => v.name === 'var_str')).not.toHaveProperty('datatype');
expect(req.find((v) => v.name === 'var_num')).toMatchObject({ value: '300', datatype: 'number' });
expect(req.find((v) => v.name === 'var_bool')).toMatchObject({ value: 'false', datatype: 'boolean' });
expect(req.find((v) => v.name === 'var_obj')).toMatchObject({ value: '{"scope":"folder"}', datatype: 'object' });
expect(req.find((v) => v.name === 'var_str')).not.toHaveProperty('dataType');
expect(req.find((v) => v.name === 'var_num')).toMatchObject({ value: 300, dataType: 'number' });
expect(req.find((v) => v.name === 'var_bool')).toMatchObject({ value: false, dataType: 'boolean' });
expect(req.find((v) => v.name === 'var_obj')).toMatchObject({ value: { scope: 'folder' }, dataType: 'object' });
});
it('writes datatype back as a { type, data } value, leaving plain strings untouched', () => {
it('writes dataType back as a { type, data } value, leaving plain strings untouched', () => {
const brunoVariables = [
{ uid: 'u1', name: 'var_str', value: 'plain', enabled: true, local: false },
{ uid: 'u2', name: 'var_num', value: '300', datatype: 'number', enabled: true, local: false }
{ uid: 'u2', name: 'var_num', value: '300', dataType: 'number', enabled: true, local: false }
];
const ocVariables = toOpenCollectionVariables(brunoVariables);

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "src/**/*.spec.ts", "src/**/*.test.ts", "src/**/tests/**"]
}

View File

@@ -15,13 +15,16 @@
"declarationMap": true,
"allowJs": true,
"checkJs": false,
"types": ["node"],
"types": ["node", "jest"],
"lib": ["ES2020"],
"typeRoots": ["../../node_modules/@types", "./node_modules/@types", "./src/types"],
"baseUrl": "../..",
"paths": {
"@usebruno/schema-types": ["packages/bruno-schema-types/dist/index.d.ts"],
"@usebruno/schema-types/*": ["packages/bruno-schema-types/dist/*"],
"@usebruno/common": ["packages/bruno-common/dist/index.d.ts"],
"@usebruno/common/utils": ["packages/bruno-common/dist/utils/index.d.ts"],
"@usebruno/common/*": ["packages/bruno-common/dist/*"],
"@opencollection/types": ["node_modules/@opencollection/types/dist/opencollection.d.ts"],
"@opencollection/types/*": ["node_modules/@opencollection/types/dist/*"]
}

View File

@@ -203,11 +203,6 @@ class Bru {
);
}
// When persist is true, only string values are allowed
if (options?.persist && typeof value !== 'string') {
throw new Error(`Persistent environment variables must be strings. Received ${typeof value} for key "${key}".`);
}
this.envVariables[key] = value;
if (options?.persist) {

View File

@@ -181,36 +181,25 @@ describe('runtime', () => {
});
describe('persistent environment variables validation', () => {
it('should throw error when trying to persist non-string values', async () => {
const script = `bru.setEnvVar('number', 42, {persist: true});`;
it('should persist non-string values without throwing', async () => {
const script = `
bru.setEnvVar('number', 42, {persist: true});
bru.setEnvVar('isActive', true, {persist: true});
bru.setEnvVar('config', {port: 3000}, {persist: true});
bru.setEnvVar('items', ['item1', 'item2'], {persist: true});
`;
const runtime = new ScriptRuntime({ runtime: 'nodevm' });
await expect(runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env))
.rejects.toThrow('Persistent environment variables must be strings. Received number for key "number".');
});
const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env);
it('should throw error when trying to persist boolean values', async () => {
const script = `bru.setEnvVar('isActive', true, {persist: true});`;
const runtime = new ScriptRuntime({ runtime: 'nodevm' });
await expect(runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env))
.rejects.toThrow('Persistent environment variables must be strings. Received boolean for key "isActive".');
});
it('should throw error when trying to persist object values', async () => {
const script = `bru.setEnvVar('config', {port: 3000}, {persist: true});`;
const runtime = new ScriptRuntime({ runtime: 'nodevm' });
await expect(runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env))
.rejects.toThrow('Persistent environment variables must be strings. Received object for key "config".');
});
it('should throw error when trying to persist array values', async () => {
const script = `bru.setEnvVar('items', ['item1', 'item2'], {persist: true});`;
const runtime = new ScriptRuntime({ runtime: 'nodevm' });
await expect(runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env))
.rejects.toThrow('Persistent environment variables must be strings. Received object for key "items".');
expect(result.envVariables.number).toBe(42);
expect(result.persistentEnvVariables.number).toBe(42);
expect(result.envVariables.isActive).toBe(true);
expect(result.persistentEnvVariables.isActive).toBe(true);
expect(result.envVariables.config).toEqual({ port: 3000 });
expect(result.persistentEnvVariables.config).toEqual({ port: 3000 });
expect(result.envVariables.items).toEqual(['item1', 'item2']);
expect(result.persistentEnvVariables.items).toEqual(['item1', 'item2']);
});
it('should allow string values when persist is true', async () => {

View File

@@ -1,4 +1,5 @@
const Bru = require('../src/bru');
const { valueToString } = require('@usebruno/common/utils');
describe('Bru.setEnvVar', () => {
const makeBru = () =>
@@ -32,11 +33,56 @@ describe('Bru.setEnvVar', () => {
expect(bru.persistentEnvVariables.no_options).toBeUndefined();
});
test('throws when persist=true but value is not a string', () => {
const bru = makeBru();
expect(() => bru.setEnvVar('persist_me', 123, { persist: true })).toThrow(
/Persistent environment variables must be strings/
);
describe('persist=true with non-string values', () => {
test('stores numbers as-is without throwing', () => {
const bru = makeBru();
expect(() => bru.setEnvVar('n', 123, { persist: true })).not.toThrow();
expect(bru.envVariables.n).toBe(123);
expect(bru.persistentEnvVariables.n).toBe(123);
});
test('stores booleans as-is without throwing', () => {
const bru = makeBru();
expect(() => bru.setEnvVar('b', true, { persist: true })).not.toThrow();
expect(bru.persistentEnvVariables.b).toBe(true);
});
test('stores plain objects and arrays by reference without throwing', () => {
const bru = makeBru();
const obj = { a: 1 };
const arr = [1, 2, 3];
bru.setEnvVar('o', obj, { persist: true });
bru.setEnvVar('a', arr, { persist: true });
expect(bru.persistentEnvVariables.o).toBe(obj);
expect(bru.persistentEnvVariables.a).toBe(arr);
});
test('stores functions and symbols without throwing — but they round-trip to "" via valueToString', () => {
const bru = makeBru();
const fn = () => 42;
const sym = Symbol('s');
bru.setEnvVar('fn', fn, { persist: true });
bru.setEnvVar('sym', sym, { persist: true });
// Raw values land in persistentEnvVariables...
expect(bru.persistentEnvVariables.fn).toBe(fn);
expect(bru.persistentEnvVariables.sym).toBe(sym);
// ...but the serializer used by mergeAndPersistEnvironment produces ''
// for both, so the value is silently lost on the next save round-trip.
expect(valueToString(fn)).toBe('');
expect(valueToString(sym)).toBe('');
});
test('stores circular objects without throwing — but they round-trip to "" via valueToString', () => {
const bru = makeBru();
const circular = { a: 1 };
circular.self = circular;
bru.setEnvVar('c', circular, { persist: true });
expect(bru.persistentEnvVariables.c).toBe(circular);
// JSON.stringify throws on circulars; valueToString swallows that and returns ''.
expect(valueToString(circular)).toBe('');
});
});
test('changing existing key to non-persistent removes prior persisted entry', () => {

View File

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

View File

@@ -1,6 +1,6 @@
const ohm = require('ohm-js');
const _ = require('lodash');
const { safeParseJson, outdentString } = require('./utils');
const { safeParseJson, outdentString, extractTypedAnnotations } = require('./utils');
const parseExample = require('./example/bruToJson');
// this is done to avoid breaking existing pairlist mapping so
@@ -182,7 +182,7 @@ const grammar = ohm.grammar(`Bru {
docs = "docs" st* "{" nl* textblock tagend
}`);
const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => {
const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true, extractTypes = false) => {
if (!pairList.length) {
return [];
}
@@ -194,6 +194,7 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => {
if (!parseEnabled) {
const result = { name, value };
if (rawAnnotations && rawAnnotations.length) result.annotations = rawAnnotations;
if (extractTypes) extractTypedAnnotations(rawAnnotations, result);
return result;
}
@@ -205,6 +206,7 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => {
const result = { name, value, enabled };
if (rawAnnotations && rawAnnotations.length) result.annotations = rawAnnotations;
if (extractTypes) extractTypedAnnotations(rawAnnotations, result);
return result;
});
};
@@ -1072,7 +1074,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
};
},
varsreq(_1, dictionary) {
const vars = mapPairListToKeyValPairs(dictionary.ast);
const vars = mapPairListToKeyValPairs(dictionary.ast, true, true);
_.each(vars, (v) => {
let name = v.name;
if (name && name.length && name.charAt(0) === '@') {
@@ -1090,7 +1092,10 @@ const sem = grammar.createSemantics().addAttribute('ast', {
};
},
varsres(_1, dictionary) {
const vars = mapPairListToKeyValPairs(dictionary.ast);
// Post-response vars carry a JSON-query expression in `value`, not a literal,
// so dataType annotations have no runtime meaning — extract them as raw
// annotations only (preserved on round-trip) without populating `dataType`.
const vars = mapPairListToKeyValPairs(dictionary.ast, true, false);
_.each(vars, (v) => {
let name = v.name;
if (name && name.length && name.charAt(0) === '@') {

View File

@@ -1,6 +1,6 @@
const ohm = require('ohm-js');
const _ = require('lodash');
const { safeParseJson, outdentString } = require('./utils');
const { safeParseJson, outdentString, extractTypedAnnotations } = require('./utils');
// this is done to avoid breaking existing pairlist mapping so
// the key is hidden and not added into the json automatically
@@ -101,7 +101,7 @@ const grammar = ohm.grammar(`Bru {
docs = "docs" st* "{" nl* textblock tagend
}`);
const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => {
const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true, extractTypes = false) => {
if (!pairList.length) {
return [];
}
@@ -113,6 +113,7 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => {
if (!parseEnabled) {
const result = { name, value };
if (rawAnnotations && rawAnnotations.length) result.annotations = rawAnnotations;
if (extractTypes) extractTypedAnnotations(rawAnnotations, result);
return result;
}
@@ -126,6 +127,7 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => {
if (rawAnnotations && rawAnnotations.length) {
result.annotations = rawAnnotations;
}
if (extractTypes) extractTypedAnnotations(rawAnnotations, result);
return result;
});
};
@@ -605,7 +607,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
};
},
varsreq(_1, dictionary) {
const vars = mapPairListToKeyValPairs(dictionary.ast);
const vars = mapPairListToKeyValPairs(dictionary.ast, true, true);
_.each(vars, (v) => {
let name = v.name;
if (name && name.length && name.charAt(0) === '@') {
@@ -623,7 +625,10 @@ const sem = grammar.createSemantics().addAttribute('ast', {
};
},
varsres(_1, dictionary) {
const vars = mapPairListToKeyValPairs(dictionary.ast);
// Post-response vars carry a JSON-query expression in `value`, not a literal,
// so dataType annotations have no runtime meaning — extract them as raw
// annotations only (preserved on round-trip) without populating `dataType`.
const vars = mapPairListToKeyValPairs(dictionary.ast, true, false);
_.each(vars, (v) => {
let name = v.name;
if (name && name.length && name.charAt(0) === '@') {

View File

@@ -1,5 +1,6 @@
const ohm = require('ohm-js');
const _ = require('lodash');
const { extractTypedAnnotations } = require('./utils');
// this is done to avoid breaking existing pairlist mapping so
// the key is hidden and not added into the json automatically
@@ -89,6 +90,9 @@ const mapPairListToKeyValPairs = (pairList = []) => {
if (rawAnnotations && rawAnnotations.length) {
result.annotations = rawAnnotations;
}
extractTypedAnnotations(rawAnnotations, result);
return result;
});
};
@@ -112,6 +116,9 @@ const mapArrayListToKeyValPairs = (arrayList = []) => {
if (item.annotations && item.annotations.length) {
result.annotations = item.annotations;
}
extractTypedAnnotations(item.annotations, result);
return result;
});
};

View File

@@ -1,6 +1,6 @@
const _ = require('lodash');
const { indentString, getValueString, getKeyString, getValueUrl, serializeAnnotations } = require('./utils');
const { indentString, getValueString, getKeyString, getValueUrl, serializeAnnotations, serializeVar } = require('./utils');
const jsonToExampleBru = require('./example/jsonToBru');
const enabled = (items = [], key = 'enabled') => items.filter((item) => item[key]);
@@ -667,19 +667,19 @@ ${indentString(body.sparql)}
bru += `vars:pre-request {`;
if (varsEnabled.length) {
bru += `\n${indentString(varsEnabled.map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsEnabled.map((item) => serializeVar(item)).join('\n'))}`;
}
if (varsLocalEnabled.length) {
bru += `\n${indentString(varsLocalEnabled.map((item) => `${serializeAnnotations(item.annotations)}@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsLocalEnabled.map((item) => serializeVar(item, '@')).join('\n'))}`;
}
if (varsDisabled.length) {
bru += `\n${indentString(varsDisabled.map((item) => `${serializeAnnotations(item.annotations)}~${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsDisabled.map((item) => serializeVar(item, '~')).join('\n'))}`;
}
if (varsLocalDisabled.length) {
bru += `\n${indentString(varsLocalDisabled.map((item) => `${serializeAnnotations(item.annotations)}~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsLocalDisabled.map((item) => serializeVar(item, '~@')).join('\n'))}`;
}
bru += '\n}\n\n';
@@ -693,19 +693,19 @@ ${indentString(body.sparql)}
bru += `vars:post-response {`;
if (varsEnabled.length) {
bru += `\n${indentString(varsEnabled.map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsEnabled.map((item) => serializeVar(item)).join('\n'))}`;
}
if (varsLocalEnabled.length) {
bru += `\n${indentString(varsLocalEnabled.map((item) => `${serializeAnnotations(item.annotations)}@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsLocalEnabled.map((item) => serializeVar(item, '@')).join('\n'))}`;
}
if (varsDisabled.length) {
bru += `\n${indentString(varsDisabled.map((item) => `${serializeAnnotations(item.annotations)}~${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsDisabled.map((item) => serializeVar(item, '~')).join('\n'))}`;
}
if (varsLocalDisabled.length) {
bru += `\n${indentString(varsLocalDisabled.map((item) => `${serializeAnnotations(item.annotations)}~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsLocalDisabled.map((item) => serializeVar(item, '~@')).join('\n'))}`;
}
bru += '\n}\n\n';

View File

@@ -1,6 +1,6 @@
const _ = require('lodash');
const { indentString, getValueString, getKeyString, serializeAnnotations } = require('./utils');
const { indentString, getValueString, getKeyString, serializeAnnotations, serializeVar } = require('./utils');
const enabled = (items = []) => items.filter((item) => item.enabled);
const disabled = (items = []) => items.filter((item) => !item.enabled);
@@ -379,19 +379,19 @@ ${indentString(
bru += `vars:pre-request {`;
if (varsEnabled.length) {
bru += `\n${indentString(varsEnabled.map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsEnabled.map((item) => serializeVar(item)).join('\n'))}`;
}
if (varsLocalEnabled.length) {
bru += `\n${indentString(varsLocalEnabled.map((item) => `${serializeAnnotations(item.annotations)}@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsLocalEnabled.map((item) => serializeVar(item, '@')).join('\n'))}`;
}
if (varsDisabled.length) {
bru += `\n${indentString(varsDisabled.map((item) => `${serializeAnnotations(item.annotations)}~${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsDisabled.map((item) => serializeVar(item, '~')).join('\n'))}`;
}
if (varsLocalDisabled.length) {
bru += `\n${indentString(varsLocalDisabled.map((item) => `${serializeAnnotations(item.annotations)}~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsLocalDisabled.map((item) => serializeVar(item, '~@')).join('\n'))}`;
}
bru += '\n}\n\n';
@@ -405,19 +405,19 @@ ${indentString(
bru += `vars:post-response {`;
if (varsEnabled.length) {
bru += `\n${indentString(varsEnabled.map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsEnabled.map((item) => serializeVar(item)).join('\n'))}`;
}
if (varsLocalEnabled.length) {
bru += `\n${indentString(varsLocalEnabled.map((item) => `${serializeAnnotations(item.annotations)}@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsLocalEnabled.map((item) => serializeVar(item, '@')).join('\n'))}`;
}
if (varsDisabled.length) {
bru += `\n${indentString(varsDisabled.map((item) => `${serializeAnnotations(item.annotations)}~${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsDisabled.map((item) => serializeVar(item, '~')).join('\n'))}`;
}
if (varsLocalDisabled.length) {
bru += `\n${indentString(varsLocalDisabled.map((item) => `${serializeAnnotations(item.annotations)}~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`;
bru += `\n${indentString(varsLocalDisabled.map((item) => serializeVar(item, '~@')).join('\n'))}`;
}
bru += '\n}\n\n';

View File

@@ -1,5 +1,5 @@
const _ = require('lodash');
const { getValueString, indentString, serializeAnnotations } = require('./utils');
const { getValueString, indentString, serializeAnnotations, buildAnnotationsFromVariable } = require('./utils');
const envToJson = (json) => {
const variables = _.get(json, 'variables', []);
@@ -9,18 +9,20 @@ const envToJson = (json) => {
const vars = variables
.filter((variable) => !variable.secret)
.map((variable) => {
const { name, value, enabled, annotations } = variable;
const { name, value, enabled } = variable;
const prefix = enabled ? '' : '~';
const annotationPrefix = serializeAnnotations(buildAnnotationsFromVariable(variable));
return indentString(`${serializeAnnotations(annotations)}${prefix}${name}: ${getValueString(value)}`);
return indentString(`${annotationPrefix}${prefix}${name}: ${getValueString(value)}`);
});
const secretVars = variables
.filter((variable) => variable.secret)
.map((variable) => {
const { name, enabled, annotations } = variable;
const { name, enabled } = variable;
const prefix = enabled ? '' : '~';
return indentString(`${serializeAnnotations(annotations)}${prefix}${name}`);
const annotationPrefix = serializeAnnotations(buildAnnotationsFromVariable(variable));
return indentString(`${annotationPrefix}${prefix}${name}`);
});
let output = '';

View File

@@ -1,3 +1,5 @@
const { parseValueByDataType, BRUNO_VARIABLE_DATATYPES } = require('@usebruno/common/utils');
// safely parse json
const safeParseJson = (json) => {
try {
@@ -33,10 +35,15 @@ const outdentString = (str, spaces = 2) => {
const getValueString = (value) => {
// Handle null, undefined, and empty strings
if (!value) {
if (!value && value !== 0 && value !== false) {
return '';
}
// Stringify non-string values (objects, numbers, booleans)
if (typeof value !== 'string') {
value = typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
}
const hasNewLines = value.includes('\n') || value.includes('\r');
if (!hasNewLines) {
@@ -74,16 +81,44 @@ function serializeAnnotations(annotations) {
annotations
.map((a) => {
if (a.value === undefined) return `@${a.name}`;
if (a.value.includes('\n')) {
return `@${a.name}('''\n${indentString(a.value)}\n''')`;
const strValue = String(a.value);
if (strValue.includes('\n')) {
return `@${a.name}('''\n${indentString(strValue)}\n''')`;
}
const quote = a.value.includes('\'') ? '"' : '\'';
return `@${a.name}(${quote}${a.value}${quote})`;
const quote = strValue.includes('\'') ? '"' : '\'';
return `@${a.name}(${quote}${strValue}${quote})`;
})
.join('\n') + '\n'
);
};
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));
if (dataType && dataType !== 'string') {
return [{ name: dataType }, ...other];
}
return other;
};
const extractTypedAnnotations = (rawAnnotations, result) => {
if (!rawAnnotations?.length) return;
const annotation = rawAnnotations.findLast((a) => BRUNO_VARIABLE_DATATYPES.includes(a.name));
// 'string' is the implicit default — don't materialize it as an explicit dataType field
if (!annotation || annotation.name === 'string') return;
result.dataType = annotation.name;
result.value = parseValueByDataType(result.value, result.dataType);
};
const serializeVar = (item, prefix = '') => {
return `${serializeAnnotations(buildAnnotationsFromVariable(item))}${prefix}${item.name}: ${getValueString(item.value)}`;
};
module.exports = {
safeParseJson,
indentString,
@@ -91,5 +126,8 @@ module.exports = {
getValueString,
getKeyString,
getValueUrl,
serializeAnnotations
serializeAnnotations,
extractTypedAnnotations,
buildAnnotationsFromVariable,
serializeVar
};

View File

@@ -906,9 +906,9 @@ describe('env pair annotations', () => {
const output = envParser(input);
expect(output.variables).toEqual([
{ name: 'env_secret_str', value: '', enabled: true, secret: true },
{ name: 'env_secret_num', value: '', enabled: true, secret: true, annotations: [{ name: 'number' }] },
{ name: 'env_secret_obj', value: '', enabled: true, secret: true, annotations: [{ name: 'object' }] },
{ name: 'env_secret_boolean', value: '', enabled: true, secret: true, annotations: [{ name: 'boolean' }] },
{ name: 'env_secret_num', value: '', enabled: true, secret: true, annotations: [{ name: 'number' }], dataType: 'number' },
{ name: 'env_secret_obj', value: '', enabled: true, secret: true, annotations: [{ name: 'object' }], dataType: 'object' },
{ name: 'env_secret_boolean', value: '', enabled: true, secret: true, annotations: [{ name: 'boolean' }], dataType: 'boolean' },
{ name: 'env_secret_new', value: '', enabled: true, secret: true }
]);
});

View File

@@ -241,6 +241,140 @@ body:grpc {
});
});
describe('vars:pre-request decorators', () => {
it('should parse all dataType decorators', () => {
const input = `
vars:pre-request {
apiKey: abc123
@number
port: 3000
@boolean
isEnabled: true
@object
config: '''
{
"name": "John Doe",
"age": 30
}
'''
}
`;
const output = parser(input);
expect(output.vars.req).toEqual([
{ name: 'apiKey', value: 'abc123', enabled: true, local: false },
{ name: 'port', value: 3000, enabled: true, local: false, annotations: [{ name: 'number' }], dataType: 'number' },
{ name: 'isEnabled', value: true, enabled: true, local: false, annotations: [{ name: 'boolean' }], dataType: 'boolean' },
{ name: 'config', value: { name: 'John Doe', age: 30 }, enabled: true, local: false, annotations: [{ name: 'object' }], dataType: 'object' }
]);
});
it('should parse @description with multiline value', () => {
const input = `
vars:pre-request {
@description('''
This is a certificate
Use this when making request
''')
certificate: some-value
url: https://example.com
}
`;
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: 'url', value: 'https://example.com', enabled: true, local: false }
]);
});
it('should parse combined @object and @description on a multiline var', () => {
const input = `
vars:pre-request {
@object
@description('''
This is a certificate
Use this when making request
''')
certificate: '''
{
"name": "John Doe",
"age": 30,
"email": "john.doe@example.com",
"signature": "John Doe"
}
'''
@number
@description('server port')
~port: 8080
}
`;
const output = parser(input);
expect(output.vars.req).toEqual([
{
name: 'certificate',
value: { name: 'John Doe', age: 30, email: 'john.doe@example.com', signature: 'John Doe' },
enabled: true,
local: false,
annotations: [
{ name: 'object' },
{ name: 'description', value: 'This is a certificate\nUse this when making request' }
],
dataType: 'object'
},
{
name: 'port',
value: 8080,
enabled: false,
local: false,
annotations: [
{ name: 'number' },
{ name: 'description', value: 'server port' }
],
dataType: 'number'
}
]);
});
it('should keep only the last dataType decorator when multiple are present', () => {
const input = `
vars:pre-request {
@object
@number
port: 3000
}
`;
const output = parser(input);
expect(output.vars.req).toEqual([
{ name: 'port', value: 3000, enabled: true, local: false, annotations: [{ name: 'object' }, { name: 'number' }], dataType: 'number' }
]);
});
it('should preserve the declared dataType and the raw value when coercion is impossible', () => {
// The UI's DataTypeSelector surfaces a warning icon for these rows; the
// declared dataType is retained so the user sees their intent.
const input = `
vars:pre-request {
@number
port: not-a-number
@boolean
flag: maybe
@object
config: plain
}
`;
const output = parser(input);
expect(output.vars.req).toEqual([
{ name: 'port', value: 'not-a-number', enabled: true, local: false, annotations: [{ name: 'number' }], dataType: 'number' },
{ name: 'flag', value: 'maybe', enabled: true, local: false, annotations: [{ name: 'boolean' }], dataType: 'boolean' },
{ name: 'config', value: 'plain', enabled: true, local: false, annotations: [{ name: 'object' }], dataType: 'object' }
]);
});
});
describe('multi-line values', () => {
it('parses multi-line values in URL, headers, params, and vars', () => {
const input = `

View File

@@ -425,4 +425,168 @@ vars {
expect(output).toEqual(expected);
});
describe('typed environment variables', () => {
it('should parse @number decorator and coerce value to number', () => {
const input = `
vars {
@number
port: 3000
}
`;
const output = parser(input);
expect(output).toEqual({
variables: [
{
name: 'port',
value: 3000,
enabled: true,
secret: false,
annotations: [{ name: 'number' }],
dataType: 'number'
}
]
});
});
it('should parse @boolean decorator and coerce value to boolean', () => {
const input = `
vars {
@boolean
isEnabled: true
}
`;
const output = parser(input);
expect(output.variables[0]).toEqual({
name: 'isEnabled',
value: true,
enabled: true,
secret: false,
annotations: [{ name: 'boolean' }],
dataType: 'boolean'
});
});
it('should parse @object decorator and coerce multiline JSON value', () => {
const input = `
vars {
@object
config: '''
{"a": 1, "b": "x"}
'''
}
`;
const output = parser(input);
expect(output.variables[0]).toEqual({
name: 'config',
value: { a: 1, b: 'x' },
enabled: true,
secret: false,
annotations: [{ name: 'object' }],
dataType: 'object'
});
});
it('should leave plain vars without dataType', () => {
const input = `
vars {
apiKey: abc123
}
`;
const output = parser(input);
expect(output.variables[0]).toEqual({
name: 'apiKey',
value: 'abc123',
enabled: true,
secret: false
});
expect(output.variables[0].dataType).toBeUndefined();
});
it('extracts the dataType from a secret var decorator', () => {
const input = `
vars:secret [
@number
api_key
]
`;
const output = parser(input);
expect(output.variables[0].secret).toBe(true);
expect(output.variables[0].dataType).toBe('number');
});
it('leaves a bare secret var without a dataType', () => {
const input = `
vars:secret [
api_key
]
`;
const output = parser(input);
expect(output.variables[0].secret).toBe(true);
expect(output.variables[0].dataType).toBeUndefined();
});
it('should preserve the declared dataType and the raw value when coercion is impossible', () => {
// The UI's DataTypeSelector surfaces a warning icon for these rows; the
// declared dataType is retained so the user sees their intent.
const input = `
vars {
@number
port: not-a-number
@boolean
flag: maybe
@object
config: plain
}
`;
const output = parser(input);
expect(output.variables).toEqual([
{
name: 'port',
value: 'not-a-number',
enabled: true,
secret: false,
annotations: [{ name: 'number' }],
dataType: 'number'
},
{
name: 'flag',
value: 'maybe',
enabled: true,
secret: false,
annotations: [{ name: 'boolean' }],
dataType: 'boolean'
},
{
name: 'config',
value: 'plain',
enabled: true,
secret: false,
annotations: [{ name: 'object' }],
dataType: 'object'
}
]);
});
it('should keep only the last dataType when multiple are stacked', () => {
const input = `
vars {
@object
@number
port: 3000
}
`;
const output = parser(input);
expect(output.variables[0].dataType).toBe('number');
expect(output.variables[0].value).toBe(3000);
});
});
});

View File

@@ -136,4 +136,99 @@ describe('jsonToBru stringify', () => {
`);
});
});
describe('vars:pre-request dataType decorators', () => {
const baseMeta = { name: 'test', type: 'http', seq: 1 };
const baseHttp = { method: 'get', url: 'http://localhost' };
it('emits dataType decorators for typed variables', () => {
const output = stringify({
meta: baseMeta,
http: baseHttp,
vars: {
req: [
{ name: 'apiKey', value: 'abc', enabled: true, local: false },
{ name: 'port', value: 3000, enabled: true, local: false, dataType: 'number' },
{ name: 'flag', value: true, enabled: true, local: false, dataType: 'boolean' }
]
}
});
expect(output).toContain('apiKey: abc');
expect(output).toContain('@number\n port: 3000');
expect(output).toContain('@boolean\n flag: true');
});
it('serializes @object values as multiline JSON', () => {
const output = stringify({
meta: baseMeta,
http: baseHttp,
vars: {
req: [
{ name: 'config', value: { a: 1, b: 'x' }, enabled: true, local: false, dataType: 'object' }
]
}
});
expect(output).toContain('@object');
expect(output).toContain('"a": 1');
expect(output).toContain('"b": "x"');
});
it('preserves local, disabled and disabled+local prefixes alongside dataType', () => {
const output = stringify({
meta: baseMeta,
http: baseHttp,
vars: {
req: [
{ name: 'a', value: 1, enabled: true, local: true, dataType: 'number' },
{ name: 'b', value: 2, enabled: false, local: false, dataType: 'number' },
{ name: 'c', value: 3, enabled: false, local: true, dataType: 'number' }
]
}
});
expect(output).toContain('@number\n @a: 1');
expect(output).toContain('@number\n ~b: 2');
expect(output).toContain('@number\n ~@c: 3');
});
it('does not emit a dataType decorator for the string default', () => {
const output = stringify({
meta: baseMeta,
http: baseHttp,
vars: {
req: [
{ name: 'apiKey', value: 'abc', enabled: true, local: false, dataType: 'string' }
]
}
});
expect(output).not.toContain('@string');
expect(output).toContain('apiKey: abc');
});
it('drops a stale dataType annotation in favour of the dataType field', () => {
const output = stringify({
meta: baseMeta,
http: baseHttp,
vars: {
req: [
{
name: 'port',
value: 3000,
enabled: true,
local: false,
annotations: [{ name: 'string' }, { name: 'description', value: 'service port' }],
dataType: 'number'
}
]
}
});
expect(output).toContain('@number');
expect(output).not.toContain('@string');
expect(output).toContain('@description(\'service port\')');
});
});
});

View File

@@ -243,4 +243,112 @@ vars:secret [
`;
expect(output).toEqual(expected);
});
describe('typed environment variables', () => {
it('should serialize @number dataType as a decorator', () => {
const input = {
variables: [
{ name: 'port', value: 3000, enabled: true, dataType: 'number' }
]
};
const output = parser(input);
expect(output).toEqual(`vars {
@number
port: 3000
}
`);
});
it('should serialize @boolean dataType as a decorator', () => {
const input = {
variables: [
{ name: 'isEnabled', value: true, enabled: true, dataType: 'boolean' }
]
};
const output = parser(input);
expect(output).toEqual(`vars {
@boolean
isEnabled: true
}
`);
});
it('should serialize @object dataType with JSON-stringified multiline value', () => {
const input = {
variables: [
{ name: 'config', value: { a: 1, b: 'x' }, enabled: true, dataType: 'object' }
]
};
const output = parser(input);
expect(output).toContain('@object');
expect(output).toContain('"a": 1');
expect(output).toContain('"b": "x"');
});
it('should not emit a decorator for string dataType', () => {
const input = {
variables: [
{ name: 'apiKey', value: 'abc123', enabled: true, dataType: 'string' }
]
};
const output = parser(input);
expect(output).toEqual(`vars {
apiKey: abc123
}
`);
});
it('should drop dataType annotations from existing list and rebuild from dataType field', () => {
const input = {
variables: [
{
name: 'port',
value: 3000,
enabled: true,
annotations: [{ name: 'string' }, { name: 'description', value: 'service port' }],
dataType: 'number'
}
]
};
const output = parser(input);
// @string from old annotations should be dropped, @number should be set from dataType
expect(output).toContain('@number');
expect(output).not.toContain('@string');
expect(output).toContain('@description(\'service port\')');
});
it('should emit dataType but not the value for secret vars', () => {
const input = {
variables: [
{ name: 'api_key', value: 'redacted', enabled: true, secret: true, dataType: 'number' }
]
};
const output = parser(input);
// secret vars use the vars:secret block: the dataType is emitted as a
// decorator, but the value is never written to disk.
expect(output).toContain('vars:secret');
expect(output).toContain('api_key');
expect(output).toContain('@number');
expect(output).not.toContain('redacted');
});
it('should round-trip non-string values through getValueString', () => {
const input = {
variables: [
{ name: 'port', value: 8080, enabled: true, dataType: 'number' },
{ name: 'flag', value: false, enabled: true, dataType: 'boolean' }
]
};
const output = parser(input);
expect(output).toContain('port: 8080');
expect(output).toContain('flag: false');
});
});
});

View File

@@ -1,4 +1,9 @@
const { getValueString } = require('../src/utils');
const {
getValueString,
extractTypedAnnotations,
buildAnnotationsFromVariable,
serializeAnnotations
} = require('../src/utils');
describe('getValueString', () => {
it('returns single line value as-is', () => {
@@ -18,4 +23,161 @@ describe('getValueString', () => {
expect(getValueString(null)).toBe('');
expect(getValueString(undefined)).toBe('');
});
it('returns "0" for the number 0 (truthy guard)', () => {
expect(getValueString(0)).toBe('0');
});
it('returns "false" for boolean false (truthy guard)', () => {
expect(getValueString(false)).toBe('false');
});
it('stringifies numbers and booleans to their primitive form', () => {
expect(getValueString(42)).toBe('42');
expect(getValueString(true)).toBe('true');
});
it('JSON-stringifies object values and wraps multiline output in triple quotes', () => {
const out = getValueString({ a: 1, b: 'x' });
expect(out).toContain('"a": 1');
expect(out).toContain('"b": "x"');
});
});
describe('extractTypedAnnotations', () => {
it('sets dataType and coerces value when a dataType annotation is present', () => {
const result = { value: '42' };
extractTypedAnnotations([{ name: 'number' }], result);
expect(result.dataType).toBe('number');
expect(result.value).toBe(42);
});
it('does nothing when no dataType annotation is present', () => {
const result = { value: 'abc' };
extractTypedAnnotations([{ name: 'description', value: 'doc' }], result);
expect(result.dataType).toBeUndefined();
expect(result.value).toBe('abc');
});
it('does not materialize @string as an explicit dataType', () => {
const result = { value: 'abc' };
extractTypedAnnotations([{ name: 'string' }], result);
expect(result.dataType).toBeUndefined();
});
it('picks the last dataType annotation when multiple are stacked', () => {
const result = { value: '99' };
extractTypedAnnotations([{ name: 'object' }, { name: 'number' }], result);
expect(result.dataType).toBe('number');
expect(result.value).toBe(99);
});
it('preserves the declared dataType and leaves the raw value when coercion is impossible', () => {
// The DataTypeSelector relies on this: when dataType is preserved but the
// coerced value's type doesn't match, the UI surfaces a warning icon.
const result = { value: 'not-a-number' };
extractTypedAnnotations([{ name: 'number' }], result);
expect(result.dataType).toBe('number');
expect(result.value).toBe('not-a-number');
});
it('preserves @boolean even when the literal is not a boolean string', () => {
const result = { value: 'maybe' };
extractTypedAnnotations([{ name: 'boolean' }], result);
expect(result.dataType).toBe('boolean');
expect(result.value).toBe('maybe');
});
it('preserves @object even when the value is not parseable JSON', () => {
const result = { value: 'plain text' };
extractTypedAnnotations([{ name: 'object' }], result);
expect(result.dataType).toBe('object');
expect(result.value).toBe('plain text');
});
it('handles null / empty annotations safely', () => {
const result = { value: 'abc' };
extractTypedAnnotations(null, result);
extractTypedAnnotations([], result);
expect(result.dataType).toBeUndefined();
});
});
describe('buildAnnotationsFromVariable', () => {
it('returns an empty array when no dataType and no annotations', () => {
expect(buildAnnotationsFromVariable({})).toEqual([]);
});
it('prepends a dataType annotation from the dataType field', () => {
expect(buildAnnotationsFromVariable({ dataType: 'number' })).toEqual([{ name: 'number' }]);
});
it('drops any existing dataType annotation and rebuilds from the dataType field', () => {
const out = buildAnnotationsFromVariable({
dataType: 'number',
annotations: [{ name: 'string' }, { name: 'description', value: 'doc' }]
});
expect(out).toEqual([{ name: 'number' }, { name: 'description', value: 'doc' }]);
});
it('does not emit a dataType annotation for the string default', () => {
expect(buildAnnotationsFromVariable({ dataType: 'string' })).toEqual([]);
});
it('preserves non-dataType annotations when dataType is absent', () => {
expect(buildAnnotationsFromVariable({
annotations: [{ name: 'description', value: 'doc' }]
})).toEqual([{ name: 'description', value: 'doc' }]);
});
});
describe('serializeAnnotations', () => {
it('returns an empty string for null/undefined/empty input', () => {
expect(serializeAnnotations(null)).toBe('');
expect(serializeAnnotations(undefined)).toBe('');
expect(serializeAnnotations([])).toBe('');
});
it('serializes a valueless annotation as @name with a trailing newline', () => {
expect(serializeAnnotations([{ name: 'number' }])).toBe('@number\n');
});
it('serializes a string-valued annotation using single-quote delimiters by default', () => {
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', () => {
expect(serializeAnnotations([{ name: 'description', value: 'O\'Reilly' }])).toBe('@description("O\'Reilly")\n');
});
it('keeps single-quote delimiters when the value contains a double quote', () => {
expect(serializeAnnotations([{ name: 'description', value: 'say "hi"' }])).toBe('@description(\'say "hi"\')\n');
});
it('wraps multiline values in triple-quote delimiters with 2-space indentation', () => {
expect(serializeAnnotations([{ name: 'description', value: 'line1\nline2' }])).toBe(
'@description(\'\'\'\n line1\n line2\n\'\'\')\n'
);
});
it('joins multiple annotations with newlines and adds a single trailing newline', () => {
expect(
serializeAnnotations([
{ name: 'number' },
{ name: 'description', value: 'doc' }
])
).toBe('@number\n@description(\'doc\')\n');
});
it('coerces non-string values to strings via String() before quoting', () => {
expect(serializeAnnotations([{ name: 'count', value: 42 }])).toBe('@count(\'42\')\n');
expect(serializeAnnotations([{ name: 'enabled', value: false }])).toBe('@enabled(\'false\')\n');
});
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.
expect(serializeAnnotations([{ name: 'description', value: null }])).toBe('@description(\'null\')\n');
expect(serializeAnnotations([{ name: 'description', value: '' }])).toBe('@description(\'\')\n');
});
});

View File

@@ -1,6 +1,6 @@
import type { UID, Annotation } from '../common';
export type EnvironmentVariableDatatype = 'string' | 'number' | 'boolean' | 'object';
export type EnvironmentVariableDataType = 'string' | 'number' | 'boolean' | 'object';
export interface EnvironmentVariable {
uid: UID;
@@ -9,7 +9,7 @@ export interface EnvironmentVariable {
type: 'text';
enabled?: boolean;
secret?: boolean;
datatype?: EnvironmentVariableDatatype;
dataType?: EnvironmentVariableDataType;
annotations?: Annotation[] | null;
}

View File

@@ -1,7 +1,7 @@
import { Annotation } from './annotation';
import type { UID } from './uid';
export type VariableDatatype = 'string' | 'number' | 'boolean' | 'object';
export type VariableDataType = 'string' | 'number' | 'boolean' | 'object';
/**
* Request-scoped variable entry.
@@ -9,11 +9,11 @@ export type VariableDatatype = 'string' | 'number' | 'boolean' | 'object';
export interface Variable {
uid: UID;
name?: string | null;
value?: string | null;
value?: string | number | boolean | Record<string, unknown> | null;
description?: string | null;
enabled?: boolean;
local?: boolean;
datatype?: VariableDatatype;
dataType?: VariableDataType;
annotations?: Annotation[] | null;
}

View File

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

View File

@@ -19,19 +19,19 @@ const buildEnvironment = (overrides = {}) => ({
});
describe('Environment Schema Validation', () => {
describe('variable datatype', () => {
it.each(['string', 'number', 'boolean', 'object'])('validates a variable with datatype %s', async (datatype) => {
const env = buildEnvironment({ variables: [buildVariable({ datatype })] });
describe('variable dataType', () => {
it.each(['string', 'number', 'boolean', 'object'])('validates a variable with dataType %s', async (dataType) => {
const env = buildEnvironment({ variables: [buildVariable({ dataType })] });
await expect(environmentSchema.validate(env)).resolves.toBeTruthy();
});
it('preserves datatype after validation', async () => {
const env = buildEnvironment({ variables: [buildVariable({ value: '300', datatype: 'number' })] });
it('preserves dataType after validation', async () => {
const env = buildEnvironment({ variables: [buildVariable({ value: '300', dataType: 'number' })] });
const validated = await environmentSchema.validate(env);
expect(validated.variables[0].datatype).toBe('number');
expect(validated.variables[0].dataType).toBe('number');
expect(validated.variables[0].value).toBe('300');
});
});

View File

@@ -1,4 +1,5 @@
const Yup = require('yup');
const { BRUNO_VARIABLE_DATATYPES } = require('@usebruno/common/utils');
const { uidSchema } = require('../common');
const annotationSchema = Yup.object({
@@ -18,9 +19,9 @@ const environmentVariablesSchema = Yup.object({
)
.nullable(),
type: Yup.string().oneOf(['text']).required('type is required'),
datatype: Yup.string().oneOf(['string', 'number', 'boolean', 'object']).nullable(),
enabled: Yup.boolean().defined(),
secret: Yup.boolean()
secret: Yup.boolean(),
dataType: Yup.string().oneOf(BRUNO_VARIABLE_DATATYPES).nullable()
})
.noUnknown(true)
.strict();
@@ -109,9 +110,9 @@ const assertionSchema = keyValueSchema.shape({
const varsSchema = Yup.object({
uid: uidSchema,
name: Yup.string().nullable(),
value: Yup.string().nullable(),
// Allow mixed types (string, number, boolean, object) to support coerced dataType values.
value: Yup.mixed().nullable(),
description: Yup.string().nullable(),
datatype: Yup.string().oneOf(['string', 'number', 'boolean', 'object']).nullable(),
// Optional annotations on variables
annotations: Yup.array()
.of(
@@ -119,6 +120,7 @@ const varsSchema = Yup.object({
)
.nullable(),
enabled: Yup.boolean(),
dataType: Yup.string().oneOf(BRUNO_VARIABLE_DATATYPES).nullable(),
// todo
// anoop(4 feb 2023) - nobody uses this, and it needs to be removed

View File

@@ -18,7 +18,7 @@ describe('Request Schema Validation', () => {
expect(isValid).toBeTruthy();
});
it('request schema must validate successfully - vars with datatype', async () => {
it('request schema must validate successfully - vars with dataType', async () => {
const request = {
url: 'https://restcountries.com/v2/alpha/in',
method: 'GET',
@@ -29,9 +29,9 @@ describe('Request Schema Validation', () => {
},
vars: {
req: [
{ uid: uuid(), name: 'var_num', value: '300', datatype: 'number', enabled: true, local: false },
{ uid: uuid(), name: 'var_bool', value: 'true', datatype: 'boolean', enabled: true, local: false },
{ uid: uuid(), name: 'var_obj', value: '{"scope":"req"}', datatype: 'object', enabled: true, local: false },
{ uid: uuid(), name: 'var_num', value: '300', dataType: 'number', enabled: true, local: false },
{ uid: uuid(), name: 'var_bool', value: 'true', dataType: 'boolean', enabled: true, local: false },
{ uid: uuid(), name: 'var_obj', value: '{"scope":"req"}', dataType: 'object', enabled: true, local: false },
{ uid: uuid(), name: 'var_str', value: 'plain', enabled: true, local: false }
],
res: []
@@ -39,10 +39,10 @@ describe('Request Schema Validation', () => {
};
const validated = await requestSchema.validate(request);
expect(validated.vars.req[0].datatype).toBe('number');
expect(validated.vars.req[1].datatype).toBe('boolean');
expect(validated.vars.req[2].datatype).toBe('object');
expect(validated.vars.req[3].datatype).toBeUndefined();
expect(validated.vars.req[0].dataType).toBe('number');
expect(validated.vars.req[1].dataType).toBe('boolean');
expect(validated.vars.req[2].dataType).toBe('object');
expect(validated.vars.req[3].dataType).toBeUndefined();
});
it('request schema must validate successfully - custom method', async () => {

View File

@@ -16,6 +16,15 @@ vars:pre-request {
collection_pre_var: collection_pre_var_value
collection_pre_var_token: {{request_pre_var_token}}
collection-var: collection-var-value
coll_str: collection_string
@number
coll_num: 100
@boolean
coll_bool: false
@object
coll_obj: '''
{"scope":"collection"}
'''
}
script:pre-request {

View File

@@ -22,6 +22,14 @@ vars {
google_auth_url: https://accounts.google.com/o/oauth2/auth
google_access_token_url: https://accounts.google.com/o/oauth2/token
google_scope: https://www.googleapis.com/auth/userinfo.email
@number
typed_env_num: 300
@boolean
typed_env_bool: true
@object
typed_env_obj: '''
{"scope":"env"}
'''
}
vars:secret [
github_client_secret,

View File

@@ -10,4 +10,12 @@ vars {
foo: bar
testSetEnvVar: bruno-29653
echo-host: https://echo.usebruno.com
@number
typed_env_num: 300
@boolean
typed_env_bool: true
@object
typed_env_obj: '''
{"scope":"env"}
'''
}

View File

@@ -0,0 +1,15 @@
meta {
name: variable-datatypes
}
vars:pre-request {
fold_str: folder_string
@number
fold_num: 200
@boolean
fold_bool: true
@object
fold_obj: '''
{"scope":"folder"}
'''
}

View File

@@ -0,0 +1,40 @@
meta {
name: form-urlencoded
type: http
seq: 3
}
post {
url: {{echo-host}}
body: formUrlEncoded
auth: none
}
body:form-urlencoded {
fu_str: {{fu_str}}
fu_num: {{fu_num}}
fu_bool: {{fu_bool}}
fu_obj: {{fu_obj}}
}
vars:pre-request {
fu_str: form_string
@number
fu_num: 42
@boolean
fu_bool: true
@object
fu_obj: '''
{"key":"value"}
'''
}
tests {
test("form-urlencoded values stringify typed vars on the wire", () => {
const raw = res.getBody();
expect(raw).to.contain("fu_str=form_string");
expect(raw).to.contain("fu_num=42");
expect(raw).to.contain("fu_bool=true");
expect(raw).to.contain("fu_obj=%7B%22key%22%3A%22value%22%7D");
});
}

View File

@@ -0,0 +1,40 @@
meta {
name: multipart
type: http
seq: 4
}
post {
url: {{echo-host}}
body: multipartForm
auth: none
}
body:multipart-form {
mp_str: {{mp_str}}
mp_num: {{mp_num}}
mp_bool: {{mp_bool}}
mp_obj: {{mp_obj}}
}
vars:pre-request {
mp_str: form_string
@number
mp_num: 42
@boolean
mp_bool: true
@object
mp_obj: '''
{"key":"value"}
'''
}
tests {
test("multipart text fields stringify typed values on the wire", () => {
const raw = res.getBody();
expect(raw).to.contain("form_string");
expect(raw).to.match(/name="mp_num"[\s\S]*?\r?\n\r?\n42/);
expect(raw).to.match(/name="mp_bool"[\s\S]*?\r?\n\r?\ntrue/);
expect(raw).to.contain('{"key":"value"}');
});
}

View File

@@ -0,0 +1,208 @@
meta {
name: typed vars
type: http
seq: 1
}
post {
url: {{echo-host}}
body: json
auth: none
}
headers {
x-h-str: {{req_str}}
x-h-num: {{req_num}}
x-h-bool: {{req_bool}}
x-h-obj: {{req_obj}}
}
body:json {
{
"coll_num": {{coll_num}},
"coll_bool": {{coll_bool}},
"coll_obj": {{coll_obj}},
"fold_num": {{fold_num}},
"fold_bool": {{fold_bool}},
"fold_obj": {{fold_obj}},
"req_num": {{req_num}},
"req_bool": {{req_bool}},
"req_obj": {{req_obj}},
"req_obj_precision": {{req_obj_precision}},
"req_obj_empty": {{req_obj_empty}},
"typed_env_num": {{typed_env_num}},
"typed_env_bool": {{typed_env_bool}},
"typed_env_obj": {{typed_env_obj}},
"runtime_num": {{runtime_num}},
"runtime_bool": {{runtime_bool}},
"runtime_obj": {{runtime_obj}},
"inferred_env_num": {{inferred_env_num}},
"inferred_env_bool": {{inferred_env_bool}},
"inferred_env_obj": {{inferred_env_obj}}
}
}
vars:pre-request {
req_str: request_string
@number
req_num: 42
@boolean
req_bool: true
@object
req_obj: '''
{"key":"value"}
'''
@object
req_obj_precision: '''
{"integer":123,"negativeInteger":-99,"zero":0,"float":2.718,"negativeFloat":-1.618,"largeDouble":12345.678901234567,"smallDouble":9.876e-12,"booleanTrue":true,"booleanFalse":false}
'''
@object
req_obj_empty: '''
{}
'''
@number
mismatched_num: not-a-number
}
script:pre-request {
bru.setVar("runtime_str", "runtime_string");
bru.setVar("runtime_num", 999);
bru.setVar("runtime_bool", true);
bru.setVar("runtime_obj", { nested: "yes" });
bru.setEnvVar("inferred_env_num", 700);
bru.setEnvVar("inferred_env_bool", true);
bru.setEnvVar("inferred_env_obj", { from: "script" });
bru.setEnvVar("strict_num_str", "42");
bru.setEnvVar("strict_bool_str", "true");
bru.setEnvVar("strict_obj_str", JSON.stringify({ k: 1 }));
bru.setVar("strict_runtime_num_str", "42");
}
tests {
test("collection vars carry their declared types", () => {
expect(typeof bru.getCollectionVar("coll_str")).to.equal("string");
expect(typeof bru.getCollectionVar("coll_num")).to.equal("number");
expect(bru.getCollectionVar("coll_num")).to.equal(100);
expect(typeof bru.getCollectionVar("coll_bool")).to.equal("boolean");
expect(bru.getCollectionVar("coll_bool")).to.equal(false);
expect(bru.getCollectionVar("coll_obj")).to.deep.equal({ scope: "collection" });
});
test("folder vars carry their declared types", () => {
expect(typeof bru.getFolderVar("fold_str")).to.equal("string");
expect(typeof bru.getFolderVar("fold_num")).to.equal("number");
expect(bru.getFolderVar("fold_num")).to.equal(200);
expect(typeof bru.getFolderVar("fold_bool")).to.equal("boolean");
expect(bru.getFolderVar("fold_bool")).to.equal(true);
expect(bru.getFolderVar("fold_obj")).to.deep.equal({ scope: "folder" });
});
test("request vars carry their declared types", () => {
expect(typeof bru.getRequestVar("req_str")).to.equal("string");
expect(typeof bru.getRequestVar("req_num")).to.equal("number");
expect(bru.getRequestVar("req_num")).to.equal(42);
expect(typeof bru.getRequestVar("req_bool")).to.equal("boolean");
expect(bru.getRequestVar("req_bool")).to.equal(true);
expect(bru.getRequestVar("req_obj")).to.deep.equal({ key: "value" });
expect(typeof bru.getRequestVar("req_obj_empty")).to.equal("object");
expect(bru.getRequestVar("req_obj_empty")).to.deep.equal({});
// @number with non-numeric data falls through to the raw string — same
// signal the env editor uses to surface its warning icon.
expect(typeof bru.getRequestVar("mismatched_num")).to.equal("string");
expect(bru.getRequestVar("mismatched_num")).to.equal("not-a-number");
});
test("typed object var preserves number precision through file → parse round-trip", () => {
const v = bru.getRequestVar("req_obj_precision");
expect(v.integer).to.equal(123);
expect(v.negativeInteger).to.equal(-99);
expect(v.zero).to.equal(0);
expect(v.float).to.equal(2.718);
expect(v.negativeFloat).to.equal(-1.618);
expect(v.largeDouble).to.equal(12345.678901234567);
expect(v.smallDouble).to.equal(9.876e-12);
expect(v.booleanTrue).to.equal(true);
expect(v.booleanFalse).to.equal(false);
});
test("collection environment vars carry their declared types", () => {
expect(typeof bru.getEnvVar("typed_env_num")).to.equal("number");
expect(bru.getEnvVar("typed_env_num")).to.equal(300);
expect(typeof bru.getEnvVar("typed_env_bool")).to.equal("boolean");
expect(bru.getEnvVar("typed_env_bool")).to.equal(true);
expect(bru.getEnvVar("typed_env_obj")).to.deep.equal({ scope: "env" });
});
test("runtime vars set via bru.setVar preserve types", () => {
expect(typeof bru.getVar("runtime_str")).to.equal("string");
expect(typeof bru.getVar("runtime_num")).to.equal("number");
expect(bru.getVar("runtime_num")).to.equal(999);
expect(typeof bru.getVar("runtime_bool")).to.equal("boolean");
expect(bru.getVar("runtime_obj")).to.deep.equal({ nested: "yes" });
});
test("setEnvVar infers dataType from the JS value", () => {
expect(typeof bru.getEnvVar("inferred_env_num")).to.equal("number");
expect(bru.getEnvVar("inferred_env_num")).to.equal(700);
expect(typeof bru.getEnvVar("inferred_env_bool")).to.equal("boolean");
expect(bru.getEnvVar("inferred_env_bool")).to.equal(true);
expect(bru.getEnvVar("inferred_env_obj")).to.deep.equal({ from: "script" });
});
test("bru.set* identifies type via the value's runtime type, not string content", () => {
expect(typeof bru.getEnvVar("strict_num_str")).to.equal("string");
expect(bru.getEnvVar("strict_num_str")).to.equal("42");
expect(typeof bru.getEnvVar("strict_bool_str")).to.equal("string");
expect(bru.getEnvVar("strict_bool_str")).to.equal("true");
expect(typeof bru.getEnvVar("strict_obj_str")).to.equal("string");
expect(bru.getEnvVar("strict_obj_str")).to.equal('{"k":1}');
expect(typeof bru.getVar("strict_runtime_num_str")).to.equal("string");
});
test("response body echoes typed values from every scope", () => {
const json = res.getBody();
expect(json.coll_num).to.equal(100);
expect(json.coll_bool).to.equal(false);
expect(json.coll_obj).to.deep.equal({ scope: "collection" });
expect(json.fold_num).to.equal(200);
expect(json.fold_bool).to.equal(true);
expect(json.fold_obj).to.deep.equal({ scope: "folder" });
expect(json.req_num).to.equal(42);
expect(json.req_bool).to.equal(true);
expect(json.req_obj).to.deep.equal({ key: "value" });
expect(json.req_obj_precision.integer).to.equal(123);
expect(json.req_obj_precision.negativeInteger).to.equal(-99);
expect(json.req_obj_precision.zero).to.equal(0);
expect(json.req_obj_precision.float).to.equal(2.718);
expect(json.req_obj_precision.negativeFloat).to.equal(-1.618);
expect(json.req_obj_precision.largeDouble).to.equal(12345.678901234567);
expect(json.req_obj_precision.smallDouble).to.equal(9.876e-12);
expect(json.req_obj_precision.booleanTrue).to.equal(true);
expect(json.req_obj_precision.booleanFalse).to.equal(false);
expect(json.req_obj_empty).to.deep.equal({});
expect(json.typed_env_num).to.equal(300);
expect(json.typed_env_bool).to.equal(true);
expect(json.typed_env_obj).to.deep.equal({ scope: "env" });
expect(json.runtime_num).to.equal(999);
expect(json.runtime_bool).to.equal(true);
expect(json.runtime_obj).to.deep.equal({ nested: "yes" });
expect(json.inferred_env_num).to.equal(700);
expect(json.inferred_env_bool).to.equal(true);
expect(json.inferred_env_obj).to.deep.equal({ from: "script" });
});
test("typed vars in request headers stringify on the wire", () => {
// echo-host reflects request headers as response headers (lowercased).
const headers = res.getHeaders();
expect(headers["x-h-str"]).to.equal("request_string");
expect(headers["x-h-num"]).to.equal("42");
expect(headers["x-h-bool"]).to.equal("true");
expect(headers["x-h-obj"]).to.equal('{"key":"value"}');
});
}

View File

@@ -74,6 +74,32 @@ app.get('/redirect-to-ping', function (req, res) {
return res.redirect('/ping');
});
// Echoes the request back in one flat shape
app.all('/api/echo/everything', (req, res) => {
return res.json({
method: req.method,
url: req.originalUrl,
query: req.query,
headers: req.headers,
body: req.rawBody
});
});
// The global JSON parser rejects malformed bodies before the route above runs.
// Recover that case by echoing the raw bytes instead of surfacing a 400.
app.use((err, req, res, next) => {
if (req.path === '/api/echo/everything') {
return res.json({
method: req.method,
url: req.originalUrl,
query: req.query,
headers: req.headers,
body: req.rawBody
});
}
return next(err);
});
const server = require('http').createServer(app);
server.on('upgrade', wsRouter);

View File

@@ -0,0 +1,19 @@
name: Local
variables:
- name: host
value: http://localhost:8080
- name: echo-host
value: https://echo.usebruno.com
- name: typed_env_num
value:
type: number
data: "300"
- name: typed_env_bool
value:
type: boolean
data: "true"
- name: typed_env_obj
value:
type: object
data: '{"scope":"env"}'

View File

@@ -0,0 +1,19 @@
name: Prod
variables:
- name: host
value: https://testbench-sanity.usebruno.com
- name: echo-host
value: https://echo.usebruno.com
- name: typed_env_num
value:
type: number
data: "300"
- name: typed_env_bool
value:
type: boolean
data: "true"
- name: typed_env_obj
value:
type: object
data: '{"scope":"env"}'

Some files were not shown because too many files have changed in this diff Show More