Compare commits

...

38 Commits
main ... v3.1.3

Author SHA1 Message Date
naman-bruno
ce436c3d0a fix: window normlize path comparison (#7240) 2026-02-20 16:10:28 +05:30
Ram
0923cc188d fix(cli): preserve request item type during import and fail on unsupported types (#7207)
* fix(cli): preserve request type when importing collections

* fix(cli): fail fast on unsupported imported item types

* fix(cli): force BRU format when writing imported files

* chore: apply code rabbit fixes

* chore: adress review comment - keep changes minimal

* add additional test to test bru folder format

* agree with coderabbit, error handling is required

---------

Co-authored-by: Ramesh Sunkara <rs@rsunkara.com>
2026-02-20 16:09:50 +05:30
naman-bruno
715b6ecbb0 fix: normalize Windows paths for cross-platform compatibility (#7185)
* fix: normalize Windows paths for cross-platform compatibility in workspace

* fixes
2026-02-18 17:41:52 +05:30
lohit
a036396cb8 fix: isJson assertion fails after res.setBody() with object in node-vm (#7191)
* fix: isJson assertion fails after res.setBody() with object in node-vm

Objects created inside Node's vm.createContext() have a different Object
constructor than the host realm. When res.setBody() is called with a JS
object from a script, _.cloneDeep preserves the cross-realm prototype,
causing obj.constructor === Object to fail in the isJson assertion.

Replace with Object.prototype.toString.call() which is cross-realm safe.

* fix: register isJson chai assertion in QuickJS test runtime

The bundled chai in QuickJS only exposes { expect, assert } via
requireObject — no Assertion class. Access the prototype through
Object.getPrototypeOf(expect(null)) and use Object.defineProperty
to register the json property directly.

* fix: enable assertion chaining on isJson in QuickJS runtime

The QuickJS isJson property getter was missing `return this`, preventing
chai assertion chaining (e.g. expect(body).to.be.json.and...).
2026-02-18 17:35:06 +05:30
naman-bruno
db612679d6 fix: update protobuf and import path handling in opencollection (#7166) 2026-02-17 15:19:37 +05:30
Sid
ec9a03f208 tests: fix breaking tests (#7132)
* fix: update placeholder text for environment variable input

* fix: handle undefined color in environment objects

Don't export if `undefined`

* fix: update collection import logic for YML and BRU formats

* fix: ensure error icon is not visible after header validation

* fix: specify format for collection and environment serialization
2026-02-13 19:15:33 +05:30
Pooja
1448fe4b52 fix: env draft loss on color change and rename (#7130) 2026-02-13 19:15:20 +05:30
Pooja
c957c9371d fix: openapi content level example (#7091)
* fix: openapi content level example

* add: unit tests
2026-02-13 19:12:13 +05:30
lohit
811daec92c fix(node-vm): scripting context and module resolution (#7033)
* fix(node-vm): scripting context and module resolution issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): use vm.createContext for true isolation and fix prototype mismatches

- Replace vm.compileFunction with vm.createContext + runInContext for true isolation
- Remove ECMAScript built-ins from safeGlobals (VM provides its own versions)
- This fixes prototype chain mismatches that broke libraries like @faker-js/faker
- Add sanitized process object (allows env, blocks exit/kill)
- Add global/globalThis pointing to isolated context (not host)
- Extract safe globals to constants.js for maintainability
- Remove typed-arrays mixin (VM provides TypedArrays)
- Add comprehensive isolation tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): remove process, add Error types and TypedArrays mixin, add jose test

- Remove process object from script context (security hardening)
- Remove createSanitizedProcess function from constants.js
- Add Error types to safeGlobals for instanceof checks with host errors
- Add TypedArrays mixin for host API compatibility (TextEncoder, crypto, Buffer)
- Add jose library and test for JWT sign/verify functionality
- Update tests to reflect process removal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): handle circular dependencies and failed module caching

- Pre-populate module cache before execution to support circular requires
- Cache moduleObj instead of moduleObj.exports to handle module.exports reassignment
- Remove failed modules from cache to allow retry
- Add test for circular dependency handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): spread all context properties in buildScriptContext

Instead of explicitly listing each context property, spread all
properties from the context input to support future additions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): add filtered process object to script context

Expose a sanitized process object with only safe read-only properties
(argv, version, arch, platform, pid, features) while keeping env empty
for security.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(node-vm): add comprehensive tests for Node.js builtins

Add 18 test files for Node.js builtin APIs in developer sandbox mode:
- Buffer, URL, TextEncoder/TextDecoder, btoa/atob
- Web Crypto API and node:crypto module
- Timers (setTimeout, setInterval, setImmediate, queueMicrotask)
- Fetch API (Request, Response, Headers, FormData, Blob)
- Intl formatters, JSON, Events (Event, EventTarget, CustomEvent)
- Node modules: fs, path, os, util, stream, zlib, querystring

All tests skip in safe mode using bru.runner.skipRequest().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): address CodeRabbit review feedback

- Block absolute paths from bypassing security by routing through loadLocalModule
- Fix process tests to expect sanitized object instead of undefined
- Fix cache test to verify module executes only once
- Add tests for absolute path handling (block outside, allow within roots)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: lint issues

* fix(node-vm): recontextualize host objects for cross-context deep equality

Objects passed from the host context into the Node VM have different
Object/Array constructors than objects created inside the VM. This breaks
deep equality checks in libraries like AJV, where fast-deep-equal fails
on `a.constructor !== b.constructor` for structurally identical objects.

Add recontextualizeScript to utils.js that wraps getter methods (res.getBody,
res.getHeaders, req.getBody, req.getHeaders, req.getPathParams, req.getTags,
bru.getVar) to JSON round-trip returned objects inside the VM, giving them
VM-native prototypes.

Add external-lib-with-bru-req-res-objects package and tests to verify
bru/req/res accessibility from npm modules. Update ajv.bru tests to
validate res.getBody() against AJV schemas with enum on nested objects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(node-vm): update spec to use saved mock refs after recontextualize

The recontextualizeScript wraps res.getBody with a JSON round-trip
function, replacing the jest mock on the context object. Save mock
references before calling runScriptInNodeVm so assertions work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(node-vm): shallow-copy mutable process properties in sandbox

process.argv, process.versions, and process.features were passed by
reference, allowing sandboxed scripts to mutate the host process.
Shallow-copy these properties to prevent leaking mutable references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(node-vm): use recursive clone in toVMNative instead of JSON round-trip

JSON.stringify converts undefined to null in arrays, breaking tests like
res.setBody([..., undefined, ...]). Replace with recursive clone that
creates new VM-native objects/arrays while preserving undefined values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(node-vm): generalize recontextualize to wrap all bru/req/res methods

Instead of hardcoding specific method names, walk the prototype chain
with Object.getOwnPropertyNames to discover and wrap all methods that
return Objects/Arrays. Async methods (sendRequest, runRequest) get their
resolved values wrapped. The res callable and res.body/res.headers are
also recontextualized for direct access and query usage.

Adds integration tests for VM-native prototype checks across res, req,
bru APIs, res() callable queries, and bru.sendRequest patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* revert(node-vm): remove recontextualizeScript and related tests

The recontextualize approach of wrapping all bru/req/res methods
to return VM-native objects is being reverted in favor of a
different solution to the cross-context prototype mismatch issue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(node-vm): expose full process object in developer sandbox via safeGlobals

* test(node-vm): update process tests for full process object in developer sandbox

* test(node-vm): update spec to verify process.nextTick availability

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 01:22:59 +05:30
Abhishek S Lal
282b0bbae7 fix: enhance tag handling and validation in collection import/export (#7107)
- Added collection format handling in Tags component.
- Updated convertCollection function to accept collectionFormat parameter.
- Improved tag validation logic in TagList component based on collection format.
- Adjusted OpenAPI transformation functions to support collection format options.
- Enhanced schema validation for tags to allow spaces and underscores.
2026-02-12 00:43:11 +05:30
Bijin A B
68334b362f fix(save-all): fix save all modified requests while closing the app (#7118) 2026-02-12 00:08:33 +05:30
Abhishek S Lal
659e6e0293 fix: enhance HTTP response status validation in stringifyHttpRequest function (#7117)
Updated the response status handling to ensure it is a positive integer before assignment, improving data integrity in HTTP request stringification.
2026-02-11 21:19:21 +05:30
Chirag Chandrashekhar
d3337c8e9e Fix/save transient request new folder theme match (#7116)
* fix: match filesystem name input style to NewFolder modal in SaveTransientRequest

- Update label to match NewFolder format with '(on filesystem)' suffix
- Add folder icon before the input field
- Apply PathDisplay-like styling with yellow text color and monospace font
- Use matching background, border, and padding from PathDisplay component

* fix: add edit toggle and help tooltip to SaveTransientRequest filesystem name

- Add edit/display mode toggle matching NewFolder modal behavior
- Show PathDisplay when not editing, input field when editing
- Add Help tooltip with placement support for filesystem name field
- Add placement prop to Help component (top, bottom, left, right)
- Remove unused filesystem input styles from StyledWrapper

* fix: update Help component usage in SaveTransientRequest filesystem name field

- Change Help component width prop from a string to a number for consistency.
2026-02-11 21:19:10 +05:30
naman-bruno
54488d6d06 fix: collection zip import for default workspace (#7108)
* fix: collection zip import for default workspace

* fixes
2026-02-11 21:18:47 +05:30
lohit
6d646e3cef fix: pass app-level proxy config to bru.sendRequest (#7113)
When collection proxy is set to "inherit", bru.sendRequest was skipping
the app-level proxy and falling through directly to system proxy. Now it
correctly checks app-level proxy settings first, matching the behavior
of normal requests. When appLevelProxyConfig is not provided (e.g. CLI),
falls through to system proxy preserving existing behavior.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:09:32 +05:30
gopu-bruno
78af8be59e fix: update codemirror bg for pastel light theme (#7110) 2026-02-11 18:32:26 +05:30
Chirag Chandrashekhar
db137da8ed fix: match filesystem name input style to NewFolder modal in SaveTransientRequest (#7109)
- Update label to match NewFolder format with '(on filesystem)' suffix
- Add folder icon before the input field
- Apply PathDisplay-like styling with yellow text color and monospace font
- Use matching background, border, and padding from PathDisplay component
2026-02-11 18:32:18 +05:30
Pooja
cb3f6629bb fix: persist environment color on import/export (#7045) 2026-02-11 18:31:46 +05:30
naman-bruno
0ba6c3d132 fix: filter existing paths for apispec in workspace (#7104) 2026-02-11 18:31:31 +05:30
Sid
1f65387ea8 fix: improve environment variable comparison by stripping UIDs (#7100) 2026-02-11 18:31:22 +05:30
Pooja
9acfed63c5 fix: env color picker ui (#7096)
* fix: env color picker ui

* rm: toast for color change

* fix: color slider alignment
2026-02-11 18:31:11 +05:30
Sid
7bc0c1b967 fix: improve value handling in editor components (#7098) 2026-02-11 18:30:59 +05:30
lohit
44538be00b fix: mark Node.js built-in modules as external in rollup config (#7095)
Use `isBuiltin` from the `module` package to dynamically exclude all
Node.js built-in modules from the bundle, preventing rollup from
trying to bundle core modules like path, fs, crypto, etc.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 18:30:48 +05:30
naman-bruno
01e3999631 handle unsaved changes in dot env file editor (#7094)
* handle unsaved changes in dot env file editor

* fixes
2026-02-11 18:30:40 +05:30
Chirag Chandrashekhar
459620170a fix: validate folder and file names in SaveTransientRequest component (#7060)
- Added validation for folder and file names to ensure they are not empty and conform to naming rules.
- Display error messages using toast notifications for invalid names.
2026-02-11 18:30:31 +05:30
gopu-bruno
b6e455f41b fix: disable text-overflow ellipsis on checkbox column (#7080) 2026-02-11 18:28:35 +05:30
naman-bruno
0f017b122c feat: validate ZIP file format for collections before import (#7085) 2026-02-11 18:28:25 +05:30
Pooja
aba8e14377 fix: header and var tooltip overflow (#7082) 2026-02-11 18:28:11 +05:30
naman-bruno
46bc0ffce7 feat: add ZIP file import for collections (#7063)
* feat: add ZIP file import for collections
2026-02-11 18:27:54 +05:30
Chirag Chandrashekhar
3080c3e144 feat: implement filtering of transient items across collection operations (#7062)
- Added `filterTransientItems` utility to recursively remove transient items from collections.
- Updated export functions for OpenCollection and Postman to filter out transient items before export.
- Enhanced collection handling in various components to skip transient requests during processing.
- Adjusted RunConfigurationPanel to exclude transient items from request handling.
2026-02-11 18:27:33 +05:30
naman-bruno
4a0000e10f Merge pull request #7067 from naman-bruno/fix/import-tests
fix: import tests
2026-02-11 18:27:06 +05:30
Chirag Chandrashekhar
838e29682d feat: enhance SaveTransientRequest component with folder navigation and input handling improvements (#7061) 2026-02-11 18:24:44 +05:30
Chirag Chandrashekhar
266e9ce230 bugfix: auto open saved transient request (#7058) 2026-02-11 18:24:32 +05:30
Chirag Chandrashekhar
977a48dfa7 fix: update dependency in CreateTransientRequest to include collectionUid in useMemo dependencies (#7057) 2026-02-11 18:24:15 +05:30
Chirag Chandrashekhar
777669ba65 fix: improve error handling in CreateTransientRequest and SaveTransientRequest components (#7059) 2026-02-11 18:24:03 +05:30
Chirag Chandrashekhar
ca86824bb9 Bugfix/close saved deleting collections (#7048) 2026-02-11 18:23:30 +05:30
Pooja
12a45cbd82 fix: code editor null value crash (#7039) 2026-02-11 18:23:06 +05:30
Pooja
b1f83f2ab1 fix: add missing URL helper translations for Bruno to Postman export (#7026)
* fix: add missing URL helper translations for Bruno to Postman export

* fix : comment
2026-02-05 18:36:51 +05:30
134 changed files with 5178 additions and 724 deletions

10
package-lock.json generated
View File

@@ -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",

View File

@@ -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) {

View File

@@ -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'
}}
/>
);

View File

@@ -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}

View File

@@ -4,6 +4,7 @@ const StyledWrapper = styled.div`
.hue-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
outline: none;

View File

@@ -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(',')})`
}}

View File

@@ -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) => {

View File

@@ -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'] {

View File

@@ -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)}

View File

@@ -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++;

View File

@@ -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">

View File

@@ -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);

View File

@@ -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>
))}

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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`
}}
>

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -58,6 +58,7 @@ const Tags = ({ item, collection }) => {
handleRemoveTag={handleRemove}
tags={tags}
onSave={handleRequestSave}
collectionFormat={collection.format}
/>
</div>
);

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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(() => [

View File

@@ -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 : '';

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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 ? (

View File

@@ -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';

View File

@@ -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();

View File

@@ -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';

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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 && (

View File

@@ -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);

View File

@@ -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)) {

View File

@@ -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;
}
}

View File

@@ -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 (

View File

@@ -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}
/>

View File

@@ -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);

View File

@@ -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');

View File

@@ -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';

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
}
}
};

View File

@@ -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.
*/

View File

@@ -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;
});
})

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;
});
};

View File

@@ -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 };
};

View File

@@ -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', {

View File

@@ -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) {

View File

@@ -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`;

View File

@@ -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
};
};

View File

@@ -11,6 +11,7 @@ const FORMAT_CONFIG = {
yml: { ext: '.yml', collectionFile: 'opencollection.yml', folderFile: 'folder.yml' },
bru: { ext: '.bru', collectionFile: 'collection.bru', folderFile: 'folder.bru' }
};
const REQUEST_ITEM_TYPES = ['http-request', 'graphql-request'];
const getCollectionFormat = (collectionPath) => {
if (fs.existsSync(path.join(collectionPath, 'opencollection.yml'))) return 'yml';
@@ -457,7 +458,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 +468,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);
}
@@ -499,7 +500,7 @@ const processCollectionItems = async (items = [], currentPath) => {
if (item.seq) {
item.root.meta.seq = item.seq;
}
const folderContent = await stringifyFolder(item.root);
const folderContent = stringifyFolder(item.root, { format: 'bru' });
safeWriteFileSync(folderBruFilePath, folderContent);
}
@@ -507,17 +508,16 @@ const processCollectionItems = async (items = [], currentPath) => {
if (item.items && item.items.length) {
await processCollectionItems(item.items, folderPath);
}
} else if (['http-request', 'graphql-request'].includes(item.type)) {
} else if (REQUEST_ITEM_TYPES.includes(item.type)) {
// Create request file
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`);
if (!sanitizedFilename.endsWith('.bru')) {
sanitizedFilename += '.bru';
}
// Convert JSON to BRU format based on the item type
let type = item.type === 'http-request' ? 'http' : 'graphql';
const bruJson = {
type: type,
// Keep schema item type so filestore can stringify request correctly
type: item.type,
name: item.name,
seq: typeof item.seq === 'number' ? item.seq : 1,
tags: item.tags || [],
@@ -538,8 +538,10 @@ const processCollectionItems = async (items = [], currentPath) => {
};
// Convert to BRU format and write to file
const content = await stringifyRequest(bruJson);
const content = stringifyRequest(bruJson, { format: 'bru' });
safeWriteFileSync(path.join(currentPath, sanitizedFilename), content);
} else {
throw new Error(`Unsupported item type: ${item.type}`);
}
}
};

View File

@@ -0,0 +1,143 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { describe, it, expect, afterEach } = require('@jest/globals');
const { parseRequest, parseFolder } = require('@usebruno/filestore');
const { createCollectionFromBrunoObject } = require('../../../src/utils/collection');
describe('createCollectionFromBrunoObject', () => {
let outputDir;
const createOutputDir = () => {
outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-cli-import-'));
return outputDir;
};
const parseBruRequestFromPath = (filePath) => parseRequest(fs.readFileSync(filePath, 'utf8'), { format: 'bru' });
const parseBruFolderFromPath = (filePath) => parseFolder(fs.readFileSync(filePath, 'utf8'), { format: 'bru' });
afterEach(() => {
if (outputDir && fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true, force: true });
}
});
it('writes http and graphql requests from imported collection items', async () => {
createOutputDir();
await createCollectionFromBrunoObject(
{
name: 'imported-collection',
items: [
{
type: 'http-request',
name: 'Get Users',
filename: 'get-users.bru',
seq: 1,
request: {
method: 'GET',
url: 'https://api.example.com/users'
}
},
{
type: 'graphql-request',
name: 'Get Viewer',
filename: 'get-viewer.bru',
seq: 2,
request: {
method: 'POST',
url: 'https://api.example.com/graphql',
body: {
mode: 'graphql',
graphql: {
query: 'query { viewer { id } }',
variables: '{}'
}
}
}
}
]
},
outputDir
);
const httpPath = path.join(outputDir, 'get-users.bru');
const graphqlPath = path.join(outputDir, 'get-viewer.bru');
expect(fs.existsSync(httpPath)).toBe(true);
expect(fs.existsSync(graphqlPath)).toBe(true);
const httpRequest = parseBruRequestFromPath(httpPath);
const graphqlRequest = parseBruRequestFromPath(graphqlPath);
expect(httpRequest).toHaveProperty('type', 'http-request');
expect(httpRequest).toHaveProperty('request.method', 'GET');
expect(graphqlRequest).toHaveProperty('type', 'graphql-request');
expect(graphqlRequest).toHaveProperty('request.method', 'POST');
});
it('writes folder.bru in bru format', async () => {
createOutputDir();
await createCollectionFromBrunoObject(
{
name: 'folder-collection',
items: [
{
type: 'folder',
name: 'Users',
seq: 3,
root: {
meta: { name: 'Users' }
},
items: [
{
type: 'http-request',
name: 'List Users',
filename: 'list-users.bru',
seq: 1,
request: {
method: 'GET',
url: 'https://api.example.com/users'
}
}
]
}
]
},
outputDir
);
const folderPath = path.join(outputDir, 'Users');
const folderBruPath = path.join(folderPath, 'folder.bru');
const nestedRequestPath = path.join(folderPath, 'list-users.bru');
expect(fs.existsSync(folderBruPath)).toBe(true);
expect(fs.existsSync(nestedRequestPath)).toBe(true);
const folder = parseBruFolderFromPath(folderBruPath);
const nestedRequest = parseBruRequestFromPath(nestedRequestPath);
expect(folder).toHaveProperty('meta.name', 'Users');
expect(folder).toHaveProperty('meta.seq', 3);
expect(nestedRequest).toHaveProperty('type', 'http-request');
expect(nestedRequest).toHaveProperty('request.method', 'GET');
});
it('throws for unsupported item types', async () => {
createOutputDir();
await expect(
createCollectionFromBrunoObject(
{
name: 'invalid-item-type-collection',
items: [
{
type: 'unsupported-type',
name: 'Unsupported'
}
]
},
outputDir
)
).rejects.toThrow('Unsupported item type: unsupported-type');
});
});

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 || '',

View File

@@ -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
}))
};
}

View File

@@ -177,7 +177,7 @@ export interface BrunoConfig {
};
protobuf?: {
protoFiles?: { path: string }[];
importPaths?: { path: string; disabled?: boolean }[];
importPaths?: { path: string; enabled?: boolean }[];
};
proxy?: {
disabled?: boolean;

View File

@@ -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

View File

@@ -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;');
});
});

View File

@@ -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 }');
});
});

View File

@@ -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",

View File

@@ -36,14 +36,14 @@ const isBrunoConfigFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return dirname === collectionPath && basename === 'bruno.json';
return path.normalize(dirname) === path.normalize(collectionPath) && basename === 'bruno.json';
};
const isEnvironmentsFolder = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const envDirectory = path.join(collectionPath, 'environments');
return dirname === envDirectory;
return path.normalize(dirname) === path.normalize(envDirectory);
};
const isFolderRootFile = (pathname, collectionPath) => {
@@ -64,7 +64,7 @@ const isCollectionRootFile = (pathname, collectionPath) => {
const basename = path.basename(pathname);
// return if we are not at the root of the collection
if (dirname !== collectionPath) {
if (path.normalize(dirname) !== path.normalize(collectionPath)) {
return false;
}
@@ -385,7 +385,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments');
if (pathname === envDirectory) {
if (path.normalize(pathname) === path.normalize(envDirectory)) {
return;
}
@@ -563,7 +563,7 @@ const unlink = (win, pathname, collectionUid, collectionPath) => {
const basename = path.basename(pathname);
const dirname = path.dirname(pathname);
if (basename === 'opencollection.yml' && dirname === collectionPath) {
if (basename === 'opencollection.yml' && path.normalize(dirname) === path.normalize(collectionPath)) {
return;
}
@@ -581,7 +581,7 @@ const unlink = (win, pathname, collectionUid, collectionPath) => {
const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments');
if (pathname === envDirectory) {
if (path.normalize(pathname) === path.normalize(envDirectory)) {
return;
}

View File

@@ -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,
@@ -112,8 +114,12 @@ const findCollectionPathByItemPath = (filePath) => {
// Sort by length descending to find the most specific (deepest) match first
const sortedPaths = allCollectionPaths.sort((a, b) => b.length - a.length);
// Normalize the file path for comparison
const normalizedFilePath = path.normalize(filePath);
for (const collectionPath of sortedPaths) {
if (filePath.startsWith(collectionPath + path.sep) || filePath === collectionPath) {
const normalizedCollectionPath = path.normalize(collectionPath);
if (normalizedFilePath.startsWith(normalizedCollectionPath + path.sep) || normalizedFilePath === normalizedCollectionPath) {
return collectionPath;
}
}
@@ -421,9 +427,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 +519,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 +542,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
const environment = {
name: uniqueName,
variables: variables || []
variables: variables || [],
color
};
if (envHasSecrets(environment)) {
@@ -597,6 +601,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 +753,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 +1007,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 +2059,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) => {

View File

@@ -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);

View File

@@ -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
};
};

View File

@@ -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);
}
});

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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}`);

View File

@@ -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 {

View File

@@ -442,7 +442,7 @@ const isDotEnvFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return dirname === collectionPath && basename === '.env';
return path.normalize(dirname) === path.normalize(collectionPath) && basename === '.env';
};
const isValidDotEnvFilename = (filename) => {
@@ -456,7 +456,7 @@ const isBrunoConfigFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return dirname === collectionPath && basename === 'bruno.json';
return path.normalize(dirname) === path.normalize(collectionPath) && basename === 'bruno.json';
};
const isBruEnvironmentConfig = (pathname, collectionPath) => {
@@ -464,14 +464,14 @@ const isBruEnvironmentConfig = (pathname, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments');
const basename = path.basename(pathname);
return dirname === envDirectory && hasBruExtension(basename);
return path.normalize(dirname) === path.normalize(envDirectory) && hasBruExtension(basename);
};
const isCollectionRootBruFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return dirname === collectionPath && basename === 'collection.bru';
return path.normalize(dirname) === path.normalize(collectionPath) && basename === 'collection.bru';
};
module.exports = {

View File

@@ -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)

View File

@@ -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)) {

View File

@@ -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
})) || []
};
}

View File

@@ -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'

View File

@@ -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`);
});

View 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
};

View 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
};

View File

@@ -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 = {

View File

@@ -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');
});
});
});

View 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
};

View File

@@ -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;

View File

@@ -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');
});
});
});
});

View File

@@ -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)
}
];

View File

@@ -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
});

View File

@@ -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'),

View 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
};

View 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
};

View File

@@ -15,7 +15,8 @@
"bypassProxy": ""
},
"scripts": {
"moduleWhitelist": ["crypto", "buffer", "form-data"]
"moduleWhitelist": ["crypto", "buffer", "form-data"],
"additionalContextRoots": ["../additional-context-root-lib"]
},
"clientCertificates": {
"enabled": true,

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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");
});
}

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