Merge branch 'main' into fix/cli-not-following-redirects

This commit is contained in:
sanjai0py
2025-05-13 20:07:29 +05:30
58 changed files with 1700 additions and 560 deletions

44
.github/workflows/playwright.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Playwright E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e-test:
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: v22.11.x
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

5
.gitignore vendored
View File

@@ -47,4 +47,7 @@ yarn-error.log*
#dev editor
bruno.iml
.idea
.vscode
.vscode
# Playwright
/blob-report/

View File

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

View File

@@ -0,0 +1,5 @@
import { test, expect } from '../playwright';
test('test-app-start', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});

54
package-lock.json generated
View File

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

View File

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

View File

@@ -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 }) => {
</div>
<div className={isFolderLoading ? "mb-2" : "mb-8"}>This will run all the requests in this folder and all its subfolders.</div>
{isFolderLoading ? <div className='mb-8 warning'>Requests in this folder are still loading.</div> : null}
{isCollectionRunInProgress ? <div className='mb-6 warning'>A Collection Run is already in progress.</div> : null}
<div className="flex justify-end bruno-modal-footer">
<span className="mr-3">
<button type="button" onClick={onClose} className="btn btn-md btn-close">
Cancel
</button>
</span>
<span>
<button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
Recursive Run
</button>
</span>
<span>
<button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
Run
</button>
</span>
{
isCollectionRunInProgress ?
<span>
<button type="submit" className="submit btn btn-md btn-secondary mr-3" onClick={handleViewRunner}>
View Run
</button>
</span>
:
<>
<span>
<button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
Recursive Run
</button>
</span>
<span>
<button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
Run
</button>
</span>
</>
}
</div>
</div>
)}

View File

@@ -11,6 +11,8 @@ import PathDisplay from 'components/PathDisplay/index';
import { useState } from 'react';
import { IconArrowBackUp, IconEdit } from '@tabler/icons';
import Help from 'components/Help';
import { multiLineMsg } from "utils/common";
import { formatIpcError } from "utils/common/error";
const CreateCollection = ({ onClose }) => {
const inputRef = useRef();
@@ -45,7 +47,7 @@ const CreateCollection = ({ onClose }) => {
toast.success('Collection created!');
onClose();
})
.catch((e) => toast.error('An error occurred while creating the collection - ' + e));
.catch((e) => toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))));
}
});
@@ -113,7 +115,6 @@ const CreateCollection = ({ onClose }) => {
id="collection-location"
type="text"
name="collectionLocation"
readOnly={true}
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
@@ -121,6 +122,9 @@ const CreateCollection = ({ onClose }) => {
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
onChange={e => {
formik.setFieldValue('collectionLocation', e.target.value);
}}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
<div className="text-red-500">{formik.errors.collectionLocation}</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
const { getOAuth2Token } = require('@usebruno/requests');
const tokenStore = require('./tokenStore');
module.exports = {
getOAuth2Token: (oauth2Config) => getOAuth2Token(oauth2Config, tokenStore)
};

View File

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

View File

@@ -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: []

View File

@@ -0,0 +1,22 @@
// In-memory token store implementation for OAuth2 tokens
const tokenStore = {
tokens: new Map(),
// Save a token with optional expiry information
async saveToken(serviceId, account, token) {
this.tokens.set(`${serviceId}:${account}`, token);
return true;
},
// Get a token
async getToken(serviceId, account) {
return this.tokens.get(`${serviceId}:${account}`);
},
// Delete a token
async deleteToken(serviceId, account) {
return this.tokens.delete(`${serviceId}:${account}`);
}
};
module.exports = tokenStore;

View File

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

View File

@@ -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 [<collection-path>/bruno.json]
const brunoConfig = getCollectionBrunoJsonConfig(collectionPath);
// get the collection root [<collection-path>/collection.bru]
const collectionRoot = getCollectionRoot(collectionPath);
// get the collection items recursively
const traverse = (currentPath) => {
const filesInCurrentDir = fs.readdirSync(currentPath);
if (currentPath.includes('node_modules')) {
return;
}
const currentDirItems = [];
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (stats.isDirectory()) {
if (filePath === environmentsPath) continue;
if (filePath.startsWith('.git') || filePath.startsWith('node_modules')) continue;
// get the folder root
let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) }
const folderBruJson = getFolderRoot(filePath);
if (folderBruJson) {
folderItem.root = folderBruJson;
folderItem.seq = folderBruJson.meta.seq;
}
currentDirItems.push(folderItem);
}
else {
if (['collection.bru', 'folder.bru'].includes(file)) continue;
if (path.extname(filePath) !== '.bru') continue;
// get the request item
const bruContent = fs.readFileSync(filePath, 'utf8');
const requestItem = bruToJson(bruContent);
currentDirItems.push({
name: file,
pathname: filePath,
...requestItem
});
}
}
let currentDirFolderItems = currentDirItems?.filter((iter) => iter.type === 'folder');
let sortedFolderItems = currentDirFolderItems?.sort((a, b) => a.seq - b.seq);
let currentDirRequestItems = currentDirItems?.filter((iter) => iter.type !== 'folder');
let sortedRequestItems = currentDirRequestItems?.sort((a, b) => a.seq - b.seq);
return sortedFolderItems?.concat(sortedRequestItems);
};
let collectionItems = traverse(collectionPath);
let collection = {
brunoConfig,
root: collectionRoot,
pathname: collectionPath,
items: collectionItems
}
return collection;
};
const getCollectionBrunoJsonConfig = (dir) => {
// right now, bru must be run from the root of the collection
// will add support in the future to run it from anywhere inside the collection
const brunoJsonPath = path.join(dir, 'bruno.json');
const brunoJsonExists = fs.existsSync(brunoJsonPath);
if (!brunoJsonExists) {
console.error(chalk.red(`You can run only at the root of a collection`));
process.exit(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
}
const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
const brunoConfig = JSON.parse(brunoConfigFile);
return brunoConfig;
}
const getCollectionRoot = (dir) => {
const collectionRootPath = path.join(dir, 'collection.bru');
const exists = fs.existsSync(collectionRootPath);
if (!exists) {
return {};
}
const content = fs.readFileSync(collectionRootPath, 'utf8');
return collectionBruToJson(content);
};
const getFolderRoot = (dir) => {
const folderRootPath = path.join(dir, 'folder.bru');
const exists = fs.existsSync(folderRootPath);
if (!exists) {
return null;
}
const content = fs.readFileSync(folderRootPath, 'utf8');
return collectionBruToJson(content);
};
const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
@@ -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
}

