From afd49d146f031fb217c830e7ea5410e5843efffe Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Mon, 5 Jan 2026 15:57:49 +0530 Subject: [PATCH] add: oc support for cli --- packages/bruno-cli/src/commands/run.js | 23 ++- packages/bruno-cli/src/utils/collection.js | 176 ++++++++---------- .../collection-json-from-pathname.spec.js | 96 +++++++++- .../collection/environments/dev.yml | 6 + .../opencollection/collection/get-users.yml | 8 + .../collection/opencollection.yml | 14 ++ .../collection/users/create-user.yml | 14 ++ .../collection/users/folder.yml | 3 + 8 files changed, 230 insertions(+), 110 deletions(-) create mode 100644 packages/bruno-cli/tests/runner/fixtures/opencollection/collection/environments/dev.yml create mode 100644 packages/bruno-cli/tests/runner/fixtures/opencollection/collection/get-users.yml create mode 100644 packages/bruno-cli/tests/runner/fixtures/opencollection/collection/opencollection.yml create mode 100644 packages/bruno-cli/tests/runner/fixtures/opencollection/collection/users/create-user.yml create mode 100644 packages/bruno-cli/tests/runner/fixtures/opencollection/collection/users/folder.yml diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index bc4903e8a..e0cb460e7 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -15,7 +15,7 @@ const { rpad } = require('../utils/common'); const { getOptions } = require('../utils/bru'); const { parseDotEnv, parseEnvironment } = require('@usebruno/filestore'); const constants = require('../constants'); -const { findItemInCollection, createCollectionJsonFromPathname, getCallStack } = require('../utils/collection'); +const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG } = require('../utils/collection'); const { hasExecutableTestInScript } = require('../utils/request'); const command = 'run [paths...]'; const desc = 'Run one or more requests/folders'; @@ -359,21 +359,21 @@ const handler = async function (argv) { } if (envFile || env) { + const envExt = FORMAT_CONFIG[collection.format].ext; const envFilePath = envFile ? path.resolve(collectionPath, envFile) - : path.join(collectionPath, 'environments', `${env}.bru`); + : path.join(collectionPath, 'environments', `${env}${envExt}`); const envFileExists = await exists(envFilePath); if (!envFileExists) { - const errorPath = envFile || `environments/${env}.bru`; + const errorPath = envFile || `environments/${env}${envExt}`; console.error(chalk.red(`Environment file not found: `) + chalk.dim(errorPath)); process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND); } - const ext = path.extname(envFilePath).toLowerCase(); - if (ext === '.json') { - // Parse Bruno schema JSON environment + const fileExt = path.extname(envFilePath).toLowerCase(); + if (fileExt === '.json') { let envJsonContent; try { envJsonContent = fs.readFileSync(envFilePath, 'utf8'); @@ -387,8 +387,12 @@ const handler = async function (argv) { console.error(chalk.red(`Failed to parse Environment JSON: ${err.message}`)); process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE); } + } else if (fileExt === '.yml' || fileExt === '.yaml') { + const envContent = fs.readFileSync(envFilePath, 'utf8'); + const envJson = parseEnvironment(envContent, { format: 'yml' }); + envVars = getEnvVars(envJson); + envVars.__name__ = envFile ? path.basename(envFilePath, fileExt) : env; } else { - // Default to .bru parsing const envBruContent = fs.readFileSync(envFilePath, 'utf8').replace(/\r\n/g, '\n'); const envJson = parseEnvironment(envBruContent); envVars = getEnvVars(envJson); @@ -596,10 +600,11 @@ const handler = async function (argv) { const runtime = getJsSandboxRuntime(sandbox); const runSingleRequestByPathname = async (relativeItemPathname) => { + const ext = FORMAT_CONFIG[collection.format].ext; return new Promise(async (resolve, reject) => { let itemPathname = path.join(collectionPath, relativeItemPathname); - if (itemPathname && !itemPathname?.endsWith('.bru')) { - itemPathname = `${itemPathname}.bru`; + if (itemPathname && !itemPathname?.endsWith(ext)) { + itemPathname = `${itemPathname}${ext}`; } const requestItem = cloneDeep(findItemInCollection(collection, itemPathname)); if (requestItem) { diff --git a/packages/bruno-cli/src/utils/collection.js b/packages/bruno-cli/src/utils/collection.js index 97b7ec588..9d06ee54e 100644 --- a/packages/bruno-cli/src/utils/collection.js +++ b/packages/bruno-cli/src/utils/collection.js @@ -7,116 +7,90 @@ const { parseRequest, parseCollection, parseFolder, stringifyCollection, stringi 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 - try { - const bruContent = fs.readFileSync(filePath, 'utf8'); - const requestItem = parseRequest(bruContent); - currentDirItems.push({ - name: file, - pathname: filePath, - ...requestItem - }); - } catch (err) { - // Log warning for invalid .bru file but continue processing - console.warn(chalk.yellow(`Warning: Skipping invalid file ${filePath}\nError: ${err.message}`)); - // Track skipped files for later reporting - if (!global.brunoSkippedFiles) { - global.brunoSkippedFiles = []; - } - global.brunoSkippedFiles.push({ path: filePath, error: err.message }); - } - } - } - let currentDirFolderItems = currentDirItems?.filter((iter) => iter.type === 'folder'); - let sortedFolderItems = sortByNameThenSequence(currentDirFolderItems); - - 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 FORMAT_CONFIG = { + yml: { ext: '.yml', collectionFile: 'opencollection.yml', folderFile: 'folder.yml' }, + bru: { ext: '.bru', collectionFile: 'collection.bru', folderFile: 'folder.bru' } }; -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) { +const getCollectionFormat = (collectionPath) => { + if (fs.existsSync(path.join(collectionPath, 'opencollection.yml'))) return 'yml'; + if (fs.existsSync(path.join(collectionPath, 'bruno.json'))) return 'bru'; + return null; +}; + +const getCollectionConfig = (collectionPath, format) => { + if (format === 'yml') { + const content = fs.readFileSync(path.join(collectionPath, 'opencollection.yml'), 'utf8'); + const parsed = parseCollection(content, { format: 'yml' }); + return { brunoConfig: parsed.brunoConfig, collectionRoot: parsed.collectionRoot || {} }; + } + const brunoConfig = JSON.parse(fs.readFileSync(path.join(collectionPath, 'bruno.json'), 'utf8')); + const collectionBruPath = path.join(collectionPath, 'collection.bru'); + const collectionRoot = fs.existsSync(collectionBruPath) + ? parseCollection(fs.readFileSync(collectionBruPath, 'utf8'), { format: 'bru' }) + : {}; + return { brunoConfig, collectionRoot }; +}; + +const getFolderRoot = (dir, format) => { + const folderPath = path.join(dir, FORMAT_CONFIG[format].folderFile); + if (!fs.existsSync(folderPath)) return null; + return parseFolder(fs.readFileSync(folderPath, 'utf8'), { format }); +}; + +const createCollectionJsonFromPathname = (collectionPath) => { + const format = getCollectionFormat(collectionPath); + if (!format) { 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 { brunoConfig, collectionRoot } = getCollectionConfig(collectionPath, format); + const { ext, collectionFile, folderFile } = FORMAT_CONFIG[format]; + const environmentsPath = path.join(collectionPath, 'environments'); -const getCollectionRoot = (dir) => { - const collectionRootPath = path.join(dir, 'collection.bru'); - const exists = fs.existsSync(collectionRootPath); - if (!exists) { - return {}; - } + const traverse = (currentPath) => { + if (currentPath.includes('node_modules')) return []; + const currentDirItems = []; - const content = fs.readFileSync(collectionRootPath, 'utf8'); - return parseCollection(content); -}; + for (const file of fs.readdirSync(currentPath)) { + const filePath = path.join(currentPath, file); + const stats = fs.lstatSync(filePath); -const getFolderRoot = (dir) => { - const folderRootPath = path.join(dir, 'folder.bru'); - const exists = fs.existsSync(folderRootPath); - if (!exists) { - return null; - } + if (stats.isDirectory()) { + if (filePath === environmentsPath || file === '.git' || file === 'node_modules') continue; + const folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) }; + const folderRoot = getFolderRoot(filePath, format); + if (folderRoot) { + folderItem.root = folderRoot; + folderItem.seq = folderRoot.meta?.seq; + } + currentDirItems.push(folderItem); + } else { + if (file === collectionFile || file === folderFile || path.extname(filePath) !== ext) continue; + try { + const requestItem = parseRequest(fs.readFileSync(filePath, 'utf8'), { format }); + currentDirItems.push({ name: file, ...requestItem, pathname: filePath }); + } catch (err) { + console.warn(chalk.yellow(`Warning: Skipping invalid file ${filePath}\nError: ${err.message}`)); + global.brunoSkippedFiles = global.brunoSkippedFiles || []; + global.brunoSkippedFiles.push({ path: filePath, error: err.message }); + } + } + } - const content = fs.readFileSync(folderRootPath, 'utf8'); - return parseFolder(content); + const folders = sortByNameThenSequence(currentDirItems.filter((i) => i.type === 'folder')); + const requests = currentDirItems.filter((i) => i.type !== 'folder').sort((a, b) => a.seq - b.seq); + return folders.concat(requests); + }; + + return { + brunoConfig, + format, + root: collectionRoot, + pathname: collectionPath, + items: traverse(collectionPath) + }; }; const mergeHeaders = (collection, request, requestTreePath) => { @@ -612,6 +586,8 @@ const sortByNameThenSequence = (items) => { }; module.exports = { + FORMAT_CONFIG, + getCollectionFormat, createCollectionJsonFromPathname, mergeHeaders, mergeVars, 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 index b26a08443..2be4d9e66 100644 --- a/packages/bruno-cli/tests/runner/collection-json-from-pathname.spec.js +++ b/packages/bruno-cli/tests/runner/collection-json-from-pathname.spec.js @@ -1,7 +1,9 @@ const path = require('node:path'); +const fs = require('node:fs'); const { describe, it, expect } = require('@jest/globals'); const constants = require('../../src/constants'); -const { createCollectionJsonFromPathname } = require('../../src/utils/collection'); +const { createCollectionJsonFromPathname, getCollectionFormat, FORMAT_CONFIG } = require('../../src/utils/collection'); +const { parseEnvironment } = require('@usebruno/filestore'); describe('create collection json from pathname', () => { it('should throw an error when the pathname is not a valid bruno collection root', () => { @@ -169,4 +171,96 @@ describe('create collection json from pathname', () => { // tests expect(c).toHaveProperty('items[4].request.tests', 'test(\"request level script\", function() {\n expect(\"test\").to.equal(\"test\");\n});'); }); + + it('creates a collection json from OpenCollection yml files', () => { + const collectionPathname = path.join(__dirname, './fixtures/opencollection/collection'); + const c = createCollectionJsonFromPathname(collectionPathname); + + expect(c).toBeDefined(); + expect(c).toHaveProperty('format', 'yml'); + expect(c).toHaveProperty('brunoConfig.opencollection', '1.0.0'); + expect(c).toHaveProperty('brunoConfig.name', 'Test OpenCollection'); + expect(c).toHaveProperty('brunoConfig.type', 'collection'); + expect(c).toHaveProperty('brunoConfig.ignore', ['node_modules', '.git']); + expect(c).toHaveProperty('pathname', collectionPathname); + + // collection root headers + expect(c).toHaveProperty('root.request.headers[0].name', 'X-Collection-Header'); + expect(c).toHaveProperty('root.request.headers[0].value', 'collection-header-value'); + expect(c).toHaveProperty('root.request.headers[0].enabled', true); + + // folder + expect(c.items.some((i) => i.type === 'folder' && i.name === 'users')).toBe(true); + const usersFolder = c.items.find((i) => i.name === 'users'); + expect(usersFolder).toHaveProperty('root.meta.name', 'Users'); + expect(usersFolder).toHaveProperty('root.meta.seq', 1); + expect(usersFolder.pathname).toContain('users'); + + // request in folder - name comes from info.name, pathname is correct + const createUserReq = usersFolder.items.find((i) => i.name === 'Create User'); + expect(createUserReq).toBeDefined(); + expect(createUserReq).toHaveProperty('type', 'http-request'); + expect(createUserReq).toHaveProperty('request.method', 'POST'); + expect(createUserReq).toHaveProperty('request.url', 'https://api.example.com/users'); + expect(createUserReq.pathname).toContain('create-user.yml'); + + // root level request - name comes from info.name, pathname is correct + const getUsersReq = c.items.find((i) => i.name === 'Get Users'); + expect(getUsersReq).toBeDefined(); + expect(getUsersReq).toHaveProperty('type', 'http-request'); + expect(getUsersReq).toHaveProperty('request.method', 'GET'); + expect(getUsersReq).toHaveProperty('request.url', 'https://api.example.com/users'); + expect(getUsersReq.pathname).toContain('get-users.yml'); + }); +}); + +describe('getCollectionFormat', () => { + it('returns yml for OpenCollection', () => { + const collectionPath = path.join(__dirname, './fixtures/opencollection/collection'); + expect(getCollectionFormat(collectionPath)).toBe('yml'); + }); + + it('returns bru for Bruno collection', () => { + const collectionPath = path.join(__dirname, './fixtures/collection-json-from-pathname/collection'); + expect(getCollectionFormat(collectionPath)).toBe('bru'); + }); + + it('returns null for invalid path', () => { + const collectionPath = path.join(__dirname, './fixtures/collection-invalid'); + expect(getCollectionFormat(collectionPath)).toBe(null); + }); +}); + +describe('FORMAT_CONFIG', () => { + it('has correct config for yml format', () => { + expect(FORMAT_CONFIG.yml).toEqual({ + ext: '.yml', + collectionFile: 'opencollection.yml', + folderFile: 'folder.yml' + }); + }); + + it('has correct config for bru format', () => { + expect(FORMAT_CONFIG.bru).toEqual({ + ext: '.bru', + collectionFile: 'collection.bru', + folderFile: 'folder.bru' + }); + }); +}); + +describe('OpenCollection environment parsing', () => { + it('parses YML environment files correctly', () => { + const envPath = path.join(__dirname, './fixtures/opencollection/collection/environments/dev.yml'); + const envContent = fs.readFileSync(envPath, 'utf8'); + const env = parseEnvironment(envContent, { format: 'yml' }); + + expect(env).toBeDefined(); + expect(env).toHaveProperty('name', 'Development'); + expect(env.variables).toHaveLength(2); + expect(env.variables[0]).toHaveProperty('name', 'baseUrl'); + expect(env.variables[0]).toHaveProperty('value', 'https://api.dev.example.com'); + expect(env.variables[1]).toHaveProperty('name', 'apiKey'); + expect(env.variables[1]).toHaveProperty('value', 'dev-api-key-123'); + }); }); diff --git a/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/environments/dev.yml b/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/environments/dev.yml new file mode 100644 index 000000000..dad2c49df --- /dev/null +++ b/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/environments/dev.yml @@ -0,0 +1,6 @@ +name: Development +variables: + - name: baseUrl + value: https://api.dev.example.com + - name: apiKey + value: dev-api-key-123 diff --git a/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/get-users.yml b/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/get-users.yml new file mode 100644 index 000000000..603b086af --- /dev/null +++ b/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/get-users.yml @@ -0,0 +1,8 @@ +info: + name: Get Users + type: http + seq: 1 + +http: + method: GET + url: https://api.example.com/users diff --git a/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/opencollection.yml b/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/opencollection.yml new file mode 100644 index 000000000..576169fc3 --- /dev/null +++ b/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/opencollection.yml @@ -0,0 +1,14 @@ +opencollection: "1.0.0" +info: + name: Test OpenCollection + +extensions: + ignore: + - node_modules + - .git + +request: + headers: + - name: X-Collection-Header + value: collection-header-value + enabled: true diff --git a/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/users/create-user.yml b/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/users/create-user.yml new file mode 100644 index 000000000..e92ff90c9 --- /dev/null +++ b/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/users/create-user.yml @@ -0,0 +1,14 @@ +info: + name: Create User + type: http + seq: 1 + +http: + method: POST + url: https://api.example.com/users + body: + mode: json + json: | + { + "name": "John Doe" + } diff --git a/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/users/folder.yml b/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/users/folder.yml new file mode 100644 index 000000000..05ad47d25 --- /dev/null +++ b/packages/bruno-cli/tests/runner/fixtures/opencollection/collection/users/folder.yml @@ -0,0 +1,3 @@ +info: + name: Users + seq: 1