UNPKG

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