diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
new file mode 100644
index 000000000..f630b56cb
--- /dev/null
+++ b/.github/workflows/playwright.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index b97cd17e3..9331b13ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,4 +47,7 @@ yarn-error.log*
#dev editor
bruno.iml
.idea
-.vscode
\ No newline at end of file
+.vscode
+
+# Playwright
+/blob-report/
diff --git a/contributing.md b/contributing.md
index 13bbec333..2e91f5220 100644
--- a/contributing.md
+++ b/contributing.md
@@ -48,7 +48,7 @@ Bruno is being developed as a desktop app. You need to load the app by running t
## Install Dependencies
```bash
-# use nodejs 20 version
+# use nodejs 22 version
nvm use
# install deps
diff --git a/e2e-tests/test-app-start.spec.ts b/e2e-tests/test-app-start.spec.ts
new file mode 100644
index 000000000..891c7ce3b
--- /dev/null
+++ b/e2e-tests/test-app-start.spec.ts
@@ -0,0 +1,5 @@
+import { test, expect } from '../playwright';
+
+test('test-app-start', async ({ page }) => {
+ await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
+});
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 8bf023cfd..03a70cccd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,9 +23,10 @@
"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",
@@ -34,6 +35,7 @@
"jest": "^29.2.0",
"lint-staged": "^15.5.2",
"lodash-es": "^4.17.21",
+ "playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
"rimraf": "^6.0.1",
@@ -6105,13 +6107,13 @@
}
},
"node_modules/@playwright/test": {
- "version": "1.49.1",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
- "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz",
+ "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright": "1.49.1"
+ "playwright": "1.52.0"
},
"bin": {
"playwright": "cli.js"
@@ -8290,6 +8292,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
@@ -8312,6 +8315,7 @@
"version": "12.2.3",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz",
"integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "*",
@@ -8322,6 +8326,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
@@ -8332,13 +8337,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.10.2",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
- "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
+ "version": "22.15.17",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz",
+ "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~6.20.0"
+ "undici-types": "~6.21.0"
}
},
"node_modules/@types/plist": {
@@ -8358,6 +8363,11 @@
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
"license": "MIT"
},
+ "node_modules/@types/qs": {
+ "version": "6.9.18",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
+ "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA=="
+ },
"node_modules/@types/react": {
"version": "18.3.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
@@ -13263,6 +13273,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -21300,13 +21311,13 @@
"license": "MIT"
},
"node_modules/playwright": {
- "version": "1.49.1",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
- "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
+ "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.49.1"
+ "playwright-core": "1.52.0"
},
"bin": {
"playwright": "cli.js"
@@ -21319,9 +21330,9 @@
}
},
"node_modules/playwright-core": {
- "version": "1.49.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
- "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
+ "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -26474,7 +26485,7 @@
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -26500,9 +26511,9 @@
}
},
"node_modules/undici-types": {
- "version": "6.20.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
- "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},
@@ -30570,6 +30581,9 @@
"name": "@usebruno/requests",
"version": "0.1.0",
"license": "MIT",
+ "dependencies": {
+ "@types/qs": "^6.9.18"
+ },
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
diff --git a/package.json b/package.json
index 30d41bb01..8d7ee6287 100644
--- a/package.json
+++ b/package.json
@@ -20,9 +20,10 @@
"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",
@@ -31,6 +32,7 @@
"jest": "^29.2.0",
"lint-staged": "^15.5.2",
"lodash-es": "^4.17.21",
+ "playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
"rimraf": "^6.0.1",
@@ -58,9 +60,8 @@
"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": "npx eslint ./"
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js
index 8aaaa749c..f56d408b0 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js
@@ -13,6 +13,7 @@ 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(
@@ -22,10 +23,24 @@ const RunCollectionItem = ({ collectionUid, 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;
@@ -55,22 +70,34 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => {
This will run all the requests in this folder and all its subfolders.
{isFolderLoading ? Requests in this folder are still loading.
: null}
+ {isCollectionRunInProgress ? A Collection Run is already in progress.
: null}
-
-
-
-
-
-
+ {
+ isCollectionRunInProgress ?
+
+
+
+ :
+ <>
+
+
+
+
+
+
+ >
+ }
)}
diff --git a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
index 07f390901..7b331c42c 100644
--- a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
@@ -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 ? (
{formik.errors.collectionLocation}
diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
index e67eb75fc..7c4e9f83f 100644
--- a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
+++ b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
@@ -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 ? (
diff --git a/packages/bruno-app/src/components/Sidebar/TitleBar/index.js b/packages/bruno-app/src/components/Sidebar/TitleBar/index.js
index e0b89b55d..65badf3aa 100644
--- a/packages/bruno-app/src/components/Sidebar/TitleBar/index.js
+++ b/packages/bruno-app/src/components/Sidebar/TitleBar/index.js
@@ -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)));
});
};
diff --git a/packages/bruno-app/src/providers/Toaster/index.js b/packages/bruno-app/src/providers/Toaster/index.js
index 1ae25764c..10dab3297 100644
--- a/packages/bruno-app/src/providers/Toaster/index.js
+++ b/packages/bruno-app/src/providers/Toaster/index.js
@@ -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'
diff --git a/packages/bruno-app/src/utils/common/error.js b/packages/bruno-app/src/utils/common/error.js
index e81e3fadc..c1ae6058c 100644
--- a/packages/bruno-app/src/utils/common/error.js
+++ b/packages/bruno-app/src/utils/common/error.js
@@ -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: )?/, '');
+}
diff --git a/packages/bruno-app/src/utils/common/index.js b/packages/bruno-app/src/utils/common/index.js
index 47055cbb7..7c1165ed9 100644
--- a/packages/bruno-app/src/utils/common/index.js
+++ b/packages/bruno-app/src/utils/common/index.js
@@ -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');
}
\ No newline at end of file
diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js
index 57e334e86..0ef587a9c 100644
--- a/packages/bruno-cli/src/commands/run.js
+++ b/packages/bruno-cli/src/commands/run.js
@@ -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 {
diff --git a/packages/bruno-cli/src/runner/interpolate-vars.js b/packages/bruno-cli/src/runner/interpolate-vars.js
index 2d11350eb..7ec7041b5 100644
--- a/packages/bruno-cli/src/runner/interpolate-vars.js
+++ b/packages/bruno-cli/src/runner/interpolate-vars.js
@@ -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) || '';
diff --git a/packages/bruno-cli/src/runner/oauth2.js b/packages/bruno-cli/src/runner/oauth2.js
new file mode 100644
index 000000000..f5335dc55
--- /dev/null
+++ b/packages/bruno-cli/src/runner/oauth2.js
@@ -0,0 +1,6 @@
+const { getOAuth2Token } = require('@usebruno/requests');
+const tokenStore = require('./tokenStore');
+
+module.exports = {
+ getOAuth2Token: (oauth2Config) => getOAuth2Token(oauth2Config, tokenStore)
+};
\ No newline at end of file
diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js
index 7e7f5d3ac..bd63704a2 100644
--- a/packages/bruno-cli/src/runner/prepare-request.js
+++ b/packages/bruno-cli/src/runner/prepare-request.js
@@ -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 || {};
diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js
index aee23ae3e..ad05ad921 100644
--- a/packages/bruno-cli/src/runner/run-single-request.js
+++ b/packages/bruno-cli/src/runner/run-single-request.js
@@ -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: [],
@@ -313,6 +312,33 @@ const runSingleRequest = async function (
requestMaxRedirects = 5; // Default to 5 redirects
}
+ // 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 {
@@ -370,10 +396,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,
@@ -382,13 +408,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,
@@ -400,12 +427,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(
@@ -446,7 +473,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(
@@ -508,7 +535,7 @@ const runSingleRequest = async function (
return {
test: {
- filename: filename
+ filename: relativeItemPathname
},
request: {
method: request.method,
@@ -524,16 +551,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,
@@ -542,12 +570,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: []
diff --git a/packages/bruno-cli/src/runner/tokenStore.js b/packages/bruno-cli/src/runner/tokenStore.js
new file mode 100644
index 000000000..1bc5c3273
--- /dev/null
+++ b/packages/bruno-cli/src/runner/tokenStore.js
@@ -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;
\ No newline at end of file
diff --git a/packages/bruno-cli/src/utils/bru.js b/packages/bruno-cli/src/utils/bru.js
index cb8cee7fe..07844a455 100644
--- a/packages/bruno-cli/src/utils/bru.js
+++ b/packages/bruno-cli/src/utils/bru.js
@@ -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);
diff --git a/packages/bruno-cli/src/utils/collection.js b/packages/bruno-cli/src/utils/collection.js
index ec2705af3..649fb2a33 100644
--- a/packages/bruno-cli/src/utils/collection.js
+++ b/packages/bruno-cli/src/utils/collection.js
@@ -4,6 +4,112 @@ 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 [/bruno.json]
+ const brunoConfig = getCollectionBrunoJsonConfig(collectionPath);
+
+ // get the collection root [/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();
@@ -204,6 +310,45 @@ 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
@@ -335,10 +480,14 @@ const processCollectionItems = async (items = [], currentPath) => {
};
module.exports = {
+ createCollectionJsonFromPathname,
mergeHeaders,
mergeVars,
mergeScripts,
findItemInCollection,
getTreePathFromCollectionToItem,
- createCollectionFromBrunoObject
+ createCollectionFromBrunoObject,
+ mergeAuth,
+ getAllRequestsInFolder,
+ getAllRequestsAtFolderRoot
}
\ No newline at end of file
diff --git a/packages/bruno-cli/tests/runner/collection-json-from-pathname.spec.js b/packages/bruno-cli/tests/runner/collection-json-from-pathname.spec.js
new file mode 100644
index 000000000..8cab346cd
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/collection-json-from-pathname.spec.js
@@ -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', '');
+ expect(c).toHaveProperty('brunoConfig.proxy.port', 3000);
+ expect(c).toHaveProperty('brunoConfig.proxy.auth.enabled', false);
+ expect(c).toHaveProperty('brunoConfig.proxy.auth.username', '');
+ expect(c).toHaveProperty('brunoConfig.proxy.auth.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 */
+ // /folder_2
+ expect(c).toHaveProperty('items[0].type', 'folder');
+ expect(c).toHaveProperty('items[0].name', 'folder_2');
+ expect(c).toHaveProperty('items[0].seq', 1);
+
+ // /folder_2/request_1
+ expect(c).toHaveProperty('items[0].items[0].name', 'request_1');
+ expect(c).toHaveProperty('items[0].items[0].seq', 1);
+
+ // /folder_2/request_3
+ expect(c).toHaveProperty('items[0].items[1].name', 'request_3');
+ expect(c).toHaveProperty('items[0].items[1].seq', 2);
+
+ // /folder_2/request_2
+ expect(c).toHaveProperty('items[0].items[2].name', 'request_2');
+ expect(c).toHaveProperty('items[0].items[2].seq', 3);
+
+ // /folder_1
+ expect(c).toHaveProperty('items[1].type', 'folder');
+ expect(c).toHaveProperty('items[1].name', 'folder_1');
+ expect(c).toHaveProperty('items[1].seq', 5);
+
+ // /folder_1/folder_2
+ expect(c).toHaveProperty('items[1].items[0].name', 'folder_2');
+ expect(c).toHaveProperty('items[1].items[0].seq', 1);
+
+ // /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);
+
+ // /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);
+
+ // /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);
+
+ // /folder_1/folder_1
+ expect(c).toHaveProperty('items[1].items[1].name', 'folder_1');
+ expect(c).toHaveProperty('items[1].items[1].seq', 2);
+
+ // /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);
+
+ // /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);
+
+ // /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);
+
+ // /folder_1/request_1
+ expect(c).toHaveProperty('items[1].items[2].name', 'request_1');
+ expect(c).toHaveProperty('items[1].items[2].seq', 3);
+
+ // /folder_1/request_3
+ expect(c).toHaveProperty('items[1].items[3].name', 'request_3');
+ expect(c).toHaveProperty('items[1].items[3].seq', 4);
+
+ // /folder_1/request_2
+ expect(c).toHaveProperty('items[1].items[4].name', 'request_2');
+ expect(c).toHaveProperty('items[1].items[4].seq', 5);
+
+ // /request_2
+ expect(c).toHaveProperty('items[2].name', 'request_3');
+ expect(c).toHaveProperty('items[2].seq', 2);
+
+ // /request_3
+ expect(c).toHaveProperty('items[3].name', 'request_1');
+ expect(c).toHaveProperty('items[3].seq', 3);
+
+ // /request_4
+ expect(c).toHaveProperty('items[4].name', 'request_2');
+ expect(c).toHaveProperty('items[4].seq', 4);
+
+ /* collection request item - /request_4 */
+ // /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});');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/bruno.json b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/bruno.json
new file mode 100644
index 000000000..366f84472
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/bruno.json
@@ -0,0 +1,31 @@
+{
+ "version": "1",
+ "name": "collection",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ],
+ "proxy": {
+ "enabled": false,
+ "protocol": "http",
+ "hostname": "",
+ "port": 3000,
+ "auth": {
+ "enabled": false,
+ "username": "",
+ "password": ""
+ },
+ "bypassProxy": ""
+ },
+ "scripts": {
+ "moduleWhitelist": ["crypto", "buffer"],
+ "filesystemAccess": {
+ "allow": true
+ }
+ },
+ "clientCertificates": {
+ "enabled": true,
+ "certs": []
+ }
+}
\ No newline at end of file
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/collection.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/collection.bru
new file mode 100644
index 000000000..bdfbfc430
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/collection.bru
@@ -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
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder.bru
new file mode 100644
index 000000000..fb30da65c
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: folder_1
+ seq: 5
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/folder.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/folder.bru
new file mode 100644
index 000000000..2b5e3cd2f
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: folder_1
+ seq: 2
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_1.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_1.bru
new file mode 100644
index 000000000..79ff1676a
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_1.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_1
+ type: http
+ seq: 3
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_2.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_2.bru
new file mode 100644
index 000000000..b0b7e046e
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_2.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_2
+ type: http
+ seq: 2
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_3.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_3.bru
new file mode 100644
index 000000000..7953c4499
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_3.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_3
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/folder.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/folder.bru
new file mode 100644
index 000000000..674476e89
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: folder_2
+ seq: 1
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_1.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_1.bru
new file mode 100644
index 000000000..c93c6cb37
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_1.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_1
+ type: http
+ seq: 2
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_2.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_2.bru
new file mode 100644
index 000000000..375cc9f6d
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_2.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_2
+ type: http
+ seq: 3
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_3.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_3.bru
new file mode 100644
index 000000000..7953c4499
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_3.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_3
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_1.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_1.bru
new file mode 100644
index 000000000..79ff1676a
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_1.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_1
+ type: http
+ seq: 3
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_2.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_2.bru
new file mode 100644
index 000000000..7dac68aed
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_2.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_2
+ type: http
+ seq: 5
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_3.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_3.bru
new file mode 100644
index 000000000..8a818f66c
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_3.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_3
+ type: http
+ seq: 4
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/folder.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/folder.bru
new file mode 100644
index 000000000..674476e89
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: folder_2
+ seq: 1
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_1.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_1.bru
new file mode 100644
index 000000000..b8fb205ed
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_1.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_1
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_2.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_2.bru
new file mode 100644
index 000000000..375cc9f6d
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_2.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_2
+ type: http
+ seq: 3
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_3.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_3.bru
new file mode 100644
index 000000000..a2582cf6c
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_3.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_3
+ type: http
+ seq: 2
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_1.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_1.bru
new file mode 100644
index 000000000..79ff1676a
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_1.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_1
+ type: http
+ seq: 3
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_2.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_2.bru
new file mode 100644
index 000000000..1aef2b30e
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_2.bru
@@ -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");
+ });
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_3.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_3.bru
new file mode 100644
index 000000000..a2582cf6c
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_3.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_3
+ type: http
+ seq: 2
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/prepare-request.spec.js b/packages/bruno-cli/tests/runner/prepare-request.spec.js
index 39b79972f..d532dcff1 100644
--- a/packages/bruno-cli/tests/runner/prepare-request.spec.js
+++ b/packages/bruno-cli/tests/runner/prepare-request.spec.js
@@ -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);
+ });
+ });
+ });
});
diff --git a/packages/bruno-requests/package.json b/packages/bruno-requests/package.json
index f43820549..1b4fc8311 100644
--- a/packages/bruno-requests/package.json
+++ b/packages/bruno-requests/package.json
@@ -28,5 +28,8 @@
},
"overrides": {
"rollup": "3.29.5"
+ },
+ "dependencies": {
+ "@types/qs": "^6.9.18"
}
}
diff --git a/packages/bruno-requests/rollup.config.js b/packages/bruno-requests/rollup.config.js
index fa04da640..83422321d 100644
--- a/packages/bruno-requests/rollup.config.js
+++ b/packages/bruno-requests/rollup.config.js
@@ -31,7 +31,8 @@ module.exports = [
}),
commonjs(),
typescript({ tsconfig: './tsconfig.json' }),
- terser()
- ]
+ terser(),
+ ],
+ external: ['axios', 'qs']
}
];
diff --git a/packages/bruno-requests/src/auth/index.ts b/packages/bruno-requests/src/auth/index.ts
index cd302427c..082ca796b 100644
--- a/packages/bruno-requests/src/auth/index.ts
+++ b/packages/bruno-requests/src/auth/index.ts
@@ -1 +1,2 @@
-export { addDigestInterceptor } from './digestauth-helper';
\ No newline at end of file
+export { addDigestInterceptor } from './digestauth-helper';
+export { getOAuth2Token } from './oauth2-helper';
\ No newline at end of file
diff --git a/packages/bruno-requests/src/auth/oauth2-helper.ts b/packages/bruno-requests/src/auth/oauth2-helper.ts
new file mode 100644
index 000000000..e73ac7158
--- /dev/null
+++ b/packages/bruno-requests/src/auth/oauth2-helper.ts
@@ -0,0 +1,199 @@
+import axios, { AxiosError } from 'axios';
+import qs from 'qs';
+
+export interface TokenStore {
+ saveToken(serviceId: string, account: string, token: any): Promise;
+ getToken(serviceId: string, account: string): Promise;
+ deleteToken(serviceId: string, account: string): Promise;
+}
+
+export interface OAuth2Config {
+ grantType: 'client_credentials' | 'password';
+ accessTokenUrl: string;
+ clientId?: string;
+ clientSecret?: string;
+ username?: string;
+ password?: string;
+ scope?: string;
+ credentialsPlacement?: 'header' | 'body';
+}
+
+interface RequestConfig {
+ headers: {
+ 'Content-Type': string;
+ 'Authorization'?: string;
+ };
+}
+
+interface ClientCredentialsData {
+ grant_type: string;
+ scope: string;
+ client_id?: string;
+ client_secret?: string;
+}
+
+interface PasswordGrantData {
+ grant_type: string;
+ username: string;
+ password: string;
+ scope: string;
+ client_id?: string;
+ client_secret?: string;
+}
+
+/**
+ * Fetches an OAuth2 token using client credentials grant
+ */
+const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => {
+ const {
+ accessTokenUrl,
+ clientId,
+ clientSecret,
+ scope,
+ credentialsPlacement = 'header'
+ } = oauth2Config;
+
+ if (!accessTokenUrl || !clientId) {
+ throw new Error('Missing required OAuth2 parameters');
+ }
+
+ const data: ClientCredentialsData = {
+ grant_type: 'client_credentials',
+ scope: scope || ''
+ };
+
+ const config: RequestConfig = {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+ };
+
+ // Handle credentials placement
+ if (credentialsPlacement === 'header') {
+ config.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret || ''}`).toString('base64')}`;
+ } else {
+ // Credentials in body
+ data.client_id = clientId;
+ if (clientSecret) {
+ data.client_secret = clientSecret;
+ }
+ }
+
+ try {
+ const response = await axios.post(accessTokenUrl, qs.stringify(data), config);
+ return response.data;
+ } catch (error) {
+ if (error instanceof Error) {
+ console.error('CLIENT_CREDENTIALS: Error fetching OAuth2 token:', error.message);
+ }
+ throw error;
+ }
+};
+
+/**
+ * Fetches an OAuth2 token using password grant
+ */
+const fetchTokenPassword = async (oauth2Config: OAuth2Config) => {
+ const {
+ accessTokenUrl,
+ clientId,
+ clientSecret,
+ username,
+ password,
+ scope,
+ credentialsPlacement = 'header'
+ } = oauth2Config;
+
+ if (!accessTokenUrl || !username || !password) {
+ throw new Error('Missing required OAuth2 parameters for password grant');
+ }
+
+ const data: PasswordGrantData = {
+ grant_type: 'password',
+ username,
+ password,
+ scope: scope || ''
+ };
+
+ const config: RequestConfig = {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+ };
+
+ // Handle credentials placement
+ if (credentialsPlacement === 'header' && clientId) {
+ config.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret || ''}`).toString('base64')}`;
+ } else if (clientId) {
+ // Credentials in body
+ data.client_id = clientId;
+ if (clientSecret) {
+ data.client_secret = clientSecret;
+ }
+ }
+
+ try {
+ const response = await axios.post(accessTokenUrl, qs.stringify(data), config);
+ return response.data;
+ } catch (error) {
+ if (error instanceof AxiosError && error.response) {
+ console.error('PASSWORD_GRANT: Error fetching OAuth2 token:', error.message);
+ console.error('Status:', error.response.status, 'Response:', error.response.data);
+ } else if (error instanceof Error) {
+ console.error('PASSWORD_GRANT: Error fetching OAuth2 token:', error.message);
+ }
+ throw error;
+ }
+};
+
+/**
+ * Manages OAuth2 token retrieval and storage
+ */
+export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: TokenStore): Promise => {
+ const { grantType, clientId, accessTokenUrl } = oauth2Config;
+
+ if (!grantType || !accessTokenUrl) {
+ throw new Error('Missing required OAuth2 parameters: grantType or accessTokenUrl');
+ }
+
+ const serviceId = accessTokenUrl;
+ const account = clientId || oauth2Config.username || 'default';
+
+ // Check if we already have a token stored
+ const existingToken = await tokenStore.getToken(serviceId, account);
+
+ if (existingToken) {
+ // Check if token is expired
+ if (existingToken.expires_at && existingToken.expires_at > Date.now()) {
+ return existingToken.access_token;
+ }
+ }
+
+ // No valid token found, fetch a new one
+ try {
+ let tokenResponse;
+
+ if (grantType === 'client_credentials') {
+ tokenResponse = await fetchTokenClientCredentials(oauth2Config);
+ } else if (grantType === 'password') {
+ tokenResponse = await fetchTokenPassword(oauth2Config);
+ } else {
+ throw new Error(`Unsupported grant type: ${grantType}`);
+ }
+
+ // Calculate expiry time if expires_in is provided
+ if (tokenResponse.expires_in) {
+ tokenResponse.expires_at = Date.now() + tokenResponse.expires_in * 1000;
+ }
+
+ // Store the token
+ await tokenStore.saveToken(serviceId, account, tokenResponse);
+
+ return tokenResponse.access_token;
+ } catch (error) {
+ if (error instanceof Error) {
+ console.error('Failed to get OAuth2 token:', error.message);
+ }
+ return null;
+ }
+};
\ No newline at end of file
diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts
index 19b02f764..5513916c5 100644
--- a/packages/bruno-requests/src/index.ts
+++ b/packages/bruno-requests/src/index.ts
@@ -1 +1 @@
-export { addDigestInterceptor } from './auth';
\ No newline at end of file
+export { addDigestInterceptor, getOAuth2Token } from './auth';
diff --git a/playwright.config.js b/playwright.config.js
deleted file mode 100644
index 63afd88d4..000000000
--- a/playwright.config.js
+++ /dev/null
@@ -1,110 +0,0 @@
-// @ts-check
-const { devices } = require('@playwright/test');
-
-/**
- * Read environment variables from file.
- * https://github.com/motdotla/dotenv
- */
-// require('dotenv').config();
-
-process.env.PLAYWRIGHT = "1";
-
-/**
- * @see https://playwright.dev/docs/test-configuration
- * @type {import('@playwright/test').PlaywrightTestConfig}
- */
-const config = {
- testDir: './tests',
- /* Maximum time one test can run for. */
- timeout: 30 * 1000,
- expect: {
- /**
- * Maximum time expect() should wait for the condition to be met.
- * For example in `await expect(locator).toHaveText();`
- */
- timeout: 5000
- },
- /* Run tests in files in parallel */
- fullyParallel: true,
- /* Fail the build on CI if you accidentally left test.only in the source code. */
- forbidOnly: !!process.env.CI,
- /* Retry on CI only */
- retries: process.env.CI ? 2 : 0,
- /* Opt out of parallel tests on CI. */
- workers: process.env.CI ? 1 : undefined,
- /* Reporter to use. See https://playwright.dev/docs/test-reporters */
- reporter: 'html',
- /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
- use: {
- /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
- actionTimeout: 0,
- /* Base URL to use in actions like `await page.goto('/')`. */
- baseURL: 'http://localhost:3000',
-
- /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
- trace: 'retain-on-failure',
- },
-
- /* Configure projects for major browsers */
- projects: [
- {
- name: 'chromium',
- use: {
- ...devices['Desktop Chrome'],
- },
- },
-
- {
- name: 'firefox',
- use: {
- ...devices['Desktop Firefox'],
- },
- },
-
- {
- name: 'webkit',
- use: {
- ...devices['Desktop Safari'],
- },
- },
-
- /* Test against mobile viewports. */
- // {
- // name: 'Mobile Chrome',
- // use: {
- // ...devices['Pixel 5'],
- // },
- // },
- // {
- // name: 'Mobile Safari',
- // use: {
- // ...devices['iPhone 12'],
- // },
- // },
-
- /* Test against branded browsers. */
- // {
- // name: 'Microsoft Edge',
- // use: {
- // channel: 'msedge',
- // },
- // },
- // {
- // name: 'Google Chrome',
- // use: {
- // channel: 'chrome',
- // },
- // },
- ],
-
- /* Folder for test artifacts such as screenshots, videos, traces, etc. */
- // outputDir: 'test-results/',
-
- /* Run your local dev server before starting the tests */
- webServer: {
- command: 'npm run dev:web',
- port: 3000,
- },
-};
-
-module.exports = config;
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 000000000..b0adb030f
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e-tests',
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? undefined : 1,
+ reporter: 'html',
+ use: {
+ trace: 'on-first-retry'
+ },
+
+ projects: [
+ {
+ name: 'Bruno Electron App'
+ }
+ ],
+
+ webServer: {
+ command: 'npm run dev:web',
+ url: 'http://localhost:3000',
+ reuseExistingServer: !process.env.CI
+ }
+});
diff --git a/playwright/codegen.ts b/playwright/codegen.ts
new file mode 100644
index 000000000..da2bfcb1f
--- /dev/null
+++ b/playwright/codegen.ts
@@ -0,0 +1,13 @@
+const path = require('path');
+const { startApp } = require('./electron.ts');
+
+async function main() {
+ const { app, context } = await startApp();
+ let outputFile = process.argv[2]?.trim();
+ if (outputFile && !/\.(ts|js)$/.test(outputFile)) {
+ outputFile = path.join(__dirname, '../e2e-tests/', outputFile + '.spec.ts');
+ }
+ await context._enableRecorder({ language: 'playwright-test', mode: 'recording', outputFile });
+}
+
+main();
diff --git a/playwright/electron.ts b/playwright/electron.ts
new file mode 100644
index 000000000..bc49363f1
--- /dev/null
+++ b/playwright/electron.ts
@@ -0,0 +1,13 @@
+const path = require('path');
+const { _electron: electron } = require('playwright');
+
+const electronAppPath = path.join(__dirname, '../packages/bruno-electron');
+
+exports.startApp = async () => {
+ const app = await electron.launch({ args: [electronAppPath] });
+ const context = await app.context();
+
+ app.process().stdout.on('data', (data) => console.log(data.toString()));
+ app.process().stderr.on('data', (error) => console.error(error.toString()));
+ return { app, context };
+};
diff --git a/playwright/index.ts b/playwright/index.ts
new file mode 100644
index 000000000..ca865437d
--- /dev/null
+++ b/playwright/index.ts
@@ -0,0 +1,23 @@
+import { test as baseTest, ElectronApplication, Page } from '@playwright/test';
+
+const { startApp } = require('./electron.ts');
+
+export const test = baseTest.extend<{ page: Page }, { electronApp: ElectronApplication }>({
+ electronApp: [
+ async ({}, use) => {
+ const { app: electronApp, context } = await startApp();
+
+ await use(electronApp);
+ await context.close();
+ await electronApp.close();
+ },
+ { scope: 'worker' }
+ ],
+ page: async ({ electronApp }, use) => {
+ const page = await electronApp.firstWindow();
+ await use(page);
+ await page.reload();
+ }
+});
+
+export * from '@playwright/test'
diff --git a/scripts/playwright-codegen.js b/scripts/playwright-codegen.js
deleted file mode 100644
index ae96c7a41..000000000
--- a/scripts/playwright-codegen.js
+++ /dev/null
@@ -1,17 +0,0 @@
-const path = require('path');
-const timer = require('node:timers/promises');
-const { _electron: electron } = require('playwright');
-
-const electronAppPath = path.join(__dirname, '../packages/bruno-electron');
-
-(async () => {
- const browser = await electron.launch({ args: [electronAppPath] });
- const context = await browser.context();
- await context.route('**/*', (route) => route.continue());
-
- while (true) {
- if(browser.windows().length) break;
- await timer.setTimeout(200);
- }
- await browser.windows()[0].pause();
-})();
diff --git a/tests/home.spec.js b/tests/home.spec.js
deleted file mode 100644
index 0ab8a5652..000000000
--- a/tests/home.spec.js
+++ /dev/null
@@ -1,48 +0,0 @@
-const { test, expect } = require('@playwright/test');
-const { HomePage } = require('../tests/pages/home.page');
-const { faker } = require('./utils/data-faker');
-
-test.describe('bruno e2e test', () => {
- let homePage;
-
- test.beforeEach(async ({ page }) => {
- homePage = new HomePage(page);
-
- await homePage.open();
- await expect(page).toHaveURL('/');
- await expect(page).toHaveTitle(/bruno/);
- });
-
- test('user should be able to create new collection & new request', async () => {
- await homePage.createNewCollection(faker.randomWords);
- await expect(homePage.createNewCollectionSuccessToast).toBeVisible();
-
- // using fake data to simulate negative case
- await homePage.createNewRequest(faker.randomVerb, faker.randomHttpMethod, faker.randomUrl);
- await expect(homePage.networkErrorToast).toBeVisible();
-
- // using real data to simulate positive case
- await homePage.createNewRequest('Single User', 'GET', 'https://reqres.in/api/users/2');
- await expect(homePage.statusRequestSuccess).toBeVisible();
- });
-
- test('user should be able to load & use sample collection', async () => {
- await homePage.loadSampleCollection();
- await expect(homePage.loadSampleCollectionSuccessToast).toBeVisible();
-
- await homePage.getUsers();
- await expect(homePage.statusRequestSuccess).toBeVisible();
-
- await homePage.getSingleUser();
- await expect(homePage.statusRequestSuccess).toBeVisible();
-
- await homePage.getUserNotFound();
- await expect(homePage.statusRequestNotFound).toBeVisible();
-
- await homePage.createUser();
- await expect(homePage.statusRequestCreated).toBeVisible();
-
- await homePage.updateUser();
- await expect(homePage.statusRequestSuccess).toBeVisible();
- });
-});
diff --git a/tests/pages/home.page.js b/tests/pages/home.page.js
deleted file mode 100644
index 4aff24ce1..000000000
--- a/tests/pages/home.page.js
+++ /dev/null
@@ -1,86 +0,0 @@
-exports.HomePage = class HomePage {
- constructor(page) {
- this.page = page;
-
- // welcome
- this.createCollectionSelector = page.locator('#create-collection');
- this.addCollectionSelector = page.locator('#add-collection');
- this.importCollectionSelector = page.locator('#import-collection');
- this.loadSampleCollectionSelector = page.locator('#load-sample-collection');
-
- // sample collection
- this.loadSampleCollectionSuccessToast = page.getByText('Sample Collection loaded successfully');
- this.sampleCollectionSelector = page.locator('#sidebar-collection-name');
- this.getUsersSelector = page.getByText('Users');
- this.getSingleUserSelector = page.getByText('Single User');
- this.getUserNotFoundSelector = page.getByText('User Not Found');
- this.postCreateSelector = page.getByText('Create');
- this.putUpdateSelector = page.getByText('Update');
-
- // request panel
- this.sendRequestButton = page.locator('#send-request');
- this.statusRequestSuccess = page.getByText('200 OK');
- this.statusRequestNotFound = page.getByText('404 Not Found');
- this.statusRequestCreated = page.getByText('201 Created');
-
- // create collection
- this.collectionNameField = page.locator('#collection-name');
- this.submitButton = page.locator(`button[type='submit']`);
- this.createNewCollectionSuccessToast = page.getByText('Collection created');
- this.createNewTab = page.locator('#create-new-tab');
- this.requestNameField = page.locator('input[name="requestName"]');
- this.methodName = page.locator('#create-new-request-method').first();
- this.requestUrlField = page.locator('#request-url');
- this.networkErrorToast = page.getByText('Network Error');
- }
-
- async open() {
- await this.page.goto('/');
- }
-
- async loadSampleCollection() {
- await this.loadSampleCollectionSelector.click();
- }
-
- async getUsers() {
- await this.sampleCollectionSelector.click();
- await this.getUsersSelector.click();
- await this.sendRequestButton.click();
- }
-
- async getSingleUser() {
- await this.getSingleUserSelector.click();
- await this.sendRequestButton.click();
- }
-
- async getUserNotFound() {
- await this.getUserNotFoundSelector.click();
- await this.sendRequestButton.click();
- }
-
- async createUser() {
- await this.postCreateSelector.click();
- await this.sendRequestButton.click();
- }
-
- async updateUser() {
- await this.putUpdateSelector.click();
- await this.sendRequestButton.click();
- }
-
- async createNewCollection(collectionName) {
- await this.createCollectionSelector.click();
- await this.collectionNameField.fill(collectionName);
- await this.submitButton.click();
- }
-
- async createNewRequest(name, method, endpoint) {
- await this.createNewTab.click();
- await this.requestNameField.fill(name);
- await this.methodName.click();
- await this.page.click(`text=${method}`);
- await this.requestUrlField.fill(endpoint);
- await this.submitButton.click();
- await this.sendRequestButton.click();
- }
-};
diff --git a/tests/utils/data-faker.js b/tests/utils/data-faker.js
deleted file mode 100644
index 2674b6244..000000000
--- a/tests/utils/data-faker.js
+++ /dev/null
@@ -1,6 +0,0 @@
-const { faker } = require('@faker-js/faker');
-
-export let randomWords = faker.random.words();
-export let randomVerb = faker.hacker.verb();
-export let randomHttpMethod = faker.internet.httpMethod();
-export let randomUrl = faker.internet.url();