View File

@@ -0,0 +1,172 @@
const path = require("node:path");
const { describe, it, expect } = require('@jest/globals');
const constants = require('../../src/constants');
const { createCollectionJsonFromPathname } = require('../../src/utils/collection');
describe('create collection json from pathname', () => {
it("should throw an error when the pathname is not a valid bruno collection root", () => {
const invalidCollectionPathname = path.join(__dirname, './fixtures/collection-invalid');
jest.spyOn(console, 'error').mockImplementation(() => { });
let mockProcessExit = jest.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(code); });
try { createCollectionJsonFromPathname(invalidCollectionPathname); } catch { }
expect(mockProcessExit).toHaveBeenCalledWith(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
jest.restoreAllMocks();
})
it("creates a bruno collection json from the collection bru files", () => {
const collectionPathname = path.join(__dirname, './fixtures/collection-json-from-pathname/collection');
const outputCollectionJson = createCollectionJsonFromPathname(collectionPathname);
let c = outputCollectionJson;
expect(c).toBeDefined();
/* collection bruno.json */
expect(c).toHaveProperty('brunoConfig.version', "1");
expect(c).toHaveProperty('brunoConfig.name', 'collection');
expect(c).toHaveProperty('brunoConfig.type', 'collection');
expect(c).toHaveProperty('brunoConfig.ignore', ["node_modules", ".git"]);
expect(c).toHaveProperty('brunoConfig.proxy.enabled', false);
expect(c).toHaveProperty('brunoConfig.proxy.protocol', 'http');
expect(c).toHaveProperty('brunoConfig.proxy.hostname', '<proxy-hostname>');
expect(c).toHaveProperty('brunoConfig.proxy.port', 3000);
expect(c).toHaveProperty('brunoConfig.proxy.auth.enabled', false);
expect(c).toHaveProperty('brunoConfig.proxy.auth.username', '<user-name>');
expect(c).toHaveProperty('brunoConfig.proxy.auth.password', '<password>');
expect(c).toHaveProperty('brunoConfig.proxy.bypassProxy', '');
expect(c).toHaveProperty('brunoConfig.scripts.moduleWhitelist', ['crypto', 'buffer']);
expect(c).toHaveProperty('brunoConfig.scripts.filesystemAccess.allow', true);
expect(c).toHaveProperty('brunoConfig.clientCertificates.enabled', true);
expect(c).toHaveProperty('brunoConfig.clientCertificates.certs', []);
/* collection pathname */
expect(c).toHaveProperty('pathname', collectionPathname);
/* collection root */
// headers
expect(c).toHaveProperty('root.request.headers[0].name', 'collection_header');
expect(c).toHaveProperty('root.request.headers[0].value', 'collection_header_value');
expect(c).toHaveProperty('root.request.headers[0].enabled', true);
// auth
expect(c).toHaveProperty('root.request.auth.mode', 'basic');
expect(c).toHaveProperty('root.request.auth.basic.username', 'username');
expect(c).toHaveProperty('root.request.auth.basic.password', 'password');
// pre-request scripts
expect(c).toHaveProperty('root.request.script.req', 'const collectionPreRequestScript = true;');
// collection root - post-response scripts
expect(c).toHaveProperty('root.request.script.res', 'const collectionPostResponseScript = true;');
// pre-request vars
expect(c).toHaveProperty('root.request.vars.req[0].name', 'collection_pre_var');
expect(c).toHaveProperty('root.request.vars.req[0].value', 'collection_pre_var_value');
expect(c).toHaveProperty('root.request.vars.req[0].enabled', true);
// post-response vars
expect(c).toHaveProperty('root.request.vars.res[0].name', 'collection_post_var');
expect(c).toHaveProperty('root.request.vars.res[0].value', 'collection_post_var_value');
expect(c).toHaveProperty('root.request.vars.res[0].enabled', true);
// tests
expect(c).toHaveProperty('root.request.tests', 'test(\"collection level script\", function() {\n expect(\"test\").to.equal(\"test\");\n});');
/* collection items names and sequences */
// <collection-root>/folder_2
expect(c).toHaveProperty('items[0].type', 'folder');
expect(c).toHaveProperty('items[0].name', 'folder_2');
expect(c).toHaveProperty('items[0].seq', 1);
// <collection-root>/folder_2/request_1
expect(c).toHaveProperty('items[0].items[0].name', 'request_1');
expect(c).toHaveProperty('items[0].items[0].seq', 1);
// <collection-root>/folder_2/request_3
expect(c).toHaveProperty('items[0].items[1].name', 'request_3');
expect(c).toHaveProperty('items[0].items[1].seq', 2);
// <collection-root>/folder_2/request_2
expect(c).toHaveProperty('items[0].items[2].name', 'request_2');
expect(c).toHaveProperty('items[0].items[2].seq', 3);
// <collection-root>/folder_1
expect(c).toHaveProperty('items[1].type', 'folder');
expect(c).toHaveProperty('items[1].name', 'folder_1');
expect(c).toHaveProperty('items[1].seq', 5);
// <collection-root>/folder_1/folder_2
expect(c).toHaveProperty('items[1].items[0].name', 'folder_2');
expect(c).toHaveProperty('items[1].items[0].seq', 1);
// <collection-root>/folder_1/folder_2/request_3
expect(c).toHaveProperty('items[1].items[0].items[0].name', 'request_3');
expect(c).toHaveProperty('items[1].items[0].items[0].seq', 1);
// <collection-root>/folder_1/folder_2/request_1
expect(c).toHaveProperty('items[1].items[0].items[1].name', 'request_1');
expect(c).toHaveProperty('items[1].items[0].items[1].seq', 2);
// <collection-root>/folder_1/folder_2/request_2
expect(c).toHaveProperty('items[1].items[0].items[2].name', 'request_2');
expect(c).toHaveProperty('items[1].items[0].items[2].seq', 3);
// <collection-root>/folder_1/folder_1
expect(c).toHaveProperty('items[1].items[1].name', 'folder_1');
expect(c).toHaveProperty('items[1].items[1].seq', 2);
// <collection-root>/folder_1/folder_1/request_3
expect(c).toHaveProperty('items[1].items[1].items[0].name', 'request_3');
expect(c).toHaveProperty('items[1].items[1].items[0].seq', 1);
// <collection-root>/folder_1/folder_1/request_2
expect(c).toHaveProperty('items[1].items[1].items[1].name', 'request_2');
expect(c).toHaveProperty('items[1].items[1].items[1].seq', 2);
// <collection-root>/folder_1/folder_1/request_1
expect(c).toHaveProperty('items[1].items[1].items[2].name', 'request_1');
expect(c).toHaveProperty('items[1].items[1].items[2].seq', 3);
// <collection-root>/folder_1/request_1
expect(c).toHaveProperty('items[1].items[2].name', 'request_1');
expect(c).toHaveProperty('items[1].items[2].seq', 3);
// <collection-root>/folder_1/request_3
expect(c).toHaveProperty('items[1].items[3].name', 'request_3');
expect(c).toHaveProperty('items[1].items[3].seq', 4);
// <collection-root>/folder_1/request_2
expect(c).toHaveProperty('items[1].items[4].name', 'request_2');
expect(c).toHaveProperty('items[1].items[4].seq', 5);
// <collection-root>/request_2
expect(c).toHaveProperty('items[2].name', 'request_3');
expect(c).toHaveProperty('items[2].seq', 2);
// <collection-root>/request_3
expect(c).toHaveProperty('items[3].name', 'request_1');
expect(c).toHaveProperty('items[3].seq', 3);
// <collection-root>/request_4
expect(c).toHaveProperty('items[4].name', 'request_2');
expect(c).toHaveProperty('items[4].seq', 4);
/* collection request item - <collection-root>/request_4 */
// <collection-root>/request_4
// headers
expect(c).toHaveProperty('items[4].request.headers[0].name', 'request_header');
expect(c).toHaveProperty('items[4].request.headers[0].value', 'request_header_value');
expect(c).toHaveProperty('items[4].request.headers[0].enabled', true);
// auth
expect(c).toHaveProperty('items[4].request.auth.mode', 'basic');
expect(c).toHaveProperty('items[4].request.auth.basic.username', 'username');
expect(c).toHaveProperty('items[4].request.auth.basic.password', 'password');
// pre-request scripts
expect(c).toHaveProperty('items[4].request.script.req', 'const requestPreRequestScript = true;');
// request items[4] - post-response scripts
expect(c).toHaveProperty('items[4].request.script.res', 'const requestPostResponseScript = true;');
// pre-request vars
expect(c).toHaveProperty('items[4].request.vars.req[0].name', 'request_pre_var');
expect(c).toHaveProperty('items[4].request.vars.req[0].value', 'request_pre_var_value');
expect(c).toHaveProperty('items[4].request.vars.req[0].enabled', true);
// post-response vars
expect(c).toHaveProperty('items[4].request.vars.res[0].name', 'request_post_var');
expect(c).toHaveProperty('items[4].request.vars.res[0].value', 'request_post_var_value');
expect(c).toHaveProperty('items[4].request.vars.res[0].enabled', true);
// tests
expect(c).toHaveProperty('items[4].request.tests', 'test(\"request level script\", function() {\n expect(\"test\").to.equal(\"test\");\n});');
});
});

