add: oc support for cli

This commit is contained in:
naman-bruno
2026-01-05 15:57:49 +05:30
parent 8590bacd79
commit afd49d146f
8 changed files with 230 additions and 110 deletions

View File

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

View File

@@ -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 [<collection-path>/bruno.json]
const brunoConfig = getCollectionBrunoJsonConfig(collectionPath);
// get the collection root [<collection-path>/collection.bru]
const collectionRoot = getCollectionRoot(collectionPath);
// get the collection items recursively
const traverse = (currentPath) => {
const filesInCurrentDir = fs.readdirSync(currentPath);
if (currentPath.includes('node_modules')) {
return;
}
const currentDirItems = [];
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (stats.isDirectory()) {
if (filePath === environmentsPath) continue;
if (filePath.startsWith('.git') || filePath.startsWith('node_modules')) continue;
// get the folder root
let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) };
const folderBruJson = getFolderRoot(filePath);
if (folderBruJson) {
folderItem.root = folderBruJson;
folderItem.seq = folderBruJson.meta.seq;
}
currentDirItems.push(folderItem);
} else {
if (['collection.bru', 'folder.bru'].includes(file)) continue;
if (path.extname(filePath) !== '.bru') continue;
// get the request item
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,

View File

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

View File

@@ -0,0 +1,6 @@
name: Development
variables:
- name: baseUrl
value: https://api.dev.example.com
- name: apiKey
value: dev-api-key-123

View File

@@ -0,0 +1,8 @@
info:
name: Get Users
type: http
seq: 1
http:
method: GET
url: https://api.example.com/users

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
info:
name: Users
seq: 1