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