From 7e3386b1b827bcdd21ee5f18d09db58e7fcc1681 Mon Sep 17 00:00:00 2001 From: Abhishek S Lal Date: Tue, 13 Jan 2026 19:24:26 +0530 Subject: [PATCH] feat: allow collection environment and environment file to be used together in run command (#6784) --- packages/bruno-cli/src/commands/run.js | 95 ++++++++------- .../cli-env-combined/cli-env-combined.spec.ts | 114 ++++++++++++++++++ .../cli-env-combined/collection/bruno.json | 5 + .../collection/environments/CollectionEnv.bru | 5 + .../collection/global-env.json | 8 ++ .../cli-env-combined/collection/request.bru | 19 +++ 6 files changed, 205 insertions(+), 41 deletions(-) create mode 100644 tests/runner/cli-env-combined/cli-env-combined.spec.ts create mode 100644 tests/runner/cli-env-combined/collection/bruno.json create mode 100644 tests/runner/cli-env-combined/collection/environments/CollectionEnv.bru create mode 100644 tests/runner/cli-env-combined/collection/global-env.json create mode 100644 tests/runner/cli-env-combined/collection/request.bru diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 7f762eb78..78b9dc259 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -354,50 +354,63 @@ const handler = async function (argv) { const runtimeVariables = {}; let envVars = {}; - if (env && envFile) { - console.error(chalk.red(`Cannot use both --env and --env-file options together`)); - process.exit(constants.EXIT_STATUS.ERROR_MALFORMED_ENV_OVERRIDE); - } + // Helper to load environment variables from a file + const loadEnvFromFile = (filePath, nameOverride) => { + const fileExt = path.extname(filePath).toLowerCase(); + let result = {}; - if (envFile || env) { - const envExt = FORMAT_CONFIG[collection.format].ext; - const envFilePath = envFile - ? path.resolve(collectionPath, envFile) - : path.join(collectionPath, 'environments', `${env}${envExt}`); - - const envFileExists = await exists(envFilePath); - if (!envFileExists) { - 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); + if (fileExt === '.json') { + const content = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(content); + const normalizedEnv = parseEnvironmentJson(parsed); + result = getEnvVars(normalizedEnv); + const rawName = normalizedEnv?.name; + const trimmedName = typeof rawName === 'string' ? rawName.trim() : ''; + result.__name__ = trimmedName || path.basename(filePath, '.json'); + } else if (fileExt === '.yml' || fileExt === '.yaml') { + const content = fs.readFileSync(filePath, 'utf8'); + const envJson = parseEnvironment(content, { format: 'yml' }); + result = getEnvVars(envJson); + result.__name__ = nameOverride || path.basename(filePath, fileExt); + } else { + const content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); + const envJson = parseEnvironment(content); + result = getEnvVars(envJson); + result.__name__ = nameOverride || path.basename(filePath, '.bru'); } - const fileExt = path.extname(envFilePath).toLowerCase(); - if (fileExt === '.json') { - let envJsonContent; - try { - envJsonContent = fs.readFileSync(envFilePath, 'utf8'); - const parsed = JSON.parse(envJsonContent); - const normalizedEnv = parseEnvironmentJson(parsed); - envVars = getEnvVars(normalizedEnv); - const rawName = normalizedEnv?.name; - const trimmedName = typeof rawName === 'string' ? rawName.trim() : ''; - envVars.__name__ = trimmedName || path.basename(envFilePath, '.json'); - } catch (err) { - 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 { - const envBruContent = fs.readFileSync(envFilePath, 'utf8').replace(/\r\n/g, '\n'); - const envJson = parseEnvironment(envBruContent); - envVars = getEnvVars(envJson); - envVars.__name__ = envFile ? path.basename(envFilePath, '.bru') : env; + return result; + }; + + // Load --env-file if provided + if (envFile) { + const envFilePath = path.resolve(collectionPath, envFile); + if (!(await exists(envFilePath))) { + console.error(chalk.red(`Environment file not found: `) + chalk.dim(envFile)); + process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND); + } + try { + envVars = loadEnvFromFile(envFilePath); + } catch (err) { + console.error(chalk.red(`Failed to parse environment file: ${err.message}`)); + process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE); + } + } + + // Load --env and merge (collection env takes precedence) + if (env) { + const envExt = FORMAT_CONFIG[collection.format].ext; + const collectionEnvFilePath = path.join(collectionPath, 'environments', `${env}${envExt}`); + if (!(await exists(collectionEnvFilePath))) { + console.error(chalk.red(`Environment file not found: `) + chalk.dim(`environments/${env}${envExt}`)); + process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND); + } + try { + const collectionEnvVars = loadEnvFromFile(collectionEnvFilePath, env); + envVars = { ...envVars, ...collectionEnvVars }; + } catch (err) { + console.error(chalk.red(`Failed to parse Environment file: ${err.message}`)); + process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE); } } diff --git a/tests/runner/cli-env-combined/cli-env-combined.spec.ts b/tests/runner/cli-env-combined/cli-env-combined.spec.ts new file mode 100644 index 000000000..8c9770e88 --- /dev/null +++ b/tests/runner/cli-env-combined/cli-env-combined.spec.ts @@ -0,0 +1,114 @@ +import { test, expect } from '../../../playwright'; +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +test.describe('CLI Combined Environment Support (--env and --env-file)', () => { + const collectionPath = path.resolve(__dirname, 'collection'); + const BRU = 'node ../../../../packages/bruno-cli/bin/bru.js'; + + // Helper: run bru CLI and return exit code + const runFrom = (cwd: string, args: string): number => { + try { + execSync(`cd "${cwd}" && ${BRU} ${args}`, { stdio: 'pipe' }); + return 0; + } catch (error: any) { + return error?.status ?? 1; + } + }; + + test('CLI: Should allow --env and --env-file to be used together', async () => { + const envFilePath = path.join(collectionPath, 'global-env.json'); + const outputPath = path.join(collectionPath, 'combined-out.json'); + + // This should NOT error out anymore - both options should be accepted + runFrom( + collectionPath, + `run request.bru --env CollectionEnv --env-file "${envFilePath}" --reporter-json "${outputPath}"` + ); + + // Check that the output file was created (command ran successfully) + expect(fs.existsSync(outputPath)).toBe(true); + + try { + fs.unlinkSync(outputPath); + } catch (_) {} + }); + + test('CLI: Collection env (--env) should override env-file variables', async () => { + const envFilePath = path.join(collectionPath, 'global-env.json'); + const outputPath = path.join(collectionPath, 'override-out.json'); + + runFrom( + collectionPath, + `run request.bru --env CollectionEnv --env-file "${envFilePath}" --reporter-json "${outputPath}"` + ); + + expect(fs.existsSync(outputPath)).toBe(true); + + const report = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + const result = report.results[0]; + + // baseUrl should be from collection env (https://echo.usebruno.com), not global (https://global.example.com) + expect(result.request.url).toBe('https://echo.usebruno.com'); + + // overrideVar should be from collection env, not global + const body = JSON.parse(result.request.data); + expect(body.overrideVar).toBe('collection-value'); + + // globalOnly should come from env-file since it's not in collection env + expect(body.globalOnly).toBe('from-global'); + + // collectionOnly should come from collection env + expect(body.collectionOnly).toBe('from-collection'); + + try { + fs.unlinkSync(outputPath); + } catch (_) {} + }); + + test('CLI: --env-file only should still work', async () => { + const envFilePath = path.join(collectionPath, 'global-env.json'); + const outputPath = path.join(collectionPath, 'envfile-only-out.json'); + + runFrom(collectionPath, `run request.bru --env-file "${envFilePath}" --reporter-json "${outputPath}"`); + + expect(fs.existsSync(outputPath)).toBe(true); + + const report = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + const result = report.results[0]; + + // Should use env-file values when --env is not provided + // baseUrl would be from global-env.json but the request would fail since it's not a real URL + // We just verify the interpolation happened + expect(result.request.url).toBe('https://global.example.com'); + + try { + fs.unlinkSync(outputPath); + } catch (_) {} + }); + + test('CLI: --env only should still work', async () => { + const outputPath = path.join(collectionPath, 'env-only-out.json'); + + runFrom(collectionPath, `run request.bru --env CollectionEnv --reporter-json "${outputPath}"`); + + expect(fs.existsSync(outputPath)).toBe(true); + + const report = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + const result = report.results[0]; + + // Should use collection env values + expect(result.request.url).toBe('https://echo.usebruno.com'); + + const body = JSON.parse(result.request.data); + expect(body.overrideVar).toBe('collection-value'); + expect(body.collectionOnly).toBe('from-collection'); + // globalOnly is not in collection env, so it should remain as template + expect(body.globalOnly).toBe('{{globalOnly}}'); + + try { + fs.unlinkSync(outputPath); + } catch (_) {} + }); +}); diff --git a/tests/runner/cli-env-combined/collection/bruno.json b/tests/runner/cli-env-combined/collection/bruno.json new file mode 100644 index 000000000..1c0a1b70d --- /dev/null +++ b/tests/runner/cli-env-combined/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "cli-env-combined-test", + "type": "collection" +} diff --git a/tests/runner/cli-env-combined/collection/environments/CollectionEnv.bru b/tests/runner/cli-env-combined/collection/environments/CollectionEnv.bru new file mode 100644 index 000000000..46ada4061 --- /dev/null +++ b/tests/runner/cli-env-combined/collection/environments/CollectionEnv.bru @@ -0,0 +1,5 @@ +vars { + baseUrl: https://echo.usebruno.com + overrideVar: collection-value + collectionOnly: from-collection +} diff --git a/tests/runner/cli-env-combined/collection/global-env.json b/tests/runner/cli-env-combined/collection/global-env.json new file mode 100644 index 000000000..c5d735660 --- /dev/null +++ b/tests/runner/cli-env-combined/collection/global-env.json @@ -0,0 +1,8 @@ +{ + "name": "GlobalEnv", + "variables": [ + { "name": "baseUrl", "value": "https://global.example.com", "enabled": true }, + { "name": "overrideVar", "value": "global-value", "enabled": true }, + { "name": "globalOnly", "value": "from-global", "enabled": true } + ] +} diff --git a/tests/runner/cli-env-combined/collection/request.bru b/tests/runner/cli-env-combined/collection/request.bru new file mode 100644 index 000000000..10bfed39f --- /dev/null +++ b/tests/runner/cli-env-combined/collection/request.bru @@ -0,0 +1,19 @@ +meta { + name: combined-env-test + type: http +} + +http { + method: POST + url: {{baseUrl}} + body: json + auth: none +} + +body:json { + { + "overrideVar": "{{overrideVar}}", + "globalOnly": "{{globalOnly}}", + "collectionOnly": "{{collectionOnly}}" + } +}