Merge remote-tracking branch 'origin/main' into feat/log-client-cert-status

This commit is contained in:
Pragadesh-45 2025-01-13 16:27:27 +05:30
commit fc54bbcdd1
135 changed files with 8170 additions and 2972 deletions

View File

@ -2,6 +2,11 @@ name: Bru CLI Tests (npm)
on:
workflow_dispatch:
inputs:
build:
description: 'Test Bru CLI (npm)'
required: true
default: 'true'
# Assign permissions for unit tests to be reported.
# See https://github.com/dorny/test-reporter/issues/168
@ -15,7 +20,10 @@ permissions:
jobs:
test:
name: CLI Tests
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3

7891
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,7 @@
"ts-jest": "^29.0.5"
},
"scripts": {
"setup": "node ./scripts/setup.js",
"dev": "concurrently --kill-others \"npm run dev:web\" \"npm run dev:electron\"",
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",

View File

@ -1,4 +1,4 @@
{
"presets": ["next/babel"],
"presets": ["@babel/preset-env"],
"plugins": [["styled-components", { "ssr": true }]]
}

View File

@ -31,6 +31,6 @@ yarn-error.log*
# next.js
.next/
out/
dist/
.env

View File

@ -1,22 +0,0 @@
module.exports = {
output: 'export',
reactStrictMode: false,
publicRuntimeConfig: {
CI: process.env.CI,
PLAYWRIGHT: process.env.PLAYWRIGHT,
ENV: process.env.ENV
},
webpack: (config, { isServer }) => {
// Fixes npm packages that depend on `fs` module
if (!isServer) {
config.resolve.fallback.fs = false;
}
Object.defineProperty(config, 'devtool', {
get() {
return 'source-map';
},
set() {},
});
return config;
},
};

View File

@ -3,15 +3,15 @@
"version": "0.3.0",
"private": true,
"scripts": {
"dev": "cross-env ENV=dev next dev -p 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"dev": "rsbuild dev",
"build": "rsbuild build -m production",
"preview": "rsbuild preview",
"test": "jest",
"test:prettier": "prettier --check \"./src/**/*.{js,jsx,json,ts,tsx}\"",
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\""
},
"dependencies": {
"@babel/preset-env": "^7.26.0",
"@fontsource/inter": "^5.0.15",
"@prantlf/jsonlint": "^16.0.0",
"@reduxjs/toolkit": "^1.8.0",
@ -35,21 +35,20 @@
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"httpsnippet": "^3.0.6",
"i18next": "^23.14.0",
"i18next": "24.1.2",
"idb": "^7.0.0",
"immer": "^9.0.15",
"jsesc": "^3.0.2",
"jshint": "^2.13.6",
"json5": "^2.2.3",
"jsonc-parser": "^3.2.1",
"jsonpath-plus": "10.1.0",
"jsonpath-plus": "10.2.0",
"know-your-http-well": "^0.5.0",
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
"mousetrap": "^1.6.5",
"nanoid": "3.3.4",
"next": "14.2.16",
"nanoid": "3.3.8",
"path": "^0.12.7",
"pdfjs-dist": "4.4.168",
"platform": "^1.3.6",
@ -57,17 +56,17 @@
"prettier": "^2.7.1",
"qs": "^6.11.0",
"query-string": "^7.0.1",
"react": "18.2.0",
"react": "19.0.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.2.0",
"react-dom": "19.0.0",
"react-hot-toast": "^2.4.0",
"react-i18next": "^15.0.1",
"react-inspector": "^6.0.2",
"react-pdf": "9.1.1",
"react-player": "^2.16.0",
"react-redux": "^7.2.6",
"react-redux": "^7.2.9",
"react-tooltip": "^5.5.2",
"sass": "^1.46.0",
"strip-json-comments": "^5.0.1",
@ -79,13 +78,14 @@
"yup": "^0.32.11"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/plugin-transform-spread": "^7.16.7",
"@babel/preset-env": "^7.16.4",
"@babel/preset-react": "^7.16.0",
"@babel/runtime": "^7.16.3",
"@rsbuild/core": "^1.1.2",
"@rsbuild/plugin-babel": "^1.0.3",
"@rsbuild/plugin-node-polyfill": "^1.2.0",
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",
"autoprefixer": "10.4.20",
"babel-loader": "^8.2.3",
"babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
"cross-env": "^7.0.3",
"css-loader": "7.1.2",
"file-loader": "^6.2.0",

View File

@ -0,0 +1,27 @@
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginBabel } from '@rsbuild/plugin-babel';
import { pluginStyledComponents } from '@rsbuild/plugin-styled-components';
import { pluginSass } from '@rsbuild/plugin-sass';
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill'
export default defineConfig({
plugins: [
pluginNodePolyfill(),
pluginReact(),
pluginStyledComponents(),
pluginSass(),
pluginBabel({
include: /\.(?:js|jsx|tsx)$/,
babelLoaderOptions(opts) {
opts.plugins?.unshift('babel-plugin-react-compiler');
}
})
],
source: {
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file
},
html: {
title: 'Bruno'
},
});

View File

@ -76,7 +76,11 @@ if (!SERVER_RENDERED) {
'bru.getRequestVar(key)',
'bru.sleep(ms)',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)'
'bru.setGlobalEnvVar(key, value)',
'bru.runner',
'bru.runner.setNextRequest(requestName)',
'bru.runner.skipRequest()',
'bru.runner.stopExecution()'
];
CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
const cursor = editor.getCursor();
@ -98,7 +102,7 @@ if (!SERVER_RENDERED) {
if (curWordBru) {
hintWords.forEach((h) => {
if (h.includes('.') == curWordBru.includes('.') && h.startsWith(curWordBru)) {
result.list.push(curWordBru.includes('.') ? h.split('.')[1] : h);
result.list.push(curWordBru.includes('.') ? h.split('.')?.at(-1) : h);
}
});
result.list?.sort();

View File

@ -68,12 +68,13 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
});
const getFile = (e) => {
if (e.files?.[0]?.path) {
const filePath = window?.ipcRenderer?.getFilePath(e?.files?.[0]);
if (filePath) {
let relativePath;
if (isWindowsOS()) {
relativePath = slash(path.win32.relative(root, e.files[0].path));
relativePath = slash(path.win32.relative(root, filePath));
} else {
relativePath = path.posix.relative(root, e.files[0].path);
relativePath = path.posix.relative(root, filePath);
}
formik.setFieldValue(e.name, relativePath);
}

View File

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

View File

@ -10,6 +10,11 @@ import Modal from 'components/Modal';
const CreateEnvironment = ({ collection, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const validateEnvironmentName = (name) => {
return !collection?.environments?.some((env) => env?.name?.toLowerCase().trim() === name?.toLowerCase().trim());
};
const formik = useFormik({
enableReinitialize: true,
initialValues: {
@ -17,9 +22,10 @@ const CreateEnvironment = ({ collection, onClose }) => {
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
.min(1, 'Must be at least 1 character')
.max(50, 'Must be 50 characters or less')
.required('Name is required')
.test('duplicate-name', 'Environment already exists', validateEnvironmentName)
}),
onSubmit: (values) => {
dispatch(addEnvironment(values.name, collection.uid))

View File

@ -23,6 +23,10 @@ const StyledWrapper = styled.div`
padding: 8px 10px;
border-left: solid 2px transparent;
text-decoration: none;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
text-decoration: none;

View File

@ -8,6 +8,7 @@ import ImportEnvironment from '../ImportEnvironment';
import ManageSecrets from '../ManageSecrets';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ToolHint from 'components/ToolHint';
const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified }) => {
const { environments } = collection;
@ -103,13 +104,15 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
{environments &&
environments.length &&
environments.map((env) => (
<ToolHint key={env.uid} text={env.name} toolhintId={env.uid} place="right">
<div
key={env.uid}
id={env.uid}
className={selectedEnvironment.uid === env.uid ? 'environment-item active' : 'environment-item'}
onClick={() => handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle clicks
>
<span className="break-all">{env.name}</span>
</div>
</ToolHint>
))}
<div className="btn-create-environment" onClick={() => handleCreateEnvClick()}>
+ <span>Create</span>

View File

@ -0,0 +1,10 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.editing-mode {
cursor: pointer;
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,63 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import { updateFolderDocs } from 'providers/ReduxStore/slices/collections';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
const Documentation = ({ collection, folder }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [isEditing, setIsEditing] = useState(false);
const docs = get(folder, 'root.docs', '');
const toggleViewMode = () => {
setIsEditing((prev) => !prev);
};
const onEdit = (value) => {
dispatch(
updateFolderDocs({
folderUid: folder.uid,
collectionUid: collection.uid,
docs: value
})
);
};
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
if (!folder) {
return null;
}
return (
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative">
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
{isEditing ? (
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={docs || ''}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
/>
) : (
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
)}
</StyledWrapper>
);
};
export default Documentation;

View File

@ -7,6 +7,7 @@ import Script from './Script';
import Tests from './Tests';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars';
import Documentation from './Documentation';
import DotIcon from 'components/Icons/Dot';
const ContentIndicator = () => {
@ -60,6 +61,9 @@ const FolderSettings = ({ collection, folder }) => {
case 'vars': {
return <Vars collection={collection} folder={folder} />;
}
case 'docs': {
return <Documentation collection={collection} folder={folder} />;
}
}
};
@ -89,6 +93,9 @@ const FolderSettings = ({ collection, folder }) => {
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
Docs
</div>
</div>
<section className={`flex mt-4 h-full`}>{getTabPanel(tab)}</section>
</div>

View File

@ -23,7 +23,7 @@ const EnvironmentSelector = () => {
<ToolHint text="Global Environments" toolhintId="GlobalEnvironmentsToolhintId" className='flex flex-row'>
<IconWorld className="globe" size={16} strokeWidth={1.5} />
{
activeEnvironment ? <div>{activeEnvironment?.name}</div> : null
activeEnvironment ? <div className='text-nowrap truncate max-w-32'>{activeEnvironment?.name}</div> : null
}
</ToolHint>
</div>

View File

@ -2,12 +2,19 @@ import React, { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
const CreateEnvironment = ({ onClose }) => {
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const validateEnvironmentName = (name) => {
const trimmedName = name?.toLowerCase().trim();
return globalEnvs.every((env) => env?.name?.toLowerCase().trim() !== trimmedName);
};
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
@ -17,9 +24,10 @@ const CreateEnvironment = ({ onClose }) => {
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
.min(1, 'Must be at least 1 character')
.max(50, 'Must be 50 characters or less')
.required('Name is required')
.test('duplicate-name', 'Global Environment already exists', validateEnvironmentName)
}),
onSubmit: (values) => {
dispatch(addGlobalEnvironment({ name: values.name }))

View File

@ -23,6 +23,10 @@ const StyledWrapper = styled.div`
padding: 8px 10px;
border-left: solid 2px transparent;
text-decoration: none;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
text-decoration: none;

View File

@ -8,6 +8,7 @@ import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ManageSecrets from 'components/Environments/EnvironmentSettings/ManageSecrets/index';
import ImportEnvironment from '../ImportEnvironment';
import { isEqual } from 'lodash';
import ToolHint from 'components/ToolHint/index';
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified }) => {
const [openCreateModal, setOpenCreateModal] = useState(false);
@ -112,13 +113,15 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
{environments &&
environments.length &&
environments.map((env) => (
<ToolHint key={env.uid} text={env.name} toolhintId={env.uid} place="right">
<div
key={env.uid}
id={env.uid}
className={selectedEnvironment.uid === env.uid ? 'environment-item active' : 'environment-item'}
onClick={() => handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle clicks
onClick={() => handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle click
>
<span className="break-all">{env.name}</span>
</div>
</ToolHint>
))}
<div className="btn-create-environment" onClick={() => handleCreateEnvClick()}>
+ <span>Create</span>

View File

@ -13,6 +13,12 @@ const StyledWrapper = styled.div`
line-height: 30px;
overflow: hidden;
pre.CodeMirror-placeholder {
color: ${(props) => props.theme.text};
padding-left: 0;
opacity: 0.5;
}
.CodeMirror-scroll {
overflow: hidden !important;
${'' /* padding-bottom: 50px !important; */}

View File

@ -30,6 +30,7 @@ class MultiLineEditor extends Component {
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
placeholder: this.props.placeholder,
mode: 'brunovariables',
brunoVarInfo: {
variables

View File

@ -1,8 +1,6 @@
import { createPortal } from 'react-dom';
function Portal({ children, wrapperId }) {
wrapperId = wrapperId || 'bruno-app-body';
return createPortal(children, document.getElementById(wrapperId));
function Portal({ children }) {
return createPortal(children, document.body);
}
export default Portal;

View File

@ -90,7 +90,10 @@ const General = ({ close }) => {
};
const addCaCertificate = (e) => {
formik.setFieldValue('customCaCertificate.filePath', e.target.files[0]?.path);
const filePath = window?.ipcRenderer?.getFilePath(e?.target?.files?.[0]);
if (filePath) {
formik.setFieldValue('customCaCertificate.filePath', filePath);
}
};
const deleteCaCertificate = () => {

View File

@ -20,6 +20,7 @@ import React from 'react';
* endsWith : ends with
* between : between
* isEmpty : is empty
* isNotEmpty : is not empty
* isNull : is null
* isUndefined : is undefined
* isDefined : is defined
@ -51,6 +52,7 @@ const AssertionOperator = ({ operator, onChange }) => {
'endsWith',
'between',
'isEmpty',
'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',

View File

@ -24,6 +24,7 @@ import { useTheme } from 'providers/Theme';
* endsWith : ends with
* between : between
* isEmpty : is empty
* isNotEmpty : is not empty
* isNull : is null
* isUndefined : is undefined
* isDefined : is defined
@ -61,6 +62,7 @@ const parseAssertionOperator = (str = '') => {
'endsWith',
'between',
'isEmpty',
'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@ -75,6 +77,7 @@ const parseAssertionOperator = (str = '') => {
const unaryOperators = [
'isEmpty',
'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@ -87,7 +90,7 @@ const parseAssertionOperator = (str = '') => {
'isArray'
];
const [operator, ...rest] = str.trim().split(' ');
const [operator, ...rest] = str.split(' ');
const value = rest.join(' ');
if (unaryOperators.includes(operator)) {
@ -113,6 +116,7 @@ const parseAssertionOperator = (str = '') => {
const isUnaryOperator = (operator) => {
const unaryOperators = [
'isEmpty',
'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@ -162,7 +166,7 @@ const AssertionRow = ({
handleAssertionChange(
{
target: {
value: `${op} ${value}`
value: isUnaryOperator(op) ? op : `${op} ${value}`
}
},
assertion,
@ -178,7 +182,7 @@ const AssertionRow = ({
theme={storedTheme}
readOnly={true}
onSave={onSave}
onChange={(newValue) =>
onChange={(newValue) => {
handleAssertionChange(
{
target: {
@ -189,6 +193,7 @@ const AssertionRow = ({
'value'
)
}
}
onRun={handleRun}
collection={collection}
item={item}

View File

@ -20,8 +20,8 @@ const Wrapper = styled.div`
td {
padding: 6px 10px;
&:nth-child(1) {
width: 30%;
&:nth-child(2) {
width: 130px;
}
&:nth-child(4) {

View File

@ -24,7 +24,15 @@ const Wrapper = styled.div`
width: 30%;
}
&:nth-child(2) {
width: 45%;
}
&:nth-child(3) {
width: 25%;
}
&:nth-child(4) {
width: 70px;
}
}

View File

@ -54,6 +54,10 @@ const MultipartFormParams = ({ item, collection }) => {
param.value = e.target.value;
break;
}
case 'contentType': {
param.contentType = e.target.value;
break;
}
case 'enabled': {
param.enabled = e.target.checked;
break;
@ -85,6 +89,7 @@ const MultipartFormParams = ({ item, collection }) => {
<tr>
<td>Key</td>
<td>Value</td>
<td>Content-Type</td>
<td></td>
</tr>
</thead>
@ -145,6 +150,27 @@ const MultipartFormParams = ({ item, collection }) => {
/>
)}
</td>
<td>
<MultiLineEditor
onSave={onSave}
theme={storedTheme}
placeholder="Auto"
value={param.contentType}
onChange={(newValue) =>
handleParamChange(
{
target: {
value: newValue
}
},
param,
'contentType'
)
}
onRun={handleRun}
collection={collection}
/>
</td>
<td>
<div className="flex items-center">
<input

View File

@ -117,7 +117,6 @@ const QueryParams = ({ item, collection }) => {
<StyledWrapper className="w-full flex flex-col absolute">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Query</div>
<Table
headers={[
{ name: 'Name', accessor: 'name', width: '31%' },
@ -153,7 +152,7 @@ const QueryParams = ({ item, collection }) => {
/>
</td>
<td>
<div className="flex items-center">
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={param.enabled}
@ -241,11 +240,7 @@ const QueryParams = ({ item, collection }) => {
: null}
</tbody>
</table>
{!(pathParams && pathParams.length) ?
<div className="title pr-2 py-3 mt-2 text-xs">
</div>
: null}
{!(pathParams && pathParams.length) ? <div className="title pr-2 py-3 mt-2 text-xs"></div> : null}
</div>
</StyledWrapper>
);

View File

@ -70,7 +70,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const handleGenerateCode = (e) => {
e.stopPropagation();
if (item.request.url !== '' || (item.draft?.request.url !== undefined && item.draft?.request.url !== '')) {
if (item?.request?.url !== '' || (item.draft?.request?.url !== undefined && item.draft?.request?.url !== '')) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');

View File

@ -10,6 +10,7 @@ const StyledWrapper = styled.div`
align-items: center;
justify-content: center;
width: 10px;
min-width: 10px;
padding: 0;
cursor: col-resize;
background: transparent;

View File

@ -20,7 +20,7 @@ import { DocExplorer } from '@usebruno/graphql-docs';
import StyledWrapper from './StyledWrapper';
import SecuritySettings from 'components/SecuritySettings';
import FolderSettings from 'components/FolderSettings';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
import { produce } from 'immer';
const MIN_LEFT_PANE_WIDTH = 300;
@ -39,13 +39,18 @@ const RequestTabPanel = () => {
const _collections = useSelector((state) => state.collections.collections);
// merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object
let collections = produce(_collections, draft => {
let collections = produce(_collections, (draft) => {
let collection = find(draft, (c) => c.uid === focusedTab?.collectionUid);
if (collection) {
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
const globalEnvironmentVariables = getGlobalEnvironmentVariables({
globalEnvironments,
activeGlobalEnvironmentUid
});
const globalEnvSecrets = getGlobalEnvironmentVariablesMasked({ globalEnvironments, activeGlobalEnvironmentUid });
collection.globalEnvironmentVariables = globalEnvironmentVariables;
collection.globalEnvSecrets = globalEnvSecrets;
}
});

View File

@ -20,14 +20,14 @@ const formatResponse = (data, mode, filter) => {
}
if (data === null) {
return data;
return 'null';
}
if (mode.includes('json')) {
let isValidJSON = false;
try {
isValidJSON = typeof JSON.parse(JSON.stringify(data)) === 'object';
isValidJSON = typeof JSON.parse(JSON.stringify(data)) === 'object'
} catch (error) {
console.log('Error parsing JSON: ', error.message);
}

View File

@ -11,7 +11,7 @@ const ResponseSave = ({ item }) => {
const saveResponseToFile = () => {
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:save-response-to-file', response, item.requestSent.url)
.invoke('renderer:save-response-to-file', response, item?.requestSent?.url)
.then(resolve)
.catch((err) => {
toast.error(get(err, 'error.message') || 'Something went wrong!');

View File

@ -43,7 +43,7 @@ const Timeline = ({ request, response }) => {
<div className="mt-4">
<pre className="line response font-bold">
<span className="arrow">{'<'}</span> {response.status} {response.statusText}
<span className="arrow">{'<'}</span> {response.status} - {response.statusText}
</pre>
{responseHeaders.map((h) => {

View File

@ -59,7 +59,7 @@ export default function RunnerResults({ collection }) {
pathname: info.pathname,
relativePath: getRelativePath(collection.pathname, info.pathname)
};
if (newItem.status !== 'error') {
if (newItem.status !== 'error' && newItem.status !== 'skipped') {
if (newItem.testResults) {
const failed = newItem.testResults.filter((result) => result.status === 'fail');
newItem.testStatus = failed.length ? 'fail' : 'pass';
@ -163,29 +163,35 @@ export default function RunnerResults({ collection }) {
<div className="pb-2 font-medium test-summary">
Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}
</div>
{runnerInfo?.statusText ?
<div className="pb-2 font-medium danger">
{runnerInfo?.statusText}
</div>
: null}
{items.map((item) => {
return (
<div key={item.uid}>
<div className="item-path mt-2">
<div className="flex items-center">
<span>
{item.status !== 'error' && item.testStatus === 'pass' ? (
{item.status !== 'error' && item.testStatus === 'pass' && item.status !== 'skipped' ? (
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
) : (
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
)}
</span>
<span
className={`mr-1 ml-2 ${item.status == 'error' || item.testStatus == 'fail' ? 'danger' : ''}`}
className={`mr-1 ml-2 ${item.status == 'error' || item.status == 'skipped' || item.testStatus == 'fail' ? 'danger' : ''}`}
>
{item.relativePath}
</span>
{item.status !== 'error' && item.status !== 'completed' ? (
{item.status !== 'error' && item.status !== 'skipped' && item.status !== 'completed' ? (
<IconRefresh className="animate-spin ml-1" size={18} strokeWidth={1.5} />
) : item.responseReceived?.status ? (
<span className="text-xs link cursor-pointer" onClick={() => setSelectedItem(item)}>
(<span className="mr-1">{item.responseReceived?.status}</span>
<span>{item.responseReceived?.statusText}</span>)
<span className="mr-1">{item.responseReceived?.status}</span>
-&nbsp;
<span>{item.responseReceived?.statusText}</span>
</span>
) : (
<span className="danger text-xs cursor-pointer" onClick={() => setSelectedItem(item)}>

View File

@ -12,10 +12,15 @@ const DeleteCollectionItem = ({ onClose, item, collection }) => {
const isFolder = isItemAFolder(item);
const onConfirm = () => {
dispatch(deleteItem(item.uid, collection.uid)).then(() => {
if (isFolder) {
// close all tabs that belong to the folder
// including the folder itself and its children
const tabUids = [...recursivelyGetAllItemUids(item.items), item.uid]
dispatch(
closeTabs({
tabUids: recursivelyGetAllItemUids(item.items)
tabUids: tabUids
})
);
} else {

View File

@ -35,6 +35,28 @@ const StyledWrapper = styled.div`
background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
}
}
.flexible-container {
width: 100%;
}
@media (max-width: 600px) {
.flexible-container {
width: 500px;
}
}
@media (min-width: 601px) and (max-width: 1200px) {
.flexible-container {
width: 800px;
}
}
@media (min-width: 1201px) {
.flexible-container {
width: 900px;
}
}
`;
export default StyledWrapper;

View File

@ -48,7 +48,7 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
return (
<Modal size="lg" title="Generate Code" handleCancel={onClose} hideFooter={true}>
<StyledWrapper>
<div className="flex w-full">
<div className="flex w-full flexible-container">
<div>
<div className="generate-code-sidebar">
{languages &&
@ -59,7 +59,26 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
className={
language.name === selectedLanguage.name ? 'generate-code-item active' : 'generate-code-item'
}
role="button"
tabIndex={0}
onClick={() => setSelectedLanguage(language)}
onKeyDown={(e) => {
if (e.key === 'Tab' || (e.shiftKey && e.key === 'Tab')) {
e.preventDefault();
const currentIndex = languages.findIndex((lang) => lang.name === selectedLanguage.name);
const nextIndex = e.shiftKey
? (currentIndex - 1 + languages.length) % languages.length
: (currentIndex + 1) % languages.length;
setSelectedLanguage(languages[nextIndex]);
// Explicitly focus on the new active element
const nextElement = document.querySelector(`[data-language="${languages[nextIndex].name}"]`);
nextElement?.focus();
}
}}
data-language={language.name}
aria-pressed={language.name === selectedLanguage.name}
>
<span className="capitalize">{language.name}</span>
</div>
@ -69,6 +88,7 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
<div className="flex-grow p-4">
{isValidUrl(finalUrl) ? (
<CodeView
tabIndex={-1}
language={selectedLanguage}
item={{
...item,

View File

@ -6,6 +6,7 @@ import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
const RenameCollectionItem = ({ collection, item, onClose }) => {
const dispatch = useDispatch();
@ -33,7 +34,8 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
}
dispatch(renameItem(values.name, item.uid, collection.uid))
.then(() => {
toast.success('Request renamed');
isFolder && dispatch(closeTabs({ tabUids: [item.uid] }));
toast.success(isFolder ? 'Folder renamed' : 'Request renamed');
onClose();
})
.catch((err) => {

View File

@ -128,6 +128,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
type: 'folder-settings'
})
);
dispatch(
collectionFolderClicked({
itemUid: item.uid,
@ -136,6 +143,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
};
const handleFolderCollapse = () => {
dispatch(
collectionFolderClicked({
itemUid: item.uid,
collectionUid: collection.uid
})
);
}
const handleRightClick = (event) => {
const _menuDropdown = dropdownTippyRef.current;
if (_menuDropdown) {
@ -183,7 +199,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
if (item.request.url !== '' || (item.draft?.request.url !== undefined && item.draft?.request.url !== '')) {
if (item?.request?.url !== '' || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
@ -260,9 +276,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
})
: null}
<div
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
className="flex flex-grow items-center h-full overflow-hidden"
style={{
paddingLeft: 8
@ -275,11 +288,17 @@ const CollectionItem = ({ item, collection, searchText }) => {
strokeWidth={2}
className={iconClassName}
style={{ color: 'rgb(160 160 160)' }}
onClick={handleFolderCollapse}
/>
) : null}
</div>
<div className="ml-1 flex items-center overflow-hidden">
<div
className="ml-1 flex items-center overflow-hidden flex-1"
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
>
<RequestMethod item={item} />
<span className="item-name" title={item.name}>
{item.name}

View File

@ -17,7 +17,6 @@ const RenameCollection = ({ collection, onClose }) => {
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),
onSubmit: (values) => {

View File

@ -68,6 +68,17 @@ const Collection = ({ collection, searchText }) => {
dispatch(collectionClicked(collection.uid));
};
const handleCollapseCollection = () => {
dispatch(collectionClicked(collection.uid));
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'collection-settings'
})
);
}
const handleRightClick = (event) => {
const _menuDropdown = menuDropdownTippyRef.current;
if (_menuDropdown) {
@ -141,16 +152,17 @@ const Collection = ({ collection, searchText }) => {
<div className="flex py-1 collection-name items-center" ref={drop}>
<div
className="flex flex-grow items-center overflow-hidden"
onClick={handleClick}
onContextMenu={handleRightClick}
>
<IconChevronRight
size={16}
strokeWidth={2}
className={iconClassName}
style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }}
onClick={handleClick}
/>
<div className="ml-1" id="sidebar-collection-name">
<div className="ml-1" id="sidebar-collection-name"
onClick={handleCollapseCollection}
onContextMenu={handleRightClick}>
{collection.name}
</div>
</div>

View File

@ -184,7 +184,7 @@ const Sidebar = () => {
Star
</GitHubButton> */}
</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.34.2</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.36.0</div>
</div>
</div>
</div>

View File

@ -63,16 +63,16 @@ const Table = ({ minColumnWidth = 1, headers = [], children }) => {
[activeColumnIndex, columns, minColumnWidth]
);
const handleMouseUp = useCallback(() => {
setActiveColumnIndex(null);
removeListeners();
}, [removeListeners]);
const removeListeners = useCallback(() => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', removeListeners);
}, [handleMouseMove]);
const handleMouseUp = useCallback(() => {
setActiveColumnIndex(null);
removeListeners?.();
}, [removeListeners]);
useEffect(() => {
if (activeColumnIndex !== null) {
window.addEventListener('mousemove', handleMouseMove);

View File

@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './pages/index';
const rootElement = document.getElementById('root');
if (rootElement) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}

View File

@ -31,6 +31,7 @@ if (!SERVER_RENDERED) {
require('codemirror/addon/search/jump-to-line');
require('codemirror/addon/search/search');
require('codemirror/addon/search/searchcursor');
require('codemirror/addon/display/placeholder');
require('codemirror/keymap/sublime');
require('codemirror-graphql/hint');

View File

@ -25,31 +25,7 @@ import '@fontsource/inter/900.css';
import { setupPolyfills } from 'utils/common/setupPolyfills';
setupPolyfills();
function SafeHydrate({ children }) {
return <div suppressHydrationWarning>{typeof window === 'undefined' ? null : children}</div>;
}
function NoSsr({ children }) {
const SERVER_RENDERED = typeof window === 'undefined';
if (SERVER_RENDERED) {
return null;
}
return <>{children}</>;
}
function MyApp({ Component, pageProps }) {
const [domLoaded, setDomLoaded] = useState(false);
useEffect(() => {
setDomLoaded(true);
}, []);
if (!domLoaded) {
return null;
}
function Main({ children }) {
if (!window.ipcRenderer) {
return (
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 mx-10 my-10 rounded relative" role="alert">
@ -65,23 +41,21 @@ function MyApp({ Component, pageProps }) {
return (
<ErrorBoundary>
<SafeHydrate>
<NoSsr>
<Provider store={ReduxStore}>
<ThemeProvider>
<ToastProvider>
<AppProvider>
<HotkeysProvider>
<Component {...pageProps} />
{children}
</HotkeysProvider>
</AppProvider>
</ToastProvider>
</ThemeProvider>
</Provider>
</NoSsr>
</SafeHydrate>
</ErrorBoundary>
);
}
export default MyApp;
export default Main;

View File

@ -1,41 +0,0 @@
import Document, { Html, Head, Main, NextScript } from 'next/document';
import { ServerStyleSheet } from 'styled-components';
export default class MyDocument extends Document {
static async getInitialProps(ctx) {
const sheet = new ServerStyleSheet();
const originalRenderPage = ctx.renderPage;
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />)
});
const initialProps = await Document.getInitialProps(ctx);
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
</>
)
};
} finally {
sheet.seal();
}
}
render() {
return (
<Html>
<Head />
<body id="bruno-app-body">
<Main />
<NextScript />
</body>
</Html>
);
}
}

View File

@ -1,20 +1,16 @@
import Head from 'next/head';
import Bruno from './Bruno';
import GlobalStyle from '../globalStyles';
import '../i18n';
import Main from './Main';
export default function Home() {
export default function App() {
return (
<div>
<Head>
<title>Bruno</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<GlobalStyle />
<main>
<Main>
<GlobalStyle />
<Bruno />
</Main>
</main>
</div>
);

View File

@ -7,21 +7,19 @@
*/
import { useEffect } from 'react';
import getConfig from 'next/config';
import { PostHog } from 'posthog-node';
import platformLib from 'platform';
import { uuid } from 'utils/common';
const { publicRuntimeConfig } = getConfig();
const posthogApiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
let posthogClient = null;
const isPlaywrightTestRunning = () => {
return publicRuntimeConfig.PLAYWRIGHT ? true : false;
return process.env.PLAYWRIGHT ? true : false;
};
const isDevEnv = () => {
return publicRuntimeConfig.ENV === 'dev';
return import.meta.env.MODE === 'development';
};
const getPosthogClient = () => {
@ -60,7 +58,7 @@ const trackStart = () => {
event: 'start',
properties: {
os: platformLib.os.family,
version: '1.34.2'
version: '1.36.0'
}
});
};

View File

@ -6,7 +6,12 @@ import { useSelector, useDispatch } from 'react-redux';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import NetworkError from 'components/ResponsePane/NetworkError';
import NewRequest from 'components/Sidebar/NewRequest';
import { sendRequest, saveRequest, saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import {
sendRequest,
saveRequest,
saveCollectionRoot,
saveFolderRoot
} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { closeTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { getKeyBindingsForActionAllOS } from './keyMappings';
@ -43,7 +48,11 @@ export const HotkeysProvider = (props) => {
if (collection) {
const item = findItemInCollection(collection, activeTab.uid);
if (item && item.uid) {
if (activeTab.type === 'folder-settings') {
dispatch(saveFolderRoot(collection.uid, item.uid));
} else {
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
}
} else if (activeTab.type === 'collection-settings') {
dispatch(saveCollectionRoot(collection.uid));
}

View File

@ -1,4 +1,3 @@
import getConfig from 'next/config';
import { configureStore } from '@reduxjs/toolkit';
import tasksMiddleware from './middlewares/tasks/middleware';
import debugMiddleware from './middlewares/debug/middleware';
@ -8,9 +7,8 @@ import tabsReducer from './slices/tabs';
import notificationsReducer from './slices/notifications';
import globalEnvironmentsReducer from './slices/global-environments';
const { publicRuntimeConfig } = getConfig();
const isDevEnv = () => {
return publicRuntimeConfig.ENV === 'dev';
return import.meta.env.MODE === 'development';
};
let middleware = [tasksMiddleware.middleware];

View File

@ -1,6 +1,5 @@
import { uuid } from 'utils/common';
import path from 'path';
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, debounce } from 'lodash';
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set } from 'lodash';
import { createSlice } from '@reduxjs/toolkit';
import {
addDepth,
@ -13,6 +12,7 @@ import {
findEnvironmentInCollection,
findItemInCollection,
findItemInCollectionByPathname,
isItemAFolder,
isItemARequest
} from 'utils/collections';
import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
@ -765,6 +765,7 @@ export const collectionsSlice = createSlice({
name: '',
value: action.payload.value,
description: '',
contentType: '',
enabled: true
});
}
@ -786,6 +787,7 @@ export const collectionsSlice = createSlice({
param.name = action.payload.param.name;
param.value = action.payload.param.value;
param.description = action.payload.param.description;
param.contentType = action.payload.param.contentType;
param.enabled = action.payload.param.enabled;
}
}
@ -1673,6 +1675,9 @@ export const collectionsSlice = createSlice({
if (type === 'testrun-ended') {
const info = collection.runnerResult.info;
info.status = 'ended';
if (action.payload.statusText) {
info.statusText = action.payload.statusText;
}
}
if (type === 'request-queued') {
@ -1710,6 +1715,12 @@ export const collectionsSlice = createSlice({
item.responseReceived = action.payload.responseReceived;
item.status = 'error';
}
if (type === 'runner-request-skipped') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.status = 'skipped';
item.responseReceived = action.payload.responseReceived;
}
}
},
resetCollectionRunner: (state, action) => {
@ -1733,6 +1744,15 @@ export const collectionsSlice = createSlice({
item.draft.request.docs = action.payload.docs;
}
}
},
updateFolderDocs: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
if (isItemAFolder(folder)) {
set(folder, 'root.docs', action.payload.docs);
}
}
}
}
});
@ -1827,7 +1847,8 @@ export const {
runRequestEvent,
runFolderEvent,
resetCollectionRunner,
updateRequestDocs
updateRequestDocs,
updateFolderDocs
} = collectionsSlice.actions;
export default collectionsSlice.reducer;

View File

@ -191,7 +191,7 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) =>
// update existing values
variables = variables?.map?.(variable => ({
...variable,
value: stringifyIfNot(globalEnvironmentVariables?.[variable?.name])
value: globalEnvironmentVariables?.[variable?.name]
}));
// add new env values
@ -201,7 +201,7 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) =>
variables.push({
uid: uuid(),
name: key,
value: stringifyIfNot(value),
value,
type: 'text',
secret: false,
enabled: true

View File

@ -9,6 +9,7 @@ const getReadNotificationIds = () => {
return readNotificationIds;
} catch (err) {
toast.error('An error occurred while fetching read notifications');
return [];
}
};
@ -34,7 +35,6 @@ export const notificationSlice = createSlice({
state.loading = action.payload.fetching;
},
setNotifications: (state, action) => {
console.log('notifications', notifications);
let notifications = action.payload.notifications || [];
let readNotificationIds = state.readNotificationIds;
@ -58,14 +58,16 @@ export const notificationSlice = createSlice({
});
},
markNotificationAsRead: (state, action) => {
if (state.readNotificationIds.includes(action.payload.notificationId)) return;
const { notificationId } = action.payload;
if (state.readNotificationIds.includes(notificationId)) return;
const notification = state.notifications.find(
(notification) => notification.id === action.payload.notificationId
(notification) => notification.id === notificationId
);
if (!notification) return;
state.readNotificationIds.push(action.payload.notificationId);
state.readNotificationIds.push(notificationId);
setReadNotificationsIds(state.readNotificationIds);
notification.read = true;
},

View File

@ -93,9 +93,6 @@ if (!SERVER_RENDERED) {
const box = target.getBoundingClientRect();
const hoverTime = getHoverTime(cm);
state.hoverTimeout = setTimeout(onHover, hoverTime);
const onMouseMove = function () {
clearTimeout(state.hoverTimeout);
state.hoverTimeout = setTimeout(onHover, hoverTime);
@ -115,6 +112,9 @@ if (!SERVER_RENDERED) {
onMouseHover(cm, box);
};
const hoverTime = getHoverTime(cm);
state.hoverTimeout = setTimeout(onHover, hoverTime);
CodeMirror.on(document, 'mousemove', onMouseMove);
CodeMirror.on(cm.getWrapperElement(), 'mouseout', onMouseOut);
}

View File

@ -303,7 +303,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
script: si.request.script,
vars: si.request.vars,
assertions: si.request.assertions,
tests: si.request.tests
tests: si.request.tests,
docs: si.request.docs
};
// Handle auth object dynamically
@ -403,7 +404,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
request: {}
};
let { request, meta } = si?.root || {};
let { request, meta, docs } = si?.root || {};
let { headers, script = {}, vars = {}, tests } = request || {};
// folder level headers
@ -435,6 +436,11 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
di.root.request.tests = tests;
}
// folder level docs
if (docs?.length) {
di.root.docs = docs;
}
if (meta?.name) {
di.root.meta = {};
di.root.meta.name = meta?.name;
@ -791,7 +797,7 @@ export const getGlobalEnvironmentVariables = ({ globalEnvironments, activeGlobal
const environment = globalEnvironments?.find(env => env?.uid === activeGlobalEnvironmentUid);
if (environment) {
each(environment.variables, (variable) => {
if (variable.name && variable.value && variable.enabled) {
if (variable.name && variable.enabled) {
variables[variable.name] = variable.value;
}
});
@ -799,6 +805,19 @@ export const getGlobalEnvironmentVariables = ({ globalEnvironments, activeGlobal
return variables;
};
export const getGlobalEnvironmentVariablesMasked = ({ globalEnvironments, activeGlobalEnvironmentUid }) => {
const environment = globalEnvironments?.find(env => env?.uid === activeGlobalEnvironmentUid);
if (environment && Array.isArray(environment.variables)) {
return environment.variables
.filter((variable) => variable.name && variable.value && variable.enabled && variable.secret)
.map((variable) => variable.name);
}
return [];
};
export const getEnvironmentVariables = (collection) => {
let variables = {};
if (collection) {
@ -872,8 +891,22 @@ export const getAllVariables = (collection, item) => {
...requestVariables,
...runtimeVariables
};
const maskedEnvVariables = getEnvironmentVariablesMasked(collection);
const mergedVariablesGlobal = {
...collectionVariables,
...envVariables,
...folderVariables,
...requestVariables,
...runtimeVariables,
}
const maskedEnvVariables = getEnvironmentVariablesMasked(collection) || [];
const maskedGlobalEnvVariables = collection?.globalEnvSecrets || [];
const filteredMaskedEnvVariables = maskedEnvVariables.filter((key) => !(key in mergedVariables));
const filteredMaskedGlobalEnvVariables = maskedGlobalEnvVariables.filter((key) => !(key in mergedVariablesGlobal));
const uniqueMaskedVariables = [...new Set([...filteredMaskedEnvVariables, ...filteredMaskedGlobalEnvVariables])];
return {
...globalEnvironmentVariables,
@ -885,7 +918,7 @@ export const getAllVariables = (collection, item) => {
pathParams: {
...pathParams
},
maskedEnvVariables: filteredMaskedEnvVariables,
maskedEnvVariables: uniqueMaskedVariables,
process: {
env: {
...processEnvVariables

View File

@ -151,7 +151,15 @@ export const relativeDate = (dateString) => {
export const humanizeDate = (dateString) => {
// See this discussion for why .split is necessary
// https://stackoverflow.com/questions/7556591/is-the-javascript-date-object-always-one-day-off
const date = new Date(dateString.split('-'));
if (!dateString || typeof dateString !== 'string') {
return 'Invalid Date';
}
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return 'Invalid Date';
}
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',

View File

@ -58,6 +58,18 @@ describe('common utils', () => {
it('should return invalid date if the date is invalid', () => {
expect(humanizeDate('9999-99-99')).toBe('Invalid Date');
});
it('should return "Invalid Date" if the date is null', () => {
expect(humanizeDate(null)).toBe('Invalid Date');
});
it('should return a humanized date for a valid date in ISO format', () => {
expect(humanizeDate('2024-11-28T00:00:00Z')).toBe('November 28, 2024');
});
it('should return "Invalid Date" for a non-date string', () => {
expect(humanizeDate('some random text')).toBe('Invalid Date');
});
});
describe('relativeDate', () => {

View File

@ -57,6 +57,8 @@ function getDataString(request) {
console.error('Failed to parse JSON data:', error);
return { data: request.data.toString() };
}
} else if (contentType && (contentType.includes('application/xml') || contentType.includes('text/plain'))) {
return { data: request.data };
}
const parsedQueryString = querystring.parse(request.data, { sort: false });
@ -179,7 +181,7 @@ const curlToJson = (curlCommand) => {
username: repr(request.auth.basic?.username),
password: repr(request.auth.basic?.password)
}
}
};
}
}

View File

@ -60,7 +60,7 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque
} else if (contentType.includes('application/json')) {
body.mode = 'json';
body.json = convertToCodeMirrorJson(parsedBody);
} else if (contentType.includes('text/xml')) {
} else if (contentType.includes('xml')) {
body.mode = 'xml';
body.xml = parsedBody;
} else if (contentType.includes('application/x-www-form-urlencoded')) {

View File

@ -291,8 +291,9 @@ export const exportCollection = (collection) => {
};
}
default: {
console.error('Unsupported auth mode:', itemAuth.mode);
return null;
return {
type: 'noauth'
};
}
}
};

View File

@ -79,9 +79,9 @@ export const transformItemsInCollection = (collection) => {
// from 5 feb 2024, multipartFormData needs to have a type
// this was introduced when we added support for file uploads
// below logic is to make older collection exports backward compatible
let multipartFormData = _.get(item, 'request.body.multipartForm');
let multipartFormData = get(item, 'request.body.multipartForm');
if (multipartFormData) {
_.each(multipartFormData, (form) => {
each(multipartFormData, (form) => {
if (!form.type) {
form.type = 'text';
}

View File

@ -166,6 +166,17 @@ const importScriptsFromEvents = (events, requestObject, options, pushTranslation
});
};
const importCollectionLevelVariables = (variables, requestObject) => {
const vars = variables.map((v) => ({
uid: uuid(),
name: v.key,
value: v.value,
enabled: true
}));
requestObject.vars.req = vars;
};
const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) => {
brunoParent.items = brunoParent.items || [];
const folderMap = {};
@ -188,6 +199,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
type: 'folder',
items: [],
root: {
docs: i.description || '',
meta: {
name: folderName
},
@ -215,6 +227,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
brunoParent.items.push(brunoFolderItem);
folderMap[folderName] = brunoFolderItem;
} else {
if (i.request) {
const baseRequestName = i.name;
@ -472,6 +485,7 @@ const importPostmanV2Collection = (collection, options) => {
items: [],
environments: [],
root: {
docs: collection.info.description || '',
meta: {
name: collection.info.name
},
@ -494,6 +508,10 @@ const importPostmanV2Collection = (collection, options) => {
importScriptsFromEvents(collection.event, brunoCollection.root.request, options, pushTranslationLog);
}
if (collection?.variable){
importCollectionLevelVariables(collection.variable, brunoCollection.root.request);
}
importPostmanV2CollectionItem(brunoCollection, collection.item, collection.auth, options);
return brunoCollection;

View File

@ -13,6 +13,28 @@
"type": "git",
"url": "git+https://github.com/usebruno/bruno.git"
},
"keywords": [
"API",
"testing",
"automation",
"cli",
"command-line",
"bruno",
"HTTP requests",
"rest-api",
"api-client",
"api-automation",
"request-handling",
"mock-api",
"http-client",
"async",
"promise",
"javascript",
"nodejs",
"automation-tool",
"postman-alternative",
"api-scripting"
],
"scripts": {
"test": "node --experimental-vm-modules $(npx which jest)"
},
@ -37,7 +59,7 @@
"fs-extra": "^10.1.0",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"json-bigint": "^1.0.0",
"iconv-lite": "^0.6.3",
"lodash": "^4.17.21",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",

View File

@ -1,7 +1,7 @@
const fs = require('fs');
const chalk = require('chalk');
const path = require('path');
const { forOwn } = require('lodash');
const { forOwn, cloneDeep } = require('lodash');
const { exists, isFile, isDirectory } = require('../utils/filesystem');
const { runSingleRequest } = require('../runner/run-single-request');
const { bruToEnvJson, getEnvVars } = require('../utils/bru');
@ -93,8 +93,68 @@ const printRunSummary = (results) => {
};
};
const createCollectionFromPath = (collectionPath) => {
const environmentsPath = path.join(collectionPath, `environments`);
const getFilesInOrder = (collectionPath) => {
let collection = {
pathname: collectionPath
};
const traverse = (currentPath) => {
const filesInCurrentDir = fs.readdirSync(currentPath);
if (currentPath.includes('node_modules')) {
return;
}
const currentDirItems = [];
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (
stats.isDirectory() &&
filePath !== environmentsPath &&
!filePath.startsWith('.git') &&
!filePath.startsWith('node_modules')
) {
let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) }
const folderBruFilePath = path.join(filePath, 'folder.bru');
const folderBruFileExists = fs.existsSync(folderBruFilePath);
if(folderBruFileExists) {
const folderBruContent = fs.readFileSync(folderBruFilePath, 'utf8');
let folderBruJson = collectionBruToJson(folderBruContent);
folderItem.root = folderBruJson;
}
currentDirItems.push(folderItem);
}
}
for (const file of filesInCurrentDir) {
if (['collection.bru', 'folder.bru'].includes(file)) {
continue;
}
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (!stats.isDirectory() && path.extname(filePath) === '.bru') {
const bruContent = fs.readFileSync(filePath, 'utf8');
const bruJson = bruToJson(bruContent);
currentDirItems.push({
name: file,
pathname: filePath,
...bruJson
});
}
}
return currentDirItems
};
collection.items = traverse(collectionPath);
return collection;
};
return getFilesInOrder(collectionPath);
};
const getBruFilesRecursively = (dir, testsOnly) => {
const environmentsPath = 'environments';
const collection = {};
const getFilesInOrder = (dir) => {
let bruJsons = [];
@ -359,6 +419,12 @@ const handler = async function (argv) {
const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
const brunoConfig = JSON.parse(brunoConfigFile);
const collectionRoot = getCollectionRoot(collectionPath);
let collection = createCollectionFromPath(collectionPath);
collection = {
brunoConfig,
root: collectionRoot,
...collection
}
if (clientCertConfig) {
try {
@ -571,7 +637,7 @@ const handler = async function (argv) {
let currentRequestIndex = 0;
let nJumps = 0; // count the number of jumps to avoid infinite loops
while (currentRequestIndex < bruJsons.length) {
const iter = bruJsons[currentRequestIndex];
const iter = cloneDeep(bruJsons[currentRequestIndex]);
const { bruFilepath, bruJson } = iter;
const start = process.hrtime();
@ -584,7 +650,8 @@ const handler = async function (argv) {
processEnvVars,
brunoConfig,
collectionRoot,
runtime
runtime,
collection
);
results.push({

View File

@ -24,7 +24,7 @@ const EXIT_STATUS = {
// Invalid output format requested
ERROR_INCORRECT_OUTPUT_FORMAT: 9,
// Everything else
ERROR_GENERIC: 255,
ERROR_GENERIC: 255
};
module.exports = {

View File

@ -13,14 +13,17 @@ const getContentType = (headers = {}) => {
return contentType;
};
const interpolateVars = (request, envVars = {}, runtimeVariables = {}, processEnvVars = {}) => {
const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => {
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
// we clone envVars because we don't want to modify the original object
envVars = cloneDeep(envVars);
envVariables = cloneDeep(envVariables);
// envVars can inturn have values as {{process.env.VAR_NAME}}
// so we need to interpolate envVars first with processEnvVars
forOwn(envVars, (value, key) => {
envVars[key] = interpolate(value, {
forOwn(envVariables, (value, key) => {
envVariables[key] = interpolate(value, {
process: {
env: {
...processEnvVars
@ -36,7 +39,10 @@ const interpolateVars = (request, envVars = {}, runtimeVariables = {}, processEn
// runtimeVariables take precedence over envVars
const combinedVars = {
...envVars,
...collectionVariables,
...envVariables,
...folderVariables,
...requestVariables,
...runtimeVariables,
process: {
env: {
@ -80,11 +86,12 @@ const interpolateVars = (request, envVars = {}, runtimeVariables = {}, processEn
} catch (err) {}
}
} else if (contentType === 'multipart/form-data') {
if (typeof request.data === 'object' && !(request?.data instanceof FormData)) {
if (Array.isArray(request?.data) && !(request.data instanceof FormData)) {
try {
forOwn(request?.data, (value, key) => {
request.data[key] = _interpolate(value);
});
request.data = request?.data?.map(d => ({
...d,
value: _interpolate(d?.value)
}));
} catch (err) {}
}
} else {

View File

@ -1,24 +1,24 @@
const { get, each, filter } = require('lodash');
const fs = require('fs');
var JSONbig = require('json-bigint');
const decomment = require('decomment');
const crypto = require('node:crypto');
const { mergeHeaders, mergeScripts, mergeVars, getTreePathFromCollectionToItem } = require('../utils/collection');
const { createFormData } = require('../utils/form-data');
const prepareRequest = (request, collectionRoot) => {
const prepareRequest = (item = {}, collection = {}) => {
const request = item?.request;
const brunoConfig = get(collection, 'brunoConfig', {});
const headers = {};
let contentTypeDefined = false;
// collection headers
each(get(collectionRoot, 'request.headers', []), (h) => {
if (h.enabled) {
headers[h.name] = h.value;
if (h.name.toLowerCase() === 'content-type') {
contentTypeDefined = true;
const scriptFlow = brunoConfig?.scripts?.flow ?? 'sandwich';
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
if (requestTreePath && requestTreePath.length > 0) {
mergeHeaders(collection, request, requestTreePath);
mergeScripts(collection, request, requestTreePath, scriptFlow);
mergeVars(collection, request, requestTreePath);
}
}
});
each(request.headers, (h) => {
each(get(request, 'headers', []), (h) => {
if (h.enabled) {
headers[h.name] = h.value;
if (h.name.toLowerCase() === 'content-type') {
@ -31,10 +31,11 @@ const prepareRequest = (request, collectionRoot) => {
method: request.method,
url: request.url,
headers: headers,
pathParams: request?.params?.filter((param) => param.type === 'path')
pathParams: request?.params?.filter((param) => param.type === 'path'),
responseType: 'arraybuffer'
};
const collectionAuth = get(collectionRoot, 'request.auth');
const collectionAuth = get(collection, 'root.request.auth');
if (collectionAuth && request.auth.mode === 'inherit') {
if (collectionAuth.mode === 'basic') {
axiosRequest.auth = {
@ -96,16 +97,10 @@ const prepareRequest = (request, collectionRoot) => {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/json';
}
let jsonBody;
try {
jsonBody = decomment(request?.body?.json);
axiosRequest.data = decomment(request?.body?.json);
} catch (error) {
jsonBody = request?.body?.json;
}
try {
axiosRequest.data = JSONbig.parse(jsonBody);
} catch (error) {
axiosRequest.data = jsonBody;
axiosRequest.data = request?.body?.json;
}
}
@ -118,7 +113,7 @@ const prepareRequest = (request, collectionRoot) => {
if (request.body.mode === 'xml') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'text/xml';
axiosRequest.headers['content-type'] = 'application/xml';
}
axiosRequest.data = request.body.xml;
}
@ -140,10 +135,8 @@ const prepareRequest = (request, collectionRoot) => {
if (request.body.mode === 'multipartForm') {
axiosRequest.headers['content-type'] = 'multipart/form-data';
const params = {};
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
each(enabledParams, (p) => (params[p.name] = p.value));
axiosRequest.data = params;
axiosRequest.data = enabledParams;
}
if (request.body.mode === 'graphql') {
@ -157,10 +150,19 @@ const prepareRequest = (request, collectionRoot) => {
axiosRequest.data = graphqlQuery;
}
if (request.script && request.script.length) {
if (request.script) {
axiosRequest.script = request.script;
}
if (request.tests) {
axiosRequest.tests = request.tests;
}
axiosRequest.vars = request.vars;
axiosRequest.collectionVariables = request.collectionVariables;
axiosRequest.folderVariables = request.folderVariables;
axiosRequest.requestVariables = request.requestVariables;
return axiosRequest;
};

View File

@ -17,10 +17,11 @@ const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { makeAxiosInstance } = require('../utils/axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
const { shouldUseProxy, PatchedHttpsProxyAgent, getSystemProxyEnvVariables } = require('../utils/proxy-util');
const path = require('path');
const { createFormData } = require('../utils/common');
const { parseDataFromResponse } = require('../utils/common');
const { getCookieStringForUrl, saveCookies, shouldUseCookies } = require('../utils/cookies');
const { createFormData } = require('../utils/form-data');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const onConsoleLog = (type, args) => {
@ -36,13 +37,17 @@ const runSingleRequest = async function (
processEnvVars,
brunoConfig,
collectionRoot,
runtime
runtime,
collection
) {
try {
let request;
let nextRequestName;
request = prepareRequest(bruJson.request, collectionRoot);
let item = {
pathname: path.join(collectionPath, filename),
...bruJson
}
request = prepareRequest(item, collection);
request.__bruno__executionMode = 'cli';
@ -50,10 +55,7 @@ const runSingleRequest = async function (
scriptingConfig.runtime = runtime;
// run pre request script
const requestScriptFile = compact([
get(collectionRoot, 'request.script.req'),
get(bruJson, 'request.script.req')
]).join(os.EOL);
const requestScriptFile = get(request, 'script.req');
if (requestScriptFile?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
const result = await scriptRuntime.runRequestScript(
@ -139,27 +141,40 @@ const runSingleRequest = async function (
}
}
// set proxy if enabled
const proxyEnabled = get(brunoConfig, 'proxy.enabled', false);
const shouldProxy = shouldUseProxy(request.url, get(brunoConfig, 'proxy.bypassProxy', ''));
if (proxyEnabled && shouldProxy) {
const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions);
const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions);
const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false);
const socksEnabled = proxyProtocol.includes('socks');
let proxyMode = 'off';
let proxyConfig = {};
const collectionProxyConfig = get(brunoConfig, 'proxy', {});
const collectionProxyEnabled = get(collectionProxyConfig, 'enabled', false);
if (collectionProxyEnabled === true) {
proxyConfig = collectionProxyConfig;
proxyMode = 'on';
} else {
// if the collection level proxy is not set, pick the system level proxy by default, to maintain backward compatibility
const { http_proxy, https_proxy } = getSystemProxyEnvVariables();
if (http_proxy?.length || https_proxy?.length) {
proxyMode = 'system';
}
}
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false);
const socksEnabled = proxyProtocol.includes('socks');
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
let proxyUri;
if (proxyAuthEnabled) {
const proxyAuthUsername = interpolateString(get(brunoConfig, 'proxy.auth.username'), interpolationOptions);
const proxyAuthPassword = interpolateString(get(brunoConfig, 'proxy.auth.password'), interpolationOptions);
const proxyAuthUsername = interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions);
const proxyAuthPassword = interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions);
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
} else {
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
if (socksEnabled) {
request.httpsAgent = new SocksProxyAgent(
proxyUri,
@ -173,6 +188,39 @@ const runSingleRequest = async function (
);
request.httpAgent = new HttpProxyAgent(proxyUri);
}
} else {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
} else if (proxyMode === 'system') {
const { http_proxy, https_proxy, no_proxy } = getSystemProxyEnvVariables();
const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || '');
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length) {
new URL(http_proxy);
request.httpAgent = new HttpProxyAgent(http_proxy);
}
} catch (error) {
throw new Error('Invalid system http_proxy');
}
try {
if (https_proxy?.length) {
new URL(https_proxy);
request.httpsAgent = new PatchedHttpsProxyAgent(
https_proxy,
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
);
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
} else {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
} else if (Object.keys(httpsAgentRequestFields).length > 0) {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
@ -226,6 +274,9 @@ const runSingleRequest = async function (
/** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request);
const { data } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
response.data = data;
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
@ -236,6 +287,8 @@ const runSingleRequest = async function (
}
} catch (err) {
if (err?.response) {
const { data } = parseDataFromResponse(err?.response);
err.response.data = data;
response = err.response;
// Prevents the duration on leaking to the actual result
@ -291,10 +344,7 @@ const runSingleRequest = async function (
}
// run post response script
const responseScriptFile = compact([
get(collectionRoot, 'request.script.res'),
get(bruJson, 'request.script.res')
]).join(os.EOL);
const responseScriptFile = get(request, 'script.res');
if (responseScriptFile?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
const result = await scriptRuntime.runResponseScript(
@ -339,7 +389,7 @@ const runSingleRequest = async function (
// run tests
let testResults = [];
const testFile = compact([get(collectionRoot, 'request.tests'), get(bruJson, 'request.tests')]).join(os.EOL);
const testFile = get(request, 'tests');
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const result = await testRuntime.runTests(

View File

@ -10,6 +10,7 @@ const { CLI_VERSION } = require('../constants');
function makeAxiosInstance() {
/** @type {axios.AxiosInstance} */
const instance = axios.create({
proxy: false,
headers: {
"User-Agent": `bruno-runtime/${CLI_VERSION}`
}

View File

@ -58,7 +58,7 @@ const bruToJson = (bru) => {
body: _.get(json, 'body', {}),
vars: _.get(json, 'vars', []),
assertions: _.get(json, 'assertions', []),
script: _.get(json, 'script', ''),
script: _.get(json, 'script', {}),
tests: _.get(json, 'tests', '')
}
};

View File

@ -0,0 +1,208 @@
const { get, each, find, compact } = require('lodash');
const os = require('os');
const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
let collectionHeaders = get(collection, 'root.request.headers', []);
collectionHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
}
});
for (let i of requestTreePath) {
if (i.type === 'folder') {
let _headers = get(i, 'root.request.headers', []);
_headers.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
}
});
} else {
const _headers = i?.draft ? get(i, 'draft.request.headers', []) : get(i, 'request.headers', []);
_headers.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
}
});
}
}
request.headers = Array.from(headers, ([name, value]) => ({ name, value, enabled: true }));
};
const mergeVars = (collection, request, requestTreePath) => {
let reqVars = new Map();
let collectionRequestVars = get(collection, 'root.request.vars.req', []);
let collectionVariables = {};
collectionRequestVars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
collectionVariables[_var.name] = _var.value;
}
});
let folderVariables = {};
let requestVariables = {};
for (let i of requestTreePath) {
if (i.type === 'folder') {
let vars = get(i, 'root.request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
folderVariables[_var.name] = _var.value;
}
});
} else {
const vars = i?.draft ? get(i, 'draft.request.vars.req', []) : get(i, 'request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
requestVariables[_var.name] = _var.value;
}
});
}
}
request.collectionVariables = collectionVariables;
request.folderVariables = folderVariables;
request.requestVariables = requestVariables;
if(request?.vars) {
request.vars.req = Array.from(reqVars, ([name, value]) => ({
name,
value,
enabled: true,
type: 'request'
}));
}
let resVars = new Map();
let collectionResponseVars = get(collection, 'root.request.vars.res', []);
collectionResponseVars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
}
});
for (let i of requestTreePath) {
if (i.type === 'folder') {
let vars = get(i, 'root.request.vars.res', []);
vars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
}
});
} else {
const vars = i?.draft ? get(i, 'draft.request.vars.res', []) : get(i, 'request.vars.res', []);
vars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
}
});
}
}
if(request?.vars) {
request.vars.res = Array.from(resVars, ([name, value]) => ({
name,
value,
enabled: true,
type: 'response'
}));
}
};
const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
let collectionPreReqScript = get(collection, 'root.request.script.req', '');
let collectionPostResScript = get(collection, 'root.request.script.res', '');
let collectionTests = get(collection, 'root.request.tests', '');
let combinedPreReqScript = [];
let combinedPostResScript = [];
let combinedTests = [];
for (let i of requestTreePath) {
if (i.type === 'folder') {
let preReqScript = get(i, 'root.request.script.req', '');
if (preReqScript && preReqScript.trim() !== '') {
combinedPreReqScript.push(preReqScript);
}
let postResScript = get(i, 'root.request.script.res', '');
if (postResScript && postResScript.trim() !== '') {
combinedPostResScript.push(postResScript);
}
let tests = get(i, 'root.request.tests', '');
if (tests && tests?.trim?.() !== '') {
combinedTests.push(tests);
}
}
}
request.script.req = compact([collectionPreReqScript, ...combinedPreReqScript, request?.script?.req || '']).join(os.EOL);
if (scriptFlow === 'sequential') {
request.script.res = compact([collectionPostResScript, ...combinedPostResScript, request?.script?.res || '']).join(os.EOL);
} else {
request.script.res = compact([request?.script?.res || '', ...combinedPostResScript.reverse(), collectionPostResScript]).join(os.EOL);
}
if (scriptFlow === 'sequential') {
request.tests = compact([collectionTests, ...combinedTests, request?.tests || '']).join(os.EOL);
} else {
request.tests = compact([request?.tests || '', ...combinedTests.reverse(), collectionTests]).join(os.EOL);
}
};
const findItem = (items = [], pathname) => {
return find(items, (i) => i.pathname === pathname);
};
const findItemInCollection = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return findItem(flattenedItems, pathname);
};
const findParentItemInCollection = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return find(flattenedItems, (item) => {
return item.items && find(item.items, (i) => i.pathname === pathname);
});
};
const flattenItems = (items = []) => {
const flattenedItems = [];
const flatten = (itms, flattened) => {
each(itms, (i) => {
flattened.push(i);
if (i.items && i.items.length) {
flatten(i.items, flattened);
}
});
};
flatten(items, flattenedItems);
return flattenedItems;
};
const getTreePathFromCollectionToItem = (collection, _item) => {
let path = [];
let item = findItemInCollection(collection, _item.pathname);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item.pathname);
}
return path;
};
module.exports = {
mergeHeaders,
mergeVars,
mergeScripts,
getTreePathFromCollectionToItem
}

View File

@ -1,7 +1,4 @@
const fs = require('fs');
const FormData = require('form-data');
const { forOwn } = require('lodash');
const path = require('path');
const iconv = require('iconv-lite');
const lpad = (str, width) => {
let paddedStr = str;
@ -19,33 +16,38 @@ const rpad = (str, width) => {
return paddedStr;
};
const createFormData = (datas, collectionPath) => {
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
const form = new FormData();
forOwn(datas, (value, key) => {
if (typeof value == 'string') {
form.append(key, value);
return;
const parseDataFromResponse = (response, disableParsingResponseJson = false) => {
// Parse the charset from content type: https://stackoverflow.com/a/33192813
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(response.headers['content-type'] || '');
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec#using_exec_with_regexp_literals
const charsetValue = charsetMatch?.[1];
const dataBuffer = Buffer.from(response.data);
// Overwrite the original data for backwards compatibility
let data;
if (iconv.encodingExists(charsetValue)) {
data = iconv.decode(dataBuffer, charsetValue);
} else {
data = iconv.decode(dataBuffer, 'utf-8');
}
// Try to parse response to JSON, this can quietly fail
try {
// Filter out ZWNBSP character
// https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d
data = data.replace(/^\uFEFF/, '');
// If the response is a string and starts and ends with double quotes, it's a stringified JSON and should not be parsed
if (!disableParsingResponseJson && !(typeof data === 'string' && data.startsWith('"') && data.endsWith('"'))) {
data = JSON.parse(data);
}
} catch {
}
const filePaths = value || [];
filePaths?.forEach?.((filePath) => {
let trimmedFilePath = filePath.trim();
if (!path.isAbsolute(trimmedFilePath)) {
trimmedFilePath = path.join(collectionPath, trimmedFilePath);
}
form.append(key, fs.createReadStream(trimmedFilePath), path.basename(trimmedFilePath));
});
});
return form;
return { data, dataBuffer };
};
module.exports = {
lpad,
rpad,
createFormData
parseDataFromResponse
};

View File

@ -0,0 +1,42 @@
const { forEach } = require('lodash');
const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
const createFormData = (data, collectionPath) => {
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
const form = new FormData();
forEach(data, (datum) => {
const { name, type, value, contentType } = datum;
let options = {};
if (contentType) {
options.contentType = contentType;
}
if (type === 'text') {
if (Array.isArray(value)) {
value.forEach((val) => form.append(name, val, options));
} else {
form.append(name, value, options);
}
return;
}
if (type === 'file') {
const filePaths = value || [];
filePaths.forEach((filePath) => {
let trimmedFilePath = filePath.trim();
if (!path.isAbsolute(trimmedFilePath)) {
trimmedFilePath = path.join(collectionPath, trimmedFilePath);
}
options.filename = path.basename(trimmedFilePath);
form.append(name, fs.createReadStream(trimmedFilePath), options);
});
}
});
return form;
};
module.exports = {
createFormData
}

View File

@ -79,7 +79,18 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
}
const getSystemProxyEnvVariables = () => {
const { http_proxy, HTTP_PROXY, https_proxy, HTTPS_PROXY, no_proxy, NO_PROXY } = process.env;
return {
http_proxy: http_proxy || HTTP_PROXY,
https_proxy: https_proxy || HTTPS_PROXY,
no_proxy: no_proxy || NO_PROXY
};
}
module.exports = {
shouldUseProxy,
PatchedHttpsProxyAgent
PatchedHttpsProxyAgent,
getSystemProxyEnvVariables
};

View File

@ -6,15 +6,19 @@ describe('prepare-request: prepareRequest', () => {
describe('Decomments request body', () => {
it('If request body is valid JSON', async () => {
const body = { mode: 'json', json: '{\n"test": "{{someVar}}" // comment\n}' };
const expected = { test: '{{someVar}}' };
const result = prepareRequest({ body });
const expected = `{
\"test\": \"{{someVar}}\"
}`;
const result = prepareRequest({ request: { body } });
expect(result.data).toEqual(expected);
});
it('If request body is not valid JSON', async () => {
const body = { mode: 'json', json: '{\n"test": {{someVar}} // comment\n}' };
const expected = '{\n"test": {{someVar}} \n}';
const result = prepareRequest({ body });
const expected = `{
\"test\": {{someVar}}
}`;
const result = prepareRequest({ request: { body } });
expect(result.data).toEqual(expected);
});
});

View File

@ -3,7 +3,7 @@ require('dotenv').config({ path: process.env.DOTENV_PATH });
const config = {
appId: 'com.usebruno.app',
productName: 'Bruno',
electronVersion: '31.2.1',
electronVersion: '33.2.1',
directories: {
buildResources: 'resources',
output: 'out'

View File

@ -1,5 +1,5 @@
{
"version": "v1.34.2",
"version": "v1.36.0",
"name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com",
@ -50,7 +50,7 @@
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "3.3.4",
"nanoid": "3.3.8",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^4.1.3",
@ -62,7 +62,8 @@
"dmg-license": "^1.0.11"
},
"devDependencies": {
"electron": "31.2.1",
"electron-builder": "25.1.8"
"electron": "33.2.1",
"electron-builder": "25.1.8",
"electron-devtools-installer": "^4.0.0"
}
}

View File

@ -389,6 +389,8 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
};
const unlink = (win, pathname, collectionUid, collectionPath) => {
console.log(`watcher unlink: ${pathname}`);
if (isBruEnvironmentConfig(pathname, collectionPath)) {
return unlinkEnvironmentFile(win, pathname, collectionUid);
}
@ -506,6 +508,33 @@ class Watcher {
this.watchers[watchPath] = null;
}
}
getWatcherByItemPath(itemPath) {
const paths = Object.keys(this.watchers);
const watcherPath = paths?.find(collectionPath => {
const absCollectionPath = path.resolve(collectionPath);
const absItemPath = path.resolve(itemPath);
return absItemPath.startsWith(absCollectionPath);
});
return watcherPath ? this.watchers[watcherPath] : null;
}
unlinkItemPathInWatcher(itemPath) {
const watcher = this.getWatcherByItemPath(itemPath);
if (watcher) {
watcher.unwatch(itemPath);
}
}
addItemPathInWatcher(itemPath) {
const watcher = this.getWatcherByItemPath(itemPath);
if (watcher && !watcher?.has?.(itemPath)) {
watcher?.add?.(itemPath);
}
}
}
module.exports = Watcher;

View File

@ -11,7 +11,7 @@ if (isDev) {
}
const { format } = require('url');
const { BrowserWindow, app, Menu, ipcMain } = require('electron');
const { BrowserWindow, app, session, Menu, ipcMain } = require('electron');
const { setContentSecurityPolicy } = require('electron-util');
const menuTemplate = require('./app/menu-template');
@ -51,6 +51,24 @@ let watcher;
// Prepare the renderer once the app is ready
app.on('ready', async () => {
if (isDev) {
const { installExtension, REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer');
try {
const extensions = await installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS], {
loadExtensionOptions: {allowFileAccess: true},
})
console.log(`Added Extensions: ${extensions.map(ext => ext.name).join(", ")}`)
await require("node:timers/promises").setTimeout(1000);
session.defaultSession.getAllExtensions().map((ext) => {
console.log(`Loading Extension: ${ext.name}`);
session.defaultSession.loadExtension(ext.path)
});
} catch (err) {
console.error('An error occurred while loading extensions: ', err);
}
}
Menu.setApplicationMenu(menu);
const { maximized, x, y, width, height } = loadWindowState();

View File

@ -1,5 +1,7 @@
const _ = require('lodash');
const fs = require('fs');
const fsExtra = require('fs-extra');
const os = require('os');
const path = require('path');
const { ipcMain, shell, dialog, app } = require('electron');
const { envJsonToBru, bruToJson, jsonToBru, jsonToCollectionBru } = require('../bru');
@ -17,7 +19,11 @@ const {
isWSLPath,
normalizeWslPath,
normalizeAndResolvePath,
safeToRename
safeToRename,
sanitizeCollectionName,
isWindowsOS,
isValidFilename,
hasSubDirectories,
} = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@ -63,6 +69,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
'renderer:create-collection',
async (event, collectionName, collectionFolderName, collectionLocation) => {
try {
collectionFolderName = sanitizeDirectoryName(collectionFolderName);
collectionName = sanitizeCollectionName(collectionName);
const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(dirPath)) {
const files = fs.readdirSync(dirPath);
@ -71,9 +79,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`collection: ${dirPath} already exists and is not empty`);
}
}
if (!isValidPathname(dirPath)) {
throw new Error(`collection: invalid pathname - ${dir}`);
if (!isValidPathname(path.basename(dirPath))) {
throw new Error(`collection: invalid pathname - ${dirPath}`);
}
if (!fs.existsSync(dirPath)) {
@ -101,13 +108,14 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle(
'renderer:clone-collection',
async (event, collectionName, collectionFolderName, collectionLocation, previousPath) => {
collectionFolderName = sanitizeCollectionName(collectionFolderName);
const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(dirPath)) {
throw new Error(`collection: ${dirPath} already exists`);
}
if (!isValidPathname(dirPath)) {
throw new Error(`collection: invalid pathname - ${dir}`);
if (!isValidPathname(path.basename(dirPath))) {
throw new Error(`collection: invalid pathname - ${dirPath}`);
}
// create dir
@ -146,6 +154,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// rename collection
ipcMain.handle('renderer:rename-collection', async (event, newName, collectionPathname) => {
try {
newName = sanitizeCollectionName(newName);
const brunoJsonFilePath = path.join(collectionPathname, 'bruno.json');
const content = fs.readFileSync(brunoJsonFilePath, 'utf8');
const json = JSON.parse(content);
@ -201,7 +210,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (fs.existsSync(pathname)) {
throw new Error(`path: ${pathname} already exists`);
}
if (!isValidFilename(request.name)) {
throw new Error(`path: ${request.name}.bru is not a valid filename`);
}
const content = jsonToBru(request);
await writeFile(pathname, content);
} catch (error) {
@ -337,6 +348,12 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// rename item
ipcMain.handle('renderer:rename-item', async (event, oldPath, newPath, newName) => {
const tempDir = path.join(os.tmpdir(), `temp-folder-${Date.now()}`);
// const parentDir = path.dirname(oldPath);
const isWindowsOSAndNotWSLAndItemHasSubDirectories = isDirectory(oldPath) && isWindowsOS() && !isWSLPath(oldPath) && hasSubDirectories(oldPath);
// let parentDirUnwatched = false;
// let parentDirRewatched = false;
try {
// Normalize paths if they are WSL paths
oldPath = isWSLPath(oldPath) ? normalizeWslPath(oldPath) : normalizeAndResolvePath(oldPath);
@ -358,33 +375,80 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const newBruFilePath = bruFile.replace(oldPath, newPath);
moveRequestUid(bruFile, newBruFilePath);
}
return fs.renameSync(oldPath, newPath);
// watcher.unlinkItemPathInWatcher(parentDir);
// parentDirUnwatched = true;
/**
* If it is windows OS
* And it is not WSL path (meaning its not linux running on windows using WSL)
* And it has sub directories
* Only then we need to use the temp dir approach to rename the folder
*
* Windows OS would sometimes throw error when renaming a folder with sub directories
* This is a alternative approach to avoid that error
*/
if (isWindowsOSAndNotWSLAndItemHasSubDirectories) {
await fsExtra.copy(oldPath, tempDir);
await fsExtra.remove(oldPath);
await fsExtra.move(tempDir, newPath, { overwrite: true });
await fsExtra.remove(tempDir);
} else {
await fs.renameSync(oldPath, newPath);
}
// watcher.addItemPathInWatcher(parentDir);
// parentDirRewatched = true;
return newPath;
}
const isBru = hasBruExtension(oldPath);
if (!isBru) {
if (!hasBruExtension(oldPath)) {
throw new Error(`path: ${oldPath} is not a bru file`);
}
// update name in file and save new copy, then delete old copy
const data = fs.readFileSync(oldPath, 'utf8');
const jsonData = bruToJson(data);
if (!isValidFilename(newName)) {
throw new Error(`path: ${newName} is not a valid filename`);
}
// update name in file and save new copy, then delete old copy
const data = await fs.promises.readFile(oldPath, 'utf8'); // Use async read
const jsonData = bruToJson(data);
jsonData.name = newName;
moveRequestUid(oldPath, newPath);
const content = jsonToBru(jsonData);
await fs.unlinkSync(oldPath);
await fs.promises.unlink(oldPath);
await writeFile(newPath, content);
return newPath;
} catch (error) {
// in case an error occurs during the rename file operations after unlinking the parent dir
// and the rewatch fails, we need to add it back to watcher
// if (parentDirUnwatched && !parentDirRewatched) {
// watcher.addItemPathInWatcher(parentDir);
// }
// in case the rename file operations fails, and we see that the temp dir exists
// and the old path does not exist, we need to restore the data from the temp dir to the old path
if (isWindowsOSAndNotWSLAndItemHasSubDirectories) {
if (fsExtra.pathExistsSync(tempDir) && !fsExtra.pathExistsSync(oldPath)) {
try {
await fsExtra.copy(tempDir, oldPath);
await fsExtra.remove(tempDir);
} catch (err) {
console.error("Failed to restore data to the old path:", err);
}
}
}
return Promise.reject(error);
}
});
// new folder
ipcMain.handle('renderer:new-folder', async (event, pathname) => {
const resolvedFolderName = sanitizeDirectoryName(path.basename(pathname));
pathname = path.join(path.dirname(pathname), resolvedFolderName);
try {
if (!fs.existsSync(pathname)) {
fs.mkdirSync(pathname);
@ -443,7 +507,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:import-collection', async (event, collection, collectionLocation) => {
try {
let collectionName = sanitizeDirectoryName(collection.name);
let collectionName = sanitizeCollectionName(collection.name);
let collectionPath = path.join(collectionLocation, collectionName);
if (fs.existsSync(collectionPath)) {
@ -459,6 +523,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
fs.writeFileSync(filePath, content);
}
if (item.type === 'folder') {
item.name = sanitizeDirectoryName(item.name);
const folderPath = path.join(currentPath, item.name);
fs.mkdirSync(folderPath);

View File

@ -2,7 +2,7 @@ const crypto = require('crypto');
const { URL } = require('url');
function isStrPresent(str) {
return str && str !== '' && str !== 'undefined';
return str && str.trim() !== '' && str.trim() !== 'undefined';
}
function stripQuotes(str) {
@ -15,7 +15,10 @@ function containsDigestHeader(response) {
}
function containsAuthorizationHeader(originalRequest) {
return Boolean(originalRequest.headers['Authorization']);
return Boolean(
originalRequest.headers['Authorization'] ||
originalRequest.headers['authorization']
);
}
function md5(input) {
@ -24,11 +27,10 @@ function md5(input) {
function addDigestInterceptor(axiosInstance, request) {
const { username, password } = request.digestConfig;
console.debug(request);
console.debug('Digest Auth Interceptor Initialized');
if (!isStrPresent(username) || !isStrPresent(password)) {
console.warn('Required Digest Auth fields are not present');
console.warn('Required Digest Auth fields (username/password) are not present');
return;
}
@ -37,41 +39,82 @@ function addDigestInterceptor(axiosInstance, request) {
(error) => {
const originalRequest = error.config;
// Prevent retry loops
if (originalRequest._retry) {
return Promise.reject(error);
}
originalRequest._retry = true;
if (
error.response?.status === 401 &&
containsDigestHeader(error.response) &&
!containsAuthorizationHeader(originalRequest)
) {
console.debug('Processing Digest Authentication Challenge');
console.debug(error.response.headers['www-authenticate']);
const authDetails = error.response.headers['www-authenticate']
.split(',')
.map((v) => v.split('=').map(stripQuotes))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
console.debug(authDetails);
.map((pair) => pair.split('=').map((item) => item.trim()).map(stripQuotes))
.reduce((acc, [key, value]) => {
const normalizedKey = key.toLowerCase().replace('digest ', '');
if (normalizedKey && value !== undefined) {
acc[normalizedKey] = value;
}
return acc;
}, {});
// Validate required auth details
if (!authDetails.realm || !authDetails.nonce) {
console.warn('Missing required auth details (realm or nonce)');
return Promise.reject(error);
}
console.debug("Auth Details: \n", authDetails);
const nonceCount = '00000001';
const cnonce = crypto.randomBytes(24).toString('hex');
if (authDetails.algorithm && authDetails.algorithm.toUpperCase() !== 'MD5') {
console.warn(`Unsupported Digest algorithm: ${algo}`);
console.warn(`Unsupported Digest algorithm: ${authDetails.algorithm}`);
return Promise.reject(error);
} else {
authDetails.algorithm = 'MD5';
}
const uri = new URL(request.url).pathname;
const HA1 = md5(`${username}:${authDetails['Digest realm']}:${password}`);
const HA2 = md5(`${request.method}:${uri}`);
const response = md5(`${HA1}:${authDetails.nonce}:${nonceCount}:${cnonce}:auth:${HA2}`);
const authorizationHeader =
`Digest username="${username}",realm="${authDetails['Digest realm']}",` +
`nonce="${authDetails.nonce}",uri="${uri}",qop="auth",algorithm="${authDetails.algorithm}",` +
`response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`;
const uri = new URL(request.url, request.baseURL || 'http://localhost').pathname; // Handle relative URLs
const HA1 = md5(`${username}:${authDetails.realm}:${password}`);
const HA2 = md5(`${request.method}:${uri}`);
const response = md5(
`${HA1}:${authDetails.nonce}:${nonceCount}:${cnonce}:auth:${HA2}`
);
const headerFields = [
`username="${username}"`,
`realm="${authDetails.realm}"`,
`nonce="${authDetails.nonce}"`,
`uri="${uri}"`,
`qop="auth"`,
`algorithm="${authDetails.algorithm}"`,
`response="${response}"`,
`nc="${nonceCount}"`,
`cnonce="${cnonce}"`,
];
if (authDetails.opaque) {
headerFields.push(`opaque="${authDetails.opaque}"`);
}
const authorizationHeader = `Digest ${headerFields.join(', ')}`;
// Ensure headers are initialized
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers['Authorization'] = authorizationHeader;
console.debug(`Authorization: ${originalRequest.headers['Authorization']}`);
delete originalRequest.digestConfig;
return axiosInstance(originalRequest);
}

View File

@ -38,7 +38,8 @@ const {
const Oauth2Store = require('../../store/oauth2');
const iconv = require('iconv-lite');
const FormData = require('form-data');
const { createFormData } = prepareRequest;
const { createFormData } = require('../../utils/form-data');
const { findItemInCollectionByPathname } = require('../../utils/collection');
const safeStringifyJSON = (data) => {
try {
@ -373,10 +374,14 @@ const parseDataFromResponse = (response, disableParsingResponseJson = false) =>
// Filter out ZWNBSP character
// https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d
data = data.replace(/^\uFEFF/, '');
if (!disableParsingResponseJson) {
// If the response is a string and starts and ends with double quotes, it's a stringified JSON and should not be parsed
if ( !disableParsingResponseJson && ! (typeof data === 'string' && data.startsWith("\"") && data.endsWith("\""))) {
data = JSON.parse(data);
}
} catch { }
} catch {
console.log('Failed to parse response data as JSON');
}
return { data, dataBuffer };
};
@ -400,11 +405,12 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig
scriptingConfig,
runRequestByItemPathname
) => {
// run pre-request script
let scriptResult;
const requestScript = compact([get(collectionRoot, 'request.script.req'), get(request, 'script.req')]).join(os.EOL);
const requestScript = get(request, 'script.req');
if (requestScript?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
scriptResult = await scriptRuntime.runRequestScript(
@ -415,7 +421,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig
scriptingConfig,
runRequestByItemPathname
);
mainWindow.webContents.send('main:script-environment-update', {
@ -465,7 +472,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig
scriptingConfig,
runRequestByItemPathname
) => {
// run post-response vars
const postResponseVars = get(request, 'vars.res', []);
@ -500,12 +508,7 @@ const registerNetworkIpc = (mainWindow) => {
}
// run post-response script
const responseScript = compact(scriptingConfig.flow === 'sequential' ? [
get(collectionRoot, 'request.script.res'), get(request, 'script.res')
] : [
get(request, 'script.res'), get(collectionRoot, 'request.script.res')
]).join(os.EOL);
const responseScript = get(request, 'script.res');
let scriptResult;
if (responseScript?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
@ -518,7 +521,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig
scriptingConfig,
runRequestByItemPathname
);
mainWindow.webContents.send('main:script-environment-update', {
@ -535,14 +539,28 @@ const registerNetworkIpc = (mainWindow) => {
return scriptResult;
};
// handler for sending http request
ipcMain.handle('send-http-request', async (event, item, collection, environment, runtimeVariables) => {
const runRequest = async ({ item, collection, environment, runtimeVariables, runInBackground = false }) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const cancelTokenUid = uuid();
const requestUid = uuid();
mainWindow.webContents.send('main:run-request-event', {
const runRequestByItemPathname = async (relativeItemPathname) => {
return new Promise(async (resolve, reject) => {
let itemPathname = path.join(collection?.pathname, relativeItemPathname);
if (itemPathname && !itemPathname?.endsWith('.bru')) {
itemPathname = `${itemPathname}.bru`;
}
const _item = findItemInCollectionByPathname(collection, itemPathname);
if(_item) {
const res = await runRequest({ item: _item, collection, environment, runtimeVariables, runInBackground: true });
resolve(res);
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
});
}
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'request-queued',
requestUid,
collectionUid,
@ -573,7 +591,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig
scriptingConfig,
runRequestByItemPathname
);
const axiosInstance = await configureRequest(
@ -586,7 +605,7 @@ const registerNetworkIpc = (mainWindow) => {
onConsoleLog
);
mainWindow.webContents.send('main:run-request-event', {
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'request-sent',
requestSent: {
url: request.url,
@ -658,7 +677,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig
scriptingConfig,
runRequestByItemPathname
);
// run assertions
@ -674,7 +694,7 @@ const registerNetworkIpc = (mainWindow) => {
processEnvVars
);
mainWindow.webContents.send('main:run-request-event', {
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'assertion-results',
results: results,
itemUid: item.uid,
@ -683,14 +703,7 @@ const registerNetworkIpc = (mainWindow) => {
});
}
// run tests
const testScript = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests');
const testFile = compact(scriptingConfig.flow === 'sequential' ? [
get(collectionRoot, 'request.tests'), testScript,
] : [
testScript, get(collectionRoot, 'request.tests')
]).join(os.EOL);
const testFile = get(request, 'tests');
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const testResults = await testRuntime.runTests(
@ -702,10 +715,11 @@ const registerNetworkIpc = (mainWindow) => {
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig
scriptingConfig,
runRequestByItemPathname
);
mainWindow.webContents.send('main:run-request-event', {
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'test-results',
results: testResults.results,
itemUid: item.uid,
@ -739,6 +753,11 @@ const registerNetworkIpc = (mainWindow) => {
return Promise.reject(error);
}
}
// handler for sending http request
ipcMain.handle('send-http-request', async (event, item, collection, environment, runtimeVariables) => {
return await runRequest({ item, collection, environment, runtimeVariables });
});
ipcMain.handle('send-collection-oauth2-request', async (event, collection, environment, runtimeVariables) => {
@ -932,10 +951,26 @@ const registerNetworkIpc = (mainWindow) => {
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
const collectionRoot = get(collection, 'root', {});
let stopRunnerExecution = false;
const abortController = new AbortController();
saveCancelToken(cancelTokenUid, abortController);
const runRequestByItemPathname = async (relativeItemPathname) => {
return new Promise(async (resolve, reject) => {
let itemPathname = path.join(collection?.pathname, relativeItemPathname);
if (itemPathname && !itemPathname?.endsWith('.bru')) {
itemPathname = `${itemPathname}.bru`;
}
const _item = findItemInCollectionByPathname(collection, itemPathname);
if(_item) {
const res = await runRequest({ item: _item, collection, environment, runtimeVariables, runInBackground: true });
resolve(res);
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
});
}
if (!folder) {
folder = collection;
}
@ -978,7 +1013,9 @@ const registerNetworkIpc = (mainWindow) => {
throw error;
}
const item = folderRequests[currentRequestIndex];
stopRunnerExecution = false;
const item = cloneDeep(folderRequests[currentRequestIndex]);
let nextRequestName;
const itemUid = item.uid;
const eventData = {
@ -1011,13 +1048,33 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig
scriptingConfig,
runRequestByItemPathname
);
if (preRequestScriptResult?.nextRequestName !== undefined) {
nextRequestName = preRequestScriptResult.nextRequestName;
}
if (preRequestScriptResult?.stopExecution) {
stopRunnerExecution = true;
}
if (preRequestScriptResult?.skipRequest) {
mainWindow.webContents.send('main:run-folder-event', {
type: 'runner-request-skipped',
error: 'Request has been skipped from pre-request script',
responseReceived: {
status: 'skipped',
statusText: 'request skipped via pre-request script',
data: null
},
...eventData
});
currentRequestIndex++;
continue;
}
// todo:
// i have no clue why electron can't send the request object
// without safeParseJSON(safeStringifyJSON(request.data))
@ -1130,13 +1187,18 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig
scriptingConfig,
runRequestByItemPathname
);
if (postRequestScriptResult?.nextRequestName !== undefined) {
nextRequestName = postRequestScriptResult.nextRequestName;
}
if (postRequestScriptResult?.stopExecution) {
stopRunnerExecution = true;
}
// run assertions
const assertions = get(item, 'request.assertions');
if (assertions) {
@ -1158,14 +1220,7 @@ const registerNetworkIpc = (mainWindow) => {
});
}
// run tests
const testScript = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests');
const testFile = compact(scriptingConfig.flow === 'sequential' ? [
get(collectionRoot, 'request.tests'), testScript
] : [
testScript, get(collectionRoot, 'request.tests')
]).join(os.EOL);
const testFile = get(request, 'tests');
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const testResults = await testRuntime.runTests(
@ -1177,7 +1232,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig
scriptingConfig,
runRequestByItemPathname
);
if (testResults?.nextRequestName !== undefined) {
@ -1208,6 +1264,18 @@ const registerNetworkIpc = (mainWindow) => {
...eventData
});
}
if (stopRunnerExecution) {
deleteCancelToken(cancelTokenUid);
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-ended',
collectionUid,
folderUid,
statusText: 'collection run was terminated!'
});
break;
}
if (nextRequestName !== undefined) {
nJumps++;
if (nJumps > 10000) {

View File

@ -86,11 +86,12 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
} catch (err) {}
}
} else if (contentType === 'multipart/form-data') {
if (typeof request.data === 'object' && !(request.data instanceof FormData)) {
if (Array.isArray(request?.data) && !(request.data instanceof FormData)) {
try {
forOwn(request?.data, (value, key) => {
request.data[key] = _interpolate(value);
});
request.data = request?.data?.map(d => ({
...d,
value: _interpolate(d?.value)
}));
} catch (err) {}
}
} else {

View File

@ -1,194 +1,8 @@
const os = require('os');
const { get, each, filter, compact, forOwn } = require('lodash');
const { get, each, filter } = require('lodash');
const decomment = require('decomment');
const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
const crypto = require('node:crypto');
const { getTreePathFromCollectionToItem } = require('../../utils/collection');
const { buildFormUrlEncodedPayload } = require('../../utils/common');
const mergeFolderLevelHeaders = (request, requestTreePath) => {
let folderHeaders = new Map();
for (let i of requestTreePath) {
if (i.type === 'folder') {
let headers = get(i, 'root.request.headers', []);
headers.forEach((header) => {
if (header.enabled) {
folderHeaders.set(header.name, header.value);
}
});
} else if (i.uid === request.uid) {
const headers = i?.draft ? get(i, 'draft.request.headers', []) : get(i, 'request.headers', []);
headers.forEach((header) => {
if (header.enabled) {
folderHeaders.set(header.name, header.value);
}
});
}
}
let mergedFolderHeaders = Array.from(folderHeaders, ([name, value]) => ({ name, value, enabled: true }));
let requestHeaders = request.headers || [];
let requestHeadersMap = new Map();
for (let header of requestHeaders) {
if (header.enabled) {
requestHeadersMap.set(header.name, header.value);
}
}
mergedFolderHeaders.forEach((header) => {
requestHeadersMap.set(header.name, header.value);
});
request.headers = Array.from(requestHeadersMap, ([name, value]) => ({ name, value, enabled: true }));
};
const mergeVars = (collection, request, requestTreePath) => {
let reqVars = new Map();
let collectionRequestVars = get(collection, 'root.request.vars.req', []);
let collectionVariables = {};
collectionRequestVars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
collectionVariables[_var.name] = _var.value;
}
});
let folderVariables = {};
let requestVariables = {};
for (let i of requestTreePath) {
if (i.type === 'folder') {
let vars = get(i, 'root.request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
folderVariables[_var.name] = _var.value;
}
});
} else {
const vars = i?.draft ? get(i, 'draft.request.vars.req', []) : get(i, 'request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
requestVariables[_var.name] = _var.value;
}
});
}
}
request.collectionVariables = collectionVariables;
request.folderVariables = folderVariables;
request.requestVariables = requestVariables;
request.vars.req = Array.from(reqVars, ([name, value]) => ({
name,
value,
enabled: true,
type: 'request'
}));
let resVars = new Map();
let collectionResponseVars = get(collection, 'root.request.vars.res', []);
collectionResponseVars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
}
});
for (let i of requestTreePath) {
if (i.type === 'folder') {
let vars = get(i, 'root.request.vars.res', []);
vars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
}
});
} else {
const vars = i?.draft ? get(i, 'draft.request.vars.res', []) : get(i, 'request.vars.res', []);
vars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
}
});
}
}
request.vars.res = Array.from(resVars, ([name, value]) => ({
name,
value,
enabled: true,
type: 'response'
}));
};
const mergeFolderLevelScripts = (request, requestTreePath, scriptFlow) => {
let folderCombinedPreReqScript = [];
let folderCombinedPostResScript = [];
let folderCombinedTests = [];
for (let i of requestTreePath) {
if (i.type === 'folder') {
let preReqScript = get(i, 'root.request.script.req', '');
if (preReqScript && preReqScript.trim() !== '') {
folderCombinedPreReqScript.push(preReqScript);
}
let postResScript = get(i, 'root.request.script.res', '');
if (postResScript && postResScript.trim() !== '') {
folderCombinedPostResScript.push(postResScript);
}
let tests = get(i, 'root.request.tests', '');
if (tests && tests?.trim?.() !== '') {
folderCombinedTests.push(tests);
}
}
}
if (folderCombinedPreReqScript.length) {
request.script.req = compact([...folderCombinedPreReqScript, request?.script?.req || '']).join(os.EOL);
}
if (folderCombinedPostResScript.length) {
if (scriptFlow === 'sequential') {
request.script.res = compact([...folderCombinedPostResScript, request?.script?.res || '']).join(os.EOL);
} else {
request.script.res = compact([request?.script?.res || '', ...folderCombinedPostResScript.reverse()]).join(os.EOL);
}
}
if (folderCombinedTests.length) {
if (scriptFlow === 'sequential') {
request.tests = compact([...folderCombinedTests, request?.tests || '']).join(os.EOL);
} else {
request.tests = compact([request?.tests || '', ...folderCombinedTests.reverse()]).join(os.EOL);
}
}
};
const createFormData = (datas, collectionPath) => {
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
const form = new FormData();
forOwn(datas, (value, key) => {
if (typeof value == 'string') {
form.append(key, value);
return;
}
const filePaths = value || [];
filePaths?.forEach?.((filePath) => {
let trimmedFilePath = filePath.trim();
if (!path.isAbsolute(trimmedFilePath)) {
trimmedFilePath = path.join(collectionPath, trimmedFilePath);
}
form.append(key, fs.createReadStream(trimmedFilePath), path.basename(trimmedFilePath));
});
});
return form;
};
const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars } = require('../../utils/collection');
const { buildFormUrlEncodedPayload, createFormData } = require('../../utils/form-data');
const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
const collectionAuth = get(collectionRoot, 'request.auth');
@ -353,28 +167,24 @@ const prepareRequest = (item, collection) => {
let contentTypeDefined = false;
let url = request.url;
// Collection level headers
each(get(collectionRoot, 'request.headers', []), (h) => {
if (h.enabled && h.name.length > 0) {
headers[h.name] = h.value;
if (h.name.toLowerCase() === 'content-type') {
if (h.enabled && h.name?.toLowerCase() === 'content-type') {
contentTypeDefined = true;
}
return false;
}
});
// scriptFlow is either "sandwich" or "sequential"
const scriptFlow = collection.brunoConfig?.scripts?.flow ?? 'sandwich';
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
if (requestTreePath && requestTreePath.length > 0) {
mergeFolderLevelHeaders(request, requestTreePath);
mergeFolderLevelScripts(request, requestTreePath, scriptFlow);
mergeHeaders(collection, request, requestTreePath);
mergeScripts(collection, request, requestTreePath, scriptFlow);
mergeVars(collection, request, requestTreePath);
request.globalEnvironmentVariables = collection?.globalEnvironmentVariables;
}
// Request level headers
each(request.headers, (h) => {
each(get(request, 'headers', []), (h) => {
if (h.enabled && h.name.length > 0) {
headers[h.name] = h.value;
if (h.name.toLowerCase() === 'content-type') {
@ -414,7 +224,7 @@ const prepareRequest = (item, collection) => {
if (request.body.mode === 'xml') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'text/xml';
axiosRequest.headers['content-type'] = 'application/xml';
}
axiosRequest.data = request.body.xml;
}
@ -435,11 +245,11 @@ const prepareRequest = (item, collection) => {
}
if (request.body.mode === 'multipartForm') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'multipart/form-data';
const params = {};
}
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
each(enabledParams, (p) => (params[p.name] = p.value));
axiosRequest.data = params;
axiosRequest.data = enabledParams;
}
if (request.body.mode === 'graphql') {
@ -458,6 +268,10 @@ const prepareRequest = (item, collection) => {
axiosRequest.script = request.script;
}
if (request.tests) {
axiosRequest.tests = request.tests;
}
axiosRequest.vars = request.vars;
axiosRequest.collectionVariables = request.collectionVariables;
axiosRequest.folderVariables = request.folderVariables;
@ -470,4 +284,3 @@ const prepareRequest = (item, collection) => {
module.exports = prepareRequest;
module.exports.setAuthHeaders = setAuthHeaders;
module.exports.createFormData = createFormData;

View File

@ -1,4 +1,4 @@
const { ipcRenderer, contextBridge } = require('electron');
const { ipcRenderer, contextBridge, webUtils } = require('electron');
contextBridge.exposeInMainWorld('ipcRenderer', {
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
@ -10,5 +10,9 @@ contextBridge.exposeInMainWorld('ipcRenderer', {
return () => {
ipcRenderer.removeListener(channel, subscription);
};
},
getFilePath (file) {
const path = webUtils.getPathForFile(file)
return path;
}
});

View File

@ -1,5 +1,161 @@
const each = require('lodash/each');
const find = require('lodash/find');
const { get, each, find, compact } = require('lodash');
const os = require('os');
const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
let collectionHeaders = get(collection, 'root.request.headers', []);
collectionHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
if (header?.name?.toLowerCase() === 'content-type') {
contentTypeDefined = true;
}
}
});
for (let i of requestTreePath) {
if (i.type === 'folder') {
let _headers = get(i, 'root.request.headers', []);
_headers.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
}
});
} else {
const _headers = i?.draft ? get(i, 'draft.request.headers', []) : get(i, 'request.headers', []);
_headers.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
}
});
}
}
request.headers = Array.from(headers, ([name, value]) => ({ name, value, enabled: true }));
};
const mergeVars = (collection, request, requestTreePath) => {
let reqVars = new Map();
let collectionRequestVars = get(collection, 'root.request.vars.req', []);
let collectionVariables = {};
collectionRequestVars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
collectionVariables[_var.name] = _var.value;
}
});
let folderVariables = {};
let requestVariables = {};
for (let i of requestTreePath) {
if (i.type === 'folder') {
let vars = get(i, 'root.request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
folderVariables[_var.name] = _var.value;
}
});
} else {
const vars = i?.draft ? get(i, 'draft.request.vars.req', []) : get(i, 'request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
requestVariables[_var.name] = _var.value;
}
});
}
}
request.collectionVariables = collectionVariables;
request.folderVariables = folderVariables;
request.requestVariables = requestVariables;
if(request?.vars) {
request.vars.req = Array.from(reqVars, ([name, value]) => ({
name,
value,
enabled: true,
type: 'request'
}));
}
let resVars = new Map();
let collectionResponseVars = get(collection, 'root.request.vars.res', []);
collectionResponseVars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
}
});
for (let i of requestTreePath) {
if (i.type === 'folder') {
let vars = get(i, 'root.request.vars.res', []);
vars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
}
});
} else {
const vars = i?.draft ? get(i, 'draft.request.vars.res', []) : get(i, 'request.vars.res', []);
vars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
}
});
}
}
if(request?.vars) {
request.vars.res = Array.from(resVars, ([name, value]) => ({
name,
value,
enabled: true,
type: 'response'
}));
}
};
const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
let collectionPreReqScript = get(collection, 'root.request.script.req', '');
let collectionPostResScript = get(collection, 'root.request.script.res', '');
let collectionTests = get(collection, 'root.request.tests', '');
let combinedPreReqScript = [];
let combinedPostResScript = [];
let combinedTests = [];
for (let i of requestTreePath) {
if (i.type === 'folder') {
let preReqScript = get(i, 'root.request.script.req', '');
if (preReqScript && preReqScript.trim() !== '') {
combinedPreReqScript.push(preReqScript);
}
let postResScript = get(i, 'root.request.script.res', '');
if (postResScript && postResScript.trim() !== '') {
combinedPostResScript.push(postResScript);
}
let tests = get(i, 'root.request.tests', '');
if (tests && tests?.trim?.() !== '') {
combinedTests.push(tests);
}
}
}
request.script.req = compact([collectionPreReqScript, ...combinedPreReqScript, request?.script?.req || '']).join(os.EOL);
if (scriptFlow === 'sequential') {
request.script.res = compact([collectionPostResScript, ...combinedPostResScript, request?.script?.res || '']).join(os.EOL);
} else {
request.script.res = compact([request?.script?.res || '', ...combinedPostResScript.reverse(), collectionPostResScript]).join(os.EOL);
}
if (scriptFlow === 'sequential') {
request.tests = compact([collectionTests, ...combinedTests, request?.tests || '']).join(os.EOL);
} else {
request.tests = compact([request?.tests || '', ...combinedTests.reverse(), collectionTests]).join(os.EOL);
}
};
const flattenItems = (items = []) => {
const flattenedItems = [];
@ -44,14 +200,34 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
path.unshift(item);
item = findParentItemInCollection(collection, item.uid);
}
return path;
};
module.exports = {
flattenItems,
findItem,
findItemInCollection,
findParentItemInCollection,
getTreePathFromCollectionToItem
const slash = (path) => {
const isExtendedLengthPath = /^\\\\\?\\/.test(path);
if (isExtendedLengthPath) {
return path;
}
return path?.replace?.(/\\/g, '/');
};
const findItemByPathname = (items = [], pathname) => {
return find(items, (i) => slash(i.pathname) === slash(pathname));
};
const findItemInCollectionByPathname = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return findItemByPathname(flattenedItems, pathname);
};
module.exports = {
mergeHeaders,
mergeVars,
mergeScripts,
getTreePathFromCollectionToItem,
slash,
findItemByPathname,
findItemInCollectionByPathname
}

View File

@ -85,24 +85,6 @@ const flattenDataForDotNotation = (data) => {
return result;
};
/**
* @param {Array.<object>} params The request body Array
* @returns {object} Returns an obj with repeating key as a array of values
* {item: 2, item: 3, item1: 4} becomes {item: [2,3], item1: 4}
*/
const buildFormUrlEncodedPayload = (params) => {
return params.reduce((acc, p) => {
if (!acc[p.name]) {
acc[p.name] = p.value;
} else if (Array.isArray(acc[p.name])) {
acc[p.name].push(p.value);
} else {
acc[p.name] = [acc[p.name], p.value];
}
return acc;
}, {});
};
module.exports = {
uuid,
stringifyJson,
@ -111,6 +93,5 @@ module.exports = {
safeParseJSON,
simpleHash,
generateUidBasedOnHash,
flattenDataForDotNotation,
buildFormUrlEncodedPayload
flattenDataForDotNotation
};

View File

@ -38,6 +38,11 @@ const isDirectory = (dirPath) => {
}
};
const hasSubDirectories = (dir) => {
const files = fs.readdirSync(dir);
return files.some(file => fs.statSync(path.join(dir, file)).isDirectory());
};
const normalizeAndResolvePath = (pathname) => {
if (isSymbolicLink(pathname)) {
const absPath = path.dirname(pathname);
@ -156,8 +161,30 @@ const searchForBruFiles = (dir) => {
return searchForFiles(dir, '.bru');
};
const sanitizeCollectionName = (name) => {
return name.trim();
}
const sanitizeDirectoryName = (name) => {
return name.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '-');
return name.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '-').trim();
};
const isWindowsOS = () => {
return os.platform() === 'win32';
}
const isValidFilename = (fileName) => {
const inValidChars = /[\\/:*?"<>|]/;
if (!fileName || inValidChars.test(fileName)) {
return false;
}
if (fileName.endsWith(' ') || fileName.endsWith('.') || fileName.startsWith('.')) {
return false;
}
return true;
};
const safeToRename = (oldPath, newPath) => {
@ -170,7 +197,7 @@ const safeToRename = (oldPath, newPath) => {
const oldStat = fs.statSync(oldPath);
const newStat = fs.statSync(newPath);
if (os.platform() === 'win32') {
if (isWindowsOS()) {
// Windows-specific comparison:
// Check if both files have the same birth time, size (Since, Win FAT-32 doesn't use inodes)
@ -204,5 +231,9 @@ module.exports = {
searchForFiles,
searchForBruFiles,
sanitizeDirectoryName,
safeToRename
sanitizeCollectionName,
isWindowsOS,
safeToRename,
isValidFilename,
hasSubDirectories
};

View File

@ -0,0 +1,62 @@
const { forEach } = require('lodash');
const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
/**
* @param {Array.<object>} params The request body Array
* @returns {object} Returns an obj with repeating key as a array of values
* {item: 2, item: 3, item1: 4} becomes {item: [2,3], item1: 4}
*/
const buildFormUrlEncodedPayload = (params) => {
return params.reduce((acc, p) => {
if (!acc[p.name]) {
acc[p.name] = p.value;
} else if (Array.isArray(acc[p.name])) {
acc[p.name].push(p.value);
} else {
acc[p.name] = [acc[p.name], p.value];
}
return acc;
}, {});
};
const createFormData = (data, collectionPath) => {
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
const form = new FormData();
forEach(data, (datum) => {
const { name, type, value, contentType } = datum;
let options = {};
if (contentType) {
options.contentType = contentType;
}
if (type === 'text') {
if (Array.isArray(value)) {
value.forEach((val) => form.append(name, val, options));
} else {
form.append(name, value, options);
}
return;
}
if (type === 'file') {
const filePaths = value || [];
filePaths.forEach((filePath) => {
let trimmedFilePath = filePath.trim();
if (!path.isAbsolute(trimmedFilePath)) {
trimmedFilePath = path.join(collectionPath, trimmedFilePath);
}
options.filename = path.basename(trimmedFilePath);
form.append(name, fs.createReadStream(trimmedFilePath), options);
});
}
});
return form;
};
module.exports = {
buildFormUrlEncodedPayload,
createFormData
};

View File

@ -1,7 +1,7 @@
const { describe, it, expect } = require('@jest/globals');
const prepareRequest = require('../../src/ipc/network/prepare-request');
const { buildFormUrlEncodedPayload } = require('../../src/utils/common');
const { buildFormUrlEncodedPayload } = require('../../src/utils/form-data');
describe('prepare-request: prepareRequest', () => {
describe('Decomments request body', () => {

View File

@ -20,7 +20,7 @@
"graphql": "^16.6.0",
"markdown-it": "^13.0.1",
"postcss": "8.4.47",
"react": "18.2.0",
"react": "19.0.0",
"react-dom": "18.2.0",
"rollup":"3.29.5",
"rollup-plugin-dts": "^5.0.0",

View File

@ -16,6 +16,7 @@
},
"dependencies": {
"@usebruno/common": "0.1.0",
"@usebruno/crypto-js": "^3.1.9",
"@usebruno/query": "0.1.0",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
@ -25,11 +26,10 @@
"chai": "^4.3.7",
"chai-string": "^1.5.0",
"crypto-js": "^4.1.1",
"crypto-js-3.1.9-1": "npm:crypto-js@^3.1.9-1",
"json-query": "^2.2.2",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"nanoid": "3.3.4",
"nanoid": "3.3.8",
"node-fetch": "^2.7.0",
"node-vault": "^0.10.2",
"path": "^0.12.7",

View File

@ -13,6 +13,17 @@ class Bru {
this.requestVariables = requestVariables || {};
this.globalEnvironmentVariables = globalEnvironmentVariables || {};
this.collectionPath = collectionPath;
this.runner = {
skipRequest: () => {
this.skipRequest = true;
},
stopExecution: () => {
this.stopExecution = true;
},
setNextRequest: (nextRequest) => {
this.nextRequest = nextRequest;
}
};
}
_interpolate = (str) => {

View File

@ -58,6 +58,7 @@ chai.use(function (chai, utils) {
* endsWith : ends with
* between : between
* isEmpty : is empty
* isNotEmpty : is not empty
* isNull : is null
* isUndefined : is undefined
* isDefined : is defined
@ -95,6 +96,7 @@ const parseAssertionOperator = (str = '') => {
'endsWith',
'between',
'isEmpty',
'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@ -109,6 +111,7 @@ const parseAssertionOperator = (str = '') => {
const unaryOperators = [
'isEmpty',
'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@ -147,6 +150,7 @@ const parseAssertionOperator = (str = '') => {
const isUnaryOperator = (operator) => {
const unaryOperators = [
'isEmpty',
'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@ -345,6 +349,9 @@ class AssertRuntime {
case 'isEmpty':
expect(lhs).to.be.empty;
break;
case 'isNotEmpty':
expect(lhs).to.not.be.empty;
break;
case 'isNull':
expect(lhs).to.be.null;
break;
@ -401,6 +408,8 @@ class AssertRuntime {
}
}
request.assertionResults = assertionResults;
return assertionResults;
}
}

View File

@ -45,7 +45,8 @@ class ScriptRuntime {
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig
scriptingConfig,
runRequestByItemPathname
) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const collectionVariables = request?.collectionVariables || {};
@ -92,6 +93,10 @@ class ScriptRuntime {
};
}
if(runRequestByItemPathname) {
context.bru.runRequest = runRequestByItemPathname;
}
if (this.runtime === 'quickjs') {
await executeQuickJsVmAsync({
script: script,
@ -104,7 +109,9 @@ class ScriptRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution
};
}
@ -152,7 +159,9 @@ class ScriptRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution
};
}
@ -165,7 +174,8 @@ class ScriptRuntime {
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig
scriptingConfig,
runRequestByItemPathname
) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const collectionVariables = request?.collectionVariables || {};
@ -209,6 +219,10 @@ class ScriptRuntime {
};
}
if(runRequestByItemPathname) {
context.bru.runRequest = runRequestByItemPathname;
}
if (this.runtime === 'quickjs') {
await executeQuickJsVmAsync({
script: script,
@ -221,7 +235,9 @@ class ScriptRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution
};
}
@ -269,7 +285,9 @@ class ScriptRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution
};
}
}

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