mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
36 Commits
exp/postma
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
715b6ecbb0 | ||
|
|
a036396cb8 | ||
|
|
db612679d6 | ||
|
|
ec9a03f208 | ||
|
|
1448fe4b52 | ||
|
|
c957c9371d | ||
|
|
811daec92c | ||
|
|
282b0bbae7 | ||
|
|
68334b362f | ||
|
|
659e6e0293 | ||
|
|
d3337c8e9e | ||
|
|
54488d6d06 | ||
|
|
6d646e3cef | ||
|
|
78af8be59e | ||
|
|
db137da8ed | ||
|
|
cb3f6629bb | ||
|
|
0ba6c3d132 | ||
|
|
1f65387ea8 | ||
|
|
9acfed63c5 | ||
|
|
7bc0c1b967 | ||
|
|
44538be00b | ||
|
|
01e3999631 | ||
|
|
459620170a | ||
|
|
b6e455f41b | ||
|
|
0f017b122c | ||
|
|
aba8e14377 | ||
|
|
46bc0ffce7 | ||
|
|
3080c3e144 | ||
|
|
4a0000e10f | ||
|
|
838e29682d | ||
|
|
266e9ce230 | ||
|
|
977a48dfa7 | ||
|
|
777669ba65 | ||
|
|
ca86824bb9 | ||
|
|
12a45cbd82 | ||
|
|
b1f83f2ab1 |
10
package-lock.json
generated
10
package-lock.json
generated
@@ -11063,6 +11063,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
||||
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||
@@ -33381,6 +33390,7 @@
|
||||
"@usebruno/requests": "^0.1.0",
|
||||
"@usebruno/schema": "0.7.0",
|
||||
"about-window": "^1.15.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"aws4-axios": "^3.3.0",
|
||||
"axios": "^1.8.3",
|
||||
|
||||
@@ -233,10 +233,17 @@ export default class CodeEditor extends React.Component {
|
||||
CodeMirror.signal(this.editor, 'change', this.editor);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = this.props.value;
|
||||
this.editor.setValue(this.props.value);
|
||||
this.editor.setCursor(cursor);
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = this.props.value ?? '';
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const ColorBadge = ({ color, size = 10, showEmptyBorder = true }) => {
|
||||
const ColorBadge = ({ color, size = 10 }) => {
|
||||
const sizeValue = typeof size === 'string' ? size : `${size}px`;
|
||||
const { theme } = useTheme();
|
||||
|
||||
const showBorder = !color && showEmptyBorder;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
width: sizeValue,
|
||||
height: sizeValue,
|
||||
backgroundColor: color || 'transparent',
|
||||
border: showBorder ? '1px solid' : 'none',
|
||||
borderColor: showBorder ? theme.background.surface1 : 'transparent'
|
||||
backgroundColor: color || 'transparent'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -134,15 +134,15 @@ const ColorPicker = ({ color, onChange, icon }) => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2 pt-2">
|
||||
<div className="flex items-center gap-2 mt-2 pt-0.5">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex-shrink-0 cursor-pointer"
|
||||
className="w-5 h-5 rounded-full flex-shrink-0 cursor-pointer"
|
||||
style={{ backgroundColor: customColor }}
|
||||
onClick={() => handleColorSelect(customColor)}
|
||||
title="Custom color"
|
||||
/>
|
||||
<ColorRangePicker
|
||||
className="flex-1"
|
||||
className="flex-1 flex"
|
||||
value={sliderPosition}
|
||||
onChange={handleSliderChange}
|
||||
onMouseUp={handleSliderEnd}
|
||||
|
||||
@@ -4,6 +4,7 @@ const StyledWrapper = styled.div`
|
||||
.hue-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
|
||||
@@ -2,14 +2,14 @@ import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ColorRangePicker = ({ selectedColor, className, value, onChange, colorRange, ...props }) => {
|
||||
return (
|
||||
<StyledWrapper color={selectedColor}>
|
||||
<StyledWrapper color={selectedColor} className={className}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={`hue-slider ${className}`}
|
||||
className="hue-slider"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${colorRange.join(',')})`
|
||||
}}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { flattenItems, isItemARequest, isItemTransientRequest } from 'utils/collections';
|
||||
import filter from 'lodash/filter';
|
||||
import { get } from 'lodash';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
|
||||
const REQUEST_TYPE = {
|
||||
HTTP: 'http',
|
||||
@@ -57,7 +58,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
|
||||
|
||||
const collection = useMemo(() => {
|
||||
return collections?.find((c) => c.uid === collectionUid);
|
||||
}, [collections]);
|
||||
}, [collections, collectionUid]);
|
||||
|
||||
const collectionPresets = useMemo(() => {
|
||||
return get(collection, collection?.draft?.brunoConfig ? 'draft.brunoConfig.presets' : 'brunoConfig.presets', {
|
||||
@@ -103,7 +104,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleCreateGraphQLRequest = useCallback(() => {
|
||||
@@ -130,7 +131,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleCreateWebSocketRequest = useCallback(() => {
|
||||
@@ -149,7 +150,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleCreateGrpcRequest = useCallback(() => {
|
||||
@@ -167,7 +168,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleItemClick = (type) => {
|
||||
|
||||
@@ -129,6 +129,7 @@ const StyledWrapper = styled.div`
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
text-overflow: clip;
|
||||
|
||||
input[type='checkbox'] {
|
||||
vertical-align: baseline;
|
||||
@@ -138,6 +139,9 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.tooltip-mod {
|
||||
max-width: 200px !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections';
|
||||
import { stripEnvVarUid } from 'utils/environments';
|
||||
|
||||
const MIN_H = 35 * 2;
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
@@ -92,6 +93,7 @@ const EnvironmentVariablesTable = ({
|
||||
}, []);
|
||||
|
||||
const prevEnvUidRef = useRef(null);
|
||||
const prevEnvVariablesRef = useRef(environment.variables);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
let _collection = collection ? cloneDeep(collection) : {};
|
||||
@@ -167,11 +169,13 @@ const EnvironmentVariablesTable = ({
|
||||
useEffect(() => {
|
||||
const isMount = !mountedRef.current;
|
||||
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
|
||||
const variablesReloaded = !isMount && !envChanged && prevEnvVariablesRef.current !== environment.variables;
|
||||
|
||||
prevEnvUidRef.current = environment.uid;
|
||||
prevEnvVariablesRef.current = environment.variables;
|
||||
mountedRef.current = true;
|
||||
|
||||
if ((isMount || envChanged) && hasDraftForThisEnv && draft?.variables) {
|
||||
if ((isMount || envChanged || variablesReloaded) && hasDraftForThisEnv && draft?.variables) {
|
||||
formik.setValues([
|
||||
...draft.variables,
|
||||
{
|
||||
@@ -184,16 +188,16 @@ const EnvironmentVariablesTable = ({
|
||||
}
|
||||
]);
|
||||
}
|
||||
}, [environment.uid, hasDraftForThisEnv, draft?.variables]);
|
||||
}, [environment.uid, environment.variables, hasDraftForThisEnv, draft?.variables]);
|
||||
|
||||
const savedValuesJson = useMemo(() => {
|
||||
return JSON.stringify(environment.variables || []);
|
||||
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
|
||||
}, [environment.variables]);
|
||||
|
||||
// Sync modified state
|
||||
useEffect(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const currentValuesJson = JSON.stringify(currentValues);
|
||||
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
setIsModified(hasActualChanges);
|
||||
}, [formik.values, savedValuesJson, setIsModified]);
|
||||
@@ -202,11 +206,11 @@ const EnvironmentVariablesTable = ({
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const currentValuesJson = JSON.stringify(currentValues);
|
||||
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
|
||||
const hasActualChanges = currentValuesJson !== savedValuesJson;
|
||||
|
||||
const existingDraftVariables = hasDraftForThisEnv ? draft?.variables : null;
|
||||
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
|
||||
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables.map(stripEnvVarUid)) : null;
|
||||
|
||||
if (hasActualChanges) {
|
||||
if (currentValuesJson !== existingDraftJson) {
|
||||
@@ -318,7 +322,8 @@ const EnvironmentVariablesTable = ({
|
||||
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const savedValues = environment.variables || [];
|
||||
|
||||
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
|
||||
// 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));
|
||||
if (!hasChanges) {
|
||||
toast.error('No changes to save');
|
||||
return;
|
||||
@@ -441,8 +446,8 @@ const EnvironmentVariablesTable = ({
|
||||
</tr>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
computeItemKey={(index, item) => item.variable.uid}
|
||||
itemContent={(index, { variable, index: actualIndex }) => {
|
||||
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
|
||||
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
@@ -472,7 +477,7 @@ const EnvironmentVariablesTable = ({
|
||||
id={`${actualIndex}.name`}
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
onChange={(e) => handleNameChange(actualIndex, e)}
|
||||
onBlur={() => handleNameBlur(actualIndex)}
|
||||
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
|
||||
|
||||
@@ -46,8 +46,8 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn
|
||||
let importedCount = 0;
|
||||
for (const environment of validEnvironments) {
|
||||
const action = isGlobal
|
||||
? addGlobalEnvironment({ name: environment.name, variables: environment.variables })
|
||||
: importEnvironment({ name: environment.name, variables: environment.variables, collectionUid: collection?.uid });
|
||||
? addGlobalEnvironment({ name: environment.name, variables: environment.variables, color: environment.color })
|
||||
: importEnvironment({ name: environment.name, variables: environment.variables, color: environment.color, collectionUid: collection?.uid });
|
||||
|
||||
await dispatch(action);
|
||||
importedCount++;
|
||||
|
||||
@@ -4,7 +4,14 @@ import Modal from 'components/Modal';
|
||||
import Portal from 'components/Portal';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal }) => {
|
||||
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal, isDotEnv }) => {
|
||||
let settingsLabel = 'collection environment settings';
|
||||
if (isDotEnv) {
|
||||
settingsLabel = '.env file';
|
||||
} else if (isGlobal) {
|
||||
settingsLabel = 'global environment settings';
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
@@ -21,7 +28,7 @@ const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose,
|
||||
<h1 className="ml-2 text-lg font-medium">Hold on...</h1>
|
||||
</div>
|
||||
<div className="font-normal mt-4">
|
||||
You have unsaved changes in {isGlobal ? 'global' : 'collection'} environment settings.
|
||||
You have unsaved changes in {settingsLabel}.
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
|
||||
@@ -217,10 +217,12 @@ const DotEnvFileEditor = ({
|
||||
];
|
||||
formik.resetForm({ values: newValues });
|
||||
setIsModified(false);
|
||||
window.dispatchEvent(new Event('dotenv-save-complete'));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
window.dispatchEvent(new Event('dotenv-save-failed'));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSaving(false);
|
||||
@@ -240,10 +242,12 @@ const DotEnvFileEditor = ({
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
setIsModified(false);
|
||||
window.dispatchEvent(new Event('dotenv-save-complete'));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
window.dispatchEvent(new Event('dotenv-save-failed'));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSaving(false);
|
||||
|
||||
@@ -39,7 +39,7 @@ const EnvironmentListContent = ({
|
||||
data-tooltip-content={env.name}
|
||||
data-tooltip-hidden={env.name?.length < 90}
|
||||
>
|
||||
<ColorBadge color={env.color} size={8} showEmptyBorder={false} />
|
||||
<ColorBadge color={env.color} size={8} />
|
||||
<span className="max-w-100% truncate no-wrap">{env.name}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -135,13 +135,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
};
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid))
|
||||
.then(() => {
|
||||
toast.success('Environment color updated!');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while updating the environment color');
|
||||
});
|
||||
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
createDotEnvFile,
|
||||
deleteDotEnvFile
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import classnames from 'classnames';
|
||||
@@ -72,11 +73,24 @@ const EnvironmentList = ({
|
||||
const envUids = environments ? environments.map((env) => env.uid) : [];
|
||||
const prevEnvUids = usePrevious(envUids);
|
||||
|
||||
const handleDotEnvModifiedChange = useCallback((modified) => {
|
||||
setIsDotEnvModified(modified);
|
||||
if (modified) {
|
||||
dispatch(setEnvironmentsDraft({
|
||||
collectionUid: collection.uid,
|
||||
environmentUid: `dotenv:${selectedDotEnvFile}`,
|
||||
variables: []
|
||||
}));
|
||||
} else {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
}
|
||||
}, [dispatch, collection.uid, selectedDotEnvFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dotEnvFiles.length === 0) {
|
||||
setSelectedDotEnvFile(null);
|
||||
setActiveView('environment');
|
||||
setIsDotEnvModified(false);
|
||||
handleDotEnvModifiedChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -424,7 +438,7 @@ const EnvironmentList = ({
|
||||
dispatch(deleteDotEnvFile(collection.uid, filename))
|
||||
.then(() => {
|
||||
toast.success(`${filename} file deleted!`);
|
||||
setIsDotEnvModified(false);
|
||||
handleDotEnvModifiedChange(false);
|
||||
if (selectedDotEnvFile === filename) {
|
||||
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
|
||||
if (remainingFiles.length > 0) {
|
||||
@@ -467,7 +481,7 @@ const EnvironmentList = ({
|
||||
onSave={handleSaveDotEnv}
|
||||
onSaveRaw={handleSaveDotEnvRaw}
|
||||
isModified={isDotEnvModified}
|
||||
setIsModified={setIsDotEnvModified}
|
||||
setIsModified={handleDotEnvModifiedChange}
|
||||
dotEnvExists={selectedDotEnvData?.exists}
|
||||
viewMode={dotEnvViewMode}
|
||||
collection={collection}
|
||||
|
||||
@@ -8,7 +8,37 @@ import React, { useState } from 'react';
|
||||
import HelpIcon from 'components/Icons/Help';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Help = ({ children, width = 200 }) => {
|
||||
const getPlacementStyles = (placement) => {
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
return {
|
||||
bottom: 'calc(100% + 8px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
};
|
||||
case 'bottom':
|
||||
return {
|
||||
top: 'calc(100% + 8px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
};
|
||||
case 'left':
|
||||
return {
|
||||
top: '50%',
|
||||
right: 'calc(100% + 8px)',
|
||||
transform: 'translateY(-50%)'
|
||||
};
|
||||
case 'right':
|
||||
default:
|
||||
return {
|
||||
top: '50%',
|
||||
left: 'calc(100% + 8px)',
|
||||
transform: 'translateY(-50%)'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Help = ({ children, width = 200, placement = 'right' }) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -24,9 +54,7 @@ const Help = ({ children, width = 200 }) => {
|
||||
<StyledWrapper
|
||||
className="absolute z-50 rounded-md p-3"
|
||||
style={{
|
||||
top: '50%',
|
||||
left: 'calc(100% + 8px)',
|
||||
transform: 'translateY(-50%)',
|
||||
...getPlacementStyles(placement),
|
||||
width: `${width}px`
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -154,10 +154,17 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setOption('readOnly', this.props.readOnly);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = String(this.props.value ?? '');
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
}
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
// If the secret flag has changed, update the editor to reflect the change
|
||||
|
||||
@@ -47,8 +47,8 @@ const General = () => {
|
||||
.test('isNumber', 'Save Delay must be a number', (value) => {
|
||||
return value === undefined || !isNaN(value);
|
||||
})
|
||||
.test('isValidInterval', 'Save Delay must be at least 100ms', (value) => {
|
||||
return value === undefined || Number(value) >= 100;
|
||||
.test('isValidInterval', 'Save Delay must be at least 500ms', (value) => {
|
||||
return value === undefined || Number(value) >= 500;
|
||||
})
|
||||
}).test('intervalRequired', 'Save Delay is required when Auto Save is enabled', (value) => {
|
||||
// If autosave is enabled, interval must be provided
|
||||
|
||||
@@ -156,8 +156,15 @@ export default class QueryEditor extends React.Component {
|
||||
CodeMirror.signal(this.editor, 'change', this.editor);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
this.cachedValue = this.props.value;
|
||||
this.editor.setValue(this.props.value);
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = this.props.value ?? '';
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.theme !== prevProps.theme && this.editor) {
|
||||
|
||||
@@ -58,6 +58,7 @@ const Tags = ({ item, collection }) => {
|
||||
handleRemoveTag={handleRemove}
|
||||
tags={tags}
|
||||
onSave={handleRequestSave}
|
||||
collectionFormat={collection.format}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ErrorBanner from 'ui/ErrorBanner';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ErrorBanner from 'ui/ErrorBanner';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ErrorBanner from 'ui/ErrorBanner';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { hasExampleChanges, findItemInCollection } from 'utils/collections';
|
||||
import ExampleIcon from 'components/Icons/ExampleIcon';
|
||||
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
@@ -236,6 +236,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
{showConfirmEnvironmentClose && tab.type === 'environment-settings' && (
|
||||
<ConfirmCloseEnvironment
|
||||
isGlobal={false}
|
||||
isDotEnv={collection.environmentsDraft?.environmentUid?.startsWith('dotenv:')}
|
||||
onCancel={() => setShowConfirmEnvironmentClose(false)}
|
||||
onCloseWithoutSave={() => {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
@@ -244,7 +245,25 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
}}
|
||||
onSaveAndClose={() => {
|
||||
const draft = collection.environmentsDraft;
|
||||
if (draft?.environmentUid && draft?.variables) {
|
||||
if (draft?.environmentUid?.startsWith('dotenv:')) {
|
||||
const onSuccess = () => {
|
||||
cleanup();
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
setShowConfirmEnvironmentClose(false);
|
||||
};
|
||||
const onFailed = () => {
|
||||
cleanup();
|
||||
setShowConfirmEnvironmentClose(false);
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('dotenv-save-complete', onSuccess);
|
||||
window.removeEventListener('dotenv-save-failed', onFailed);
|
||||
};
|
||||
window.addEventListener('dotenv-save-complete', onSuccess, { once: true });
|
||||
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
|
||||
window.dispatchEvent(new Event('dotenv-save'));
|
||||
} else if (draft?.environmentUid && draft?.variables) {
|
||||
dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid))
|
||||
.then(() => {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
@@ -263,6 +282,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
{showConfirmGlobalEnvironmentClose && tab.type === 'global-environment-settings' && (
|
||||
<ConfirmCloseEnvironment
|
||||
isGlobal={true}
|
||||
isDotEnv={globalEnvironmentDraft?.environmentUid?.startsWith('dotenv:')}
|
||||
onCancel={() => setShowConfirmGlobalEnvironmentClose(false)}
|
||||
onCloseWithoutSave={() => {
|
||||
dispatch(clearGlobalEnvironmentDraft());
|
||||
@@ -271,7 +291,25 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
}}
|
||||
onSaveAndClose={() => {
|
||||
const draft = globalEnvironmentDraft;
|
||||
if (draft?.environmentUid && draft?.variables) {
|
||||
if (draft?.environmentUid?.startsWith('dotenv:')) {
|
||||
const onSuccess = () => {
|
||||
cleanup();
|
||||
dispatch(clearGlobalEnvironmentDraft());
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
setShowConfirmGlobalEnvironmentClose(false);
|
||||
};
|
||||
const onFailed = () => {
|
||||
cleanup();
|
||||
setShowConfirmGlobalEnvironmentClose(false);
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('dotenv-save-complete', onSuccess);
|
||||
window.removeEventListener('dotenv-save-failed', onFailed);
|
||||
};
|
||||
window.addEventListener('dotenv-save-complete', onSuccess, { once: true });
|
||||
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
|
||||
window.dispatchEvent(new Event('dotenv-save'));
|
||||
} else if (draft?.environmentUid && draft?.variables) {
|
||||
dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid }))
|
||||
.then(() => {
|
||||
dispatch(clearGlobalEnvironmentDraft());
|
||||
@@ -474,19 +512,42 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t
|
||||
} catch (err) { }
|
||||
}
|
||||
|
||||
async function handleCloseMultipleTabs(tabs) {
|
||||
const tabUidsToClose = [];
|
||||
|
||||
for (const tab of tabs) {
|
||||
const item = findItemInCollection(collection, tab.uid);
|
||||
if (item && hasRequestChanges(item)) {
|
||||
try {
|
||||
await dispatch(saveRequest(item.uid, collection.uid, true));
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (tab?.uid) {
|
||||
tabUidsToClose.push(tab.uid);
|
||||
}
|
||||
}
|
||||
|
||||
if (tabUidsToClose.length > 0) {
|
||||
dispatch(closeTabs({ tabUids: tabUidsToClose }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseOtherTabs() {
|
||||
const otherTabs = collectionRequestTabs.filter((_, index) => index !== tabIndex);
|
||||
await Promise.all(otherTabs.map((tab) => handleCloseTab(tab.uid)));
|
||||
await handleCloseMultipleTabs(otherTabs);
|
||||
}
|
||||
|
||||
async function handleCloseTabsToTheLeft() {
|
||||
const leftTabs = collectionRequestTabs.filter((_, index) => index < tabIndex);
|
||||
await Promise.all(leftTabs.map((tab) => handleCloseTab(tab.uid)));
|
||||
await handleCloseMultipleTabs(leftTabs);
|
||||
}
|
||||
|
||||
async function handleCloseTabsToTheRight() {
|
||||
const rightTabs = collectionRequestTabs.filter((_, index) => index > tabIndex);
|
||||
await Promise.all(rightTabs.map((tab) => handleCloseTab(tab.uid)));
|
||||
await handleCloseMultipleTabs(rightTabs);
|
||||
}
|
||||
|
||||
function handleCloseSavedTabs() {
|
||||
@@ -497,7 +558,7 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t
|
||||
}
|
||||
|
||||
async function handleCloseAllTabs() {
|
||||
await Promise.all(collectionRequestTabs.map((tab) => handleCloseTab(tab.uid)));
|
||||
await handleCloseMultipleTabs(collectionRequestTabs);
|
||||
}
|
||||
|
||||
const menuItems = useMemo(() => [
|
||||
|
||||
@@ -164,7 +164,7 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
|
||||
if (!items?.length) return;
|
||||
|
||||
items.forEach((item) => {
|
||||
if (isItemARequest(item) && !item.partial) {
|
||||
if (isItemARequest(item) && !item.partial && !item.isTransient) {
|
||||
const relativePath = path.relative(collection.pathname, path.dirname(item.pathname));
|
||||
const folderPath = relativePath !== '.' ? relativePath : '';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSelector, useDispatch } from 'react-redux';
|
||||
import { pluralizeWord } from 'utils/common';
|
||||
import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons';
|
||||
import { clearAllSaveTransientRequestModals } from 'providers/ReduxStore/slices/collections';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
@@ -163,30 +163,17 @@ const StyledWrapper = styled.div`
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.new-folder-content {
|
||||
.new-folder-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.new-folder-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.new-folder-name-input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.new-folder-name-label {
|
||||
font-size: 12px;
|
||||
.new-folder-header-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.new-folder-input-row {
|
||||
@@ -247,13 +234,41 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.new-folder-filesystem-label {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.filesystem-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${(props) => props.theme.requestTabPanel.url.bg};
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.filesystem-input-icon {
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
|
||||
.filesystem-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
.new-folder-toggle-filesystem-btn {
|
||||
|
||||
@@ -3,19 +3,24 @@ import { useSelector, useDispatch } from 'react-redux';
|
||||
import Modal from 'components/Modal';
|
||||
import SearchInput from 'components/SearchInput';
|
||||
import Button from 'ui/Button';
|
||||
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff, IconEdit, IconArrowBackUp } from '@tabler/icons';
|
||||
import PathDisplay from 'components/PathDisplay/index';
|
||||
import Help from 'components/Help';
|
||||
import filter from 'lodash/filter';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import useCollectionFolderTree from 'hooks/useCollectionFolderTree';
|
||||
import { removeSaveTransientRequestModal, deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { newFolder, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import path from 'utils/common/path';
|
||||
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections';
|
||||
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
|
||||
import { itemSchema } from '@usebruno/schema';
|
||||
import { uuid } from 'utils/common';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
|
||||
const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -42,6 +47,8 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [newFolderDirectoryName, setNewFolderDirectoryName] = useState('');
|
||||
const [showFilesystemName, setShowFilesystemName] = useState(false);
|
||||
const [isEditingFolderFilename, setIsEditingFolderFilename] = useState(false);
|
||||
const [pendingFolderNavigation, setPendingFolderNavigation] = useState(null);
|
||||
const newFolderInputRef = useRef(null);
|
||||
|
||||
const {
|
||||
@@ -65,6 +72,8 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
setNewFolderName('');
|
||||
setNewFolderDirectoryName('');
|
||||
setShowFilesystemName(false);
|
||||
setIsEditingFolderFilename(false);
|
||||
setPendingFolderNavigation(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,6 +86,17 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
}
|
||||
}, [showNewFolderInput]);
|
||||
|
||||
// Auto-navigate into newly created folder when it appears in currentFolders
|
||||
useEffect(() => {
|
||||
if (pendingFolderNavigation) {
|
||||
const newFolder = currentFolders.find((f) => f.filename === pendingFolderNavigation);
|
||||
if (newFolder) {
|
||||
navigateIntoFolder(newFolder.uid);
|
||||
setPendingFolderNavigation(null);
|
||||
}
|
||||
}
|
||||
}, [currentFolders, pendingFolderNavigation, navigateIntoFolder]);
|
||||
|
||||
const filteredFolders = useMemo(() => {
|
||||
if (!searchText.trim()) {
|
||||
return currentFolders;
|
||||
@@ -107,6 +127,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateName(trimmedName)) {
|
||||
toast.error(validateNameError(trimmedName));
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedFilename = sanitizeName(trimmedName);
|
||||
|
||||
const itemToSave = latestItem.draft ? { ...latestItem, ...latestItem.draft } : { ...latestItem };
|
||||
@@ -118,6 +143,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
|
||||
const format = collection.format || DEFAULT_COLLECTION_FORMAT;
|
||||
const targetFilename = resolveRequestFilename(sanitizedFilename, format);
|
||||
const targetPathname = path.join(targetDirname, targetFilename);
|
||||
|
||||
await ipcRenderer.invoke('renderer:save-transient-request', {
|
||||
sourcePathname: item.pathname,
|
||||
@@ -127,12 +153,19 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
format
|
||||
});
|
||||
|
||||
// Add task to open the newly saved request when file watcher detects it
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: [item.uid]
|
||||
insertTaskIntoQueue({
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid: collection.uid,
|
||||
itemPathname: targetPathname,
|
||||
preview: false
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(closeTabs({ tabUids: [item.uid] }));
|
||||
|
||||
dispatch({
|
||||
type: 'collections/deleteItem',
|
||||
payload: {
|
||||
@@ -144,7 +177,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
toast.success('Request saved successfully');
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
toast.error(err?.message || 'Failed to save request');
|
||||
toast.error(formatIpcError(err) || 'Failed to save request');
|
||||
console.error('Error saving request:', err);
|
||||
}
|
||||
};
|
||||
@@ -154,6 +187,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
setNewFolderName('');
|
||||
setNewFolderDirectoryName('');
|
||||
setShowFilesystemName(false);
|
||||
setIsEditingFolderFilename(false);
|
||||
};
|
||||
|
||||
const handleCancelNewFolder = () => {
|
||||
@@ -161,26 +195,38 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
setNewFolderName('');
|
||||
setNewFolderDirectoryName('');
|
||||
setShowFilesystemName(false);
|
||||
setIsEditingFolderFilename(false);
|
||||
};
|
||||
|
||||
const handleNewFolderNameChange = (value) => {
|
||||
setNewFolderName(value);
|
||||
if (!showFilesystemName) {
|
||||
if (!isEditingFolderFilename) {
|
||||
setNewFolderDirectoryName(sanitizeName(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDirectoryNameChange = (value) => {
|
||||
setNewFolderDirectoryName(value);
|
||||
};
|
||||
|
||||
const handleCreateNewFolder = async () => {
|
||||
const directoryName = newFolderDirectoryName.trim() || sanitizeName(newFolderName.trim());
|
||||
const trimmedFolderName = newFolderName.trim();
|
||||
|
||||
if (!trimmedFolderName) {
|
||||
toast.error('Folder name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateName(trimmedFolderName)) {
|
||||
toast.error(validateNameError(trimmedFolderName));
|
||||
return;
|
||||
}
|
||||
|
||||
const directoryName = newFolderDirectoryName.trim() || sanitizeName(trimmedFolderName);
|
||||
const parentFolder = getCurrentParentFolder();
|
||||
|
||||
try {
|
||||
await dispatch(newFolder(newFolderName.trim(), directoryName, collection?.uid, parentFolder?.uid));
|
||||
await dispatch(newFolder(trimmedFolderName, directoryName, collection?.uid, parentFolder?.uid));
|
||||
toast.success('New folder created!');
|
||||
|
||||
// Set pending navigation - useEffect will navigate when folder appears in state
|
||||
setPendingFolderNavigation(directoryName);
|
||||
handleCancelNewFolder();
|
||||
} catch (err) {
|
||||
const errorMessage = err?.message || 'An error occurred while adding the folder';
|
||||
@@ -286,76 +332,120 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
))}
|
||||
{showNewFolderInput && (
|
||||
<li className="new-folder-item">
|
||||
<div className="new-folder-content">
|
||||
<div className="new-folder-header">
|
||||
<IconFolder size={16} strokeWidth={1.5} />
|
||||
<div className="new-folder-inputs">
|
||||
<div className="new-folder-name-input-wrapper">
|
||||
{showFilesystemName && (
|
||||
<label className="new-folder-name-label">New Folder name (in bruno)</label>
|
||||
<label className="new-folder-header-label">
|
||||
{showFilesystemName ? 'New Folder name (in bruno)' : 'New Folder name'}
|
||||
</label>
|
||||
</div>
|
||||
<div className="new-folder-input-row">
|
||||
<input
|
||||
ref={newFolderInputRef}
|
||||
type="text"
|
||||
className="new-folder-input"
|
||||
placeholder="Untitled new folder"
|
||||
value={newFolderName}
|
||||
onChange={(e) => handleNewFolderNameChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCreateNewFolder();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
handleCancelNewFolder();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="new-folder-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="new-folder-action-btn"
|
||||
onClick={handleCancelNewFolder}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="new-folder-action-btn"
|
||||
onClick={handleCreateNewFolder}
|
||||
title="Create folder"
|
||||
>
|
||||
<IconCheck size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilesystemName && (
|
||||
<div className="new-folder-filesystem-wrapper">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="new-folder-filesystem-label flex items-center font-medium">
|
||||
Folder Name <small className="font-normal text-muted ml-1">(on filesystem)</small>
|
||||
<Help width={300} placement="top">
|
||||
<p>
|
||||
You can choose to save the folder as a different name on your file system versus what is displayed in the app.
|
||||
</p>
|
||||
</Help>
|
||||
</label>
|
||||
{isEditingFolderFilename ? (
|
||||
<IconArrowBackUp
|
||||
className="cursor-pointer opacity-50 hover:opacity-80"
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
onClick={() => setIsEditingFolderFilename(false)}
|
||||
/>
|
||||
) : (
|
||||
<IconEdit
|
||||
className="cursor-pointer opacity-50 hover:opacity-80"
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
onClick={() => setIsEditingFolderFilename(true)}
|
||||
/>
|
||||
)}
|
||||
<div className="new-folder-input-row">
|
||||
</div>
|
||||
{isEditingFolderFilename ? (
|
||||
<div className="relative flex flex-row gap-1 items-center justify-between">
|
||||
<input
|
||||
ref={newFolderInputRef}
|
||||
type="text"
|
||||
className="new-folder-input"
|
||||
placeholder="Untitled new folder"
|
||||
value={newFolderName}
|
||||
onChange={(e) => handleNewFolderNameChange(e.target.value)}
|
||||
className="block textbox mt-2 w-full"
|
||||
placeholder="Folder Name"
|
||||
value={newFolderDirectoryName}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={(e) => setNewFolderDirectoryName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCreateNewFolder();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
handleCancelNewFolder();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="new-folder-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="new-folder-action-btn"
|
||||
onClick={handleCancelNewFolder}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="new-folder-action-btn"
|
||||
onClick={handleCreateNewFolder}
|
||||
title="Create folder"
|
||||
>
|
||||
<IconCheck size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilesystemName && (
|
||||
<div className="new-folder-filesystem-wrapper">
|
||||
<label className="new-folder-filesystem-label">Name on filesystem</label>
|
||||
<input
|
||||
type="text"
|
||||
className="new-folder-input"
|
||||
value={newFolderDirectoryName}
|
||||
onChange={(e) => handleDirectoryNameChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleCreateNewFolder();
|
||||
}
|
||||
}}
|
||||
) : (
|
||||
<div className="relative flex flex-row gap-1 items-center justify-between">
|
||||
<PathDisplay
|
||||
iconType="folder"
|
||||
baseName={newFolderDirectoryName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="new-folder-toggle-filesystem-btn"
|
||||
onClick={() => {
|
||||
setShowFilesystemName(!showFilesystemName);
|
||||
setNewFolderDirectoryName(sanitizeName(newFolderName));
|
||||
setIsEditingFolderFilename(false);
|
||||
}}
|
||||
>
|
||||
{showFilesystemName ? (
|
||||
|
||||
@@ -2,8 +2,7 @@ import React from 'react';
|
||||
import Modal from 'components/Modal';
|
||||
import { isItemAFolder } from 'utils/tabs';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { deleteItem, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { recursivelyGetAllItemUids } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -3,8 +3,7 @@ import Modal from 'components/Modal';
|
||||
import Portal from 'components/Portal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { deleteResponseExample } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
const DeleteResponseExampleModal = ({ onClose, example, item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -4,12 +4,11 @@ import * as Yup from 'yup';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { isItemAFolder } from 'utils/tabs';
|
||||
import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { renameItem, saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import path from 'utils/common/path';
|
||||
import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import Help from 'components/Help';
|
||||
import PathDisplay from 'components/PathDisplay';
|
||||
import Portal from 'components/Portal';
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { IconFileImport } from '@tabler/icons';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { isPostmanCollection } from 'utils/importers/postman-collection';
|
||||
import { isInsomniaCollection } from 'utils/importers/insomnia-collection';
|
||||
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
|
||||
import { isWSDLCollection } from 'utils/importers/wsdl-collection';
|
||||
import { isBrunoCollection } from 'utils/importers/bruno-collection';
|
||||
import { isOpenCollection } from 'utils/importers/opencollection';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const convertFileToObject = async (file) => {
|
||||
const text = await file.text();
|
||||
|
||||
// Handle WSDL files - return as plain text
|
||||
if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') {
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
if (file.type === 'application/json' || file.name.endsWith('.json')) {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
const parsed = jsyaml.load(text);
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error();
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new Error('Failed to parse the file – ensure it is valid JSON or YAML');
|
||||
}
|
||||
};
|
||||
|
||||
const FileTab = ({
|
||||
setIsLoading,
|
||||
handleSubmit,
|
||||
setErrorMessage
|
||||
}) => {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const acceptedFileTypes = [
|
||||
'.json',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'.wsdl',
|
||||
'.zip',
|
||||
'application/json',
|
||||
'application/yaml',
|
||||
'application/x-yaml',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'text/xml',
|
||||
'application/xml'
|
||||
];
|
||||
|
||||
const handleDrag = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setDragActive(true);
|
||||
} else if (e.type === 'dragleave') {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processZipFile = async (zipFile) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const filePath = window.ipcRenderer.getFilePath(zipFile);
|
||||
const isBrunoZip = await window.ipcRenderer.invoke('renderer:is-bruno-collection-zip', filePath);
|
||||
|
||||
if (isBrunoZip) {
|
||||
const collectionName = zipFile.name.replace(/\.zip$/i, '');
|
||||
await handleSubmit({ rawData: { zipFilePath: filePath, collectionName }, type: 'bruno-zip' });
|
||||
return;
|
||||
}
|
||||
|
||||
toastError(new Error('The ZIP file is not a valid Bruno collection'));
|
||||
} catch (err) {
|
||||
toastError(err, 'Import ZIP file failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processFile = async (file) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await convertFileToObject(file);
|
||||
|
||||
if (!data) {
|
||||
throw new Error('Failed to parse file content');
|
||||
}
|
||||
|
||||
let type = null;
|
||||
|
||||
if (isOpenApiSpec(data)) {
|
||||
type = 'openapi';
|
||||
} else if (isWSDLCollection(data)) {
|
||||
type = 'wsdl';
|
||||
} else if (isPostmanCollection(data)) {
|
||||
type = 'postman';
|
||||
} else if (isInsomniaCollection(data)) {
|
||||
type = 'insomnia';
|
||||
} else if (isOpenCollection(data)) {
|
||||
type = 'opencollection';
|
||||
} else if (isBrunoCollection(data)) {
|
||||
type = 'bruno';
|
||||
} else {
|
||||
throw new Error('Unsupported collection format');
|
||||
}
|
||||
|
||||
await handleSubmit({ rawData: data, type });
|
||||
} catch (err) {
|
||||
toastError(err, 'Import collection failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processFiles = async (files) => {
|
||||
setErrorMessage('');
|
||||
|
||||
const fileArray = Array.from(files);
|
||||
const zipFiles = fileArray.filter((file) => file.name.endsWith('.zip'));
|
||||
|
||||
// If both ZIP and non-ZIP files are selected, show error
|
||||
if (zipFiles.length && (fileArray.length - zipFiles.length > 0)) {
|
||||
setErrorMessage('Cannot mix ZIP files with other file types. Please select either a single ZIP file OR collection files (JSON/YAML)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (zipFiles.length > 1) {
|
||||
setErrorMessage('Multiple ZIP files selected. Please select only one ZIP file at a time for import.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (zipFiles.length) {
|
||||
await processZipFile(zipFiles[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileArray.length > 0) {
|
||||
await processFile(fileArray[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
await processFiles(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseFiles = () => {
|
||||
setErrorMessage('');
|
||||
fileInputRef.current.click();
|
||||
};
|
||||
|
||||
const handleFileInputChange = async (e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
await processFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
|
||||
${dragActive
|
||||
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<IconFileImport
|
||||
size={28}
|
||||
className="text-gray-400 dark:text-gray-500 mb-3"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
accept={acceptedFileTypes.join(',')}
|
||||
/>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||
Drop file to import or{' '}
|
||||
<button
|
||||
className="underline cursor-pointer"
|
||||
onClick={handleBrowseFiles}
|
||||
style={{ color: theme.textLink }}
|
||||
>
|
||||
choose a file
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, WSDL, and ZIP formats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileTab;
|
||||
@@ -1,175 +1,53 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { IconFileImport } from '@tabler/icons';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import React, { useState } from 'react';
|
||||
import { IconX } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { isPostmanCollection } from 'utils/importers/postman-collection';
|
||||
import { isInsomniaCollection } from 'utils/importers/insomnia-collection';
|
||||
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
|
||||
import { isWSDLCollection } from 'utils/importers/wsdl-collection';
|
||||
import { isBrunoCollection } from 'utils/importers/bruno-collection';
|
||||
import { isOpenCollection } from 'utils/importers/opencollection';
|
||||
import FileTab from './FileTab';
|
||||
import FullscreenLoader from './FullscreenLoader/index';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const convertFileToObject = async (file) => {
|
||||
const text = await file.text();
|
||||
|
||||
// Handle WSDL files - return as plain text
|
||||
if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') {
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
if (file.type === 'application/json' || file.name.endsWith('.json')) {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
const parsed = jsyaml.load(text);
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error();
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new Error('Failed to parse the file – ensure it is valid JSON or YAML');
|
||||
}
|
||||
};
|
||||
|
||||
const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
const { theme } = useTheme();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleDrag = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setDragActive(true);
|
||||
} else if (e.type === 'dragleave') {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processFile = async (file) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await convertFileToObject(file);
|
||||
|
||||
if (!data) {
|
||||
throw new Error('Failed to parse file content');
|
||||
}
|
||||
|
||||
let type = null;
|
||||
|
||||
if (isOpenApiSpec(data)) {
|
||||
type = 'openapi';
|
||||
} else if (isWSDLCollection(data)) {
|
||||
type = 'wsdl';
|
||||
} else if (isPostmanCollection(data)) {
|
||||
type = 'postman';
|
||||
} else if (isInsomniaCollection(data)) {
|
||||
type = 'insomnia';
|
||||
} else if (isOpenCollection(data)) {
|
||||
type = 'opencollection';
|
||||
} else if (isBrunoCollection(data)) {
|
||||
type = 'bruno';
|
||||
} else {
|
||||
throw new Error('Unsupported collection format');
|
||||
}
|
||||
|
||||
handleSubmit({ rawData: data, type });
|
||||
} catch (err) {
|
||||
toastError(err, 'Import collection failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
await processFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseFiles = () => {
|
||||
fileInputRef.current.click();
|
||||
};
|
||||
|
||||
const handleFileInputChange = async (e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
await processFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
if (isLoading) {
|
||||
return <FullscreenLoader isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
const acceptedFileTypes = [
|
||||
'.json',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'.wsdl',
|
||||
'application/json',
|
||||
'application/yaml',
|
||||
'application/x-yaml',
|
||||
'text/xml',
|
||||
'application/xml'
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-2">Import from file</h3>
|
||||
{errorMessage && (
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
|
||||
${dragActive ? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700'}
|
||||
`}
|
||||
className="mb-4 p-2 border rounded-md"
|
||||
style={{
|
||||
backgroundColor: theme.status?.danger?.background || '#fef2f2',
|
||||
borderColor: theme.status?.danger?.border || '#fecaca'
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<IconFileImport
|
||||
size={28}
|
||||
className="text-gray-400 dark:text-gray-500 mb-3"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
accept={acceptedFileTypes.join(',')}
|
||||
/>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-2">
|
||||
Drop file to import or{' '}
|
||||
<button
|
||||
className="underline cursor-pointer"
|
||||
onClick={handleBrowseFiles}
|
||||
style={{ color: theme.textLink }}
|
||||
>
|
||||
choose a file
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, and WSDL formats
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="text-xs flex-1"
|
||||
style={{ color: theme.status?.danger?.text || '#dc2626' }}
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
<div
|
||||
className="close-button flex items-center cursor-pointer"
|
||||
onClick={() => setErrorMessage('')}
|
||||
style={{ color: theme.status?.danger?.text || '#dc2626' }}
|
||||
>
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileTab
|
||||
setIsLoading={setIsLoading}
|
||||
handleSubmit={handleSubmit}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -43,19 +43,21 @@ const getCollectionName = (format, rawData) => {
|
||||
return rawData.info?.name || 'OpenCollection';
|
||||
case 'wsdl':
|
||||
return 'WSDL Collection';
|
||||
case 'bruno-zip':
|
||||
return rawData.collectionName || 'Bruno Collection';
|
||||
default:
|
||||
return 'Collection';
|
||||
}
|
||||
};
|
||||
|
||||
// Convert raw data to Bruno collection format
|
||||
const convertCollection = async (format, rawData, groupingType) => {
|
||||
const convertCollection = async (format, rawData, groupingType, collectionFormat) => {
|
||||
try {
|
||||
let collection;
|
||||
|
||||
switch (format) {
|
||||
case 'openapi':
|
||||
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType });
|
||||
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType, collectionFormat });
|
||||
break;
|
||||
case 'wsdl':
|
||||
collection = await wsdlToBruno(rawData);
|
||||
@@ -72,6 +74,10 @@ const convertCollection = async (format, rawData, groupingType) => {
|
||||
case 'opencollection':
|
||||
collection = await processOpenCollection(rawData);
|
||||
break;
|
||||
case 'bruno-zip':
|
||||
// ZIP doesn't need conversion
|
||||
collection = rawData;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown collection format');
|
||||
}
|
||||
@@ -96,6 +102,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT);
|
||||
const dropdownTippyRef = useRef();
|
||||
const isOpenApi = format === 'openapi';
|
||||
const isZipImport = format === 'bruno-zip';
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
@@ -120,7 +127,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
.required('Location is required')
|
||||
}),
|
||||
onSubmit: async (values) => {
|
||||
const convertedCollection = await convertCollection(format, rawData, groupingType);
|
||||
const convertedCollection = await convertCollection(format, rawData, groupingType, collectionFormat);
|
||||
handleSubmit(convertedCollection, values.collectionLocation, { format: collectionFormat });
|
||||
}
|
||||
});
|
||||
@@ -159,7 +166,19 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const onSubmit = () => formik.handleSubmit();
|
||||
const onSubmit = async () => {
|
||||
if (isZipImport) {
|
||||
const errors = await formik.validateForm();
|
||||
if (Object.keys(errors).length > 0) {
|
||||
formik.setTouched({ collectionLocation: true });
|
||||
return;
|
||||
}
|
||||
const collectionLocation = formik.values.collectionLocation;
|
||||
handleSubmit(rawData, collectionLocation, { format: collectionFormat, isZipImport: true });
|
||||
} else {
|
||||
formik.handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -212,30 +231,32 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label htmlFor="format" className="flex items-center font-medium">
|
||||
File Format
|
||||
<Help width="300">
|
||||
<p>Choose the file format for storing requests in this collection.</p>
|
||||
<p className="mt-2">
|
||||
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<strong>BRU:</strong> Bruno's native file format (.bru files)
|
||||
</p>
|
||||
</Help>
|
||||
</label>
|
||||
<select
|
||||
id="format"
|
||||
name="format"
|
||||
className="block textbox mt-2 w-full"
|
||||
value={collectionFormat}
|
||||
onChange={(e) => setCollectionFormat(e.target.value)}
|
||||
>
|
||||
<option value="yml">OpenCollection (YAML)</option>
|
||||
<option value="bru">BRU Format (.bru)</option>
|
||||
</select>
|
||||
</div>
|
||||
{!isZipImport && (
|
||||
<div className="mt-4">
|
||||
<label htmlFor="format" className="flex items-center font-medium">
|
||||
File Format
|
||||
<Help width="300">
|
||||
<p>Choose the file format for storing requests in this collection.</p>
|
||||
<p className="mt-2">
|
||||
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<strong>BRU:</strong> Bruno's native file format (.bru files)
|
||||
</p>
|
||||
</Help>
|
||||
</label>
|
||||
<select
|
||||
id="format"
|
||||
name="format"
|
||||
className="block textbox mt-2 w-full"
|
||||
value={collectionFormat}
|
||||
onChange={(e) => setCollectionFormat(e.target.value)}
|
||||
>
|
||||
<option value="yml">OpenCollection (YAML)</option>
|
||||
<option value="bru">BRU Format (.bru)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpenApi && (
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
IconTerminal2
|
||||
} from '@tabler/icons';
|
||||
|
||||
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
|
||||
@@ -52,14 +52,18 @@ const CollectionsSection = () => {
|
||||
);
|
||||
}, [activeWorkspace, collections]);
|
||||
|
||||
const handleImportCollection = ({ rawData, type }) => {
|
||||
const handleImportCollection = ({ rawData, type, ...rest }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
setImportData({ rawData, type });
|
||||
setImportData({ rawData, type, ...rest });
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => {
|
||||
dispatch(importCollection(convertedCollection, collectionLocation, options))
|
||||
const importAction = options.isZipImport
|
||||
? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation)
|
||||
: importCollection(convertedCollection, collectionLocation, options);
|
||||
|
||||
dispatch(importAction)
|
||||
.then(() => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
setImportData(null);
|
||||
|
||||
@@ -169,14 +169,21 @@ class SingleLineEditor extends Component {
|
||||
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value ?? ''));
|
||||
this.editor.setCursor(cursor);
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = String(this.props.value ?? '');
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
|
||||
// Update newline markers after value change
|
||||
if (this.props.showNewlineArrow) {
|
||||
this._updateNewlineMarkers();
|
||||
// Update newline markers after value change
|
||||
if (this.props.showNewlineArrow) {
|
||||
this._updateNewlineMarkers();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
|
||||
@@ -4,9 +4,10 @@ import StyledWrapper from './StyledWrapper';
|
||||
import SingleLineEditor from 'components/SingleLineEditor/index';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
|
||||
const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSave, handleValidation }) => {
|
||||
const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSave, handleValidation, collectionFormat }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const tagNameRegex = /^[\w-]+$/;
|
||||
const isBruFormat = collectionFormat === 'bru';
|
||||
const tagNameRegex = isBruFormat ? /^[\w-]+$/ : /^[\w-][\w\s-]*[\w-]$|^[\w-]+$/;
|
||||
const [text, setText] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -16,8 +17,14 @@ const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSav
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!tagNameRegex.test(text)) {
|
||||
setError('Tags must only contain alpha-numeric characters, "-", "_"');
|
||||
setError(isBruFormat
|
||||
? 'Tags in BRU format must only contain alpha-numeric characters, "-", "_".'
|
||||
: 'Tags must only contain alpha-numeric characters, spaces, "-", "_"'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (tags.includes(text)) {
|
||||
@@ -28,7 +35,6 @@ const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSav
|
||||
const error = handleValidation(text);
|
||||
if (error) {
|
||||
setError(error);
|
||||
setText('');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,13 +134,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
};
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
dispatch(updateGlobalEnvironmentColor(environment.uid, color))
|
||||
.then(() => {
|
||||
toast.success('Environment color updated!');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while updating the environment color');
|
||||
});
|
||||
dispatch(updateGlobalEnvironmentColor(environment.uid, color));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ import DotEnvFileDetails from 'components/Environments/DotEnvFileDetails';
|
||||
import ColorBadge from 'components/ColorBadge';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment, setGlobalEnvironmentDraft, clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
|
||||
import {
|
||||
saveWorkspaceDotEnvVariables,
|
||||
saveWorkspaceDotEnvRaw,
|
||||
@@ -72,9 +72,22 @@ const EnvironmentList = ({
|
||||
const envUids = environments ? environments.map((env) => env.uid) : [];
|
||||
const prevEnvUids = usePrevious(envUids);
|
||||
|
||||
const handleDotEnvModifiedChange = useCallback((modified) => {
|
||||
setIsDotEnvModified(modified);
|
||||
if (modified) {
|
||||
dispatch(setGlobalEnvironmentDraft({
|
||||
environmentUid: `dotenv:${selectedDotEnvFile}`,
|
||||
variables: []
|
||||
}));
|
||||
} else {
|
||||
dispatch(clearGlobalEnvironmentDraft());
|
||||
}
|
||||
}, [dispatch, selectedDotEnvFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dotEnvFiles.length === 0) {
|
||||
setSelectedDotEnvFile(null);
|
||||
handleDotEnvModifiedChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -422,7 +435,7 @@ const EnvironmentList = ({
|
||||
dispatch(deleteWorkspaceDotEnvFile(workspace.uid, filename))
|
||||
.then(() => {
|
||||
toast.success(`${filename} file deleted!`);
|
||||
setIsDotEnvModified(false);
|
||||
handleDotEnvModifiedChange(false);
|
||||
if (selectedDotEnvFile === filename) {
|
||||
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
|
||||
if (remainingFiles.length > 0) {
|
||||
@@ -465,7 +478,7 @@ const EnvironmentList = ({
|
||||
onSave={handleSaveDotEnv}
|
||||
onSaveRaw={handleSaveDotEnvRaw}
|
||||
isModified={isDotEnvModified}
|
||||
setIsModified={setIsDotEnvModified}
|
||||
setIsModified={handleDotEnvModifiedChange}
|
||||
dotEnvExists={selectedDotEnvData?.exists}
|
||||
viewMode={dotEnvViewMode}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconPlus, IconFolder, IconDownload } from '@tabler/icons';
|
||||
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
@@ -51,14 +51,18 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
setImportCollectionModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImportCollectionSubmit = ({ rawData, type }) => {
|
||||
const handleImportCollectionSubmit = ({ rawData, type, ...rest }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
setImportData({ rawData, type });
|
||||
setImportData({ rawData, type, ...rest });
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => {
|
||||
dispatch(importCollection(convertedCollection, collectionLocation, options))
|
||||
const importAction = options.isZipImport
|
||||
? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation)
|
||||
: importCollection(convertedCollection, collectionLocation, options);
|
||||
|
||||
dispatch(importAction)
|
||||
.then(() => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
setImportData(null);
|
||||
|
||||
@@ -62,7 +62,6 @@ const SaveRequestsModal = ({ onClose }) => {
|
||||
const requests = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
|
||||
each(requests, (draft) => {
|
||||
requestDrafts.push({
|
||||
type: 'request',
|
||||
...draft,
|
||||
collectionUid: collectionUid
|
||||
});
|
||||
@@ -116,7 +115,7 @@ const SaveRequestsModal = ({ onClose }) => {
|
||||
// Separate drafts by type
|
||||
const collectionDrafts = allDrafts.filter((d) => d.type === 'collection');
|
||||
const folderDrafts = allDrafts.filter((d) => d.type === 'folder');
|
||||
const requestDrafts = allDrafts.filter((d) => d.type === 'request');
|
||||
const requestDrafts = allDrafts.filter((d) => isItemARequest(d));
|
||||
const collectionEnvironmentDrafts = allDrafts.filter((d) => d.type === 'collection-environment');
|
||||
const globalEnvironmentDrafts = allDrafts.filter((d) => d.type === 'global-environment');
|
||||
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
saveRequest,
|
||||
saveCollectionRoot,
|
||||
saveFolderRoot,
|
||||
saveCollectionSettings
|
||||
saveCollectionSettings,
|
||||
closeTabs
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
|
||||
import { addTab, closeTabs, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs';
|
||||
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { getKeyBindingsForActionAllOS } from './keyMappings';
|
||||
|
||||
@@ -3,9 +3,9 @@ import each from 'lodash/each';
|
||||
import filter from 'lodash/filter';
|
||||
import { createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { addTab, closeTabs, closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
|
||||
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid, findItemInCollection, flattenItems } from 'utils/collections/index';
|
||||
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
|
||||
import { taskTypes } from './utils';
|
||||
|
||||
const taskMiddleware = createListenerMiddleware();
|
||||
@@ -29,14 +29,13 @@ taskMiddleware.startListening({
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) {
|
||||
const item = findItemInCollectionByPathname(collection, task.itemPathname);
|
||||
const isTransient = item?.isTransient ?? false;
|
||||
if (item) {
|
||||
listenerApi.dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(item),
|
||||
preview: !isTransient
|
||||
preview: task?.preview ?? true
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -93,39 +92,4 @@ taskMiddleware.startListening({
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* When tabs are closed, check if any of them are transient requests.
|
||||
* If so, delete the temporary files from the filesystem.
|
||||
* Note: If a transient request was saved (moved to permanent location),
|
||||
* the file will already be deleted, which is expected behavior.
|
||||
*/
|
||||
taskMiddleware.startListening({
|
||||
actionCreator: closeTabs,
|
||||
effect: (action, listenerApi) => {
|
||||
const state = listenerApi.getState();
|
||||
const tabUids = action.payload.tabUids || [];
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
each(tabUids, (tabUid) => {
|
||||
const collections = state.collections.collections;
|
||||
|
||||
for (const collection of collections) {
|
||||
const item = findItemInCollection(collection, tabUid);
|
||||
const isTransient = item?.isTransient ?? false;
|
||||
if (item && isTransient) {
|
||||
ipcRenderer
|
||||
.invoke('renderer:delete-item', item.pathname, item.type, collection.pathname)
|
||||
.then(() => {})
|
||||
.catch((err) => {
|
||||
if (err.message && !err.message.includes('does not exist')) {
|
||||
console.error(`Failed to delete transient request file: ${item.pathname}`, err);
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
export default taskMiddleware;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import filter from 'lodash/filter';
|
||||
import brunoClipboard from 'utils/bruno-clipboard';
|
||||
import { addTab, focusTab, closeTabs } from './tabs';
|
||||
import { addTab, focusTab } from './tabs';
|
||||
|
||||
const initialState = {
|
||||
isDragging: false,
|
||||
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
} from './index';
|
||||
|
||||
import { each } from 'lodash';
|
||||
import { closeAllCollectionTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeAllCollectionTabs, closeTabs as _closeTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
|
||||
import { removeCollectionFromWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import { interpolateUrl, parsePathParams, splitOnFirst } from 'utils/url/index';
|
||||
@@ -1338,7 +1338,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
itemPathname: fullName,
|
||||
preview: false
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
@@ -1494,7 +1495,8 @@ export const newGrpcRequest = (params) => (dispatch, getState) => {
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
itemPathname: fullName,
|
||||
preview: false
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
@@ -1621,7 +1623,8 @@ export const newWsRequest = (params) => (dispatch, getState) => {
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
itemPathname: fullName,
|
||||
preview: false
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
@@ -1770,7 +1773,7 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const importEnvironment = ({ name, variables, collectionUid }) => (dispatch, getState) => {
|
||||
export const importEnvironment = ({ name, variables, color, collectionUid }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
@@ -1782,7 +1785,7 @@ export const importEnvironment = ({ name, variables, collectionUid }) => (dispat
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
|
||||
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables, color)
|
||||
.then(
|
||||
dispatch(
|
||||
updateLastAction({
|
||||
@@ -2660,6 +2663,24 @@ export const importCollection = (collection, collectionLocation, options = {}) =
|
||||
});
|
||||
};
|
||||
|
||||
export const importCollectionFromZip = (zipFilePath, collectionLocation) => async (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
|
||||
const collectionPath = await ipcRenderer.invoke('renderer:import-collection-zip', zipFilePath, collectionLocation);
|
||||
|
||||
if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
|
||||
const collectionName = path.basename(collectionPath);
|
||||
await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, {
|
||||
name: collectionName,
|
||||
path: collectionPath
|
||||
});
|
||||
}
|
||||
|
||||
return collectionPath;
|
||||
};
|
||||
|
||||
export const moveCollectionAndPersist
|
||||
= ({ draggedItem, targetItem }) =>
|
||||
(dispatch, getState) => {
|
||||
@@ -2964,3 +2985,47 @@ export const deleteDotEnvFile = (collectionUid, filename = '.env') => (dispatch,
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Close tabs and delete any transient request files from the filesystem.
|
||||
* This thunk wraps the closeTabs reducer to handle transient file cleanup automatically.
|
||||
*/
|
||||
export const closeTabs = ({ tabUids }) => async (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
const collections = state.collections.collections;
|
||||
const tempDirectories = state.collections.tempDirectories || {};
|
||||
|
||||
// Find transient items and group by temp directory before closing tabs
|
||||
const transientByTempDir = {};
|
||||
each(tabUids, (tabUid) => {
|
||||
for (const collection of collections) {
|
||||
const item = findItemInCollection(collection, tabUid);
|
||||
if (item?.isTransient && item.pathname) {
|
||||
const tempDir = tempDirectories[collection.uid];
|
||||
if (tempDir) {
|
||||
if (!transientByTempDir[tempDir]) {
|
||||
transientByTempDir[tempDir] = [];
|
||||
}
|
||||
transientByTempDir[tempDir].push(item.pathname);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Close the tabs first
|
||||
await dispatch(_closeTabs({ tabUids }));
|
||||
|
||||
// Delete transient files after tabs are closed
|
||||
for (const [tempDir, filePaths] of Object.entries(transientByTempDir)) {
|
||||
try {
|
||||
const results = await ipcRenderer.invoke('renderer:delete-transient-requests', filePaths, tempDir);
|
||||
if (results.errors?.length > 0) {
|
||||
console.error('Errors deleting transient files:', results.errors);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete transient request files:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2868,6 +2868,7 @@ export const collectionsSlice = createSlice({
|
||||
const prevEphemerals = (existingEnv.variables || []).filter((v) => v.ephemeral);
|
||||
existingEnv.name = environment.name;
|
||||
existingEnv.variables = environment.variables;
|
||||
existingEnv.color = environment.color;
|
||||
/*
|
||||
Apply temporary (ephemeral) values only to variables that actually exist in the file. This prevents deleted temporaries from “popping back” after a save. If a variable is present in the file, we temporarily override the UI value while also remembering the on-disk value in persistedValue for future saves.
|
||||
*/
|
||||
|
||||
@@ -18,12 +18,13 @@ export const globalEnvironmentsSlice = createSlice({
|
||||
state.activeGlobalEnvironmentUid = action.payload?.activeGlobalEnvironmentUid;
|
||||
},
|
||||
_addGlobalEnvironment: (state, action) => {
|
||||
const { name, uid, variables = [] } = action.payload;
|
||||
const { name, uid, variables = [], color } = action.payload;
|
||||
if (name?.length) {
|
||||
state.globalEnvironments.push({
|
||||
uid,
|
||||
name,
|
||||
variables
|
||||
variables,
|
||||
color
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -110,7 +111,7 @@ const getWorkspaceContext = (state) => {
|
||||
return { workspaceUid, workspacePath: workspace?.pathname };
|
||||
};
|
||||
|
||||
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
|
||||
export const addGlobalEnvironment = ({ name, variables = [], color }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const uid = uuid();
|
||||
const environment = { name, uid, variables };
|
||||
@@ -120,12 +121,13 @@ export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, get
|
||||
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:create-global-environment', { name, uid, variables, workspaceUid, workspacePath }))
|
||||
.then(() => ipcRenderer.invoke('renderer:create-global-environment', { name, uid, variables, color, workspaceUid, workspacePath }))
|
||||
.then((result) => {
|
||||
const finalUid = result?.uid || uid;
|
||||
const finalName = result?.name || name;
|
||||
const finalVariables = result?.variables || variables;
|
||||
dispatch(_addGlobalEnvironment({ name: finalName, uid: finalUid, variables: finalVariables }));
|
||||
const finalColor = result?.color || color;
|
||||
dispatch(_addGlobalEnvironment({ name: finalName, uid: finalUid, variables: finalVariables, color: finalColor }));
|
||||
return finalUid;
|
||||
})
|
||||
.then((finalUid) => dispatch(selectGlobalEnvironment({ environmentUid: finalUid })))
|
||||
@@ -181,6 +183,13 @@ export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (d
|
||||
.invoke('renderer:get-global-environments', { workspaceUid, workspacePath })
|
||||
.then((data) => {
|
||||
dispatch(updateGlobalEnvironments(data));
|
||||
if (resolvedUid !== environmentUid) {
|
||||
const currentState = getState();
|
||||
const draft = currentState.globalEnvironments.globalEnvironmentDraft;
|
||||
if (draft?.environmentUid === environmentUid) {
|
||||
dispatch(setGlobalEnvironmentDraft({ environmentUid: resolvedUid, variables: draft.variables }));
|
||||
}
|
||||
}
|
||||
return resolvedUid;
|
||||
});
|
||||
})
|
||||
|
||||
@@ -94,7 +94,11 @@ export const tabsSlice = createSlice({
|
||||
state.activeTabUid = uid;
|
||||
},
|
||||
focusTab: (state, action) => {
|
||||
state.activeTabUid = action.payload.uid;
|
||||
const { uid } = action.payload;
|
||||
const tabExists = state.tabs.some((t) => t.uid === uid);
|
||||
if (tabExists) {
|
||||
state.activeTabUid = uid;
|
||||
}
|
||||
},
|
||||
switchTab: (state, action) => {
|
||||
if (!state.tabs || !state.tabs.length) {
|
||||
|
||||
@@ -398,14 +398,14 @@ const lightPastelTheme = {
|
||||
},
|
||||
|
||||
codemirror: {
|
||||
bg: 'transparent',
|
||||
bg: colors.BACKGROUND,
|
||||
border: colors.WHITE,
|
||||
placeholder: {
|
||||
color: colors.GRAY_6,
|
||||
opacity: 0.75
|
||||
},
|
||||
gutter: {
|
||||
bg: 'transparent'
|
||||
bg: colors.BACKGROUND
|
||||
},
|
||||
variable: {
|
||||
valid: colors.GREEN,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as FileSaver from 'file-saver';
|
||||
import get from 'lodash/get';
|
||||
import each from 'lodash/each';
|
||||
import { filterTransientItems } from 'utils/collections';
|
||||
|
||||
export const deleteUidsInItems = (items) => {
|
||||
each(items, (item) => {
|
||||
@@ -101,6 +102,9 @@ export const exportCollection = (collection, version) => {
|
||||
delete collection.processEnvVariables;
|
||||
delete collection.workspaceProcessEnvVariables;
|
||||
|
||||
// filter out transient items
|
||||
collection.items = filterTransientItems(collection.items);
|
||||
|
||||
deleteUidsInItems(collection.items);
|
||||
deleteUidsInEnvs(collection.environments);
|
||||
deleteSecretsInEnvs(collection.environments);
|
||||
|
||||
@@ -294,6 +294,11 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip transient requests
|
||||
if (si.isTransient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isGrpcRequest = si.type === 'grpc-request';
|
||||
|
||||
const di = {
|
||||
@@ -1159,7 +1164,7 @@ const getPathParams = (item) => {
|
||||
export const getTotalRequestCountInCollection = (collection) => {
|
||||
let count = 0;
|
||||
each(collection.items, (item) => {
|
||||
if (isItemARequest(item)) {
|
||||
if (isItemARequest(item) && !item.isTransient) {
|
||||
count++;
|
||||
} else if (isItemAFolder(item)) {
|
||||
count += getTotalRequestCountInCollection(item);
|
||||
@@ -1468,7 +1473,7 @@ export const getRequestItemsForCollectionRun = ({ recursive, items = [], tags })
|
||||
}
|
||||
|
||||
const requestTypes = ['http-request', 'graphql-request'];
|
||||
requestItems = requestItems.filter((request) => requestTypes.includes(request.type));
|
||||
requestItems = requestItems.filter((request) => requestTypes.includes(request.type) && !request.isTransient);
|
||||
|
||||
if (tags && tags.include && tags.exclude) {
|
||||
const includeTags = tags.include ? tags.include : [];
|
||||
@@ -1708,3 +1713,27 @@ export const generateUniqueRequestName = async (collection, baseName = 'Untitled
|
||||
export const isItemTransientRequest = (item) => {
|
||||
return isItemARequest(item) && item?.isTransient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively filter out transient items from a collection's items array.
|
||||
* Used for collection runner, exports, and other operations that shouldn't include transient requests.
|
||||
* @param {Array} items - The items array to filter
|
||||
* @returns {Array} A new array with transient items removed
|
||||
*/
|
||||
export const filterTransientItems = (items) => {
|
||||
if (!items || !Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items
|
||||
.filter((item) => !item?.isTransient)
|
||||
.map((item) => {
|
||||
if (item.items && item.items.length > 0) {
|
||||
return {
|
||||
...item,
|
||||
items: filterTransientItems(item.items)
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -50,3 +50,12 @@ export const buildEnvVariable = ({ envVariable: obj, withUuid = false }) => {
|
||||
...envVariable
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@ export const exportBrunoEnvironment = async ({ environments, environmentType, fi
|
||||
|
||||
let cleanEnvironments = environments.map((environment) => ({
|
||||
name: environment.name,
|
||||
variables: (environment.variables || []).map((envVariable) => buildEnvVariable({ envVariable }))
|
||||
variables: (environment.variables || []).map((envVariable) => buildEnvVariable({ envVariable })),
|
||||
color: environment.color ?? undefined
|
||||
}));
|
||||
|
||||
await ipcRenderer.invoke('renderer:export-environment', {
|
||||
|
||||
@@ -2,8 +2,12 @@ import * as FileSaver from 'file-saver';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { brunoToOpenCollection } from '@usebruno/converters';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import { filterTransientItems } from 'utils/collections';
|
||||
|
||||
export const exportCollection = (collection, version) => {
|
||||
// Filter out transient items before export
|
||||
collection.items = filterTransientItems(collection.items);
|
||||
|
||||
const openCollection = brunoToOpenCollection(collection);
|
||||
|
||||
if (!openCollection.extensions) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import * as FileSaver from 'file-saver';
|
||||
import { brunoToPostman } from '@usebruno/converters';
|
||||
import { filterTransientItems } from 'utils/collections';
|
||||
|
||||
export const exportCollection = (collection) => {
|
||||
// Filter out transient items before export
|
||||
collection.items = filterTransientItems(collection.items);
|
||||
|
||||
const collectionToExport = brunoToPostman(collection);
|
||||
|
||||
const fileName = `${collection.name}.json`;
|
||||
|
||||
@@ -22,7 +22,8 @@ const validateBrunoEnvironment = (env) => {
|
||||
|
||||
return {
|
||||
name: env.name || 'Imported Environment',
|
||||
variables: env.variables.map((envVariable) => buildEnvVariable({ envVariable, withUuid: true }))
|
||||
variables: env.variables.map((envVariable) => buildEnvVariable({ envVariable, withUuid: true })),
|
||||
color: env.color
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -457,7 +457,7 @@ const createCollectionFromBrunoObject = async (collection, dirPath) => {
|
||||
|
||||
// Create collection.bru if root exists
|
||||
if (collection.root) {
|
||||
const collectionContent = await stringifyCollection(collection.root);
|
||||
const collectionContent = await stringifyCollection(collection.root, {}, { format: 'bru' });
|
||||
fs.writeFileSync(path.join(dirPath, 'collection.bru'), collectionContent);
|
||||
}
|
||||
|
||||
@@ -467,7 +467,7 @@ const createCollectionFromBrunoObject = async (collection, dirPath) => {
|
||||
fs.mkdirSync(envDirPath, { recursive: true });
|
||||
|
||||
for (const env of collection.environments) {
|
||||
const content = await stringifyEnvironment(env);
|
||||
const content = await stringifyEnvironment(env, { format: 'bru' });
|
||||
const filename = sanitizeName(`${env.name}.bru`);
|
||||
fs.writeFileSync(path.join(envDirPath, filename), content);
|
||||
}
|
||||
|
||||
@@ -347,6 +347,12 @@ const BODY_TYPE_HANDLERS = [
|
||||
}
|
||||
];
|
||||
|
||||
const getContentLevelExample = (bodyContent) => {
|
||||
if (bodyContent.example !== undefined) return bodyContent.example;
|
||||
const firstExample = Object.values(bodyContent.examples ?? {})[0];
|
||||
return firstExample?.value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts or generates an example value from an OpenAPI schema
|
||||
* Handles objects, arrays, primitives, and explicit examples
|
||||
@@ -488,7 +494,7 @@ const createBrunoExample = ({ brunoRequestItem, exampleValue, exampleName, examp
|
||||
return brunoExample;
|
||||
};
|
||||
|
||||
const transformOpenapiRequestItem = (request, usedNames = new Set()) => {
|
||||
const transformOpenapiRequestItem = (request, usedNames = new Set(), options = {}) => {
|
||||
let _operationObject = request.operationObject;
|
||||
|
||||
let operationName = _operationObject.summary || _operationObject.operationId || _operationObject.description;
|
||||
@@ -524,6 +530,15 @@ const transformOpenapiRequestItem = (request, usedNames = new Set()) => {
|
||||
uid: uuid(),
|
||||
name: operationName,
|
||||
type: 'http-request',
|
||||
tags: [...new Set(
|
||||
(request.operationObject.tags || []).map((tag) => {
|
||||
let sanitized = tag.trim();
|
||||
if (options.collectionFormat !== 'yml') {
|
||||
sanitized = sanitized.replace(/\s+/g, '_');
|
||||
}
|
||||
return sanitized;
|
||||
}).filter((tag) => tag.trim())
|
||||
)],
|
||||
request: {
|
||||
url: ensureUrl(request.global.server + path),
|
||||
method: request.method.toUpperCase(),
|
||||
@@ -733,7 +748,14 @@ const transformOpenapiRequestItem = (request, usedNames = new Set()) => {
|
||||
const content = get(_operationObject, 'requestBody.content', {});
|
||||
const mimeType = Object.keys(content)[0];
|
||||
const bodyContent = content[mimeType] || {};
|
||||
const bodySchema = bodyContent.schema;
|
||||
let bodySchema = bodyContent.schema;
|
||||
|
||||
if (bodySchema?.example === undefined) {
|
||||
const contentExample = getContentLevelExample(bodyContent);
|
||||
if (contentExample !== undefined) {
|
||||
bodySchema = { ...bodySchema, example: contentExample };
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize: lowercase (object keys may vary in case)
|
||||
const normalizedMimeType = typeof mimeType === 'string' ? mimeType.toLowerCase() : '';
|
||||
@@ -1037,7 +1059,7 @@ const groupRequestsByTags = (requests) => {
|
||||
return [groups, ungrouped];
|
||||
};
|
||||
|
||||
const groupRequestsByPath = (requests) => {
|
||||
const groupRequestsByPath = (requests, options = {}) => {
|
||||
const pathGroups = {};
|
||||
|
||||
// Group requests by their path segments
|
||||
@@ -1097,7 +1119,7 @@ const groupRequestsByPath = (requests) => {
|
||||
const buildFolderStructure = (group) => {
|
||||
// Create a new usedNames set for each folder/subfolder scope
|
||||
const localUsedNames = new Set();
|
||||
const items = group.requests.map((req) => transformOpenapiRequestItem(req, localUsedNames));
|
||||
const items = group.requests.map((req) => transformOpenapiRequestItem(req, localUsedNames, options));
|
||||
|
||||
// Add sub-folders
|
||||
const subFolders = [];
|
||||
@@ -1245,7 +1267,7 @@ export const parseOpenApiCollection = (data, options = {}) => {
|
||||
const groupingType = options.groupBy || 'tags';
|
||||
|
||||
if (groupingType === 'path') {
|
||||
brunoCollection.items = groupRequestsByPath(allRequests);
|
||||
brunoCollection.items = groupRequestsByPath(allRequests, options);
|
||||
} else {
|
||||
// Default tag-based grouping
|
||||
let [groups, ungroupedRequests] = groupRequestsByTags(allRequests);
|
||||
@@ -1269,11 +1291,11 @@ export const parseOpenApiCollection = (data, options = {}) => {
|
||||
name: group.name
|
||||
}
|
||||
},
|
||||
items: group.requests.map((req) => transformOpenapiRequestItem(req, usedNames))
|
||||
items: group.requests.map((req) => transformOpenapiRequestItem(req, usedNames, options))
|
||||
};
|
||||
});
|
||||
|
||||
let ungroupedItems = ungroupedRequests.map((req) => transformOpenapiRequestItem(req, usedNames));
|
||||
let ungroupedItems = ungroupedRequests.map((req) => transformOpenapiRequestItem(req, usedNames, options));
|
||||
let brunoCollectionItems = brunoFolders.concat(ungroupedItems);
|
||||
brunoCollection.items = brunoCollectionItems;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const toOpenCollectionConfig = (brunoConfig: BrunoConfig | undefined): Collectio
|
||||
if (brunoConfig.protobuf.importPaths?.length) {
|
||||
config.protobuf.importPaths = brunoConfig.protobuf.importPaths.map((p) => {
|
||||
const importPath: { path: string; disabled?: boolean } = { path: p.path };
|
||||
if (p.disabled) {
|
||||
if (p.enabled === false) {
|
||||
importPath.disabled = true;
|
||||
}
|
||||
return importPath;
|
||||
|
||||
@@ -42,7 +42,8 @@ export const fromOpenCollectionEnvironments = (environments: Environment[] | und
|
||||
enabled: variable.disabled !== true,
|
||||
secret: isSecret
|
||||
};
|
||||
})
|
||||
}),
|
||||
color: env.color || null
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -54,6 +55,7 @@ export const toOpenCollectionEnvironments = (environments: BrunoEnvironment[] |
|
||||
return environments.map((env): Environment => {
|
||||
const ocEnv: Environment = {
|
||||
name: env.name || 'Untitled Environment',
|
||||
color: env.color ?? undefined,
|
||||
variables: (env.variables || []).map((v): OCVariable => {
|
||||
const ocVar: OCVariable = {
|
||||
name: v.name || '',
|
||||
|
||||
@@ -48,7 +48,7 @@ const fromOpenCollectionConfig = (oc: OpenCollection): BrunoConfig => {
|
||||
})),
|
||||
importPaths: config.protobuf.importPaths?.map((p) => ({
|
||||
path: p.path,
|
||||
disabled: p.disabled || false
|
||||
enabled: p.disabled !== true
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ export interface BrunoConfig {
|
||||
};
|
||||
protobuf?: {
|
||||
protoFiles?: { path: string }[];
|
||||
importPaths?: { path: string; disabled?: boolean }[];
|
||||
importPaths?: { path: string; enabled?: boolean }[];
|
||||
};
|
||||
proxy?: {
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -62,6 +62,11 @@ const simpleTranslations = {
|
||||
'req.getHeader': 'pm.request.headers.get',
|
||||
'req.setHeader': 'pm.request.headers.set',
|
||||
|
||||
// URL helper methods
|
||||
'req.getHost': 'pm.request.url.getHost',
|
||||
'req.getPath': 'pm.request.url.getPath',
|
||||
'req.getQueryString': 'pm.request.url.getQueryString',
|
||||
|
||||
// Response helpers
|
||||
// Note: res.getStatus(), res.getResponseTime(), res.getHeaders(), res.getUrl() are handled
|
||||
// in complexTransformations because they're function -> property conversions
|
||||
@@ -202,6 +207,11 @@ const complexTransformations = [
|
||||
pattern: 'req.getAuthMode',
|
||||
transform: () => buildMemberExpressionFromString('pm.request.auth.type')
|
||||
},
|
||||
// req.getPathParams() -> pm.request.url.variables
|
||||
{
|
||||
pattern: 'req.getPathParams',
|
||||
transform: () => buildMemberExpressionFromString('pm.request.url.variables')
|
||||
},
|
||||
|
||||
// Response helpers: function -> property conversions
|
||||
// res.getStatus() -> pm.response.code
|
||||
|
||||
@@ -178,4 +178,29 @@ console.log("Headers:", JSON.stringify(pm.request.headers));
|
||||
const translatedCode = translateBruToPostman(code);
|
||||
expect(translatedCode).toBe('const body = {id: 1}; pm.request.body.update({\n mode: "raw",\n raw: JSON.stringify(body)\n});');
|
||||
});
|
||||
|
||||
// URL helper methods tests
|
||||
it('should translate req.getHost() to pm.request.url.getHost()', () => {
|
||||
const code = 'const host = req.getHost();';
|
||||
const translatedCode = translateBruToPostman(code);
|
||||
expect(translatedCode).toBe('const host = pm.request.url.getHost();');
|
||||
});
|
||||
|
||||
it('should translate req.getPath() to pm.request.url.getPath()', () => {
|
||||
const code = 'const path = req.getPath();';
|
||||
const translatedCode = translateBruToPostman(code);
|
||||
expect(translatedCode).toBe('const path = pm.request.url.getPath();');
|
||||
});
|
||||
|
||||
it('should translate req.getQueryString() to pm.request.url.getQueryString()', () => {
|
||||
const code = 'const queryString = req.getQueryString();';
|
||||
const translatedCode = translateBruToPostman(code);
|
||||
expect(translatedCode).toBe('const queryString = pm.request.url.getQueryString();');
|
||||
});
|
||||
|
||||
it('should translate req.getPathParams() to pm.request.url.variables (function to property)', () => {
|
||||
const code = 'const pathParams = req.getPathParams();';
|
||||
const translatedCode = translateBruToPostman(code);
|
||||
expect(translatedCode).toBe('const pathParams = pm.request.url.variables;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -909,3 +909,223 @@ paths:
|
||||
expect(request.request.body.json).toBe('[]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('content-level example vs examples priority', () => {
|
||||
it('should prefer singular example over examples (plural) and fall back to examples when example is absent', () => {
|
||||
const spec = `
|
||||
openapi: "3.1.0"
|
||||
info:
|
||||
version: "1.0.0"
|
||||
title: "Test"
|
||||
servers:
|
||||
- url: "https://api.example.com"
|
||||
paths:
|
||||
/both:
|
||||
post:
|
||||
summary: "Both example and examples"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example:
|
||||
name: "from singular"
|
||||
examples:
|
||||
first:
|
||||
value:
|
||||
name: "from plural"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
/only-examples:
|
||||
post:
|
||||
summary: "Only examples plural"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
examples:
|
||||
first:
|
||||
value:
|
||||
name: "from plural"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
/schema-wins:
|
||||
post:
|
||||
summary: "Schema example wins over all"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
example:
|
||||
name: "from schema"
|
||||
example:
|
||||
name: "from content"
|
||||
examples:
|
||||
first:
|
||||
value:
|
||||
name: "from plural"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
`;
|
||||
const result = openApiToBruno(spec);
|
||||
|
||||
// When both example and examples exist, singular example wins
|
||||
const bothBody = JSON.parse(result.items.find((i) => i.name === 'Both example and examples').request.body.json);
|
||||
expect(bothBody.name).toBe('from singular');
|
||||
|
||||
// When only examples exists, it is used as fallback
|
||||
const pluralBody = JSON.parse(result.items.find((i) => i.name === 'Only examples plural').request.body.json);
|
||||
expect(pluralBody.name).toBe('from plural');
|
||||
|
||||
// schema.example priority over both content-level example and examples
|
||||
const schemaBody = JSON.parse(result.items.find((i) => i.name === 'Schema example wins over all').request.body.json);
|
||||
expect(schemaBody.name).toBe('from schema');
|
||||
});
|
||||
});
|
||||
|
||||
describe('content-level example values for each body type', () => {
|
||||
const spec = `
|
||||
openapi: "3.1.0"
|
||||
info:
|
||||
version: "1.0.0"
|
||||
title: "Test"
|
||||
servers:
|
||||
- url: "https://api.example.com"
|
||||
paths:
|
||||
/json:
|
||||
post:
|
||||
summary: "JSON body"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example:
|
||||
name: "json example"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
/xml:
|
||||
post:
|
||||
summary: "XML body"
|
||||
requestBody:
|
||||
content:
|
||||
application/xml:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example:
|
||||
name: "xml example"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
/text:
|
||||
post:
|
||||
summary: "Text body"
|
||||
requestBody:
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
example: "plain text example"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
/form:
|
||||
post:
|
||||
summary: "Form body"
|
||||
requestBody:
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
example:
|
||||
username: "form_user"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
/multipart:
|
||||
post:
|
||||
summary: "Multipart body"
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
desc:
|
||||
type: string
|
||||
example:
|
||||
desc: "multipart desc"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
/sparql:
|
||||
post:
|
||||
summary: "SPARQL body"
|
||||
requestBody:
|
||||
content:
|
||||
application/sparql-query:
|
||||
schema:
|
||||
type: string
|
||||
example: "SELECT * WHERE { ?s ?p ?o }"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
`;
|
||||
|
||||
it('should import content-level example for JSON body', () => {
|
||||
const result = openApiToBruno(spec);
|
||||
const body = JSON.parse(result.items.find((i) => i.name === 'JSON body').request.body.json);
|
||||
expect(body.name).toBe('json example');
|
||||
});
|
||||
|
||||
it('should import content-level example for XML body', () => {
|
||||
const result = openApiToBruno(spec);
|
||||
const xml = result.items.find((i) => i.name === 'XML body').request.body.xml;
|
||||
expect(xml).toContain('<name>xml example</name>');
|
||||
});
|
||||
|
||||
it('should import content-level example for text/plain body', () => {
|
||||
const result = openApiToBruno(spec);
|
||||
const text = result.items.find((i) => i.name === 'Text body').request.body.text;
|
||||
expect(text).toBe('plain text example');
|
||||
});
|
||||
|
||||
it('should import content-level example for form-urlencoded body', () => {
|
||||
const result = openApiToBruno(spec);
|
||||
const field = result.items.find((i) => i.name === 'Form body').request.body.formUrlEncoded.find((f) => f.name === 'username');
|
||||
expect(field.value).toBe('form_user');
|
||||
});
|
||||
|
||||
it('should import content-level example for multipart body', () => {
|
||||
const result = openApiToBruno(spec);
|
||||
const field = result.items.find((i) => i.name === 'Multipart body').request.body.multipartForm.find((f) => f.name === 'desc');
|
||||
expect(field.value).toBe('multipart desc');
|
||||
});
|
||||
|
||||
it('should import content-level example for SPARQL body', () => {
|
||||
const result = openApiToBruno(spec);
|
||||
const sparql = result.items.find((i) => i.name === 'SPARQL body').request.body.sparql;
|
||||
expect(sparql).toBe('SELECT * WHERE { ?s ?p ?o }');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@usebruno/requests": "^0.1.0",
|
||||
"@usebruno/schema": "0.7.0",
|
||||
"about-window": "^1.15.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"aws4-axios": "^3.3.0",
|
||||
"axios": "^1.8.3",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
|
||||
@@ -4,6 +4,8 @@ const fsExtra = require('fs-extra');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const archiver = require('archiver');
|
||||
const extractZip = require('extract-zip');
|
||||
const AdmZip = require('adm-zip');
|
||||
const { ipcMain, shell, dialog, app } = require('electron');
|
||||
const {
|
||||
parseRequest,
|
||||
@@ -421,9 +423,6 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
// Step 3: Create new file at target location with the content
|
||||
await writeFile(targetPathname, fileContent);
|
||||
|
||||
// Step 4: Delete the old temp file
|
||||
await removePath(sourcePathname);
|
||||
|
||||
// Return the new pathname (file watcher will handle adding to Redux)
|
||||
return { newPathname: targetPathname };
|
||||
} catch (error) {
|
||||
@@ -516,7 +515,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
});
|
||||
|
||||
// create environment
|
||||
ipcMain.handle('renderer:create-environment', async (event, collectionPathname, name, variables) => {
|
||||
ipcMain.handle('renderer:create-environment', async (event, collectionPathname, name, variables, color) => {
|
||||
try {
|
||||
const envDirPath = path.join(collectionPathname, 'environments');
|
||||
if (!fs.existsSync(envDirPath)) {
|
||||
@@ -539,7 +538,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
|
||||
const environment = {
|
||||
name: uniqueName,
|
||||
variables: variables || []
|
||||
variables: variables || [],
|
||||
color
|
||||
};
|
||||
|
||||
if (envHasSecrets(environment)) {
|
||||
@@ -597,6 +597,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
throw new Error(`environment: ${newEnvFilePath} already exists`);
|
||||
}
|
||||
|
||||
moveRequestUid(envFilePath, newEnvFilePath);
|
||||
fs.renameSync(envFilePath, newEnvFilePath);
|
||||
|
||||
environmentSecretsStore.renameEnvironment(collectionPathname, environmentName, newName);
|
||||
@@ -748,6 +749,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
const environmentWithInfo = (environment) => ({
|
||||
name: environment.name,
|
||||
variables: environment.variables,
|
||||
color: environment.color ?? undefined,
|
||||
info: {
|
||||
type: 'bruno-environment',
|
||||
exportedAt: new Date().toISOString(),
|
||||
@@ -1001,6 +1003,46 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete transient request files by their absolute paths
|
||||
// This is a simpler handler specifically for cleaning up transient requests
|
||||
// tempDirectory: the collection's temp directory path to validate files belong to this collection
|
||||
ipcMain.handle('renderer:delete-transient-requests', async (event, filePaths, tempDirectory) => {
|
||||
const brunoTempPrefix = path.join(os.tmpdir(), 'bruno-');
|
||||
const results = { deleted: [], skipped: [], errors: [] };
|
||||
|
||||
// Validate tempDirectory is within Bruno temp prefix
|
||||
const normalizedTempDir = tempDirectory ? path.normalize(tempDirectory) : null;
|
||||
if (!normalizedTempDir || !normalizedTempDir.startsWith(brunoTempPrefix)) {
|
||||
return { deleted: [], skipped: filePaths.map((p) => ({ path: p, reason: 'Invalid temp directory' })), errors: [] };
|
||||
}
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
try {
|
||||
// Safety check: only delete files within the collection's temp directory
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
if (!normalizedPath.startsWith(normalizedTempDir + path.sep) && normalizedPath !== normalizedTempDir) {
|
||||
results.skipped.push({ path: filePath, reason: 'Not in collection temp directory' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if file exists before trying to delete
|
||||
if (!fs.existsSync(filePath)) {
|
||||
results.skipped.push({ path: filePath, reason: 'File does not exist' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete the file and its UID mapping
|
||||
deleteRequestUid(filePath);
|
||||
fs.unlinkSync(filePath);
|
||||
results.deleted.push(filePath);
|
||||
} catch (error) {
|
||||
results.errors.push({ path: filePath, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:open-collection', async () => {
|
||||
if (watcher && mainWindow) {
|
||||
await openCollectionDialog(mainWindow, watcher);
|
||||
@@ -2013,6 +2055,142 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:is-bruno-collection-zip', async (event, zipFilePath) => {
|
||||
try {
|
||||
const zip = new AdmZip(zipFilePath);
|
||||
const entries = zip.getEntries().map((e) => e.entryName);
|
||||
|
||||
return entries.some(
|
||||
(name) =>
|
||||
name === 'bruno.json'
|
||||
|| name === 'opencollection.yml'
|
||||
|| /^[^/]+\/bruno\.json$/.test(name)
|
||||
|| /^[^/]+\/opencollection\.yml$/.test(name)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:import-collection-zip', async (event, zipFilePath, collectionLocation) => {
|
||||
try {
|
||||
if (!fs.existsSync(zipFilePath)) {
|
||||
throw new Error('ZIP file does not exist');
|
||||
}
|
||||
|
||||
if (!collectionLocation || !fs.existsSync(collectionLocation)) {
|
||||
throw new Error('Collection location does not exist');
|
||||
}
|
||||
|
||||
const tempDir = path.join(os.tmpdir(), `bruno_zip_import_${Date.now()}`);
|
||||
await fsExtra.ensureDir(tempDir);
|
||||
|
||||
// Validates that no symlinks point outside the base directory
|
||||
const validateNoExternalSymlinks = (dir, baseDir) => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const stat = fs.lstatSync(fullPath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
const linkTarget = fs.readlinkSync(fullPath);
|
||||
const resolvedTarget = path.resolve(path.dirname(fullPath), linkTarget);
|
||||
if (!resolvedTarget.startsWith(baseDir + path.sep) && resolvedTarget !== baseDir) {
|
||||
throw new Error(`Security error: Symlink "${entry.name}" points outside extraction directory`);
|
||||
}
|
||||
}
|
||||
|
||||
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
||||
validateNoExternalSymlinks(fullPath, baseDir);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await extractZip(zipFilePath, { dir: tempDir });
|
||||
|
||||
validateNoExternalSymlinks(tempDir, tempDir);
|
||||
|
||||
const extractedItems = fs.readdirSync(tempDir);
|
||||
let collectionDir = tempDir;
|
||||
|
||||
if (extractedItems.length === 1) {
|
||||
const singleItem = path.join(tempDir, extractedItems[0]);
|
||||
const singleItemStat = fs.lstatSync(singleItem);
|
||||
if (singleItemStat.isDirectory() && !singleItemStat.isSymbolicLink()) {
|
||||
collectionDir = singleItem;
|
||||
}
|
||||
}
|
||||
|
||||
const brunoJsonPath = path.join(collectionDir, 'bruno.json');
|
||||
const openCollectionYmlPath = path.join(collectionDir, 'opencollection.yml');
|
||||
|
||||
if (!fs.existsSync(brunoJsonPath) && !fs.existsSync(openCollectionYmlPath)) {
|
||||
throw new Error('Invalid collection: Neither bruno.json nor opencollection.yml found in the ZIP file');
|
||||
}
|
||||
|
||||
// Ensure config files are not symlinks
|
||||
if (fs.existsSync(brunoJsonPath) && fs.lstatSync(brunoJsonPath).isSymbolicLink()) {
|
||||
throw new Error('Security error: bruno.json cannot be a symbolic link');
|
||||
}
|
||||
if (fs.existsSync(openCollectionYmlPath) && fs.lstatSync(openCollectionYmlPath).isSymbolicLink()) {
|
||||
throw new Error('Security error: opencollection.yml cannot be a symbolic link');
|
||||
}
|
||||
|
||||
let collectionName = 'Imported Collection';
|
||||
let brunoConfig = { name: collectionName, version: '1', type: 'collection', ignore: ['node_modules', '.git'] };
|
||||
if (fs.existsSync(openCollectionYmlPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(openCollectionYmlPath, 'utf8');
|
||||
const parsed = parseCollection(content, { format: 'yml' });
|
||||
brunoConfig = parsed.brunoConfig || brunoConfig;
|
||||
collectionName = brunoConfig.name || collectionName;
|
||||
} catch (e) {
|
||||
console.error(`Error parsing opencollection.yml at ${openCollectionYmlPath}:`, e);
|
||||
}
|
||||
} else if (fs.existsSync(brunoJsonPath)) {
|
||||
try {
|
||||
brunoConfig = JSON.parse(fs.readFileSync(brunoJsonPath, 'utf8'));
|
||||
collectionName = brunoConfig.name || collectionName;
|
||||
} catch (e) {
|
||||
console.error(`Error parsing bruno.json at ${brunoJsonPath}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
let sanitizedName = sanitizeName(collectionName);
|
||||
if (!sanitizedName) {
|
||||
sanitizedName = `untitled-${Date.now()}`;
|
||||
}
|
||||
let finalCollectionPath = path.join(collectionLocation, sanitizedName);
|
||||
let counter = 1;
|
||||
while (fs.existsSync(finalCollectionPath)) {
|
||||
finalCollectionPath = path.join(collectionLocation, `${sanitizedName} (${counter})`);
|
||||
counter++;
|
||||
}
|
||||
|
||||
await fsExtra.move(collectionDir, finalCollectionPath);
|
||||
if (tempDir !== collectionDir) {
|
||||
await fsExtra.remove(tempDir).catch(() => {});
|
||||
}
|
||||
|
||||
const uid = generateUidBasedOnHash(finalCollectionPath);
|
||||
const { size, filesCount } = await getCollectionStats(finalCollectionPath);
|
||||
brunoConfig.size = size;
|
||||
brunoConfig.filesCount = filesCount;
|
||||
|
||||
mainWindow.webContents.send('main:collection-opened', finalCollectionPath, uid, brunoConfig);
|
||||
ipcMain.emit('main:collection-opened', mainWindow, finalCollectionPath, uid, brunoConfig);
|
||||
|
||||
return finalCollectionPath;
|
||||
} catch (error) {
|
||||
await fsExtra.remove(tempDir).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const registerMainEventHandlers = (mainWindow, watcher) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ const { globalEnvironmentsStore } = require('../store/global-environments');
|
||||
const { generateUniqueName, sanitizeName, writeFile, isValidDotEnvFilename } = require('../utils/filesystem');
|
||||
|
||||
const registerGlobalEnvironmentsIpc = (mainWindow, workspaceEnvironmentsManager) => {
|
||||
ipcMain.handle('renderer:create-global-environment', async (event, { uid, name, variables, workspaceUid, workspacePath }) => {
|
||||
ipcMain.handle('renderer:create-global-environment', async (event, { uid, name, variables, color, workspaceUid, workspacePath }) => {
|
||||
try {
|
||||
// If workspace path provided, use workspace environments manager
|
||||
if (workspacePath && workspaceEnvironmentsManager) {
|
||||
@@ -16,7 +16,7 @@ const registerGlobalEnvironmentsIpc = (mainWindow, workspaceEnvironmentsManager)
|
||||
const sanitizedName = sanitizeName(name);
|
||||
const uniqueName = generateUniqueName(sanitizedName, (name) => existingNames.includes(name));
|
||||
|
||||
return await workspaceEnvironmentsManager.addGlobalEnvironmentByPath(workspacePath, { uid, name: uniqueName, variables });
|
||||
return await workspaceEnvironmentsManager.addGlobalEnvironmentByPath(workspacePath, { uid, name: uniqueName, variables, color });
|
||||
}
|
||||
|
||||
const existingGlobalEnvironments = globalEnvironmentsStore.getGlobalEnvironments();
|
||||
@@ -25,9 +25,9 @@ const registerGlobalEnvironmentsIpc = (mainWindow, workspaceEnvironmentsManager)
|
||||
const sanitizedName = sanitizeName(name);
|
||||
const uniqueName = generateUniqueName(sanitizedName, (name) => existingNames.includes(name));
|
||||
|
||||
globalEnvironmentsStore.addGlobalEnvironment({ uid, name: uniqueName, variables });
|
||||
globalEnvironmentsStore.addGlobalEnvironment({ uid, name: uniqueName, variables, color });
|
||||
|
||||
return { name: uniqueName };
|
||||
return { name: uniqueName, color };
|
||||
} catch (error) {
|
||||
console.error('Error in renderer:create-global-environment:', error);
|
||||
return Promise.reject(error);
|
||||
|
||||
@@ -207,6 +207,9 @@ const buildCertsAndProxyConfig = async ({
|
||||
const collectionProxyConfig = get(brunoConfig, 'proxy', {});
|
||||
const collectionLevelProxy = interpolateObject(collectionProxyConfig, interpolationOptions);
|
||||
|
||||
// Get app-level proxy config from global preferences
|
||||
const appLevelProxyConfig = preferencesUtil.getGlobalProxyConfig();
|
||||
|
||||
// Get system proxy config
|
||||
const systemProxyConfig = getCachedSystemProxy();
|
||||
|
||||
@@ -215,6 +218,7 @@ const buildCertsAndProxyConfig = async ({
|
||||
options,
|
||||
clientCertificates,
|
||||
collectionLevelProxy,
|
||||
appLevelProxyConfig,
|
||||
systemProxyConfig
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1193,7 +1193,8 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
folderRequests = getAllRequestsInFolderRecursively(sortedFolder);
|
||||
} else {
|
||||
each(folder.items, (item) => {
|
||||
if (item.request) {
|
||||
// Skip transient requests
|
||||
if (item.request && !item.isTransient) {
|
||||
folderRequests.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -211,15 +211,17 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => {
|
||||
|
||||
const specs = workspaceConfig.specs || [];
|
||||
|
||||
const resolvedSpecs = specs.map((spec) => {
|
||||
if (spec.path && !path.isAbsolute(spec.path)) {
|
||||
return {
|
||||
...spec,
|
||||
path: path.join(workspacePath, spec.path)
|
||||
};
|
||||
}
|
||||
return spec;
|
||||
});
|
||||
const resolvedSpecs = specs
|
||||
.map((spec) => {
|
||||
if (spec.path && !path.isAbsolute(spec.path)) {
|
||||
return {
|
||||
...spec,
|
||||
path: path.join(workspacePath, spec.path)
|
||||
};
|
||||
}
|
||||
return spec;
|
||||
})
|
||||
.filter((spec) => spec.path && fs.existsSync(spec.path));
|
||||
|
||||
return resolvedSpecs;
|
||||
} catch (error) {
|
||||
|
||||
@@ -97,7 +97,7 @@ class GlobalEnvironmentsStore {
|
||||
return this.store.set('activeGlobalEnvironmentUid', uid);
|
||||
}
|
||||
|
||||
addGlobalEnvironment({ uid, name, variables = [] }) {
|
||||
addGlobalEnvironment({ uid, name, variables = [], color }) {
|
||||
let globalEnvironments = this.getGlobalEnvironments();
|
||||
const existingEnvironment = globalEnvironments.find((env) => env?.name == name);
|
||||
if (existingEnvironment) {
|
||||
@@ -106,7 +106,8 @@ class GlobalEnvironmentsStore {
|
||||
globalEnvironments.push({
|
||||
uid,
|
||||
name,
|
||||
variables
|
||||
variables,
|
||||
color
|
||||
});
|
||||
this.setGlobalEnvironments(globalEnvironments);
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ class GlobalEnvironmentsManager {
|
||||
});
|
||||
}
|
||||
|
||||
async createGlobalEnvironment(workspacePath, { uid, name, variables }) {
|
||||
async createGlobalEnvironment(workspacePath, { uid, name, variables, color }) {
|
||||
try {
|
||||
if (!workspacePath) {
|
||||
throw new Error('Workspace path is required');
|
||||
@@ -191,7 +191,8 @@ class GlobalEnvironmentsManager {
|
||||
|
||||
const environment = {
|
||||
name: name,
|
||||
variables: variables || []
|
||||
variables: variables || [],
|
||||
color
|
||||
};
|
||||
|
||||
if (this.envHasSecrets(environment)) {
|
||||
@@ -204,7 +205,8 @@ class GlobalEnvironmentsManager {
|
||||
return {
|
||||
uid: generateUidBasedOnHash(environmentFilePath),
|
||||
name,
|
||||
variables
|
||||
variables,
|
||||
color
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
|
||||
@@ -100,13 +100,13 @@ async function importCollection(collection, collectionLocation, mainWindow, uniq
|
||||
let brunoConfig = getBrunoJsonConfig(collection);
|
||||
|
||||
if (format === 'yml') {
|
||||
const collectionContent = await stringifyCollection(collection.root, { format });
|
||||
const collectionContent = await stringifyCollection(collection.root, brunoConfig, { format });
|
||||
await writeFile(path.join(collectionPath, 'opencollection.yml'), collectionContent);
|
||||
} else if (format === 'bru') {
|
||||
const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
|
||||
await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
|
||||
|
||||
const collectionContent = await stringifyCollection(collection.root, { format });
|
||||
const collectionContent = await stringifyCollection(collection.root, brunoConfig, { format });
|
||||
await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
|
||||
} else {
|
||||
throw new Error(`Invalid format: ${format}`);
|
||||
|
||||
@@ -575,6 +575,10 @@ const getAllRequestsInFolderRecursively = (folder = {}) => {
|
||||
|
||||
if (folder.items && folder.items.length) {
|
||||
folder.items.forEach((item) => {
|
||||
// Skip transient requests
|
||||
if (item.isTransient) {
|
||||
return;
|
||||
}
|
||||
if (item.type !== 'folder') {
|
||||
requests.push(item);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,9 @@ const { writeFile, validateName, isValidCollectionDirectory } = require('./files
|
||||
const { generateUidBasedOnHash } = require('./common');
|
||||
const { withLock, getWorkspaceLockKey } = require('./workspace-lock');
|
||||
|
||||
// Normalize Windows backslash paths to forward slashes for cross-platform compatibility.
|
||||
const posixifyPath = (p) => p.replace(/\\/g, '/');
|
||||
|
||||
const WORKSPACE_TYPE = 'workspace';
|
||||
const OPENCOLLECTION_VERSION = '1.0.0';
|
||||
|
||||
@@ -94,7 +97,7 @@ const sanitizeCollections = (collections) => {
|
||||
}).map((collection) => {
|
||||
const sanitized = {
|
||||
name: collection.name.trim(),
|
||||
path: collection.path.trim()
|
||||
path: posixifyPath(collection.path.trim())
|
||||
};
|
||||
|
||||
if (collection.remote && typeof collection.remote === 'string') {
|
||||
@@ -118,23 +121,23 @@ const sanitizeSpecs = (specs) => {
|
||||
return true;
|
||||
}).map((spec) => ({
|
||||
name: spec.name.trim(),
|
||||
path: spec.path.trim()
|
||||
path: posixifyPath(spec.path.trim())
|
||||
}));
|
||||
};
|
||||
|
||||
const makeRelativePath = (workspacePath, absolutePath) => {
|
||||
if (!path.isAbsolute(absolutePath)) {
|
||||
return absolutePath;
|
||||
return posixifyPath(absolutePath);
|
||||
}
|
||||
|
||||
try {
|
||||
const relativePath = path.relative(workspacePath, absolutePath);
|
||||
if (relativePath.startsWith('..') && relativePath.split(path.sep).filter((s) => s === '..').length > 2) {
|
||||
return absolutePath;
|
||||
return posixifyPath(absolutePath);
|
||||
}
|
||||
return relativePath;
|
||||
return posixifyPath(relativePath);
|
||||
} catch (error) {
|
||||
return absolutePath;
|
||||
return posixifyPath(absolutePath);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -335,14 +338,14 @@ const addCollectionToWorkspace = async (workspacePath, collection) => {
|
||||
|
||||
const normalizedCollection = {
|
||||
name: collection.name.trim(),
|
||||
path: collection.path.trim()
|
||||
path: posixifyPath(collection.path.trim())
|
||||
};
|
||||
|
||||
if (collection.remote && typeof collection.remote === 'string') {
|
||||
normalizedCollection.remote = collection.remote.trim();
|
||||
}
|
||||
|
||||
const existingIndex = config.collections.findIndex((c) => c.path === normalizedCollection.path);
|
||||
const existingIndex = config.collections.findIndex((c) => c.path && posixifyPath(c.path) === normalizedCollection.path);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
config.collections[existingIndex] = normalizedCollection;
|
||||
@@ -363,7 +366,7 @@ const removeCollectionFromWorkspace = async (workspacePath, collectionPath) => {
|
||||
let removedCollection = null;
|
||||
|
||||
config.collections = (config.collections || []).filter((c) => {
|
||||
const collectionPathFromYml = c.path;
|
||||
const collectionPathFromYml = c.path ? posixifyPath(c.path) : c.path;
|
||||
|
||||
if (!collectionPathFromYml) {
|
||||
return true;
|
||||
@@ -398,13 +401,14 @@ const getWorkspaceCollections = (workspacePath) => {
|
||||
const seenPaths = new Set();
|
||||
return collections
|
||||
.map((collection) => {
|
||||
if (collection.path && !path.isAbsolute(collection.path)) {
|
||||
const collectionPath = collection.path ? posixifyPath(collection.path) : collection.path;
|
||||
if (collectionPath && !path.isAbsolute(collectionPath)) {
|
||||
return {
|
||||
...collection,
|
||||
path: path.resolve(workspacePath, collection.path)
|
||||
path: path.resolve(workspacePath, collectionPath)
|
||||
};
|
||||
}
|
||||
return collection;
|
||||
return { ...collection, path: collectionPath };
|
||||
})
|
||||
.filter((collection) => {
|
||||
if (!collection.path) {
|
||||
@@ -427,13 +431,14 @@ const getWorkspaceApiSpecs = (workspacePath) => {
|
||||
const specs = config.specs || [];
|
||||
|
||||
return specs.map((spec) => {
|
||||
if (spec.path && !path.isAbsolute(spec.path)) {
|
||||
const specPath = spec.path ? posixifyPath(spec.path) : spec.path;
|
||||
if (specPath && !path.isAbsolute(specPath)) {
|
||||
return {
|
||||
...spec,
|
||||
path: path.join(workspacePath, spec.path)
|
||||
path: path.join(workspacePath, specPath)
|
||||
};
|
||||
}
|
||||
return spec;
|
||||
return { ...spec, path: specPath };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -455,7 +460,7 @@ const addApiSpecToWorkspace = async (workspacePath, apiSpec) => {
|
||||
};
|
||||
|
||||
const existingIndex = config.specs.findIndex(
|
||||
(a) => a.name === normalizedSpec.name || a.path === normalizedSpec.path
|
||||
(a) => a.name === normalizedSpec.name || (a.path && posixifyPath(a.path) === normalizedSpec.path)
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
@@ -481,7 +486,7 @@ const removeApiSpecFromWorkspace = async (workspacePath, apiSpecPath) => {
|
||||
let removedApiSpec = null;
|
||||
|
||||
config.specs = config.specs.filter((a) => {
|
||||
const specPathFromYml = a.path;
|
||||
const specPathFromYml = a.path ? posixifyPath(a.path) : a.path;
|
||||
if (!specPathFromYml) return true;
|
||||
|
||||
const absoluteSpecPath = path.isAbsolute(specPathFromYml)
|
||||
|
||||
@@ -174,8 +174,9 @@ const stringifyHttpRequest = (item: BrunoItem): string => {
|
||||
if (example.response) {
|
||||
ocExample.response = {};
|
||||
|
||||
if (example.response.status !== undefined && example.response.status !== null && isNumber(example.response.status)) {
|
||||
ocExample.response.status = Number(example.response.status);
|
||||
const statusNum = Number(example.response.status);
|
||||
if (Number.isInteger(statusNum) && statusNum > 0) {
|
||||
ocExample.response.status = statusNum;
|
||||
}
|
||||
|
||||
if (isNonEmptyString(example.response.statusText)) {
|
||||
|
||||
@@ -58,12 +58,12 @@ const parseCollection = (ymlString: string): ParsedCollection => {
|
||||
// protobuf
|
||||
if (oc.config?.protobuf) {
|
||||
brunoConfig.protobuf = {
|
||||
protofFiles: oc.config.protobuf.protoFiles?.map((protoFile: any) => ({
|
||||
protoFiles: oc.config.protobuf.protoFiles?.map((protoFile: any) => ({
|
||||
path: protoFile.path
|
||||
})) || [],
|
||||
importPaths: oc.config.protobuf.importPaths?.map((importPath: any) => ({
|
||||
path: importPath.path,
|
||||
disabled: importPath.disabled || false
|
||||
enabled: importPath.disabled !== true
|
||||
})) || []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { Auth } from '@opencollection/types/common/auth';
|
||||
const hasCollectionConfig = (brunoConfig: any): boolean => {
|
||||
// protobuf
|
||||
const hasProtobuf = (
|
||||
brunoConfig.protobuf?.protofFiles?.length > 0
|
||||
brunoConfig.protobuf?.protoFiles?.length > 0
|
||||
|| brunoConfig.protobuf?.importPaths?.length > 0
|
||||
);
|
||||
|
||||
@@ -47,13 +47,16 @@ const hasRequestDefaults = (collectionRoot: any): boolean => {
|
||||
};
|
||||
|
||||
const hasRequestAuth = (collectionRoot: any): boolean => {
|
||||
return Boolean((collectionRoot.request?.auth?.mode !== 'none'));
|
||||
const reqAuthMode = collectionRoot?.request?.auth?.mode;
|
||||
return Boolean(reqAuthMode && reqAuthMode !== 'none');
|
||||
};
|
||||
|
||||
const hasRequestScripts = (collectionRoot: any): boolean => {
|
||||
return (collectionRoot.request?.script?.req)
|
||||
|| (collectionRoot.request?.script?.res)
|
||||
|| (collectionRoot.request?.tests);
|
||||
if (!collectionRoot?.request) return false;
|
||||
|
||||
return (collectionRoot.request.script?.req)
|
||||
|| (collectionRoot.request.script?.res)
|
||||
|| (collectionRoot.request.tests);
|
||||
};
|
||||
|
||||
const hasPresets = (brunoConfig: any): boolean => {
|
||||
@@ -74,17 +77,25 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
|
||||
if (hasCollectionConfig(brunoConfig)) {
|
||||
oc.config = {};
|
||||
|
||||
if (brunoConfig.protobuf?.protofFiles?.length) {
|
||||
oc.config.protobuf = {
|
||||
protoFiles: brunoConfig.protobuf.protofFiles.map((protoFile: any): ProtoFileItem => ({
|
||||
if (brunoConfig.protobuf?.protoFiles?.length || brunoConfig.protobuf?.importPaths?.length) {
|
||||
oc.config.protobuf = {};
|
||||
|
||||
if (brunoConfig.protobuf.protoFiles?.length) {
|
||||
oc.config.protobuf.protoFiles = brunoConfig.protobuf.protoFiles.map((protoFile: any): ProtoFileItem => ({
|
||||
type: 'file' as const,
|
||||
path: protoFile.path
|
||||
})),
|
||||
importPaths: brunoConfig.protobuf.importPaths.map((importPath: any): ProtoFileImportPath => ({
|
||||
path: importPath.path,
|
||||
disabled: importPath.disabled
|
||||
}))
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
if (brunoConfig.protobuf.importPaths?.length) {
|
||||
oc.config.protobuf.importPaths = brunoConfig.protobuf.importPaths.map((importPath: any): ProtoFileImportPath => {
|
||||
const item: ProtoFileImportPath = { path: importPath.path };
|
||||
if (importPath.enabled === false) {
|
||||
item.disabled = true;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// proxy - only write newer format
|
||||
@@ -199,7 +210,7 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
|
||||
}
|
||||
|
||||
// docs
|
||||
if (collectionRoot.docs?.trim().length) {
|
||||
if (collectionRoot?.docs?.trim().length) {
|
||||
oc.docs = {
|
||||
content: collectionRoot.docs,
|
||||
type: 'text/markdown'
|
||||
|
||||
@@ -13,7 +13,12 @@ chai.use(function (chai, utils) {
|
||||
// Custom assertion for checking if a variable is JSON
|
||||
chai.Assertion.addProperty('json', function () {
|
||||
const obj = this._obj;
|
||||
const isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj) && obj.constructor === Object;
|
||||
// Use Object.prototype.toString instead of constructor check for cross-realm compatibility.
|
||||
// Objects created inside Node's vm.createContext() have a different Object constructor,
|
||||
// so obj.constructor === Object fails for objects passed via res.setBody() from scripts.
|
||||
// Note: toString check is more permissive than constructor check — custom class instances
|
||||
const isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj)
|
||||
&& Object.prototype.toString.call(obj) === '[object Object]';
|
||||
|
||||
this.assert(isJson, `expected ${utils.inspect(obj)} to be JSON`, `expected ${utils.inspect(obj)} not to be JSON`);
|
||||
});
|
||||
|
||||
369
packages/bruno-js/src/sandbox/node-vm/cjs-loader.js
Normal file
369
packages/bruno-js/src/sandbox/node-vm/cjs-loader.js
Normal file
@@ -0,0 +1,369 @@
|
||||
const vm = require('node:vm');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const nodeModule = require('node:module');
|
||||
|
||||
const { isBuiltinModule, isPathWithinAllowedRoots } = require('./utils');
|
||||
|
||||
/**
|
||||
* Resolve a local module path, handling files and directories
|
||||
* Follows Node.js resolution algorithm:
|
||||
* 1. Exact path (with extension)
|
||||
* 2. Path + .js extension
|
||||
* 3. Directory with package.json (main field)
|
||||
* 4. Directory with index.js
|
||||
* @param {string} fromDir - Directory to resolve from
|
||||
* @param {string} moduleName - Module name/path
|
||||
* @returns {string} Resolved absolute path
|
||||
*/
|
||||
function resolveLocalModulePath(fromDir, moduleName) {
|
||||
const basePath = path.resolve(fromDir, moduleName);
|
||||
|
||||
// 1. If has extension, use as-is
|
||||
if (path.extname(moduleName)) {
|
||||
return path.normalize(basePath);
|
||||
}
|
||||
|
||||
// 2. Try with .js extension
|
||||
const withJs = basePath + '.js';
|
||||
if (fs.existsSync(withJs)) {
|
||||
return path.normalize(withJs);
|
||||
}
|
||||
|
||||
// 3. Check if it's a directory
|
||||
if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) {
|
||||
// 3a. Check for package.json with main field
|
||||
const pkgPath = path.join(basePath, 'package.json');
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (pkg.main) {
|
||||
const mainPath = path.resolve(basePath, pkg.main);
|
||||
if (fs.existsSync(mainPath)) {
|
||||
return path.normalize(mainPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors, fall through to index.js
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Check for index.js
|
||||
const indexPath = path.join(basePath, 'index.js');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
return path.normalize(indexPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fall back to original path (will likely fail with file not found)
|
||||
return path.normalize(basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a custom require function with enhanced security and local module support
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.collectionPath - Path to the collection directory
|
||||
* @param {Object} options.isolatedContext - The VM isolated context created with vm.createContext()
|
||||
* @param {string} options.currentModuleDir - Current module directory for resolving relative paths
|
||||
* @param {Map} options.localModuleCache - Cache for loaded modules
|
||||
* @param {string[]} options.additionalContextRootsAbsolute - Additional allowed root paths
|
||||
* @returns {Function} Custom require function
|
||||
*/
|
||||
function createCustomRequire({
|
||||
collectionPath,
|
||||
isolatedContext,
|
||||
currentModuleDir = collectionPath,
|
||||
localModuleCache = new Map(),
|
||||
additionalContextRootsAbsolute = []
|
||||
}) {
|
||||
return (moduleName) => {
|
||||
const normalizedModuleName = moduleName.replace(/\\/g, '/');
|
||||
|
||||
// 1. Handle local modules (./path, ../path)
|
||||
if (normalizedModuleName.startsWith('./') || normalizedModuleName.startsWith('../')) {
|
||||
return loadLocalModule({
|
||||
moduleName: normalizedModuleName,
|
||||
collectionPath,
|
||||
isolatedContext,
|
||||
localModuleCache,
|
||||
currentModuleDir,
|
||||
additionalContextRootsAbsolute
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Handle absolute paths - route through local module security checks
|
||||
// This prevents bypassing additionalContextRoots by using absolute paths
|
||||
if (path.isAbsolute(normalizedModuleName)) {
|
||||
return loadLocalModule({
|
||||
moduleName: normalizedModuleName,
|
||||
collectionPath,
|
||||
isolatedContext,
|
||||
localModuleCache,
|
||||
currentModuleDir,
|
||||
additionalContextRootsAbsolute
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Handle Node.js builtin modules
|
||||
// Note: Builtins are loaded via native require, bypassing VM isolation.
|
||||
// This is intentional - [`developer` mode] node-vm isolation need not be strict for builtins.
|
||||
if (isBuiltinModule(moduleName)) {
|
||||
return require(moduleName);
|
||||
}
|
||||
|
||||
// 4. Handle npm modules - load INTO vm context
|
||||
return loadNpmModule({
|
||||
moduleName,
|
||||
collectionPath,
|
||||
isolatedContext,
|
||||
localModuleCache
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a local module from the filesystem with security checks and caching
|
||||
* @param {Object} options - Configuration options
|
||||
* @returns {*} The exported content of the loaded module
|
||||
* @throws {Error} When module is outside collection path or cannot be loaded
|
||||
*/
|
||||
function loadLocalModule({
|
||||
moduleName,
|
||||
collectionPath,
|
||||
isolatedContext,
|
||||
localModuleCache,
|
||||
currentModuleDir,
|
||||
additionalContextRootsAbsolute = []
|
||||
}) {
|
||||
// Validate the raw module name doesn't try to escape allowed roots
|
||||
const preliminaryPath = path.resolve(currentModuleDir, moduleName);
|
||||
if (!isPathWithinAllowedRoots(path.normalize(preliminaryPath), additionalContextRootsAbsolute)) {
|
||||
const allowedRootsDisplay = additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n');
|
||||
throw new Error(
|
||||
`Access to files outside of the allowed context roots is not allowed: ${moduleName}\n\n`
|
||||
+ `Allowed context roots:\n${allowedRootsDisplay}`
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the module path, handling files and directories
|
||||
const normalizedFilePath = resolveLocalModulePath(currentModuleDir, moduleName);
|
||||
|
||||
// Final security check after resolution
|
||||
if (!isPathWithinAllowedRoots(normalizedFilePath, additionalContextRootsAbsolute)) {
|
||||
const allowedRootsDisplay = additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n');
|
||||
throw new Error(
|
||||
`Access to files outside of the allowed context roots is not allowed: ${moduleName}\n\n`
|
||||
+ `Allowed context roots:\n${allowedRootsDisplay}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check cache - we cache moduleObj, return its exports
|
||||
if (localModuleCache.has(normalizedFilePath)) {
|
||||
return localModuleCache.get(normalizedFilePath).exports;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(normalizedFilePath)) {
|
||||
throw new Error(`Cannot find module ${moduleName}`);
|
||||
}
|
||||
|
||||
const moduleCode = fs.readFileSync(normalizedFilePath, 'utf8');
|
||||
const moduleObj = { exports: {} };
|
||||
const moduleDir = path.dirname(normalizedFilePath);
|
||||
|
||||
// Pre-populate cache with moduleObj BEFORE execution to handle circular dependencies
|
||||
// This allows re-entrant requires to get partial exports (Node.js behavior)
|
||||
// We cache moduleObj (not moduleObj.exports) so that module.exports reassignment works
|
||||
localModuleCache.set(normalizedFilePath, moduleObj);
|
||||
|
||||
// Create require function for nested imports
|
||||
const moduleRequire = createCustomRequire({
|
||||
collectionPath,
|
||||
isolatedContext,
|
||||
currentModuleDir: moduleDir,
|
||||
localModuleCache,
|
||||
additionalContextRootsAbsolute
|
||||
});
|
||||
|
||||
try {
|
||||
// Wrap module code in a function that receives CJS parameters
|
||||
const wrappedCode = `(function(module, exports, require, __filename, __dirname) {\n${moduleCode}\n})`;
|
||||
const compiledScript = new vm.Script(wrappedCode, { filename: normalizedFilePath });
|
||||
const moduleFunction = compiledScript.runInContext(isolatedContext);
|
||||
moduleFunction(moduleObj, moduleObj.exports, moduleRequire, normalizedFilePath, moduleDir);
|
||||
return moduleObj.exports;
|
||||
} catch (error) {
|
||||
// Remove failed module from cache to allow retry
|
||||
localModuleCache.delete(normalizedFilePath);
|
||||
throw new Error(`Error loading local module ${moduleName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a module in the VM context with caching and special file handling
|
||||
* @param {Object} options - Configuration options
|
||||
* @returns {*} The exported content of the loaded module
|
||||
* @throws {Error} When module cannot be loaded
|
||||
*/
|
||||
function executeModuleInVmContext({
|
||||
resolvedPath,
|
||||
moduleName,
|
||||
isolatedContext,
|
||||
collectionPath,
|
||||
localModuleCache
|
||||
}) {
|
||||
// Check cache - we cache moduleObj, return its exports
|
||||
if (localModuleCache.has(resolvedPath)) {
|
||||
return localModuleCache.get(resolvedPath).exports;
|
||||
}
|
||||
|
||||
// Native modules (.node files) - fall back to host require
|
||||
// Note: This bypasses VM isolation for native addons.
|
||||
// This is intentional - [`developer` mode] node-vm isolation need not be strict for native modules.
|
||||
if (resolvedPath.endsWith('.node')) {
|
||||
const result = require(resolvedPath);
|
||||
// Wrap in moduleObj format for consistent cache retrieval
|
||||
localModuleCache.set(resolvedPath, { exports: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
// JSON files - parse directly
|
||||
if (resolvedPath.endsWith('.json')) {
|
||||
const jsonContent = fs.readFileSync(resolvedPath, 'utf8');
|
||||
const result = JSON.parse(jsonContent);
|
||||
// Wrap in moduleObj format for consistent cache retrieval
|
||||
localModuleCache.set(resolvedPath, { exports: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
// JavaScript files
|
||||
const moduleSource = fs.readFileSync(resolvedPath, 'utf8');
|
||||
const moduleDir = path.dirname(resolvedPath);
|
||||
const moduleObj = { exports: {} };
|
||||
|
||||
// Pre-populate cache with moduleObj BEFORE execution to handle circular dependencies
|
||||
// This allows re-entrant requires to get partial exports (Node.js behavior)
|
||||
// We cache moduleObj (not moduleObj.exports) so that module.exports reassignment works
|
||||
localModuleCache.set(resolvedPath, moduleObj);
|
||||
|
||||
const moduleRequire = createNpmModuleRequire({
|
||||
collectionPath,
|
||||
isolatedContext,
|
||||
currentModuleDir: moduleDir,
|
||||
localModuleCache
|
||||
});
|
||||
|
||||
try {
|
||||
// Wrap module code in a function that receives CJS parameters
|
||||
const wrappedCode = `(function(module, exports, require, __filename, __dirname) {\n${moduleSource}\n})`;
|
||||
const compiledScript = new vm.Script(wrappedCode, { filename: resolvedPath });
|
||||
const moduleFunction = compiledScript.runInContext(isolatedContext);
|
||||
moduleFunction(moduleObj, moduleObj.exports, moduleRequire, resolvedPath, moduleDir);
|
||||
} catch (error) {
|
||||
// Remove failed module from cache to allow retry
|
||||
localModuleCache.delete(resolvedPath);
|
||||
const stack = error.stack || '';
|
||||
throw new Error(`Error loading module ${moduleName}: ${error.message}\nStack: ${stack}`);
|
||||
}
|
||||
|
||||
return moduleObj.exports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an npm module into the vm context
|
||||
* @param {Object} options - Configuration options
|
||||
* @returns {*} The exported content of the loaded module
|
||||
* @throws {Error} When module cannot be resolved or loaded
|
||||
*/
|
||||
function loadNpmModule({
|
||||
moduleName,
|
||||
collectionPath,
|
||||
isolatedContext,
|
||||
localModuleCache
|
||||
}) {
|
||||
let resolvedPath;
|
||||
|
||||
// Module resolution order:
|
||||
// 1. Collection's node_modules (user-installed packages for their collection)
|
||||
// 2. Bruno's node_modules (fallback for built-in dependencies)
|
||||
//
|
||||
// This order ensures user packages take precedence, allowing users to:
|
||||
// - Override Bruno's bundled package versions
|
||||
// - Install collection-specific dependencies
|
||||
if (collectionPath) {
|
||||
try {
|
||||
const collectionRequire = nodeModule.createRequire(path.join(collectionPath, 'package.json'));
|
||||
resolvedPath = collectionRequire.resolve(moduleName);
|
||||
} catch {
|
||||
// Module not found in collection, continue to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Bruno's node_modules
|
||||
if (!resolvedPath) {
|
||||
try {
|
||||
resolvedPath = require.resolve(moduleName, { paths: module.paths });
|
||||
} catch (mainError) {
|
||||
throw new Error(
|
||||
`Could not resolve module "${moduleName}": ${mainError.message}\n\n`
|
||||
+ `Install it with: npm install ${moduleName}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return executeModuleInVmContext({
|
||||
resolvedPath,
|
||||
moduleName,
|
||||
isolatedContext,
|
||||
collectionPath,
|
||||
localModuleCache
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates require function for npm module dependencies
|
||||
* @param {Object} options - Configuration options
|
||||
* @returns {Function} Custom require function for npm module dependencies
|
||||
*/
|
||||
function createNpmModuleRequire({
|
||||
collectionPath,
|
||||
isolatedContext,
|
||||
currentModuleDir,
|
||||
localModuleCache
|
||||
}) {
|
||||
const moduleRequire = nodeModule.createRequire(path.join(currentModuleDir, 'index.js'));
|
||||
|
||||
return (moduleName) => {
|
||||
// Handle relative imports within npm module
|
||||
if (moduleName.startsWith('./') || moduleName.startsWith('../')) {
|
||||
const resolvedPath = moduleRequire.resolve(moduleName);
|
||||
return executeModuleInVmContext({
|
||||
resolvedPath,
|
||||
moduleName,
|
||||
isolatedContext,
|
||||
collectionPath,
|
||||
localModuleCache
|
||||
});
|
||||
}
|
||||
|
||||
// Handle builtins
|
||||
// Note: Builtins are loaded via native require, bypassing VM isolation.
|
||||
// This is intentional - [`developer` mode] node-vm isolation need not be strict for builtins.
|
||||
if (isBuiltinModule(moduleName)) {
|
||||
return require(moduleName);
|
||||
}
|
||||
|
||||
// Handle npm dependencies - resolve from current module's directory
|
||||
const resolvedPath = moduleRequire.resolve(moduleName);
|
||||
return executeModuleInVmContext({
|
||||
resolvedPath,
|
||||
moduleName,
|
||||
isolatedContext,
|
||||
collectionPath,
|
||||
localModuleCache
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCustomRequire
|
||||
};
|
||||
99
packages/bruno-js/src/sandbox/node-vm/constants.js
Normal file
99
packages/bruno-js/src/sandbox/node-vm/constants.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Constants for the Node.js VM sandbox.
|
||||
*
|
||||
* ECMAScript built-ins (Object, Array, Function, etc.)
|
||||
* are NOT passed from the host. The VM provides its own versions, ensuring
|
||||
* consistent prototype chains for libraries that use introspection.
|
||||
*
|
||||
* Handled separately in index.js:
|
||||
* - global/globalThis: Points to isolated context (not host)
|
||||
* - require: createCustomRequire() (custom module loader)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safe globals to pass from host to VM context.
|
||||
*
|
||||
* ECMAScript built-ins (Object, Array, Function, String, Number,
|
||||
* Boolean, Symbol, Date, RegExp, Map, Set, Promise, JSON, Math,
|
||||
* parseInt, etc.) are intentionally NOT included here.
|
||||
*
|
||||
* The VM context provides its own versions of these, which ensures consistent
|
||||
* prototype chains. Passing host versions causes prototype mismatches.
|
||||
*
|
||||
* Only Node.js-specific and Web APIs that the VM doesn't provide are listed.
|
||||
*/
|
||||
const safeGlobals = [
|
||||
'process',
|
||||
|
||||
// Node.js timers (not part of ECMAScript)
|
||||
'setTimeout',
|
||||
'setInterval',
|
||||
'clearTimeout',
|
||||
'clearInterval',
|
||||
'setImmediate',
|
||||
'clearImmediate',
|
||||
'queueMicrotask',
|
||||
|
||||
// Node.js globals
|
||||
'Buffer',
|
||||
|
||||
// Error types - needed for instanceof checks with errors from host APIs/modules
|
||||
'Error',
|
||||
'TypeError',
|
||||
'ReferenceError',
|
||||
'SyntaxError',
|
||||
'RangeError',
|
||||
'URIError',
|
||||
'EvalError',
|
||||
'AggregateError',
|
||||
|
||||
// URL APIs (WHATWG - not ECMAScript)
|
||||
'URL',
|
||||
'URLSearchParams',
|
||||
|
||||
// Encoding APIs
|
||||
'TextEncoder',
|
||||
'TextDecoder',
|
||||
'atob',
|
||||
'btoa',
|
||||
|
||||
// Fetch API (Node 18+)
|
||||
'fetch',
|
||||
'Request',
|
||||
'Response',
|
||||
'Headers',
|
||||
'FormData',
|
||||
'AbortController',
|
||||
'AbortSignal',
|
||||
'Blob',
|
||||
|
||||
// Streams API
|
||||
'ReadableStream',
|
||||
'WritableStream',
|
||||
'TransformStream',
|
||||
|
||||
// Internationalization (needs host's locale data)
|
||||
'Intl',
|
||||
|
||||
// Web Crypto API
|
||||
'crypto',
|
||||
|
||||
// WebAssembly
|
||||
'WebAssembly',
|
||||
|
||||
// Performance API
|
||||
'performance',
|
||||
|
||||
// Events API
|
||||
'Event',
|
||||
'EventTarget',
|
||||
'CustomEvent',
|
||||
|
||||
// Message passing
|
||||
'MessageChannel',
|
||||
'MessagePort'
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
safeGlobals
|
||||
};
|
||||
@@ -1,42 +1,30 @@
|
||||
const vm = require('node:vm');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { get } = require('lodash');
|
||||
const lodash = require('lodash');
|
||||
const { ScriptError } = require('./utils');
|
||||
const { createCustomRequire } = require('./cjs-loader');
|
||||
const { safeGlobals } = require('./constants');
|
||||
const { mixinTypedArrays } = require('../mixins/typed-arrays');
|
||||
|
||||
class ScriptError extends Error {
|
||||
constructor(error, script) {
|
||||
super(error.message);
|
||||
this.name = 'ScriptError';
|
||||
this.originalError = error;
|
||||
this.script = script;
|
||||
this.stack = error.stack;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a script in a Node.js VM context with enhanced security and module loading
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.script - The script code to execute
|
||||
* @param {Object} options.context - The execution context with Bruno objects
|
||||
* @param {string} options.collectionPath - Path to the collection directory
|
||||
* @param {Object} options.scriptingConfig - Scripting configuration options
|
||||
* @returns {Promise<Object>} Execution results including variables and test results
|
||||
* @returns {Promise<void>}
|
||||
* @throws {ScriptError} When script execution fails
|
||||
*/
|
||||
async function runScriptInNodeVm({
|
||||
script,
|
||||
context,
|
||||
collectionPath,
|
||||
scriptingConfig
|
||||
}) {
|
||||
async function runScriptInNodeVm({ script, context, collectionPath, scriptingConfig }) {
|
||||
if (script.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Compute additional context roots
|
||||
// Compute allowed context roots for security validation
|
||||
const additionalContextRoots = get(scriptingConfig, 'additionalContextRoots', []);
|
||||
const additionalContextRootsAbsolute = lodash
|
||||
.chain(additionalContextRoots)
|
||||
@@ -45,196 +33,67 @@ async function runScriptInNodeVm({
|
||||
.value();
|
||||
additionalContextRootsAbsolute.push(path.normalize(collectionPath));
|
||||
|
||||
// Create script context with all necessary variables
|
||||
const scriptContext = {
|
||||
// Bruno context
|
||||
console: context.console,
|
||||
req: context.req,
|
||||
res: context.res,
|
||||
bru: context.bru,
|
||||
expect: context.expect,
|
||||
assert: context.assert,
|
||||
__brunoTestResults: context.__brunoTestResults,
|
||||
test: context.test,
|
||||
// Configuration for nested module loading
|
||||
scriptingConfig: scriptingConfig,
|
||||
// Global objects
|
||||
Buffer: global.Buffer,
|
||||
process: global.process,
|
||||
setTimeout: global.setTimeout,
|
||||
setInterval: global.setInterval,
|
||||
clearTimeout: global.clearTimeout,
|
||||
clearInterval: global.clearInterval,
|
||||
setImmediate: global.setImmediate,
|
||||
clearImmediate: global.clearImmediate,
|
||||
Error: global.Error,
|
||||
TypeError: global.TypeError,
|
||||
ReferenceError: global.ReferenceError,
|
||||
SyntaxError: global.SyntaxError,
|
||||
RangeError: global.RangeError
|
||||
};
|
||||
// Build the script context with Bruno objects and globals
|
||||
const scriptContext = buildScriptContext(context, scriptingConfig);
|
||||
|
||||
mixinTypedArrays(scriptContext);
|
||||
// Create truly isolated context - scriptContext becomes the global object
|
||||
// Scripts can ONLY access what's explicitly in scriptContext
|
||||
const isolatedContext = vm.createContext(scriptContext);
|
||||
|
||||
// Create shared cache for local modules
|
||||
// Add global/globalThis pointing to the isolated context (not host global)
|
||||
// This allows libraries that reference 'global' to work while maintaining isolation
|
||||
scriptContext.global = scriptContext;
|
||||
scriptContext.globalThis = scriptContext;
|
||||
|
||||
// Create module cache for CJS modules
|
||||
const localModuleCache = new Map();
|
||||
|
||||
// Create a custom require function and add it to the context
|
||||
// Add require() function for CJS module loading
|
||||
scriptContext.require = createCustomRequire({
|
||||
scriptingConfig,
|
||||
collectionPath,
|
||||
scriptContext,
|
||||
isolatedContext,
|
||||
currentModuleDir: collectionPath,
|
||||
localModuleCache,
|
||||
additionalContextRootsAbsolute
|
||||
});
|
||||
|
||||
// Execute the script in an isolated VM context
|
||||
await vm.runInNewContext(`
|
||||
(async function(){
|
||||
${script}
|
||||
})();
|
||||
`, scriptContext, {
|
||||
filename: path.join(collectionPath, 'script.js'),
|
||||
displayErrors: true
|
||||
// Execute the script in the isolated context
|
||||
const wrappedScript = `(async function(){ ${script} \n})();`;
|
||||
const compiledScript = new vm.Script(wrappedScript, {
|
||||
filename: path.join(collectionPath, 'script.js')
|
||||
});
|
||||
|
||||
await compiledScript.runInContext(isolatedContext);
|
||||
} catch (error) {
|
||||
throw new ScriptError(error, script);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a custom require function with enhanced security and local module support
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {Object} options.scriptingConfig - Scripting configuration with additional context roots
|
||||
* @param {string} options.collectionPath - Base collection path for security checks
|
||||
* @param {Object} options.scriptContext - Script execution context
|
||||
* @param {string} options.currentModuleDir - Current module directory for relative imports
|
||||
* @param {Map} options.localModuleCache - Cache for loaded local modules
|
||||
* @param {Array<string>} options.additionalContextRootsAbsolute - Pre-computed absolute context roots
|
||||
* @returns {Function} Custom require function
|
||||
* Build the script context with Bruno objects and necessary globals
|
||||
* @param {Object} context - Bruno context (bru, req, res, etc.)
|
||||
* @param {Object} scriptingConfig - Scripting configuration
|
||||
* @returns {Object} Script context object
|
||||
*/
|
||||
function createCustomRequire({
|
||||
scriptingConfig,
|
||||
collectionPath,
|
||||
scriptContext,
|
||||
currentModuleDir = collectionPath,
|
||||
localModuleCache = new Map(),
|
||||
additionalContextRootsAbsolute = []
|
||||
}) {
|
||||
return (moduleName) => {
|
||||
// Check if it's a local module (starts with ./ or ../ or .\ or ..\)
|
||||
// Normalize backslashes to forward slashes for cross-platform compatibility
|
||||
const normalizedModuleName = moduleName.replace(/\\/g, '/');
|
||||
if (normalizedModuleName.startsWith('./') || normalizedModuleName.startsWith('../')) {
|
||||
return loadLocalModule({ moduleName: normalizedModuleName, collectionPath, scriptContext, localModuleCache, currentModuleDir, additionalContextRootsAbsolute });
|
||||
}
|
||||
function buildScriptContext(context, scriptingConfig) {
|
||||
const scriptContext = {
|
||||
...context,
|
||||
|
||||
// First try to require as a native/npm module
|
||||
try {
|
||||
const requiredModulePath = require.resolve(moduleName, { paths: [...additionalContextRootsAbsolute, ...module.paths] });
|
||||
return require(requiredModulePath);
|
||||
} catch (requireError) {
|
||||
// If that fails, try to resolve from additionalContextRoots
|
||||
throw new Error(`Could not resolve module "${moduleName}": ${requireError.message}\n\nThis most likely means you did not install the module under the collection or the "additionalContextRoots" using a package manager like npm.\n\nThese are your current "additionalContextRoots":\n${additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n') || ' - No "additionalContextRoots" defined'}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
// Configuration for nested module loading
|
||||
scriptingConfig: scriptingConfig,
|
||||
|
||||
/**
|
||||
* Loads a local module from the filesystem with security checks and caching
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.moduleName - Name/path of the module to load
|
||||
* @param {string} options.collectionPath - Base collection path for security validation
|
||||
* @param {Object} options.scriptContext - Script execution context to inherit
|
||||
* @param {Map} options.localModuleCache - Cache for loaded modules
|
||||
* @param {string} options.currentModuleDir - Directory of the current module for relative resolution
|
||||
* @param {Array<string>} options.additionalContextRootsAbsolute - Additional allowed context root paths
|
||||
* @returns {*} The exported content of the loaded module
|
||||
* @throws {Error} When module is outside collection path or cannot be loaded
|
||||
*/
|
||||
function loadLocalModule({
|
||||
moduleName,
|
||||
collectionPath,
|
||||
scriptContext,
|
||||
localModuleCache,
|
||||
currentModuleDir,
|
||||
additionalContextRootsAbsolute = []
|
||||
}) {
|
||||
// Check if the filename has an extension
|
||||
const hasExtension = path.extname(moduleName) !== '';
|
||||
const resolvedFilename = hasExtension ? moduleName : `${moduleName}.js`;
|
||||
|
||||
// Resolve the file path relative to the current module's directory
|
||||
const filePath = path.resolve(currentModuleDir, resolvedFilename);
|
||||
const normalizedFilePath = path.normalize(filePath);
|
||||
|
||||
const isWithinAllowedRoot = additionalContextRootsAbsolute.some((allowedRoot) => {
|
||||
const normalizedAllowedRoot = path.normalize(allowedRoot);
|
||||
const relativePath = path.relative(normalizedAllowedRoot, normalizedFilePath);
|
||||
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
||||
});
|
||||
|
||||
if (!isWithinAllowedRoot) {
|
||||
const allowedRootsDisplay = additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n');
|
||||
throw new Error(
|
||||
`Access to files outside of the allowed context roots is not allowed: ${moduleName}\n\n`
|
||||
+ `Allowed context roots:\n${allowedRootsDisplay}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check cache first (use normalized path as key)
|
||||
if (localModuleCache.has(normalizedFilePath)) {
|
||||
return localModuleCache.get(normalizedFilePath);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(normalizedFilePath)) {
|
||||
throw new Error(`Cannot find module ${moduleName}`);
|
||||
}
|
||||
|
||||
// Read and execute the local module
|
||||
const moduleCode = fs.readFileSync(normalizedFilePath, 'utf8');
|
||||
|
||||
// Create module object
|
||||
const moduleObj = { exports: {} };
|
||||
|
||||
// Get the directory of this module for nested imports
|
||||
const moduleDir = path.dirname(normalizedFilePath);
|
||||
|
||||
// Create a new context that inherits from the script context
|
||||
const moduleContext = {
|
||||
...scriptContext,
|
||||
module: moduleObj,
|
||||
exports: moduleObj.exports,
|
||||
__filename: normalizedFilePath,
|
||||
__dirname: moduleDir,
|
||||
// Create a custom require function for this module that resolves relative to its directory
|
||||
require: createCustomRequire({
|
||||
scriptingConfig: scriptContext.scriptingConfig || {},
|
||||
collectionPath,
|
||||
scriptContext,
|
||||
currentModuleDir: moduleDir,
|
||||
localModuleCache,
|
||||
additionalContextRootsAbsolute
|
||||
})
|
||||
// Safe globals from allowlist (Node.js/Web APIs only, not ECMAScript built-ins)
|
||||
...Object.fromEntries(
|
||||
safeGlobals
|
||||
.filter((key) => global[key] !== undefined)
|
||||
.map((key) => [key, global[key]])
|
||||
)
|
||||
};
|
||||
|
||||
try {
|
||||
// Execute the module code in the shared context
|
||||
vm.runInNewContext(moduleCode, moduleContext, {
|
||||
filename: normalizedFilePath,
|
||||
displayErrors: true
|
||||
});
|
||||
// Add TypedArrays from host for compatibility with host APIs (TextEncoder, crypto, etc.)
|
||||
mixinTypedArrays(scriptContext);
|
||||
|
||||
// Cache the result using normalized path
|
||||
localModuleCache.set(normalizedFilePath, moduleObj.exports);
|
||||
|
||||
return moduleObj.exports;
|
||||
} catch (error) {
|
||||
throw new Error(`Error loading local module ${moduleName}: ${error.message}`);
|
||||
}
|
||||
return scriptContext;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -106,6 +106,43 @@ describe('node-vm sandbox', () => {
|
||||
runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} })
|
||||
).rejects.toThrow('Access to files outside of the allowed context roots is not allowed');
|
||||
});
|
||||
|
||||
it('should block absolute paths outside allowed roots', async () => {
|
||||
// Try to require an absolute path outside the collection
|
||||
const script = `
|
||||
const secret = require('/etc/passwd');
|
||||
`;
|
||||
|
||||
const context = { console: console };
|
||||
|
||||
await expect(
|
||||
runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} })
|
||||
).rejects.toThrow('Access to files outside of the allowed context roots is not allowed');
|
||||
});
|
||||
|
||||
it('should allow absolute paths within allowed roots', async () => {
|
||||
// Create a module in the collection
|
||||
fs.writeFileSync(
|
||||
path.join(collectionPath, 'absolute-test.js'),
|
||||
'module.exports = { loaded: true };'
|
||||
);
|
||||
|
||||
// Use absolute path to require it
|
||||
const absolutePath = path.join(collectionPath, 'absolute-test.js');
|
||||
const script = `
|
||||
const mod = require('${absolutePath.replace(/\\/g, '\\\\')}');
|
||||
bru.setVar('result', mod.loaded);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCustomRequire - additionalContextRoots', () => {
|
||||
@@ -225,18 +262,23 @@ describe('node-vm sandbox', () => {
|
||||
|
||||
describe('createCustomRequire - module caching', () => {
|
||||
it('should cache loaded modules', async () => {
|
||||
let callCount = 0;
|
||||
// Module increments a counter each time it's executed
|
||||
// If caching works, counter should only be 1 after multiple requires
|
||||
fs.writeFileSync(
|
||||
path.join(collectionPath, 'cached.js'),
|
||||
`
|
||||
module.exports = { count: ${++callCount} };
|
||||
if (!global._cacheTestCount) global._cacheTestCount = 0;
|
||||
global._cacheTestCount++;
|
||||
module.exports = { id: Date.now() };
|
||||
`
|
||||
);
|
||||
|
||||
const script = `
|
||||
const mod1 = require('./cached');
|
||||
const mod2 = require('./cached');
|
||||
bru.setVar('same', mod1.count === mod2.count);
|
||||
const mod3 = require('./cached');
|
||||
bru.setVar('sameInstance', mod1 === mod2 && mod2 === mod3);
|
||||
bru.setVar('loadCount', global._cacheTestCount);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
@@ -246,7 +288,911 @@ describe('node-vm sandbox', () => {
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('same', true);
|
||||
// All requires should return the same cached instance
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('sameInstance', true);
|
||||
// Module should only be executed once
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('loadCount', 1);
|
||||
});
|
||||
|
||||
it('should handle circular dependencies', async () => {
|
||||
// Create two modules that require each other
|
||||
fs.writeFileSync(
|
||||
path.join(collectionPath, 'circularA.js'),
|
||||
`
|
||||
exports.name = 'A';
|
||||
const B = require('./circularB');
|
||||
exports.fromB = B.name;
|
||||
`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(collectionPath, 'circularB.js'),
|
||||
`
|
||||
exports.name = 'B';
|
||||
const A = require('./circularA');
|
||||
exports.fromA = A.name;
|
||||
`
|
||||
);
|
||||
|
||||
const script = `
|
||||
const A = require('./circularA');
|
||||
// A loads first, sets exports.name='A', then requires B
|
||||
// B loads, sets exports.name='B', requires A (gets partial: {name:'A'})
|
||||
// B finishes with {name:'B', fromA:'A'}
|
||||
// A finishes with {name:'A', fromB:'B'}
|
||||
bru.setVar('result', A.name + '-' + A.fromB);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'A-B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCustomRequire - Node.js builtin modules', () => {
|
||||
it('should load builtin modules (crypto)', async () => {
|
||||
const script = `
|
||||
const crypto = require('crypto');
|
||||
bru.setVar('result', typeof crypto.createHash);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'function');
|
||||
});
|
||||
|
||||
it('should support node: prefix syntax', async () => {
|
||||
const script = `
|
||||
const path = require('node:path');
|
||||
bru.setVar('result', typeof path.join);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'function');
|
||||
});
|
||||
|
||||
it('should allow all builtin modules including fs', async () => {
|
||||
const script = `
|
||||
const fs = require('fs');
|
||||
bru.setVar('result', typeof fs.readFileSync);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'function');
|
||||
});
|
||||
|
||||
it('should load multiple builtins', async () => {
|
||||
const script = `
|
||||
const url = require('url');
|
||||
const util = require('util');
|
||||
const buffer = require('buffer');
|
||||
const fs = require('fs');
|
||||
bru.setVar('result', typeof url.parse + '-' + typeof util.format + '-' + typeof buffer.Buffer + '-' + typeof fs.readFileSync);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'function-function-function-function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCustomRequire - npm modules in vm context', () => {
|
||||
it('should load npm modules from collection into vm context', async () => {
|
||||
// Create a mock npm module in collection's node_modules
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'test-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
'module.exports = { name: "test-module", value: 123 };'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const testMod = require('test-module');
|
||||
bru.setVar('result', testMod.name + '-' + testMod.value);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'test-module-123');
|
||||
});
|
||||
|
||||
it('should handle npm module with dependencies', async () => {
|
||||
// Create a mock npm module with internal dependencies
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'parent-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'helper.js'),
|
||||
'module.exports = { helper: true };'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
'const helper = require("./helper"); module.exports = { hasHelper: helper.helper };'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const parentMod = require('parent-module');
|
||||
bru.setVar('result', parentMod.hasHelper);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', true);
|
||||
});
|
||||
|
||||
it('should provide bru object to npm modules', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'bru-access-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
`module.exports = {
|
||||
getEnvVar: function(name) { return bru.getEnvVar(name); },
|
||||
setVar: function(name, value) { bru.setVar(name, value); }
|
||||
};`
|
||||
);
|
||||
|
||||
const script = `
|
||||
const bruModule = require('bru-access-module');
|
||||
const envValue = bruModule.getEnvVar('TEST_VAR');
|
||||
bruModule.setVar('result', envValue);
|
||||
`;
|
||||
|
||||
const getEnvVarMock = jest.fn().mockReturnValue('test-value');
|
||||
const setVarMock = jest.fn();
|
||||
const context = {
|
||||
bru: {
|
||||
getEnvVar: getEnvVarMock,
|
||||
setVar: setVarMock
|
||||
},
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(getEnvVarMock).toHaveBeenCalledWith('TEST_VAR');
|
||||
expect(setVarMock).toHaveBeenCalledWith('result', 'test-value');
|
||||
});
|
||||
|
||||
it('should provide req object to npm modules', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'req-access-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
`module.exports = {
|
||||
getUrl: function() { return req.getUrl(); },
|
||||
getMethod: function() { return req.getMethod(); },
|
||||
setHeader: function(name, value) { req.setHeader(name, value); }
|
||||
};`
|
||||
);
|
||||
|
||||
const script = `
|
||||
const reqModule = require('req-access-module');
|
||||
const url = reqModule.getUrl();
|
||||
const method = reqModule.getMethod();
|
||||
reqModule.setHeader('X-Custom', 'value');
|
||||
bru.setVar('result', method + ':' + url);
|
||||
`;
|
||||
|
||||
const setVarMock = jest.fn();
|
||||
const getUrlMock = jest.fn().mockReturnValue('https://api.example.com');
|
||||
const getMethodMock = jest.fn().mockReturnValue('POST');
|
||||
const setHeaderMock = jest.fn();
|
||||
const context = {
|
||||
bru: { setVar: setVarMock },
|
||||
req: {
|
||||
getUrl: getUrlMock,
|
||||
getMethod: getMethodMock,
|
||||
setHeader: setHeaderMock
|
||||
},
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(getUrlMock).toHaveBeenCalled();
|
||||
expect(getMethodMock).toHaveBeenCalled();
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('X-Custom', 'value');
|
||||
expect(setVarMock).toHaveBeenCalledWith('result', 'POST:https://api.example.com');
|
||||
});
|
||||
|
||||
it('should provide res object to npm modules', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'res-access-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
`module.exports = {
|
||||
getStatus: function() { return res.getStatus(); },
|
||||
getBody: function() { return res.getBody(); },
|
||||
getHeader: function(name) { return res.getHeader(name); }
|
||||
};`
|
||||
);
|
||||
|
||||
const script = `
|
||||
const resModule = require('res-access-module');
|
||||
const status = resModule.getStatus();
|
||||
const body = resModule.getBody();
|
||||
const contentType = resModule.getHeader('content-type');
|
||||
bru.setVar('result', status + ':' + contentType + ':' + body.message);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
res: {
|
||||
getStatus: jest.fn().mockReturnValue(200),
|
||||
getBody: jest.fn().mockReturnValue({ message: 'success' }),
|
||||
getHeader: jest.fn().mockReturnValue('application/json')
|
||||
},
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.res.getStatus).toHaveBeenCalled();
|
||||
expect(context.res.getBody).toHaveBeenCalled();
|
||||
expect(context.res.getHeader).toHaveBeenCalledWith('content-type');
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', '200:application/json:success');
|
||||
});
|
||||
|
||||
it('should provide bru, req, res to nested npm module dependencies', async () => {
|
||||
// Create parent module
|
||||
const parentDir = path.join(collectionPath, 'node_modules', 'parent-ctx-module');
|
||||
fs.mkdirSync(parentDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(parentDir, 'index.js'),
|
||||
`const child = require('./child');
|
||||
module.exports = { childResult: child.getData() };`
|
||||
);
|
||||
// Create child module that accesses context
|
||||
fs.writeFileSync(
|
||||
path.join(parentDir, 'child.js'),
|
||||
`module.exports = {
|
||||
getData: function() {
|
||||
return {
|
||||
envVar: bru.getEnvVar('NESTED_VAR'),
|
||||
reqUrl: req.getUrl(),
|
||||
resStatus: res.getStatus()
|
||||
};
|
||||
}
|
||||
};`
|
||||
);
|
||||
|
||||
const script = `
|
||||
const parent = require('parent-ctx-module');
|
||||
const data = parent.childResult;
|
||||
bru.setVar('result', data.envVar + '|' + data.reqUrl + '|' + data.resStatus);
|
||||
`;
|
||||
|
||||
const getEnvVarMock = jest.fn().mockReturnValue('nested-value');
|
||||
const setVarMock = jest.fn();
|
||||
const getUrlMock = jest.fn().mockReturnValue('https://nested.example.com');
|
||||
const getStatusMock = jest.fn().mockReturnValue(201);
|
||||
const context = {
|
||||
bru: {
|
||||
getEnvVar: getEnvVarMock,
|
||||
setVar: setVarMock
|
||||
},
|
||||
req: {
|
||||
getUrl: getUrlMock
|
||||
},
|
||||
res: {
|
||||
getStatus: getStatusMock
|
||||
},
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(getEnvVarMock).toHaveBeenCalledWith('NESTED_VAR');
|
||||
expect(getUrlMock).toHaveBeenCalled();
|
||||
expect(getStatusMock).toHaveBeenCalled();
|
||||
expect(setVarMock).toHaveBeenCalledWith('result', 'nested-value|https://nested.example.com|201');
|
||||
});
|
||||
|
||||
describe('CommonJS module patterns', () => {
|
||||
it('should handle module.exports = object pattern', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-object');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
'module.exports = { foo: "bar", num: 42 };'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const mod = require('cjs-object');
|
||||
bru.setVar('result', mod.foo + '-' + mod.num);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'bar-42');
|
||||
});
|
||||
|
||||
it('should handle module.exports = function pattern', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-function');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
'module.exports = function(x) { return x * 2; };'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const double = require('cjs-function');
|
||||
bru.setVar('result', double(21));
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 42);
|
||||
});
|
||||
|
||||
it('should handle module.exports = class pattern', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-class');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
`class Calculator {
|
||||
constructor(val) { this.val = val; }
|
||||
add(x) { return this.val + x; }
|
||||
}
|
||||
module.exports = Calculator;`
|
||||
);
|
||||
|
||||
const script = `
|
||||
const Calculator = require('cjs-class');
|
||||
const calc = new Calculator(10);
|
||||
bru.setVar('result', calc.add(5));
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 15);
|
||||
});
|
||||
|
||||
it('should handle exports.property pattern', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-exports');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
`exports.add = function(a, b) { return a + b; };
|
||||
exports.multiply = function(a, b) { return a * b; };
|
||||
exports.VERSION = '1.0.0';`
|
||||
);
|
||||
|
||||
const script = `
|
||||
const math = require('cjs-exports');
|
||||
bru.setVar('result', math.add(2, 3) + '-' + math.multiply(4, 5) + '-' + math.VERSION);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', '5-20-1.0.0');
|
||||
});
|
||||
|
||||
it('should handle mixed module.exports and exports pattern', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-mixed');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
`// module.exports takes precedence
|
||||
exports.ignored = 'this will be ignored';
|
||||
module.exports = { actual: 'value' };`
|
||||
);
|
||||
|
||||
const script = `
|
||||
const mod = require('cjs-mixed');
|
||||
bru.setVar('result', mod.actual + '-' + (mod.ignored || 'undefined'));
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'value-undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('File extension handling', () => {
|
||||
it('should load .cjs files as CommonJS', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-ext-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'package.json'),
|
||||
'{"name": "cjs-ext-module", "main": "index.cjs"}'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.cjs'),
|
||||
'module.exports = { format: "cjs", value: 100 };'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const mod = require('cjs-ext-module');
|
||||
bru.setVar('result', mod.format + '-' + mod.value);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'cjs-100');
|
||||
});
|
||||
|
||||
it('should fail when loading .mjs files (ES modules)', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'mjs-ext-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'package.json'),
|
||||
'{"name": "mjs-ext-module", "main": "index.mjs"}'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.mjs'),
|
||||
'export default { format: "esm" };'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const mod = require('mjs-ext-module');
|
||||
`;
|
||||
|
||||
const context = { console: console };
|
||||
|
||||
await expect(
|
||||
runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should load module with package.json main field', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'custom-main');
|
||||
fs.mkdirSync(path.join(nodeModulesDir, 'lib'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'package.json'),
|
||||
'{"name": "custom-main", "main": "lib/entry.js"}'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'lib', 'entry.js'),
|
||||
'module.exports = { entry: "custom-main-lib" };'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const mod = require('custom-main');
|
||||
bru.setVar('result', mod.entry);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'custom-main-lib');
|
||||
});
|
||||
|
||||
it('should require relative .cjs files within npm module', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'cjs-relative');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'helper.cjs'),
|
||||
'module.exports = { helperValue: "from-cjs" };'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
'const helper = require("./helper.cjs"); module.exports = helper;'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const mod = require('cjs-relative');
|
||||
bru.setVar('result', mod.helperValue);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'from-cjs');
|
||||
});
|
||||
|
||||
it('should load .json files directly', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'json-direct');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'package.json'),
|
||||
'{"name": "json-direct", "main": "data.json"}'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'data.json'),
|
||||
'{"type": "json-main", "count": 42}'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const data = require('json-direct');
|
||||
bru.setVar('result', data.type + '-' + data.count);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'json-main-42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON file handling', () => {
|
||||
it('should load JSON files from npm modules', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'json-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'config.json'),
|
||||
'{"name": "test-config", "version": "1.0.0", "enabled": true}'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
'const config = require("./config.json"); module.exports = config;'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const config = require('json-module');
|
||||
bru.setVar('result', config.name + '-' + config.version + '-' + config.enabled);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'test-config-1.0.0-true');
|
||||
});
|
||||
|
||||
it('should handle nested JSON requires', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'nested-json');
|
||||
fs.mkdirSync(path.join(nodeModulesDir, 'data'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'data', 'schema.json'),
|
||||
'{"type": "object", "properties": {"id": {"type": "number"}}}'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
'const schema = require("./data/schema.json"); module.exports = { schema };'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const mod = require('nested-json');
|
||||
bru.setVar('result', mod.schema.type + '-' + mod.schema.properties.id.type);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'object-number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node.js globals in npm modules', () => {
|
||||
it('should have access to Buffer', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'buffer-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
`module.exports = {
|
||||
encode: function(str) { return Buffer.from(str).toString('base64'); },
|
||||
decode: function(b64) { return Buffer.from(b64, 'base64').toString('utf8'); }
|
||||
};`
|
||||
);
|
||||
|
||||
const script = `
|
||||
const bufMod = require('buffer-module');
|
||||
const encoded = bufMod.encode('hello');
|
||||
const decoded = bufMod.decode(encoded);
|
||||
bru.setVar('result', encoded + '-' + decoded);
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'aGVsbG8=-hello');
|
||||
});
|
||||
|
||||
it('should have access to URL and URLSearchParams', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'url-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
`module.exports = {
|
||||
parseUrl: function(urlStr) {
|
||||
const url = new URL(urlStr);
|
||||
return url.hostname;
|
||||
},
|
||||
buildQuery: function(params) {
|
||||
const search = new URLSearchParams(params);
|
||||
return search.toString();
|
||||
}
|
||||
};`
|
||||
);
|
||||
|
||||
const script = `
|
||||
const urlMod = require('url-module');
|
||||
bru.setVar('result', urlMod.parseUrl('https://example.com/path'));
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'example.com');
|
||||
});
|
||||
|
||||
it('should have access to setTimeout/clearTimeout', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'timer-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
`module.exports = {
|
||||
hasTimers: function() {
|
||||
return typeof setTimeout === 'function' && typeof clearTimeout === 'function';
|
||||
}
|
||||
};`
|
||||
);
|
||||
|
||||
const script = `
|
||||
const timerMod = require('timer-module');
|
||||
bru.setVar('result', timerMod.hasTimers());
|
||||
`;
|
||||
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should throw error for non-existent module', async () => {
|
||||
const script = `
|
||||
const mod = require('non-existent-module-xyz');
|
||||
`;
|
||||
|
||||
const context = { console: console };
|
||||
|
||||
await expect(
|
||||
runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} })
|
||||
).rejects.toThrow('Could not resolve module');
|
||||
});
|
||||
|
||||
it('should throw error for module with syntax error', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'syntax-error-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
'module.exports = { invalid syntax here'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const mod = require('syntax-error-module');
|
||||
`;
|
||||
|
||||
const context = { console: console };
|
||||
|
||||
await expect(
|
||||
runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for module with runtime error', async () => {
|
||||
const nodeModulesDir = path.join(collectionPath, 'node_modules', 'runtime-error-module');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nodeModulesDir, 'index.js'),
|
||||
'throw new Error("Module initialization failed");'
|
||||
);
|
||||
|
||||
const script = `
|
||||
const mod = require('runtime-error-module');
|
||||
`;
|
||||
|
||||
const context = { console: console };
|
||||
|
||||
await expect(
|
||||
runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} })
|
||||
).rejects.toThrow('Module initialization failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('context isolation', () => {
|
||||
it('should have global pointing to isolated context (not host)', async () => {
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
// global exists but points to isolated context, so global.bru should exist
|
||||
// process is a sanitized object in the isolated context
|
||||
const script = `bru.setVar('result', typeof global.bru === 'object' && typeof global.process === 'object')`;
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', true);
|
||||
});
|
||||
|
||||
it('should not have access to host fs module via globalThis', async () => {
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
const script = `bru.setVar('result', typeof globalThis.fs)`;
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'undefined');
|
||||
});
|
||||
|
||||
it('should throw ReferenceError for undeclared variables', async () => {
|
||||
const context = { console: console };
|
||||
|
||||
const script = `const x = someUndeclaredVar`;
|
||||
|
||||
await expect(
|
||||
runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} })
|
||||
).rejects.toThrow('someUndeclaredVar is not defined');
|
||||
});
|
||||
|
||||
it('should have access to context objects via globalThis', async () => {
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
req: { url: 'http://test.com' },
|
||||
console: console
|
||||
};
|
||||
|
||||
const script = `bru.setVar('result', typeof globalThis.req)`;
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'object');
|
||||
});
|
||||
|
||||
it('should have access to allowed globals like Buffer', async () => {
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
const script = `bru.setVar('result', typeof globalThis.Buffer)`;
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'function');
|
||||
});
|
||||
|
||||
it('should have access to process object with nextTick', async () => {
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
const script = `
|
||||
const hasSafeProps = typeof process.version === 'string' && typeof process.platform === 'string';
|
||||
const hasNextTick = typeof process.nextTick === 'function';
|
||||
bru.setVar('result', hasSafeProps && hasNextTick);
|
||||
`;
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', true);
|
||||
});
|
||||
|
||||
it('should work with Array.isArray across context boundaries', async () => {
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
const script = `
|
||||
const arr = [1, 2, 3];
|
||||
bru.setVar('result', Array.isArray(arr));
|
||||
`;
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', true);
|
||||
});
|
||||
|
||||
it('should have working Object methods', async () => {
|
||||
const context = {
|
||||
bru: { setVar: jest.fn() },
|
||||
console: console
|
||||
};
|
||||
|
||||
const script = `
|
||||
const obj = { a: 1, b: 2 };
|
||||
bru.setVar('result', Object.keys(obj).join(','));
|
||||
`;
|
||||
|
||||
await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
|
||||
|
||||
expect(context.bru.setVar).toHaveBeenCalledWith('result', 'a,b');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
42
packages/bruno-js/src/sandbox/node-vm/utils.js
Normal file
42
packages/bruno-js/src/sandbox/node-vm/utils.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const path = require('node:path');
|
||||
const nodeModule = require('node:module');
|
||||
|
||||
/**
|
||||
* Check if a module is a Node.js builtin
|
||||
* @param {string} moduleName - Module name to check
|
||||
* @returns {boolean} True if module is a builtin
|
||||
*/
|
||||
function isBuiltinModule(moduleName) {
|
||||
const normalized = moduleName.startsWith('node:') ? moduleName.slice(5) : moduleName;
|
||||
return nodeModule.builtinModules.includes(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path is within allowed context roots
|
||||
* @param {string} normalizedPath - Normalized file path
|
||||
* @param {Array<string>} additionalContextRootsAbsolute - Allowed roots
|
||||
* @returns {boolean} True if path is within allowed roots
|
||||
*/
|
||||
function isPathWithinAllowedRoots(normalizedPath, additionalContextRootsAbsolute) {
|
||||
return additionalContextRootsAbsolute.some((allowedRoot) => {
|
||||
const normalizedAllowedRoot = path.normalize(allowedRoot);
|
||||
const relativePath = path.relative(normalizedAllowedRoot, normalizedPath);
|
||||
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
||||
});
|
||||
}
|
||||
|
||||
class ScriptError extends Error {
|
||||
constructor(error, script) {
|
||||
super(error.message);
|
||||
this.name = 'ScriptError';
|
||||
this.originalError = error;
|
||||
this.script = script;
|
||||
this.stack = error.stack;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isBuiltinModule,
|
||||
isPathWithinAllowedRoots,
|
||||
ScriptError
|
||||
};
|
||||
@@ -58,6 +58,27 @@ const addBruShimToContext = (vm, __brunoTestResults) => {
|
||||
globalThis.test = Test(__brunoTestResults);
|
||||
`
|
||||
);
|
||||
|
||||
// Register custom chai assertion for isJson (expect(...).to.be.json)
|
||||
// The bundled chai only exposes { expect, assert } — no Assertion class.
|
||||
// Access the prototype through an expect() instance instead.
|
||||
vm.evalCode(
|
||||
`
|
||||
(function() {
|
||||
var proto = Object.getPrototypeOf(expect(null));
|
||||
Object.defineProperty(proto, 'json', {
|
||||
get: function () {
|
||||
var obj = this._obj;
|
||||
var isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj) &&
|
||||
Object.prototype.toString.call(obj) === '[object Object]';
|
||||
this.assert(isJson, 'expected #{this} to be JSON', 'expected #{this} not to be JSON');
|
||||
return this;
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
})();
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = addBruShimToContext;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
const TestRuntime = require('../src/runtime/test-runtime');
|
||||
const ScriptRuntime = require('../src/runtime/script-runtime');
|
||||
const AssertRuntime = require('../src/runtime/assert-runtime');
|
||||
const Bru = require('../src/bru');
|
||||
const VarsRuntime = require('../src/runtime/vars-runtime');
|
||||
|
||||
@@ -258,4 +259,87 @@ describe('runtime', () => {
|
||||
expect(result.runtimeVariables.title).toBe('{{$randomFirstName}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assert-runtime', () => {
|
||||
const baseRequest = {
|
||||
method: 'GET',
|
||||
url: 'http://localhost:3000/',
|
||||
headers: {},
|
||||
data: undefined
|
||||
};
|
||||
|
||||
const makeResponse = (data) => ({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data,
|
||||
headers: {}
|
||||
});
|
||||
|
||||
const runAssertions = (assertions, response, runtime = 'nodevm') => {
|
||||
const assertRuntime = new AssertRuntime({ runtime });
|
||||
return assertRuntime.runAssertions(assertions, { ...baseRequest }, response, {}, {}, process.env);
|
||||
};
|
||||
|
||||
describe('isJson', () => {
|
||||
it('should pass for a plain object', () => {
|
||||
const results = runAssertions(
|
||||
[{ name: 'res.body', value: 'isJson', enabled: true }],
|
||||
makeResponse({ id: 1, name: 'test' })
|
||||
);
|
||||
expect(results[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('should pass for a nested object', () => {
|
||||
const results = runAssertions(
|
||||
[{ name: 'res.body', value: 'isJson', enabled: true }],
|
||||
makeResponse({ user: { id: 1, tags: ['a', 'b'] } })
|
||||
);
|
||||
expect(results[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('should pass for objects from a different realm (e.g. after res.setBody in node-vm)', async () => {
|
||||
const response = makeResponse({ id: 1, name: 'original' });
|
||||
|
||||
// res.setBody() inside node-vm creates a cross-realm object whose
|
||||
// constructor is the VM's Object, not the host's Object
|
||||
const scriptRuntime = new ScriptRuntime({ runtime: 'nodevm' });
|
||||
await scriptRuntime.runResponseScript(
|
||||
`res.setBody({ id: 2, name: 'updated' });`,
|
||||
{ ...baseRequest },
|
||||
response,
|
||||
{}, {}, '.', null, process.env
|
||||
);
|
||||
|
||||
const results = runAssertions(
|
||||
[{ name: 'res.body', value: 'isJson', enabled: true }],
|
||||
response
|
||||
);
|
||||
expect(results[0].status).toBe('pass');
|
||||
});
|
||||
|
||||
it('should fail for an array', () => {
|
||||
const results = runAssertions(
|
||||
[{ name: 'res.body', value: 'isJson', enabled: true }],
|
||||
makeResponse([1, 2, 3])
|
||||
);
|
||||
expect(results[0].status).toBe('fail');
|
||||
});
|
||||
|
||||
it('should fail for a string', () => {
|
||||
const results = runAssertions(
|
||||
[{ name: 'res.body', value: 'isJson', enabled: true }],
|
||||
makeResponse('hello')
|
||||
);
|
||||
expect(results[0].status).toBe('fail');
|
||||
});
|
||||
|
||||
it('should fail for null', () => {
|
||||
const results = runAssertions(
|
||||
[{ name: 'res.body', value: 'isJson', enabled: true }],
|
||||
makeResponse(null)
|
||||
);
|
||||
expect(results[0].status).toBe('fail');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ const dts = require('rollup-plugin-dts');
|
||||
const { terser } = require('rollup-plugin-terser');
|
||||
const peerDepsExternal = require('rollup-plugin-peer-deps-external');
|
||||
const json = require('@rollup/plugin-json');
|
||||
const { isBuiltin } = require('module');
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
module.exports = [
|
||||
@@ -38,6 +39,6 @@ module.exports = [
|
||||
typescript({ tsconfig: './tsconfig.json' }),
|
||||
terser()
|
||||
],
|
||||
external: ['axios', 'qs', 'ws', 'debug']
|
||||
external: (id) => isBuiltin(id) || ['axios', 'qs', 'ws', 'debug'].includes(id)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -103,6 +103,7 @@ type GetCertsAndProxyConfigParams = {
|
||||
certs?: ClientCertificate[];
|
||||
};
|
||||
collectionLevelProxy?: ProxyConfig;
|
||||
appLevelProxyConfig?: Record<string, any>;
|
||||
systemProxyConfig?: SystemProxyConfig;
|
||||
};
|
||||
|
||||
@@ -129,6 +130,7 @@ type GetHttpHttpsAgentsParams = {
|
||||
certs?: ClientCertificate[];
|
||||
};
|
||||
collectionLevelProxy?: ProxyConfig;
|
||||
appLevelProxyConfig?: Record<string, any>;
|
||||
systemProxyConfig?: SystemProxyConfig;
|
||||
};
|
||||
|
||||
@@ -210,6 +212,7 @@ const getCertsAndProxyConfig = ({
|
||||
options,
|
||||
clientCertificates,
|
||||
collectionLevelProxy,
|
||||
appLevelProxyConfig,
|
||||
systemProxyConfig
|
||||
}: GetCertsAndProxyConfigParams): GetCertsAndProxyConfigResult => {
|
||||
const certsConfig: CertsConfig = {};
|
||||
@@ -302,12 +305,31 @@ const getCertsAndProxyConfig = ({
|
||||
proxyConfig = collectionProxyConfigData;
|
||||
proxyMode = 'on';
|
||||
} else if (!collectionProxyDisabled && collectionProxyInherit) {
|
||||
// Inherit from system proxy
|
||||
const { http_proxy, https_proxy } = systemProxyConfig || {};
|
||||
if (http_proxy?.length || https_proxy?.length) {
|
||||
proxyMode = 'system';
|
||||
// Inherit from app-level proxy settings
|
||||
if (appLevelProxyConfig) {
|
||||
const globalDisabled = get(appLevelProxyConfig, 'disabled', false);
|
||||
const globalInherit = get(appLevelProxyConfig, 'inherit', false);
|
||||
const globalProxyConfigData = get(appLevelProxyConfig, 'config', appLevelProxyConfig);
|
||||
|
||||
if (!globalDisabled && !globalInherit) {
|
||||
// Use app-level custom proxy
|
||||
proxyConfig = globalProxyConfigData;
|
||||
proxyMode = 'on';
|
||||
} else if (!globalDisabled && globalInherit) {
|
||||
// App-level also inherits, fall through to system proxy
|
||||
const { http_proxy, https_proxy } = systemProxyConfig || {};
|
||||
if (http_proxy?.length || https_proxy?.length) {
|
||||
proxyMode = 'system';
|
||||
}
|
||||
}
|
||||
// else: app-level proxy is disabled, proxyMode stays 'off'
|
||||
} else {
|
||||
// No app-level proxy config (e.g. CLI), fall through to system proxy
|
||||
const { http_proxy, https_proxy } = systemProxyConfig || {};
|
||||
if (http_proxy?.length || https_proxy?.length) {
|
||||
proxyMode = 'system';
|
||||
}
|
||||
}
|
||||
// else: no system proxy available, proxyMode stays 'off'
|
||||
}
|
||||
// else: collection proxy is disabled, proxyMode stays 'off'
|
||||
|
||||
@@ -409,6 +431,7 @@ const getHttpHttpsAgents = async ({
|
||||
collectionPath,
|
||||
clientCertificates,
|
||||
collectionLevelProxy,
|
||||
appLevelProxyConfig,
|
||||
systemProxyConfig,
|
||||
options
|
||||
}: GetHttpHttpsAgentsParams): Promise<AgentResult> => {
|
||||
@@ -417,6 +440,7 @@ const getHttpHttpsAgents = async ({
|
||||
collectionPath,
|
||||
clientCertificates,
|
||||
collectionLevelProxy,
|
||||
appLevelProxyConfig,
|
||||
systemProxyConfig,
|
||||
options
|
||||
});
|
||||
|
||||
@@ -545,7 +545,7 @@ const itemSchema = Yup.object({
|
||||
type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder', 'js', 'grpc-request', 'ws-request']).required('type is required'),
|
||||
seq: Yup.number().min(1),
|
||||
name: Yup.string().min(1, 'name must be at least 1 character').required('name is required'),
|
||||
tags: Yup.array().of(Yup.string().matches(/^[\w-]+$/, 'tag must be alphanumeric')),
|
||||
tags: Yup.array().of(Yup.string().matches(/^[\w-][\w\s-]*[\w-]$|^[\w-]+$/, 'tag must contain only alphanumeric characters, spaces, hyphens, or underscores')),
|
||||
request: Yup.mixed().when('type', {
|
||||
is: (type) => type === 'grpc-request',
|
||||
then: grpcRequestSchema.required('request is required when item-type is grpc-request'),
|
||||
|
||||
45
packages/bruno-tests/additional-context-root-lib/index.js
Normal file
45
packages/bruno-tests/additional-context-root-lib/index.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Utility module in additionalContextRoot to test:
|
||||
* 1. Loading modules from additionalContextRoot
|
||||
* 2. npm module resolution (@faker-js/faker) from collection's node_modules
|
||||
* 3. Local module resolution (./lib.js) relative to additionalContextRoot
|
||||
*/
|
||||
|
||||
const { faker } = require('@faker-js/faker');
|
||||
const { formatName, generateGreeting } = require('./lib');
|
||||
|
||||
/**
|
||||
* Generate a random user with greeting
|
||||
* Tests both npm module and local module resolution
|
||||
*/
|
||||
function generateUser() {
|
||||
const firstName = faker.person.firstName();
|
||||
const lastName = faker.person.lastName();
|
||||
const fullName = formatName(firstName, lastName);
|
||||
const greeting = generateGreeting(fullName);
|
||||
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
fullName,
|
||||
greeting,
|
||||
email: faker.internet.email({ firstName, lastName })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that all dependencies resolved correctly
|
||||
*/
|
||||
function verifyDependencies() {
|
||||
return {
|
||||
fakerLoaded: typeof faker === 'object' && typeof faker.person === 'object',
|
||||
localModuleLoaded: typeof formatName === 'function' && typeof generateGreeting === 'function'
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateUser,
|
||||
verifyDependencies,
|
||||
formatName,
|
||||
generateGreeting
|
||||
};
|
||||
16
packages/bruno-tests/additional-context-root-lib/lib.js
Normal file
16
packages/bruno-tests/additional-context-root-lib/lib.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Simple local module to test local module resolution from additionalContextRoot
|
||||
*/
|
||||
|
||||
function formatName(firstName, lastName) {
|
||||
return `${firstName} ${lastName}`;
|
||||
}
|
||||
|
||||
function generateGreeting(name) {
|
||||
return `Hello, ${name}!`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatName,
|
||||
generateGreeting
|
||||
};
|
||||
@@ -15,7 +15,8 @@
|
||||
"bypassProxy": ""
|
||||
},
|
||||
"scripts": {
|
||||
"moduleWhitelist": ["crypto", "buffer", "form-data"]
|
||||
"moduleWhitelist": ["crypto", "buffer", "form-data"],
|
||||
"additionalContextRoots": ["../additional-context-root-lib"]
|
||||
},
|
||||
"clientCertificates": {
|
||||
"enabled": true,
|
||||
|
||||
73
packages/bruno-tests/collection/package-lock.json
generated
73
packages/bruno-tests/collection/package-lock.json
generated
@@ -9,10 +9,17 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "^8.4.0",
|
||||
"ajv": "~8.17.1",
|
||||
"external-lib-with-bru-req-res-objects": "file:../external-lib-with-bru-req-res-objects",
|
||||
"jose": "^5.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lru-map-cache": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"../external-lib-with-bru-req-res-objects": {
|
||||
"name": "@usebruno/external-lib-with-bru-req-res-objects",
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"node_modules/@faker-js/faker": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.0.tgz",
|
||||
@@ -28,6 +35,22 @@
|
||||
"npm": ">=6.14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@@ -43,6 +66,47 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/external-lib-with-bru-req-res-objects": {
|
||||
"resolved": "../external-lib-with-bru-req-res-objects",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
|
||||
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@@ -142,6 +206,15 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "^8.4.0",
|
||||
"ajv": "~8.17.1",
|
||||
"external-lib-with-bru-req-res-objects": "file:../external-lib-with-bru-req-res-objects",
|
||||
"jose": "^5.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lru-map-cache": "^0.1.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
meta {
|
||||
name: isJson after setBody
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{host}}/api/echo/json
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"hello": "bruno"
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 200
|
||||
res.body: isJson
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
res.setBody({ id: 1, name: "updated", nested: { key: "value" } });
|
||||
}
|
||||
|
||||
tests {
|
||||
test("res.body should be json after setBody with object", function() {
|
||||
const body = res.getBody();
|
||||
expect(body).to.be.json;
|
||||
expect(body.id).to.eql(1);
|
||||
expect(body.name).to.eql("updated");
|
||||
expect(body.nested.key).to.eql("value");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
meta {
|
||||
name: additional context root
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{host}}/api/echo/json
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"test": "additionalContextRoot"
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 200
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
// Load module from additionalContextRoot using relative path
|
||||
// This tests that modules outside the collection can be loaded when configured in bruno.json
|
||||
// The path "../additional-context-root-lib" is allowed because it's listed in additionalContextRoots
|
||||
const additionalLib = require('../additional-context-root-lib');
|
||||
|
||||
// Verify all dependencies loaded correctly
|
||||
const deps = additionalLib.verifyDependencies();
|
||||
bru.setVar('fakerLoaded', deps.fakerLoaded);
|
||||
bru.setVar('localModuleLoaded', deps.localModuleLoaded);
|
||||
|
||||
// Test the utility functions
|
||||
const user = additionalLib.generateUser();
|
||||
bru.setVar('hasFirstName', typeof user.firstName === 'string' && user.firstName.length > 0);
|
||||
bru.setVar('hasLastName', typeof user.lastName === 'string' && user.lastName.length > 0);
|
||||
bru.setVar('hasFullName', typeof user.fullName === 'string' && user.fullName.includes(' '));
|
||||
bru.setVar('hasGreeting', typeof user.greeting === 'string' && user.greeting.startsWith('Hello, '));
|
||||
bru.setVar('hasEmail', typeof user.email === 'string' && user.email.includes('@'));
|
||||
|
||||
// Test direct functions from local module
|
||||
const formatted = additionalLib.formatName('John', 'Doe');
|
||||
bru.setVar('formatNameResult', formatted);
|
||||
|
||||
const greeting = additionalLib.generateGreeting('Bruno');
|
||||
bru.setVar('greetingResult', greeting);
|
||||
|
||||
// Test direct require of a specific file from additionalContextRoot
|
||||
const libDirect = require('../additional-context-root-lib/lib.js');
|
||||
bru.setVar('directRequireWorks', typeof libDirect.formatName === 'function');
|
||||
bru.setVar('directFormatName', libDirect.formatName('Direct', 'Test'));
|
||||
bru.setVar('directGreeting', libDirect.generateGreeting('World'));
|
||||
}
|
||||
|
||||
tests {
|
||||
test("should load module from additionalContextRoot", function() {
|
||||
expect(bru.getVar('fakerLoaded')).to.equal(true);
|
||||
expect(bru.getVar('localModuleLoaded')).to.equal(true);
|
||||
});
|
||||
|
||||
test("should resolve npm module (@faker-js/faker) from collection node_modules", function() {
|
||||
expect(bru.getVar('hasFirstName')).to.equal(true);
|
||||
expect(bru.getVar('hasLastName')).to.equal(true);
|
||||
expect(bru.getVar('hasEmail')).to.equal(true);
|
||||
});
|
||||
|
||||
test("should resolve local module (./lib.js) relative to additionalContextRoot", function() {
|
||||
expect(bru.getVar('hasFullName')).to.equal(true);
|
||||
expect(bru.getVar('hasGreeting')).to.equal(true);
|
||||
});
|
||||
|
||||
test("should correctly execute local module functions", function() {
|
||||
expect(bru.getVar('formatNameResult')).to.equal('John Doe');
|
||||
expect(bru.getVar('greetingResult')).to.equal('Hello, Bruno!');
|
||||
});
|
||||
|
||||
test("should directly require specific file from additionalContextRoot", function() {
|
||||
expect(bru.getVar('directRequireWorks')).to.equal(true);
|
||||
expect(bru.getVar('directFormatName')).to.equal('Direct Test');
|
||||
expect(bru.getVar('directGreeting')).to.equal('Hello, World!');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
meta {
|
||||
name: buffer
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
// Skip in safe mode - these tests require developer sandbox
|
||||
if (bru.isSafeMode()) {
|
||||
bru.runner.skipRequest();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("Buffer.from and toString", function() {
|
||||
const buf = Buffer.from('hello bruno', 'utf8');
|
||||
expect(buf.toString()).to.equal('hello bruno');
|
||||
expect(buf.toString('base64')).to.equal('aGVsbG8gYnJ1bm8=');
|
||||
expect(buf.toString('hex')).to.equal('68656c6c6f206272756e6f');
|
||||
expect(buf.length).to.equal(11);
|
||||
});
|
||||
|
||||
test("Buffer.from with base64 and hex", function() {
|
||||
expect(Buffer.from('aGVsbG8=', 'base64').toString()).to.equal('hello');
|
||||
expect(Buffer.from('68656c6c6f', 'hex').toString()).to.equal('hello');
|
||||
});
|
||||
|
||||
test("Buffer.alloc", function() {
|
||||
const buf = Buffer.alloc(10, 0);
|
||||
expect(buf.length).to.equal(10);
|
||||
expect(buf[0]).to.equal(0);
|
||||
});
|
||||
|
||||
test("Buffer.concat", function() {
|
||||
const result = Buffer.concat([Buffer.from('hello '), Buffer.from('world')]);
|
||||
expect(result.toString()).to.equal('hello world');
|
||||
});
|
||||
|
||||
test("Buffer.isBuffer", function() {
|
||||
expect(Buffer.isBuffer(Buffer.from('test'))).to.equal(true);
|
||||
expect(Buffer.isBuffer('string')).to.equal(false);
|
||||
expect(Buffer.isBuffer(new Uint8Array(4))).to.equal(false);
|
||||
});
|
||||
|
||||
test("Buffer.subarray", function() {
|
||||
const buf = Buffer.from('hello bruno');
|
||||
expect(buf.subarray(0, 5).toString()).to.equal('hello');
|
||||
expect(buf.subarray(6).toString()).to.equal('bruno');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
meta {
|
||||
name: encoding
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
// Skip in safe mode - these tests require developer sandbox
|
||||
if (bru.isSafeMode()) {
|
||||
bru.runner.skipRequest();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("TextEncoder", function() {
|
||||
const encoder = new TextEncoder();
|
||||
const encoded = encoder.encode('hello');
|
||||
expect(encoded).to.be.instanceOf(Uint8Array);
|
||||
expect(encoded.length).to.equal(5);
|
||||
expect(encoded[0]).to.equal(104); // 'h'
|
||||
});
|
||||
|
||||
test("TextDecoder", function() {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const decoded = decoder.decode(new Uint8Array([104, 101, 108, 108, 111]));
|
||||
expect(decoded).to.equal('hello');
|
||||
});
|
||||
|
||||
test("TextDecoder with utf-16le", function() {
|
||||
const decoder = new TextDecoder('utf-16le');
|
||||
const decoded = decoder.decode(new Uint8Array([104, 0, 105, 0]));
|
||||
expect(decoded).to.equal('hi');
|
||||
});
|
||||
|
||||
test("btoa and atob", function() {
|
||||
expect(btoa('hello bruno')).to.equal('aGVsbG8gYnJ1bm8=');
|
||||
expect(atob('aGVsbG8gYnJ1bm8=')).to.equal('hello bruno');
|
||||
});
|
||||
|
||||
test("base64 roundtrip with binary data", function() {
|
||||
const binary = String.fromCharCode(0, 1, 255, 254);
|
||||
const encoded = btoa(binary);
|
||||
const decoded = atob(encoded);
|
||||
expect(decoded.charCodeAt(0)).to.equal(0);
|
||||
expect(decoded.charCodeAt(2)).to.equal(255);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user