mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 17:38:36 +00:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
942c0ee113 | ||
|
|
dbf8af1146 | ||
|
|
d7ccf1454e | ||
|
|
652d447f8b | ||
|
|
2f58379feb | ||
|
|
d4673a2f07 | ||
|
|
3a0c94577f | ||
|
|
c407b73c22 | ||
|
|
361add3385 | ||
|
|
9d6ab69d37 | ||
|
|
b699088dd6 | ||
|
|
458c070004 | ||
|
|
babac6df3c | ||
|
|
f58477931f | ||
|
|
2171d491a6 | ||
|
|
aa911f88f2 | ||
|
|
bdbcaeff67 | ||
|
|
b2756b3c63 | ||
|
|
27f11ab583 | ||
|
|
2776970317 | ||
|
|
9d28bf7e82 | ||
|
|
6455b00742 | ||
|
|
16179a3b50 | ||
|
|
6a37c9d076 | ||
|
|
1915b1c00a | ||
|
|
a9982d6e28 | ||
|
|
1daeb8fe93 | ||
|
|
3dfb158382 | ||
|
|
fb7d247fa7 | ||
|
|
6bf2312a94 | ||
|
|
0cdcb83a7a | ||
|
|
e4f48e81fc | ||
|
|
1d32a95a09 | ||
|
|
4c934a78a6 | ||
|
|
c47bc86d37 | ||
|
|
a125781312 | ||
|
|
dfa951e574 | ||
|
|
76779e6f95 | ||
|
|
e9a79a32da | ||
|
|
967170a7b2 | ||
|
|
3326784315 | ||
|
|
fc553e1009 | ||
|
|
da172ff9b5 | ||
|
|
fc422853ef | ||
|
|
2852c07ec7 | ||
|
|
ead1c9ecab | ||
|
|
5b5066577f | ||
|
|
4af0bb3943 | ||
|
|
f2eaa79318 | ||
|
|
2ee7ce5829 | ||
|
|
38c307d6f1 | ||
|
|
520567793a | ||
|
|
126186041e | ||
|
|
6379e1703c | ||
|
|
2b246e431b | ||
|
|
529803f791 | ||
|
|
b93d8e73a2 | ||
|
|
17c9813c98 |
44
.github/workflows/playwright.yml
vendored
Normal file
44
.github/workflows/playwright.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Playwright E2E Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
e2e-test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: v22.11.x
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get --no-install-recommends install -y \
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb
|
||||
npm ci --legacy-peer-deps
|
||||
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
|
||||
- name: Build libraries
|
||||
run: |
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
xvfb-run npm run test:e2e
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
3
.github/workflows/tests.yml
vendored
3
.github/workflows/tests.yml
vendored
@@ -31,6 +31,9 @@ jobs:
|
||||
npm run build --workspace=packages/bruno-converters
|
||||
npm run build --workspace=packages/bruno-requests
|
||||
|
||||
- name: Lint Check
|
||||
run: npm run lint
|
||||
|
||||
# tests
|
||||
- name: Test Package bruno-js
|
||||
run: npm run test --workspace=packages/bruno-js
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -46,4 +46,8 @@ yarn-error.log*
|
||||
|
||||
#dev editor
|
||||
bruno.iml
|
||||
.idea
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
@@ -15,15 +15,15 @@
|
||||
| [正體中文](docs/contributing/contributing_zhtw.md)
|
||||
| [日本語](docs/contributing/contributing_ja.md)
|
||||
| [हिंदी](docs/contributing/contributing_hi.md)
|
||||
| [Nederlands](docs/contributing/contributing_nl.md)
|
||||
| [Dutch](docs/contributing/contributing_nl.md)
|
||||
|
||||
## Let's make Bruno better, together!!
|
||||
|
||||
We are happy that you are looking to improve Bruno. Below are the guidelines to get started bringing up Bruno on your computer.
|
||||
We are happy that you are looking to improve Bruno. Below are the guidelines to run Bruno on your computer.
|
||||
|
||||
### Technology Stack
|
||||
|
||||
Bruno is built using Next.js and React. We also use electron to ship a desktop version (that supports local collections)
|
||||
Bruno is built using React and Electron.
|
||||
|
||||
Libraries we use
|
||||
|
||||
@@ -42,30 +42,49 @@ Libraries we use
|
||||
|
||||
## Development
|
||||
|
||||
Bruno is being developed as a desktop app. You need to load the app by running the Next.js app in one terminal and then run the electron app in another terminal.
|
||||
Bruno is a desktop app. Below are the instructions to run Bruno.
|
||||
|
||||
> Note: We use React for the frontend and rsbuild for build and dev server.
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
```bash
|
||||
# use nodejs 20 version
|
||||
# use nodejs 22 version
|
||||
nvm use
|
||||
|
||||
# install deps
|
||||
npm i --legacy-peer-deps
|
||||
```
|
||||
|
||||
### Local Development (Option 1)
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
#### Build packages
|
||||
|
||||
##### Option 1
|
||||
|
||||
```bash
|
||||
# build packages
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
# bundle js sandbox libraries
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
```
|
||||
##### Option 2
|
||||
|
||||
```bash
|
||||
# install dependencies and setup
|
||||
npm run setup
|
||||
```
|
||||
|
||||
#### Run the app
|
||||
|
||||
##### Option 1
|
||||
|
||||
```bash
|
||||
# run react app (terminal 1)
|
||||
npm run dev:web
|
||||
|
||||
@@ -73,12 +92,8 @@ npm run dev:web
|
||||
npm run dev:electron
|
||||
```
|
||||
|
||||
### Local Development (Option 2)
|
||||
|
||||
##### Option 2
|
||||
```bash
|
||||
# install dependencies and setup
|
||||
npm run setup
|
||||
|
||||
# run electron and react app concurrently
|
||||
npm run dev
|
||||
```
|
||||
@@ -101,7 +116,28 @@ find . -type f -name "package-lock.json" -delete
|
||||
|
||||
```bash
|
||||
# run bruno-schema tests
|
||||
npm test --workspace=packages/bruno-schema
|
||||
npm run test --workspace=packages/bruno-schema
|
||||
|
||||
# run bruno-query tests
|
||||
npm run test --workspace=packages/bruno-query
|
||||
|
||||
# run bruno-common tests
|
||||
npm run test --workspace=packages/bruno-common
|
||||
|
||||
# run bruno-converters tests
|
||||
npm run test --workspace=packages/bruno-converters
|
||||
|
||||
# run bruno-app tests
|
||||
npm run test --workspace=packages/bruno-app
|
||||
|
||||
# run bruno-electron tests
|
||||
npm run test --workspace=packages/bruno-electron
|
||||
|
||||
# run bruno-lang tests
|
||||
npm run test --workspace=packages/bruno-lang
|
||||
|
||||
# run bruno-toml tests
|
||||
npm run test --workspace=packages/bruno-toml
|
||||
|
||||
# run tests over all workspaces
|
||||
npm test --workspaces --if-present
|
||||
|
||||
@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
# Next.js ऐप चलाएँ (टर्मिनल 1 पर)
|
||||
npm run dev:web
|
||||
|
||||
@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
# run next app (terminal 1)
|
||||
npm run dev:web
|
||||
|
||||
@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
# next 앱 실행 (1번 터미널)
|
||||
npm run dev:web
|
||||
|
||||
@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
# draai next app (terminal 1)
|
||||
npm run dev:web
|
||||
|
||||
@@ -42,6 +42,8 @@ npm i --legacy-peer-deps
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
|
||||
# spustite ďalšiu aplikáciu (terminál 1)
|
||||
npm run dev:web
|
||||
|
||||
5
e2e-tests/test-app-start.spec.ts
Normal file
5
e2e-tests/test-app-start.spec.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { test, expect } from '../playwright';
|
||||
|
||||
test('test-app-start', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
|
||||
});
|
||||
41
eslint.config.js
Normal file
41
eslint.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// eslint.config.js
|
||||
const { defineConfig } = require("eslint/config");
|
||||
const globals = require("globals");
|
||||
|
||||
module.exports = defineConfig([
|
||||
{
|
||||
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
|
||||
ignores: ["**/*.config.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.jest,
|
||||
global: false,
|
||||
require: false,
|
||||
Buffer: false,
|
||||
process: false
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-electron/**/*.{js}"],
|
||||
ignores: ["**/*.config.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
}
|
||||
]);
|
||||
1795
package-lock.json
generated
1795
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -20,14 +20,17 @@
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@playwright/test": "^1.27.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^9.26.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"husky": "^8.0.3",
|
||||
"globals": "^16.1.0",
|
||||
"jest": "^29.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"playwright": "^1.51.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
"randomstring": "^1.2.2",
|
||||
"rimraf": "^6.0.1",
|
||||
@@ -55,11 +58,10 @@
|
||||
"build:electron:rpm": "./scripts/build-electron.sh rpm",
|
||||
"build:electron:snap": "./scripts/build-electron.sh snap",
|
||||
"watch:common": "npm run watch --workspace=packages/bruno-common",
|
||||
"test:codegen": "npm run dev:web & node ./scripts/playwright-codegen.js",
|
||||
"test:e2e": "npx playwright test",
|
||||
"test:report": "npx playwright show-report",
|
||||
"test:codegen": "node playwright/codegen.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
|
||||
"prepare": "husky install"
|
||||
"lint": "node --max_old_space_size=4096 $(npx which eslint)"
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": "3.29.5",
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
})
|
||||
],
|
||||
source: {
|
||||
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file
|
||||
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file,
|
||||
},
|
||||
html: {
|
||||
title: 'Bruno'
|
||||
@@ -34,6 +34,16 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
ignoreWarnings: [
|
||||
(warning) => warning.message.includes('Critical dependency: the request of a dependency is an expression') && warning?.moduleDescriptor?.name?.includes('flow-parser')
|
||||
],
|
||||
// Add externals configuration to exclude Node.js libraries
|
||||
externals: {
|
||||
// List specific Node.js modules you want to exclude
|
||||
// Format: 'module-name': 'commonjs module-name'
|
||||
'worker_threads': 'commonjs worker_threads',
|
||||
// 'path': 'commonjs path'
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ const Info = ({ collection }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showShareCollectionModal && <ShareCollection collection={collection} onClose={handleToggleShowShareCollectionModal(false)} />}
|
||||
{showShareCollectionModal && <ShareCollection collectionUid={collection.uid} onClose={handleToggleShowShareCollectionModal(false)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -13,11 +13,18 @@ import { variableNameRegex } from 'utils/common/regex';
|
||||
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections';
|
||||
|
||||
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const addButtonRef = useRef(null);
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
let _collection = cloneDeep(collection);
|
||||
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
@@ -160,7 +167,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<SingleLineEditor
|
||||
theme={storedTheme}
|
||||
collection={collection}
|
||||
collection={_collection}
|
||||
name={`${index}.value`}
|
||||
value={variable.value}
|
||||
isSecret={variable.secret}
|
||||
|
||||
@@ -28,7 +28,7 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
tab = folderLevelSettingsSelectedTab[folder?.uid];
|
||||
}
|
||||
|
||||
const folderRoot = collection?.items.find((item) => item.uid === folder?.uid)?.root;
|
||||
const folderRoot = folder?.root;
|
||||
const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req;
|
||||
const hasTests = folderRoot?.request?.tests;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { find } from "lodash";
|
||||
import StyledWrapper from "./StyledWrapper";
|
||||
import { useState, useEffect } from "react";
|
||||
import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/icons';
|
||||
import { getAllVariables } from 'utils/collections/index';
|
||||
import { interpolate } from '@usebruno/common';
|
||||
|
||||
@@ -140,7 +140,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
</div>
|
||||
</div>
|
||||
{generateCodeItemModalOpen && (
|
||||
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
|
||||
<GenerateCodeItem collectionUid={collection.uid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
const FolderNotFound = ({ folderUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [showErrorMessage, setShowErrorMessage] = useState(false);
|
||||
|
||||
const closeTab = useCallback(() => {
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: [folderUid]
|
||||
})
|
||||
);
|
||||
}, [dispatch, folderUid]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setShowErrorMessage(true);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
if (!showErrorMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 px-6">
|
||||
<div className="p-4 bg-orange-100 border-l-4 border-yellow-500 text-yellow-700">
|
||||
<div>Folder no longer exists.</div>
|
||||
<div className="mt-2">
|
||||
This can happen when the folder was renamed or deleted on your filesystem.
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-md btn-secondary mt-6" onClick={closeTab}>
|
||||
Close Tab
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderNotFound;
|
||||
@@ -25,7 +25,7 @@ import { produce } from 'immer';
|
||||
import CollectionOverview from 'components/CollectionSettings/Overview';
|
||||
import RequestNotLoaded from './RequestNotLoaded';
|
||||
import RequestIsLoading from './RequestIsLoading';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import FolderNotFound from './FolderNotFound';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 350;
|
||||
@@ -165,11 +165,7 @@ const RequestTabPanel = () => {
|
||||
if (focusedTab.type === 'folder-settings') {
|
||||
const folder = findItemInCollection(collection, focusedTab.folderUid);
|
||||
if (!folder) {
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: [activeTabUid]
|
||||
})
|
||||
);
|
||||
return <FolderNotFound folderUid={focusedTab.folderUid} />;
|
||||
}
|
||||
|
||||
return <FolderSettings collection={collection} folder={folder} />;
|
||||
|
||||
@@ -76,7 +76,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? "italic" : ""}`}
|
||||
onMouseUp={handleMouseUp} // Add middle-click behavior here
|
||||
>
|
||||
{tab.type === 'folder-settings' ? (
|
||||
{tab.type === 'folder-settings' && !folder ? (
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
) : tab.type === 'folder-settings' ? (
|
||||
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
|
||||
) : (
|
||||
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
|
||||
@@ -261,13 +263,13 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
return (
|
||||
<Fragment>
|
||||
{showAddNewRequestModal && (
|
||||
<NewRequest collection={collection} onClose={() => setShowAddNewRequestModal(false)} />
|
||||
<NewRequest collectionUid={collection.uid} onClose={() => setShowAddNewRequestModal(false)} />
|
||||
)}
|
||||
|
||||
{showCloneRequestModal && (
|
||||
<CloneCollectionItem
|
||||
item={currentTabItem}
|
||||
collection={collection}
|
||||
collectionUid={collection.uid}
|
||||
onClose={() => setShowCloneRequestModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -79,7 +79,7 @@ const RequestTabs = () => {
|
||||
return (
|
||||
<StyledWrapper className={getRootClassname()}>
|
||||
{newRequestModalOpen && (
|
||||
<NewRequest collection={activeCollection} onClose={() => setNewRequestModalOpen(false)} />
|
||||
<NewRequest collectionUid={activeCollection?.uid} onClose={() => setNewRequestModalOpen(false)} />
|
||||
)}
|
||||
{collectionRequestTabs && collectionRequestTabs.length ? (
|
||||
<>
|
||||
|
||||
@@ -2,14 +2,20 @@ import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ResponseSize = ({ size }) => {
|
||||
|
||||
if (!Number.isFinite(size)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let sizeToDisplay = '';
|
||||
|
||||
// If size is greater than 1024 bytes, format as KB
|
||||
if (size > 1024) {
|
||||
// size is greater than 1kb
|
||||
let kb = Math.floor(size / 1024);
|
||||
let decimal = Math.round(((size % 1024) / 1024).toFixed(2) * 100);
|
||||
sizeToDisplay = kb + '.' + decimal + 'KB';
|
||||
} else {
|
||||
// If size is less than or equal to 1024 bytes, display as bytes (B)
|
||||
sizeToDisplay = size + 'B';
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
};
|
||||
|
||||
const response = item.response || {};
|
||||
const responseSize = response.size || 0;
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
@@ -156,7 +157,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
<ResponseSave item={item} />
|
||||
<StatusCode status={response.status} />
|
||||
<ResponseTime duration={response.duration} />
|
||||
<ResponseSize size={response.size} />
|
||||
<ResponseSize size={responseSize} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,11 @@ import exportBrunoCollection from 'utils/collections/export';
|
||||
import exportPostmanCollection from 'utils/exporters/postman-collection';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { findCollectionByUid } from 'utils/collections/index';
|
||||
|
||||
const ShareCollection = ({ onClose, collection }) => {
|
||||
const ShareCollection = ({ onClose, collectionUid }) => {
|
||||
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
const handleExportBrunoCollection = () => {
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -11,11 +11,13 @@ import Help from 'components/Help';
|
||||
import PathDisplay from 'components/PathDisplay';
|
||||
import { useState } from 'react';
|
||||
import { IconArrowBackUp, IconEdit } from "@tabler/icons";
|
||||
import { findCollectionByUid } from 'utils/collections/index';
|
||||
|
||||
const CloneCollection = ({ onClose, collection }) => {
|
||||
const CloneCollection = ({ onClose, collectionUid }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const [isEditing, toggleEditing] = useState(false);
|
||||
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
const { name } = collection;
|
||||
|
||||
const formik = useFormik({
|
||||
@@ -46,7 +48,7 @@ const CloneCollection = ({ onClose, collection }) => {
|
||||
values.collectionName,
|
||||
values.collectionFolderName,
|
||||
values.collectionLocation,
|
||||
collection.pathname
|
||||
collection?.pathname
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import Portal from 'components/Portal';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CloneCollectionItem = ({ collection, item, onClose }) => {
|
||||
const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const isFolder = isItemAFolder(item);
|
||||
const inputRef = useRef();
|
||||
@@ -49,7 +49,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
|
||||
.test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
dispatch(cloneItem(values.name, values.filename, item.uid, collection.uid))
|
||||
dispatch(cloneItem(values.name, values.filename, item.uid, collectionUid))
|
||||
.then(() => {
|
||||
toast.success('Request cloned!');
|
||||
onClose();
|
||||
@@ -172,8 +172,6 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
|
||||
) : (
|
||||
<div className='relative flex flex-row gap-1 items-center justify-between'>
|
||||
<PathDisplay
|
||||
collection={collection}
|
||||
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
|
||||
baseName={formik.values.filename}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.drag-preview {
|
||||
background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useDragLayer } from 'react-dnd';
|
||||
import {
|
||||
IconFile,
|
||||
IconFolder,
|
||||
} from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
function getItemStyles({ x, y }) {
|
||||
if (Number.isNaN(x) || Number.isNaN(y)) return { display: 'none' };
|
||||
const transform = `translate(${x}px, ${y}px)`;
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
pointerEvents: 'none',
|
||||
top: 0,
|
||||
transform,
|
||||
WebkitTransform: transform,
|
||||
zIndex: 100,
|
||||
};
|
||||
}
|
||||
|
||||
export const CollectionItemDragPreview = () => {
|
||||
const {
|
||||
item,
|
||||
isDragging,
|
||||
clientOffset
|
||||
} = useDragLayer((monitor) => ({
|
||||
item: monitor.getItem(),
|
||||
isDragging: monitor.isDragging(),
|
||||
clientOffset: monitor.getClientOffset(),
|
||||
}));
|
||||
if (!isDragging) return null;
|
||||
const { x, y } = clientOffset || {};
|
||||
const shouldShowFolderIcon = !item.type || item.type === 'folder';
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div style={getItemStyles({ x, y })} className='p-2'>
|
||||
<div className='flex items-center gap-2 border border-gray-500/10 rounded-md px-2 py-1 drag-preview'>
|
||||
{shouldShowFolderIcon ? (
|
||||
<IconFolder size={16} />
|
||||
) : (
|
||||
<IconFile size={16} />
|
||||
)}
|
||||
{item.name}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
@@ -7,11 +7,11 @@ import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { recursivelyGetAllItemUids } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const DeleteCollectionItem = ({ onClose, item, collection }) => {
|
||||
const DeleteCollectionItem = ({ onClose, item, collectionUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
const isFolder = isItemAFolder(item);
|
||||
const onConfirm = () => {
|
||||
dispatch(deleteItem(item.uid, collection.uid)).then(() => {
|
||||
dispatch(deleteItem(item.uid, collectionUid)).then(() => {
|
||||
|
||||
if (isFolder) {
|
||||
// close all tabs that belong to the folder
|
||||
|
||||
@@ -62,6 +62,7 @@ const CodeView = ({ language, item }) => {
|
||||
<CodeEditor
|
||||
readOnly
|
||||
collection={collection}
|
||||
item={item}
|
||||
value={snippet}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
|
||||
@@ -10,9 +10,11 @@ import { getLanguages } from 'utils/codegenerator/targets';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
|
||||
|
||||
const GenerateCodeItem = ({ collection, item, onClose }) => {
|
||||
const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
|
||||
const languages = getLanguages();
|
||||
|
||||
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
|
||||
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import Portal from 'components/Portal';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const RenameCollectionItem = ({ collection, item, onClose }) => {
|
||||
const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const isFolder = isItemAFolder(item);
|
||||
const inputRef = useRef();
|
||||
@@ -57,13 +57,13 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
|
||||
return;
|
||||
}
|
||||
if (!isFolder && item.draft) {
|
||||
await dispatch(saveRequest(item.uid, collection.uid, true));
|
||||
await dispatch(saveRequest(item.uid, collectionUid, true));
|
||||
}
|
||||
const { name: newName, filename: newFilename } = values;
|
||||
try {
|
||||
let renameConfig = {
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
collectionUid,
|
||||
};
|
||||
renameConfig['newName'] = newName;
|
||||
if (itemFilename !== newFilename) {
|
||||
@@ -191,8 +191,6 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
|
||||
) : (
|
||||
<div className='relative flex flex-row gap-1 items-center justify-between'>
|
||||
<PathDisplay
|
||||
collection={collection}
|
||||
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
|
||||
baseName={formik.values.filename}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,16 +2,19 @@ import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { uuid } from 'utils/common';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
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 RunCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
|
||||
const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended');
|
||||
|
||||
const onSubmit = (recursive) => {
|
||||
dispatch(
|
||||
addTab({
|
||||
@@ -20,10 +23,24 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
|
||||
type: 'collection-runner'
|
||||
})
|
||||
);
|
||||
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
|
||||
if (!isCollectionRunInProgress) {
|
||||
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleViewRunner = (e) => {
|
||||
e.preventDefault();
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: uuid(),
|
||||
collectionUid: collection.uid,
|
||||
type: 'collection-runner'
|
||||
})
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const getRequestsCount = (items) => {
|
||||
const requestTypes = ['http-request', 'graphql-request']
|
||||
return items.filter(req => requestTypes.includes(req.type)).length;
|
||||
@@ -34,8 +51,6 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
|
||||
const recursiveRunLength = getRequestsCount(flattenedItems);
|
||||
|
||||
const isFolderLoading = areItemsLoading(item);
|
||||
console.log(item);
|
||||
console.log(isFolderLoading);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -55,22 +70,34 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
|
||||
</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}
|
||||
{isCollectionRunInProgress ? <div className='mb-6 warning'>A Collection Run is already in progress.</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">
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
|
||||
Recursive Run
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
|
||||
Run
|
||||
</button>
|
||||
</span>
|
||||
{
|
||||
isCollectionRunInProgress ?
|
||||
<span>
|
||||
<button type="submit" className="submit btn btn-md btn-secondary mr-3" onClick={handleViewRunner}>
|
||||
View Run
|
||||
</button>
|
||||
</span>
|
||||
:
|
||||
<>
|
||||
<span>
|
||||
<button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
|
||||
Recursive Run
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
|
||||
Run
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
.menu-icon {
|
||||
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
|
||||
|
||||
@@ -22,6 +23,65 @@ const Wrapper = styled.div`
|
||||
height: 1.875rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
|
||||
/* Common styles for drop indicators */
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: ${(props) => props.theme.dragAndDrop.border};
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Drop target styles */
|
||||
&.drop-target {
|
||||
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.drop-target-above {
|
||||
&::before {
|
||||
opacity: 1;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.drop-target-below {
|
||||
&::after {
|
||||
opacity: 1;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inside drop target style */
|
||||
&.drop-target {
|
||||
&::before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
opacity: 1;
|
||||
background: ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
// border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.rotate-90 {
|
||||
transform: rotateZ(90deg);
|
||||
@@ -45,6 +105,20 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
&.item-target {
|
||||
background: #ccc3;
|
||||
}
|
||||
|
||||
&.item-seperator {
|
||||
.seperator {
|
||||
bottom: 0px;
|
||||
position: absolute;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background: #ccc3;
|
||||
}
|
||||
}
|
||||
|
||||
&.item-focused-in-tab {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.bg};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, forwardRef, useEffect } from 'react';
|
||||
import { getEmptyImage } from 'react-dnd-html5-backend';
|
||||
import range from 'lodash/range';
|
||||
import filter from 'lodash/filter';
|
||||
import classnames from 'classnames';
|
||||
@@ -6,7 +7,7 @@ import { useDrag, useDrop } from 'react-dnd';
|
||||
import { IconChevronRight, IconDots } from '@tabler/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { moveItem, showInFolder, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { handleCollectionItemDrop, sendRequest, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
@@ -16,7 +17,7 @@ import CloneCollectionItem from './CloneCollectionItem';
|
||||
import DeleteCollectionItem from './DeleteCollectionItem';
|
||||
import RunCollectionItem from './RunCollectionItem';
|
||||
import GenerateCodeItem from './GenerateCodeItem';
|
||||
import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
|
||||
import { isItemARequest, isItemAFolder } from 'utils/tabs';
|
||||
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
@@ -26,13 +27,22 @@ import NetworkError from 'components/ResponsePane/NetworkError/index';
|
||||
import CollectionItemInfo from './CollectionItemInfo/index';
|
||||
import CollectionItemIcon from './CollectionItemIcon';
|
||||
import { scrollToTheActiveTab } from 'utils/tabs';
|
||||
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
|
||||
import { isEqual } from 'lodash';
|
||||
import { calculateDraggedItemNewPathname } from 'utils/collections/index';
|
||||
|
||||
const CollectionItem = ({ item, collection, searchText }) => {
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
|
||||
const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
|
||||
const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual);
|
||||
|
||||
const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid });
|
||||
const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual);
|
||||
|
||||
const isSidebarDragging = useSelector((state) => state.app.isDragging);
|
||||
const dispatch = useDispatch();
|
||||
const collectionItemRef = useRef(null);
|
||||
|
||||
// We use a single ref for drag and drop.
|
||||
const ref = useRef(null);
|
||||
|
||||
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
|
||||
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
|
||||
@@ -44,10 +54,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
|
||||
const hasSearchText = searchText && searchText?.trim()?.length;
|
||||
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
|
||||
const isFolder = isItemAFolder(item);
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: `collection-item-${collection.uid}`,
|
||||
item: item,
|
||||
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
|
||||
|
||||
const [{ isDragging }, drag, dragPreview] = useDrag({
|
||||
type: `collection-item-${collectionUid}`,
|
||||
item,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging()
|
||||
}),
|
||||
@@ -56,21 +69,72 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
}
|
||||
});
|
||||
|
||||
const [{ isOver }, drop] = useDrop({
|
||||
accept: `collection-item-${collection.uid}`,
|
||||
drop: (draggedItem) => {
|
||||
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
|
||||
useEffect(() => {
|
||||
dragPreview(getEmptyImage(), { captureDraggingState: true });
|
||||
}, []);
|
||||
|
||||
const determineDropType = (monitor) => {
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!hoverBoundingRect || !clientOffset) return null;
|
||||
|
||||
const clientY = clientOffset.y - hoverBoundingRect.top;
|
||||
const folderUpperThreshold = hoverBoundingRect.height * 0.35;
|
||||
const fileUpperThreshold = hoverBoundingRect.height * 0.5;
|
||||
|
||||
if (isItemAFolder(item)) {
|
||||
return clientY < folderUpperThreshold ? 'adjacent' : 'inside';
|
||||
} else {
|
||||
return clientY < fileUpperThreshold ? 'adjacent' : null;
|
||||
}
|
||||
};
|
||||
|
||||
const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => {
|
||||
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
|
||||
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
|
||||
|
||||
if (draggedItemUid === targetItemUid) return false;
|
||||
|
||||
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
|
||||
if (!newPathname) return false;
|
||||
|
||||
if (targetItemPathname?.startsWith(draggedItemPathname)) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: `collection-item-${collectionUid}`,
|
||||
hover: (draggedItem, monitor) => {
|
||||
const { uid: targetItemUid } = item;
|
||||
const { uid: draggedItemUid } = draggedItem;
|
||||
|
||||
if (draggedItemUid === targetItemUid) return;
|
||||
|
||||
const dropType = determineDropType(monitor);
|
||||
|
||||
const _canItemBeDropped = canItemBeDropped({ draggedItem, targetItem: item, dropType });
|
||||
|
||||
setDropType(_canItemBeDropped ? dropType : null);
|
||||
},
|
||||
canDrop: (draggedItem) => {
|
||||
return draggedItem.uid !== item.uid;
|
||||
drop: async (draggedItem, monitor) => {
|
||||
const { uid: targetItemUid } = item;
|
||||
const { uid: draggedItemUid } = draggedItem;
|
||||
|
||||
if (draggedItemUid === targetItemUid) return;
|
||||
|
||||
const dropType = determineDropType(monitor);
|
||||
if (!dropType) return;
|
||||
|
||||
await dispatch(handleCollectionItemDrop({ targetItem: item, draggedItem, dropType, collectionUid }))
|
||||
setDropType(null);
|
||||
},
|
||||
canDrop: (draggedItem) => draggedItem.uid !== item.uid,
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
isOver: monitor.isOver()
|
||||
}),
|
||||
});
|
||||
|
||||
drag(drop(collectionItemRef));
|
||||
|
||||
const dropdownTippyRef = useRef();
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
@@ -84,13 +148,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
'rotate-90': !itemIsCollapsed
|
||||
});
|
||||
|
||||
const itemRowClassName = classnames('flex collection-item-name items-center', {
|
||||
'item-focused-in-tab': item.uid == activeTabUid,
|
||||
'item-hovered': isOver
|
||||
const itemRowClassName = classnames('flex collection-item-name relative items-center', {
|
||||
'item-focused-in-tab': isTabForItemActive,
|
||||
'item-hovered': isOver && canDrop,
|
||||
'drop-target': isOver && dropType === 'inside',
|
||||
'drop-target-above': isOver && dropType === 'adjacent'
|
||||
});
|
||||
|
||||
const handleRun = async () => {
|
||||
dispatch(sendRequest(item, collection.uid)).catch((err) =>
|
||||
dispatch(sendRequest(item, collectionUid)).catch((err) =>
|
||||
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
|
||||
duration: 5000
|
||||
})
|
||||
@@ -101,12 +167,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
if (event && event.detail != 1) return;
|
||||
//scroll to the active tab
|
||||
setTimeout(scrollToTheActiveTab, 50);
|
||||
|
||||
const isRequest = isItemARequest(item);
|
||||
|
||||
if (isRequest) {
|
||||
dispatch(hideHomePage());
|
||||
if (itemIsOpenedInTabs(item, tabs)) {
|
||||
if (isTabForItemPresent) {
|
||||
dispatch(
|
||||
focusTab({
|
||||
uid: item.uid
|
||||
@@ -114,11 +178,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
collectionUid: collectionUid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(item),
|
||||
type: 'request',
|
||||
})
|
||||
@@ -127,14 +190,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
collectionUid: collectionUid,
|
||||
type: 'folder-settings',
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
collectionFolderClicked({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
collectionUid: collectionUid
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -146,10 +209,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
dispatch(
|
||||
collectionFolderClicked({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
collectionUid: collectionUid
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightClick = (event) => {
|
||||
const _menuDropdown = dropdownTippyRef.current;
|
||||
@@ -164,7 +227,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
|
||||
let indents = range(item.depth);
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const isFolder = isItemAFolder(item);
|
||||
|
||||
const className = classnames('flex flex-col w-full', {
|
||||
'is-sidebar-dragging': isSidebarDragging
|
||||
@@ -183,49 +245,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
}
|
||||
|
||||
const handleDoubleClick = (event) => {
|
||||
dispatch(makeTabPermanent({ uid: item.uid }))
|
||||
dispatch(makeTabPermanent({ uid: item.uid }));
|
||||
};
|
||||
|
||||
// we need to sort request items by seq property
|
||||
const sortRequestItems = (items = []) => {
|
||||
// Sort items by their "seq" property.
|
||||
const sortItemsBySequence = (items = []) => {
|
||||
return items.sort((a, b) => a.seq - b.seq);
|
||||
};
|
||||
|
||||
// we need to sort folder items by name alphabetically
|
||||
const sortFolderItems = (items = []) => {
|
||||
return items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
const handleGenerateCode = (e) => {
|
||||
e.stopPropagation();
|
||||
dropdownTippyRef.current.hide();
|
||||
if (item?.request?.url !== '' || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')) {
|
||||
setGenerateCodeItemModalOpen(true);
|
||||
} else {
|
||||
toast.error('URL is required');
|
||||
}
|
||||
};
|
||||
|
||||
const viewFolderSettings = () => {
|
||||
if (isItemAFolder(item)) {
|
||||
if (itemIsOpenedInTabs(item, tabs)) {
|
||||
dispatch(
|
||||
focusTab({
|
||||
uid: item.uid
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'folder-settings'
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowInFolder = () => {
|
||||
dispatch(showInFolder(item.pathname)).catch((error) => {
|
||||
console.error('Error opening the folder', error);
|
||||
@@ -233,62 +260,89 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
|
||||
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
|
||||
const folderItems = sortItemsBySequence(filter(item.items, (i) => isItemAFolder(i)));
|
||||
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
|
||||
|
||||
const handleGenerateCode = (e) => {
|
||||
e.stopPropagation();
|
||||
dropdownTippyRef.current.hide();
|
||||
if (
|
||||
(item?.request?.url !== '') ||
|
||||
(item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
|
||||
) {
|
||||
setGenerateCodeItemModalOpen(true);
|
||||
} else {
|
||||
toast.error('URL is required');
|
||||
}
|
||||
};
|
||||
|
||||
const viewFolderSettings = () => {
|
||||
if (isItemAFolder(item)) {
|
||||
if (isTabForItemPresent) {
|
||||
dispatch(focusTab({ uid: item.uid }));
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid,
|
||||
type: 'folder-settings'
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={className}>
|
||||
{renameItemModalOpen && (
|
||||
<RenameCollectionItem item={item} collection={collection} onClose={() => setRenameItemModalOpen(false)} />
|
||||
<RenameCollectionItem item={item} collectionUid={collectionUid} onClose={() => setRenameItemModalOpen(false)} />
|
||||
)}
|
||||
{cloneItemModalOpen && (
|
||||
<CloneCollectionItem item={item} collection={collection} onClose={() => setCloneItemModalOpen(false)} />
|
||||
<CloneCollectionItem item={item} collectionUid={collectionUid} onClose={() => setCloneItemModalOpen(false)} />
|
||||
)}
|
||||
{deleteItemModalOpen && (
|
||||
<DeleteCollectionItem item={item} collection={collection} onClose={() => setDeleteItemModalOpen(false)} />
|
||||
<DeleteCollectionItem item={item} collectionUid={collectionUid} onClose={() => setDeleteItemModalOpen(false)} />
|
||||
)}
|
||||
{newRequestModalOpen && (
|
||||
<NewRequest item={item} collection={collection} onClose={() => setNewRequestModalOpen(false)} />
|
||||
<NewRequest item={item} collectionUid={collectionUid} onClose={() => setNewRequestModalOpen(false)} />
|
||||
)}
|
||||
{newFolderModalOpen && (
|
||||
<NewFolder item={item} collection={collection} onClose={() => setNewFolderModalOpen(false)} />
|
||||
<NewFolder item={item} collectionUid={collectionUid} onClose={() => setNewFolderModalOpen(false)} />
|
||||
)}
|
||||
{runCollectionModalOpen && (
|
||||
<RunCollectionItem collection={collection} item={item} onClose={() => setRunCollectionModalOpen(false)} />
|
||||
<RunCollectionItem collectionUid={collectionUid} item={item} onClose={() => setRunCollectionModalOpen(false)} />
|
||||
)}
|
||||
{generateCodeItemModalOpen && (
|
||||
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
|
||||
<GenerateCodeItem collectionUid={collectionUid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
|
||||
)}
|
||||
{itemInfoModalOpen && (
|
||||
<CollectionItemInfo item={item} collection={collection} onClose={() => setItemInfoModalOpen(false)} />
|
||||
<CollectionItemInfo item={item} onClose={() => setItemInfoModalOpen(false)} />
|
||||
)}
|
||||
<div className={itemRowClassName} ref={collectionItemRef}>
|
||||
<div
|
||||
className={itemRowClassName}
|
||||
ref={(node) => {
|
||||
ref.current = node;
|
||||
drag(drop(node));
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center h-full w-full">
|
||||
{indents && indents.length
|
||||
? indents.map((i) => {
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className="indent-block"
|
||||
key={i}
|
||||
style={{
|
||||
width: 16,
|
||||
minWidth: 16,
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
{/* Indent */}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
? indents.map((i) => (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className="indent-block"
|
||||
key={i}
|
||||
style={{ width: 16, minWidth: 16, height: '100%' }}
|
||||
>
|
||||
{/* Indent */}
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<div
|
||||
className="flex flex-grow items-center h-full overflow-hidden"
|
||||
style={{
|
||||
paddingLeft: 8
|
||||
}}
|
||||
style={{ paddingLeft: 8 }}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
@@ -304,10 +358,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="ml-1 flex w-full h-full items-center overflow-hidden"
|
||||
>
|
||||
<div className="ml-1 flex w-full h-full items-center overflow-hidden">
|
||||
<CollectionItemIcon item={item} />
|
||||
<span className="item-name" title={item.name}>
|
||||
{item.name}
|
||||
@@ -429,17 +480,16 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!itemIsCollapsed ? (
|
||||
<div>
|
||||
{folderItems && folderItems.length
|
||||
? folderItems.map((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
|
||||
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
|
||||
})
|
||||
: null}
|
||||
{requestItems && requestItems.length
|
||||
? requestItems.map((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
|
||||
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
@@ -448,4 +498,4 @@ const CollectionItem = ({ item, collection, searchText }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionItem;
|
||||
export default React.memo(CollectionItem);
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconFiles } from '@tabler/icons';
|
||||
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findCollectionByUid } from 'utils/collections/index';
|
||||
|
||||
const RemoveCollection = ({ onClose, collection }) => {
|
||||
const RemoveCollection = ({ onClose, collectionUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
|
||||
const onConfirm = () => {
|
||||
dispatch(removeCollection(collection.uid))
|
||||
|
||||
@@ -2,13 +2,15 @@ import React, { useRef, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import { renameCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findCollectionByUid } from 'utils/collections/index';
|
||||
|
||||
const RenameCollection = ({ collection, onClose }) => {
|
||||
const RenameCollection = ({ collectionUid, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
|
||||
@@ -13,7 +13,8 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
&.item-hovered {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
border-bottom: 2px solid transparent;
|
||||
.collection-actions {
|
||||
.dropdown {
|
||||
div[aria-expanded='false'] {
|
||||
@@ -62,6 +63,36 @@ const Wrapper = styled.div`
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.drop-target {
|
||||
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
transition: ${(props) => props.theme.dragAndDrop.transition};
|
||||
}
|
||||
|
||||
&.drop-target-above {
|
||||
border: none;
|
||||
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
margin-top: -2px;
|
||||
background: transparent;
|
||||
transition: ${(props) => props.theme.dragAndDrop.transition};
|
||||
}
|
||||
|
||||
&.drop-target-below {
|
||||
border: none;
|
||||
border-bottom: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
margin-bottom: -2px;
|
||||
background: transparent;
|
||||
transition: ${(props) => props.theme.dragAndDrop.transition};
|
||||
}
|
||||
}
|
||||
|
||||
.collection-name.drop-target {
|
||||
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
border-radius: 4px;
|
||||
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
margin: -2px;
|
||||
transition: ${(props) => props.theme.dragAndDrop.transition};
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
}
|
||||
|
||||
#sidebar-collection-name {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, forwardRef, useRef, useEffect } from 'react';
|
||||
import { getEmptyImage } from 'react-dnd-html5-backend';
|
||||
import classnames from 'classnames';
|
||||
import { uuid } from 'utils/common';
|
||||
import filter from 'lodash/filter';
|
||||
@@ -6,8 +7,8 @@ import { useDrop, useDrag } from 'react-dnd';
|
||||
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { collapseCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { mountCollection, moveItemToRootOfCollection, moveCollectionAndPersist } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import NewFolder from 'components/Sidebar/NewFolder';
|
||||
@@ -19,9 +20,10 @@ import { isItemAFolder, isItemARequest } from 'utils/collections';
|
||||
import RenameCollection from './RenameCollection';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import CloneCollection from './CloneCollection';
|
||||
import { areItemsLoading, findItemInCollection } from 'utils/collections';
|
||||
import { areItemsLoading } from 'utils/collections';
|
||||
import { scrollToTheActiveTab } from 'utils/tabs';
|
||||
import ShareCollection from 'components/ShareCollection/index';
|
||||
import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
|
||||
|
||||
const Collection = ({ collection, searchText }) => {
|
||||
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
|
||||
@@ -33,7 +35,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
const dispatch = useDispatch();
|
||||
const isLoading = areItemsLoading(collection);
|
||||
const collectionRef = useRef(null);
|
||||
|
||||
|
||||
const menuDropdownTippyRef = useRef();
|
||||
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
@@ -127,8 +129,8 @@ const Collection = ({ collection, searchText }) => {
|
||||
const isCollectionItem = (itemType) => {
|
||||
return itemType.startsWith('collection-item');
|
||||
};
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
|
||||
const [{ isDragging }, drag, dragPreview] = useDrag({
|
||||
type: "collection",
|
||||
item: collection,
|
||||
collect: (monitor) => ({
|
||||
@@ -144,7 +146,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
drop: (draggedItem, monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
if (isCollectionItem(itemType)) {
|
||||
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid))
|
||||
dispatch(handleCollectionItemDrop({ targetItem: collection, draggedItem, dropType: 'inside', collectionUid: collection.uid }))
|
||||
} else {
|
||||
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
|
||||
}
|
||||
@@ -157,7 +159,9 @@ const Collection = ({ collection, searchText }) => {
|
||||
}),
|
||||
});
|
||||
|
||||
drag(drop(collectionRef));
|
||||
useEffect(() => {
|
||||
dragPreview(getEmptyImage(), { captureDraggingState: true });
|
||||
}, []);
|
||||
|
||||
if (searchText && searchText.length) {
|
||||
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
|
||||
@@ -170,36 +174,35 @@ const Collection = ({ collection, searchText }) => {
|
||||
});
|
||||
|
||||
// we need to sort request items by seq property
|
||||
const sortRequestItems = (items = []) => {
|
||||
const sortItemsBySequence = (items = []) => {
|
||||
return items.sort((a, b) => a.seq - b.seq);
|
||||
};
|
||||
|
||||
// we need to sort folder items by name alphabetically
|
||||
const sortFolderItems = (items = []) => {
|
||||
return items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
const requestItems = sortRequestItems(filter(collection.items, (i) => isItemARequest(i)));
|
||||
const folderItems = sortFolderItems(filter(collection.items, (i) => isItemAFolder(i)));
|
||||
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
|
||||
const folderItems = sortItemsBySequence(filter(collection.items, (i) => isItemAFolder(i)));
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col">
|
||||
{showNewRequestModal && <NewRequest collection={collection} onClose={() => setShowNewRequestModal(false)} />}
|
||||
{showNewFolderModal && <NewFolder collection={collection} onClose={() => setShowNewFolderModal(false)} />}
|
||||
{showNewRequestModal && <NewRequest collectionUid={collection.uid} onClose={() => setShowNewRequestModal(false)} />}
|
||||
{showNewFolderModal && <NewFolder collectionUid={collection.uid} onClose={() => setShowNewFolderModal(false)} />}
|
||||
{showRenameCollectionModal && (
|
||||
<RenameCollection collection={collection} onClose={() => setShowRenameCollectionModal(false)} />
|
||||
<RenameCollection collectionUid={collection.uid} onClose={() => setShowRenameCollectionModal(false)} />
|
||||
)}
|
||||
{showRemoveCollectionModal && (
|
||||
<RemoveCollection collection={collection} onClose={() => setShowRemoveCollectionModal(false)} />
|
||||
<RemoveCollection collectionUid={collection.uid} onClose={() => setShowRemoveCollectionModal(false)} />
|
||||
)}
|
||||
{showShareCollectionModal && (
|
||||
<ShareCollection collection={collection} onClose={() => setShowShareCollectionModal(false)} />
|
||||
<ShareCollection collectionUid={collection.uid} onClose={() => setShowShareCollectionModal(false)} />
|
||||
)}
|
||||
{showCloneCollectionModalOpen && (
|
||||
<CloneCollection collection={collection} onClose={() => setShowCloneCollectionModalOpen(false)} />
|
||||
<CloneCollection collectionUid={collection.uid} onClose={() => setShowCloneCollectionModalOpen(false)} />
|
||||
)}
|
||||
<CollectionItemDragPreview />
|
||||
<div className={collectionRowClassName}
|
||||
ref={collectionRef}
|
||||
ref={(node) => {
|
||||
collectionRef.current = node;
|
||||
drag(drop(node));
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex flex-grow items-center overflow-hidden"
|
||||
@@ -296,20 +299,15 @@ const Collection = ({ collection, searchText }) => {
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{!collectionIsCollapsed ? (
|
||||
<div>
|
||||
{folderItems && folderItems.length
|
||||
? folderItems.map((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
|
||||
})
|
||||
: null}
|
||||
{requestItems && requestItems.length
|
||||
? requestItems.map((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
|
||||
})
|
||||
: null}
|
||||
{folderItems?.map?.((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
|
||||
})}
|
||||
{requestItems?.map?.((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ import PathDisplay from 'components/PathDisplay/index';
|
||||
import { useState } from 'react';
|
||||
import { IconArrowBackUp, IconEdit } from '@tabler/icons';
|
||||
import Help from 'components/Help';
|
||||
import { multiLineMsg } from "utils/common";
|
||||
import { formatIpcError } from "utils/common/error";
|
||||
|
||||
const CreateCollection = ({ onClose }) => {
|
||||
const inputRef = useRef();
|
||||
@@ -45,7 +47,7 @@ const CreateCollection = ({ onClose }) => {
|
||||
toast.success('Collection created!');
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => toast.error('An error occurred while creating the collection - ' + e));
|
||||
.catch((e) => toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -113,7 +115,6 @@ const CreateCollection = ({ onClose }) => {
|
||||
id="collection-location"
|
||||
type="text"
|
||||
name="collectionLocation"
|
||||
readOnly={true}
|
||||
className="block textbox mt-2 w-full cursor-pointer"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
@@ -121,6 +122,9 @@ const CreateCollection = ({ onClose }) => {
|
||||
spellCheck="false"
|
||||
value={formik.values.collectionLocation || ''}
|
||||
onClick={browse}
|
||||
onChange={e => {
|
||||
formik.setFieldValue('collectionLocation', e.target.value);
|
||||
}}
|
||||
/>
|
||||
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
|
||||
<div className="text-red-500">{formik.errors.collectionLocation}</div>
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
import importBrunoCollection from 'utils/importers/bruno-collection';
|
||||
import importPostmanCollection from 'utils/importers/postman-collection';
|
||||
import { postmanToBruno, readFile } from 'utils/importers/postman-collection';
|
||||
import importInsomniaCollection from 'utils/importers/insomnia-collection';
|
||||
import importOpenapiCollection from 'utils/importers/openapi-collection';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import Modal from 'components/Modal';
|
||||
import fileDialog from 'file-dialog';
|
||||
|
||||
const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleImportBrunoCollection = () => {
|
||||
importBrunoCollection()
|
||||
.then(({ collection }) => {
|
||||
handleSubmit({ collection });
|
||||
})
|
||||
.catch((err) => toastError(err, 'Import collection failed'));
|
||||
.catch((err) => toastError(err, 'Import collection failed'))
|
||||
};
|
||||
|
||||
|
||||
const handleImportPostmanCollection = () => {
|
||||
importPostmanCollection()
|
||||
.then(({ collection }) => {
|
||||
handleSubmit({ collection });
|
||||
fileDialog({ accept: 'application/json' })
|
||||
.then((...args) => {
|
||||
setIsLoading(true);
|
||||
return readFile(...args);
|
||||
})
|
||||
.catch((err) => toastError(err, 'Postman Import collection failed'));
|
||||
};
|
||||
.then((collection) => postmanToBruno(collection))
|
||||
.then((collection) => handleSubmit({ collection }))
|
||||
.catch((err) => toastError(err, 'Postman Import collection failed'))
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
|
||||
const handleImportInsomniaCollection = () => {
|
||||
importInsomniaCollection()
|
||||
.then(({ collection }) => {
|
||||
handleSubmit({ collection });
|
||||
})
|
||||
.catch((err) => toastError(err, 'Insomnia Import collection failed'));
|
||||
.catch((err) => toastError(err, 'Insomnia Import collection failed'))
|
||||
};
|
||||
|
||||
const handleImportOpenapiCollection = () => {
|
||||
@@ -36,8 +45,9 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
.then(({ collection }) => {
|
||||
handleSubmit({ collection });
|
||||
})
|
||||
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'));
|
||||
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'))
|
||||
};
|
||||
|
||||
const CollectionButton = ({ children, className, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
@@ -50,18 +60,67 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
</button>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<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">
|
||||
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
|
||||
|
||||
const FullscreenLoader = () => {
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
|
||||
// Messages to cycle through while loading
|
||||
const loadingMessages = [
|
||||
'Processing collection...',
|
||||
'Analyzing requests...',
|
||||
'Translating scripts...',
|
||||
'Preparing collection...',
|
||||
'Almost done...'
|
||||
];
|
||||
|
||||
|
||||
// Cycle through loading messages for better UX
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
|
||||
let messageIndex = 0;
|
||||
const interval = setInterval(() => {
|
||||
messageIndex = (messageIndex + 1) % loadingMessages.length;
|
||||
setLoadingMessage(loadingMessages[messageIndex]);
|
||||
}, 2000);
|
||||
|
||||
setLoadingMessage(loadingMessages[0]);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoading]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
|
||||
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
|
||||
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
|
||||
{loadingMessage}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
This may take a moment depending on the collection size
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <FullscreenLoader />}
|
||||
{!isLoading && (
|
||||
<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">
|
||||
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
|
||||
id="collection-location"
|
||||
type="text"
|
||||
name="collectionLocation"
|
||||
readOnly={true}
|
||||
className="block textbox mt-2 w-full cursor-pointer"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
@@ -69,6 +68,9 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
|
||||
spellCheck="false"
|
||||
value={formik.values.collectionLocation || ''}
|
||||
onClick={browse}
|
||||
onChange={e => {
|
||||
formik.setFieldValue('collectionLocation', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
|
||||
|
||||
@@ -14,7 +14,7 @@ import Dropdown from "components/Dropdown";
|
||||
import { IconCaretDown } from "@tabler/icons";
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const NewFolder = ({ collection, item, onClose }) => {
|
||||
const NewFolder = ({ collectionUid, item, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
const [isEditing, toggleEditing] = useState(false);
|
||||
@@ -52,7 +52,7 @@ const NewFolder = ({ collection, item, onClose }) => {
|
||||
})
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
dispatch(newFolder(values.folderName, values.directoryName, collection.uid, item ? item.uid : null))
|
||||
dispatch(newFolder(values.folderName, values.directoryName, collectionUid, item ? item.uid : null))
|
||||
.then(() => {
|
||||
toast.success('New folder created!');
|
||||
onClose();
|
||||
|
||||
@@ -5,7 +5,7 @@ import toast from 'react-hot-toast';
|
||||
import path from 'utils/common/path';
|
||||
import { uuid } from 'utils/common';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections';
|
||||
import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -20,9 +20,11 @@ import Portal from 'components/Portal';
|
||||
import Help from 'components/Help';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
|
||||
const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
|
||||
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
|
||||
const {
|
||||
brunoConfig: { presets: collectionPresets = {} }
|
||||
} = collection;
|
||||
@@ -135,14 +137,14 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
|
||||
requestType: values.requestType,
|
||||
requestUrl: values.requestUrl,
|
||||
requestMethod: values.requestMethod,
|
||||
collectionUid: collection.uid
|
||||
collectionUid: collectionUid
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: uid,
|
||||
collectionUid: collection.uid,
|
||||
collectionUid: collectionUid,
|
||||
requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType })
|
||||
})
|
||||
);
|
||||
@@ -158,7 +160,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
|
||||
requestType: curlRequestTypeDetected,
|
||||
requestUrl: request.url,
|
||||
requestMethod: request.method,
|
||||
collectionUid: collection.uid,
|
||||
collectionUid: collectionUid,
|
||||
itemUid: item ? item.uid : null,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
@@ -178,7 +180,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
|
||||
requestType: values.requestType,
|
||||
requestUrl: values.requestUrl,
|
||||
requestMethod: values.requestMethod,
|
||||
collectionUid: collection.uid,
|
||||
collectionUid: collectionUid,
|
||||
itemUid: item ? item.uid : null
|
||||
})
|
||||
)
|
||||
@@ -389,8 +391,6 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
|
||||
) : (
|
||||
<div className='relative flex flex-row gap-1 items-center justify-between'>
|
||||
<PathDisplay
|
||||
collection={collection}
|
||||
dirName={path.relative(collection?.pathname, item?.pathname || collection?.pathname)}
|
||||
baseName={formik.values.filename? `${formik.values.filename}.bru` : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useDispatch } from 'react-redux';
|
||||
import { showHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { multiLineMsg } from "utils/common";
|
||||
import { formatIpcError } from "utils/common/error";
|
||||
|
||||
const TitleBar = () => {
|
||||
const [importedCollection, setImportedCollection] = useState(null);
|
||||
@@ -34,9 +36,8 @@ const TitleBar = () => {
|
||||
toast.success('Collection imported successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
console.error(err);
|
||||
toast.error('An error occurred while importing the collection. Check the logs for more information.');
|
||||
toast.error(multiLineMsg('An error occurred while importing the collection.', formatIpcError(err)));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -13,12 +13,9 @@ import {
|
||||
findEnvironmentInCollection,
|
||||
findItemInCollection,
|
||||
findParentItemInCollection,
|
||||
getItemsToResequence,
|
||||
isItemAFolder,
|
||||
refreshUidsInItem,
|
||||
isItemARequest,
|
||||
moveCollectionItem,
|
||||
moveCollectionItemToRootOfCollection,
|
||||
transformRequestToSaveToFilesystem
|
||||
} from 'utils/collections';
|
||||
import { uuid, waitForNextTick } from 'utils/common';
|
||||
@@ -47,8 +44,7 @@ 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 { getGlobalEnvironmentVariables } from 'utils/collections/index';
|
||||
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
|
||||
import { getGlobalEnvironmentVariables, findCollectionByPathname, findEnvironmentInCollectionByName, getReorderedItemsInTargetDirectory, resetSequencesInFolder, getReorderedItemsInSourceDirectory, calculateDraggedItemNewPathname } from 'utils/collections/index';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
|
||||
|
||||
@@ -60,7 +56,7 @@ export const renameCollection = (newName, collectionUid) => (dispatch, getState)
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:rename-collection', newName, collection.pathname).then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
@@ -337,6 +333,7 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
|
||||
})
|
||||
);
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke(
|
||||
'renderer:run-collection-folder',
|
||||
@@ -358,6 +355,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
|
||||
export const newFolder = (folderName, directoryName, collectionUid, itemUid) => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
|
||||
const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!collection) {
|
||||
@@ -372,10 +371,27 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
|
||||
if (!folderWithSameNameExists) {
|
||||
const fullName = path.join(collection.pathname, directoryName);
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:new-folder', fullName, folderName)
|
||||
.then(() => resolve())
|
||||
.invoke('renderer:new-folder', fullName)
|
||||
.then(async () => {
|
||||
const folderData = {
|
||||
name: folderName,
|
||||
pathname: fullName,
|
||||
root: {
|
||||
meta: {
|
||||
name: folderName,
|
||||
seq: items?.length + 1
|
||||
}
|
||||
}
|
||||
};
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-folder-root', folderData)
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
toast.error('Failed to save folder settings!');
|
||||
reject(err);
|
||||
});
|
||||
})
|
||||
.catch((error) => reject(error));
|
||||
} else {
|
||||
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
|
||||
@@ -392,8 +408,26 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:new-folder', fullName, folderName)
|
||||
.then(() => resolve())
|
||||
.invoke('renderer:new-folder', fullName)
|
||||
.then(async () => {
|
||||
const folderData = {
|
||||
name: folderName,
|
||||
pathname: fullName,
|
||||
root: {
|
||||
meta: {
|
||||
name: folderName,
|
||||
seq: items?.length + 1
|
||||
}
|
||||
}
|
||||
};
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-folder-root', folderData)
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
toast.error('Failed to save folder settings!');
|
||||
reject(err);
|
||||
});
|
||||
})
|
||||
.catch((error) => reject(error));
|
||||
} else {
|
||||
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
|
||||
@@ -495,8 +529,11 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (disp
|
||||
set(item, 'name', newName);
|
||||
set(item, 'filename', newFilename);
|
||||
set(item, 'root.meta.name', newName);
|
||||
|
||||
set(item, 'root.meta.seq', parentFolder?.items?.length + 1);
|
||||
|
||||
const collectionPath = path.join(parentFolder.pathname, newFilename);
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
@@ -594,176 +631,114 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
|
||||
export const sortCollections = (payload) => (dispatch) => {
|
||||
dispatch(_sortCollections(payload));
|
||||
};
|
||||
export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => {
|
||||
|
||||
export const moveItem = ({ targetDirname, sourcePathname }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
ipcRenderer.invoke('renderer:move-item', { targetDirname, sourcePathname })
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
export const handleCollectionItemDrop = ({ targetItem, draggedItem, dropType, collectionUid }) => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
|
||||
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
|
||||
const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection;
|
||||
const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items);
|
||||
const draggedItemDirectory = findParentItemInCollection(collection, draggedItemUid) || collection;
|
||||
const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items);
|
||||
|
||||
const handleMoveToNewLocation = async ({ draggedItem, draggedItemDirectoryItems, targetItem, targetItemDirectoryItems, newPathname, dropType }) => {
|
||||
const { uid: targetItemUid } = targetItem;
|
||||
const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem;
|
||||
|
||||
const newDirname = path.dirname(newPathname);
|
||||
await dispatch(moveItem({
|
||||
targetDirname: newDirname,
|
||||
sourcePathname: draggedItemPathname
|
||||
}));
|
||||
|
||||
// Update sequences in the source directory
|
||||
if (draggedItemDirectoryItems?.length) {
|
||||
// reorder items in the source directory
|
||||
const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter(i => i.uid !== draggedItemUid);
|
||||
const reorderedSourceItems = getReorderedItemsInSourceDirectory({ items: draggedItemDirectoryItemsWithoutDraggedItem });
|
||||
if (reorderedSourceItems?.length) {
|
||||
await dispatch(updateItemsSequences({ itemsToResequence: reorderedSourceItems }));
|
||||
}
|
||||
}
|
||||
|
||||
// Update sequences in the target directory (if dropping adjacent)
|
||||
if (dropType === 'adjacent') {
|
||||
const targetItemSequence = targetItemDirectoryItems.findIndex(i => i.uid === targetItemUid)?.seq;
|
||||
|
||||
const draggedItemWithNewPathAndSequence = {
|
||||
...draggedItem,
|
||||
pathname: newPathname,
|
||||
seq: targetItemSequence
|
||||
};
|
||||
|
||||
// draggedItem is added to the targetItem's directory
|
||||
const reorderedTargetItems = getReorderedItemsInTargetDirectory({
|
||||
items: [ ...targetItemDirectoryItems, draggedItemWithNewPathAndSequence ],
|
||||
targetItemUid,
|
||||
draggedItemUid
|
||||
});
|
||||
|
||||
if (reorderedTargetItems?.length) {
|
||||
await dispatch(updateItemsSequences({ itemsToResequence: reorderedTargetItems }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems }) => {
|
||||
const { uid: targetItemUid } = targetItem;
|
||||
const { uid: draggedItemUid } = draggedItem;
|
||||
|
||||
// reorder items in the targetItem's directory
|
||||
const reorderedItems = getReorderedItemsInTargetDirectory({
|
||||
items: targetItemDirectoryItems,
|
||||
targetItemUid,
|
||||
draggedItemUid
|
||||
});
|
||||
|
||||
if (reorderedItems?.length) {
|
||||
await dispatch(updateItemsSequences({ itemsToResequence: reorderedItems }));
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname: collection.pathname });
|
||||
if (!newPathname) return;
|
||||
if (targetItemPathname?.startsWith(draggedItemPathname)) return;
|
||||
if (newPathname !== draggedItemPathname) {
|
||||
await handleMoveToNewLocation({ targetItem, targetItemDirectoryItems, draggedItem, draggedItemDirectoryItems, newPathname, dropType });
|
||||
} else {
|
||||
await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem });
|
||||
}
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error?.message);
|
||||
reject(error);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const updateItemsSequences = ({ itemsToResequence }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
|
||||
const targetItem = findItemInCollection(collectionCopy, targetItemUid);
|
||||
|
||||
if (!draggedItem) {
|
||||
return reject(new Error('Dragged item not found'));
|
||||
}
|
||||
|
||||
if (!targetItem) {
|
||||
return reject(new Error('Target item not found'));
|
||||
}
|
||||
|
||||
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
|
||||
const targetItemParent = findParentItemInCollection(collectionCopy, targetItemUid);
|
||||
const sameParent = draggedItemParent === targetItemParent;
|
||||
|
||||
// file item dragged onto another file item and both are in the same folder
|
||||
// this is also true when both items are at the root level
|
||||
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && sameParent) {
|
||||
moveCollectionItem(collectionCopy, draggedItem, targetItem);
|
||||
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:resequence-items', itemsToResequence)
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
|
||||
// file item dragged onto another file item which is at the root level
|
||||
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
|
||||
const draggedItemPathname = draggedItem.pathname;
|
||||
moveCollectionItem(collectionCopy, draggedItem, targetItem);
|
||||
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
|
||||
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
|
||||
// file item dragged onto another file item and both are in different folders
|
||||
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !sameParent) {
|
||||
const draggedItemPathname = draggedItem.pathname;
|
||||
moveCollectionItem(collectionCopy, draggedItem, targetItem);
|
||||
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
|
||||
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-file-item', draggedItemPathname, targetItemParent.pathname)
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
|
||||
// file item dragged into its own folder
|
||||
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// file item dragged into another folder
|
||||
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
|
||||
const draggedItemPathname = draggedItem.pathname;
|
||||
moveCollectionItem(collectionCopy, draggedItem, targetItem);
|
||||
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
|
||||
const itemsToResequence2 = getItemsToResequence(targetItem, collectionCopy);
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-file-item', draggedItemPathname, targetItem.pathname)
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
|
||||
// end of the file drags, now let's handle folder drags
|
||||
// folder drags are simpler since we don't allow ordering of folders
|
||||
|
||||
// folder dragged into its own folder
|
||||
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// folder dragged into a file which is at the same level
|
||||
// this is also true when both items are at the root level
|
||||
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && sameParent) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// folder dragged into a file which is a child of the folder
|
||||
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && draggedItem === targetItemParent) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// folder dragged into a file which is at the root level
|
||||
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
|
||||
const draggedItemPathname = draggedItem.pathname;
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
|
||||
// folder dragged into another folder
|
||||
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
|
||||
const draggedItemPathname = draggedItem.pathname;
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-folder-item', draggedItemPathname, targetItem.pathname)
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
const collectionCopy = cloneDeep(collection);
|
||||
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
|
||||
if (!draggedItem) {
|
||||
return reject(new Error('Dragged item not found'));
|
||||
}
|
||||
|
||||
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
|
||||
// file item is already at the root level
|
||||
if (!draggedItemParent) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
const draggedItemPathname = draggedItem.pathname;
|
||||
moveCollectionItemToRootOfCollection(collectionCopy, draggedItem);
|
||||
|
||||
if (isItemAFolder(draggedItem)) {
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
} else {
|
||||
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
|
||||
const itemsToResequence2 = getItemsToResequence(collectionCopy, collectionCopy);
|
||||
|
||||
return ipcRenderer
|
||||
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
|
||||
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
|
||||
.then(resolve)
|
||||
.catch((error) => reject(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
|
||||
@@ -823,8 +798,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
collection.items,
|
||||
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
|
||||
);
|
||||
const requestItems = filter(collection.items, (i) => i.type !== 'folder');
|
||||
item.seq = requestItems.length + 1;
|
||||
const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
item.seq = items.length + 1;
|
||||
|
||||
if (!reqWithSameNameExists) {
|
||||
const fullName = path.join(collection.pathname, resolvedFilename);
|
||||
@@ -852,8 +827,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
currentItem.items,
|
||||
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
|
||||
);
|
||||
const requestItems = filter(currentItem.items, (i) => i.type !== 'folder');
|
||||
item.seq = requestItems.length + 1;
|
||||
const items = filter(currentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
item.seq = items.length + 1;
|
||||
if (!reqWithSameNameExists) {
|
||||
const fullName = path.join(currentItem.pathname, resolvedFilename);
|
||||
const { ipcRenderer } = window;
|
||||
@@ -885,6 +860,7 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-environment', collection.pathname, name)
|
||||
.then(
|
||||
@@ -913,6 +889,7 @@ export const importEnvironment = (name, variables, collectionUid) => (dispatch,
|
||||
|
||||
const sanitizedName = sanitizeName(name);
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
|
||||
.then(
|
||||
@@ -946,6 +923,7 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
|
||||
|
||||
const sanitizedName = sanitizeName(name);
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
|
||||
.then(
|
||||
@@ -982,6 +960,7 @@ export const renameEnvironment = (newName, environmentUid, collectionUid) => (di
|
||||
const oldName = environment.name;
|
||||
environment.name = sanitizedName;
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, sanitizedName))
|
||||
@@ -1005,6 +984,7 @@ export const deleteEnvironment = (environmentUid, collectionUid) => (dispatch, g
|
||||
return reject(new Error('Environment not found'));
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:delete-environment', collection.pathname, environment.name)
|
||||
.then(resolve)
|
||||
@@ -1028,6 +1008,7 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
|
||||
|
||||
environment.variables = variables;
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
|
||||
@@ -1053,7 +1034,8 @@ export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, g
|
||||
if (environmentUid && !environmentName) {
|
||||
return reject(new Error('Environment not found'));
|
||||
}
|
||||
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:update-ui-state-snapshot', { type: 'COLLECTION_ENVIRONMENT', data: { collectionPath: collection?.pathname, environmentName }});
|
||||
|
||||
dispatch(_selectEnvironment({ environmentUid, collectionUid }));
|
||||
@@ -1112,11 +1094,13 @@ export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getS
|
||||
const state = getState();
|
||||
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:update-bruno-config', brunoConfig, collection.pathname, collectionUid)
|
||||
.then(resolve)
|
||||
@@ -1135,6 +1119,8 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
|
||||
brunoConfig: brunoConfig
|
||||
};
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer.invoke('renderer:get-collection-security-config', pathname).then((securityConfig) => {
|
||||
collectionSchema
|
||||
|
||||
@@ -1719,6 +1719,9 @@ export const collectionsSlice = createSlice({
|
||||
folderItem.name = file?.data?.meta?.name;
|
||||
}
|
||||
folderItem.root = file.data;
|
||||
if (file?.data?.meta?.seq) {
|
||||
folderItem.seq = file.data?.meta?.seq;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1795,9 +1798,10 @@ export const collectionsSlice = createSlice({
|
||||
currentPath = path.join(currentPath, directoryName);
|
||||
if (!childItem) {
|
||||
childItem = {
|
||||
uid: uuid(),
|
||||
uid: dir?.meta?.uid || uuid(),
|
||||
pathname: currentPath,
|
||||
name: dir?.meta?.name || directoryName,
|
||||
seq: dir?.meta?.seq || 1,
|
||||
filename: directoryName,
|
||||
collapsed: true,
|
||||
type: 'folder',
|
||||
@@ -1829,6 +1833,9 @@ export const collectionsSlice = createSlice({
|
||||
if (file?.data?.meta?.name) {
|
||||
folderItem.name = file?.data?.meta?.name;
|
||||
}
|
||||
if (file?.data?.meta?.seq) {
|
||||
folderItem.seq = file?.data?.meta?.seq;
|
||||
}
|
||||
folderItem.root = file.data;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { stringifyIfNot, uuid } from 'utils/common/index';
|
||||
import { uuid } from 'utils/common/index';
|
||||
import { environmentSchema } from '@usebruno/schema';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
@@ -90,6 +90,7 @@ export const {
|
||||
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const uid = uuid();
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-global-environment', { name, uid, variables })
|
||||
.then(() => dispatch(_addGlobalEnvironment({ name, uid, variables })))
|
||||
@@ -104,6 +105,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
|
||||
const globalEnvironments = state.globalEnvironments.globalEnvironments;
|
||||
const baseEnv = globalEnvironments?.find(env => env?.uid == baseEnvUid)
|
||||
const uid = uuid();
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables })
|
||||
.then(() => dispatch(_copyGlobalEnvironment({ name, uid, variables: baseEnv.variables })))
|
||||
@@ -114,6 +116,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
|
||||
|
||||
export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
const globalEnvironments = state.globalEnvironments.globalEnvironments;
|
||||
const environment = globalEnvironments?.find(env => env?.uid == environmentUid)
|
||||
@@ -139,6 +142,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
|
||||
return reject(new Error('Environment not found'));
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:save-global-environment', {
|
||||
@@ -155,6 +159,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
|
||||
|
||||
export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:select-global-environment', { environmentUid })
|
||||
.then(() => dispatch(_selectGlobalEnvironment({ environmentUid })))
|
||||
@@ -165,6 +170,7 @@ export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
|
||||
|
||||
export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:delete-global-environment', { environmentUid })
|
||||
.then(() => dispatch(_deleteGlobalEnvironment({ environmentUid })))
|
||||
@@ -175,6 +181,7 @@ export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
|
||||
|
||||
export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
if (!globalEnvironmentVariables) resolve();
|
||||
|
||||
const state = getState();
|
||||
|
||||
@@ -7,9 +7,17 @@ export const ToastContext = React.createContext();
|
||||
export const ToastProvider = (props) => {
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const toastOptions = { duration: 2000 };
|
||||
const toastOptions = {
|
||||
duration: 2000,
|
||||
style: {
|
||||
// Break long word like file-path, URL etc. to prevent overflow
|
||||
overflowWrap: 'anywhere'
|
||||
}
|
||||
};
|
||||
|
||||
if (storedTheme === 'dark') {
|
||||
toastOptions.style = {
|
||||
...toastOptions.style,
|
||||
borderRadius: '10px',
|
||||
background: '#3d3d3d',
|
||||
color: '#fff'
|
||||
|
||||
9
packages/bruno-app/src/selectors/tab.js
Normal file
9
packages/bruno-app/src/selectors/tab.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
export const isTabForItemActive = ({ itemUid }) => createSelector([
|
||||
(state) => state.tabs?.activeTabUid
|
||||
], (activeTabUid) => activeTabUid === itemUid);
|
||||
|
||||
export const isTabForItemPresent = ({ itemUid }) => createSelector([
|
||||
(state) => state.tabs.tabs,
|
||||
], (tabs) => tabs.some((tab) => tab.uid === itemUid));
|
||||
@@ -281,6 +281,12 @@ const darkTheme = {
|
||||
color: 'rgb(52 51 49)'
|
||||
},
|
||||
|
||||
dragAndDrop: {
|
||||
border: '#666666',
|
||||
borderStyle: '2px solid',
|
||||
hoverBg: 'rgba(102, 102, 102, 0.08)',
|
||||
transition: 'all 0.1s ease'
|
||||
},
|
||||
infoTip: {
|
||||
bg: '#1f1f1f',
|
||||
border: '#333333',
|
||||
|
||||
@@ -282,6 +282,12 @@ const lightTheme = {
|
||||
color: 'rgb(152 151 149)'
|
||||
},
|
||||
|
||||
dragAndDrop: {
|
||||
border: '#8b8b8b', // Using the same gray as focusBorder from input
|
||||
borderStyle: '2px solid',
|
||||
hoverBg: 'rgba(139, 139, 139, 0.05)', // Matching the border color with reduced opacity
|
||||
transition: 'all 0.1s ease'
|
||||
},
|
||||
infoTip: {
|
||||
bg: 'white',
|
||||
border: '#e0e0e0',
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* Copyright (C) 2017 by Marijn Haverbeke <marijnh@gmail.com> and others
|
||||
*/
|
||||
|
||||
import { JSHINT } from 'jshint';
|
||||
|
||||
let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
|
||||
|
||||
@@ -98,6 +98,14 @@ export const findItemInCollectionByPathname = (collection, pathname) => {
|
||||
return findItemByPathname(flattenedItems, pathname);
|
||||
};
|
||||
|
||||
export const findParentItemInCollectionByPathname = (collection, pathname) => {
|
||||
let flattenedItems = flattenItems(collection.items);
|
||||
|
||||
return find(flattenedItems, (item) => {
|
||||
return item.items && find(item.items, (i) => i.pathname === pathname);
|
||||
});
|
||||
};
|
||||
|
||||
export const findItemInCollection = (collection, itemUid) => {
|
||||
let flattenedItems = flattenItems(collection.items);
|
||||
|
||||
@@ -150,90 +158,6 @@ export const getItemsLoadStats = (folder) => {
|
||||
};
|
||||
}
|
||||
|
||||
export const moveCollectionItem = (collection, draggedItem, targetItem) => {
|
||||
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
|
||||
|
||||
if (draggedItemParent) {
|
||||
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
|
||||
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
|
||||
draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
|
||||
} else {
|
||||
collection.items = sortBy(collection.items, (item) => item.seq);
|
||||
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
|
||||
}
|
||||
|
||||
if (targetItem.type === 'folder') {
|
||||
targetItem.items = sortBy(targetItem.items || [], (item) => item.seq);
|
||||
targetItem.items.push(draggedItem);
|
||||
draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename);
|
||||
} else {
|
||||
let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
|
||||
|
||||
if (targetItemParent) {
|
||||
targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
|
||||
let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
|
||||
targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
|
||||
draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
|
||||
} else {
|
||||
collection.items = sortBy(collection.items, (item) => item.seq);
|
||||
let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
|
||||
collection.items.splice(targetItemIndex + 1, 0, draggedItem);
|
||||
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const moveCollectionItemToRootOfCollection = (collection, draggedItem) => {
|
||||
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
|
||||
|
||||
// If the dragged item is already at the root of the collection, do nothing
|
||||
if (!draggedItemParent) {
|
||||
return;
|
||||
}
|
||||
|
||||
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
|
||||
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
|
||||
collection.items = sortBy(collection.items, (item) => item.seq);
|
||||
collection.items.push(draggedItem);
|
||||
if (draggedItem.type == 'folder') {
|
||||
draggedItem.pathname = path.join(collection.pathname, draggedItem.name);
|
||||
} else {
|
||||
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
|
||||
}
|
||||
};
|
||||
|
||||
export const getItemsToResequence = (parent, collection) => {
|
||||
let itemsToResequence = [];
|
||||
|
||||
if (!parent) {
|
||||
let index = 1;
|
||||
each(collection.items, (item) => {
|
||||
if (isItemARequest(item)) {
|
||||
itemsToResequence.push({
|
||||
pathname: item.pathname,
|
||||
seq: index++
|
||||
});
|
||||
}
|
||||
});
|
||||
return itemsToResequence;
|
||||
}
|
||||
|
||||
if (parent.items && parent.items.length) {
|
||||
let index = 1;
|
||||
each(parent.items, (item) => {
|
||||
if (isItemARequest(item)) {
|
||||
itemsToResequence.push({
|
||||
pathname: item.pathname,
|
||||
seq: index++
|
||||
});
|
||||
}
|
||||
});
|
||||
return itemsToResequence;
|
||||
}
|
||||
|
||||
return itemsToResequence;
|
||||
};
|
||||
|
||||
export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => {
|
||||
const copyHeaders = (headers) => {
|
||||
return map(headers, (header) => {
|
||||
@@ -502,6 +426,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
if (meta?.name) {
|
||||
di.root.meta = {};
|
||||
di.root.meta.name = meta?.name;
|
||||
di.root.meta.seq = meta?.seq;
|
||||
}
|
||||
if (!Object.keys(di.root.request)?.length) {
|
||||
delete di.root.request;
|
||||
@@ -1086,3 +1011,77 @@ export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = []
|
||||
});
|
||||
return credentialsVariables;
|
||||
};
|
||||
|
||||
|
||||
// item sequence utils - START
|
||||
|
||||
export const resetSequencesInFolder = (folderItems) => {
|
||||
const items = folderItems;
|
||||
const sortedItems = items.sort((a, b) => a.seq - b.seq);
|
||||
return sortedItems.map((item, index) => {
|
||||
item.seq = index + 1;
|
||||
return item;
|
||||
});
|
||||
};
|
||||
|
||||
export const isItemBetweenSequences = (itemSequence, sourceItemSequence, targetItemSequence) => {
|
||||
if (targetItemSequence > sourceItemSequence) {
|
||||
return itemSequence > sourceItemSequence && itemSequence < targetItemSequence;
|
||||
}
|
||||
return itemSequence < sourceItemSequence && itemSequence >= targetItemSequence;
|
||||
};
|
||||
|
||||
export const calculateNewSequence = (isDraggedItem, targetSequence, draggedSequence) => {
|
||||
if (!isDraggedItem) {
|
||||
return null;
|
||||
}
|
||||
return targetSequence > draggedSequence ? targetSequence - 1 : targetSequence;
|
||||
};
|
||||
|
||||
export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, draggedItemUid }) => {
|
||||
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
|
||||
const targetItem = findItem(itemsWithFixedSequences, targetItemUid);
|
||||
const draggedItem = findItem(itemsWithFixedSequences, draggedItemUid);
|
||||
const targetSequence = targetItem?.seq;
|
||||
const draggedSequence = draggedItem?.seq;
|
||||
itemsWithFixedSequences?.forEach(item => {
|
||||
const isDraggedItem = item?.uid === draggedItemUid;
|
||||
const isBetween = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
if (isBetween) {
|
||||
item.seq += targetSequence > draggedSequence ? -1 : 1;
|
||||
}
|
||||
const newSequence = calculateNewSequence(isDraggedItem, targetSequence, draggedSequence);
|
||||
if (newSequence !== null) {
|
||||
item.seq = newSequence;
|
||||
}
|
||||
});
|
||||
// only return items that have been reordered
|
||||
return itemsWithFixedSequences.filter(item =>
|
||||
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
|
||||
);
|
||||
};
|
||||
|
||||
export const getReorderedItemsInSourceDirectory = ({ items }) => {
|
||||
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
|
||||
return itemsWithFixedSequences.filter(item =>
|
||||
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
|
||||
);
|
||||
};
|
||||
|
||||
export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropType, collectionPathname }) => {
|
||||
const { pathname: targetItemPathname } = targetItem;
|
||||
const { filename: draggedItemFilename } = draggedItem;
|
||||
const targetItemDirname = path.dirname(targetItemPathname);
|
||||
const isTargetTheCollection = targetItemPathname === collectionPathname;
|
||||
const isTargetItemAFolder = isItemAFolder(targetItem);
|
||||
|
||||
if (dropType === 'inside' && (isTargetItemAFolder || isTargetTheCollection)) {
|
||||
return path.join(targetItemPathname, draggedItemFilename)
|
||||
} else if (dropType === 'adjacent') {
|
||||
return path.join(targetItemDirname, draggedItemFilename)
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// item sequence utils - END
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
class Cache {
|
||||
get(key) {
|
||||
return window.localStorage.getItem(key);
|
||||
}
|
||||
set(key, val) {
|
||||
window.localStorage.setItem(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Cache();
|
||||
@@ -34,3 +34,11 @@ export const toastError = (error, defaultErrorMsg = 'An error occurred') => {
|
||||
|
||||
return toast.error(errorMsg);
|
||||
};
|
||||
|
||||
export function formatIpcError(error) {
|
||||
if (!(error instanceof Error)) return error;
|
||||
if (!error?.message) return ''; // Avoid returning `null` or `undefined`
|
||||
// https://github.com/electron/electron/blob/659e79fc08c6ffc2f7506dd1358918d97d240147/lib/renderer/api/ipc-renderer.ts#L24-L30
|
||||
// There is no other way to get rid of this error prefix as of now.
|
||||
return error.message.replace(/^Error invoking remote method '.+?': (Error: )?/, '');
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const safeStringifyJSON = (obj, indent = false) => {
|
||||
|
||||
export const convertToCodeMirrorJson = (obj) => {
|
||||
try {
|
||||
return JSON5.stringify(obj).slice(1, -1);
|
||||
return JSON.stringify(obj, null, 2).slice(1, -1);
|
||||
} catch (e) {
|
||||
return obj;
|
||||
}
|
||||
@@ -181,4 +181,8 @@ export const getEncoding = (headers) => {
|
||||
// Parse the charset from content type: https://stackoverflow.com/a/33192813
|
||||
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(headers?.['content-type'] || '');
|
||||
return charsetMatch?.[1];
|
||||
}
|
||||
|
||||
export const multiLineMsg = (...messages) => {
|
||||
return messages.filter(m => m !== undefined && m !== null && m !== '').join('\n');
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // replace invalid characters with hyphens
|
||||
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
|
||||
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
|
||||
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
|
||||
const lastCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot or space at end, hyphen allowed
|
||||
const firstCharacter = /^[^\s\-<>:"/\\|?*\x00-\x1F]/; // no space, hyphen and `invalidCharacters`
|
||||
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no `invalidCharacters`
|
||||
const lastCharacter = /[^.\s<>:"/\\|?*\x00-\x1F]$/; // no dot, space and `invalidCharacters`
|
||||
|
||||
export const variableNameRegex = /^[\w-.]*$/;
|
||||
|
||||
export const sanitizeName = (name) => {
|
||||
name = name
|
||||
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
|
||||
.replace(/^[.\s-]+/, '') // remove leading dots, hyphens and spaces
|
||||
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
|
||||
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
|
||||
.replace(/^[\s\-]+/, '') // remove leading spaces and hyphens
|
||||
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces
|
||||
return name;
|
||||
};
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ describe('regex validators', () => {
|
||||
});
|
||||
|
||||
it('should remove trailing periods', () => {
|
||||
expect(sanitizeName('.file')).toBe('file');
|
||||
expect(sanitizeName('.file.')).toBe('file');
|
||||
expect(sanitizeName('.file')).toBe('.file');
|
||||
expect(sanitizeName('.file.')).toBe('.file');
|
||||
expect(sanitizeName('file.')).toBe('file');
|
||||
expect(sanitizeName('file.name.')).toBe('file.name');
|
||||
expect(sanitizeName('hello world.')).toBe('hello world');
|
||||
@@ -83,11 +83,11 @@ describe('regex validators', () => {
|
||||
|
||||
it('should handle filenames with multiple consecutive periods (only remove trailing)', () => {
|
||||
expect(sanitizeName('file.name...')).toBe('file.name');
|
||||
expect(sanitizeName('...file')).toBe('file');
|
||||
expect(sanitizeName('...file')).toBe('...file');
|
||||
expect(sanitizeName('file.name... ')).toBe('file.name');
|
||||
expect(sanitizeName(' ...file')).toBe('file');
|
||||
expect(sanitizeName(' ...file ')).toBe('file');
|
||||
expect(sanitizeName(' ...file.... ')).toBe('file');
|
||||
expect(sanitizeName(' ...file')).toBe('...file');
|
||||
expect(sanitizeName(' ...file ')).toBe('...file');
|
||||
expect(sanitizeName(' ...file.... ')).toBe('...file');
|
||||
});
|
||||
|
||||
it('should handle very long filenames', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fileDialog from 'file-dialog';
|
||||
import { BrunoError } from 'utils/common/error';
|
||||
import { postmanToBruno } from '@usebruno/converters';
|
||||
import { safeParseJSON } from 'utils/common/index';
|
||||
|
||||
const readFile = (files) => {
|
||||
@@ -12,18 +11,15 @@ const readFile = (files) => {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const importCollection = () => {
|
||||
const postmanToBruno = (collection) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fileDialog({ accept: 'application/json' })
|
||||
.then(readFile)
|
||||
.then((collection) => postmanToBruno(collection))
|
||||
.then((collection) => resolve({ collection }))
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
reject(new BrunoError('Import collection failed'));
|
||||
})
|
||||
window.ipcRenderer.invoke('renderer:convert-postman-to-bruno', collection)
|
||||
.then(result => resolve(result))
|
||||
.catch(err => {
|
||||
console.error('Error converting Postman to Bruno via Electron:', err);
|
||||
reject(new BrunoError('Conversion failed'));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export default importCollection;
|
||||
export { postmanToBruno, readFile };
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { resetSequencesInFolder, isItemBetweenSequences } from 'utils/collections/index';
|
||||
|
||||
describe('resetSequencesInFolder', () => {
|
||||
it('should fix the sequences in the folder 1', () => {
|
||||
const folder = {
|
||||
items: [
|
||||
{ uid: '1', seq: 1 },
|
||||
{ uid: '2', seq: 3 },
|
||||
{ uid: '3', seq: 6 },
|
||||
],
|
||||
};
|
||||
|
||||
const fixedFolder = resetSequencesInFolder(folder.items);
|
||||
expect(fixedFolder).toEqual([
|
||||
{ uid: '1', seq: 1 },
|
||||
{ uid: '2', seq: 2 },
|
||||
{ uid: '3', seq: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should fix the sequences in the folder 2', () => {
|
||||
const folder = {
|
||||
items: [
|
||||
{ uid: '1', seq: 3 },
|
||||
{ uid: '2', seq: 1 },
|
||||
{ uid: '3', seq: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
const fixedFolder = resetSequencesInFolder(folder.items);
|
||||
expect(fixedFolder).toEqual([
|
||||
{ uid: '2', seq: 1 },
|
||||
{ uid: '3', seq: 2 },
|
||||
{ uid: '1', seq: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fix the sequences in the folder with missing sequences', () => {
|
||||
const folder = {
|
||||
items: [
|
||||
{ uid: '1', seq: 1 },
|
||||
{ uid: '2', type: 'folder' },
|
||||
{ uid: '3', type: 'folder' },
|
||||
{ uid: '4', seq: 7 },
|
||||
]
|
||||
};
|
||||
|
||||
const fixedFolder = resetSequencesInFolder(folder.items);
|
||||
expect(fixedFolder).toEqual([
|
||||
{ uid: '1', seq: 1 },
|
||||
{ uid: '2', seq: 2, type: 'folder' },
|
||||
{ uid: '3', seq: 3, type: 'folder' },
|
||||
{ uid: '4', seq: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fix the sequences in the folder with same sequences', () => {
|
||||
const folder = {
|
||||
items: [
|
||||
{ uid: '1', seq: 2 },
|
||||
{ uid: '2', seq: 2 },
|
||||
{ uid: '3', seq: 3 },
|
||||
{ uid: '4', seq: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const fixedFolder = resetSequencesInFolder(folder.items);
|
||||
expect(fixedFolder).toEqual([
|
||||
{ uid: '4', seq: 1 },
|
||||
{ uid: '1', seq: 2 },
|
||||
{ uid: '2', seq: 3 },
|
||||
{ uid: '3', seq: 4 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isItemBetweenSequences', () => {
|
||||
it('should return true if the item is between the sequences 1', () => {
|
||||
const item = { uid: '1', seq: 2 };
|
||||
const draggedSequence = 1;
|
||||
const targetSequence = 5;
|
||||
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if the item is between the sequences 2', () => {
|
||||
const item = { uid: '1', seq: 2 };
|
||||
const draggedSequence = 1;
|
||||
const targetSequence = 5;
|
||||
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if the item is between the sequences 3', () => {
|
||||
const item = { uid: '1', seq: 4 };
|
||||
const draggedSequence = 1;
|
||||
const targetSequence = 5;
|
||||
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if the item is between the sequences 4', () => {
|
||||
const item = { uid: '1', seq: 1 };
|
||||
const draggedSequence = 5;
|
||||
const targetSequence = 1;
|
||||
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the item is between the sequences 1', () => {
|
||||
const item = { uid: '1', seq: 1 };
|
||||
const draggedSequence = 1;
|
||||
const targetSequence = 5;
|
||||
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if the item is between the sequences 2', () => {
|
||||
const item = { uid: '1', seq: 5 };
|
||||
const draggedSequence = 1;
|
||||
const targetSequence = 5;
|
||||
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@
|
||||
"@usebruno/lang": "0.12.0",
|
||||
"@usebruno/vm2": "^3.9.13",
|
||||
"@usebruno/requests": "^0.1.0",
|
||||
"@usebruno/converters": "^0.1.0",
|
||||
"aws4-axios": "^3.3.0",
|
||||
"axios": "^1.8.3",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
@@ -63,6 +64,7 @@
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"qs": "^6.11.0",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
|
||||
@@ -58,6 +58,44 @@ If you need to limit the trusted CA to a specified set when validating the reque
|
||||
bru run request.bru --cacert myCustomCA.pem --ignore-truststore
|
||||
```
|
||||
|
||||
## Importing Collections
|
||||
|
||||
You can import collections from other formats, such as OpenAPI, using the import command:
|
||||
|
||||
```bash
|
||||
bru import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"
|
||||
```
|
||||
|
||||
You can also use the shorter form with aliases:
|
||||
|
||||
```bash
|
||||
bru import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"
|
||||
```
|
||||
|
||||
This creates a Bruno collection directory that can be opened in Bruno.
|
||||
|
||||
You can also import directly from a URL:
|
||||
|
||||
```bash
|
||||
bru import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"
|
||||
```
|
||||
|
||||
You can also export the collection as a JSON file:
|
||||
|
||||
```bash
|
||||
bru import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"
|
||||
```
|
||||
|
||||
Import Options:
|
||||
|
||||
| Option | Details |
|
||||
| ------------------------- | -------------------------------------------------- |
|
||||
| --source, -s | Path to the source file or URL (required) |
|
||||
| --output, -o | Path to the output directory |
|
||||
| --output-file, -f | Path to the output JSON file |
|
||||
| --collection-name, -n | Name for the imported collection |
|
||||
| --insecure | Skip SSL certificate validation when fetching from URLs |
|
||||
|
||||
## Command Line Options
|
||||
|
||||
| Option | Details |
|
||||
|
||||
230
packages/bruno-cli/src/commands/import.js
Normal file
230
packages/bruno-cli/src/commands/import.js
Normal file
@@ -0,0 +1,230 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const chalk = require('chalk');
|
||||
const jsyaml = require('js-yaml');
|
||||
const axios = require('axios');
|
||||
const { openApiToBruno } = require('@usebruno/converters');
|
||||
const { exists, isDirectory, sanitizeName } = require('../utils/filesystem');
|
||||
const { createCollectionFromBrunoObject } = require('../utils/collection');
|
||||
|
||||
const command = 'import <type>';
|
||||
const desc = 'Import a collection from other formats';
|
||||
|
||||
const builder = (yargs) => {
|
||||
yargs
|
||||
.positional('type', {
|
||||
describe: 'Type of collection to import',
|
||||
type: 'string',
|
||||
choices: ['openapi']
|
||||
})
|
||||
.option('source', {
|
||||
alias: 's',
|
||||
describe: 'Path to the source file or URL',
|
||||
type: 'string',
|
||||
demandOption: true
|
||||
})
|
||||
.option('output', {
|
||||
alias: 'o',
|
||||
describe: 'Path to the output directory',
|
||||
type: 'string',
|
||||
conflicts: 'output-file'
|
||||
})
|
||||
.option('output-file', {
|
||||
alias: 'f',
|
||||
describe: 'Path to the output JSON file',
|
||||
type: 'string',
|
||||
conflicts: 'output'
|
||||
})
|
||||
.option('collection-name', {
|
||||
alias: 'n',
|
||||
describe: 'Name for the imported collection',
|
||||
type: 'string'
|
||||
})
|
||||
.option('insecure', {
|
||||
type: 'boolean',
|
||||
describe: 'Skip SSL certificate verification when fetching from URLs',
|
||||
default: false
|
||||
})
|
||||
.example('$0 import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"')
|
||||
.example('$0 import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"')
|
||||
.example('$0 import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"')
|
||||
.example('$0 import openapi --source https://self-signed.example.com/api.json --insecure --output ~/Desktop')
|
||||
.example('$0 import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"')
|
||||
.example('$0 import openapi -s api.yml -f ~/Desktop/my-collection.json -n "My API"');
|
||||
};
|
||||
|
||||
const isUrl = (str) => {
|
||||
try {
|
||||
return Boolean(new URL(str));
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const readOpenApiFile = async (source, options = {}) => {
|
||||
try {
|
||||
let content;
|
||||
|
||||
if (isUrl(source)) {
|
||||
// Handle URL input
|
||||
console.log(chalk.yellow(`Fetching specification from URL: ${source}`));
|
||||
try {
|
||||
const axiosOptions = {
|
||||
timeout: 30000, // 30 second timeout
|
||||
maxContentLength: 10 * 1024 * 1024,
|
||||
validateStatus: status => status >= 200 && status < 300
|
||||
};
|
||||
|
||||
// Skip SSL certificate validation if insecure flag is set
|
||||
if (options.insecure) {
|
||||
console.log(chalk.yellow('Warning: SSL certificate verification is disabled. Use with caution.'));
|
||||
axiosOptions.httpsAgent = new (require('https')).Agent({ rejectUnauthorized: false });
|
||||
}
|
||||
|
||||
const response = await axios.get(source, axiosOptions);
|
||||
content = response.data;
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
throw new Error('Request timed out. The server took too long to respond.');
|
||||
} else if (error.code === 'CERT_HAS_EXPIRED' || error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
|
||||
error.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
|
||||
throw new Error(`SSL Certificate error: ${error.code}. Try using --insecure if you trust this source.`);
|
||||
} else if (error.response) {
|
||||
throw new Error(`Failed to fetch from URL: ${error.response.status} ${error.response.statusText}`);
|
||||
} else if (error.request) {
|
||||
throw new Error(`No response received from server. Check the URL and your network connection.`);
|
||||
} else {
|
||||
throw new Error(`Error fetching URL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If response is already an object, return it directly
|
||||
if (typeof content === 'object' && content !== null) {
|
||||
return content;
|
||||
}
|
||||
} else {
|
||||
// Handle file input
|
||||
if (!await exists(source)) {
|
||||
throw new Error(`File does not exist: ${source}`);
|
||||
}
|
||||
content = fs.readFileSync(source, 'utf8');
|
||||
}
|
||||
|
||||
// If content is a string, try to parse as JSON or YAML
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (jsonError) {
|
||||
try {
|
||||
return jsyaml.load(content);
|
||||
} catch (yamlError) {
|
||||
throw new Error('Failed to parse content as JSON or YAML');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
} catch (error) {
|
||||
// Let the specific error handling from above propagate
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handler = async (argv) => {
|
||||
try {
|
||||
const { type, source, output, outputFile, collectionName, insecure } = argv;
|
||||
|
||||
if (!type || type !== 'openapi') {
|
||||
console.error(chalk.red('Only OpenAPI import is supported currently'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
console.error(chalk.red('Source file or URL is required'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!output && !outputFile) {
|
||||
console.error(chalk.red('Either --output or --output-file is required'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.yellow(`Reading OpenAPI specification from ${source}...`));
|
||||
|
||||
const openApiSpec = await readOpenApiFile(source, { insecure });
|
||||
|
||||
if (!openApiSpec) {
|
||||
console.error(chalk.red('Failed to parse OpenAPI specification'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.yellow('Converting OpenAPI specification to Bruno format...'));
|
||||
|
||||
// Convert OpenAPI to Bruno format
|
||||
let brunoCollection = openApiToBruno(openApiSpec);
|
||||
|
||||
// Override collection name if provided
|
||||
if (collectionName) {
|
||||
brunoCollection.name = collectionName;
|
||||
}
|
||||
|
||||
if (outputFile) {
|
||||
// Save as JSON file
|
||||
const outputPath = path.resolve(outputFile);
|
||||
fs.writeFileSync(outputPath, JSON.stringify(brunoCollection, null, 2));
|
||||
console.log(chalk.green(`Bruno collection saved as JSON to ${outputPath}`));
|
||||
} else if (output) {
|
||||
const resolvedOutput = path.resolve(output);
|
||||
|
||||
// Check if output is an existing directory
|
||||
const isOutputDirectory = await exists(resolvedOutput) && isDirectory(resolvedOutput);
|
||||
|
||||
// Determine the final output directory
|
||||
let outputDir;
|
||||
if (isOutputDirectory) {
|
||||
// If output is an existing directory, use collection name to create a subdirectory
|
||||
const dirName = sanitizeName(brunoCollection.name);
|
||||
outputDir = path.join(resolvedOutput, dirName);
|
||||
|
||||
// Check if this subfolder already exists
|
||||
if (await exists(outputDir)) {
|
||||
const dirContents = fs.readdirSync(outputDir);
|
||||
if (dirContents.length > 0) {
|
||||
console.error(chalk.red(`Output directory is not empty: ${outputDir}`));
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Create the subfolder
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
} else {
|
||||
// If output doesn't exist or is not a directory, use it directly
|
||||
outputDir = resolvedOutput;
|
||||
|
||||
// Check if parent directory exists
|
||||
const parentDir = path.dirname(outputDir);
|
||||
if (!await exists(parentDir)) {
|
||||
console.error(chalk.red(`Parent directory does not exist: ${parentDir}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
await createCollectionFromBrunoObject(brunoCollection, outputDir);
|
||||
console.log(chalk.green(`Bruno collection created at ${outputDir}`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error: ${error.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
command,
|
||||
desc,
|
||||
builder,
|
||||
handler,
|
||||
isUrl,
|
||||
readOpenApiFile
|
||||
};
|
||||
@@ -12,7 +12,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 { findItemInCollection, getAllRequestsInFolder, createCollectionJsonFromPathname } = require('../utils/collection');
|
||||
const command = 'run [filename]';
|
||||
const desc = 'Run a request';
|
||||
|
||||
@@ -22,6 +22,7 @@ const printRunSummary = (results) => {
|
||||
passedRequests,
|
||||
failedRequests,
|
||||
skippedRequests,
|
||||
errorRequests,
|
||||
totalAssertions,
|
||||
passedAssertions,
|
||||
failedAssertions,
|
||||
@@ -36,6 +37,9 @@ const printRunSummary = (results) => {
|
||||
if (failedRequests > 0) {
|
||||
requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`;
|
||||
}
|
||||
if (errorRequests > 0) {
|
||||
requestSummary += `, ${chalk.red(`${errorRequests} error`)}`;
|
||||
}
|
||||
if (skippedRequests > 0) {
|
||||
requestSummary += `, ${chalk.magenta(`${skippedRequests} skipped`)}`;
|
||||
}
|
||||
@@ -62,6 +66,7 @@ const printRunSummary = (results) => {
|
||||
passedRequests,
|
||||
failedRequests,
|
||||
skippedRequests,
|
||||
errorRequests,
|
||||
totalAssertions,
|
||||
passedAssertions,
|
||||
failedAssertions,
|
||||
@@ -71,163 +76,6 @@ const printRunSummary = (results) => {
|
||||
}
|
||||
};
|
||||
|
||||
const createCollectionFromPath = (collectionPath) => {
|
||||
const environmentsPath = path.join(collectionPath, `environments`);
|
||||
const getFilesInOrder = (collectionPath) => {
|
||||
let collection = {
|
||||
pathname: collectionPath
|
||||
};
|
||||
const traverse = (currentPath) => {
|
||||
const filesInCurrentDir = fs.readdirSync(currentPath);
|
||||
|
||||
if (currentPath.includes('node_modules')) {
|
||||
return;
|
||||
}
|
||||
const currentDirItems = [];
|
||||
for (const file of filesInCurrentDir) {
|
||||
const filePath = path.join(currentPath, file);
|
||||
const stats = fs.lstatSync(filePath);
|
||||
if (
|
||||
stats.isDirectory() &&
|
||||
filePath !== environmentsPath &&
|
||||
!filePath.startsWith('.git') &&
|
||||
!filePath.startsWith('node_modules')
|
||||
) {
|
||||
let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) }
|
||||
const folderBruFilePath = path.join(filePath, 'folder.bru');
|
||||
const folderBruFileExists = fs.existsSync(folderBruFilePath);
|
||||
if(folderBruFileExists) {
|
||||
const folderBruContent = fs.readFileSync(folderBruFilePath, 'utf8');
|
||||
let folderBruJson = collectionBruToJson(folderBruContent);
|
||||
folderItem.root = folderBruJson;
|
||||
}
|
||||
currentDirItems.push(folderItem);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of filesInCurrentDir) {
|
||||
if (['collection.bru', 'folder.bru'].includes(file)) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(currentPath, file);
|
||||
const stats = fs.lstatSync(filePath);
|
||||
|
||||
if (!stats.isDirectory() && path.extname(filePath) === '.bru') {
|
||||
const bruContent = fs.readFileSync(filePath, 'utf8');
|
||||
const bruJson = bruToJson(bruContent);
|
||||
currentDirItems.push({
|
||||
name: file,
|
||||
pathname: filePath,
|
||||
...bruJson
|
||||
});
|
||||
}
|
||||
}
|
||||
return currentDirItems;
|
||||
};
|
||||
collection.items = traverse(collectionPath);
|
||||
return collection;
|
||||
};
|
||||
return getFilesInOrder(collectionPath);
|
||||
};
|
||||
|
||||
const getBruFilesRecursively = (dir, testsOnly) => {
|
||||
const environmentsPath = 'environments';
|
||||
const collection = {};
|
||||
|
||||
const getFilesInOrder = (dir) => {
|
||||
let bruJsons = [];
|
||||
|
||||
const traverse = (currentPath) => {
|
||||
const filesInCurrentDir = fs.readdirSync(currentPath);
|
||||
|
||||
if (currentPath.includes('node_modules')) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of filesInCurrentDir) {
|
||||
const filePath = path.join(currentPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
// todo: we might need a ignore config inside bruno.json
|
||||
if (
|
||||
stats.isDirectory() &&
|
||||
filePath !== environmentsPath &&
|
||||
!filePath.startsWith('.git') &&
|
||||
!filePath.startsWith('node_modules')
|
||||
) {
|
||||
traverse(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
const currentDirBruJsons = [];
|
||||
for (const file of filesInCurrentDir) {
|
||||
if (['collection.bru', 'folder.bru'].includes(file)) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(currentPath, file);
|
||||
const stats = fs.lstatSync(filePath);
|
||||
|
||||
if (!stats.isDirectory() && path.extname(filePath) === '.bru') {
|
||||
const bruContent = fs.readFileSync(filePath, 'utf8');
|
||||
const bruJson = bruToJson(bruContent);
|
||||
const requestHasTests = bruJson.request?.tests;
|
||||
const requestHasActiveAsserts = bruJson.request?.assertions.some((x) => x.enabled) || false;
|
||||
|
||||
if (testsOnly) {
|
||||
if (requestHasTests || requestHasActiveAsserts) {
|
||||
currentDirBruJsons.push({
|
||||
bruFilepath: filePath,
|
||||
bruJson
|
||||
});
|
||||
}
|
||||
} else {
|
||||
currentDirBruJsons.push({
|
||||
bruFilepath: filePath,
|
||||
bruJson
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// order requests by sequence
|
||||
currentDirBruJsons.sort((a, b) => {
|
||||
const aSequence = a.bruJson.seq || 0;
|
||||
const bSequence = b.bruJson.seq || 0;
|
||||
return aSequence - bSequence;
|
||||
});
|
||||
|
||||
bruJsons = bruJsons.concat(currentDirBruJsons);
|
||||
};
|
||||
|
||||
traverse(dir);
|
||||
return bruJsons;
|
||||
};
|
||||
|
||||
return getFilesInOrder(dir);
|
||||
};
|
||||
|
||||
const getCollectionRoot = (dir) => {
|
||||
const collectionRootPath = path.join(dir, 'collection.bru');
|
||||
const exists = fs.existsSync(collectionRootPath);
|
||||
if (!exists) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(collectionRootPath, 'utf8');
|
||||
return collectionBruToJson(content);
|
||||
};
|
||||
|
||||
const getFolderRoot = (dir) => {
|
||||
const folderRootPath = path.join(dir, 'folder.bru');
|
||||
const exists = fs.existsSync(folderRootPath);
|
||||
if (!exists) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(folderRootPath, 'utf8');
|
||||
return collectionBruToJson(content);
|
||||
};
|
||||
|
||||
const getJsSandboxRuntime = (sandbox) => {
|
||||
return sandbox === 'safe' ? 'quickjs' : 'vm2';
|
||||
};
|
||||
@@ -320,7 +168,6 @@ const builder = async (yargs) => {
|
||||
type:"number",
|
||||
description: "Delay between each requests (in miliseconds)"
|
||||
})
|
||||
|
||||
.example('$0 run request.bru', 'Run a request')
|
||||
.example('$0 run request.bru --env local', 'Run a request with the environment set to local')
|
||||
.example('$0 run folder', 'Run all requests in a folder')
|
||||
@@ -390,25 +237,8 @@ const handler = async function (argv) {
|
||||
} = argv;
|
||||
const collectionPath = process.cwd();
|
||||
|
||||
// todo
|
||||
// right now, bru must be run from the root of the collection
|
||||
// will add support in the future to run it from anywhere inside the collection
|
||||
const brunoJsonPath = path.join(collectionPath, 'bruno.json');
|
||||
const brunoJsonExists = await exists(brunoJsonPath);
|
||||
if (!brunoJsonExists) {
|
||||
console.error(chalk.red(`You can run only at the root of a collection`));
|
||||
process.exit(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
|
||||
}
|
||||
|
||||
const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
|
||||
const brunoConfig = JSON.parse(brunoConfigFile);
|
||||
const collectionRoot = getCollectionRoot(collectionPath);
|
||||
let collection = createCollectionFromPath(collectionPath);
|
||||
collection = {
|
||||
brunoConfig,
|
||||
root: collectionRoot,
|
||||
...collection
|
||||
}
|
||||
let collection = createCollectionJsonFromPathname(collectionPath);
|
||||
const { root: collectionRoot, brunoConfig } = collection;
|
||||
|
||||
if (clientCertConfig) {
|
||||
try {
|
||||
@@ -444,7 +274,6 @@ const handler = async function (argv) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (filename && filename.length) {
|
||||
const pathExists = await exists(filename);
|
||||
if (!pathExists) {
|
||||
@@ -566,54 +395,39 @@ const handler = async function (argv) {
|
||||
const _isFile = isFile(filename);
|
||||
let results = [];
|
||||
|
||||
let bruJsons = [];
|
||||
let requestItems = [];
|
||||
|
||||
if (_isFile) {
|
||||
console.log(chalk.yellow('Running Request \n'));
|
||||
const bruContent = fs.readFileSync(filename, 'utf8');
|
||||
const bruJson = bruToJson(bruContent);
|
||||
bruJsons.push({
|
||||
bruFilepath: filename,
|
||||
bruJson
|
||||
});
|
||||
const requestItem = bruToJson(bruContent);
|
||||
requestItem.pathname = path.resolve(collectionPath, filename);
|
||||
requestItems.push(requestItem);
|
||||
}
|
||||
|
||||
const _isDirectory = isDirectory(filename);
|
||||
if (_isDirectory) {
|
||||
if (!recursive) {
|
||||
console.log(chalk.yellow('Running Folder \n'));
|
||||
const files = fs.readdirSync(filename);
|
||||
const bruFiles = files.filter((file) => !['folder.bru'].includes(file) && file.endsWith('.bru'));
|
||||
|
||||
for (const bruFile of bruFiles) {
|
||||
const bruFilepath = path.join(filename, bruFile);
|
||||
const bruContent = fs.readFileSync(bruFilepath, 'utf8');
|
||||
const bruJson = bruToJson(bruContent);
|
||||
const requestHasTests = bruJson.request?.tests;
|
||||
const requestHasActiveAsserts = bruJson.request?.assertions.some((x) => x.enabled) || false;
|
||||
if (testsOnly) {
|
||||
if (requestHasTests || requestHasActiveAsserts) {
|
||||
bruJsons.push({
|
||||
bruFilepath,
|
||||
bruJson
|
||||
});
|
||||
}
|
||||
} else {
|
||||
bruJsons.push({
|
||||
bruFilepath,
|
||||
bruJson
|
||||
});
|
||||
}
|
||||
}
|
||||
bruJsons.sort((a, b) => {
|
||||
const aSequence = a.bruJson.seq || 0;
|
||||
const bSequence = b.bruJson.seq || 0;
|
||||
return aSequence - bSequence;
|
||||
});
|
||||
} else {
|
||||
console.log(chalk.yellow('Running Folder Recursively \n'));
|
||||
}
|
||||
const resolvedFilepath = path.resolve(filename);
|
||||
if (resolvedFilepath === collectionPath) {
|
||||
requestItems = getAllRequestsInFolder(collection?.items, recursive);
|
||||
} else {
|
||||
const folderItem = findItemInCollection(collection, resolvedFilepath);
|
||||
if (folderItem) {
|
||||
requestItems = getAllRequestsInFolder(folderItem.items, recursive);
|
||||
}
|
||||
}
|
||||
|
||||
bruJsons = getBruFilesRecursively(filename, testsOnly);
|
||||
if (testsOnly) {
|
||||
requestItems = requestItems.filter((iter) => {
|
||||
const requestHasTests = iter.request?.tests;
|
||||
const requestHasActiveAsserts = iter.request?.assertions.some((x) => x.enabled) || false;
|
||||
return requestHasTests || requestHasActiveAsserts;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,11 +439,10 @@ const handler = async function (argv) {
|
||||
if (itemPathname && !itemPathname?.endsWith('.bru')) {
|
||||
itemPathname = `${itemPathname}.bru`;
|
||||
}
|
||||
const bruJson = cloneDeep(findItemInCollection(collection, itemPathname));
|
||||
if (bruJson) {
|
||||
const requestItem = cloneDeep(findItemInCollection(collection, itemPathname));
|
||||
if (requestItem) {
|
||||
const res = await runSingleRequest(
|
||||
itemPathname,
|
||||
bruJson,
|
||||
requestItem,
|
||||
collectionPath,
|
||||
runtimeVariables,
|
||||
envVars,
|
||||
@@ -648,14 +461,13 @@ const handler = async function (argv) {
|
||||
|
||||
let currentRequestIndex = 0;
|
||||
let nJumps = 0; // count the number of jumps to avoid infinite loops
|
||||
while (currentRequestIndex < bruJsons.length) {
|
||||
const iter = cloneDeep(bruJsons[currentRequestIndex]);
|
||||
const { bruFilepath, bruJson } = iter;
|
||||
while (currentRequestIndex < requestItems.length) {
|
||||
const requestItem = cloneDeep(requestItems[currentRequestIndex]);
|
||||
const { pathname } = requestItem;
|
||||
|
||||
const start = process.hrtime();
|
||||
const result = await runSingleRequest(
|
||||
bruFilepath,
|
||||
bruJson,
|
||||
requestItem,
|
||||
collectionPath,
|
||||
runtimeVariables,
|
||||
envVars,
|
||||
@@ -667,7 +479,7 @@ const handler = async function (argv) {
|
||||
runSingleRequestByPathname
|
||||
);
|
||||
|
||||
const isLastRun = currentRequestIndex === bruJsons.length - 1;
|
||||
const isLastRun = currentRequestIndex === requestItems.length - 1;
|
||||
const isValidDelay = !Number.isNaN(delay) && delay > 0;
|
||||
if(isValidDelay && !isLastRun){
|
||||
console.log(chalk.yellow(`Waiting for ${delay}ms or ${(delay/1000).toFixed(3)}s before next request.`));
|
||||
@@ -681,7 +493,7 @@ const handler = async function (argv) {
|
||||
results.push({
|
||||
...result,
|
||||
runtime: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
|
||||
suitename: bruFilepath.replace('.bru', '')
|
||||
suitename: pathname.replace('.bru', '')
|
||||
});
|
||||
|
||||
if (reporterSkipAllHeaders) {
|
||||
@@ -739,7 +551,7 @@ const handler = async function (argv) {
|
||||
if (nextRequestName === null) {
|
||||
break;
|
||||
}
|
||||
const nextRequestIdx = bruJsons.findIndex((iter) => iter.bruJson.name === nextRequestName);
|
||||
const nextRequestIdx = requestItems.findIndex((iter) => iter.name === nextRequestName);
|
||||
if (nextRequestIdx >= 0) {
|
||||
currentRequestIndex = nextRequestIdx;
|
||||
} else {
|
||||
|
||||
@@ -156,6 +156,37 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
delete request.basicAuth;
|
||||
}
|
||||
|
||||
if (request?.oauth2?.grantType) {
|
||||
switch (request.oauth2.grantType) {
|
||||
case 'password':
|
||||
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
|
||||
request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
|
||||
request.oauth2.username = _interpolate(request.oauth2.username) || '';
|
||||
request.oauth2.password = _interpolate(request.oauth2.password) || '';
|
||||
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
|
||||
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
|
||||
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
|
||||
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
|
||||
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
|
||||
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
|
||||
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
|
||||
break;
|
||||
case 'client_credentials':
|
||||
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
|
||||
request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
|
||||
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
|
||||
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
|
||||
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
|
||||
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
|
||||
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
|
||||
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
|
||||
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.awsv4config) {
|
||||
request.awsv4config.accessKeyId = _interpolate(request.awsv4config.accessKeyId) || '';
|
||||
request.awsv4config.secretAccessKey = _interpolate(request.awsv4config.secretAccessKey) || '';
|
||||
|
||||
6
packages/bruno-cli/src/runner/oauth2.js
Normal file
6
packages/bruno-cli/src/runner/oauth2.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const { getOAuth2Token } = require('@usebruno/requests');
|
||||
const tokenStore = require('./tokenStore');
|
||||
|
||||
module.exports = {
|
||||
getOAuth2Token: (oauth2Config) => getOAuth2Token(oauth2Config, tokenStore)
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
const { get, each, filter } = require('lodash');
|
||||
const decomment = require('decomment');
|
||||
const crypto = require('node:crypto');
|
||||
const { mergeHeaders, mergeScripts, mergeVars, getTreePathFromCollectionToItem } = require('../utils/collection');
|
||||
const { mergeHeaders, mergeScripts, mergeVars, mergeAuth, getTreePathFromCollectionToItem } = require('../utils/collection');
|
||||
const { createFormData } = require('../utils/form-data');
|
||||
|
||||
const prepareRequest = (item = {}, collection = {}) => {
|
||||
@@ -16,6 +16,7 @@ const prepareRequest = (item = {}, collection = {}) => {
|
||||
mergeHeaders(collection, request, requestTreePath);
|
||||
mergeScripts(collection, request, requestTreePath, scriptFlow);
|
||||
mergeVars(collection, request, requestTreePath);
|
||||
mergeAuth(collection, request, requestTreePath);
|
||||
}
|
||||
|
||||
each(get(request, 'headers', []), (h) => {
|
||||
@@ -72,6 +73,76 @@ const prepareRequest = (item = {}, collection = {}) => {
|
||||
password: get(collectionAuth, 'digest.password')
|
||||
};
|
||||
}
|
||||
|
||||
if (collectionAuth.mode === 'oauth2') {
|
||||
const grantType = get(collectionAuth, 'oauth2.grantType');
|
||||
|
||||
if (grantType === 'client_credentials') {
|
||||
axiosRequest.oauth2 = {
|
||||
grantType,
|
||||
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
|
||||
clientId: get(collectionAuth, 'oauth2.clientId'),
|
||||
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
|
||||
scope: get(collectionAuth, 'oauth2.scope'),
|
||||
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
|
||||
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
|
||||
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
|
||||
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
|
||||
};
|
||||
} else if (grantType === 'password') {
|
||||
axiosRequest.oauth2 = {
|
||||
grantType,
|
||||
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
|
||||
username: get(collectionAuth, 'oauth2.username'),
|
||||
password: get(collectionAuth, 'oauth2.password'),
|
||||
clientId: get(collectionAuth, 'oauth2.clientId'),
|
||||
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
|
||||
scope: get(collectionAuth, 'oauth2.scope'),
|
||||
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
|
||||
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
|
||||
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
|
||||
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
|
||||
};
|
||||
}
|
||||
}
|
||||
if (collectionAuth.mode === 'awsv4') {
|
||||
axiosRequest.awsv4config = {
|
||||
accessKeyId: get(collectionAuth, 'awsv4.accessKeyId'),
|
||||
secretAccessKey: get(collectionAuth, 'awsv4.secretAccessKey'),
|
||||
sessionToken: get(collectionAuth, 'awsv4.sessionToken'),
|
||||
service: get(collectionAuth, 'awsv4.service'),
|
||||
region: get(collectionAuth, 'awsv4.region'),
|
||||
profileName: get(collectionAuth, 'awsv4.profileName')
|
||||
};
|
||||
}
|
||||
|
||||
if (collectionAuth.mode === 'ntlm') {
|
||||
axiosRequest.ntlmConfig = {
|
||||
username: get(collectionAuth, 'ntlm.username'),
|
||||
password: get(collectionAuth, 'ntlm.password'),
|
||||
domain: get(collectionAuth, 'ntlm.domain')
|
||||
};
|
||||
}
|
||||
|
||||
if (collectionAuth.mode === 'wsse') {
|
||||
const username = get(collectionAuth, 'wsse.username', '');
|
||||
const password = get(collectionAuth, 'wsse.password', '');
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const nonce = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Create the password digest using SHA-1 as required for WSSE
|
||||
const hash = crypto.createHash('sha1');
|
||||
hash.update(nonce + ts + password);
|
||||
const digest = Buffer.from(hash.digest('hex').toString('utf8')).toString('base64');
|
||||
|
||||
// Construct the WSSE header
|
||||
axiosRequest.headers[
|
||||
'X-WSSE'
|
||||
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Nonce="${nonce}", Created="${ts}"`;
|
||||
}
|
||||
|
||||
console.log('axiosRequest', axiosRequest);
|
||||
}
|
||||
|
||||
if (request.auth && request.auth.mode !== 'inherit') {
|
||||
@@ -129,6 +200,56 @@ const prepareRequest = (item = {}, collection = {}) => {
|
||||
password: get(request, 'auth.digest.password')
|
||||
};
|
||||
}
|
||||
|
||||
if (request.auth.mode === 'oauth2') {
|
||||
const grantType = get(request, 'auth.oauth2.grantType');
|
||||
|
||||
if (grantType === 'client_credentials') {
|
||||
axiosRequest.oauth2 = {
|
||||
grantType,
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
clientSecret: get(request, 'auth.oauth2.clientSecret'),
|
||||
scope: get(request, 'auth.oauth2.scope'),
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
|
||||
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
|
||||
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
|
||||
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
|
||||
};
|
||||
} else if (grantType === 'password') {
|
||||
axiosRequest.oauth2 = {
|
||||
grantType,
|
||||
username: get(request, 'auth.oauth2.username'),
|
||||
password: get(request, 'auth.oauth2.password'),
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
clientSecret: get(request, 'auth.oauth2.clientSecret'),
|
||||
scope: get(request, 'auth.oauth2.scope'),
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
|
||||
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
|
||||
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
|
||||
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (request.auth.mode === 'apikey') {
|
||||
if (request.auth.apikey?.placement === 'header') {
|
||||
axiosRequest.headers[request.auth.apikey?.key] = request.auth.apikey?.value;
|
||||
}
|
||||
|
||||
if (request.auth.apikey?.placement === 'queryparams') {
|
||||
if (axiosRequest.url && request.auth.apikey?.key) {
|
||||
try {
|
||||
const urlObj = new URL(request.url);
|
||||
urlObj.searchParams.set(request.auth.apikey?.key, request.auth.apikey?.value);
|
||||
axiosRequest.url = urlObj.toString();
|
||||
} catch (error) {
|
||||
console.error('Invalid URL:', request.url, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request.body = request.body || {};
|
||||
|
||||
@@ -22,6 +22,7 @@ const path = require('path');
|
||||
const { parseDataFromResponse } = require('../utils/common');
|
||||
const { getCookieStringForUrl, saveCookies, shouldUseCookies } = require('../utils/cookies');
|
||||
const { createFormData } = require('../utils/form-data');
|
||||
const { getOAuth2Token } = require('./oauth2');
|
||||
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
|
||||
const { NtlmClient } = require('axios-ntlm');
|
||||
const { addDigestInterceptor } = require('@usebruno/requests');
|
||||
@@ -31,8 +32,7 @@ const onConsoleLog = (type, args) => {
|
||||
};
|
||||
|
||||
const runSingleRequest = async function (
|
||||
filename,
|
||||
bruJson,
|
||||
item,
|
||||
collectionPath,
|
||||
runtimeVariables,
|
||||
envVariables,
|
||||
@@ -43,14 +43,12 @@ const runSingleRequest = async function (
|
||||
collection,
|
||||
runSingleRequestByPathname
|
||||
) {
|
||||
const { pathname: itemPathname } = item;
|
||||
const relativeItemPathname = path.relative(collectionPath, itemPathname);
|
||||
try {
|
||||
let request;
|
||||
let nextRequestName;
|
||||
let shouldStopRunnerExecution = false;
|
||||
let item = {
|
||||
pathname: path.join(collectionPath, filename),
|
||||
...bruJson
|
||||
}
|
||||
request = prepareRequest(item, collection);
|
||||
|
||||
request.__bruno__executionMode = 'cli';
|
||||
@@ -84,7 +82,7 @@ const runSingleRequest = async function (
|
||||
if (result?.skipRequest) {
|
||||
return {
|
||||
test: {
|
||||
filename: filename
|
||||
filename: relativeItemPathname
|
||||
},
|
||||
request: {
|
||||
method: request.method,
|
||||
@@ -98,7 +96,8 @@ const runSingleRequest = async function (
|
||||
data: null,
|
||||
responseTime: 0
|
||||
},
|
||||
error: 'Request has been skipped from pre-request script',
|
||||
error: null,
|
||||
status: 'skipped',
|
||||
skipped: true,
|
||||
assertionResults: [],
|
||||
testResults: [],
|
||||
@@ -305,6 +304,33 @@ const runSingleRequest = async function (
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OAuth2 authentication
|
||||
if (request.oauth2) {
|
||||
try {
|
||||
const token = await getOAuth2Token(request.oauth2);
|
||||
if (token) {
|
||||
const { tokenPlacement = 'header', tokenHeaderPrefix = 'Bearer', tokenQueryKey = 'access_token' } = request.oauth2;
|
||||
|
||||
if (tokenPlacement === 'header') {
|
||||
request.headers['Authorization'] = `${tokenHeaderPrefix} ${token}`;
|
||||
} else if (tokenPlacement === 'url') {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.set(tokenQueryKey, token);
|
||||
request.url = url.toString();
|
||||
} catch (error) {
|
||||
console.error('Error applying OAuth2 token to URL:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('OAuth2 token fetch error:', error.message);
|
||||
}
|
||||
|
||||
// Remove oauth2 config from request to prevent it from being sent
|
||||
delete request.oauth2;
|
||||
}
|
||||
|
||||
let response, responseTime;
|
||||
try {
|
||||
|
||||
@@ -362,10 +388,10 @@ const runSingleRequest = async function (
|
||||
responseTime = response.headers.get('request-duration');
|
||||
response.headers.delete('request-duration');
|
||||
} else {
|
||||
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
|
||||
console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`));
|
||||
return {
|
||||
test: {
|
||||
filename: filename
|
||||
filename: relativeItemPathname
|
||||
},
|
||||
request: {
|
||||
method: request.method,
|
||||
@@ -374,13 +400,14 @@ const runSingleRequest = async function (
|
||||
data: request.data
|
||||
},
|
||||
response: {
|
||||
status: null,
|
||||
status: 'error',
|
||||
statusText: null,
|
||||
headers: null,
|
||||
data: null,
|
||||
responseTime: 0
|
||||
},
|
||||
error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!',
|
||||
status: 'error',
|
||||
assertionResults: [],
|
||||
testResults: [],
|
||||
nextRequestName: nextRequestName,
|
||||
@@ -392,12 +419,12 @@ const runSingleRequest = async function (
|
||||
response.responseTime = responseTime;
|
||||
|
||||
console.log(
|
||||
chalk.green(stripExtension(filename)) +
|
||||
chalk.green(stripExtension(relativeItemPathname)) +
|
||||
chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`)
|
||||
);
|
||||
|
||||
// run post-response vars
|
||||
const postResponseVars = get(bruJson, 'request.vars.res');
|
||||
const postResponseVars = get(item, 'request.vars.res');
|
||||
if (postResponseVars?.length) {
|
||||
const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
|
||||
varsRuntime.runPostResponseVars(
|
||||
@@ -438,7 +465,7 @@ const runSingleRequest = async function (
|
||||
|
||||
// run assertions
|
||||
let assertionResults = [];
|
||||
const assertions = get(bruJson, 'request.assertions');
|
||||
const assertions = get(item, 'request.assertions');
|
||||
if (assertions) {
|
||||
const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
|
||||
assertionResults = assertRuntime.runAssertions(
|
||||
@@ -500,7 +527,7 @@ const runSingleRequest = async function (
|
||||
|
||||
return {
|
||||
test: {
|
||||
filename: filename
|
||||
filename: relativeItemPathname
|
||||
},
|
||||
request: {
|
||||
method: request.method,
|
||||
@@ -516,16 +543,17 @@ const runSingleRequest = async function (
|
||||
responseTime
|
||||
},
|
||||
error: null,
|
||||
status: 'pass',
|
||||
assertionResults,
|
||||
testResults,
|
||||
nextRequestName: nextRequestName,
|
||||
shouldStopRunnerExecution
|
||||
};
|
||||
} catch (err) {
|
||||
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
|
||||
console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`));
|
||||
return {
|
||||
test: {
|
||||
filename: filename
|
||||
filename: relativeItemPathname
|
||||
},
|
||||
request: {
|
||||
method: null,
|
||||
@@ -534,12 +562,13 @@ const runSingleRequest = async function (
|
||||
data: null
|
||||
},
|
||||
response: {
|
||||
status: null,
|
||||
status: 'error',
|
||||
statusText: null,
|
||||
headers: null,
|
||||
data: null,
|
||||
responseTime: 0
|
||||
},
|
||||
status: 'error',
|
||||
error: err.message,
|
||||
assertionResults: [],
|
||||
testResults: []
|
||||
|
||||
22
packages/bruno-cli/src/runner/tokenStore.js
Normal file
22
packages/bruno-cli/src/runner/tokenStore.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// In-memory token store implementation for OAuth2 tokens
|
||||
const tokenStore = {
|
||||
tokens: new Map(),
|
||||
|
||||
// Save a token with optional expiry information
|
||||
async saveToken(serviceId, account, token) {
|
||||
this.tokens.set(`${serviceId}:${account}`, token);
|
||||
return true;
|
||||
},
|
||||
|
||||
// Get a token
|
||||
async getToken(serviceId, account) {
|
||||
return this.tokens.get(`${serviceId}:${account}`);
|
||||
},
|
||||
|
||||
// Delete a token
|
||||
async deleteToken(serviceId, account) {
|
||||
return this.tokens.delete(`${serviceId}:${account}`);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = tokenStore;
|
||||
@@ -15,6 +15,17 @@ const collectionBruToJson = (bru) => {
|
||||
}
|
||||
};
|
||||
|
||||
// add meta if it exists
|
||||
// this is only for folder bru file
|
||||
// in the future, all of this will be replaced by standard bru lang
|
||||
const sequence = _.get(json, 'meta.seq');
|
||||
if (json?.meta) {
|
||||
transformedJson.meta = {
|
||||
name: json.meta.name,
|
||||
seq: !isNaN(sequence) ? Number(sequence) : 1
|
||||
};
|
||||
}
|
||||
|
||||
return transformedJson;
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
|
||||
@@ -1,5 +1,115 @@
|
||||
const { get, each, find, compact } = require('lodash');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { jsonToBruV2, envJsonToBruV2, jsonToCollectionBru } = require('@usebruno/lang');
|
||||
const { sanitizeName } = require('./filesystem');
|
||||
const { bruToJson, collectionBruToJson } = require('./bru');
|
||||
const constants = require('../constants');
|
||||
const chalk = require('chalk');
|
||||
|
||||
const createCollectionJsonFromPathname = (collectionPath) => {
|
||||
const environmentsPath = path.join(collectionPath, `environments`);
|
||||
|
||||
// get the collection bruno json config [<collection-path>/bruno.json]
|
||||
const brunoConfig = getCollectionBrunoJsonConfig(collectionPath);
|
||||
|
||||
// get the collection root [<collection-path>/collection.bru]
|
||||
const collectionRoot = getCollectionRoot(collectionPath);
|
||||
|
||||
// get the collection items recursively
|
||||
const traverse = (currentPath) => {
|
||||
const filesInCurrentDir = fs.readdirSync(currentPath);
|
||||
if (currentPath.includes('node_modules')) {
|
||||
return;
|
||||
}
|
||||
const currentDirItems = [];
|
||||
for (const file of filesInCurrentDir) {
|
||||
const filePath = path.join(currentPath, file);
|
||||
const stats = fs.lstatSync(filePath);
|
||||
if (stats.isDirectory()) {
|
||||
if (filePath === environmentsPath) continue;
|
||||
if (filePath.startsWith('.git') || filePath.startsWith('node_modules')) continue;
|
||||
|
||||
// get the folder root
|
||||
let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) }
|
||||
const folderBruJson = getFolderRoot(filePath);
|
||||
if (folderBruJson) {
|
||||
folderItem.root = folderBruJson;
|
||||
folderItem.seq = folderBruJson.meta.seq;
|
||||
}
|
||||
currentDirItems.push(folderItem);
|
||||
}
|
||||
else {
|
||||
if (['collection.bru', 'folder.bru'].includes(file)) continue;
|
||||
if (path.extname(filePath) !== '.bru') continue;
|
||||
|
||||
// get the request item
|
||||
const bruContent = fs.readFileSync(filePath, 'utf8');
|
||||
const requestItem = bruToJson(bruContent);
|
||||
currentDirItems.push({
|
||||
name: file,
|
||||
pathname: filePath,
|
||||
...requestItem
|
||||
});
|
||||
}
|
||||
}
|
||||
let currentDirFolderItems = currentDirItems?.filter((iter) => iter.type === 'folder');
|
||||
let sortedFolderItems = currentDirFolderItems?.sort((a, b) => a.seq - b.seq);
|
||||
|
||||
let currentDirRequestItems = currentDirItems?.filter((iter) => iter.type !== 'folder');
|
||||
let sortedRequestItems = currentDirRequestItems?.sort((a, b) => a.seq - b.seq);
|
||||
|
||||
return sortedFolderItems?.concat(sortedRequestItems);
|
||||
};
|
||||
let collectionItems = traverse(collectionPath);
|
||||
|
||||
let collection = {
|
||||
brunoConfig,
|
||||
root: collectionRoot,
|
||||
pathname: collectionPath,
|
||||
items: collectionItems
|
||||
}
|
||||
|
||||
return collection;
|
||||
};
|
||||
|
||||
const getCollectionBrunoJsonConfig = (dir) => {
|
||||
// right now, bru must be run from the root of the collection
|
||||
// will add support in the future to run it from anywhere inside the collection
|
||||
const brunoJsonPath = path.join(dir, 'bruno.json');
|
||||
const brunoJsonExists = fs.existsSync(brunoJsonPath);
|
||||
if (!brunoJsonExists) {
|
||||
console.error(chalk.red(`You can run only at the root of a collection`));
|
||||
process.exit(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
|
||||
}
|
||||
|
||||
const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
|
||||
const brunoConfig = JSON.parse(brunoConfigFile);
|
||||
return brunoConfig;
|
||||
}
|
||||
|
||||
const getCollectionRoot = (dir) => {
|
||||
const collectionRootPath = path.join(dir, 'collection.bru');
|
||||
const exists = fs.existsSync(collectionRootPath);
|
||||
if (!exists) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(collectionRootPath, 'utf8');
|
||||
return collectionBruToJson(content);
|
||||
};
|
||||
|
||||
const getFolderRoot = (dir) => {
|
||||
const folderRootPath = path.join(dir, 'folder.bru');
|
||||
const exists = fs.existsSync(folderRootPath);
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(folderRootPath, 'utf8');
|
||||
return collectionBruToJson(content);
|
||||
};
|
||||
|
||||
const mergeHeaders = (collection, request, requestTreePath) => {
|
||||
let headers = new Map();
|
||||
@@ -200,10 +310,184 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
|
||||
return path;
|
||||
};
|
||||
|
||||
const mergeAuth = (collection, request, requestTreePath) => {
|
||||
let collectionAuth = collection?.root?.request?.auth || { mode: 'none' };
|
||||
let effectiveAuth = collectionAuth;
|
||||
|
||||
for (let i of requestTreePath) {
|
||||
if (i.type === 'folder') {
|
||||
const folderAuth = i?.root?.request?.auth;
|
||||
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
|
||||
effectiveAuth = folderAuth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.auth && request.auth.mode === 'inherit') {
|
||||
request.auth = effectiveAuth;
|
||||
}
|
||||
}
|
||||
|
||||
const getAllRequestsInFolder = (folderItems = [], recursive = true) => {
|
||||
let requests = [];
|
||||
|
||||
if (folderItems && folderItems.length) {
|
||||
folderItems.forEach((item) => {
|
||||
if (item.type !== 'folder') {
|
||||
requests.push(item);
|
||||
} else {
|
||||
if (recursive) {
|
||||
requests = requests.concat(getAllRequestsInFolder(item.items, recursive));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return requests;
|
||||
};
|
||||
|
||||
const getAllRequestsAtFolderRoot = (folderItems = []) => {
|
||||
return getAllRequestsInFolder(folderItems, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe write file implementation to handle errors
|
||||
* @param {string} filePath - Path to write file
|
||||
* @param {string} content - Content to write
|
||||
*/
|
||||
const safeWriteFileSync = (filePath, content) => {
|
||||
try {
|
||||
fs.writeFileSync(filePath, content, { encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
console.error(`Error writing file ${filePath}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a Bruno collection directory structure from a Bruno collection object
|
||||
*
|
||||
* @param {Object} collection - The Bruno collection object
|
||||
* @param {string} dirPath - The output directory path
|
||||
*/
|
||||
const createCollectionFromBrunoObject = async (collection, dirPath) => {
|
||||
// Create bruno.json
|
||||
const brunoConfig = {
|
||||
version: '1',
|
||||
name: collection.name,
|
||||
type: 'collection',
|
||||
ignore: ['node_modules', '.git']
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dirPath, 'bruno.json'),
|
||||
JSON.stringify(brunoConfig, null, 2)
|
||||
);
|
||||
|
||||
// Create collection.bru if root exists
|
||||
if (collection.root) {
|
||||
const collectionContent = await jsonToCollectionBru(collection.root);
|
||||
fs.writeFileSync(path.join(dirPath, 'collection.bru'), collectionContent);
|
||||
}
|
||||
|
||||
// Process environments
|
||||
if (collection.environments && collection.environments.length) {
|
||||
const envDirPath = path.join(dirPath, 'environments');
|
||||
fs.mkdirSync(envDirPath, { recursive: true });
|
||||
|
||||
for (const env of collection.environments) {
|
||||
const content = await envJsonToBruV2(env);
|
||||
const filename = sanitizeName(`${env.name}.bru`);
|
||||
fs.writeFileSync(path.join(envDirPath, filename), content);
|
||||
}
|
||||
}
|
||||
|
||||
// Process collection items
|
||||
await processCollectionItems(collection.items, dirPath);
|
||||
|
||||
return dirPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively processes collection items to create files and folders
|
||||
*
|
||||
* @param {Array} items - Collection items
|
||||
* @param {string} currentPath - Current directory path
|
||||
*/
|
||||
const processCollectionItems = async (items = [], currentPath) => {
|
||||
for (const item of items) {
|
||||
if (item.type === 'folder') {
|
||||
// Create folder
|
||||
let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
|
||||
const folderPath = path.join(currentPath, sanitizedFolderName);
|
||||
fs.mkdirSync(folderPath, { recursive: true });
|
||||
|
||||
// Create folder.bru file if root exists
|
||||
if (item?.root?.meta?.name) {
|
||||
const folderBruFilePath = path.join(folderPath, 'folder.bru');
|
||||
if (item.seq) {
|
||||
item.root.meta.seq = item.seq;
|
||||
}
|
||||
const folderContent = await jsonToCollectionBru(
|
||||
item.root,
|
||||
true
|
||||
);
|
||||
safeWriteFileSync(folderBruFilePath, folderContent);
|
||||
}
|
||||
|
||||
// Process folder items recursively
|
||||
if (item.items && item.items.length) {
|
||||
await processCollectionItems(item.items, folderPath);
|
||||
}
|
||||
} else if (['http-request', 'graphql-request'].includes(item.type)) {
|
||||
// Create request file
|
||||
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`);
|
||||
if (!sanitizedFilename.endsWith('.bru')) {
|
||||
sanitizedFilename += '.bru';
|
||||
}
|
||||
|
||||
// Convert JSON to BRU format based on the item type
|
||||
let type = item.type === 'http-request' ? 'http' : 'graphql';
|
||||
const bruJson = {
|
||||
meta: {
|
||||
name: item.name,
|
||||
type: type,
|
||||
seq: typeof item.seq === 'number' ? item.seq : 1
|
||||
},
|
||||
http: {
|
||||
method: (item.request?.method || 'GET').toLowerCase(),
|
||||
url: item.request?.url || '',
|
||||
auth: item.request?.auth?.mode || 'none',
|
||||
body: item.request?.body?.mode || 'none'
|
||||
},
|
||||
params: item.request?.params || [],
|
||||
headers: item.request?.headers || [],
|
||||
auth: item.request?.auth || {},
|
||||
body: item.request?.body || {},
|
||||
script: item.request?.script || {},
|
||||
vars: {
|
||||
req: item.request?.vars?.req || [],
|
||||
res: item.request?.vars?.res || []
|
||||
},
|
||||
assertions: item.request?.assertions || [],
|
||||
tests: item.request?.tests || '',
|
||||
docs: item.request?.docs || ''
|
||||
};
|
||||
|
||||
// Convert to BRU format and write to file
|
||||
const content = await jsonToBruV2(bruJson);
|
||||
safeWriteFileSync(path.join(currentPath, sanitizedFilename), content);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createCollectionJsonFromPathname,
|
||||
mergeHeaders,
|
||||
mergeVars,
|
||||
mergeScripts,
|
||||
findItemInCollection,
|
||||
getTreePathFromCollectionToItem
|
||||
getTreePathFromCollectionToItem,
|
||||
createCollectionFromBrunoObject,
|
||||
mergeAuth,
|
||||
getAllRequestsInFolder,
|
||||
getAllRequestsAtFolderRoot
|
||||
}
|
||||
@@ -118,6 +118,46 @@ const getSubDirectories = (dir) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes a filename to make it safe for filesystem operations
|
||||
*
|
||||
* @param {string} name - The name to sanitize
|
||||
* @returns {string} - The sanitized name
|
||||
*/
|
||||
const sanitizeName = (name) => {
|
||||
if (!name) return '';
|
||||
|
||||
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g;
|
||||
return name
|
||||
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
|
||||
.replace(/^[.\s-]+/, '') // remove leading dots, hyphens and spaces
|
||||
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if a name is valid for the filesystem
|
||||
*
|
||||
* @param {string} name - The name to validate
|
||||
* @returns {boolean} - True if the name is valid, false otherwise
|
||||
*/
|
||||
const validateName = (name) => {
|
||||
if (!name) return false;
|
||||
|
||||
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
|
||||
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
|
||||
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
|
||||
const lastCharacter = /[^.\s]$/; // no dot or space at end, hyphen allowed
|
||||
|
||||
if (name.length > 255) return false; // max name length
|
||||
if (reservedDeviceNames.test(name)) return false; // windows reserved names
|
||||
|
||||
return (
|
||||
firstCharacter.test(name) &&
|
||||
middleCharacters.test(name) &&
|
||||
lastCharacter.test(name)
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
exists,
|
||||
isSymbolicLink,
|
||||
@@ -131,5 +171,7 @@ module.exports = {
|
||||
searchForFiles,
|
||||
searchForBruFiles,
|
||||
stripExtension,
|
||||
getSubDirectories
|
||||
getSubDirectories,
|
||||
sanitizeName,
|
||||
validateName
|
||||
};
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
const path = require("node:path");
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
const constants = require('../../src/constants');
|
||||
const { createCollectionJsonFromPathname } = require('../../src/utils/collection');
|
||||
|
||||
describe('create collection json from pathname', () => {
|
||||
it("should throw an error when the pathname is not a valid bruno collection root", () => {
|
||||
const invalidCollectionPathname = path.join(__dirname, './fixtures/collection-invalid');
|
||||
jest.spyOn(console, 'error').mockImplementation(() => { });
|
||||
let mockProcessExit = jest.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(code); });
|
||||
try { createCollectionJsonFromPathname(invalidCollectionPathname); } catch { }
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
|
||||
jest.restoreAllMocks();
|
||||
})
|
||||
|
||||
it("creates a bruno collection json from the collection bru files", () => {
|
||||
const collectionPathname = path.join(__dirname, './fixtures/collection-json-from-pathname/collection');
|
||||
const outputCollectionJson = createCollectionJsonFromPathname(collectionPathname);
|
||||
|
||||
let c = outputCollectionJson;
|
||||
expect(c).toBeDefined();
|
||||
|
||||
/* collection bruno.json */
|
||||
expect(c).toHaveProperty('brunoConfig.version', "1");
|
||||
expect(c).toHaveProperty('brunoConfig.name', 'collection');
|
||||
expect(c).toHaveProperty('brunoConfig.type', 'collection');
|
||||
expect(c).toHaveProperty('brunoConfig.ignore', ["node_modules", ".git"]);
|
||||
expect(c).toHaveProperty('brunoConfig.proxy.enabled', false);
|
||||
expect(c).toHaveProperty('brunoConfig.proxy.protocol', 'http');
|
||||
expect(c).toHaveProperty('brunoConfig.proxy.hostname', '<proxy-hostname>');
|
||||
expect(c).toHaveProperty('brunoConfig.proxy.port', 3000);
|
||||
expect(c).toHaveProperty('brunoConfig.proxy.auth.enabled', false);
|
||||
expect(c).toHaveProperty('brunoConfig.proxy.auth.username', '<user-name>');
|
||||
expect(c).toHaveProperty('brunoConfig.proxy.auth.password', '<password>');
|
||||
expect(c).toHaveProperty('brunoConfig.proxy.bypassProxy', '');
|
||||
expect(c).toHaveProperty('brunoConfig.scripts.moduleWhitelist', ['crypto', 'buffer']);
|
||||
expect(c).toHaveProperty('brunoConfig.scripts.filesystemAccess.allow', true);
|
||||
expect(c).toHaveProperty('brunoConfig.clientCertificates.enabled', true);
|
||||
expect(c).toHaveProperty('brunoConfig.clientCertificates.certs', []);
|
||||
|
||||
/* collection pathname */
|
||||
expect(c).toHaveProperty('pathname', collectionPathname);
|
||||
|
||||
/* collection root */
|
||||
// headers
|
||||
expect(c).toHaveProperty('root.request.headers[0].name', 'collection_header');
|
||||
expect(c).toHaveProperty('root.request.headers[0].value', 'collection_header_value');
|
||||
expect(c).toHaveProperty('root.request.headers[0].enabled', true);
|
||||
// auth
|
||||
expect(c).toHaveProperty('root.request.auth.mode', 'basic');
|
||||
expect(c).toHaveProperty('root.request.auth.basic.username', 'username');
|
||||
expect(c).toHaveProperty('root.request.auth.basic.password', 'password');
|
||||
// pre-request scripts
|
||||
expect(c).toHaveProperty('root.request.script.req', 'const collectionPreRequestScript = true;');
|
||||
// collection root - post-response scripts
|
||||
expect(c).toHaveProperty('root.request.script.res', 'const collectionPostResponseScript = true;');
|
||||
// pre-request vars
|
||||
expect(c).toHaveProperty('root.request.vars.req[0].name', 'collection_pre_var');
|
||||
expect(c).toHaveProperty('root.request.vars.req[0].value', 'collection_pre_var_value');
|
||||
expect(c).toHaveProperty('root.request.vars.req[0].enabled', true);
|
||||
// post-response vars
|
||||
expect(c).toHaveProperty('root.request.vars.res[0].name', 'collection_post_var');
|
||||
expect(c).toHaveProperty('root.request.vars.res[0].value', 'collection_post_var_value');
|
||||
expect(c).toHaveProperty('root.request.vars.res[0].enabled', true);
|
||||
// tests
|
||||
expect(c).toHaveProperty('root.request.tests', 'test(\"collection level script\", function() {\n expect(\"test\").to.equal(\"test\");\n});');
|
||||
|
||||
/* collection items names and sequences */
|
||||
// <collection-root>/folder_2
|
||||
expect(c).toHaveProperty('items[0].type', 'folder');
|
||||
expect(c).toHaveProperty('items[0].name', 'folder_2');
|
||||
expect(c).toHaveProperty('items[0].seq', 1);
|
||||
|
||||
// <collection-root>/folder_2/request_1
|
||||
expect(c).toHaveProperty('items[0].items[0].name', 'request_1');
|
||||
expect(c).toHaveProperty('items[0].items[0].seq', 1);
|
||||
|
||||
// <collection-root>/folder_2/request_3
|
||||
expect(c).toHaveProperty('items[0].items[1].name', 'request_3');
|
||||
expect(c).toHaveProperty('items[0].items[1].seq', 2);
|
||||
|
||||
// <collection-root>/folder_2/request_2
|
||||
expect(c).toHaveProperty('items[0].items[2].name', 'request_2');
|
||||
expect(c).toHaveProperty('items[0].items[2].seq', 3);
|
||||
|
||||
// <collection-root>/folder_1
|
||||
expect(c).toHaveProperty('items[1].type', 'folder');
|
||||
expect(c).toHaveProperty('items[1].name', 'folder_1');
|
||||
expect(c).toHaveProperty('items[1].seq', 5);
|
||||
|
||||
// <collection-root>/folder_1/folder_2
|
||||
expect(c).toHaveProperty('items[1].items[0].name', 'folder_2');
|
||||
expect(c).toHaveProperty('items[1].items[0].seq', 1);
|
||||
|
||||
// <collection-root>/folder_1/folder_2/request_3
|
||||
expect(c).toHaveProperty('items[1].items[0].items[0].name', 'request_3');
|
||||
expect(c).toHaveProperty('items[1].items[0].items[0].seq', 1);
|
||||
|
||||
// <collection-root>/folder_1/folder_2/request_1
|
||||
expect(c).toHaveProperty('items[1].items[0].items[1].name', 'request_1');
|
||||
expect(c).toHaveProperty('items[1].items[0].items[1].seq', 2);
|
||||
|
||||
// <collection-root>/folder_1/folder_2/request_2
|
||||
expect(c).toHaveProperty('items[1].items[0].items[2].name', 'request_2');
|
||||
expect(c).toHaveProperty('items[1].items[0].items[2].seq', 3);
|
||||
|
||||
// <collection-root>/folder_1/folder_1
|
||||
expect(c).toHaveProperty('items[1].items[1].name', 'folder_1');
|
||||
expect(c).toHaveProperty('items[1].items[1].seq', 2);
|
||||
|
||||
// <collection-root>/folder_1/folder_1/request_3
|
||||
expect(c).toHaveProperty('items[1].items[1].items[0].name', 'request_3');
|
||||
expect(c).toHaveProperty('items[1].items[1].items[0].seq', 1);
|
||||
|
||||
// <collection-root>/folder_1/folder_1/request_2
|
||||
expect(c).toHaveProperty('items[1].items[1].items[1].name', 'request_2');
|
||||
expect(c).toHaveProperty('items[1].items[1].items[1].seq', 2);
|
||||
|
||||
// <collection-root>/folder_1/folder_1/request_1
|
||||
expect(c).toHaveProperty('items[1].items[1].items[2].name', 'request_1');
|
||||
expect(c).toHaveProperty('items[1].items[1].items[2].seq', 3);
|
||||
|
||||
// <collection-root>/folder_1/request_1
|
||||
expect(c).toHaveProperty('items[1].items[2].name', 'request_1');
|
||||
expect(c).toHaveProperty('items[1].items[2].seq', 3);
|
||||
|
||||
// <collection-root>/folder_1/request_3
|
||||
expect(c).toHaveProperty('items[1].items[3].name', 'request_3');
|
||||
expect(c).toHaveProperty('items[1].items[3].seq', 4);
|
||||
|
||||
// <collection-root>/folder_1/request_2
|
||||
expect(c).toHaveProperty('items[1].items[4].name', 'request_2');
|
||||
expect(c).toHaveProperty('items[1].items[4].seq', 5);
|
||||
|
||||
// <collection-root>/request_2
|
||||
expect(c).toHaveProperty('items[2].name', 'request_3');
|
||||
expect(c).toHaveProperty('items[2].seq', 2);
|
||||
|
||||
// <collection-root>/request_3
|
||||
expect(c).toHaveProperty('items[3].name', 'request_1');
|
||||
expect(c).toHaveProperty('items[3].seq', 3);
|
||||
|
||||
// <collection-root>/request_4
|
||||
expect(c).toHaveProperty('items[4].name', 'request_2');
|
||||
expect(c).toHaveProperty('items[4].seq', 4);
|
||||
|
||||
/* collection request item - <collection-root>/request_4 */
|
||||
// <collection-root>/request_4
|
||||
// headers
|
||||
expect(c).toHaveProperty('items[4].request.headers[0].name', 'request_header');
|
||||
expect(c).toHaveProperty('items[4].request.headers[0].value', 'request_header_value');
|
||||
expect(c).toHaveProperty('items[4].request.headers[0].enabled', true);
|
||||
// auth
|
||||
expect(c).toHaveProperty('items[4].request.auth.mode', 'basic');
|
||||
expect(c).toHaveProperty('items[4].request.auth.basic.username', 'username');
|
||||
expect(c).toHaveProperty('items[4].request.auth.basic.password', 'password');
|
||||
// pre-request scripts
|
||||
expect(c).toHaveProperty('items[4].request.script.req', 'const requestPreRequestScript = true;');
|
||||
// request items[4] - post-response scripts
|
||||
expect(c).toHaveProperty('items[4].request.script.res', 'const requestPostResponseScript = true;');
|
||||
// pre-request vars
|
||||
expect(c).toHaveProperty('items[4].request.vars.req[0].name', 'request_pre_var');
|
||||
expect(c).toHaveProperty('items[4].request.vars.req[0].value', 'request_pre_var_value');
|
||||
expect(c).toHaveProperty('items[4].request.vars.req[0].enabled', true);
|
||||
// post-response vars
|
||||
expect(c).toHaveProperty('items[4].request.vars.res[0].name', 'request_post_var');
|
||||
expect(c).toHaveProperty('items[4].request.vars.res[0].value', 'request_post_var_value');
|
||||
expect(c).toHaveProperty('items[4].request.vars.res[0].enabled', true);
|
||||
// tests
|
||||
expect(c).toHaveProperty('items[4].request.tests', 'test(\"request level script\", function() {\n expect(\"test\").to.equal(\"test\");\n});');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "collection",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
],
|
||||
"proxy": {
|
||||
"enabled": false,
|
||||
"protocol": "http",
|
||||
"hostname": "<proxy-hostname>",
|
||||
"port": 3000,
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
"username": "<user-name>",
|
||||
"password": "<password>"
|
||||
},
|
||||
"bypassProxy": ""
|
||||
},
|
||||
"scripts": {
|
||||
"moduleWhitelist": ["crypto", "buffer"],
|
||||
"filesystemAccess": {
|
||||
"allow": true
|
||||
}
|
||||
},
|
||||
"clientCertificates": {
|
||||
"enabled": true,
|
||||
"certs": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
headers {
|
||||
collection_header: collection_header_value
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: basic
|
||||
}
|
||||
|
||||
auth:basic {
|
||||
username: username
|
||||
password: password
|
||||
}
|
||||
|
||||
vars:pre-request {
|
||||
collection_pre_var: collection_pre_var_value
|
||||
}
|
||||
|
||||
vars:post-response {
|
||||
collection_post_var: collection_post_var_value
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const collectionPreRequestScript = true;
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
const collectionPostResponseScript = true;
|
||||
}
|
||||
|
||||
tests {
|
||||
test("collection level script", function() {
|
||||
expect("test").to.equal("test");
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
# docs
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
meta {
|
||||
name: folder_1
|
||||
seq: 5
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
meta {
|
||||
name: folder_1
|
||||
seq: 2
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_1
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_2
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_3
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
meta {
|
||||
name: folder_2
|
||||
seq: 1
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_1
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_2
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_3
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_1
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_2
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_3
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
meta {
|
||||
name: folder_2
|
||||
seq: 1
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_1
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_2
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_3
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_1
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
meta {
|
||||
name: request_2
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://echo.usebruno.com/:request_path_param?request_query_param=request_query_param_value
|
||||
body: text
|
||||
auth: basic
|
||||
}
|
||||
|
||||
params:query {
|
||||
request_query_param: request_query_param_value
|
||||
}
|
||||
|
||||
params:path {
|
||||
request_path_param: request_path_param_value
|
||||
}
|
||||
|
||||
headers {
|
||||
request_header: request_header_value
|
||||
}
|
||||
|
||||
auth:basic {
|
||||
username: username
|
||||
password: password
|
||||
}
|
||||
|
||||
body:text {
|
||||
ping
|
||||
}
|
||||
|
||||
vars:pre-request {
|
||||
request_pre_var: request_pre_var_value
|
||||
}
|
||||
|
||||
vars:post-response {
|
||||
request_post_var: request_post_var_value
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 200
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const requestPreRequestScript = true;
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
const requestPostResponseScript = true;
|
||||
}
|
||||
|
||||
tests {
|
||||
test("request level script", function() {
|
||||
expect("test").to.equal("test");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request_3
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -150,6 +150,159 @@ describe('prepare-request: prepareRequest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth2 Authentication', () => {
|
||||
it('If collection auth is OAuth2 with client credentials grant type', () => {
|
||||
collection.root.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: 'https://auth.example.com/token',
|
||||
clientId: 'test_client_id',
|
||||
clientSecret: 'test_client_secret',
|
||||
scope: 'read write',
|
||||
credentialsPlacement: 'header',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token'
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item, collection);
|
||||
|
||||
expect(result.oauth2).toBeDefined();
|
||||
expect(result.oauth2.grantType).toBe('client_credentials');
|
||||
expect(result.oauth2.accessTokenUrl).toBe('https://auth.example.com/token');
|
||||
expect(result.oauth2.clientId).toBe('test_client_id');
|
||||
expect(result.oauth2.clientSecret).toBe('test_client_secret');
|
||||
expect(result.oauth2.scope).toBe('read write');
|
||||
expect(result.oauth2.credentialsPlacement).toBe('header');
|
||||
expect(result.oauth2.tokenPlacement).toBe('header');
|
||||
expect(result.oauth2.tokenHeaderPrefix).toBe('Bearer');
|
||||
expect(result.oauth2.tokenQueryKey).toBe('access_token');
|
||||
});
|
||||
|
||||
it('If collection auth is OAuth2 with password grant type', () => {
|
||||
collection.root.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'password',
|
||||
accessTokenUrl: 'https://auth.example.com/token',
|
||||
username: 'test_user',
|
||||
password: 'test_password',
|
||||
clientId: 'test_client_id',
|
||||
clientSecret: 'test_client_secret',
|
||||
scope: 'read write',
|
||||
credentialsPlacement: 'body',
|
||||
tokenPlacement: 'url',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token'
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item, collection);
|
||||
|
||||
expect(result.oauth2).toBeDefined();
|
||||
expect(result.oauth2.grantType).toBe('password');
|
||||
expect(result.oauth2.accessTokenUrl).toBe('https://auth.example.com/token');
|
||||
expect(result.oauth2.username).toBe('test_user');
|
||||
expect(result.oauth2.password).toBe('test_password');
|
||||
expect(result.oauth2.clientId).toBe('test_client_id');
|
||||
expect(result.oauth2.clientSecret).toBe('test_client_secret');
|
||||
expect(result.oauth2.scope).toBe('read write');
|
||||
expect(result.oauth2.credentialsPlacement).toBe('body');
|
||||
expect(result.oauth2.tokenPlacement).toBe('url');
|
||||
expect(result.oauth2.tokenHeaderPrefix).toBe('Bearer');
|
||||
expect(result.oauth2.tokenQueryKey).toBe('access_token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AWS v4 Authentication', () => {
|
||||
it('If collection auth is AWS v4', () => {
|
||||
collection.root.request.auth = {
|
||||
mode: 'awsv4',
|
||||
awsv4: {
|
||||
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
||||
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
sessionToken: 'session-token',
|
||||
service: 's3',
|
||||
region: 'us-west-2',
|
||||
profileName: 'default'
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item, collection);
|
||||
const expected = {
|
||||
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
||||
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
sessionToken: 'session-token',
|
||||
service: 's3',
|
||||
region: 'us-west-2',
|
||||
profileName: 'default'
|
||||
};
|
||||
expect(result.awsv4config).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NTLM Authentication', () => {
|
||||
it('If collection auth is NTLM', () => {
|
||||
collection.root.request.auth = {
|
||||
mode: 'ntlm',
|
||||
ntlm: {
|
||||
username: 'testUser',
|
||||
password: 'testPass123',
|
||||
domain: 'testDomain'
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item, collection);
|
||||
const expected = {
|
||||
username: 'testUser',
|
||||
password: 'testPass123',
|
||||
domain: 'testDomain'
|
||||
};
|
||||
expect(result.ntlmConfig).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WSSE Authentication', () => {
|
||||
it('If collection auth is WSSE', () => {
|
||||
collection.root.request.auth = {
|
||||
mode: 'wsse',
|
||||
wsse: {
|
||||
username: 'testUser',
|
||||
password: 'testPass123'
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item, collection);
|
||||
expect(result.headers).toHaveProperty('X-WSSE');
|
||||
expect(result.headers['X-WSSE']).toContain('UsernameToken Username="testUser"');
|
||||
expect(result.headers['X-WSSE']).toContain('PasswordDigest="');
|
||||
expect(result.headers['X-WSSE']).toContain('Nonce="');
|
||||
expect(result.headers['X-WSSE']).toContain('Created="');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Digest Authentication', () => {
|
||||
it('If collection auth is digest auth', () => {
|
||||
collection.root.request.auth = {
|
||||
mode: 'digest',
|
||||
digest: {
|
||||
username: 'testUser',
|
||||
password: 'testPass123'
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item, collection);
|
||||
|
||||
const expected = {
|
||||
username: 'testUser',
|
||||
password: 'testPass123'
|
||||
};
|
||||
expect(result.digestConfig).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('No Authentication', () => {
|
||||
it('If request does not have auth configured', () => {
|
||||
delete item.request.auth;
|
||||
@@ -161,4 +314,209 @@ describe('prepare-request: prepareRequest', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Properly maps request-level auth', () => {
|
||||
let item;
|
||||
|
||||
beforeEach(() => {
|
||||
item = {
|
||||
name: 'Test Request',
|
||||
type: 'http-request',
|
||||
request: {
|
||||
method: 'GET',
|
||||
headers: [],
|
||||
params: [],
|
||||
url: 'https://usebruno.com',
|
||||
auth: {
|
||||
mode: 'basic' // Will be overridden in each test
|
||||
},
|
||||
script: {
|
||||
req: 'console.log("Pre Request")',
|
||||
res: 'console.log("Post Response")'
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe('API Key Authentication', () => {
|
||||
it('If request auth is apikey in header', () => {
|
||||
item.request.auth = {
|
||||
mode: "apikey",
|
||||
apikey: {
|
||||
key: "x-api-key",
|
||||
value: "{{apiKey}}",
|
||||
placement: "header"
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item);
|
||||
expect(result.headers).toHaveProperty('x-api-key', '{{apiKey}}');
|
||||
});
|
||||
|
||||
it('If request auth is apikey in header and request has existing headers', () => {
|
||||
item.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);
|
||||
expect(result.headers).toHaveProperty('Content-Type', 'application/json');
|
||||
expect(result.headers).toHaveProperty('x-api-key', '{{apiKey}}');
|
||||
});
|
||||
|
||||
it('If request auth is apikey in query parameters', () => {
|
||||
item.request.auth = {
|
||||
mode: "apikey",
|
||||
apikey: {
|
||||
key: "x-api-key",
|
||||
value: "{{apiKey}}",
|
||||
placement: "queryparams"
|
||||
}
|
||||
};
|
||||
|
||||
const urlObj = new URL(item.request.url);
|
||||
urlObj.searchParams.set(item.request.auth.apikey.key, item.request.auth.apikey.value);
|
||||
|
||||
const expected = urlObj.toString();
|
||||
const result = prepareRequest(item);
|
||||
expect(result.url).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basic Authentication', () => {
|
||||
it('If request auth is basic auth', () => {
|
||||
item.request.auth = {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'testUser',
|
||||
password: 'testPass123'
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item);
|
||||
const expected = { username: 'testUser', password: 'testPass123' };
|
||||
expect(result.basicAuth).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bearer Token Authentication', () => {
|
||||
it('If request auth is bearer token', () => {
|
||||
item.request.auth = {
|
||||
mode: 'bearer',
|
||||
bearer: {
|
||||
token: 'token123'
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item);
|
||||
expect(result.headers).toHaveProperty('Authorization', 'Bearer token123');
|
||||
});
|
||||
|
||||
it('If request auth is bearer token and request has existing headers', () => {
|
||||
item.request.auth = {
|
||||
mode: 'bearer',
|
||||
bearer: {
|
||||
token: 'token123'
|
||||
}
|
||||
};
|
||||
|
||||
item.request.headers.push({ name: 'Content-Type', value: 'application/json', enabled: true });
|
||||
|
||||
const result = prepareRequest(item);
|
||||
expect(result.headers).toHaveProperty('Authorization', 'Bearer token123');
|
||||
expect(result.headers).toHaveProperty('Content-Type', 'application/json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AWS v4 Authentication', () => {
|
||||
it('If request auth is AWS v4', () => {
|
||||
item.request.auth = {
|
||||
mode: 'awsv4',
|
||||
awsv4: {
|
||||
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
||||
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
sessionToken: 'request-session-token',
|
||||
service: 'dynamodb',
|
||||
region: 'us-east-1',
|
||||
profileName: 'dev'
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item);
|
||||
const expected = {
|
||||
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
||||
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
sessionToken: 'request-session-token',
|
||||
service: 'dynamodb',
|
||||
region: 'us-east-1',
|
||||
profileName: 'dev'
|
||||
};
|
||||
expect(result.awsv4config).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NTLM Authentication', () => {
|
||||
it('If request auth is NTLM', () => {
|
||||
item.request.auth = {
|
||||
mode: 'ntlm',
|
||||
ntlm: {
|
||||
username: 'testUser',
|
||||
password: 'testPass123',
|
||||
domain: 'testDomain'
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item);
|
||||
const expected = {
|
||||
username: 'testUser',
|
||||
password: 'testPass123',
|
||||
domain: 'testDomain'
|
||||
};
|
||||
expect(result.ntlmConfig).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WSSE Authentication', () => {
|
||||
it('If request auth is WSSE', () => {
|
||||
item.request.auth = {
|
||||
mode: 'wsse',
|
||||
wsse: {
|
||||
username: 'requestUser',
|
||||
password: 'requestPass'
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item);
|
||||
expect(result.headers).toHaveProperty('X-WSSE');
|
||||
expect(result.headers['X-WSSE']).toContain('UsernameToken Username="requestUser"');
|
||||
expect(result.headers['X-WSSE']).toContain('PasswordDigest="');
|
||||
expect(result.headers['X-WSSE']).toContain('Nonce="');
|
||||
expect(result.headers['X-WSSE']).toContain('Created="');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Digest Authentication', () => {
|
||||
it('If request auth is digest auth', () => {
|
||||
item.request.auth = {
|
||||
mode: 'digest',
|
||||
digest: {
|
||||
username: 'requestUser',
|
||||
password: 'requestPass123'
|
||||
}
|
||||
};
|
||||
|
||||
const result = prepareRequest(item);
|
||||
const expected = {
|
||||
username: 'requestUser',
|
||||
password: 'requestPass123'
|
||||
};
|
||||
expect(result.digestConfig).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { T_RunnerResults } from "../../types";
|
||||
import { isHtmlContentType, getContentType, redactImageData, encodeBase64 } from "../../utils";
|
||||
import { getRunnerSummary } from "../../runner-summary";
|
||||
import htmlTemplateString from "./template";
|
||||
|
||||
const generateHtmlReport = ({
|
||||
@@ -8,7 +7,7 @@ const generateHtmlReport = ({
|
||||
}: {
|
||||
runnerResults: T_RunnerResults[]
|
||||
}): string => {
|
||||
const resultsWithSummaryAndCleanData = runnerResults.map(({ iterationIndex, results }) => {
|
||||
const resultsWithSummaryAndCleanData = runnerResults.map(({ iterationIndex, results, summary }) => {
|
||||
return {
|
||||
iterationIndex,
|
||||
results: results.map((result) => {
|
||||
@@ -29,7 +28,7 @@ const generateHtmlReport = ({
|
||||
}
|
||||
}
|
||||
}),
|
||||
summary: getRunnerSummary(results)
|
||||
summary
|
||||
}
|
||||
});
|
||||
const htmlString = htmlTemplateString(encodeBase64(JSON.stringify(resultsWithSummaryAndCleanData)));
|
||||
|
||||
@@ -96,6 +96,7 @@ export type T_RunnerResults = {
|
||||
iterationIndex: number;
|
||||
iterationData?: any; // todo - csv/json row data
|
||||
results: T_RunnerRequestExecutionResult[];
|
||||
summary: T_RunSummary;
|
||||
}
|
||||
|
||||
// run summary type
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user