diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 261244949..6d4531587 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -6,6 +6,7 @@ const { getRunnerSummary } = require('@usebruno/common/runner'); const { exists, isFile, isDirectory } = require('../utils/filesystem'); const { runSingleRequest } = require('../runner/run-single-request'); const { getEnvVars } = require('../utils/bru'); +const { parseEnvironmentJson } = require('../utils/environment'); const { isRequestTagsIncluded } = require("@usebruno/common") const makeJUnitOutput = require('../reporters/junit'); const makeHtmlOutput = require('../reporters/html'); @@ -131,7 +132,7 @@ const builder = async (yargs) => { type: 'string' }) .option('env-file', { - describe: 'Path to environment file (.bru) - can be absolute or relative path', + describe: 'Path to environment file (.bru or .json) - absolute or relative', type: 'string' }) .option('env-var', { @@ -306,7 +307,7 @@ const handler = async function (argv) { clientCertConfigJson = JSON.parse(clientCertConfigFileContent); } catch (err) { console.error(chalk.red(`Failed to parse Client Certificate Config JSON: ${err.message}`)); - process.exit(constants.EXIT_STATUS.ERROR_INVALID_JSON); + process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE); } if (clientCertConfigJson?.enabled && Array.isArray(clientCertConfigJson?.certs)) { @@ -346,10 +347,29 @@ const handler = async function (argv) { process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND); } - 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; + const ext = path.extname(envFilePath).toLowerCase(); + if (ext === '.json') { + // Parse Bruno schema JSON environment + 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 { + // Default to .bru parsing + 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; + } } if (envVar) { diff --git a/packages/bruno-cli/src/constants.js b/packages/bruno-cli/src/constants.js index cdc0d47aa..75c1be81c 100644 --- a/packages/bruno-cli/src/constants.js +++ b/packages/bruno-cli/src/constants.js @@ -23,6 +23,8 @@ const EXIT_STATUS = { ERROR_INCORRECT_ENV_OVERRIDE: 8, // Invalid output format requested ERROR_INCORRECT_OUTPUT_FORMAT: 9, + // Invalid file format + ERROR_INVALID_FILE: 10, // Everything else ERROR_GENERIC: 255 }; diff --git a/packages/bruno-cli/src/utils/environment.js b/packages/bruno-cli/src/utils/environment.js new file mode 100644 index 000000000..8989b8917 --- /dev/null +++ b/packages/bruno-cli/src/utils/environment.js @@ -0,0 +1,26 @@ +/** + * Parse a Bruno JSON environment object and normalize variables + * Accepts only single environment object: { name?, uid?, variables: [...] } + */ +const parseEnvironmentJson = (parsed = {}) => { + if (!parsed || !Array.isArray(parsed.variables)) { + throw new Error('Invalid environment JSON: expected a single environment object with a "variables" array'); + } + + const normalized = { + name: parsed.name, + variables: (parsed.variables || []).filter(Boolean).map((variable) => ({ + name: variable.name, + value: variable.value, + type: variable.type || 'text', + enabled: variable.enabled !== false, + secret: variable.secret || false + })) + }; + + return normalized; +}; + +module.exports = { + parseEnvironmentJson +}; diff --git a/packages/bruno-cli/tests/utils/parse-environment-json.spec.js b/packages/bruno-cli/tests/utils/parse-environment-json.spec.js new file mode 100644 index 000000000..9eb466a97 --- /dev/null +++ b/packages/bruno-cli/tests/utils/parse-environment-json.spec.js @@ -0,0 +1,91 @@ +const { describe, it, expect } = require('@jest/globals'); +const { getEnvVars } = require('../../src/utils/bru'); +const { parseEnvironmentJson } = require('../../src/utils/environment'); + +describe('parseEnvironmentJson', () => { + it('normalizes single environment object', () => { + const input = { + name: 'My Env', + variables: [ + { name: 'host', value: 'https://www.httpfaker.org' }, + { name: 'token', value: 'abc', enabled: false, secret: true } + ] + }; + const env = parseEnvironmentJson(input); + expect(Array.isArray(env.variables)).toBe(true); + expect(env.variables[0]).toEqual({ + name: 'host', + value: 'https://www.httpfaker.org', + type: 'text', + enabled: true, + secret: false + }); + expect(env.variables[1].enabled).toBe(false); + expect(env.variables[1].secret).toBe(true); + + const vars = getEnvVars(env); + expect(vars).toEqual({ host: 'https://www.httpfaker.org' }); + }); + + it('throws on invalid shape', () => { + expect(() => parseEnvironmentJson({ name: 'x' })).toThrow(/Invalid environment JSON/i); + }); + + it('respects explicit fields and preserves secret flag', () => { + const input = { + name: 'My Env', + variables: [ + { name: 'one', value: '1', type: 'text', enabled: true, secret: true }, + { name: 'two', value: '2', type: 'file', enabled: false, secret: false } + ] + }; + const env = parseEnvironmentJson(input); + + expect(env.variables[0]).toEqual({ + name: 'one', + value: '1', + type: 'text', + enabled: true, + secret: true + }); + expect(env.variables[1]).toEqual({ + name: 'two', + value: '2', + type: 'file', + enabled: false, + secret: false + }); + + const vars = getEnvVars(env); + expect(vars).toEqual({ one: '1' }); + }); + + it('defaults secret to false for undefined and null', () => { + const input = { + name: 'My Env', + variables: [ + { name: 'three', value: '3', enabled: true }, + { name: 'four', value: '4', enabled: true, secret: null } + ] + }; + const env = parseEnvironmentJson(input); + + expect(env.variables[0]).toEqual({ + name: 'three', + value: '3', + type: 'text', + enabled: true, + secret: false + }); + expect(env.variables[1]).toEqual({ + name: 'four', + value: '4', + type: 'text', + enabled: true, + secret: false + }); + + const vars = getEnvVars(env); + expect(vars).toEqual({ three: '3', four: '4' }); + }); +}); diff --git a/tests/runner/cli-json-env-file/cli-json-env-file.spec.ts b/tests/runner/cli-json-env-file/cli-json-env-file.spec.ts new file mode 100644 index 000000000..080dedc8b --- /dev/null +++ b/tests/runner/cli-json-env-file/cli-json-env-file.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '../../../playwright'; +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import constants from '../../../packages/bruno-cli/src/constants.js'; + +test.describe('CLI JSON Environment File Support', () => { + const collectionPath = path.resolve(__dirname, 'collection'); + const BRU = 'node ../../../../packages/bruno-cli/bin/bru.js'; + + // Helper: emulate `bru run` from a given working directory and + // return the process exit code (0 on success). We use execSync so + // these tests behave like invoking the CLI directly in a shell. + 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: Run with invalid JSON environment file should fail', async () => { + // Create a temporary invalid JSON file + const tempDir = '/tmp/bruno-cli-test'; + const invalidEnvPath = path.join(tempDir, 'invalid-env.json'); + + fs.mkdirSync(tempDir, { recursive: true }); + fs.writeFileSync(invalidEnvPath, + JSON.stringify({ + name: 'Invalid Env' + // missing variables array - invalid JSON + })); + + const status = runFrom(collectionPath, `run --env-file "${invalidEnvPath}"`); + expect(status).toBe(constants.EXIT_STATUS.ERROR_INVALID_FILE); + try { + // Cleanup + fs.unlinkSync(invalidEnvPath); + fs.rmdirSync(tempDir); + } catch (e) {} + }); + + test('CLI: Run with valid JSON env and interpolates variables', async () => { + const envPath = path.join(collectionPath, 'env.json'); + const outputPath = path.join(collectionPath, 'out.json'); + + // Even if exit is non-zero (network warnings), reporter should be written + runFrom(collectionPath, `run request.bru --env-file "${envPath}" --reporter-json "${outputPath}"`); + + expect(fs.existsSync(outputPath)).toBe(true); + const report = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + const result = report.results[0]; + expect(result.request.url).toBe('https://echo.usebruno.com'); + expect(result.response.status).toBe(200); + + try { + fs.unlinkSync(outputPath); + } catch (_) {} + }); +}); diff --git a/tests/runner/cli-json-env-file/collection/bruno.json b/tests/runner/cli-json-env-file/collection/bruno.json new file mode 100644 index 000000000..368998d23 --- /dev/null +++ b/tests/runner/cli-json-env-file/collection/bruno.json @@ -0,0 +1,6 @@ +{ + "version": "1", + "name": "cli-json-env-fixture", + "type": "collection" +} + diff --git a/tests/runner/cli-json-env-file/collection/env.json b/tests/runner/cli-json-env-file/collection/env.json new file mode 100644 index 000000000..a61a14007 --- /dev/null +++ b/tests/runner/cli-json-env-file/collection/env.json @@ -0,0 +1,4 @@ +{ + "name": "CLI JSON Env", + "variables": [{ "name": "baseUrl", "value": "https://echo.usebruno.com", "enabled": true }] +} diff --git a/tests/runner/cli-json-env-file/collection/request.bru b/tests/runner/cli-json-env-file/collection/request.bru new file mode 100644 index 000000000..278e47876 --- /dev/null +++ b/tests/runner/cli-json-env-file/collection/request.bru @@ -0,0 +1,17 @@ +meta { + name: cli-json-env-test + type: http +} + +http { + method: POST + url: {{baseUrl}} + body: json + auth: none +} + +body:json { + { + "ping": "pong" + } +} \ No newline at end of file