View File

@@ -0,0 +1,31 @@
{
"version": "1",
"name": "collection",
"type": "collection",
"ignore": [
"node_modules",
".git"
],
"proxy": {
"enabled": false,
"protocol": "http",
"hostname": "<proxy-hostname>",
"port": 3000,
"auth": {
"enabled": false,
"username": "<user-name>",
"password": "<password>"
},
"bypassProxy": ""
},
"scripts": {
"moduleWhitelist": ["crypto", "buffer"],
"filesystemAccess": {
"allow": true
}
},
"clientCertificates": {
"enabled": true,
"certs": []
}
}

View File

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

View File

@@ -0,0 +1,4 @@
meta {
name: folder_1
seq: 5
}

View File

@@ -0,0 +1,4 @@
meta {
name: folder_1
seq: 2
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_1
type: http
seq: 3
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_2
type: http
seq: 2
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_3
type: http
seq: 1
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

@@ -0,0 +1,4 @@
meta {
name: folder_2
seq: 1
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_1
type: http
seq: 2
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_2
type: http
seq: 3
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_3
type: http
seq: 1
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_1
type: http
seq: 3
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_2
type: http
seq: 5
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_3
type: http
seq: 4
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

@@ -0,0 +1,4 @@
meta {
name: folder_2
seq: 1
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_1
type: http
seq: 1
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_2
type: http
seq: 3
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_3
type: http
seq: 2
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_1
type: http
seq: 3
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

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

View File

@@ -0,0 +1,11 @@
meta {
name: request_3
type: http
seq: 2
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

View File

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

View File

@@ -28,5 +28,8 @@
},
"overrides": {
"rollup": "3.29.5"
},
"dependencies": {
"@types/qs": "^6.9.18"
}
}

View File

@@ -31,7 +31,8 @@ module.exports = [
}),
commonjs(),
typescript({ tsconfig: './tsconfig.json' }),
terser()
]
terser(),
],
external: ['axios', 'qs']
}
];

View File

@@ -1 +1,2 @@
export { addDigestInterceptor } from './digestauth-helper';
export { addDigestInterceptor } from './digestauth-helper';
export { getOAuth2Token } from './oauth2-helper';

View File

@@ -0,0 +1,199 @@
import axios, { AxiosError } from 'axios';
import qs from 'qs';
export interface TokenStore {
saveToken(serviceId: string, account: string, token: any): Promise<boolean>;
getToken(serviceId: string, account: string): Promise<any>;
deleteToken(serviceId: string, account: string): Promise<boolean>;
}
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<string | null> => {
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;
}
};

View File

@@ -1 +1 @@
export { addDigestInterceptor } from './auth';
export { addDigestInterceptor, getOAuth2Token } from './auth';

View File

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

25
playwright.config.ts Normal file
View File

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

13
playwright/codegen.ts Normal file
View File

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

13
playwright/electron.ts Normal file
View File

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

23
playwright/index.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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