mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
3 Commits
feat/fully
...
feat/test-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb6e87860 | ||
|
|
6136d3ac62 | ||
|
|
942f995717 |
4
.github/workflows/tests-linux.yml
vendored
4
.github/workflows/tests-linux.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/tests-macos.yml
vendored
2
.github/workflows/tests-macos.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/tests-windows.yml
vendored
2
.github/workflows/tests-windows.yml
vendored
@@ -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
7
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
58
packages/bruno-app/src/components/DataTypeSelector/index.js
Normal file
58
packages/bruno-app/src/components/DataTypeSelector/index.js
Normal 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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
// aren’t 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)) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
149
packages/bruno-app/src/utils/environments.spec.js
Normal file
149
packages/bruno-app/src/utils/environments.spec.js
Normal 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 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
253
packages/bruno-common/src/utils/datatype.spec.ts
Normal file
253
packages/bruno-common/src/utils/datatype.spec.ts
Normal 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}');
|
||||
});
|
||||
});
|
||||
73
packages/bruno-common/src/utils/datatype.ts
Normal file
73
packages/bruno-common/src/utils/datatype.ts
Normal 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;
|
||||
};
|
||||
@@ -28,3 +28,13 @@ export {
|
||||
jsonToDotenv,
|
||||
DotenvVariable
|
||||
} from './jsonToDotenv';
|
||||
|
||||
export {
|
||||
parseValueByDataType,
|
||||
getDataTypeFromValue,
|
||||
validateDataTypeValue,
|
||||
valueToString,
|
||||
BrunoVariableDataType,
|
||||
BRUNO_VARIABLE_DATATYPES,
|
||||
isBrunoVariableDataType
|
||||
} from './datatype';
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
module.exports = {
|
||||
presets: [['@babel/preset-env', { targets: { node: 'current' } }]]
|
||||
presets: [
|
||||
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||
'@babel/preset-typescript'
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
transform: {
|
||||
'^.+\\.js$': 'babel-jest'
|
||||
'^.+\\.[jt]s$': 'babel-jest'
|
||||
},
|
||||
setupFiles: ['<rootDir>/jest.setup.js'],
|
||||
transformIgnorePatterns: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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\/.*/,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/*"]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
126
packages/bruno-electron/tests/store/global-environments.spec.js
Normal file
126
packages/bruno-electron/tests/store/global-environments.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -11,6 +11,8 @@ const externalDeps = [
|
||||
'@usebruno/lang',
|
||||
'@usebruno/schema-types',
|
||||
/@usebruno\/schema-types\/.*/,
|
||||
'@usebruno/common',
|
||||
/@usebruno\/common\/.*/,
|
||||
'@opencollection/types',
|
||||
/@opencollection\/types\/.*/,
|
||||
// Runtime dependencies
|
||||
|
||||
@@ -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: {
|
||||
|
||||
110
packages/bruno-filestore/src/formats/yml/common/datatype.spec.ts
Normal file
110
packages/bruno-filestore/src/formats/yml/common/datatype.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
57
packages/bruno-filestore/src/formats/yml/common/datatype.ts
Normal file
57
packages/bruno-filestore/src/formats/yml/common/datatype.ts
Normal 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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
52
packages/bruno-filestore/src/formats/yml/parseFolder.spec.ts
Normal file
52
packages/bruno-filestore/src/formats/yml/parseFolder.spec.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
147
packages/bruno-filestore/src/formats/yml/parseItem.spec.ts
Normal file
147
packages/bruno-filestore/src/formats/yml/parseItem.spec.ts
Normal 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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
4
packages/bruno-filestore/tsconfig.build.json
Normal file
4
packages/bruno-filestore/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "src/**/*.spec.ts", "src/**/*.test.ts", "src/**/tests/**"]
|
||||
}
|
||||
@@ -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/*"]
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@usebruno/common": "0.1.0",
|
||||
"arcsecond": "^5.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -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) === '@') {
|
||||
|
||||
@@ -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) === '@') {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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\')');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@usebruno/common": "0.1.0",
|
||||
"nanoid": "3.3.8",
|
||||
"yup": "^0.32.11"
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
'''
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
'''
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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"}');
|
||||
});
|
||||
}
|
||||
@@ -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"}');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
19
packages/bruno-tests/yml-collection/environments/Local.yml
Normal file
19
packages/bruno-tests/yml-collection/environments/Local.yml
Normal 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"}'
|
||||
19
packages/bruno-tests/yml-collection/environments/Prod.yml
Normal file
19
packages/bruno-tests/yml-collection/environments/Prod.yml
Normal 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
Reference in New Issue
Block a user