@usebruno/cli
Version:
With Bruno CLI, you can now run your API collections with ease using simple command line commands.
1,285 lines (1,161 loc) • 60.4 kB
JavaScript
const fs = require('fs');
const chalk = require('chalk');
const path = require('path');
const yaml = require('js-yaml');
const { forOwn, cloneDeep } = require('lodash');
const { getRunnerSummary } = require('@usebruno/common/runner');
const { exists, parseCSV, stripExtension } = 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');
const { getOptions } = require('../utils/bru');
const { parseDotEnv, parseEnvironment } = require('@usebruno/filestore');
const { getExternalSecretsData } = require('../utils/external-secrets');
const { sanitizeRunnerResults } = require('../utils/sanitize-results');
const { interpolateString } = require('../runner/interpolate-string');
const constants = require('../constants');
const Table = require('cli-table3');
const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG } = require('../utils/collection');
const { hasExecutableTestInScript } = require('../utils/request');
const { createSkippedFileResults } = require('../utils/run');
const { sanitizeResultsForReporter } = require('../utils/sanitize-results');
const { maybePrintEnvVarWriteWarning } = require('../utils/env-var-write-warning');
const { getSystemProxy } = require('@usebruno/requests');
const command = 'run [paths...]';
const desc = 'Run one or more requests/folders';
/**
* Calculates a success rate for an iteration as a weighted average across requests.
*
* Rationale:
* - Each non-skipped request contributes equally (uniform per-request weight).
* - A request's success is the fraction of its checks that passed (tests + assertions).
* - Requests with no checks are considered fully successful unless the request errored.
*
* Steps:
* 1) Exclude skipped requests from the calculation.
* 2) Split 100% equally among remaining requests to get per-request weight.
* 3) For each request:
* - If checks exist: contribution = (passedChecks / totalChecks) * requestWeight.
* - If no checks: contribution = requestWeight when no error, else 0.
* 4) Sum contributions, return as percentage string with one decimal.
*/
const calculateSuccessRate = (iteration) => {
const iterationResults = Array.isArray(iteration.results)
? iteration.results
: [];
// Exclude skipped requests from the calculation
const eligibleRequests = iterationResults.filter((res) => !res.skipped);
if (eligibleRequests.length === 0) {
return '0%';
}
// Distribute 100% evenly across eligible requests
const PERCENT_SCALE = 100;
const weightPerRequest = PERCENT_SCALE / eligibleRequests.length;
const computeRequestContribution = (requestResult, requestWeight) => {
const tests = Array.isArray(requestResult.testResults)
? requestResult.testResults
: [];
const assertions = Array.isArray(requestResult.assertionResults)
? requestResult.assertionResults
: [];
const totalChecks = tests.length + assertions.length;
if (totalChecks > 0) {
const passedChecks
= tests.filter((t) => t.status === 'pass').length
+ assertions.filter((a) => a.status === 'pass').length;
return (passedChecks / totalChecks) * requestWeight;
}
// No checks; full credit if no error, else zero
return requestResult?.error ? 0 : requestWeight;
};
const totalSuccessWeight = eligibleRequests.reduce(
(acc, req) => acc + computeRequestContribution(req, weightPerRequest),
0
);
return `${totalSuccessWeight.toFixed(1)}%`;
};
// Formats the Requests cell as: "<total> (<non-zero breakdown>)"
// Example: 10 (8 Passed, 1 Failed, 1 Skipped, 0 Skipped (Bail))
// User-skipped (bru.runner.skipRequest) and bail-skipped (never reached) render distinctly.
function formatRequestsCellFromSummary(summary) {
const total = summary.totalRequests || 0;
const passed = summary.passedRequests || 0;
const failedOrErrored = (summary.failedRequests || 0) + (summary.errorRequests || 0);
const totalSkipped = summary.skippedRequests || 0;
const skippedByBail = summary.skippedByBail || 0;
const skippedByUser = summary.skippedByUser != null
? summary.skippedByUser
: Math.max(totalSkipped - skippedByBail, 0);
const parts = [];
if (passed > 0) {
parts.push(chalk.green(`${passed} Passed`));
}
if (failedOrErrored > 0) {
parts.push(chalk.red(`${failedOrErrored} Failed`));
}
if (skippedByUser > 0) {
parts.push(chalk.magenta(`${skippedByUser} Skipped`));
}
if (skippedByBail > 0) {
parts.push(chalk.hex(constants.COLORS.ORANGE)(`${skippedByBail} Skipped (Bail)`));
}
return parts.length ? `${total} (${parts.join(', ')})` : `${total}`;
}
const printGenericTable = (headers, rows, title) => {
const colAligns = headers.map((_, idx) => (idx === 0 ? 'left' : 'center'));
const table = new Table({ head: headers, style: { head: [], border: [] }, colAligns });
rows.forEach((row) => table.push(row));
console.log('\n' + chalk.bold(title));
console.log(table.toString());
};
// Prints the "⚠ Bail summary" footer block when bail is enabled and at least one iteration bailed.
function printBailSummaryBlock(results, isParallelRun) {
const bailedIterations = results.filter((it) => it.bailed);
if (bailedIterations.length === 0) return;
const skippedIterations = results.filter((it) => it.skipped);
const totalBailSkippedRequests = results.reduce(
(acc, it) => acc + (it.summary?.skippedByBail || 0),
0
);
const bullet = chalk.dim('•');
console.log('\n' + chalk.hex(constants.COLORS.ORANGE).bold('⚠ Bail summary'));
bailedIterations.forEach((it) => {
console.log(
` ${bullet} Iteration #${it.iterationIndex + 1} bailed: ${it.bailReason} in "${it.bailedAt}"`
);
});
if (skippedIterations.length > 0) {
const sortedIndexes = skippedIterations.map((it) => it.iterationIndex + 1).sort((a, b) => a - b);
const firstSkipped = sortedIndexes[0];
const lastSkipped = sortedIndexes[sortedIndexes.length - 1];
const range = firstSkipped === lastSkipped ? `#${firstSkipped}` : `#${firstSkipped}–#${lastSkipped}`;
console.log(` ${bullet} Iterations ${range} skipped because bail halted the run`);
}
if (totalBailSkippedRequests > 0) {
console.log(` ${bullet} ${totalBailSkippedRequests} request(s) skipped due to bail`);
}
}
// Prints a summary table for single run results
function printSingleRunTable(results) {
const iteration = results[0];
const summary = iteration.summary;
const duration = Math.round(
iteration.results.reduce(
(acc, res) => acc + (res.runDuration || 0),
0
) * 1000
);
const hasFailures
= summary.failedRequests > 0
|| summary.failedAssertions > 0
|| summary.failedTests > 0
|| (summary.errorRequests || 0) > 0;
const status = hasFailures
? chalk.red.bold('✗ FAIL')
: chalk.green.bold('✓ PASS');
const requests = formatRequestsCellFromSummary(summary);
const tests = `${summary.passedTests}/${summary.totalTests}`;
const assertions = `${summary.passedAssertions}/${summary.totalAssertions}`;
const headers = [chalk.bold('Metric'), chalk.bold('Result')];
const rows = [
['Status', status],
['Requests', requests],
['Tests', tests],
['Assertions', assertions],
['Duration (ms)', duration]
];
printGenericTable(headers, rows, '📊 Execution Summary');
}
// Prints a summary table for iteration mode results
function printIterationTable(results, isParallelRun, bailEnabled = false) {
const headers = [
chalk.bold('Iteration'),
chalk.bold('Status'),
chalk.bold('Requests'),
chalk.bold('Tests'),
chalk.bold('Assertions'),
chalk.bold('Duration (ms)')
];
const aggregateTotals = {
requests: { total: 0, passed: 0, failed: 0, skippedUser: 0, skippedBail: 0 },
tests: { total: 0, passed: 0, failed: 0 },
assertions: { total: 0, passed: 0, failed: 0 },
duration: 0,
allPassed: true
};
const rows = [];
results.forEach((iteration, idx) => {
const summary = iteration.summary;
const duration = iteration.results.reduce(
(acc, res) => acc + (res.response?.responseTime || 0),
0
);
const hasFailures
= summary.failedRequests > 0
|| summary.failedAssertions > 0
|| summary.failedTests > 0
|| (summary.errorRequests || 0) > 0;
// An iteration that contains any bail-skipped request was interrupted by bail
// (in-flight requests aborted, or the rest of the iteration never ran). Even if
// its completed requests passed, the iteration as a whole did not finish — show
// it as ⚠ BAILED rather than ✓ PASS.
const touchedByBail = (summary.skippedByBail || 0) > 0
|| iteration.bailed
|| iteration.haltedBySibling;
let statusCell;
if (iteration.skipped) {
statusCell = chalk.hex(constants.COLORS.ORANGE).bold('⊘ SKIP (Bail)');
aggregateTotals.allPassed = false;
} else if (hasFailures) {
statusCell = chalk.red.bold('✗ FAIL');
aggregateTotals.allPassed = false;
} else if (touchedByBail) {
statusCell = chalk.hex(constants.COLORS.ORANGE).bold('⚠ BAILED');
aggregateTotals.allPassed = false;
} else {
statusCell = chalk.green.bold('✓ PASS');
}
const requests = formatRequestsCellFromSummary(summary);
const tests = `${summary.passedTests}/${summary.totalTests}`;
const assertions = `${summary.passedAssertions}/${summary.totalAssertions}`;
const durationCell = iteration.skipped ? '—' : duration;
rows.push([
`#${idx + 1}`,
statusCell,
requests,
tests,
assertions,
durationCell
]);
// accumulate
aggregateTotals.requests.total += summary.totalRequests;
aggregateTotals.requests.passed += summary.passedRequests;
const { failedRequests = 0, errorRequests = 0 } = summary;
aggregateTotals.requests.failed += failedRequests + errorRequests;
const bailSkipped = summary.skippedByBail || 0;
const userSkipped = summary.skippedByUser != null
? summary.skippedByUser
: Math.max((summary.skippedRequests || 0) - bailSkipped, 0);
aggregateTotals.requests.skippedUser += userSkipped;
aggregateTotals.requests.skippedBail += bailSkipped;
aggregateTotals.tests.total += summary.totalTests;
aggregateTotals.tests.passed += summary.passedTests;
aggregateTotals.tests.failed += summary.failedTests;
aggregateTotals.assertions.total += summary.totalAssertions;
aggregateTotals.assertions.passed += summary.passedAssertions;
aggregateTotals.assertions.failed += summary.failedAssertions;
if (!iteration.skipped) {
aggregateTotals.duration += duration;
}
});
const totalRequestsFormatted = formatRequestsCellFromSummary({
totalRequests: aggregateTotals.requests.total,
passedRequests: aggregateTotals.requests.passed,
failedRequests: aggregateTotals.requests.failed,
errorRequests: 0,
skippedRequests: aggregateTotals.requests.skippedUser + aggregateTotals.requests.skippedBail,
skippedByUser: aggregateTotals.requests.skippedUser,
skippedByBail: aggregateTotals.requests.skippedBail
});
const totalRowDuration = isParallelRun
? Math.max(
...results
.filter((iter) => !iter.skipped)
.map((iter) =>
iter.results.reduce(
(acc, res) => acc + (res.response?.responseTime || 0),
0
)
),
0
)
: aggregateTotals.duration;
const overallStatusCell = aggregateTotals.allPassed
? chalk.green.bold('✓ PASS')
: chalk.red.bold('✗ FAIL');
rows.push([
chalk.bold('TOTAL'),
chalk.bold(overallStatusCell),
chalk.bold(totalRequestsFormatted),
chalk.bold(`${aggregateTotals.tests.passed}/${aggregateTotals.tests.total}`),
chalk.bold(`${aggregateTotals.assertions.passed}/${aggregateTotals.assertions.total}`),
chalk.bold(totalRowDuration)
]);
if (bailEnabled) {
printBailSummaryBlock(results, isParallelRun);
}
printGenericTable(headers, rows, '📊 Iteration Summary Table');
}
/**
* Prints a summary table for run results.
*
* Modes:
* - Single run: shows key metrics and status.
* - Iteration mode: one row per iteration plus a TOTAL row.
*
* Notes:
* - Success Rate uses `calculateSuccessRate`, which gives equal weight to each non-skipped request,
* based on the fraction of passed checks (tests + assertions).
* - In parallel iteration mode, the TOTAL duration is the longest iteration duration (not the sum).
*/
function printUnifiedSummaryTable(
results,
isIterationMode = false,
isParallelRun = false,
bailEnabled = false
) {
if (!isIterationMode) {
printSingleRunTable(results);
} else {
printIterationTable(results, isParallelRun, bailEnabled);
}
}
const getJsSandboxRuntime = (sandbox) => {
return sandbox === 'safe' ? 'quickjs' : 'nodevm';
};
const builder = async (yargs) => {
yargs
.option('r', {
describe: 'Indicates a recursive run',
type: 'boolean',
default: false
})
.option('cacert', {
type: 'string',
description: 'CA certificate to verify peer against'
})
.option('ignore-truststore', {
type: 'boolean',
default: false,
description:
'The specified custom CA certificate (--cacert) will be used exclusively and the default truststore is ignored, if this option is specified. Evaluated in combination with "--cacert" only.'
})
.option('disable-cookies', {
type: 'boolean',
default: false,
description: 'Automatically save and sent cookies with requests'
})
.option('env', {
describe: 'Environment variables',
type: 'string'
})
.option('env-file', {
describe: 'Path to environment file (.bru or .json) - absolute or relative',
type: 'string'
})
.option('global-env', {
describe: 'Global environment name (requires collection to be in a workspace)',
type: 'string'
})
.option('workspace-path', {
describe: 'Path to workspace directory (auto-detected if not provided)',
type: 'string'
})
.option('env-var', {
describe: 'Overwrite a single environment variable, multiple usages possible',
type: 'string'
})
.option('sandbox', {
describe: 'Javascript sandbox to use; available sandboxes are "safe" (default) or "developer"',
default: 'safe',
type: 'string'
})
.option('output', {
alias: 'o',
describe: 'Path to write file results to',
type: 'string'
})
.option('format', {
alias: 'f',
describe: 'Format of the file results; available formats are "json" (default), "junit" or "html"',
default: 'json',
type: 'string'
})
.option('reporter-json', {
describe: 'Path to write json file results to',
type: 'string'
})
.option('reporter-junit', {
describe: 'Path to write junit file results to',
type: 'string'
})
.option('reporter-html', {
describe: 'Path to write html file results to',
type: 'string'
})
.option('insecure', {
type: 'boolean',
description: 'Allow insecure server connections'
})
.option('tests-only', {
type: 'boolean',
description: 'Only run requests that have a test or active assertion'
})
.option('bail', {
type: 'boolean',
description: 'Stop execution after a failure of a request, test, or assertion'
})
.option('parallel', {
type: 'boolean',
description: 'Execute CSV iterations in parallel instead of sequentially'
})
.option('verbose', {
type: 'boolean',
description: 'Allow verbose output for debugging purposes'
})
.option('csv-file-path', {
describe: 'Path to the CSV file',
type: 'string'
})
.option('json-file-path', {
describe: 'Path to the JSON data file',
type: 'string'
})
.option('iteration-count', {
describe: 'Number of iterations',
type: 'string'
})
.option('reporter-skip-all-headers', {
type: 'boolean',
description: 'Omit headers from the reporter output',
default: false
})
.option('reporter-skip-headers', {
type: 'array',
description: 'Skip specific headers from the reporter output',
default: []
})
.option('reporter-skip-request-body', {
type: 'boolean',
description: 'Omit request body from the reporter output',
default: false
})
.option('reporter-skip-response-body', {
type: 'boolean',
description: 'Omit response body from the reporter output',
default: false
})
.option('reporter-skip-body', {
type: 'boolean',
description: 'Omit both request and response bodies from the reporter output',
default: false
})
.option('client-cert-config', {
type: 'string',
description: 'Path to the Client certificate config file used for securing the connection in the request'
})
.option('noproxy', {
type: 'boolean',
description: 'Disable all proxy settings (both collection-defined and system proxies)',
default: false
})
.option('cache-ssl-session', {
type: 'boolean',
description: 'Enable SSL session caching — reuses TLS sessions across requests for faster handshakes',
default: false
})
.option('delay', {
type: 'number',
description: 'Delay between each requests (in miliseconds)'
})
.option('tags', {
type: 'string',
description: 'Tags to include in the run'
})
.option('exclude-tags', {
type: 'string',
description: 'Tags to exclude from the run'
})
.option('verbose', {
type: 'boolean',
description: 'Allow verbose output for debugging purposes'
})
.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 request.bru --env-file env.bru', 'Run a request with the environment from env.bru file')
.example('$0 run folder', 'Run all requests in a folder')
.example('$0 run folder -r', 'Run all requests in a folder recursively')
.example('$0 run request.bru folder', 'Run a request and all requests in a folder')
.example('$0 run --reporter-skip-all-headers', 'Run all requests in a folder recursively with omitted headers from the reporter output')
.example('$0 run --reporter-skip-request-body', 'Run all requests with request bodies omitted from the reporter output')
.example('$0 run --reporter-skip-response-body', 'Run all requests with response bodies omitted from the reporter output')
.example('$0 run --reporter-skip-body', 'Run all requests with both request and response bodies omitted from the reporter output')
.example(
'$0 run --reporter-skip-headers "Authorization"',
'Run all requests in a folder recursively with skipped headers from the reporter output'
)
.example(
'$0 run request.bru --env local --env-var secret=xxx',
'Run a request with the environment set to local and overwrite the variable secret with value xxx'
)
.example(
'$0 run request.bru --output results.json',
'Run a request and write the results to results.json in the current directory'
)
.example(
'$0 run request.bru --output results.xml --format junit',
'Run a request and write the results to results.xml in junit format in the current directory'
)
.example(
'$0 run request.bru --output results.html --format html',
'Run a request and write the results to results.html in html format in the current directory'
)
.example(
'$0 run request.bru --reporter-junit results.xml --reporter-html results.html',
'Run a request and write the results to results.html in html format and results.xml in junit format in the current directory'
)
.example('$0 run request.bru --tests-only', 'Run all requests that have a test')
.example(
'$0 run request.bru --cacert myCustomCA.pem',
'Use a custom CA certificate in combination with the default truststore when validating the peer of this request.'
)
.example(
'$0 run folder --cacert myCustomCA.pem --ignore-truststore',
'Use a custom CA certificate exclusively when validating the peers of the requests in the specified folder.'
)
.example('$0 run --client-cert-config client-cert-config.json', 'Run a request with Client certificate configurations')
.example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.')
.example('$0 run --noproxy', 'Run requests with system proxy disabled')
.example(
'$0 run folder --tags=hello,world --exclude-tags=skip',
'Run only requests with tags "hello" or "world" and exclude any request with tag "skip".'
)
.example(
'$0 run folder --csv-file-path data.csv --parallel',
'Run all requests in a folder with CSV data in parallel instead of sequentially.'
)
.example(
'$0 run request.bru --global-env production',
'Run a request with the global environment set to production'
)
.example(
'$0 run request.bru --global-env production --workspace-path /path/to/workspace',
'Run a request with a global environment from the specified workspace'
);
};
const handler = async function (argv) {
try {
let {
paths,
cacert,
ignoreTruststore,
disableCookies,
env,
envFile,
globalEnv,
workspacePath,
envVar,
insecure,
r: recursive,
output: outputPath,
format,
reporterJson,
reporterJunit,
reporterHtml,
sandbox,
testsOnly,
bail,
verbose,
csvFilePath,
jsonFilePath,
iterationCount = 1,
reporterSkipAllHeaders,
reporterSkipHeaders,
reporterSkipRequestBody,
reporterSkipResponseBody,
reporterSkipBody,
clientCertConfig,
noproxy,
cacheSslSession,
delay,
tags: includeTags,
excludeTags,
parallel
} = argv;
const collectionPath = process.cwd();
let collection = createCollectionJsonFromPathname(collectionPath);
const { root: collectionRoot, brunoConfig } = collection;
if (clientCertConfig) {
try {
const clientCertConfigExists = await exists(clientCertConfig);
if (!clientCertConfigExists) {
console.error(chalk.red(`Client Certificate Config file "${clientCertConfig}" does not exist.`));
process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND);
}
const clientCertConfigFileContent = fs.readFileSync(clientCertConfig, 'utf8');
let clientCertConfigJson;
try {
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_FILE);
}
if (clientCertConfigJson?.enabled && Array.isArray(clientCertConfigJson?.certs)) {
if (brunoConfig.clientCertificates) {
brunoConfig.clientCertificates.certs.push(...clientCertConfigJson.certs);
} else {
brunoConfig.clientCertificates = { certs: clientCertConfigJson.certs };
}
console.log(chalk.green(`Client certificates has been added`));
} else {
console.warn(chalk.yellow(`Client certificate configuration is enabled, but it either contains no valid "certs" array or the added configuration has been set to false`));
}
} catch (err) {
console.error(chalk.red(`Unexpected error: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_GENERIC);
}
}
let runtimeVariables = {};
let envVars = {};
let dotEnvSecrets = [];
let externalSecretVariables = {};
// Track secret variable names from the selected environment and any overridden secret values
let envSecretNamesSet = new Set();
let overriddenSecretValues = [];
// Helper to load environment variables from a file
const loadEnvFromFile = (filePath, nameOverride) => {
const fileExt = path.extname(filePath).toLowerCase();
let result = {};
let secretNames = new Set();
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');
// Collect secret var names for masking decisions
secretNames = new Set((normalizedEnv?.variables || [])
.filter((v) => v && v.secret === true && v.name)
.map((v) => v.name));
} 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);
// Collect secret var names for masking decisions
secretNames = new Set((envJson?.variables || [])
.filter((v) => v && v.secret === true && v.name)
.map((v) => v.name));
} else {
const content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
const envJson = parseEnvironment(content, { format: 'bru' });
result = getEnvVars(envJson);
result.__name__ = nameOverride || path.basename(filePath, '.bru');
// Collect secret var names for masking decisions
secretNames = new Set((envJson?.variables || [])
.filter((v) => v && v.secret === true && v.name)
.map((v) => v.name));
}
return { vars: result, secretNames };
};
// 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 {
const { vars, secretNames } = loadEnvFromFile(envFilePath);
envVars = vars;
// Merge secret names from this environment file
secretNames.forEach((name) => envSecretNamesSet.add(name));
} 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 { vars: collectionEnvVars, secretNames } = loadEnvFromFile(collectionEnvFilePath, env);
envVars = { ...envVars, ...collectionEnvVars };
// Merge secret names from this environment file
secretNames.forEach((name) => envSecretNamesSet.add(name));
} catch (err) {
console.error(chalk.red(`Failed to parse Environment file: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE);
}
}
let globalEnvVars = {};
if (globalEnv) {
const findWorkspacePath = (startPath) => {
let currentPath = startPath;
while (currentPath !== path.dirname(currentPath)) {
const workspaceYmlPath = path.join(currentPath, 'workspace.yml');
if (fs.existsSync(workspaceYmlPath)) {
return currentPath;
}
currentPath = path.dirname(currentPath);
}
return null;
};
if (!workspacePath) {
workspacePath = findWorkspacePath(collectionPath);
}
if (!workspacePath) {
console.error(chalk.red(`Workspace not found. Please specify a workspace path using --workspace-path or ensure the collection is inside a workspace directory.`));
process.exit(constants.EXIT_STATUS.ERROR_GLOBAL_ENV_REQUIRES_WORKSPACE);
}
const workspaceExists = await exists(workspacePath);
if (!workspaceExists) {
console.error(chalk.red(`Workspace path not found: `) + chalk.dim(workspacePath));
process.exit(constants.EXIT_STATUS.ERROR_WORKSPACE_NOT_FOUND);
}
const workspaceYmlPath = path.join(workspacePath, 'workspace.yml');
const workspaceYmlExists = await exists(workspaceYmlPath);
if (!workspaceYmlExists) {
console.error(chalk.red(`Invalid workspace: workspace.yml not found in `) + chalk.dim(workspacePath));
process.exit(constants.EXIT_STATUS.ERROR_WORKSPACE_NOT_FOUND);
}
const globalEnvFilePath = path.join(workspacePath, 'environments', `${globalEnv}.yml`);
const globalEnvFileExists = await exists(globalEnvFilePath);
if (!globalEnvFileExists) {
console.error(chalk.red(`Global environment not found: `) + chalk.dim(`environments/${globalEnv}.yml`));
console.error(chalk.dim(`Workspace: ${workspacePath}`));
process.exit(constants.EXIT_STATUS.ERROR_GLOBAL_ENV_NOT_FOUND);
}
try {
const globalEnvContent = fs.readFileSync(globalEnvFilePath, 'utf8');
const globalEnvJson = parseEnvironment(globalEnvContent, { format: 'yml' });
globalEnvVars = getEnvVars(globalEnvJson);
globalEnvVars.__name__ = globalEnv;
// Collect secret var names from global environment
const globalSecretNames = new Set((globalEnvJson?.variables || [])
.filter((v) => v && v.secret === true && v.name)
.map((v) => v.name));
// Merge secret names from global environment
globalSecretNames.forEach((name) => envSecretNamesSet.add(name));
} catch (err) {
console.error(chalk.red(`Failed to parse global environment: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE);
}
}
if (envVar) {
let processVars;
if (typeof envVar === 'string') {
processVars = [envVar];
} else if (typeof envVar === 'object' && Array.isArray(envVar)) {
processVars = envVar;
} else {
console.error(chalk.red(`overridable environment variables not parsable: use name=value`));
process.exit(constants.EXIT_STATUS.ERROR_MALFORMED_ENV_OVERRIDE);
}
if (processVars && Array.isArray(processVars)) {
for (const value of processVars.values()) {
// split the string at the first equals sign
const match = value.match(/^([^=]+)=(.*)$/);
if (!match) {
console.error(
chalk.red(`Overridable environment variable not correct: use name=value - presented: `)
+ chalk.dim(`${value}`)
);
process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_ENV_OVERRIDE);
}
// Track secret values for report masking: if the overridden variable name exists in the environment's vars:secret block, add its value to the masking list
// match[1] = variable name (e.g., "clientid" from --env-var clientid=12345)
// match[2] = variable value (e.g., "12345" from --env-var clientid=12345)
if (envSecretNamesSet && envSecretNamesSet.has(match[1]) && match[2] !== undefined) {
const v = String(match[2]);
if (v.length) overriddenSecretValues.push(v);
}
envVars[match[1]] = match[2];
}
}
}
const options = getOptions();
if (bail) {
options['bail'] = true;
}
if (insecure) {
options['insecure'] = true;
}
if (disableCookies) {
options['disableCookies'] = true;
}
if (noproxy) {
options['noproxy'] = true;
}
if (cacheSslSession) {
options['cacheSslSession'] = true;
}
if (verbose) {
options['verbose'] = true;
}
if (cacert && cacert.length) {
if (insecure) {
console.error(chalk.red(`Ignoring the cacert option since insecure connections are enabled`));
} else {
const pathExists = await exists(cacert);
if (pathExists) {
options['cacert'] = cacert;
} else {
console.error(chalk.red(`Cacert File ${cacert} does not exist`));
}
}
}
options['ignoreTruststore'] = ignoreTruststore;
if (verbose) {
options['verbose'] = true;
}
// Fetch system proxy once for all requests (skip if --noproxy flag is set)
if (!noproxy) {
try {
options['cachedSystemProxy'] = await getSystemProxy();
} catch (error) {
console.warn(chalk.yellow('Failed to detect system proxy, continuing without system proxy'));
}
}
includeTags = includeTags ? includeTags.split(',') : [];
excludeTags = excludeTags ? excludeTags.split(',') : [];
if (['json', 'junit', 'html'].indexOf(format) === -1) {
console.error(chalk.red(`Format must be one of "json", "junit or "html"`));
process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_OUTPUT_FORMAT);
}
let formats = {};
// Maintains back compat with --format and --output
if (outputPath && outputPath.length) {
formats[format] = outputPath;
}
if (reporterHtml && reporterHtml.length) {
formats['html'] = reporterHtml;
}
if (reporterJson && reporterJson.length) {
formats['json'] = reporterJson;
}
if (reporterJunit && reporterJunit.length) {
formats['junit'] = reporterJunit;
}
// load .env file at root of collection if it exists
const dotEnvPath = path.join(collectionPath, '.env');
const dotEnvExists = await exists(dotEnvPath);
const processEnvVars = {
...process.env
};
if (dotEnvExists) {
const content = fs.readFileSync(dotEnvPath, 'utf8');
const jsonData = parseDotEnv(content);
// Treat .env values as secrets for value-based masking in CLI
dotEnvSecrets = Object.values(jsonData)
.filter((v) => v !== undefined && v !== null && String(v).length > 0)
.map((v) => String(v));
forOwn(jsonData, (value, key) => {
processEnvVars[key] = value;
});
}
if (env) {
const externalSecretsJsonPath = path.join(collectionPath, 'secrets.json');
const externalSecretsExists = await exists(externalSecretsJsonPath);
if (externalSecretsExists) {
const externalSecretsContent = fs.readFileSync(externalSecretsJsonPath, 'utf8');
const interpolationOptions = {
envVars,
runtimeVariables,
processEnvVars
};
const externalSecretsInterpolatedContent = interpolateString(externalSecretsContent, interpolationOptions);
const externalSecretsJson = JSON.parse(externalSecretsInterpolatedContent);
const {
cli: externalSecretsProviderConfig,
data: externalSecretsPathsData,
type: externalSecretsType
} = externalSecretsJson;
const externalSecretsForCurrentEnvironment = externalSecretsPathsData?.find(
(d) => d?.environment === env
)?.secrets;
const externalSecretsPathsForCurrentEnvironment = externalSecretsForCurrentEnvironment?.map((s) => s.path);
if (externalSecretsProviderConfig && externalSecretsPathsForCurrentEnvironment) {
verbose && console.log(chalk.yellow('Fetching external secrets... \n'));
try {
const secrets = await getExternalSecretsData({
type: externalSecretsType,
config: externalSecretsProviderConfig,
paths: externalSecretsPathsForCurrentEnvironment,
debug: verbose
});
secrets.forEach((s, idx) => {
if (s?.error) {
verbose
&& console.error(
chalk.red(`${idx + 1}. Couldn't fetch secret for path: ${s?.path}: `)
+ chalk.dim(`${JSON.stringify(s?.error)}`)
);
return;
}
verbose && console.log(chalk.yellow(`${idx + 1}. Fetched secret for path: ${s?.path} \n`));
let secretName = externalSecretsForCurrentEnvironment?.find((x) => x.path === s.path)?.name;
if (typeof s?.data === 'string' || typeof s?.data === 'number') {
externalSecretVariables[`$secrets.${secretName}`] = s?.data;
} else {
Object.entries(s?.data).forEach(([key, value]) => {
externalSecretVariables[`$secrets.${secretName}.${key}`] = value;
});
}
});
} catch (err) {
verbose && console.error(chalk.red(`Fetching external secrets failed: `) + chalk.dim(`${err.message}`));
}
}
}
}
let requestItems = [];
let results = [];
if (!paths || !paths.length) {
paths = ['./'];
recursive = true;
}
const resolvedPaths = paths.map((p) => path.resolve(process.cwd(), p));
for (const resolvedPath of resolvedPaths) {
const pathExists = await exists(resolvedPath);
if (!pathExists) {
console.error(chalk.red(`Path not found: ${resolvedPath}`));
process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND);
}
}
requestItems = getCallStack(resolvedPaths, collection, { recursive });
if (testsOnly) {
requestItems = requestItems.filter((item) => {
const requestHasTests = hasExecutableTestInScript(item.request?.tests);
const requestHasActiveAsserts = item.request?.assertions.some((x) => x.enabled) || false;
const preRequestScript = item.request?.script?.req;
const requestHasPreRequestTests = hasExecutableTestInScript(preRequestScript);
const postResponseScript = item.request?.script?.res;
const requestHasPostResponseTests = hasExecutableTestInScript(postResponseScript);
return requestHasTests || requestHasActiveAsserts || requestHasPreRequestTests || requestHasPostResponseTests;
});
}
requestItems = requestItems.filter((item) => {
return isRequestTagsIncluded(item.tags, includeTags, excludeTags);
});
maybePrintEnvVarWriteWarning(requestItems, collectionRoot, collection);
let csvFileData = [];
if (csvFilePath) {
const csvPathExists = await exists(csvFilePath);
if (!csvPathExists) {
console.error(chalk.red(`CSV file ${csvFilePath} does not exist`));
process.exit(constants.EXIT_STATUS.ERROR_CSV_FILE_NOT_FOUND);
}
const csvData = fs.readFileSync(csvFilePath, 'utf8');
csvFileData = await parseCSV(csvData);
iterationCount = csvFileData?.length;
}
let jsonFileData = [];
if (jsonFilePath) {
const jsonPathExists = await exists(jsonFilePath);
if (!jsonPathExists) {
console.error(chalk.red(`CSV file ${jsonFilePath} does not exist`));
process.exit(constants.EXIT_STATUS.ERROR_JSON_FILE_NOT_FOUND);
}
const jsonData = fs.readFileSync(jsonFilePath, 'utf8');
jsonFileData = JSON.parse(jsonData);
iterationCount = jsonFileData?.length;
}
let iterationRunResults = [];
// Shared halt signal so any iteration that bails can halt the whole parallel run,
// matching sequential --bail behaviour. Iterations still running will stop starting
// new requests; in-flight requests are aborted via the AbortController below.
const runHaltState = {
halted: false,
bailedIterationIndex: null,
bailedAt: null,
bailReason: null
};
// AbortController: standard Node primitive. Calling abort() on it cancels every
// in-flight axios request that received its `signal`. We use it so --bail in
// parallel mode can stop sibling iterations' already-launched HTTP requests
// immediately, instead of letting them run to completion. The signal is passed
// explicitly into runSingleRequest; abort() is called inside the bail block below.
const bailAbortController = new AbortController();
const bailAbortSignal = bail ? bailAbortController.signal : null;
// Function to run a single iteration
const runIteration = async (iterationIndex) => {
let currentRequestIndex = 0;
const runtime = getJsSandboxRuntime(sandbox);
const csvDataVariables = csvFileData?.[iterationIndex] || {};
const jsonDataVariables = jsonFileData?.[iterationIndex] || {};
const hasCsvData = Object.keys(csvDataVariables).length > 0;
const hasJsonData = Object.keys(jsonDataVariables).length > 0;
const hasExternalData = hasCsvData || hasJsonData;
const resultsForCurrentIteration = [];
// Show an "Iteration: <n>" header only when it adds value and won't spam output.
// - We print it for sequential multi-iteration runs, or when external data (CSV/JSON) is used.
// - We suppress it for true parallel multi-iteration runs to keep logs compact.
const isMultiIteration = iterationCount > 1;
const isParallelMulti = parallel && isMultiIteration;
const shouldShowIterationHeader = !isParallelMulti && (hasExternalData || isMultiIteration);
if (shouldShowIterationHeader) {
console.log(`\n${chalk.green('Iteration:', iterationIndex + 1)}\n`);
}
if (verbose && hasCsvData) {
console.log(`${chalk.green('CSV data:')}\n${chalk.yellow(JSON.stringify(csvDataVariables, null, 2))}\n`);
}
if (verbose && hasJsonData) {
console.log(`${chalk.green('JSON data:')}\n${chalk.yellow(JSON.stringify(jsonDataVariables, null, 2))}\n`);
}
let iterationData = { ...csvDataVariables, ...jsonDataVariables };
const iterationRuntimeVariables = {
...runtimeVariables,
// the current iteration's data (csv/json row data) can be accessed via `bru.getVar`
// keeping this behaviour intact for backward compatibilty
// adding `iterationData` to the runtimeVariables
...iterationData
};
const iterationCollection = cloneDeep(collection);
iterationCollection.runnerIterationDetails = {
iterationIndex,
iterationData,
totalIterations: iterationCount
};
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(ext)) {
itemPathname = `${itemPathname}${ext}`;
}
const requestItem = cloneDeep(findItemInCollection(iterationCollection, itemPathname));
if (requestItem) {
const res = await runSingleRequest(
requestItem,
collectionPath,
iterationRuntimeVariables,
envVars,
processEnvVars,
brunoConfig,
collectionRoot,
externalSecretVariables,
runtime,
iterationCollection,
runSingleRequestByPathname,
globalEnvVars,
bailAbortSignal
);
resolve(res?.response);
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
});
};
let nJumps = 0; // count the number of jumps to avoid infinite loops
let bailed = false; // halts the outer sequential iteration loop when true
let bailReason = null;
let bailedAt = null; // name of the request that triggered the bail
let requestsRemainingInIteration = 0; // requests left in this iteration after the bail
let haltedBySibling = false; // true when a sibling iteration bailed and halted this one
let bailInfo = null; // aggregated metadata when --bail triggers
while (currentRequestIndex < requestItems.length) {
// If another iteration already bailed, stop launching new requests in this iteration
// and synthesize bail-skipped placeholders for everything remaining.
if (bail && runHaltState.halted) {
haltedBySibling = true;
for (let r = currentRequestIndex; r < requestItems.length; r++) {
const ri = requestItems[r];
const relativePath = path.relative(collectionPath, ri.pathname);
const placeholder = {
test: { filename: relativePath },
request: { method: null, url: null, headers: null, data: null },
response: { status: 'skipped', statusText: null, data: null, responseTime: 0 },
status: 'skipped',
skipped: true,
skipReason: 'bail',
assertionResults: [],
testResults: [],
preRequestTestResults: [],
postResponseTestResults: [],
runDuration: 0,
name: ri.name,
suitename: stripExtension(ri.pathname),
path: relativePath,
iterationIndex
};
results.push(placeholder);
resultsForCurrentIteration.push(placeholder);
}
break;
}
const requestItem = cloneDeep(requestItems[currentRequestIndex]);
const { name, pathname } = requestItem;
const start = process.hrtime();
const result = await runSingleRequest(
requestItem,
collectionPath,
iterationRuntimeVariables,
envVars,
processEnvVars,
brunoConfig,
collectionRoot,
externalSecretVariables,
runtime,
iterationCollection,
runSingleRequestByPathname,
globalEnvVars,
bailAbortSignal
);
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.`));
await new Promise((resolve) => setTimeout(resolve, delay));
}
if (Number.isNaN(delay) && !isLastRun) {
console.log(chalk.red(`Ignoring delay because it's not a valid number.`));
}
results.push({
...result,
runDuration: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
name,
suitename: stripExtension(pathname),
path: path.relative(collectionPath, pathname)?.replace?.('.bru', ''),
iterationIndex
});
resultsForCurrentIteration.push({
...result,
runDuration: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
name,
suitename: stripExtension(pathname),
path: path.relative(collectionPath, pathname)?.replace?.('.bru', ''),
iterationIndex
});
sanitizeResultsForReporter(results, {
skipAllHeaders: reporterSkipAllHeaders,
skipHeaders: reporterSkipHeaders,
skipRequestBody: reporterSkipRequestBody || reporterSkipBody,
skipResponseBody: reporterSkipResponseBody || reporterSkipBody
});
// bail if option is set and there is a failure
if (bail) {
const requestFailure = result?.error && !result?.skipped;
const testFailure = result?.testResults?.find((iter) => iter.status === 'fail');
const assertionFailure = result?.assertionResults?.find((iter) => iter.status === 'fail');
const preRequestTestFailure = result?.preRequestTestResults?.find((iter) => iter.status === 'fail');
const postResponseTestFailure = result?.postResponseTestResults?.find((iter) => iter.status === 'fail');
if (requestFailure || testFailure || assertionFailure || preRequestTestFailure || postResponseTestFailure) {
bailed = true;
// pick the most specific reason for the user-facing message
if (requestFailure) bailReason = 'request failure';
else if (assertionFailure) bailReason = 'assertion failure';
else if (preRequestTestFailure) bailReason = 'pre-request test failure';
else if (postResponseTestFailure) bailReason = 'post-response test failure';
else bailReason = 'test failure';
bailedAt = name;
requestsRemainingInIteration = requestItems.length - currentRequestIndex - 1;
// Signal sibling iterations (parallel mode) to halt — first-bail-wins for the whole run.
if (!runHaltState.halted) {
runHaltState.halted = true;
runHaltState.bailedIterationIndex = iterationIndex;
runHaltState.bailedAt = name;
runHaltState.bailReason = bailReason;
// Abort any in-flight axios requests in sibling iterations.
if (!bailAbortController.signal.aborted) {
bailAbortController.abort();
}