Compare commits

...

28 Commits

Author SHA1 Message Date
lohit
85ccc21151 Revert "raw file body - fixes (#3913)"
This reverts commit a075cc71d4.
2025-02-04 12:31:07 +05:30
lohit
a075cc71d4 raw file body - fixes (#3913)
---------

Co-authored-by: lohit <lohit@usebruno.com>
Co-authored-by: Sanjai Kumar <161328623+sanjaikumar-bruno@users.noreply.github.com>
2025-02-04 12:27:45 +05:30
Marcos Adriano
364b0f523a feat: raw binary files handling in request body (#3734)
--------------
Co-authored-by: lohit <lohit@usebruno.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-02-03 19:22:45 +05:30
Pooja Belaramani
4598acd068 fix 2025-02-03 13:49:35 +05:30
Pooja Belaramani
062ab00a66 fix: ensure API key values are converted to strings in Postman collection importer. 2025-02-03 13:49:35 +05:30
Pooja Belaramani
650cb47a8b fix: resolve function recusion 2025-02-03 13:01:24 +05:30
Pooja Belaramani
08139a8f3e fix 2025-01-30 09:58:38 +05:30
Pooja Belaramani
51e087efba add: hint word for runRequest, sendNextRequest, skipRequest, getTestResults 2025-01-30 09:58:38 +05:30
lohxt1
ff5683f19f add runRequest and runner utils functions to cli
~ add bru.runRequest support for cli
~ add bru.runner.skipRequest, bru.runner.stopExecution support for cli
2025-01-29 11:53:02 +05:30
lohxt1
d13e4b3b54 ensure variables set in scripts/tests during bru.runRequest reflect in original request scripts/tests 2025-01-29 11:53:02 +05:30
Jarod Gowgiel
2d08567d8d Add an npm script to run the Electron app for debugging (#3616)
This allows for developers to attach Dev Tools, e.g. the Chrome
"dedicated DevTools for node", to the main Electron process
for debugging operations that occur on the main process.
2025-01-29 11:25:41 +05:30
ramki-bruno
b51f8109a2 Review changes and minor-refactoring in Reveal in Finder feature 2025-01-29 11:18:57 +05:30
Naman
8f241a32ae Feature: Reveal in Finder (#3698)
* feat: Reveal in Finder

* added support for linux
2025-01-29 11:18:57 +05:30
Sanjai Kumar
b7fda331dc Fix: Comment toggling for JSON modes in CodeEditor 2025-01-29 11:16:15 +05:30
lohxt1
10e0fde2a8 use lowercase header keys while making requests 2025-01-29 08:34:01 +05:30
Anoop M D
9f5f975f70 feat: async parser workers (#3834) (#3887)
feat: async parser workers (#3834)
Co-authored-by: lohit <lohit.jiddimani@gmail.com>
2025-01-29 02:53:53 +05:30
Pragadesh-45
b5bd259a1b fix: handle Windows paths in cloneItem and getDirectoryName functions (fixes: #3401) (#3646)
* fix: handle Windows paths in cloneItem and getDirectoryName functions

* chore: removed commented lines

---------

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-01-28 21:32:51 +05:30
lohit
0633d45a10 upgraded axios libarary (#3899) 2025-01-28 18:55:59 +05:30
Pragadesh-45
16e27d2ca4 feat: make BrunoResponse callable to access body data using expressions fixes (#481) (#3710)
* feat: make `BrunoResponse` callable to access body data using expressions
2025-01-28 14:57:00 +05:30
Alex
956d5a38e9 fix: bump axios version to handle ivp6 (#3878)
Co-authored-by: alex.chua <alex.chua@bytedance.com>
2025-01-28 14:36:29 +05:30
Sanjai Kumar
bfc1101371 refactor: update GitHub Actions workflow to add permissions for checks and pull requests for the cli-tests job (#3844) 2025-01-28 14:13:14 +05:30
sanish chirayath
05be59f00c style: update StyledWrapper to use flex layout and adjust dialog positioning (#3888) 2025-01-27 18:49:05 +05:30
tlaloc911
fee631d496 passing defaults instead of axiosInstance to NTLMClient (#3841) 2025-01-18 21:34:19 +05:30
Bobby Bonestell
d03de2b622 fix: Inherited apikey auth mapping for bruno-cli (#3512)
* Added bruno-cli support for mapping inherited apikey auth from collection

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-01-17 20:33:53 +05:30
sanish chirayath
31b2818821 fix: modal - provide default handleConfirm function and update dependencies in useEffect (#3830) 2025-01-17 14:52:06 +05:30
naman-bruno
8a71dfc022 enhancement: moved collection click area from name to div (#3813) 2025-01-17 13:20:53 +05:30
naman-bruno
3e6204e49b Fix: Horizontal Rules missing in markdown docs preview (#3814) 2025-01-17 13:10:16 +05:30
naman-bruno
dab4bb6a1c fix: hide env dropdown on configure (#3826) 2025-01-17 13:07:39 +05:30
103 changed files with 2754 additions and 400 deletions

View File

@@ -52,6 +52,9 @@ jobs:
cli-test:
name: CLI Tests
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4

50
package-lock.json generated
View File

@@ -24030,7 +24030,6 @@
"@usebruno/common": "0.1.0",
"@usebruno/graphql-docs": "0.1.0",
"@usebruno/schema": "0.7.0",
"axios": "1.7.5",
"classnames": "^2.3.1",
"codemirror": "5.65.2",
"codemirror-graphql": "2.1.1",
@@ -24147,7 +24146,7 @@
"@usebruno/lang": "0.12.0",
"@usebruno/vm2": "^3.9.13",
"aws4-axios": "^3.3.0",
"axios": "1.7.5",
"axios": "1.7.7",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chalk": "^3.0.0",
@@ -24168,6 +24167,16 @@
"bru": "bin/bru.js"
}
},
"packages/bruno-cli/node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"packages/bruno-cli/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@@ -24210,7 +24219,7 @@
"@usebruno/vm2": "^3.9.13",
"about-window": "^1.15.2",
"aws4-axios": "^3.3.0",
"axios": "1.7.5",
"axios": "1.7.7",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chokidar": "^3.5.3",
@@ -24247,6 +24256,17 @@
"dmg-license": "^1.0.11"
}
},
"packages/bruno-electron/node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"packages/bruno-electron/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@@ -24341,7 +24361,7 @@
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"atob": "^2.1.2",
"axios": "1.7.5",
"axios": "1.7.7",
"btoa": "^1.2.1",
"chai": "^4.3.7",
"chai-string": "^1.5.0",
@@ -24368,6 +24388,16 @@
"@usebruno/vm2": "^3.9.13"
}
},
"packages/bruno-js/node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"packages/bruno-js/node_modules/nanoid": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
@@ -24445,7 +24475,7 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"axios": "1.7.5",
"axios": "1.7.7",
"body-parser": "1.20.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
@@ -24459,6 +24489,16 @@
"multer": "^1.4.5-lts.1"
}
},
"packages/bruno-tests/node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"packages/bruno-tests/node_modules/fast-xml-parser": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.1.tgz",

View File

@@ -36,6 +36,7 @@
"build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",
"dev:electron": "npm run dev --workspace=packages/bruno-electron",
"dev:electron:debug": "npm run debug --workspace=packages/bruno-electron",
"build:bruno-common": "npm run build --workspace=packages/bruno-common",
"build:bruno-query": "npm run build --workspace=packages/bruno-query",
"build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs",

View File

@@ -20,7 +20,6 @@
"@usebruno/common": "0.1.0",
"@usebruno/graphql-docs": "0.1.0",
"@usebruno/schema": "0.7.0",
"axios": "1.7.5",
"classnames": "^2.3.1",
"codemirror": "5.65.2",
"codemirror-graphql": "2.1.1",

View File

@@ -8,6 +8,8 @@ const StyledWrapper = styled.div`
font-size: ${(props) => (props.fontSize ? `${props.fontSize}px` : 'inherit')};
line-break: anywhere;
flex: 1 1 0;
display: flex;
flex-direction: column-reverse;
}
/* Removes the glow outline around the folded json */
@@ -26,6 +28,10 @@ const StyledWrapper = styled.div`
.CodeMirror-dialog {
overflow: visible;
position: relative;
top: unset;
left: unset;
input {
background: transparent;
border: 1px solid #d3d6db;

View File

@@ -74,13 +74,16 @@ if (!SERVER_RENDERED) {
'bru.setNextRequest(requestName)',
'req.disableParsingResponseJson()',
'bru.getRequestVar(key)',
'bru.runRequest(requestPathName)',
'bru.getAssertionResults()',
'bru.getTestResults()',
'bru.sleep(ms)',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)',
'bru.runner',
'bru.runner.setNextRequest(requestName)',
'bru.runner.skipRequest()',
'bru.runner.stopExecution()'
'bru.runner.stopExecution()',
];
CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
const cursor = editor.getCursor();
@@ -194,8 +197,20 @@ export default class CodeEditor extends React.Component {
'Cmd-Y': 'foldAll',
'Ctrl-I': 'unfoldAll',
'Cmd-I': 'unfoldAll',
'Ctrl-/': 'toggleComment',
'Cmd-/': 'toggleComment'
'Ctrl-/': () => {
if (['application/ld+json', 'application/json'].includes(this.props.mode)) {
this.editor.toggleComment({ lineComment: '//', blockComment: '/*' });
} else {
this.editor.toggleComment();
}
},
'Cmd-/': () => {
if (['application/ld+json', 'application/json'].includes(this.props.mode)) {
this.editor.toggleComment({ lineComment: '//', blockComment: '/*' });
} else {
this.editor.toggleComment();
}
}
},
foldOptions: {
widget: (from, to) => {

View File

@@ -1,5 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div``;
const Wrapper = styled.div`
max-width: 800px;
`;
export default Wrapper;

View File

@@ -8,7 +8,6 @@ const StyledWrapper = styled.div`
}
.editing-mode {
cursor: pointer;
color: ${(props) => props.theme.colors.text.yellow};
}
`;

View File

@@ -8,6 +8,7 @@ import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/acti
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
const Docs = ({ collection }) => {
const dispatch = useDispatch();
@@ -29,35 +30,95 @@ const Docs = ({ collection }) => {
);
};
const onSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleDiscardChanges = () => {
dispatch(
updateCollectionDocs({
collectionUid: collection.uid,
docs: docs
})
);
toggleViewMode();
}
const onSave = () => {
dispatch(saveCollectionRoot(collection.uid));
toggleViewMode();
}
return (
<StyledWrapper className="mt-1 h-full w-full relative flex flex-col">
<div className="editing-mode flex justify-between items-center" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
{isEditing ? (
<div className="mt-2 flex-1 max-h-[70vh]">
<CodeEditor
collection={collection}
theme={displayedTheme}
value={docs || ''}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
/>
<button type="submit" className="submit btn btn-sm btn-secondary my-6" onClick={onSave}>
Save
</button>
<div className='flex flex-row w-full justify-between items-center mb-4'>
<div className='text-lg font-medium flex items-center gap-2'>
<IconFileText size={20} strokeWidth={1.5} />
Documentation
</div>
<div className='flex flex-row gap-2 items-center justify-center'>
{isEditing ? (
<>
<div className="editing-mode" role="tab" onClick={handleDiscardChanges}>
<IconX className="cursor-pointer" size={20} strokeWidth={1.5} />
</div>
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={onSave}>
Save
</button>
</>
) : (
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} />
</div>
)}
</div>
</div>
{isEditing ? (
<CodeEditor
collection={collection}
theme={displayedTheme}
value={docs}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
/>
) : (
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
<div className='h-full overflow-auto pl-1'>
<div className='h-[1px] min-h-[500px]'>
{
docs?.length > 0 ?
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
:
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={documentationPlaceholder} />
}
</div>
</div>
)}
</StyledWrapper>
);
};
export default Docs;
const documentationPlaceholder = `
Welcome to your collection documentation! This space is designed to help you document your API collection effectively.
## Overview
Use this section to provide a high-level overview of your collection. You can describe:
- The purpose of these API endpoints
- Key features and functionalities
- Target audience or users
## Best Practices
- Keep documentation up to date
- Include request/response examples
- Document error scenarios
- Add relevant links and references
## Markdown Support
This documentation supports Markdown formatting! You can use:
- **Bold** and *italic* text
- \`code blocks\` and syntax highlighting
- Tables and lists
- [Links](https://example.com)
- And more!
`;

View File

@@ -1,6 +1,8 @@
import styled from 'styled-components';
const Wrapper = styled.div`
max-width: 800px;
table {
width: 100%;
border-collapse: collapse;

View File

@@ -1,13 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
table {
td {
&:first-child {
width: 120px;
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,39 +0,0 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
import { getTotalRequestCountInCollection } from 'utils/collections/';
const Info = ({ collection }) => {
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">General information about the collection.</div>
<table className="w-full border-collapse">
<tbody>
<tr className="">
<td className="py-2 px-2 text-right">Name&nbsp;:</td>
<td className="py-2 px-2">{collection.name}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right">Location&nbsp;:</td>
<td className="py-2 px-2 break-all">{collection.pathname}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right">Ignored files&nbsp;:</td>
<td className="py-2 px-2 break-all">{collection.brunoConfig?.ignore?.map((x) => `'${x}'`).join(', ')}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right">Environments&nbsp;:</td>
<td className="py-2 px-2">{collection.environments?.length || 0}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right">Requests&nbsp;:</td>
<td className="py-2 px-2">{totalRequestsInCollection}</td>
</tr>
</tbody>
</table>
</StyledWrapper>
);
};
export default Info;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { IconFolder, IconFileOff, IconWorld, IconApi } from '@tabler/icons';
const Info = ({ collection }) => {
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
return (
<div className="w-full flex flex-col h-fit">
<div className="rounded-lg py-6">
<div className="grid gap-6">
{/* Location Row */}
<div className="flex items-start">
<div className="flex-shrink-0 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<IconFolder className="w-5 h-5 text-blue-500" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-semibold text-sm">Location</div>
<div className="mt-1 text-sm text-muted break-all">
{collection.pathname}
</div>
</div>
</div>
{/* Environments Row */}
<div className="flex items-start">
<div className="flex-shrink-0 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<IconWorld className="w-5 h-5 text-green-500" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-semibold text-sm">Environments</div>
<div className="mt-1 text-sm text-muted">
{collection.environments?.length || 0} environment{collection.environments?.length !== 1 ? 's' : ''} configured
</div>
</div>
</div>
{/* Requests Row */}
<div className="flex items-start">
<div className="flex-shrink-0 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<IconApi className="w-5 h-5 text-purple-500" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-semibold text-sm">Requests</div>
<div className="mt-1 text-sm text-muted">
{totalRequestsInCollection} request{totalRequestsInCollection !== 1 ? 's' : ''} in collection
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default Info;

View File

@@ -0,0 +1,25 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
&.card {
background-color: ${(props) => props.theme.requestTabPanel.card.bg};
.title {
border-top: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border};
border-left: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border};
border-right: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border};
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
.table {
thead {
background-color: ${(props) => props.theme.requestTabPanel.cardTable.table.thead.bg};
color: ${(props) => props.theme.requestTabPanel.cardTable.table.thead.color};
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { flattenItems } from "utils/collections";
import { IconAlertTriangle } from '@tabler/icons';
import StyledWrapper from "./StyledWrapper";
const RequestsNotLoaded = ({ collection }) => {
const flattenedItems = flattenItems(collection.items);
const itemsFailedLoading = flattenedItems?.filter(item => item?.partial && !item?.loading);
if (!itemsFailedLoading?.length) {
return null;
}
return (
<StyledWrapper className="w-full card my-2">
<div className="flex items-center gap-2 px-3 py-2 title bg-yellow-50 dark:bg-yellow-900/20">
<IconAlertTriangle size={16} className="text-yellow-500" />
<span className="font-medium">Following requests were not loaded</span>
</div>
<table className="w-full border-collapse">
<thead>
<tr>
<th className="py-2 px-3 text-left font-medium">
Pathname
</th>
<th className="py-2 px-3 text-left font-medium">
Size
</th>
</tr>
</thead>
<tbody>
{flattenedItems?.map((item, index) => (
item?.partial && !item?.loading ? (
<tr key={index}>
<td className="py-1.5 px-3">
{item?.pathname?.split(`${collection?.pathname}/`)?.[1]}
</td>
<td className="py-1.5 px-3">
{item?.size?.toFixed?.(2)}&nbsp;MB
</td>
</tr>
) : null
))}
</tbody>
</table>
</StyledWrapper>
);
};
export default RequestsNotLoaded;

View File

@@ -0,0 +1,25 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.partial {
color: ${(props) => props.theme.colors.text.yellow};
opacity: 0.8;
}
.loading {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
}
.completed {
color: ${(props) => props.theme.colors.text.green};
opacity: 0.8;
}
.failed {
color: ${(props) => props.theme.colors.text.danger};
opacity: 0.8;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,27 @@
import StyledWrapper from "./StyledWrapper";
import Docs from "../Docs";
import Info from "./Info";
import { IconBox } from '@tabler/icons';
import RequestsNotLoaded from "./RequestsNotLoaded";
const Overview = ({ collection }) => {
return (
<div className="h-full">
<div className="grid grid-cols-5 gap-4 h-full">
<div className="col-span-2">
<div className="text-xl font-semibold flex items-center gap-2">
<IconBox size={24} stroke={1.5} />
{collection?.name}
</div>
<Info collection={collection} />
<RequestsNotLoaded collection={collection} />
</div>
<div className="col-span-3">
<Docs collection={collection} />
</div>
</div>
</div>
);
}
export default Overview;

View File

@@ -1,6 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
.settings-label {
width: 110px;
}

View File

@@ -1,6 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
div.CodeMirror {
height: inherit;
}

View File

@@ -1,8 +1,6 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
div.tabs {
div.tab {
padding: 6px 0px;

View File

@@ -1,5 +1,7 @@
import styled from 'styled-components';
const StyledWrapper = styled.div``;
const StyledWrapper = styled.div`
max-width: 800px;
`;
export default StyledWrapper;

View File

@@ -1,6 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
div.title {
color: var(--color-tab-inactive);
}

View File

@@ -12,12 +12,11 @@ import Headers from './Headers';
import Auth from './Auth';
import Script from './Script';
import Test from './Tests';
import Docs from './Docs';
import Presets from './Presets';
import Info from './Info';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
import DotIcon from 'components/Icons/Dot';
import Overview from './Overview/index';
const ContentIndicator = () => {
return (
@@ -97,6 +96,9 @@ const CollectionSettings = ({ collection }) => {
const getTabPanel = (tab) => {
switch (tab) {
case 'overview': {
return <Overview collection={collection} />;
}
case 'headers': {
return <Headers collection={collection} />;
}
@@ -128,12 +130,6 @@ const CollectionSettings = ({ collection }) => {
/>
);
}
case 'docs': {
return <Docs collection={collection} />;
}
case 'info': {
return <Info collection={collection} />;
}
}
};
@@ -146,6 +142,9 @@ const CollectionSettings = ({ collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
Overview
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
@@ -177,13 +176,6 @@ const CollectionSettings = ({ collection }) => {
Client Certificates
{clientCertConfig.length > 0 && <ContentIndicator />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
Docs
{hasDocs && <ContentIndicator />}
</div>
<div className={getTabClassname('info')} role="tab" onClick={() => setTab('info')}>
Info
</div>
</div>
<section className="mt-4 h-full">{getTabPanel(tab)}</section>
</StyledWrapper>

View File

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

View File

@@ -78,7 +78,10 @@ const EnvironmentSelector = ({ collection }) => {
<IconDatabaseOff size={18} strokeWidth={1.5} />
<span className="ml-2">No Environment</span>
</div>
<div className="dropdown-item border-top" onClick={handleSettingsIconClick}>
<div className="dropdown-item border-top" onClick={() => {
handleSettingsIconClick();
dropdownTippyRef.current.hide();
}}>
<div className="pr-2 text-gray-600">
<IconSettings size={18} strokeWidth={1.5} />
</div>

View File

@@ -6,7 +6,7 @@ import { IconX } from '@tabler/icons';
import { isWindowsOS } from 'utils/common/platform';
import slash from 'utils/common/slash';
const FilePickerEditor = ({ value, onChange, collection }) => {
const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false}) => {
value = value || [];
const dispatch = useDispatch();
const filenames = value
@@ -20,7 +20,7 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
const title = filenames.map((v) => `- ${v}`).join('\n');
const browse = () => {
dispatch(browseFiles())
dispatch(browseFiles([],['']))
.then((filePaths) => {
// If file is in the collection's directory, then we use relative path
// Otherwise, we use the absolute path
@@ -49,7 +49,7 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
if (filenames.length == 1) {
return filenames[0];
}
return filenames.length + ' files selected';
return filenames.length + ' file(s) selected';
};
return filenames.length > 0 ? (
@@ -66,7 +66,7 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
</div>
) : (
<button className="btn btn-secondary px-1" style={{ width: '100%' }} onClick={browse}>
Select Files
{isSingleFilePicker? 'Select File' : 'Select Files'}
</button>
);
};

View File

@@ -9,7 +9,6 @@ const StyledMarkdownBodyWrapper = styled.div`
box-sizing: border-box;
height: 100%;
margin: 0 auto;
padding-top: 0.5rem;
font-size: 0.875rem;
h1 {
@@ -55,7 +54,7 @@ const StyledMarkdownBodyWrapper = styled.div`
height: 1px;
padding: 0;
margin: 24px 0;
background-color: var(--color-border-default);
background-color: var(--color-sidebar-collection-item-active-indent-border);
border: 0;
}
@@ -80,12 +79,6 @@ const StyledMarkdownBodyWrapper = styled.div`
}
}
}
@media (max-width: 767px) {
.markdown-body {
padding: 15px;
}
}
`;
export default StyledMarkdownBodyWrapper;

View File

@@ -62,7 +62,7 @@ const Modal = ({
confirmText,
cancelText,
handleCancel,
handleConfirm,
handleConfirm = () => {},
children,
confirmDisabled,
hideCancel,
@@ -92,7 +92,7 @@ const Modal = ({
};
useFocusTrap(modalRef);
const closeModal = (args) => {
setIsClosing(true);
setTimeout(() => handleCancel(args), closeModalFadeTimeout);
@@ -103,7 +103,7 @@ const Modal = ({
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [disableEscapeKey, document]);
}, [disableEscapeKey, document, handleConfirm]);
let classes = 'bruno-modal';
if (isClosing) {

View File

@@ -0,0 +1,65 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
&:nth-child(1) {
width: 30%;
}
&:nth-child(2) {
width: 45%;
}
&:nth-child(3) {
width: 25%;
}
&:nth-child(4) {
width: 70px;
}
}
}
.btn-add-param {
font-size: 0.8125rem;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
color: ${(props) => props.theme.table.input.color};
background: transparent;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='radio'] {
cursor: pointer;
position: relative;
top: 1px;
}
`;
export default Wrapper;

View File

@@ -0,0 +1,173 @@
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
addBinaryFile,
updateBinaryFile,
deleteBinaryFile
} from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor';
import SingleLineEditor from 'components/SingleLineEditor/index';
import { isArray } from 'lodash';
import path from 'node:path';
import { useState } from 'react';
const Binary = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const params = item.draft ? get(item, 'draft.request.body.binaryFile') : get(item, 'request.body.binaryFile');
const [enabledFileUid, setEnableFileUid] = useState(params && params.length ? params[0].uid : '');
const addFile = () => {
dispatch(
addBinaryFile({
itemUid: item.uid,
collectionUid: collection.uid,
type: 'binaryFile',
value: [''],
})
);
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleParamChange = (e, _param, type) => {
const param = cloneDeep(_param);
switch (type) {
case 'value': {
param.value = isArray(e.target.value) && e.target.value.length > 0 ? e.target.value : [''];
param.name = param.value.length === 0 ? '': path.basename(param.value[0], path.extname(param.value[0]));
break;
}
case 'contentType': {
param.contentType = e.target.value;
break;
}
case 'enabled': {
param.enabled = e.target.checked;
setEnableFileUid(param.uid);
break;
}
}
dispatch(
updateBinaryFile({
param: param,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleRemoveParams = (param) => {
dispatch(
deleteBinaryFile({
paramUid: param.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
return (
<StyledWrapper className="w-full">
<table>
<thead>
<tr>
<td><div className="flex items-center justify-center">File</div></td>
<td><div className="flex items-center justify-center">Content-Type</div></td>
<td><div className="flex items-center justify-center">Enabled</div></td>
<td></td>
</tr>
</thead>
<tbody>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid}>
<td>
<FilePickerEditor
isSingleFilePicker={true}
value={param.value}
onChange={(newValue) =>
handleParamChange(
{
target: {
value: newValue
}
},
param,
'value'
)
}
collection={collection}
/>
</td>
<td>
<SingleLineEditor
className='flex items-center justify-center'
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 justify-center">
<input
key={param.uid}
type="radio"
name="enabled"
checked={enabledFileUid === param.uid || param.enabled}
tabIndex="-1"
className="mr-1 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
/>
</div>
</td>
<td>
<div className="flex items-center justify-center">
<button tabIndex="-1" onClick={() => handleRemoveParams(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<div>
<button className="btn-add-param text-link pr-2 pt-3 select-none" onClick={addFile}>
+ Add File
</button>
</div>
</StyledWrapper>
);
};
export default Binary;

View File

@@ -128,6 +128,15 @@ const RequestBodyMode = ({ item, collection }) => {
SPARQL
</div>
<div className="label-item font-medium">Other</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('binaryFile');
}}
>
Binary File
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@@ -8,6 +8,7 @@ import { useTheme } from 'providers/Theme';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import Binary from '../Binary/index';
const RequestBody = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -62,6 +63,10 @@ const RequestBody = ({ item, collection }) => {
);
}
if (bodyMode === 'binaryFile') {
return <Binary item={item} collection={collection}/>
}
if (bodyMode === 'formUrlEncoded') {
return <FormUrlEncodedParams item={item} collection={collection} />;
}

View File

@@ -0,0 +1,19 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.card {
background: ${(props) => props.theme.requestTabPanel.card.bg};
border: 1px solid ${(props) => props.theme.requestTabPanel.card.border};
div.hr {
border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.card.hr};
height: 1px;
}
div.border-top {
border-top: 1px solid ${(props) => props.theme.requestTabPanel.card.border};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,47 @@
import { IconLoader2, IconFile } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const RequestIsLoading = ({ item }) => {
return <StyledWrapper>
<div className='flex flex-col p-4'>
<div className='card shadow-sm rounded-md p-4 w-[600px]'>
<div>
<div className='font-medium flex items-center gap-2 pb-4'>
<IconFile size={16} strokeWidth={1.5} className="text-gray-400" />
File Info
</div>
<div className='hr'/>
<div className='flex items-center mt-2'>
<span className='w-12 mr-2 text-muted'>Name:</span>
<div>
{item?.name}
</div>
</div>
<div className='flex items-center mt-1'>
<span className='w-12 mr-2 text-muted'>Path:</span>
<div className='break-all'>
{item?.pathname}
</div>
</div>
<div className='flex items-center mt-1 pb-4'>
<span className='w-12 mr-2 text-muted'>Size:</span>
<div>
{item?.size?.toFixed?.(2)} MB
</div>
</div>
<div className='hr'/>
<div className='flex items-center gap-2 mt-4'>
<IconLoader2 className="animate-spin" size={16} strokeWidth={2} />
<span>Loading...</span>
</div>
</div>
</div>
</div>
</StyledWrapper>
}
export default RequestIsLoading;

View File

@@ -0,0 +1,19 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.card {
background: ${(props) => props.theme.requestTabPanel.card.bg};
border: 1px solid ${(props) => props.theme.requestTabPanel.card.border};
div.hr {
border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.card.hr};
height: 1px;
}
div.border-top {
border-top: 1px solid ${(props) => props.theme.requestTabPanel.card.border};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,89 @@
import { IconLoader2, IconFile } from '@tabler/icons';
import { loadRequest, loadRequestViaWorker } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
const RequestNotLoaded = ({ collection, item }) => {
const dispatch = useDispatch();
const handleLoadRequestViaWorker = () => {
!item?.loading && dispatch(loadRequestViaWorker({ collectionUid: collection?.uid, pathname: item?.pathname }));
}
const handleLoadRequest = () => {
!item?.loading && dispatch(loadRequest({ collectionUid: collection?.uid, pathname: item?.pathname }));
}
return <StyledWrapper>
<div className='flex flex-col p-4'>
<div className='card shadow-sm rounded-md p-4 w-[600px]'>
<div>
<div className='font-medium flex items-center gap-2 pb-4'>
<IconFile size={16} strokeWidth={1.5} className="text-gray-400" />
File Info
</div>
<div className='hr'/>
<div className='flex items-center mt-2'>
<span className='w-12 mr-2 text-muted'>Name:</span>
<div>{item?.name}</div>
</div>
<div className='flex items-center mt-1'>
<span className='w-12 mr-2 text-muted'>Path:</span>
<div className='break-all'>{item?.pathname}</div>
</div>
<div className='flex items-center mt-1 pb-4'>
<span className='w-12 mr-2 text-muted'>Size:</span>
<div>{item?.size?.toFixed?.(2)} MB</div>
</div>
{!item?.error && (
<>
<div className='hr'/>
<div className='text-muted text-xs mt-4 mb-2'>
Due to its large size, this request wasn't loaded automatically.
</div>
<div className='flex flex-col gap-6 mt-4'>
<div className='flex flex-col'>
<button
className={`submit btn btn-sm btn-secondary w-fit h-fit flex flex-row gap-2 ${item?.loading? 'opacity-50 cursor-blocked': ''}`}
onClick={handleLoadRequest}
>
Load Request
</button>
<small className='text-muted mt-1'>
May cause the app to freeze temporarily while it runs.
</small>
</div>
<div className='flex flex-col'>
<button
className={`submit btn btn-sm btn-secondary w-fit h-fit flex flex-row gap-2 ${item?.loading? 'opacity-50 cursor-blocked': ''}`}
onClick={handleLoadRequestViaWorker}
>
Load Request in Background
</button>
<small className='text-muted mt-1'>
Runs in background.
</small>
</div>
</div>
</>
)}
{item?.loading && (
<>
<div className='hr mt-4'/>
<div className='flex items-center gap-2 mt-4'>
<IconLoader2 className="animate-spin" size={16} strokeWidth={2} />
<span>Loading...</span>
</div>
</>
)}
</div>
</div>
</div>
</StyledWrapper>
}
export default RequestNotLoaded;

View File

@@ -22,6 +22,9 @@ import SecuritySettings from 'components/SecuritySettings';
import FolderSettings from 'components/FolderSettings';
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
import { produce } from 'immer';
import CollectionOverview from 'components/CollectionSettings/Overview';
import RequestNotLoaded from './RequestNotLoaded';
import RequestIsLoading from './RequestIsLoading';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@@ -153,6 +156,11 @@ const RequestTabPanel = () => {
if (focusedTab.type === 'collection-settings') {
return <CollectionSettings collection={collection} />;
}
if (focusedTab.type === 'collection-overview') {
return <CollectionOverview collection={collection} />;
}
if (focusedTab.type === 'folder-settings') {
const folder = findItemInCollection(collection, focusedTab.folderUid);
return <FolderSettings collection={collection} folder={folder} />;
@@ -167,6 +175,14 @@ const RequestTabPanel = () => {
return <RequestNotFound itemUid={activeTabUid} />;
}
if (item?.partial) {
return <RequestNotLoaded item={item} collection={collection} />
}
if (item?.loading) {
return <RequestIsLoading item={item} />
}
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {

View File

@@ -13,6 +13,14 @@ const SpecialTab = ({ handleCloseClick, type, tabName }) => {
</>
);
}
case 'collection-overview': {
return (
<>
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1 leading-6">Collection</span>
</>
);
}
case 'security-settings': {
return (
<>

View File

@@ -70,7 +70,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
};
const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
if (['collection-settings', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return (
<StyledWrapper
className="flex items-center justify-between tab-container px-1"

View File

@@ -9,6 +9,7 @@ import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun }
import slash from 'utils/common/slash';
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
const getRelativePath = (fullPath, pathname) => {
// convert to unix style path
@@ -106,6 +107,8 @@ export default function RunnerResults({ collection }) {
return (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail';
});
let isCollectionLoading = areItemsLoading(collection);
if (!items || !items.length) {
return (
<StyledWrapper className="px-4 pb-4">
@@ -116,7 +119,7 @@ export default function RunnerResults({ collection }) {
<div className="mt-6">
You have <span className="font-medium">{totalRequestsInCollection}</span> requests in this collection.
</div>
{isCollectionLoading ? <div className='my-1 danger'>Requests in this collection are still loading.</div> : null}
<div className="mt-6">
<label>Delay (in ms)</label>
<input

View File

@@ -0,0 +1,12 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.partial {
color: ${(props) => props.theme.colors.text.yellow};
}
.error {
color: ${(props) => props.theme.colors.text.danger};
}
`;
export default Wrapper;

View File

@@ -0,0 +1,21 @@
import RequestMethod from "../RequestMethod";
import { IconLoader2, IconAlertTriangle, IconAlertCircle } from '@tabler/icons';
import StyledWrapper from "./StyledWrapper";
const CollectionItemIcon = ({ item }) => {
if (item?.error) {
return <StyledWrapper><IconAlertCircle className="w-fit mr-2 error" size={18} strokeWidth={1.5} /></StyledWrapper>;
}
if (item?.loading) {
return <IconLoader2 className="animate-spin w-fit mr-2" size={18} strokeWidth={1.5} />;
}
if (item?.partial) {
return <StyledWrapper><IconAlertTriangle size={18} className="w-fit mr-2 partial" strokeWidth={1.5} /></StyledWrapper>;
}
return <RequestMethod item={item} />;
};
export default CollectionItemIcon;

View File

@@ -4,6 +4,9 @@ const Wrapper = styled.div`
.bruno-modal-content {
padding-bottom: 1rem;
}
.warning {
color: ${(props) => props.theme.colors.text.danger};
}
`;
export default Wrapper;

View File

@@ -7,6 +7,7 @@ import { addTab } from 'providers/ReduxStore/slices/tabs';
import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions';
import { flattenItems } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
const RunCollectionItem = ({ collection, item, onClose }) => {
const dispatch = useDispatch();
@@ -32,6 +33,10 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
const flattenedItems = flattenItems(item ? item.items : collection.items);
const recursiveRunLength = getRequestsCount(flattenedItems);
const isFolderLoading = areItemsLoading(item);
console.log(item);
console.log(isFolderLoading);
return (
<StyledWrapper>
<Modal size="md" title="Collection Runner" hideFooter={true} handleCancel={onClose}>
@@ -44,13 +49,12 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
<span className="ml-1 text-xs">({runLength} requests)</span>
</div>
<div className="mb-8">This will only run the requests in this folder.</div>
<div className="mb-1">
<span className="font-medium">Recursive Run</span>
<span className="ml-1 text-xs">({recursiveRunLength} requests)</span>
</div>
<div className="mb-8">This will run all the requests in this folder and all its subfolders.</div>
<div className={isFolderLoading ? "mb-2" : "mb-8"}>This will run all the requests in this folder and all its subfolders.</div>
{isFolderLoading ? <div className='mb-8 warning'>Requests in this folder are still loading.</div> : null}
<div className="flex justify-end bruno-modal-footer">
<span className="mr-3">
<button type="button" onClick={onClose} className="btn btn-md btn-close">

View File

@@ -6,12 +6,11 @@ import { useDrag, useDrop } from 'react-dnd';
import { IconChevronRight, IconDots } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { moveItem, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { moveItem, showInFolder, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
import Dropdown from 'components/Dropdown';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import RequestMethod from './RequestMethod';
import RenameCollectionItem from './RenameCollectionItem';
import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
@@ -24,7 +23,7 @@ import { hideHomePage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import NetworkError from 'components/ResponsePane/NetworkError/index';
import { uuid } from 'utils/common';
import CollectionItemIcon from './CollectionItemIcon/index';
const CollectionItem = ({ item, collection, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs);
@@ -39,7 +38,9 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
const [itemIsCollapsed, setItemisCollapsed] = useState(item.collapsed);
const hasSearchText = searchText && searchText?.trim()?.length;
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
const [{ isDragging }, drag] = useDrag({
type: `COLLECTION_ITEM_${collection.uid}`,
@@ -64,14 +65,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
})
});
useEffect(() => {
if (searchText && searchText.length) {
setItemisCollapsed(false);
} else {
setItemisCollapsed(item.collapsed);
}
}, [searchText, item]);
const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => {
return (
@@ -227,6 +220,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
};
const handleShowInFolder = () => {
dispatch(showInFolder(item.pathname)).catch((error) => {
console.error('Error opening the folder', error);
toast.error('Error opening the folder');
});
};
const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
@@ -294,12 +294,12 @@ const CollectionItem = ({ item, collection, searchText }) => {
</div>
<div
className="ml-1 flex items-center overflow-hidden flex-1"
className="ml-1 flex w-full h-full items-center overflow-hidden"
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
>
<RequestMethod item={item} />
<CollectionItemIcon item={item} />
<span className="item-name" title={item.name}>
{item.name}
</span>
@@ -378,6 +378,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
Generate Code
</div>
)}
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleShowInFolder();
}}
>
Show in Folder
</div>
<div
className="dropdown-item delete-item"
onClick={(e) => {
@@ -421,4 +430,4 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
};
export default CollectionItem;
export default CollectionItem;

View File

@@ -3,10 +3,10 @@ import classnames from 'classnames';
import { uuid } from 'utils/common';
import filter from 'lodash/filter';
import { useDrop } from 'react-dnd';
import { IconChevronRight, IconDots } from '@tabler/icons';
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { collectionClicked } from 'providers/ReduxStore/slices/collections';
import { moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
import { collapseCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
@@ -15,12 +15,12 @@ import CollectionItem from './CollectionItem';
import RemoveCollection from './RemoveCollection';
import ExportCollection from './ExportCollection';
import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search';
import { isItemAFolder, isItemARequest, transformCollectionToSaveToExportAsFile } from 'utils/collections';
import exportCollection from 'utils/collections/export';
import { isItemAFolder, isItemARequest } from 'utils/collections';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
import CloneCollection from './CloneCollection/index';
import CloneCollection from './CloneCollection';
import { areItemsLoading } from 'utils/collections';
const Collection = ({ collection, searchText }) => {
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
@@ -29,8 +29,8 @@ const Collection = ({ collection, searchText }) => {
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
const [showExportCollectionModal, setShowExportCollectionModal] = useState(false);
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
const [collectionIsCollapsed, setCollectionIsCollapsed] = useState(collection.collapsed);
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
@@ -52,32 +52,37 @@ const Collection = ({ collection, searchText }) => {
);
};
useEffect(() => {
if (searchText && searchText.length) {
setCollectionIsCollapsed(false);
} else {
setCollectionIsCollapsed(collection.collapsed);
}
}, [searchText, collection]);
const hasSearchText = searchText && searchText?.trim()?.length;
const collectionIsCollapsed = hasSearchText ? false : collection.collapsed;
const iconClassName = classnames({
'rotate-90': !collectionIsCollapsed
});
const handleClick = (event) => {
dispatch(collectionClicked(collection.uid));
};
// Check if the click came from the chevron icon
const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon');
const handleCollapseCollection = () => {
dispatch(collectionClicked(collection.uid));
dispatch(
addTab({
uid: uuid(),
if (collection.mountStatus === 'unmounted') {
dispatch(mountCollection({
collectionUid: collection.uid,
type: 'collection-settings'
})
);
}
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
}
dispatch(collapseCollection(collection.uid));
// Only open collection settings if not clicking the chevron
if(!isChevronClick) {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'collection-settings'
})
);
}
};
const handleRightClick = (event) => {
const _menuDropdown = menuDropdownTippyRef.current;
@@ -152,19 +157,19 @@ 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}
className={`chevron-icon ${iconClassName}`}
style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }}
onClick={handleClick}
/>
<div className="ml-1" id="sidebar-collection-name"
onClick={handleCollapseCollection}
onContextMenu={handleRightClick}>
<div className="ml-1" id="sidebar-collection-name">
{collection.name}
</div>
{isLoading ? <IconLoader2 className="animate-spin mx-1" size={18} strokeWidth={1.5} /> : null}
</div>
<div className="collection-actions">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">

View File

@@ -68,7 +68,7 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
);
};
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
<div className="flex flex-col">
<h3 className="text-sm">Select the type of your existing collection :</h3>
<div className="mt-4 grid grid-rows-2 grid-flow-col gap-2">

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.36.0</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.36.1</div>
</div>
</div>
</div>

View File

@@ -21,8 +21,9 @@ import {
transformRequestToSaveToFilesystem
} from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common';
import { PATH_SEPARATOR, getDirectoryName } from 'utils/common/platform';
import { PATH_SEPARATOR, getDirectoryName, isWindowsPath } from 'utils/common/platform';
import { cancelNetworkRequest, sendNetworkRequest } from 'utils/network';
import { callIpc } from 'utils/common/ipc';
import {
collectionAddEnvFileEvent as _collectionAddEnvFileEvent,
@@ -30,6 +31,7 @@ import {
removeCollection as _removeCollection,
selectEnvironment as _selectEnvironment,
sortCollections as _sortCollections,
updateCollectionMountStatus,
requestCancelled,
resetRunResults,
responseReceived,
@@ -42,7 +44,6 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { name } from 'file-loader';
import slash from 'utils/common/slash';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
@@ -161,7 +162,6 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState)
if (!folder) {
return reject(new Error('Folder not found'));
}
console.log(collection);
const { ipcRenderer } = window;
@@ -170,7 +170,6 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState)
pathname: folder.pathname,
root: folder.root
};
console.log(folderData);
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
@@ -494,7 +493,7 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
);
if (!reqWithSameNameExists) {
const dirname = getDirectoryName(item.pathname);
const fullName = path.join(dirname, filename);
const fullName = isWindowsPath(item.pathname) ? path.win32.join(dirname, filename) : path.join(dirname, filename);
const { ipcRenderer } = window;
const requestItems = filter(parentItem.items, (i) => i.type !== 'folder');
itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
@@ -759,7 +758,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
xml: null,
sparql: null,
multipartForm: null,
formUrlEncoded: null
formUrlEncoded: null,
binaryFile: null
},
auth: auth ?? {
mode: 'none'
@@ -1039,12 +1039,12 @@ export const browseDirectory = () => (dispatch, getState) => {
};
export const browseFiles =
(filters = []) =>
(filters = [], properties = ['multiSelections']) =>
(dispatch, getState) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
ipcRenderer.invoke('renderer:browse-files', filters).then(resolve).catch(reject);
ipcRenderer.invoke('renderer:browse-files', undefined, undefined, undefined, filters, properties).then(resolve).catch(reject);
});
};
@@ -1192,4 +1192,38 @@ export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getS
reject(error);
}
});
};
};
export const loadRequestViaWorker = ({ collectionUid, pathname }) => (dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-request-via-worker', { collectionUid, pathname }).then(resolve).catch(reject);
});
};
export const loadRequest = ({ collectionUid, pathname }) => (dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }).then(resolve).catch(reject);
});
};
export const mountCollection = ({ collectionUid, collectionPathname, brunoConfig }) => (dispatch, getState) => {
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounting' }));
return new Promise(async (resolve, reject) => {
callIpc('renderer:mount-collection', { collectionUid, collectionPathname, brunoConfig })
.then(() => dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' })))
.then(resolve)
.catch(() => {
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'unmounted' }));
reject();
});
});
};
export const showInFolder = (collectionPath) => () => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:show-in-folder', collectionPath).then(resolve).catch(reject);
});
};

View File

@@ -4,7 +4,7 @@ import { createSlice } from '@reduxjs/toolkit';
import {
addDepth,
areItemsTheSameExceptSeqUpdate,
collapseCollection,
collapseAllItemsInCollection,
deleteItemInCollection,
deleteItemInCollectionByPathname,
findCollectionByPathname,
@@ -18,6 +18,8 @@ import {
import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
import { getDirectoryName, getSubdirectoriesFromRoot, PATH_SEPARATOR } from 'utils/common/platform';
import toast from 'react-hot-toast';
import mime from 'mime-types';
import path from 'node:path';
const initialState = {
collections: [],
@@ -32,9 +34,13 @@ export const collectionsSlice = createSlice({
const collectionUids = map(state.collections, (c) => c.uid);
const collection = action.payload;
collection.settingsSelectedTab = 'headers';
collection.settingsSelectedTab = 'overview';
collection.folderLevelSettingsSelectedTab = {};
// Collection mount status is used to track the mount status of the collection
// values can be 'unmounted', 'mounting', 'mounted'
collection.mountStatus = 'unmounted';
// TODO: move this to use the nextAction approach
// last action is used to track the last action performed on the collection
// this is optional
@@ -44,12 +50,18 @@ export const collectionsSlice = createSlice({
collection.importedAt = new Date().getTime();
collection.lastAction = null;
collapseCollection(collection);
collapseAllItemsInCollection(collection);
addDepth(collection.items);
if (!collectionUids.includes(collection.uid)) {
state.collections.push(collection);
}
},
updateCollectionMountStatus: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
collection.mountStatus = action.payload.mountStatus;
}
},
setCollectionSecurityConfig: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
@@ -358,7 +370,7 @@ export const collectionsSlice = createSlice({
collection.items.push(item);
}
},
collectionClicked: (state, action) => {
collapseCollection: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload);
if (collection) {
@@ -863,25 +875,89 @@ export const collectionsSlice = createSlice({
}
}
},
moveMultipartFormParam: (state, action) => {
moveMultipartFormParam: (state, action) => {
// Ensure item.draft is a deep clone of item if not already present
if (!item.draft) {
item.draft = cloneDeep(item);
}
// Extract payload data
const { updateReorderedItem } = action.payload;
const params = item.draft.request.body.multipartForm;
item.draft.request.body.multipartForm = updateReorderedItem.map((uid) => {
return params.find((param) => param.uid === uid);
});
},
addBinaryFile: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.body.binaryFile = item.draft.request.body.binaryFile || [];
item.draft.request.body.binaryFile.push({
uid: uuid(),
type: action.payload.type,
name: '',
value: [''],
contentType: '',
enabled: false
});
}
}
},
updateBinaryFile: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
// Ensure item.draft is a deep clone of item if not already present
if (!item.draft) {
item.draft = cloneDeep(item);
}
// Extract payload data
const { updateReorderedItem } = action.payload;
const params = item.draft.request.body.multipartForm;
item.draft.request.body.multipartForm = updateReorderedItem.map((uid) => {
return params.find((param) => param.uid === uid);
item.draft.request.body.binaryFile = item.draft.request.body.binaryFile.map((p) => {
p.enabled = false;
return p;
});
const param = find(item.draft.request.body.binaryFile, (p) => p.uid === action.payload.param.uid);
if (param) {
const contentType = mime.contentType(path.extname(action.payload.param.value[0]));
param.type = action.payload.param.type;
param.name = action.payload.param.name;
param.value = action.payload.param.value;
param.contentType = action.payload.param.contentType || contentType || '';
param.enabled = action.payload.param.enabled;
}
}
}
},
deleteBinaryFile: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.body.binaryFile = filter(
item.draft.request.body.binaryFile,
(p) => p.uid !== action.payload.paramUid
);
}
}
},
@@ -941,6 +1017,10 @@ export const collectionsSlice = createSlice({
item.draft.request.body.sparql = action.payload.content;
break;
}
case 'binaryFile': {
item.draft.request.body.binaryFile = action.payload.content;
break;
}
case 'formUrlEncoded': {
item.draft.request.body.formUrlEncoded = action.payload.content;
break;
@@ -1582,7 +1662,7 @@ export const collectionsSlice = createSlice({
name: directoryName,
collapsed: true,
type: 'folder',
items: []
items: [],
};
currentSubItems.push(childItem);
}
@@ -1604,6 +1684,10 @@ export const collectionsSlice = createSlice({
currentItem.filename = file.meta.name;
currentItem.pathname = file.meta.pathname;
currentItem.draft = null;
currentItem.partial = file.partial;
currentItem.loading = file.loading;
currentItem.size = file.size;
currentItem.error = file.error;
} else {
currentSubItems.push({
uid: file.data.uid,
@@ -1613,7 +1697,11 @@ export const collectionsSlice = createSlice({
request: file.data.request,
filename: file.meta.name,
pathname: file.meta.pathname,
draft: null
draft: null,
partial: file.partial,
loading: file.loading,
size: file.size,
error: file.error
});
}
}
@@ -1890,6 +1978,7 @@ export const collectionsSlice = createSlice({
export const {
createCollection,
updateCollectionMountStatus,
setCollectionSecurityConfig,
brunoConfigUpdateEvent,
renameCollection,
@@ -1913,7 +2002,7 @@ export const {
saveRequest,
deleteRequestDraft,
newEphemeralHttpRequest,
collectionClicked,
collapseCollection,
collectionFolderClicked,
requestUrlChanged,
updateAuth,
@@ -1933,6 +2022,9 @@ export const {
addMultipartFormParam,
updateMultipartFormParam,
deleteMultipartFormParam,
addBinaryFile,
updateBinaryFile,
deleteBinaryFile,
moveMultipartFormParam,
updateRequestAuthMode,
updateRequestBodyMode,

View File

@@ -25,7 +25,7 @@ export const tabsSlice = createSlice({
}
if (
['variables', 'collection-settings', 'collection-runner', 'security-settings'].includes(action.payload.type)
['variables', 'collection-settings', 'collection-overview', 'collection-runner', 'security-settings'].includes(action.payload.type)
) {
const tab = tabTypeAlreadyExists(state.tabs, action.payload.collectionUid, action.payload.type);
if (tab) {

View File

@@ -114,7 +114,25 @@ const darkTheme = {
responseStatus: '#ccc',
responseOk: '#8cd656',
responseError: '#f06f57',
responseOverlayBg: 'rgba(30, 30, 30, 0.6)'
responseOverlayBg: 'rgba(30, 30, 30, 0.6)',
card: {
bg: '#252526',
border: 'transparent',
borderDark: '#8cd656',
hr: '#424242'
},
cardTable: {
border: '#333',
bg: '#252526',
table: {
thead: {
bg: '#3D3D3D',
color: '#ccc'
}
}
}
},
collection: {

View File

@@ -114,7 +114,22 @@ const lightTheme = {
responseStatus: 'rgb(117 117 117)',
responseOk: '#047857',
responseError: 'rgb(185, 28, 28)',
responseOverlayBg: 'rgba(255, 255, 255, 0.6)'
responseOverlayBg: 'rgba(255, 255, 255, 0.6)',
card: {
bg: '#fff',
border: '#f4f4f4',
hr: '#f4f4f4'
},
cardTable: {
border: '#efefef',
bg: '#fff',
table: {
thead: {
bg: 'rgb(249, 250, 251)',
color: 'rgb(75 85 99)'
}
}
}
},
collection: {

View File

@@ -14,6 +14,8 @@ const createContentType = (mode) => {
return 'application/json';
case 'multipartForm':
return 'multipart/form-data';
case 'binaryFile':
return 'application/octet-stream';
default:
return '';
}
@@ -60,26 +62,48 @@ const createPostData = (body, type) => {
}
const contentType = createContentType(body.mode);
if (body.mode === 'formUrlEncoded' || body.mode === 'multipartForm') {
return {
mimeType: contentType,
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({
name: param.name,
value: param.value,
...(param.type === 'file' && { fileName: param.value })
}))
};
} else {
return {
mimeType: contentType,
text: body[body.mode]
};
switch (body.mode) {
case 'formUrlEncoded':
case 'multipartForm':
return {
mimeType: contentType,
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({
name: param.name,
value: param.value,
...(param.type === 'file' && { fileName: param.value })
}))
};
case 'binaryFile':
const binary = {
mimeType: 'application/octet-stream',
// mimeType: body[body.mode].filter((param) => param.enabled)[0].contentType,
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({
name: param.name,
value: param.value,
fileName: param.value
}))
};
console.log('curl-binary', binary);
return binary;
default:
return {
mimeType: contentType,
text: body[body.mode]
};
}
};
export const buildHarRequest = ({ request, headers, type }) => {
console.log('buildHarRequest', request, headers, type);
console.log('buildHarRequest-postData', createPostData(request.body, type));
return {
method: request.method,
url: encodeURI(request.url),
@@ -89,6 +113,7 @@ export const buildHarRequest = ({ request, headers, type }) => {
queryString: createQuery(request.params),
postData: createPostData(request.body, type),
headersSize: 0,
bodySize: 0
bodySize: 0,
binary: true
};
};

View File

@@ -14,6 +14,7 @@ export const deleteUidsInItems = (items) => {
each(get(item, 'request.vars.assertions'), (a) => delete a.uid);
each(get(item, 'request.body.multipartForm'), (param) => delete param.uid);
each(get(item, 'request.body.formUrlEncoded'), (param) => delete param.uid);
each(get(item, 'request.body.binaryFile'), (param) => delete param.uid);
}
if (item.items && item.items.length) {

View File

@@ -34,7 +34,7 @@ export const addDepth = (items = []) => {
depth(items, 1);
};
export const collapseCollection = (collection) => {
export const collapseAllItemsInCollection = (collection) => {
collection.collapsed = true;
const collapseItem = (items) => {
@@ -47,7 +47,7 @@ export const collapseCollection = (collection) => {
});
};
collapseItem(collection.items, 1);
collapseItem(collection.items);
};
export const sortItems = (collection) => {
@@ -136,6 +136,16 @@ export const findEnvironmentInCollectionByName = (collection, name) => {
return find(collection.environments, (e) => e.name === name);
};
export const areItemsLoading = (folder) => {
let flattenedItems = flattenItems(folder.items);
return flattenedItems?.reduce((isLoading, i) => {
if (i?.loading) {
isLoading = true;
}
return isLoading;
}, false);
}
export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
@@ -271,6 +281,19 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
});
};
const copyBinaryFileParams = (params = []) => {
return map(params, (param) => {
return {
uid: param.uid,
type: param.type,
name: param.name,
value: param.value,
contentType: param.contentType,
enabled: param.enabled
}
});
}
const copyItems = (sourceItems, destItems) => {
each(sourceItems, (si) => {
if (!isItemAFolder(si) && !isItemARequest(si) && si.type !== 'js') {
@@ -298,7 +321,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
graphql: si.request.body.graphql,
sparql: si.request.body.sparql,
formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded),
multipartForm: copyMultipartFormParams(si.request.body.multipartForm)
multipartForm: copyMultipartFormParams(si.request.body.multipartForm),
binaryFile: copyBinaryFileParams(si.request.body.binaryFile)
},
script: si.request.script,
vars: si.request.vars,
@@ -651,6 +675,10 @@ export const humanizeRequestBodyMode = (mode) => {
label = 'SPARQL';
break;
}
case 'binaryFile': {
label = 'Binary File';
break;
}
case 'formUrlEncoded': {
label = 'Form URL Encoded';
break;
@@ -751,6 +779,7 @@ export const refreshUidsInItem = (item) => {
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.binaryFile'), (param) => (param.uid = uuid()));
return item;
};
@@ -761,11 +790,13 @@ export const deleteUidsInItem = (item) => {
const headers = get(item, 'request.headers', []);
const bodyFormUrlEncoded = get(item, 'request.body.formUrlEncoded', []);
const bodyMultipartForm = get(item, 'request.body.multipartForm', []);
const binaryFile = get(item, 'request.body.binaryFile', []);
params.forEach((param) => delete param.uid);
headers.forEach((header) => delete header.uid);
bodyFormUrlEncoded.forEach((param) => delete param.uid);
bodyMultipartForm.forEach((param) => delete param.uid);
binaryFile.forEach((param) => delete param.uid);
return item;
};
@@ -991,4 +1022,4 @@ const mergeVars = (collection, requestTreePath = []) => {
folderVariables,
requestVariables
};
};
};

View File

@@ -0,0 +1,14 @@
/**
* Wrapper for ipcRenderer.invoke that handles error cases
* @param {string} channel - The IPC channel name
* @param {...any} args - Arguments to pass to the channel
* @returns {Promise} - Resolves with the result or rejects with error
*/
export const callIpc = (channel, ...args) => {
const { ipcRenderer } = window;
if (!ipcRenderer) {
return Promise.reject(new Error('IPC Renderer not available'));
}
return ipcRenderer.invoke(channel, ...args);
};

View File

@@ -24,11 +24,25 @@ export const getSubdirectoriesFromRoot = (rootPath, pathname) => {
return relativePath ? relativePath.split(path.sep) : [];
};
export const getDirectoryName = (pathname) => {
// convert to unix style path
pathname = slash(pathname);
return path.dirname(pathname);
export const isWindowsPath = (pathname) => {
if (!isWindowsOS()) {
return false;
}
// Check for Windows drive letter format (e.g., "C:\")
const hasDriveLetter = /^[a-zA-Z]:\\/.test(pathname);
// Check for UNC path format (e.g., "\\server\share") a.k.a. network path || WSL path
const isUNCPath = pathname.startsWith('\\\\');
return hasDriveLetter || isUNCPath;
};
export const getDirectoryName = (pathname) => {
return isWindowsPath(pathname) ? path.win32.dirname(pathname) : path.dirname(pathname);
};
export const isWindowsOS = () => {

View File

@@ -9,6 +9,7 @@
import parseCurlCommand from './parse-curl';
import * as querystring from 'query-string';
import * as jsesc from 'jsesc';
import * as path from 'path';
function getContentType(headers = {}) {
const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type');
@@ -99,9 +100,36 @@ function getMultipleDataString(request, parsedQueryString) {
function getFilesString(request) {
const data = {};
data.files = {};
data.data = {};
if (request.isDataBinary){
let filePath = ''
if(request.data.startsWith('@')){
filePath = request.data.slice(1);
}else{
filePath = request.data;
}
const fileName = path.basename(filePath);
data.data = [
{
name: repr(fileName),
value: [repr(filePath)],
enabled: true,
contentType: request.headers['Content-Type'],
type: 'binaryFile'
}
];
return data;
}
data.files = {};
for (const multipartKey in request.multipartUploads) {
const multipartValue = request.multipartUploads[multipartKey];
if (multipartValue.startsWith('@')) {
@@ -140,6 +168,7 @@ const curlToJson = (curlCommand) => {
requestJson.url = request.urlWithoutQuery;
requestJson.raw_url = request.url;
requestJson.method = request.method;
requestJson.isDataBinary = request.isDataBinary;
if (request.cookies) {
const cookies = {};
@@ -163,11 +192,11 @@ const curlToJson = (curlCommand) => {
requestJson.queries = getQueries(request);
}
if (typeof request.data === 'string' || typeof request.data === 'number') {
Object.assign(requestJson, getDataString(request));
} else if (request.multipartUploads) {
else if (request.multipartUploads || request.isDataBinary) {
Object.assign(requestJson, getFilesString(request));
}
} else if (typeof request.data === 'string' || typeof request.data === 'number') {
Object.assign(requestJson, getDataString(request));
}
if (request.insecure) {
requestJson.insecure = false;

View File

@@ -86,4 +86,40 @@ describe('curlToJson', () => {
method: 'get'
});
});
it('should return a parse a curl with a post body with binary file type', () => {
const curlCommand = `curl 'https://www.usebruno.com'
-H 'Accept: application/json, text/plain, */*'
-H 'Accept-Language: en-US,en;q=0.9,hi;q=0.8'
-H 'Content-Type: application/json;charset=utf-8'
-H 'Origin: https://www.usebruno.com'
-H 'Referer: https://www.usebruno.com/'
--data-binary '@/path/to/file'
`;
const result = curlToJson(curlCommand);
expect(result).toEqual({
url: 'https://www.usebruno.com',
raw_url: 'https://www.usebruno.com',
method: 'post',
headers: {
Accept: 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9,hi;q=0.8',
'Content-Type': 'application/json;charset=utf-8',
Origin: 'https://www.usebruno.com',
Referer: 'https://www.usebruno.com/'
},
isDataBinary: true,
data: [
{
name: 'file',
value: ['/path/to/file'],
enabled: true,
contentType: 'application/json;charset=utf-8',
type: 'binaryFile'
}
]
});
});
});

View File

@@ -50,14 +50,18 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque
sparql: null,
multipartForm: null,
formUrlEncoded: null,
graphql: null
graphql: null,
binaryFile: null
};
if (parsedBody && contentType && typeof contentType === 'string') {
if (requestType === 'graphql-request' && (contentType.includes('application/json') || contentType.includes('application/graphql'))) {
body.mode = 'graphql';
body.graphql = parseGraphQL(parsedBody);
} else if (contentType.includes('application/json')) {
} else if (requestType === 'http-request' && request.isDataBinary) {
body.mode = 'binaryFile';
body.binaryFile = parsedBody;
}else if (contentType.includes('application/json')) {
body.mode = 'json';
body.json = convertToCodeMirrorJson(parsedBody);
} else if (contentType.includes('xml')) {

View File

@@ -35,6 +35,7 @@ export const updateUidsInCollection = (_collection) => {
each(get(item, 'request.assertions'), (a) => (a.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.binaryFile'), (param) => (param.uid = uuid()));
if (item.items && item.items.length) {
updateItemUids(item.items);

View File

@@ -229,26 +229,27 @@ const transformOpenapiRequestItem = (request) => {
return brunoRequestItem;
};
const resolveRefs = (spec, components = spec?.components, visitedItems = new Set()) => {
const resolveRefs = (spec, components = spec?.components, cache = new Map()) => {
if (!spec || typeof spec !== 'object') {
return spec;
}
if (cache.has(spec)) {
return cache.get(spec);
}
if (Array.isArray(spec)) {
return spec.map((item) => resolveRefs(item, components, visitedItems));
return spec.map(item => resolveRefs(item, components, cache));
}
if ('$ref' in spec) {
const refPath = spec.$ref;
if (visitedItems.has(refPath)) {
return spec;
} else {
visitedItems.add(refPath);
if (cache.has(refPath)) {
return cache.get(refPath);
}
if (refPath.startsWith('#/components/')) {
// Local reference within components
const refKeys = refPath.replace('#/components/', '').split('/');
let ref = components;
@@ -256,25 +257,26 @@ const resolveRefs = (spec, components = spec?.components, visitedItems = new Set
if (ref && ref[key]) {
ref = ref[key];
} else {
// Handle invalid references gracefully?
return spec;
}
}
return resolveRefs(ref, components, visitedItems);
} else {
// Handle external references (not implemented here)
// You would need to fetch the external reference and resolve it.
// Example: Fetch and resolve an external reference from a URL.
cache.set(refPath, {});
const resolved = resolveRefs(ref, components, cache);
cache.set(refPath, resolved);
return resolved;
}
return spec;
}
// Recursively resolve references in nested objects
for (const prop in spec) {
spec[prop] = resolveRefs(spec[prop], components, new Set(visitedItems));
const resolved = {};
cache.set(spec, resolved);
for (const [key, value] of Object.entries(spec)) {
resolved[key] = resolveRefs(value, components, cache);
}
return spec;
return resolved;
};
const groupRequestsByTags = (requests) => {

View File

@@ -422,7 +422,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
brunoRequestItem.request.auth.mode = 'apikey';
brunoRequestItem.request.auth.apikey = {
key: authValues.key,
value: authValues.value,
value: authValues.value?.toString(), // Convert the value to a string as Postman's schema does not rigidly define the type of it,
placement: "header" //By default we are placing the apikey values in headers!
}
}

View File

@@ -52,7 +52,7 @@
"@usebruno/lang": "0.12.0",
"@usebruno/vm2": "^3.9.13",
"aws4-axios": "^3.3.0",
"axios": "1.7.5",
"axios": "1.7.7",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chalk": "^3.0.0",
@@ -66,7 +66,6 @@
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^4.1.3",
"@usebruno/vm2": "^3.9.13",
"xmlbuilder": "^15.1.1",
"yargs": "^17.6.2"
}

View File

@@ -11,6 +11,7 @@ const { rpad } = require('../utils/common');
const { bruToJson, getOptions, collectionBruToJson } = require('../utils/bru');
const { dotenvToJson } = require('@usebruno/lang');
const constants = require('../constants');
const { findItemInCollection } = require('../utils/collection');
const command = 'run [filename]';
const desc = 'Run a request';
@@ -18,6 +19,7 @@ const printRunSummary = (results) => {
let totalRequests = 0;
let passedRequests = 0;
let failedRequests = 0;
let skippedRequests = 0;
let totalAssertions = 0;
let passedAssertions = 0;
let failedAssertions = 0;
@@ -49,7 +51,10 @@ const printRunSummary = (results) => {
failedAssertions += 1;
}
}
if (!hasAnyTestsOrAssertions && result.error) {
if (!hasAnyTestsOrAssertions && result.skipped) {
skippedRequests += 1;
}
else if (!hasAnyTestsOrAssertions && result.error) {
failedRequests += 1;
} else {
passedRequests += 1;
@@ -62,6 +67,9 @@ const printRunSummary = (results) => {
if (failedRequests > 0) {
requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`;
}
if (skippedRequests > 0) {
requestSummary += `, ${chalk.magenta(`${skippedRequests} skipped`)}`;
}
requestSummary += `, ${totalRequests} total`;
let assertSummary = `${rpad('Tests:', maxLength)} ${chalk.green(`${passedTests} passed`)}`;
@@ -84,6 +92,7 @@ const printRunSummary = (results) => {
totalRequests,
passedRequests,
failedRequests,
skippedRequests,
totalAssertions,
passedAssertions,
failedAssertions,
@@ -144,7 +153,7 @@ const createCollectionFromPath = (collectionPath) => {
});
}
}
return currentDirItems
return currentDirItems;
};
collection.items = traverse(collectionPath);
return collection;
@@ -634,6 +643,34 @@ const handler = async function (argv) {
}
const runtime = getJsSandboxRuntime(sandbox);
const runSingleRequestByPathname = async (relativeItemPathname) => {
return new Promise(async (resolve, reject) => {
let itemPathname = path.join(collectionPath, relativeItemPathname);
if (itemPathname && !itemPathname?.endsWith('.bru')) {
itemPathname = `${itemPathname}.bru`;
}
const bruJson = cloneDeep(findItemInCollection(collection, itemPathname));
if (bruJson) {
const res = await runSingleRequest(
itemPathname,
bruJson,
collectionPath,
runtimeVariables,
envVars,
processEnvVars,
brunoConfig,
collectionRoot,
runtime,
collection,
runSingleRequestByPathname
);
resolve(res?.response);
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
});
}
let currentRequestIndex = 0;
let nJumps = 0; // count the number of jumps to avoid infinite loops
while (currentRequestIndex < bruJsons.length) {
@@ -651,7 +688,8 @@ const handler = async function (argv) {
brunoConfig,
collectionRoot,
runtime,
collection
collection,
runSingleRequestByPathname
);
results.push({
@@ -701,6 +739,11 @@ const handler = async function (argv) {
// determine next request
const nextRequestName = result?.nextRequestName;
if (result?.shouldStopRunnerExecution) {
break;
}
if (nextRequestName !== undefined) {
nJumps++;
if (nJumps > 10000) {

View File

@@ -36,7 +36,7 @@ const prepareRequest = (item = {}, collection = {}) => {
};
const collectionAuth = get(collection, 'root.request.auth');
if (collectionAuth && request.auth.mode === 'inherit') {
if (collectionAuth && request.auth?.mode === 'inherit') {
if (collectionAuth.mode === 'basic') {
axiosRequest.auth = {
username: get(collectionAuth, 'basic.username'),
@@ -47,9 +47,27 @@ const prepareRequest = (item = {}, collection = {}) => {
if (collectionAuth.mode === 'bearer') {
axiosRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`;
}
if (collectionAuth.mode === 'apikey') {
if (collectionAuth.apikey?.placement === 'header') {
axiosRequest.headers[collectionAuth.apikey?.key] = collectionAuth.apikey?.value;
}
if (collectionAuth.apikey?.placement === 'queryparams') {
if (axiosRequest.url && collectionAuth.apikey?.key) {
try {
const urlObj = new URL(request.url);
urlObj.searchParams.set(collectionAuth.apikey?.key, collectionAuth.apikey?.value);
axiosRequest.url = urlObj.toString();
} catch (error) {
console.error('Invalid URL:', request.url, error);
}
}
}
}
}
if (request.auth) {
if (request.auth && request.auth.mode !== 'inherit') {
if (request.auth.mode === 'basic') {
axiosRequest.auth = {
username: get(request, 'auth.basic.username'),

View File

@@ -40,11 +40,13 @@ const runSingleRequest = async function (
brunoConfig,
collectionRoot,
runtime,
collection
collection,
runSingleRequestByPathname
) {
try {
let request;
let nextRequestName;
let shouldStopRunnerExecution = false;
let item = {
pathname: path.join(collectionPath, filename),
...bruJson
@@ -68,11 +70,41 @@ const runSingleRequest = async function (
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig
scriptingConfig,
runSingleRequestByPathname
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
if (result?.skipRequest) {
return {
test: {
filename: filename
},
request: {
method: request.method,
url: request.url,
headers: request.headers,
data: request.data
},
response: {
status: 'skipped',
statusText: 'request skipped via pre-request script',
data: null,
responseTime: 0
},
error: 'Request has been skipped from pre-request script',
skipped: true,
assertionResults: [],
testResults: [],
shouldStopRunnerExecution
};
}
}
// interpolate variables inside request
@@ -255,7 +287,7 @@ const runSingleRequest = async function (
let axiosInstance = makeAxiosInstance();
if (request.ntlmConfig) {
axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance)
axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults)
delete request.ntlmConfig;
}
@@ -323,7 +355,8 @@ const runSingleRequest = async function (
error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!',
assertionResults: [],
testResults: [],
nextRequestName: nextRequestName
nextRequestName: nextRequestName,
shouldStopRunnerExecution
};
}
}
@@ -363,11 +396,16 @@ const runSingleRequest = async function (
collectionPath,
null,
processEnvVars,
scriptingConfig
scriptingConfig,
runSingleRequestByPathname
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
}
// run assertions
@@ -408,13 +446,18 @@ const runSingleRequest = async function (
collectionPath,
null,
processEnvVars,
scriptingConfig
scriptingConfig,
runSingleRequestByPathname
);
testResults = get(result, 'results', []);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
}
if (testResults?.length) {
@@ -447,7 +490,8 @@ const runSingleRequest = async function (
error: null,
assertionResults,
testResults,
nextRequestName: nextRequestName
nextRequestName: nextRequestName,
shouldStopRunnerExecution
};
} catch (err) {
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));

View File

@@ -204,5 +204,6 @@ module.exports = {
mergeHeaders,
mergeVars,
mergeScripts,
findItemInCollection,
getTreePathFromCollectionToItem
}

View File

@@ -1,5 +1,4 @@
const { describe, it, expect } = require('@jest/globals');
const { describe, it, expect, beforeEach } = require('@jest/globals');
const prepareRequest = require('../../src/runner/prepare-request');
describe('prepare-request: prepareRequest', () => {
@@ -22,4 +21,144 @@ describe('prepare-request: prepareRequest', () => {
expect(result.data).toEqual(expected);
});
});
describe('Properly maps inherited auth from collectionRoot', () => {
// Initialize Test Fixtures
let collection, item;
beforeEach(() => {
collection = {
name: 'Test Collection',
root: {
request: {
auth: {}
}
}
};
item = {
name: 'Test Request',
type: 'http-request',
request: {
method: 'GET',
headers: [],
params: [],
url: 'https://usebruno.com',
auth: {
mode: 'inherit'
},
script: {
req: 'console.log("Pre Request")',
res: 'console.log("Post Response")'
}
}
};
});
describe('API Key Authentication', () => {
it('If collection auth is apikey in header', () => {
collection.root.request.auth = {
mode: "apikey",
apikey: {
key: "x-api-key",
value: "{{apiKey}}",
placement: "header"
}
};
const result = prepareRequest(item, collection);
expect(result.headers).toHaveProperty('x-api-key', '{{apiKey}}');
});
it('If collection auth is apikey in header and request has existing headers', () => {
collection.root.request.auth = {
mode: "apikey",
apikey: {
key: "x-api-key",
value: "{{apiKey}}",
placement: "header"
}
};
item.request.headers.push({ name: 'Content-Type', value: 'application/json', enabled: true });
const result = prepareRequest(item, collection);
expect(result.headers).toHaveProperty('Content-Type', 'application/json');
expect(result.headers).toHaveProperty('x-api-key', '{{apiKey}}');
});
it('If collection auth is apikey in query parameters', () => {
collection.root.request.auth = {
mode: "apikey",
apikey: {
key: "x-api-key",
value: "{{apiKey}}",
placement: "queryparams"
}
};
const urlObj = new URL(item.request.url);
urlObj.searchParams.set(collection.root.request.auth.apikey.key, collection.root.request.auth.apikey.value);
const expected = urlObj.toString();
const result = prepareRequest(item, collection);
expect(result.url).toEqual(expected);
});
});
describe('Basic Authentication', () => {
it('If collection auth is basic auth', () => {
collection.root.request.auth = {
mode: 'basic',
basic: {
username: 'testUser',
password: 'testPass123'
}
};
const result = prepareRequest(item, collection);
const expected = { username: 'testUser', password: 'testPass123' };
expect(result.auth).toEqual(expected);
});
});
describe('Bearer Token Authentication', () => {
it('If collection auth is bearer token', () => {
collection.root.request.auth = {
mode: 'bearer',
bearer: {
token: 'token'
}
};
const result = prepareRequest(item, collection);
expect(result.headers).toHaveProperty('Authorization', 'Bearer token');
});
it('If collection auth is bearer token and request has existing headers', () => {
collection.root.request.auth = {
mode: 'bearer',
bearer: {
token: 'token'
}
};
item.request.headers.push({ name: 'Content-Type', value: 'application/json', enabled: true });
const result = prepareRequest(item, collection);
expect(result.headers).toHaveProperty('Authorization', 'Bearer token');
expect(result.headers).toHaveProperty('Content-Type', 'application/json');
});
});
describe('No Authentication', () => {
it('If request does not have auth configured', () => {
delete item.request.auth;
let result;
expect(() => {
result = prepareRequest(item, collection);
}).not.toThrow();
expect(result).toBeDefined();
});
});
});
});

View File

@@ -9,6 +9,7 @@
"scripts": {
"clean": "rimraf dist",
"dev": "electron .",
"debug": "electron . --inspect=9229",
"dist:mac": "electron-builder --mac --config electron-builder-config.js",
"dist:win": "electron-builder --win --config electron-builder-config.js",
"dist:linux": "electron-builder --linux AppImage --config electron-builder-config.js",
@@ -33,7 +34,7 @@
"@usebruno/vm2": "^3.9.13",
"about-window": "^1.15.2",
"aws4-axios": "^3.3.0",
"axios": "1.7.5",
"axios": "1.7.7",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chokidar": "^3.5.3",

View File

@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
const { dialog, ipcMain } = require('electron');
const Yup = require('yup');
const { isDirectory, normalizeAndResolvePath } = require('../utils/filesystem');
const { isDirectory, normalizeAndResolvePath, getCollectionStats } = require('../utils/filesystem');
const { generateUidBasedOnHash } = require('../utils/common');
// todo: bruno.json config schema validation errors must be propagated to the UI
@@ -59,7 +59,7 @@ const openCollectionDialog = async (win, watcher) => {
const openCollection = async (win, watcher, collectionPath, options = {}) => {
if (!watcher.hasWatcher(collectionPath)) {
try {
const brunoConfig = await getCollectionConfigFile(collectionPath);
let brunoConfig = await getCollectionConfigFile(collectionPath);
const uid = generateUidBasedOnHash(collectionPath);
if (!brunoConfig.ignore || brunoConfig.ignore.length === 0) {
@@ -70,6 +70,10 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => {
brunoConfig.ignore = ['node_modules', '.git'];
}
const { size, filesCount } = await getCollectionStats(collectionPath);
brunoConfig.size = size;
brunoConfig.filesCount = filesCount;
win.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', win, collectionPath, uid, brunoConfig);
} catch (err) {

View File

@@ -2,8 +2,8 @@ const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
const { hasBruExtension, isWSLPath, normalizeAndResolvePath, normalizeWslPath } = require('../utils/filesystem');
const { bruToEnvJson, bruToJson, collectionBruToJson } = require('../bru');
const { hasBruExtension, isWSLPath, normalizeAndResolvePath, normalizeWslPath, sizeInMB } = require('../utils/filesystem');
const { bruToEnvJson, bruToJson, bruToJsonViaWorker ,collectionBruToJson } = require('../bru');
const { dotenvToJson } = require('@usebruno/lang');
const { uuid } = require('../utils/common');
@@ -13,6 +13,9 @@ const { setDotEnvVars } = require('../store/process-env');
const { setBrunoConfig } = require('../store/bruno-config');
const EnvironmentSecretsStore = require('../store/env-secrets');
const UiStateSnapshot = require('../store/ui-state-snapshot');
const { parseBruFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
const MAX_FILE_SIZE = 2.5 * 1024 * 1024;
const environmentSecretsStore = new EnvironmentSecretsStore();
@@ -44,28 +47,6 @@ const isCollectionRootBruFile = (pathname, collectionPath) => {
return dirname === collectionPath && basename === 'collection.bru';
};
const hydrateRequestWithUuid = (request, pathname) => {
request.uid = getRequestUid(pathname);
const params = _.get(request, 'request.params', []);
const headers = _.get(request, 'request.headers', []);
const requestVars = _.get(request, 'request.vars.req', []);
const responseVars = _.get(request, 'request.vars.res', []);
const assertions = _.get(request, 'request.assertions', []);
const bodyFormUrlEncoded = _.get(request, 'request.body.formUrlEncoded', []);
const bodyMultipartForm = _.get(request, 'request.body.multipartForm', []);
params.forEach((param) => (param.uid = uuid()));
headers.forEach((header) => (header.uid = uuid()));
requestVars.forEach((variable) => (variable.uid = uuid()));
responseVars.forEach((variable) => (variable.uid = uuid()));
assertions.forEach((assertion) => (assertion.uid = uuid()));
bodyFormUrlEncoded.forEach((param) => (param.uid = uuid()));
bodyMultipartForm.forEach((param) => (param.uid = uuid()));
return request;
};
const hydrateBruCollectionFileWithUuid = (collectionRoot) => {
const params = _.get(collectionRoot, 'request.params', []);
const headers = _.get(collectionRoot, 'request.headers', []);
@@ -99,7 +80,7 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath)
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = bruToEnvJson(bruContent);
file.data = await bruToEnvJson(bruContent);
file.data.name = basename.substring(0, basename.length - 4);
file.data.uid = getRequestUid(pathname);
@@ -134,7 +115,7 @@ const changeEnvironmentFile = async (win, pathname, collectionUid, collectionPat
};
const bruContent = fs.readFileSync(pathname, 'utf8');
file.data = bruToEnvJson(bruContent);
file.data = await bruToEnvJson(bruContent);
file.data.name = basename.substring(0, basename.length - 4);
file.data.uid = getRequestUid(pathname);
_.each(_.get(file, 'data.variables', []), (variable) => (variable.uid = uuid()));
@@ -179,7 +160,7 @@ const unlinkEnvironmentFile = async (win, pathname, collectionUid) => {
}
};
const add = async (win, pathname, collectionUid, collectionPath) => {
const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread) => {
console.log(`watcher add: ${pathname}`);
if (isBrunoConfigFile(pathname, collectionPath)) {
@@ -228,7 +209,7 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = collectionBruToJson(bruContent);
file.data = await collectionBruToJson(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
@@ -241,7 +222,6 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
// Is this a folder.bru file?
if (path.basename(pathname) === 'folder.bru') {
console.log('folder.bru file detected');
const file = {
meta: {
collectionUid,
@@ -254,7 +234,7 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = collectionBruToJson(bruContent);
file.data = await collectionBruToJson(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
@@ -274,15 +254,67 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
}
};
const fileStats = fs.statSync(pathname);
let bruContent = fs.readFileSync(pathname, 'utf8');
// If worker thread is not used, we can directly parse the file
if (!useWorkerThread) {
try {
file.data = await bruToJson(bruContent);
file.partial = false;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
} catch (error) {
console.error(error);
}
return;
}
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = bruToJson(bruContent);
// we need to send a partial file info to the UI
// so that the UI can display the file in the collection tree
file.data = {
name: path.basename(pathname),
type: 'http-request'
};
const metaJson = await bruToJson(parseBruFileMeta(bruContent), true);
file.data = metaJson;
file.partial = true;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
if (fileStats.size < MAX_FILE_SIZE) {
// This is to update the loading indicator in the UI
file.data = metaJson;
file.partial = false;
file.loading = true;
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
// This is to update the file info in the UI
file.data = await bruToJsonViaWorker(bruContent);
file.partial = false;
file.loading = false;
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
}
} catch(error) {
file.data = {
name: path.basename(pathname),
type: 'http-request'
};
file.error = {
message: error?.message
};
file.partial = true;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
} catch (err) {
console.error(err);
}
}
};
@@ -357,7 +389,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = collectionBruToJson(bruContent);
file.data = await collectionBruToJson(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'change', file);
return;
@@ -378,7 +410,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
};
const bru = fs.readFileSync(pathname, 'utf8');
file.data = bruToJson(bru);
file.data = await bruToJson(bru);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'change', file);
@@ -424,10 +456,10 @@ const unlinkDir = (win, pathname, collectionUid, collectionPath) => {
win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory);
};
const onWatcherSetupComplete = (win, collectionPath) => {
const onWatcherSetupComplete = (win, watchPath) => {
const UiStateSnapshotStore = new UiStateSnapshot();
const collectionsSnapshotState = UiStateSnapshotStore.getCollections();
const collectionSnapshotState = collectionsSnapshotState?.find(c => c?.pathname == collectionPath);
const collectionSnapshotState = collectionsSnapshotState?.find(c => c?.pathname == watchPath);
win.webContents.send('main:hydrate-app-with-ui-state-snapshot', collectionSnapshotState);
};
@@ -436,7 +468,7 @@ class Watcher {
this.watchers = {};
}
addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false) {
addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false, useWorkerThread) {
if (this.watchers[watchPath]) {
this.watchers[watchPath].close();
}
@@ -467,7 +499,7 @@ class Watcher {
let startedNewWatcher = false;
watcher
.on('ready', () => onWatcherSetupComplete(win, watchPath))
.on('add', (pathname) => add(win, pathname, collectionUid, watchPath))
.on('add', (pathname) => add(win, pathname, collectionUid, watchPath, useWorkerThread))
.on('addDir', (pathname) => addDirectory(win, pathname, collectionUid, watchPath))
.on('change', (pathname) => change(win, pathname, collectionUid, watchPath))
.on('unlink', (pathname) => unlink(win, pathname, collectionUid, watchPath))
@@ -488,7 +520,7 @@ class Watcher {
'Update you system config to allow more concurrently watched files with:',
'"echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p"'
);
this.addWatcher(win, watchPath, collectionUid, brunoConfig, true);
this.addWatcher(win, watchPath, collectionUid, brunoConfig, true, useWorkerThread);
} else {
console.error(`An error occurred in the watcher for: ${watchPath}`, error);
}

View File

@@ -7,10 +7,13 @@ const {
collectionBruToJson: _collectionBruToJson,
jsonToCollectionBru: _jsonToCollectionBru
} = require('@usebruno/lang');
const BruParserWorker = require('./workers');
const collectionBruToJson = (bru) => {
const bruParserWorker = new BruParserWorker();
const collectionBruToJson = async (data, parsed = false) => {
try {
const json = _collectionBruToJson(bru);
const json = parsed ? data : _collectionBruToJson(data);
const transformedJson = {
request: {
@@ -38,7 +41,7 @@ const collectionBruToJson = (bru) => {
}
};
const jsonToCollectionBru = (json, isFolder) => {
const jsonToCollectionBru = async (json, isFolder) => {
try {
const collectionBruJson = {
headers: _.get(json, 'request.headers', []),
@@ -73,7 +76,7 @@ const jsonToCollectionBru = (json, isFolder) => {
}
};
const bruToEnvJson = (bru) => {
const bruToEnvJson = async (bru) => {
try {
const json = bruToEnvJsonV2(bru);
@@ -90,7 +93,7 @@ const bruToEnvJson = (bru) => {
}
};
const envJsonToBru = (json) => {
const envJsonToBru = async (json) => {
try {
const bru = envJsonToBruV2(json);
return bru;
@@ -105,12 +108,12 @@ const envJsonToBru = (json) => {
* We map the json response from the bru lang and transform it into the DSL
* format that the app uses
*
* @param {string} bru The BRU file content.
* @param {string} data The BRU file content.
* @returns {object} The JSON representation of the BRU file.
*/
const bruToJson = (bru) => {
const bruToJson = (data, parsed = false) => {
try {
const json = bruToJsonV2(bru);
const json = parsed ? data : bruToJsonV2(data);
let requestType = _.get(json, 'meta.type');
if (requestType === 'http') {
@@ -149,6 +152,16 @@ const bruToJson = (bru) => {
return Promise.reject(e);
}
};
const bruToJsonViaWorker = async (data) => {
try {
const json = await bruParserWorker?.bruToJson(data);
return bruToJson(json, true);
} catch (e) {
return Promise.reject(e);
}
};
/**
* The transformer function for converting a JSON to BRU file.
*
@@ -158,7 +171,7 @@ const bruToJson = (bru) => {
* @param {object} json The JSON representation of the BRU file.
* @returns {string} The BRU file content.
*/
const jsonToBru = (json) => {
const jsonToBru = async (json) => {
let type = _.get(json, 'type');
if (type === 'http-request') {
type = 'http';
@@ -195,14 +208,59 @@ const jsonToBru = (json) => {
docs: _.get(json, 'request.docs', '')
};
return jsonToBruV2(bruJson);
const bru = jsonToBruV2(bruJson);
return bru;
};
const jsonToBruViaWorker = async (json) => {
let type = _.get(json, 'type');
if (type === 'http-request') {
type = 'http';
} else if (type === 'graphql-request') {
type = 'graphql';
} else {
type = 'http';
}
const sequence = _.get(json, 'seq');
const bruJson = {
meta: {
name: _.get(json, 'name'),
type: type,
seq: !isNaN(sequence) ? Number(sequence) : 1
},
http: {
method: _.lowerCase(_.get(json, 'request.method')),
url: _.get(json, 'request.url'),
auth: _.get(json, 'request.auth.mode', 'none'),
body: _.get(json, 'request.body.mode', 'none')
},
params: _.get(json, 'request.params', []),
headers: _.get(json, 'request.headers', []),
auth: _.get(json, 'request.auth', {}),
body: _.get(json, 'request.body', {}),
script: _.get(json, 'request.script', {}),
vars: {
req: _.get(json, 'request.vars.req', []),
res: _.get(json, 'request.vars.res', [])
},
assertions: _.get(json, 'request.assertions', []),
tests: _.get(json, 'request.tests', ''),
docs: _.get(json, 'request.docs', '')
};
const bru = await bruParserWorker?.jsonToBru(bruJson)
return bru;
};
module.exports = {
bruToJson,
bruToJsonViaWorker,
jsonToBru,
bruToEnvJson,
envJsonToBru,
collectionBruToJson,
jsonToCollectionBru
jsonToCollectionBru,
jsonToBruViaWorker
};

View File

@@ -0,0 +1,57 @@
const WorkerQueue = require("../../workers");
const path = require("path");
const getSize = (data) => {
return typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : Buffer.byteLength(JSON.stringify(data), 'utf8');
}
/**
* Lanes are used to determine which worker queue to use based on the size of the data.
*
* The first lane is for smaller files (<0.1MB), the second lane is for larger files (>=0.1MB).
* This helps with parsing performance.
*/
const LANES = [{
maxSize: 0.1
},{
maxSize: 100
}];
class BruParserWorker {
constructor() {
this.workerQueues = LANES?.map(lane => ({
maxSize: lane?.maxSize,
workerQueue: new WorkerQueue()
}));
}
getWorkerQueue(size) {
// Find the first queue that can handle the given size
// or fallback to the last queue for largest files
const queueForSize = this.workerQueues.find((queue) =>
queue.maxSize >= size
);
return queueForSize?.workerQueue ?? this.workerQueues.at(-1).workerQueue;
}
async enqueueTask({data, scriptFile }) {
const size = getSize(data);
const workerQueue = this.getWorkerQueue(size);
return workerQueue.enqueue({
data,
priority: size,
scriptPath: path.join(__dirname, `./scripts/${scriptFile}.js`)
});
}
async bruToJson(data) {
return this.enqueueTask({ data, scriptFile: `bru-to-json` });
}
async jsonToBru(data) {
return this.enqueueTask({ data, scriptFile: `json-to-bru` });
}
}
module.exports = BruParserWorker;

View File

@@ -0,0 +1,14 @@
const { workerData, parentPort } = require('worker_threads');
const {
bruToJsonV2,
} = require('@usebruno/lang');
try {
const bru = workerData;
const json = bruToJsonV2(bru);
parentPort.postMessage(json);
}
catch(error) {
console.error(error);
parentPort.postMessage({ error: error?.message });
}

View File

@@ -0,0 +1,13 @@
const { workerData, parentPort } = require('worker_threads');
const {
jsonToBruV2,
} = require('@usebruno/lang');
try {
const json = workerData;
const bru = jsonToBruV2(json);
parentPort.postMessage(bru);
}
catch(error) {
console.error(error);
parentPort.postMessage({ error: error?.message });
}

View File

@@ -4,7 +4,7 @@ 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');
const { envJsonToBru, bruToJson, jsonToBruViaWorker, jsonToCollectionBru, bruToJsonViaWorker } = require('../bru');
const {
isValidPathname,
@@ -24,6 +24,8 @@ const {
isWindowsOS,
isValidFilename,
hasSubDirectories,
getCollectionStats,
sizeInMB
} = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@@ -32,11 +34,17 @@ const { deleteCookiesForDomain, getDomainsWithCookies } = require('../utils/cook
const EnvironmentSecretsStore = require('../store/env-secrets');
const CollectionSecurityStore = require('../store/collection-security');
const UiStateSnapshotStore = require('../store/ui-state-snapshot');
const { parseBruFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
const environmentSecretsStore = new EnvironmentSecretsStore();
const collectionSecurityStore = new CollectionSecurityStore();
const uiStateSnapshotStore = new UiStateSnapshotStore();
// size and file count limits to determine whether the bru files in the collection should be loaded asynchronously or not.
const MAX_COLLECTION_SIZE_IN_MB = 5;
const MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB = 2;
const MAX_COLLECTION_FILES_COUNT = 100;
const envHasSecrets = (environment = {}) => {
const secrets = _.filter(environment.variables, (v) => v.secret);
@@ -54,9 +62,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
});
// browse directory for file
ipcMain.handle('renderer:browse-files', async (event, pathname, request, filters) => {
ipcMain.handle('renderer:browse-files', async (event, pathname, request, filters, properties) => {
try {
const filePaths = await browseFiles(mainWindow, filters);
const filePaths = await browseFiles(mainWindow, filters, properties);
return filePaths;
} catch (error) {
@@ -97,6 +106,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const content = await stringifyJson(brunoConfig);
await writeFile(path.join(dirPath, 'bruno.json'), content);
const { size, filesCount } = await getCollectionStats(dirPath);
brunoConfig.size = size;
brunoConfig.filesCount = filesCount;
mainWindow.webContents.send('main:collection-opened', dirPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid, brunoConfig);
} catch (error) {
@@ -126,9 +139,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const brunoJsonFilePath = path.join(previousPath, 'bruno.json');
const content = fs.readFileSync(brunoJsonFilePath, 'utf8');
//Change new name of collection
let json = JSON.parse(content);
json.name = collectionName;
// Change new name of collection
let brunoConfig = JSON.parse(content);
brunoConfig.name = collectionName;
const cont = await stringifyJson(json);
// write the bruno.json to new dir
@@ -147,7 +160,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
fs.copyFileSync(sourceFilePath, newFilePath);
}
mainWindow.webContents.send('main:collection-opened', dirPath, uid, json);
const { size, filesCount } = await getCollectionStats(dirPath);
brunoConfig.size = size;
brunoConfig.filesCount = filesCount;
mainWindow.webContents.send('main:collection-opened', dirPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid);
}
);
@@ -184,7 +201,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
name: folderName
};
const content = jsonToCollectionBru(
const content = await jsonToCollectionBru(
folderRoot,
true // isFolder
);
@@ -197,7 +214,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
try {
const collectionBruFilePath = path.join(collectionPathname, 'collection.bru');
const content = jsonToCollectionBru(collectionRoot);
const content = await jsonToCollectionBru(collectionRoot);
await writeFile(collectionBruFilePath, content);
} catch (error) {
return Promise.reject(error);
@@ -213,7 +230,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (!isValidFilename(request.name)) {
throw new Error(`path: ${request.name}.bru is not a valid filename`);
}
const content = jsonToBru(request);
const content = await jsonToBruViaWorker(request);
await writeFile(pathname, content);
} catch (error) {
return Promise.reject(error);
@@ -227,7 +244,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`path: ${pathname} does not exist`);
}
const content = jsonToBru(request);
const content = await jsonToBruViaWorker(request);
await writeFile(pathname, content);
} catch (error) {
return Promise.reject(error);
@@ -245,7 +262,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`path: ${pathname} does not exist`);
}
const content = jsonToBru(request);
const content = await jsonToBruViaWorker(request);
await writeFile(pathname, content);
}
} catch (error) {
@@ -275,7 +292,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
environmentSecretsStore.storeEnvSecrets(collectionPathname, environment);
}
const content = envJsonToBru(environment);
const content = await envJsonToBru(environment);
await writeFile(envFilePath, content);
} catch (error) {
@@ -300,7 +317,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
environmentSecretsStore.storeEnvSecrets(collectionPathname, environment);
}
const content = envJsonToBru(environment);
const content = await envJsonToBru(environment);
await writeFile(envFilePath, content);
} catch (error) {
return Promise.reject(error);
@@ -412,11 +429,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// 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);
const jsonData = await bruToJsonViaWorker(data);
jsonData.name = newName;
moveRequestUid(oldPath, newPath);
const content = jsonToBru(jsonData);
const content = await jsonToBruViaWorker(jsonData);
await fs.promises.unlink(oldPath);
await writeFile(newPath, content);
@@ -516,9 +533,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// Recursive function to parse the collection items and create files/folders
const parseCollectionItems = (items = [], currentPath) => {
items.forEach((item) => {
items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
const content = jsonToBru(item);
const content = await jsonToBruViaWorker(item);
const filePath = path.join(currentPath, `${item.name}.bru`);
fs.writeFileSync(filePath, content);
}
@@ -529,7 +546,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (item?.root?.meta?.name) {
const folderBruFilePath = path.join(folderPath, 'folder.bru');
const folderContent = jsonToCollectionBru(
const folderContent = await jsonToCollectionBru(
item.root,
true // isFolder
);
@@ -554,8 +571,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
fs.mkdirSync(envDirPath);
}
environments.forEach((env) => {
const content = envJsonToBru(env);
environments.forEach(async (env) => {
const content = await envJsonToBru(env);
const filePath = path.join(envDirPath, `${env.name}.bru`);
fs.writeFileSync(filePath, content);
});
@@ -579,15 +596,19 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
await createDirectory(collectionPath);
const uid = generateUidBasedOnHash(collectionPath);
const brunoConfig = getBrunoJsonConfig(collection);
let brunoConfig = getBrunoJsonConfig(collection);
const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
// Write the Bruno configuration to a file
await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
const collectionContent = jsonToCollectionBru(collection.root);
const collectionContent = await jsonToCollectionBru(collection.root);
await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
const { size, filesCount } = await getCollectionStats(collectionPath);
brunoConfig.size = size;
brunoConfig.filesCount = filesCount;
mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
@@ -609,9 +630,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// Recursive function to parse the folder and create files/folders
const parseCollectionItems = (items = [], currentPath) => {
items.forEach((item) => {
items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
const content = jsonToBru(item);
const content = await jsonToBruViaWorker(item);
const filePath = path.join(currentPath, `${item.name}.bru`);
fs.writeFileSync(filePath, content);
}
@@ -621,7 +642,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// If folder has a root element, then I should write its folder.bru file
if (item.root) {
const folderContent = jsonToCollectionBru(item.root, true);
const folderContent = await jsonToCollectionBru(item.root, true);
if (folderContent) {
const bruFolderPath = path.join(folderPath, `folder.bru`);
fs.writeFileSync(bruFolderPath, folderContent);
@@ -639,7 +660,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// If initial folder has a root element, then I should write its folder.bru file
if (itemFolder.root) {
const folderContent = jsonToCollectionBru(itemFolder.root, true);
const folderContent = await jsonToCollectionBru(itemFolder.root, true);
if (folderContent) {
const bruFolderPath = path.join(collectionPath, `folder.bru`);
fs.writeFileSync(bruFolderPath, folderContent);
@@ -655,13 +676,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:resequence-items', async (event, itemsToResequence) => {
try {
for (let item of itemsToResequence) {
for await (let item of itemsToResequence) {
const bru = fs.readFileSync(item.pathname, 'utf8');
const jsonData = bruToJson(bru);
const jsonData = await bruToJsonViaWorker(bru);
if (jsonData.seq !== item.seq) {
jsonData.seq = item.seq;
const content = jsonToBru(jsonData);
const content = await jsonToBruViaWorker(jsonData);
await writeFile(item.pathname, content);
}
}
@@ -776,6 +797,131 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(error.message);
}
});
ipcMain.handle('renderer:load-request-via-worker', async (event, { collectionUid, pathname }) => {
let fileStats;
try {
fileStats = fs.statSync(pathname);
if (hasBruExtension(pathname)) {
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
const metaJson = await bruToJson(parseBruFileMeta(bruContent), true);
file.data = metaJson;
file.loading = true;
file.partial = true;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
file.data = await bruToJsonViaWorker(bruContent);
file.partial = false;
file.loading = true;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
}
} catch (error) {
if (hasBruExtension(pathname)) {
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
const metaJson = await bruToJson(parseBruFileMeta(bruContent), true);
file.data = metaJson;
file.partial = true;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
}
return Promise.reject(error);
}
});
ipcMain.handle('renderer:load-request', async (event, { collectionUid, pathname }) => {
let fileStats;
try {
fileStats = fs.statSync(pathname);
if (hasBruExtension(pathname)) {
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
const metaJson = await bruToJson(parseBruFileMeta(bruContent), true);
file.data = metaJson;
file.loading = true;
file.partial = true;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
file.data = bruToJson(bruContent);
file.partial = false;
file.loading = true;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
}
} catch (error) {
if (hasBruExtension(pathname)) {
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
const metaJson = await bruToJson(parseBruFileMeta(bruContent), true);
file.data = metaJson;
file.partial = true;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
}
return Promise.reject(error);
}
});
ipcMain.handle('renderer:mount-collection', async (event, { collectionUid, collectionPathname, brunoConfig }) => {
const {
size,
filesCount,
maxFileSize
} = await getCollectionStats(collectionPathname);
const shouldLoadCollectionAsync =
(size > MAX_COLLECTION_SIZE_IN_MB) ||
(filesCount > MAX_COLLECTION_FILES_COUNT) ||
(maxFileSize > MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB);
watcher.addWatcher(mainWindow, collectionPathname, collectionUid, brunoConfig, false, shouldLoadCollectionAsync);
});
ipcMain.handle('renderer:show-in-folder', async (event, filePath) => {
try {
if (!filePath) {
throw new Error('File path is required');
}
shell.showItemInFolder(filePath);
} catch (error) {
console.error('Error in show-in-folder: ', error);
throw error;
}
});
};
const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {
@@ -790,8 +936,7 @@ const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) =
shell.openExternal(docsURL);
});
ipcMain.on('main:collection-opened', (win, pathname, uid, brunoConfig) => {
watcher.addWatcher(win, pathname, uid, brunoConfig);
ipcMain.on('main:collection-opened', async (win, pathname, uid, brunoConfig) => {
lastOpenedCollections.add(pathname);
app.addRecentDocument(pathname);
});

View File

@@ -274,11 +274,10 @@ const configureRequest = async (
});
}
let axiosInstance = makeAxiosInstance();
if (request.ntlmConfig) {
axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance)
axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults)
delete request.ntlmConfig;
}
@@ -403,7 +402,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -437,6 +436,8 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
}
// interpolate variables inside request
@@ -470,7 +471,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -507,6 +508,8 @@ const registerNetworkIpc = (mainWindow) => {
if (result?.error) {
mainWindow.webContents.send('main:display-error', result.error);
}
collection.globalEnvironmentVariables = result.globalEnvironmentVariables;
}
// run post-response script
@@ -537,11 +540,13 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
}
return scriptResult;
};
const runRequest = async ({ item, collection, environment, runtimeVariables, runInBackground = false }) => {
const runRequest = async ({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground = false }) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const cancelTokenUid = uuid();
@@ -553,9 +558,9 @@ const registerNetworkIpc = (mainWindow) => {
if (itemPathname && !itemPathname?.endsWith('.bru')) {
itemPathname = `${itemPathname}.bru`;
}
const _item = findItemInCollectionByPathname(collection, itemPathname);
const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname));
if(_item) {
const res = await runRequest({ item: _item, collection, environment, runtimeVariables, runInBackground: true });
const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
resolve(res);
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
@@ -570,26 +575,27 @@ const registerNetworkIpc = (mainWindow) => {
cancelTokenUid
});
const abortController = new AbortController();
const collectionRoot = get(collection, 'root', {});
const request = prepareRequest(item, collection);
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'standalone';
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
const brunoConfig = getBrunoConfig(collectionUid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
try {
const controller = new AbortController();
request.signal = controller.signal;
saveCancelToken(cancelTokenUid, controller);
request.signal = abortController.signal;
saveCancelToken(cancelTokenUid, abortController);
await runPreRequest(
request,
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -612,7 +618,7 @@ const registerNetworkIpc = (mainWindow) => {
url: request.url,
method: request.method,
headers: request.headers,
data: safeParseJSON(safeStringifyJSON(request.data)),
data: request.mode == 'binaryFile'? undefined: safeParseJSON(safeStringifyJSON(request.data)) ,
timestamp: Date.now()
},
collectionUid,
@@ -674,7 +680,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -758,7 +764,10 @@ const registerNetworkIpc = (mainWindow) => {
// handler for sending http request
ipcMain.handle('send-http-request', async (event, item, collection, environment, runtimeVariables) => {
return await runRequest({ item, collection, environment, runtimeVariables });
const collectionUid = collection.uid;
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
return await runRequest({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: false });
});
ipcMain.handle('send-collection-oauth2-request', async (event, collection, environment, runtimeVariables) => {
@@ -782,7 +791,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -818,7 +827,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -888,7 +897,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -912,7 +921,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -949,7 +958,8 @@ const registerNetworkIpc = (mainWindow) => {
const brunoConfig = getBrunoConfig(collectionUid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
const collectionRoot = get(collection, 'root', {});
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
let stopRunnerExecution = false;
const abortController = new AbortController();
@@ -961,9 +971,9 @@ const registerNetworkIpc = (mainWindow) => {
if (itemPathname && !itemPathname?.endsWith('.bru')) {
itemPathname = `${itemPathname}.bru`;
}
const _item = findItemInCollectionByPathname(collection, itemPathname);
const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname));
if(_item) {
const res = await runRequest({ item: _item, collection, environment, runtimeVariables, runInBackground: true });
const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
resolve(res);
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
@@ -983,7 +993,6 @@ const registerNetworkIpc = (mainWindow) => {
});
try {
const envVars = getEnvVars(environment);
let folderRequests = [];
if (recursive) {
@@ -1031,11 +1040,10 @@ const registerNetworkIpc = (mainWindow) => {
...eventData
});
const request = prepareRequest(item, collection);
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'runner';
const requestUid = uuid();
const processEnvVars = getProcessEnvVars(collectionUid);
try {
const preRequestScriptResult = await runPreRequest(
@@ -1043,7 +1051,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
@@ -1181,7 +1189,7 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
collectionRoot,
collection,
collectionUid,
runtimeVariables,
processEnvVars,

View File

@@ -1,8 +1,10 @@
const { get, each, filter } = require('lodash');
const decomment = require('decomment');
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars } = require('../../utils/collection');
const { buildFormUrlEncodedPayload, createFormData } = require('../../utils/form-data');
const path = require('node:path');
const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
@@ -174,7 +176,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
return axiosRequest;
};
const prepareRequest = (item, collection) => {
const prepareRequest = async (item, collection, abortController) => {
const request = item.draft ? item.draft.request : item.request;
const collectionRoot = get(collection, 'root', {});
const collectionPath = collection.pathname;
@@ -251,6 +253,36 @@ const prepareRequest = (item, collection) => {
axiosRequest.data = request.body.sparql;
}
if (request.body.mode === 'binaryFile') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/octet-stream';
}
if (request.body.binaryFile && request.body.binaryFile.length > 0) {
axiosRequest.headers['content-type'] = request.body.binaryFile[0].contentType;
let filePath = request.body.binaryFile[0].value[0];
if (filePath && filePath !== '') {
if (!path.isAbsolute(filePath)) {
filePath = path.join(collectionPath, filePath);
}
const file = await fs.readFile(filePath, abortController)
axiosRequest.data = file
if(axiosRequest.headers['content-type'].includes('application/json')) {
axiosRequest.data = JSON.parse(file)
}
}
}
}
if (request.body.mode === 'formUrlEncoded') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded';

View File

@@ -1,3 +1,7 @@
const fs = require('fs');
const { getRequestUid } = require('../cache/requestUids');
const { uuid } = require('./common');
const { get, each, find, compact } = require('lodash');
const os = require('os');
@@ -7,7 +11,7 @@ const mergeHeaders = (collection, request, requestTreePath) => {
let collectionHeaders = get(collection, 'root.request.headers', []);
collectionHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
headers.set(header.name?.toLowerCase?.(), header.value);
if (header?.name?.toLowerCase() === 'content-type') {
contentTypeDefined = true;
}
@@ -19,14 +23,14 @@ const mergeHeaders = (collection, request, requestTreePath) => {
let _headers = get(i, 'root.request.headers', []);
_headers.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
headers.set(header.name?.toLowerCase?.(), 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);
headers.set(header.name?.toLowerCase?.(), header.value);
}
});
}
@@ -203,6 +207,51 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
return path;
};
const parseBruFileMeta = (data) => {
try {
const metaRegex = /meta\s*{\s*([\s\S]*?)\s*}/;
const match = data?.match?.(metaRegex);
if (match) {
const metaContent = match[1].trim();
const lines = metaContent.replace(/\r\n/g, '\n').split('\n');
const metaJson = {};
lines.forEach(line => {
const [key, value] = line.split(':').map(str => str.trim());
if (key && value) {
metaJson[key] = isNaN(value) ? value : Number(value);
}
});
return { meta: metaJson };
} else {
console.log('No "meta" block found in the file.');
}
} catch (err) {
console.error('Error reading file:', err);
}
}
const hydrateRequestWithUuid = (request, pathname) => {
request.uid = getRequestUid(pathname);
const params = get(request, 'request.params', []);
const headers = get(request, 'request.headers', []);
const requestVars = get(request, 'request.vars.req', []);
const responseVars = get(request, 'request.vars.res', []);
const assertions = get(request, 'request.assertions', []);
const bodyFormUrlEncoded = get(request, 'request.body.formUrlEncoded', []);
const bodyMultipartForm = get(request, 'request.body.multipartForm', []);
params.forEach((param) => (param.uid = uuid()));
headers.forEach((header) => (header.uid = uuid()));
requestVars.forEach((variable) => (variable.uid = uuid()));
responseVars.forEach((variable) => (variable.uid = uuid()));
assertions.forEach((assertion) => (assertion.uid = uuid()));
bodyFormUrlEncoded.forEach((param) => (param.uid = uuid()));
bodyMultipartForm.forEach((param) => (param.uid = uuid()));
return request;
};
const slash = (path) => {
const isExtendedLengthPath = /^\\\\\?\\/.test(path);
if (isExtendedLengthPath) {
@@ -221,13 +270,18 @@ const findItemInCollectionByPathname = (collection, pathname) => {
return findItemByPathname(flattenedItems, pathname);
};
module.exports = {
mergeHeaders,
mergeVars,
mergeScripts,
getTreePathFromCollectionToItem,
flattenItems,
findItem,
findItemInCollection,
slash,
findItemByPathname,
findItemInCollectionByPathname
}
findItemInCollectionByPathname,
findParentItemInCollection,
parseBruFileMeta,
hydrateRequestWithUuid
};

View File

@@ -121,9 +121,9 @@ const browseDirectory = async (win) => {
return isDirectory(resolvedPath) ? resolvedPath : false;
};
const browseFiles = async (win, filters) => {
const browseFiles = async (win, filters, properties) => {
const { filePaths } = await dialog.showOpenDialog(win, {
properties: ['openFile', 'multiSelections'],
properties: ['openFile', ...properties],
filters
});
@@ -211,6 +211,50 @@ const safeToRename = (oldPath, newPath) => {
}
};
const getCollectionStats = async (directoryPath) => {
let size = 0;
let filesCount = 0;
let maxFileSize = 0;
async function calculateStats(directory) {
const entries = await fsPromises.readdir(directory, { withFileTypes: true });
const tasks = entries.map(async (entry) => {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (['node_modules', '.git'].includes(entry.name)) {
return;
}
await calculateStats(fullPath);
}
if (path.extname(fullPath) === '.bru') {
const stats = await fsPromises.stat(fullPath);
size += stats?.size;
if (maxFileSize < stats?.size) {
maxFileSize = stats?.size;
}
filesCount += 1;
}
});
await Promise.all(tasks);
}
await calculateStats(directoryPath);
size = sizeInMB(size);
maxFileSize = sizeInMB(maxFileSize);
return { size, filesCount, maxFileSize };
}
const sizeInMB = (size) => {
return size / (1024 * 1024);
}
module.exports = {
isValidPathname,
exists,
@@ -235,5 +279,7 @@ module.exports = {
isWindowsOS,
safeToRename,
isValidFilename,
hasSubDirectories
hasSubDirectories,
getCollectionStats,
sizeInMB
};

View File

@@ -0,0 +1,60 @@
const { Worker } = require('worker_threads');
class WorkerQueue {
constructor() {
this.queue = [];
this.isProcessing = false;
}
async enqueue(task) {
const { priority, scriptPath, data } = task;
return new Promise((resolve, reject) => {
this.queue.push({ priority, scriptPath, data, resolve, reject });
this.queue?.sort((taskX, taskY) => taskX?.priority - taskY?.priority);
this.processQueue();
});
}
async processQueue() {
if (this.isProcessing || this.queue.length === 0){
return;
}
this.isProcessing = true;
const { scriptPath, data, resolve, reject } = this.queue.shift();
try {
const result = await this.runWorker({ scriptPath, data });
resolve(result);
} catch (error) {
reject(error);
} finally {
this.isProcessing = false;
this.processQueue();
}
}
async runWorker({ scriptPath, data }) {
return new Promise((resolve, reject) => {
const worker = new Worker(scriptPath, { workerData: data });
worker.on('message', (data) => {
if (data?.error) {
reject(new Error(data?.error));
}
resolve(data);
worker.terminate();
});
worker.on('error', (error) => {
reject(error);
worker.terminate();
});
worker.on('exit', (code) => {
reject(new Error(`stopped with ${code} exit code`));
worker.terminate();
});
});
}
}
module.exports = WorkerQueue;

View File

@@ -0,0 +1,121 @@
const { parseBruFileMeta } = require("../../src/utils/collection");
describe('parseBruFileMeta', () => {
test('parses valid meta block correctly', () => {
const data = `meta {
name: 0.2_mb
type: http
seq: 1
}`;
const result = parseBruFileMeta(data);
expect(result).toEqual({
meta: {
name: '0.2_mb',
type: 'http',
seq: 1,
},
});
});
test('returns undefined for missing meta block', () => {
const data = `someOtherBlock {
key: value
}`;
const result = parseBruFileMeta(data);
expect(result).toBeUndefined();
});
test('handles empty meta block gracefully', () => {
const data = `meta {}`;
const result = parseBruFileMeta(data);
expect(result).toEqual({ meta: {} });
});
test('ignores invalid lines in meta block', () => {
const data = `meta {
name: 0.2_mb
invalidLine
seq: 1
}`;
const result = parseBruFileMeta(data);
expect(result).toEqual({
meta: {
name: '0.2_mb',
seq: 1,
},
});
});
test('handles unexpected input gracefully', () => {
const data = null;
const result = parseBruFileMeta(data);
expect(result).toBeUndefined();
});
test('handles missing colon gracefully', () => {
const data = `meta {
name 0.2_mb
seq: 1
}`;
const result = parseBruFileMeta(data);
expect(result).toEqual({
meta: {
seq: 1,
},
});
});
test('parses numeric values correctly', () => {
const data = `meta {
numValue: 1234
floatValue: 12.34
strValue: some_text
}`;
const result = parseBruFileMeta(data);
expect(result).toEqual({
meta: {
numValue: 1234,
floatValue: 12.34,
strValue: 'some_text',
},
});
});
test('handles syntax error in meta block 1', () => {
const data = `meta
name: 0.2_mb
type: http
seq: 1
}`;
const result = parseBruFileMeta(data);
expect(result).toBeUndefined();
});
test('handles syntax error in meta block 2', () => {
const data = `meta {
name: 0.2_mb
type: http
seq: 1
`;
const result = parseBruFileMeta(data);
expect(result).toBeUndefined();
});
});

View File

@@ -21,7 +21,7 @@
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"atob": "^2.1.2",
"axios": "1.7.5",
"axios": "1.7.7",
"btoa": "^1.2.1",
"chai": "^4.3.7",
"chai-string": "^1.5.0",

View File

@@ -1,3 +1,5 @@
const { get } = require('@usebruno/query');
class BrunoResponse {
constructor(res) {
this.res = res;
@@ -6,6 +8,13 @@ class BrunoResponse {
this.headers = res ? res.headers : null;
this.body = res ? res.data : null;
this.responseTime = res ? res.responseTime : null;
// Make the instance callable
const callable = (...args) => get(this.body, ...args);
Object.setPrototypeOf(callable, this.constructor.prototype);
Object.assign(callable, this);
return callable;
}
getStatus() {

View File

@@ -25,7 +25,7 @@ const grammar = ohm.grammar(`Bru {
BruFile = (meta | http | query | params | headers | auths | bodies | varsandassert | script | tests | docs)*
auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth2 | authwsse | authapikey
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body
bodyforms = bodyformurlencoded | bodymultipart
bodyforms = bodyformurlencoded | bodymultipart | bodybinaryfile
params = paramspath | paramsquery
nl = "\\r"? "\\n"
@@ -102,7 +102,8 @@ const grammar = ohm.grammar(`Bru {
bodyformurlencoded = "body:form-urlencoded" dictionary
bodymultipart = "body:multipart-form" dictionary
bodybinaryfile = "body:binary-file" dictionary
script = scriptreq | scriptres
scriptreq = "script:pre-request" st* "{" nl* textblock tagend
scriptres = "script:post-response" st* "{" nl* textblock tagend
@@ -173,6 +174,19 @@ const multipartExtractContentType = (pair) => {
}
};
const binaryFileExtractContentType = (pair) => {
if (_.isString(pair.value)) {
const match = pair.value.match(/^(.*?)\s*@contentType\((.*?)\)\s*$/);
if (match != null && match.length > 2) {
pair.value = match[1];
pair.contentType = match[2];
} else {
pair.contentType = '';
}
}
};
const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) => {
const pairs = mapPairListToKeyValPairs(pairList, parseEnabled);
@@ -190,6 +204,23 @@ const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) =
});
};
const mapPairListToKeyValPairsBinaryFile = (pairList = [], parseEnabled = true) => {
const pairs = mapPairListToKeyValPairs(pairList, parseEnabled);
return pairs.map((pair) => {
binaryFileExtractContentType(pair);
if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) {
let filestr = pair.value.replace(/^@file\(/, '').replace(/\)$/, '');
pair.type = 'binaryFile';
pair.value = filestr != '' ? filestr.split('|') : [''];
}
return pair;
});
};
const concatArrays = (objValue, srcValue) => {
if (_.isArray(objValue) && _.isArray(srcValue)) {
return objValue.concat(srcValue);
@@ -574,6 +605,13 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
bodybinaryfile(_1, dictionary) {
return {
body: {
binaryFile: mapPairListToKeyValPairsBinaryFile(dictionary.ast)
}
};
},
body(_1, _2, _3, _4, textblock, _5) {
return {
http: {

View File

@@ -313,6 +313,32 @@ ${indentString(body.sparql)}
bru += '\n}\n\n';
}
if (body && body.binaryFile && body.binaryFile.length) {
bru += `body:binary-file {`;
const binaryFiles = enabled(body.binaryFile).concat(disabled(body.binaryFile));
if (binaryFiles.length) {
bru += `\n${indentString(
binaryFiles
.map((item) => {
const enabled = item.enabled ? '' : '~';
const contentType =
item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
if (item.type === 'binaryFile') {
let filestr = item.value[0] || '';
const value = `@file(${filestr})`;
return `${enabled}${item.name}: ${value}${contentType}`;
}
})
.join('\n')
)}`;
}
bru += '\n}\n\n';
}
if (body && body.graphql && body.graphql.query) {
bru += `body:graphql {\n`;
bru += `${indentString(body.graphql.query)}`;

View File

@@ -102,6 +102,11 @@ body:multipart-form {
~message: hello
}
body:binary-file {
file: @file(path/to/file.json) @contentType(application/json)
~file2: @file(path/to/file2.json) @contentType(application/json)
}
body:graphql {
{
launchesPast {

View File

@@ -137,6 +137,22 @@
"enabled": false,
"type": "text"
}
],
"binaryFile" : [
{
"name": "file",
"value": ["path/to/file.json"],
"enabled": true,
"type": "binaryFile",
"contentType": "application/json"
},
{
"name": "file2",
"value": ["path/to/file2.json"],
"enabled": false,
"type": "binaryFile",
"contentType": "application/json"
}
]
},
"vars": {

View File

@@ -74,9 +74,21 @@ const multipartFormSchema = Yup.object({
.noUnknown(true)
.strict();
const binaryFileSchema = Yup.object({
uid: uidSchema,
type: Yup.string().oneOf(['binaryFile']).required('type is required'),
name: Yup.string().nullable(),
value: Yup.array().of(Yup.string().nullable()).nullable(),
contentType: Yup.string().nullable(),
enabled: Yup.boolean()
})
.noUnknown(true)
.strict();
const requestBodySchema = Yup.object({
mode: Yup.string()
.oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql'])
.oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql', 'binaryFile'])
.required('mode is required'),
json: Yup.string().nullable(),
text: Yup.string().nullable(),
@@ -84,7 +96,8 @@ const requestBodySchema = Yup.object({
sparql: Yup.string().nullable(),
formUrlEncoded: Yup.array().of(keyValueSchema).nullable(),
multipartForm: Yup.array().of(multipartFormSchema).nullable(),
graphql: graphqlBodySchema.nullable()
graphql: graphqlBodySchema.nullable(),
binaryFile: Yup.array().of(binaryFileSchema).nullable()
})
.noUnknown(true)
.strict();

View File

@@ -0,0 +1,27 @@
meta {
name: binary-files-types
type: http
seq: 1
}
post {
url: {{host}}/api/binaryFile/binary-file-types
body: binaryFile
auth: none
}
body:binary-file {
file1: @file() @contentType()
file2: @file(binaryFile/binary-file.json) @contentType()
file3: @file(binaryFile/binary-file.json) @contentType(application/json)
}
assert {
res.status: eq 200
res.body.find(p=>p.name === 'file1').value[0]: isUndefined
res.body.find(p=>p.name === 'file1').contentType: isUndefined
res.body.find(p=>p.name === 'file2').value[0]: eq binaryFile/binary-file.json
res.body.find(p=>p.name === 'file2').contentType: eq isUndefined
res.body.find(p=>p.name === 'file3').value[0]: eq binaryFile/binary-file.json
res.body.find(p=>p.name === 'file3').contentType: eq application/json
}

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "bruno-testing",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,15 @@
meta {
name: ping-another-one
type: http
seq: 2
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:pre-request {
throw new Error('this should not execute in a collection run');
}

View File

@@ -9,3 +9,7 @@ get {
body: none
auth: none
}
script:pre-request {
bru.runner.stopExecution();
}

View File

@@ -0,0 +1,60 @@
meta {
name: runRequest-1
type: http
seq: 10
}
post {
url: {{echo-host}}
body: text
auth: none
}
body:text {
bruno
}
script:pre-request {
// reset values
bru.setVar('run-request-runtime-var', null);
bru.setEnvVar('run-request-env-var', null);
bru.setGlobalEnvVar('run-request-global-env-var', null);
// the above vars will be set in the below request
const resp = await bru.runRequest('scripting/api/bru/runRequest-2');
bru.setVar('run-request-resp', {
data: resp?.data,
statusText: resp?.statusText,
status: resp?.status
});
}
tests {
test("should get runtime var set in runRequest-2", function() {
const val = bru.getVar("run-request-runtime-var");
expect(val).to.equal("run-request-runtime-var-value");
});
test("should get env var set in runRequest-2", function() {
const val = bru.getEnvVar("run-request-env-var");
expect(val).to.equal("run-request-env-var-value");
});
test("should get global env var set in runRequest-2", function() {
const val = bru.getGlobalEnvVar("run-request-global-env-var");
const executionMode = req.getExecutionMode();
if (executionMode == 'runner') {
expect(val).to.equal("run-request-global-env-var-value");
}
});
test("should get response of runRequest-2", function() {
const val = bru.getVar('run-request-resp');
expect(JSON.stringify(val)).to.equal(JSON.stringify({
"data": "bruno",
"statusText": "OK",
"status": 200
}));
});
}

View File

@@ -0,0 +1,21 @@
meta {
name: runRequest-2
type: http
seq: 11
}
post {
url: {{echo-host}}
body: text
auth: none
}
body:text {
bruno
}
script:pre-request {
bru.setVar('run-request-runtime-var', 'run-request-runtime-var-value');
bru.setEnvVar('run-request-env-var', 'run-request-env-var-value');
bru.setGlobalEnvVar('run-request-global-env-var', 'run-request-global-env-var-value');
}

View File

@@ -0,0 +1,96 @@
meta {
name: runRequest
type: http
seq: 2
}
post {
url: {{host}}/api/echo/json
body: json
auth: none
}
headers {
foo: bar
}
auth:basic {
username: asd
password: j
}
auth:bearer {
token:
}
body:json {
{
"hello": "bruno"
}
}
assert {
res.status: eq 200
}
script:pre-request {
bru.setVar("runRequest-ping-res-1", null);
bru.setVar("runRequest-ping-res-2", null);
bru.setVar("runRequest-ping-res-3", null);
let pingRes = await bru.runRequest('ping');
bru.setVar('runRequest-ping-res-1', {
data: pingRes?.data,
statusText: pingRes?.statusText,
status: pingRes?.status
});
}
script:post-response {
let pingRes = await bru.runRequest('ping');
bru.setVar('runRequest-ping-res-2', {
data: pingRes?.data,
statusText: pingRes?.statusText,
status: pingRes?.status
});
}
tests {
const pingRes = await bru.runRequest('ping');
bru.setVar('runRequest-ping-res-3', {
data: pingRes?.data,
statusText: pingRes?.statusText,
status: pingRes?.status
});
test("should run request and return valid response in pre-request script", function() {
const expectedPingRes = {
data: "pong",
statusText: "OK",
status: 200
};
const pingRes = bru.getVar('runRequest-ping-res-1');
expect(pingRes).to.eql(expectedPingRes);
});
test("should run request and return valid response in post-response script", function() {
const expectedPingRes = {
data: "pong",
statusText: "OK",
status: 200
};
const pingRes = bru.getVar('runRequest-ping-res-2');
expect(pingRes).to.eql(expectedPingRes);
});
test("should run request and return valid response in tests script", function() {
const expectedPingRes = {
data: "pong",
statusText: "OK",
status: 200
};
const pingRes = bru.getVar('runRequest-ping-res-3');
expect(pingRes).to.eql(expectedPingRes);
});
}

View File

@@ -0,0 +1,19 @@
meta {
name: 1
type: http
seq: 1
}
post {
url: https://echo.usebruno.com
body: none
auth: none
}
script:pre-request {
bru.setVar('bru-runner-req', 1);
}
script:post-response {
bru.setVar('bru.runner.skipRequest', true);
}

View File

@@ -0,0 +1,19 @@
meta {
name: 2
type: http
seq: 2
}
post {
url: https://echo.usebruno.com
body: none
auth: none
}
script:pre-request {
bru.runner.skipRequest();
}
script:post-response {
bru.setVar('bru.runner.skipRequest', false);
}

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