mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-01 08:34:07 +00:00
feat(cli): persist env, global-env, and collection var changes from scripts (#8387)
* feat(variables): implement variable persistence and updates for environment and collection files
- Introduced new utility functions for applying and persisting variable updates, enhancing the management of environment and collection variables.
- Updated the run command to include descriptors for environment files, allowing for better tracking of variable changes.
- Enhanced the runSingleRequest function to synchronize variable updates and persist changes to the appropriate files.
- Added comprehensive tests to ensure the correct functionality of variable merging and persistence logic across different file formats.
* fix(variables): refine YAML handling and data type inference for environment variables
- Updated the run command to exclusively recognize '.yml' files, removing support for '.yaml' extensions.
- Enhanced the persistence logic to correctly handle data type inference for newly added environment and collection variables.
- Added comprehensive tests to validate the correct writing of variables to YAML and .bru files, ensuring accurate data type preservation and updates.
- Improved the handling of existing data types when variables are modified, ensuring consistency across different formats.
* feat(tests): add integration tests for typed variable persistence in CLI
- Introduced a new test suite to validate the persistence of typed environment and collection variables set via scripts in the CLI.
- Implemented a local server to facilitate HTTP requests during tests, ensuring accurate simulation of variable management.
- Enhanced the test logic to verify that the correct data type annotations are written to both .bru and YAML files after CLI execution.
- Added comprehensive assertions to confirm the expected disk state for both global and collection variables across different sandboxes.
* refactor(runSingleRequest): remove synchronization of variable updates after post response script execution
- Eliminated the syncVariableUpdates call from the runSingleRequest function, streamlining the variable handling process.
- Adjusted the logic to focus on executing post response variables without unnecessary synchronization, improving performance and clarity.
* feat(env-vars): implement transient environment variable handling and improve persistence resilience
- Added support for transient environment variable overrides via the `--env-var` CLI option, ensuring these values are not persisted to disk.
- Enhanced the `mergeScriptVarsIntoEnvList` function to prevent overridden variables from being written back, preserving existing entries.
- Updated the `persistVariableUpdates` function to handle potential disk write errors gracefully, logging warnings instead of failing the execution.
- Introduced comprehensive tests to validate the behavior of transient variables and ensure correct persistence logic under various scenarios.
* fix(persist-variables): enhance variable persistence logic and testing
- Improved the `persistEnvFile` function to preserve additional fields (uid, dataType, custom metadata) during JSON writes, ensuring no data loss for unrecognized entries.
- Updated the `persistVariableUpdates` function to respect `envVarOverrides` when writing to the global environment file, preventing transient values from being persisted.
- Added regression tests to validate the preservation of additional fields and the correct handling of environment variable overrides in persistence scenarios.
* refactor(tests): streamline fixture writing and enhance variable persistence tests
- Renamed the `writeFileSyncMkdirP` function to `writeFixtureFile` for clarity, emphasizing its role in writing fixture files with parent directory creation.
- Replaced multiple calls to `writeFileSyncMkdirP` with the new `writeFixtureFile` function to improve code readability and maintainability.
- Added tests to verify the deletion of environment variables from disk when removed from the environment map, ensuring accurate persistence behavior.
- Implemented checks to prevent runtime variables from being persisted, safeguarding against unintended data leaks.
* test(env-file): add integration test for JSON environment variable persistence
- Implemented a new test to verify that typed environment variables are correctly persisted to a JSON file using the --env-file option.
- Ensured that existing entries retain their uid and custom fields during the persistence process, validating the shape-preservation guarantee.
- Enhanced the test to check that new typed variables from the script are accurately written and that untouched entries remain unchanged.
* test(env-file): add integration tests for YAML and .bru variable persistence
- Implemented new tests to verify the persistence of typed environment variables to both YAML and .bru files using the --env-file option.
- Ensured that typed values are correctly serialized with appropriate annotations in the output files, validating the correct handling of different formats.
- Enhanced coverage for the persistence behavior of environment variables, confirming that existing entries remain unchanged while new variables are accurately written.
* test(env-file): enhance JSON environment variable persistence tests
- Added a new test to verify the preservation of native typed values in JSON environment files when unrelated keys are touched during script execution.
- Ensured that typed values maintain their original types and are auto-annotated with the correct dataType, validating the integrity of the environment variable persistence process.
- Expanded the typed-value inference tests to cover cross-type transitions, confirming that existing dataType annotations are ignored when new values are written.
* refactor(env-vars): transition env-var overrides to Map for improved persistence handling
- Changed the `envVarOverrides` from a Set to a Map to track injected values, enhancing the logic for distinguishing between transient and deliberate variable writes.
- Updated related functions to accommodate the new Map structure, ensuring that only matching overrides are filtered out during persistence.
- Enhanced documentation and tests to reflect the new behavior, confirming that deliberate script writes with different values are correctly persisted.
* test(integration): refine comments and enhance clarity in typed persistence tests
* test(integration): add tests for variable persistence in tests blocks and error handling
- Implemented new tests to verify that variables set within `tests {}` blocks are correctly persisted to the environment and collection files.
- Added a test to ensure that variables written before an error in a `tests {}` block are still saved, confirming the integrity of partial results during execution.
- Enhanced existing tests with detailed comments for clarity and understanding of the persistence behavior in various scenarios.
* test(integration): add collection variable persistence test for error handling
- Introduced a new test to verify that collection variables set before an error in a `tests {}` block are correctly persisted to the collection file.
- Enhanced existing tests to ensure that both environment and collection variables maintain their expected values during error scenarios, confirming the robustness of variable persistence.
* test(integration): add test for environment and collection variable persistence from post-response expressions
- Introduced a new test to verify that environment and collection variables mutated as a side effect of `vars:post-response` expressions are correctly persisted.
- Enhanced the `runSingleRequest` function to synchronize variable updates after executing post-response scripts, ensuring that changes are reflected in the environment and collection files.
- This change improves the robustness of variable handling in the CLI, aligning it with the expected behavior of the desktop application.
* refactor(persist-variables): streamline JSON parsing and enhance variable persistence tests
- Simplified the JSON parsing logic in the `persistEnvFile` function by removing unnecessary try-catch blocks, ensuring that malformed files are handled gracefully.
- Updated tests to verify the persistence of new collection variable types, including numbers, booleans, and objects, ensuring that typed variables are correctly serialized and maintained.
- Enhanced existing tests to confirm that plain string variables remain unchanged during persistence, improving the robustness of variable handling in the CLI.
* jsdoc change
This commit is contained in:
@@ -360,6 +360,20 @@ const handler = async function (argv) {
|
||||
|
||||
const runtimeVariables = {};
|
||||
let envVars = {};
|
||||
let envFileDescriptor = null;
|
||||
let globalEnvFileDescriptor = null;
|
||||
// --env-var overrides as Map<name, injected value>. The persistence layer compares the
|
||||
// script's resulting value against the injected value to tell a leaked override (same
|
||||
// value passed through unchanged) apart from a deliberate same-named script write that
|
||||
// must reach disk. Typical use: CI injects a secret the CLI can't decrypt at rest.
|
||||
const envVarOverrides = new Map();
|
||||
|
||||
const resolveEnvFileFormat = (filePath) => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.json') return 'json';
|
||||
if (ext === '.yml') return 'yml';
|
||||
return 'bru';
|
||||
};
|
||||
|
||||
// Helper to load environment variables from a file
|
||||
const loadEnvFromFile = (filePath, nameOverride) => {
|
||||
@@ -374,11 +388,11 @@ const handler = async function (argv) {
|
||||
const rawName = normalizedEnv?.name;
|
||||
const trimmedName = typeof rawName === 'string' ? rawName.trim() : '';
|
||||
result.__name__ = trimmedName || path.basename(filePath, '.json');
|
||||
} else if (fileExt === '.yml' || fileExt === '.yaml') {
|
||||
} else if (fileExt === '.yml') {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const envJson = parseEnvironment(content, { format: 'yml' });
|
||||
result = getEnvVars(envJson);
|
||||
result.__name__ = nameOverride || path.basename(filePath, fileExt);
|
||||
result.__name__ = nameOverride || path.basename(filePath, '.yml');
|
||||
} else {
|
||||
const content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
||||
const envJson = parseEnvironment(content, { format: 'bru' });
|
||||
@@ -398,6 +412,7 @@ const handler = async function (argv) {
|
||||
}
|
||||
try {
|
||||
envVars = loadEnvFromFile(envFilePath);
|
||||
envFileDescriptor = { path: envFilePath, format: resolveEnvFileFormat(envFilePath) };
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Failed to parse environment file: ${err.message}`));
|
||||
process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE);
|
||||
@@ -415,6 +430,7 @@ const handler = async function (argv) {
|
||||
try {
|
||||
const collectionEnvVars = loadEnvFromFile(collectionEnvFilePath, env);
|
||||
envVars = { ...envVars, ...collectionEnvVars };
|
||||
envFileDescriptor = { path: collectionEnvFilePath, format: collection.format };
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Failed to parse Environment file: ${err.message}`));
|
||||
process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE);
|
||||
@@ -470,6 +486,7 @@ const handler = async function (argv) {
|
||||
const globalEnvJson = parseEnvironment(globalEnvContent, { format: 'yml' });
|
||||
globalEnvVars = getEnvVars(globalEnvJson);
|
||||
globalEnvVars.__name__ = globalEnv;
|
||||
globalEnvFileDescriptor = { path: globalEnvFilePath, format: 'yml' };
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Failed to parse global environment: ${err.message}`));
|
||||
process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE);
|
||||
@@ -498,6 +515,7 @@ const handler = async function (argv) {
|
||||
process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_ENV_OVERRIDE);
|
||||
}
|
||||
envVars[match[1]] = match[2];
|
||||
envVarOverrides.set(match[1], match[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -618,6 +636,15 @@ const handler = async function (argv) {
|
||||
|
||||
const runtime = getJsSandboxRuntime(sandbox);
|
||||
|
||||
const collectionRootFile = collection.format === 'yml' ? 'opencollection.yml' : 'collection.bru';
|
||||
const collectionRootPath = path.join(collectionPath, collectionRootFile);
|
||||
const persistPaths = {
|
||||
envFile: envFileDescriptor,
|
||||
globalEnvFile: globalEnvFileDescriptor,
|
||||
collectionRootPath,
|
||||
envVarOverrides
|
||||
};
|
||||
|
||||
// Fetch system proxy once for all requests (skip if --noproxy flag is set)
|
||||
if (!noproxy) {
|
||||
try {
|
||||
@@ -647,7 +674,8 @@ const handler = async function (argv) {
|
||||
runtime,
|
||||
collection,
|
||||
runSingleRequestByPathname,
|
||||
globalEnvVars
|
||||
globalEnvVars,
|
||||
persistPaths
|
||||
);
|
||||
resolve(res?.response);
|
||||
}
|
||||
@@ -674,7 +702,8 @@ const handler = async function (argv) {
|
||||
runtime,
|
||||
collection,
|
||||
runSingleRequestByPathname,
|
||||
globalEnvVars
|
||||
globalEnvVars,
|
||||
persistPaths
|
||||
);
|
||||
|
||||
const isLastRun = currentRequestIndex === requestItems.length - 1;
|
||||
|
||||
@@ -9,6 +9,7 @@ const { interpolateString, interpolateObject } = require('./interpolate-string')
|
||||
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, formatErrorWithContext, SCRIPT_TYPES } = require('@usebruno/js');
|
||||
const { stripExtension } = require('../utils/filesystem');
|
||||
const { getOptions } = require('../utils/bru');
|
||||
const { applyVariableUpdates, persistVariableUpdates } = require('../utils/persist-variables');
|
||||
const { makeAxiosInstance } = require('../utils/axios-instance');
|
||||
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
|
||||
const { setupProxyAgents } = require('../utils/proxy-util');
|
||||
@@ -93,8 +94,31 @@ const runSingleRequest = async function (
|
||||
runtime,
|
||||
collection,
|
||||
runSingleRequestByPathname,
|
||||
globalEnvVars = {}
|
||||
globalEnvVars = {},
|
||||
persistPaths = {}
|
||||
) {
|
||||
const syncVariableUpdates = (result, currentRequest) => {
|
||||
if (!result) return;
|
||||
applyVariableUpdates(result, {
|
||||
envVariables,
|
||||
runtimeVariables,
|
||||
globalEnvVars,
|
||||
request: currentRequest
|
||||
});
|
||||
// Persistence is a side effect — never tank the run for it. In CI, env files may sit on
|
||||
// read-only mounts or the user's shell may lack write permissions; log and continue.
|
||||
try {
|
||||
persistVariableUpdates(result, {
|
||||
envFile: persistPaths.envFile,
|
||||
globalEnvFile: persistPaths.globalEnvFile,
|
||||
collection,
|
||||
collectionRootPath: persistPaths.collectionRootPath,
|
||||
envVarOverrides: persistPaths.envVarOverrides
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(chalk.yellow(`Warning: failed to persist variable updates: ${err.message}`));
|
||||
}
|
||||
};
|
||||
const { pathname: itemPathname } = item;
|
||||
const relativeItemPathname = path.relative(collectionPath, itemPathname);
|
||||
|
||||
@@ -228,6 +252,7 @@ const runSingleRequest = async function (
|
||||
scriptingConfig,
|
||||
runSingleRequestByPathname,
|
||||
collectionName);
|
||||
syncVariableUpdates(result, request);
|
||||
if (result?.nextRequestName !== undefined) {
|
||||
nextRequestName = result.nextRequestName;
|
||||
}
|
||||
@@ -281,6 +306,9 @@ const runSingleRequest = async function (
|
||||
// Extract partial results from the error (tests that passed before the error)
|
||||
preRequestTestResults = error?.partialResults?.results || [];
|
||||
|
||||
// Persist any variable changes the script made before erroring
|
||||
syncVariableUpdates(error?.partialResults, request);
|
||||
|
||||
// Preserve nextRequestName if it was set before the error
|
||||
if (error?.partialResults?.nextRequestName !== undefined) {
|
||||
nextRequestName = error.partialResults.nextRequestName;
|
||||
@@ -742,7 +770,7 @@ const runSingleRequest = async function (
|
||||
const postResponseVars = get(item, 'request.vars.res');
|
||||
if (postResponseVars?.length) {
|
||||
const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
|
||||
varsRuntime.runPostResponseVars(
|
||||
const result = varsRuntime.runPostResponseVars(
|
||||
postResponseVars,
|
||||
request,
|
||||
response,
|
||||
@@ -751,6 +779,9 @@ const runSingleRequest = async function (
|
||||
collectionPath,
|
||||
processEnvVars
|
||||
);
|
||||
// Expressions can invoke bru.setEnvVar / setGlobalEnvVar / setCollectionVar as a side effect,
|
||||
// mirroring how the desktop app surfaces these mutations after the vars block.
|
||||
syncVariableUpdates(result, request);
|
||||
}
|
||||
|
||||
// run post response script
|
||||
@@ -771,6 +802,7 @@ const runSingleRequest = async function (
|
||||
runSingleRequestByPathname,
|
||||
collectionName
|
||||
);
|
||||
syncVariableUpdates(result, request);
|
||||
if (result?.nextRequestName !== undefined) {
|
||||
nextRequestName = result.nextRequestName;
|
||||
}
|
||||
@@ -802,6 +834,8 @@ const runSingleRequest = async function (
|
||||
}
|
||||
];
|
||||
|
||||
syncVariableUpdates(error?.partialResults, request);
|
||||
|
||||
if (error?.partialResults?.nextRequestName !== undefined) {
|
||||
nextRequestName = error.partialResults.nextRequestName;
|
||||
}
|
||||
@@ -847,6 +881,7 @@ const runSingleRequest = async function (
|
||||
runSingleRequestByPathname,
|
||||
collectionName
|
||||
);
|
||||
syncVariableUpdates(result, request);
|
||||
testResults = get(result, 'results', []);
|
||||
|
||||
if (result?.nextRequestName !== undefined) {
|
||||
@@ -879,6 +914,8 @@ const runSingleRequest = async function (
|
||||
}
|
||||
];
|
||||
|
||||
syncVariableUpdates(error?.partialResults, request);
|
||||
|
||||
if (error?.partialResults?.nextRequestName !== undefined) {
|
||||
nextRequestName = error.partialResults.nextRequestName;
|
||||
}
|
||||
|
||||
421
packages/bruno-cli/src/utils/persist-variables.js
Normal file
421
packages/bruno-cli/src/utils/persist-variables.js
Normal file
@@ -0,0 +1,421 @@
|
||||
const fs = require('fs');
|
||||
const { stringifyEnvironment, stringifyCollection, parseEnvironment } = require('@usebruno/filestore');
|
||||
const { getDataTypeFromValue } = require('@usebruno/common').utils;
|
||||
const { parseEnvironmentJson } = require('./environment');
|
||||
|
||||
/**
|
||||
* Bruno stashes the env name inside the vars map under `__name__`; it is metadata, never a real variable.
|
||||
*/
|
||||
const INTERNAL_KEYS = new Set(['__name__']);
|
||||
|
||||
/**
|
||||
* Sets or removes the `dataType` field on a variable based on the JS type of `value`.
|
||||
* `string` is the implicit default on disk — omit the field rather than writing `dataType: string`.
|
||||
*
|
||||
* @param {{ name: string, value: any, dataType?: string }} variable - Variable entry to mutate in place.
|
||||
* @param {any} value - JS value whose type drives the inference.
|
||||
* @returns {object} The same `variable` reference, after mutation.
|
||||
*
|
||||
* @example
|
||||
* applyInferredDataType({ name: 'port', value: 3000 }, 3000);
|
||||
* → { name: 'port', value: 3000, dataType: 'number' }
|
||||
*
|
||||
* applyInferredDataType({ name: 'host', value: 'x', dataType: 'number' }, 'x');
|
||||
* → { name: 'host', value: 'x' } // dataType deleted
|
||||
*/
|
||||
const applyInferredDataType = (variable, value) => {
|
||||
const inferred = getDataTypeFromValue(value);
|
||||
if (inferred === 'string') {
|
||||
delete variable.dataType;
|
||||
} else {
|
||||
variable.dataType = inferred;
|
||||
}
|
||||
return variable;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a shallow copy of `vars` with internal keys (`__name__`) removed.
|
||||
*
|
||||
* @param {Object<string, any>} vars - Flat name→value map; may be null/undefined.
|
||||
* @returns {Object<string, any>} New object without internal keys.
|
||||
*
|
||||
* @example
|
||||
* stripInternal({ token: 'abc', __name__: 'dev' });
|
||||
* → { token: 'abc' }
|
||||
*/
|
||||
const stripInternal = (vars) => {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(vars || {})) {
|
||||
if (!INTERNAL_KEYS.has(k)) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* In-place replace of `target`'s contents with `source`'s, while preserving `target.__name__`.
|
||||
* Callers hold long-lived references to `target`, so a fresh object would not propagate.
|
||||
* Keys missing from `source` are deleted — this is how `bru.deleteEnvVar` flows through.
|
||||
*
|
||||
* @param {Object<string, any>} target - Object mutated in place.
|
||||
* @param {Object<string, any>} source - Desired end state (minus internal keys).
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* const target = { a: 1, b: 2, __name__: 'dev' };
|
||||
* overwriteMap(target, { a: 9, c: 3 });
|
||||
* -> target is now { a: 9, c: 3, __name__: 'dev' }
|
||||
* -> b deleted, c added, __name__ preserved
|
||||
*/
|
||||
const overwriteMap = (target, source) => {
|
||||
const preservedName = target.__name__;
|
||||
for (const key of Object.keys(target)) {
|
||||
if (INTERNAL_KEYS.has(key)) continue;
|
||||
if (!(key in source)) delete target[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (INTERNAL_KEYS.has(key)) continue;
|
||||
target[key] = value;
|
||||
}
|
||||
if (preservedName !== undefined && target.__name__ === undefined) {
|
||||
target.__name__ = preservedName;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync a runtime result into the in-memory maps the next request will read. No disk I/O.
|
||||
* Env / runtime / global maps are mutated by reference; collection vars are replaced on the request.
|
||||
*
|
||||
* @param {{
|
||||
* envVariables?: Object,
|
||||
* runtimeVariables?: Object,
|
||||
* collectionVariables?: Object,
|
||||
* globalEnvironmentVariables?: Object
|
||||
* } | null} result - Runtime return value; any field may be null to indicate "unchanged".
|
||||
* @param {{
|
||||
* envVariables: Object,
|
||||
* runtimeVariables: Object,
|
||||
* globalEnvVars: Object,
|
||||
* request: Object
|
||||
* }} ctx - The long-lived maps and the current request object to update.
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* After a script calls `bru.setEnvVar('token', 'abc')`:
|
||||
* const result = {
|
||||
* envVariables: { token: 'abc' },
|
||||
* runtimeVariables: null,
|
||||
* collectionVariables: null,
|
||||
* globalEnvironmentVariables: null
|
||||
* };
|
||||
* applyVariableUpdates(result, { envVariables, runtimeVariables, globalEnvVars, request });
|
||||
* -> envVariables now contains `token: 'abc'`, and the next request sees it.
|
||||
*/
|
||||
const applyVariableUpdates = (result, { envVariables, runtimeVariables, globalEnvVars, request }) => {
|
||||
if (!result) return;
|
||||
if (result.envVariables && envVariables) {
|
||||
overwriteMap(envVariables, result.envVariables);
|
||||
}
|
||||
if (result.runtimeVariables && runtimeVariables) {
|
||||
overwriteMap(runtimeVariables, result.runtimeVariables);
|
||||
}
|
||||
if (result.globalEnvironmentVariables && globalEnvVars) {
|
||||
overwriteMap(globalEnvVars, result.globalEnvironmentVariables);
|
||||
}
|
||||
// Collection vars live on the per-request object, not a shared map — replace the field outright.
|
||||
if (result.collectionVariables && request) {
|
||||
request.collectionVariables = stripInternal(result.collectionVariables);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconcile the env file's array of variable entries against the script's flat name→value map.
|
||||
* - Disabled entries are always preserved (user intent — toggled off, not deleted).
|
||||
* - Enabled entries absent from script output are dropped (so `bru.deleteEnvVar` reaches disk).
|
||||
* - New script keys are appended as enabled text vars with inferred dataType.
|
||||
* - `overrides`: names supplied via CLI `--env-var` keyed to their injected value. The leaked
|
||||
* override value never reaches disk and the file's entry is preserved, but a deliberate
|
||||
* script write of a *different* value for the same name still persists.
|
||||
*
|
||||
* @param {Array<{ name: string, value: any, enabled?: boolean, type?: string, secret?: boolean, dataType?: string }>} variables
|
||||
* Existing entries from the env file.
|
||||
* @param {Object<string, any>} scriptVarsRaw - Flat map from the script runtime; may include `__name__`.
|
||||
* @param {{ overrides?: Map<string, string> }} [options] - Names → injected override values.
|
||||
* @returns {Array<object>} New array of merged variable entries.
|
||||
*
|
||||
* @example
|
||||
* const variables = [
|
||||
* { name: 'host', value: 'old', enabled: true, type: 'text', secret: false },
|
||||
* { name: 'stale', value: 'gone', enabled: true, type: 'text', secret: false },
|
||||
* { name: 'off', value: 'kept', enabled: false, type: 'text', secret: false }
|
||||
* ];
|
||||
* const scriptVarsRaw = { host: 'new', port: 3000, __name__: 'dev' };
|
||||
* mergeScriptVarsIntoEnvList(variables, scriptVarsRaw);
|
||||
* → [
|
||||
* { name: 'host', value: 'new', enabled: true, type: 'text', secret: false }, // updated
|
||||
* { name: 'off', value: 'kept', enabled: false, type: 'text', secret: false }, // preserved (disabled)
|
||||
* { name: 'port', value: 3000, enabled: true, type: 'text', secret: false, dataType: 'number' } // appended
|
||||
* ]
|
||||
* -> `stale` was dropped (enabled but absent from script output).
|
||||
*
|
||||
* @example
|
||||
* With CLI override `--env-var token=transient`:
|
||||
* mergeScriptVarsIntoEnvList(
|
||||
* [{ name: 'token', value: 'real', enabled: true, type: 'text', secret: true }],
|
||||
* { token: 'transient', other: 'x' },
|
||||
* { overrides: new Map([['token', 'transient']]) }
|
||||
* );
|
||||
* → [
|
||||
* { name: 'token', value: 'real', enabled: true, type: 'text', secret: true }, // preserved unchanged
|
||||
* { name: 'other', value: 'x', enabled: true, type: 'text', secret: false } // appended
|
||||
* ]
|
||||
*
|
||||
* @example
|
||||
* Script *deliberately* sets the overridden key to a new value:
|
||||
* mergeScriptVarsIntoEnvList(
|
||||
* [{ name: 'token', value: 'real', enabled: true, type: 'text', secret: true }],
|
||||
* { token: 'rotated' },
|
||||
* { overrides: new Map([['token', 'transient']]) }
|
||||
* );
|
||||
* → [{ name: 'token', value: 'rotated', enabled: true, type: 'text', secret: true }] // persisted
|
||||
*/
|
||||
const mergeScriptVarsIntoEnvList = (variables, scriptVarsRaw, options = {}) => {
|
||||
const overrides = options.overrides instanceof Map ? options.overrides : new Map();
|
||||
const scriptVars = stripInternal(scriptVarsRaw);
|
||||
// Drop a script value only when it still matches the injected override — a different
|
||||
// value means the script deliberately wrote it (e.g. `bru.setEnvVar('token', 'rotated')`)
|
||||
// and must reach disk.
|
||||
for (const [key, overrideValue] of overrides) {
|
||||
if (key in scriptVars && scriptVars[key] === overrideValue) {
|
||||
delete scriptVars[key];
|
||||
}
|
||||
}
|
||||
const scriptKeys = new Set(Object.keys(scriptVars));
|
||||
|
||||
const next = (variables || [])
|
||||
.filter((v) => {
|
||||
if (v.enabled === false) return true;
|
||||
if (scriptKeys.has(v.name)) return true;
|
||||
// Keep the file's entry for an overridden name even if the script didn't echo it back.
|
||||
if (overrides.has(v.name)) return true;
|
||||
return false;
|
||||
})
|
||||
.map((v) => {
|
||||
if (v.enabled === false || !scriptKeys.has(v.name)) return v;
|
||||
return applyInferredDataType({ ...v, value: scriptVars[v.name] }, scriptVars[v.name]);
|
||||
});
|
||||
|
||||
// Skip names that already appear as enabled entries; a same-named disabled entry still gets a fresh enabled row appended.
|
||||
const presentEnabled = new Set(next.filter((v) => v.enabled !== false).map((v) => v.name));
|
||||
for (const key of scriptKeys) {
|
||||
if (presentEnabled.has(key)) continue;
|
||||
const entry = { name: key, value: scriptVars[key], type: 'text', enabled: true, secret: false };
|
||||
next.push(applyInferredDataType(entry, scriptVars[key]));
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same shape as {@link mergeScriptVarsIntoEnvList}, with collection-var conventions:
|
||||
* `type: 'request'`, no `secret` field, no `__name__` to strip.
|
||||
*
|
||||
* @param {Array<{ name: string, value: any, enabled?: boolean, type?: string, dataType?: string }>} variables
|
||||
* Existing entries from the collection root.
|
||||
* @param {Object<string, any>} scriptVars - Flat map from the script runtime.
|
||||
* @returns {Array<object>} New array of merged collection-var entries.
|
||||
*
|
||||
* @example
|
||||
* mergeScriptVarsIntoCollectionVarsList(
|
||||
* [{ name: 'region', value: 'us', enabled: true, type: 'request' }],
|
||||
* { region: 'eu', retries: 3 }
|
||||
* );
|
||||
* → [
|
||||
* { name: 'region', value: 'eu', enabled: true, type: 'request' },
|
||||
* { name: 'retries', value: 3, enabled: true, type: 'request', dataType: 'number' }
|
||||
* ]
|
||||
*/
|
||||
const mergeScriptVarsIntoCollectionVarsList = (variables, scriptVars) => {
|
||||
const scriptKeys = new Set(Object.keys(scriptVars || {}));
|
||||
const next = (variables || [])
|
||||
.filter((v) => (v.enabled === false ? true : scriptKeys.has(v.name)))
|
||||
.map((v) => {
|
||||
if (v.enabled === false || !scriptKeys.has(v.name)) return v;
|
||||
return applyInferredDataType({ ...v, value: scriptVars[v.name] }, scriptVars[v.name]);
|
||||
});
|
||||
|
||||
const presentEnabled = new Set(next.filter((v) => v.enabled !== false).map((v) => v.name));
|
||||
for (const key of scriptKeys) {
|
||||
if (presentEnabled.has(key)) continue;
|
||||
const entry = { name: key, value: scriptVars[key], type: 'request', enabled: true };
|
||||
next.push(applyInferredDataType(entry, scriptVars[key]));
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Idempotency guard: skip the write when serialized output is byte-identical.
|
||||
*
|
||||
* @param {string} filePath - Absolute path to write.
|
||||
* @param {string} content - Serialized new content.
|
||||
* @param {string} existing - Current on-disk content.
|
||||
* @returns {boolean} `true` if the file was written, `false` if unchanged.
|
||||
*
|
||||
* @example
|
||||
* writeIfChanged('Prod.bru', 'vars { x: 1 }', 'vars { x: 1 }'); // → false (no write)
|
||||
* writeIfChanged('Prod.bru', 'vars { x: 2 }', 'vars { x: 1 }'); // → true (file updated)
|
||||
*/
|
||||
const writeIfChanged = (filePath, content, existing) => {
|
||||
if (content === existing) return false;
|
||||
fs.writeFileSync(filePath, content);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read → merge → write one env file. Used for both per-env and global-env files
|
||||
* (same shape, different descriptor).
|
||||
*
|
||||
* @param {{ path: string, format: 'json' | 'yml' | 'bru' } | null | undefined} envFile - Env file descriptor; no-op when missing.
|
||||
* @param {Object<string, any>} scriptVars - Flat map of vars the script declared.
|
||||
* @param {{ overrides?: Map<string, string> }} [options] - `--env-var name=value` entries keyed by name → injected value; forwarded to `mergeScriptVarsIntoEnvList` to keep override values off disk.
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* persistEnvFile(
|
||||
* { path: '/coll/environments/Dev.bru', format: 'bru' },
|
||||
* { host: 'api.example.com', port: 3000 }
|
||||
* );
|
||||
* -> on-disk before:
|
||||
* vars {
|
||||
* host: localhost
|
||||
* }
|
||||
* -> on-disk after:
|
||||
* vars {
|
||||
* host: api.example.com
|
||||
* @number
|
||||
* port: 3000
|
||||
* }
|
||||
*/
|
||||
const persistEnvFile = (envFile, scriptVars, options = {}) => {
|
||||
// No descriptor means no `--env` flag was passed — nothing to persist to.
|
||||
if (!envFile || !envFile.path) return;
|
||||
const { path: filePath, format } = envFile;
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
|
||||
if (format === 'json') {
|
||||
const existingRaw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(existingRaw);
|
||||
// Validate shape, but merge against the raw `parsed.variables` so per-entry fields the
|
||||
// CLI doesn't recognize (uid, dataType, custom metadata) survive on entries the script
|
||||
// didn't touch — parseEnvironmentJson's normalizer would otherwise strip them.
|
||||
parseEnvironmentJson(parsed);
|
||||
|
||||
const rawVariables = Array.isArray(parsed.variables) ? parsed.variables.filter(Boolean) : [];
|
||||
const mergedVars = mergeScriptVarsIntoEnvList(rawVariables, scriptVars, options);
|
||||
// Spread preserves any top-level fields the user has beyond `variables` (name, metadata, etc.).
|
||||
const next = { ...parsed, variables: mergedVars };
|
||||
const content = JSON.stringify(next, null, 2) + '\n';
|
||||
writeIfChanged(filePath, content, existingRaw);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingRaw = fs.readFileSync(filePath, 'utf8');
|
||||
// Bru parser expects \n line endings; yml parser is tolerant.
|
||||
const sourceForParse = format === 'bru' ? existingRaw.replace(/\r\n/g, '\n') : existingRaw;
|
||||
const parsed = parseEnvironment(sourceForParse, { format });
|
||||
const mergedVars = mergeScriptVarsIntoEnvList(parsed.variables || [], scriptVars, options);
|
||||
const next = { ...parsed, variables: mergedVars };
|
||||
const content = stringifyEnvironment(next, { format });
|
||||
writeIfChanged(filePath, content, existingRaw);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutate the in-memory collection root and write it out. The mutation matters:
|
||||
* subsequent requests in the same iteration read `collection.root` and must see the updated vars.
|
||||
*
|
||||
* @param {{ root?: object, format: 'bru' | 'yml', brunoConfig?: object }} collection - Loaded collection; mutated in place.
|
||||
* @param {Object<string, any>} scriptCollVars - Flat map of collection vars the script declared.
|
||||
* @param {string} collectionRootPath - Absolute path to the collection root file (`collection.bru` / `opencollection.yml`).
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* -> collection.root.request.vars.req before: [{ name: 'region', value: 'us', enabled: true, type: 'request' }]
|
||||
* persistCollectionVars(collection, { region: 'eu', retries: 3 }, '/coll/collection.bru');
|
||||
* -> collection.root.request.vars.req after:
|
||||
* [
|
||||
* { name: 'region', value: 'eu', enabled: true, type: 'request' },
|
||||
* { name: 'retries', value: 3, enabled: true, type: 'request', dataType: 'number' }
|
||||
* ]
|
||||
* -> `collection.bru` on disk is rewritten with the same content.
|
||||
*/
|
||||
const persistCollectionVars = (collection, scriptCollVars, collectionRootPath) => {
|
||||
if (!collection || !collectionRootPath) return;
|
||||
const collectionRoot = collection.root || {};
|
||||
collectionRoot.request = collectionRoot.request || {};
|
||||
collectionRoot.request.vars = collectionRoot.request.vars || {};
|
||||
const existingVars = collectionRoot.request.vars.req || [];
|
||||
const merged = mergeScriptVarsIntoCollectionVarsList(existingVars, scriptCollVars);
|
||||
collectionRoot.request.vars.req = merged;
|
||||
collection.root = collectionRoot;
|
||||
|
||||
const format = collection.format;
|
||||
const content = stringifyCollection(collectionRoot, collection.brunoConfig || {}, { format });
|
||||
const existing = fs.existsSync(collectionRootPath) ? fs.readFileSync(collectionRootPath, 'utf8') : null;
|
||||
if (existing !== null) {
|
||||
writeIfChanged(collectionRootPath, content, existing);
|
||||
} else {
|
||||
fs.writeFileSync(collectionRootPath, content);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Disk-write counterpart to {@link applyVariableUpdates}.
|
||||
* Runtime vars are intentionally not persisted — they are ephemeral by definition.
|
||||
*
|
||||
* @param {{
|
||||
* envVariables?: Object,
|
||||
* collectionVariables?: Object,
|
||||
* globalEnvironmentVariables?: Object,
|
||||
* runtimeVariables?: Object
|
||||
* } | null} result - Runtime return value; any field may be null to indicate "unchanged".
|
||||
* @param {{
|
||||
* envFile?: { path: string, format: 'json' | 'yml' | 'bru' },
|
||||
* globalEnvFile?: { path: string, format: 'yml' },
|
||||
* collection: object,
|
||||
* collectionRootPath: string,
|
||||
* envVarOverrides?: Map<string, string>
|
||||
* }} targets - Where each kind of var should land on disk. `envVarOverrides` maps each
|
||||
* CLI `--env-var name=value` to its injected value; that value is never persisted, but a
|
||||
* deliberate same-named script write with a different value still reaches disk.
|
||||
* `globalEnvFile.format` is yml-only because the CLI's `--global-env <name>` flag looks
|
||||
* up `<workspace>/environments/<name>.yml` (no JSON/bru equivalent exists today).
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* Script runs `bru.setEnvVar('token', 'abc')` and `bru.setCollectionVar('region', 'eu')`.
|
||||
* const result = {
|
||||
* envVariables: { token: 'abc' },
|
||||
* collectionVariables: { region: 'eu' },
|
||||
* runtimeVariables: null,
|
||||
* globalEnvironmentVariables: null
|
||||
* };
|
||||
* persistVariableUpdates(result, { envFile, globalEnvFile, collection, collectionRootPath });
|
||||
* -> writes `token: abc` into the active env file and `region: eu` into the collection root file.
|
||||
*/
|
||||
const persistVariableUpdates = (result, { envFile, globalEnvFile, collection, collectionRootPath, envVarOverrides }) => {
|
||||
if (!result) return;
|
||||
const envOpts = envVarOverrides ? { overrides: envVarOverrides } : undefined;
|
||||
if (result.envVariables) persistEnvFile(envFile, result.envVariables, envOpts);
|
||||
// Defense-in-depth: the bru runtime keeps envVariables and globalEnvironmentVariables as
|
||||
// separate maps and never auto-syncs between them, so the override can't reach the global
|
||||
// env map through normal flow. Still, pass the override filter through — if a user script
|
||||
// ever copies via `bru.setGlobalEnvVar(k, bru.getVar(k))`, the override value won't land
|
||||
// on disk in the global env file.
|
||||
if (result.globalEnvironmentVariables) persistEnvFile(globalEnvFile, result.globalEnvironmentVariables, envOpts);
|
||||
if (result.collectionVariables) persistCollectionVars(collection, result.collectionVariables, collectionRootPath);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
applyVariableUpdates,
|
||||
persistVariableUpdates,
|
||||
mergeScriptVarsIntoEnvList,
|
||||
mergeScriptVarsIntoCollectionVarsList
|
||||
};
|
||||
@@ -0,0 +1,789 @@
|
||||
const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const CLI_BIN = path.resolve(__dirname, '..', '..', 'bin', 'bru.js');
|
||||
|
||||
const writeFixtureFile = (filePath, content) => {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
};
|
||||
|
||||
const REQUEST_BRU = `meta {
|
||||
name: set-typed-vars
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setEnvVar("envNum", 42);
|
||||
bru.setEnvVar("envBool", true);
|
||||
bru.setEnvVar("envObj", { port: 3000, ssl: true });
|
||||
bru.setEnvVar("envStr", "hello");
|
||||
|
||||
bru.setCollectionVar("collNum", 7);
|
||||
bru.setCollectionVar("collBool", false);
|
||||
bru.setCollectionVar("collObj", { region: "eu", retries: 3 });
|
||||
bru.setCollectionVar("collStr", "plain");
|
||||
}
|
||||
`;
|
||||
|
||||
describe('CLI run — typed env + collection vars set via scripts are persisted to disk', () => {
|
||||
let server;
|
||||
let baseUrl;
|
||||
let tmpDir;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bru-cli-typed-persist-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// spawnSync blocks jest's event loop, starving the in-process HTTP server → ECONNREFUSED.
|
||||
// Use async spawn so the server stays responsive.
|
||||
const runCli = (args, cwd = tmpDir) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [CLI_BIN, ...args], { cwd, env: { ...process.env } });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||
});
|
||||
|
||||
const assertDiskState = () => {
|
||||
const envContent = fs.readFileSync(path.join(tmpDir, 'environments', 'Test.bru'), 'utf8');
|
||||
expect(envContent).toMatch(/@number\s+envNum:\s*42/);
|
||||
expect(envContent).toMatch(/@boolean\s+envBool:\s*true/);
|
||||
expect(envContent).toMatch(/@object\s+envObj:/);
|
||||
expect(envContent).toContain('"port": 3000');
|
||||
expect(envContent).toContain('"ssl": true');
|
||||
// 'string' is the implicit default — never materialized as an annotation.
|
||||
expect(envContent).not.toMatch(/@string\s+envStr/);
|
||||
expect(envContent).toMatch(/envStr:\s*hello/);
|
||||
|
||||
const collectionBru = fs.readFileSync(path.join(tmpDir, 'collection.bru'), 'utf8');
|
||||
expect(collectionBru).toMatch(/@number\s+collNum:\s*7/);
|
||||
expect(collectionBru).toMatch(/@boolean\s+collBool:\s*false/);
|
||||
expect(collectionBru).toMatch(/@object\s+collObj:/);
|
||||
expect(collectionBru).toContain('"region": "eu"');
|
||||
expect(collectionBru).toContain('"retries": 3');
|
||||
expect(collectionBru).not.toMatch(/@string\s+collStr/);
|
||||
expect(collectionBru).toMatch(/collStr:\s*plain/);
|
||||
};
|
||||
|
||||
// 'safe' → quickjs, anything else → nodevm. Both runtimes must produce identical disk state.
|
||||
it.each([
|
||||
['developer'],
|
||||
['safe']
|
||||
])('writes @number/@boolean/@object annotations after CLI run (--sandbox %s)', async (sandbox) => {
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'bruno.json'),
|
||||
JSON.stringify({ version: '1', name: 'typed-cli-collection', type: 'collection' }, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'collection.bru'),
|
||||
'meta {\n name: typed-cli-collection\n seq: 1\n}\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'environments', 'Test.bru'),
|
||||
`vars {\n host: ${baseUrl}\n}\n`
|
||||
);
|
||||
writeFixtureFile(path.join(tmpDir, 'set-typed-vars.bru'), REQUEST_BRU);
|
||||
|
||||
const result = await runCli([
|
||||
'run', 'set-typed-vars.bru', '--env', 'Test', '--sandbox', sandbox, '--noproxy'
|
||||
]);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${result.code}.\n--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
assertDiskState();
|
||||
}, 60_000);
|
||||
|
||||
// Global env vars live in the workspace, not the collection — CLI walks up from cwd looking
|
||||
// for workspace.yml, then reads <workspace>/environments/<name>.yml.
|
||||
it.each([
|
||||
['developer'],
|
||||
['safe']
|
||||
])('writes typed global env vars back to <workspace>/environments/<name>.yml (--sandbox %s)', async (sandbox) => {
|
||||
const workspaceDir = path.join(tmpDir, 'workspace');
|
||||
const collectionDir = path.join(workspaceDir, 'typed-cli-collection');
|
||||
|
||||
writeFixtureFile(
|
||||
path.join(workspaceDir, 'workspace.yml'),
|
||||
'opencollection: 1.0.0\ninfo:\n name: "Test Workspace"\n type: workspace\ncollections:\n - name: "typed-cli-collection"\n path: "typed-cli-collection"\nspecs:\ndocs: \'\'\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(workspaceDir, 'environments', 'Global.yml'),
|
||||
`name: Global\nvariables:\n - name: baseUrl\n value: ${baseUrl}\n enabled: true\n secret: false\n`
|
||||
);
|
||||
|
||||
writeFixtureFile(
|
||||
path.join(collectionDir, 'bruno.json'),
|
||||
JSON.stringify({ version: '1', name: 'typed-cli-collection', type: 'collection' }, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(collectionDir, 'collection.bru'),
|
||||
'meta {\n name: typed-cli-collection\n seq: 1\n}\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(collectionDir, 'set-typed-global-vars.bru'),
|
||||
`meta {
|
||||
name: set-typed-global-vars
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setGlobalEnvVar("globalNum", 99);
|
||||
bru.setGlobalEnvVar("globalBool", false);
|
||||
bru.setGlobalEnvVar("globalObj", { tier: "premium", limit: 100 });
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const result = await runCli(
|
||||
['run', 'set-typed-global-vars.bru', '--global-env', 'Global', '--sandbox', sandbox, '--noproxy'],
|
||||
collectionDir
|
||||
);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${result.code}.\n--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
// yml encodes typed values as `value: { type, data }` blocks; strings stay as raw `value:`.
|
||||
const written = fs.readFileSync(path.join(workspaceDir, 'environments', 'Global.yml'), 'utf8');
|
||||
expect(written).toMatch(/name:\s*globalNum[\s\S]*?type:\s*number[\s\S]*?data:\s*['"]?99/);
|
||||
expect(written).toMatch(/name:\s*globalBool[\s\S]*?type:\s*boolean[\s\S]*?data:\s*['"]?false/);
|
||||
expect(written).toMatch(/name:\s*globalObj[\s\S]*?type:\s*object[\s\S]*?data:[\s\S]*?tier/);
|
||||
expect(written).toMatch(/name:\s*baseUrl[\s\S]*?value:\s*['"]?http:\/\/127\.0\.0\.1/);
|
||||
expect(written).not.toMatch(/name:\s*baseUrl[\s\S]*?type:\s*string/);
|
||||
}, 60_000);
|
||||
|
||||
// Collection lives outside the workspace tree, so cwd walk-up can't find workspace.yml —
|
||||
// only --workspace-path can locate it.
|
||||
it('persists typed global env vars when --workspace-path is provided explicitly', async () => {
|
||||
const workspaceDir = path.join(tmpDir, 'workspace');
|
||||
const collectionDir = path.join(tmpDir, 'standalone-collection');
|
||||
|
||||
writeFixtureFile(
|
||||
path.join(workspaceDir, 'workspace.yml'),
|
||||
'opencollection: 1.0.0\ninfo:\n name: "Test Workspace"\n type: workspace\ncollections:\nspecs:\ndocs: \'\'\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(workspaceDir, 'environments', 'Global.yml'),
|
||||
`name: Global\nvariables:\n - name: baseUrl\n value: ${baseUrl}\n enabled: true\n secret: false\n`
|
||||
);
|
||||
|
||||
writeFixtureFile(
|
||||
path.join(collectionDir, 'bruno.json'),
|
||||
JSON.stringify({ version: '1', name: 'standalone-collection', type: 'collection' }, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(collectionDir, 'collection.bru'),
|
||||
'meta {\n name: standalone-collection\n seq: 1\n}\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(collectionDir, 'set-typed-global-vars.bru'),
|
||||
`meta {
|
||||
name: set-typed-global-vars
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setGlobalEnvVar("globalNum", 99);
|
||||
bru.setGlobalEnvVar("globalBool", false);
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
'run', 'set-typed-global-vars.bru',
|
||||
'--global-env', 'Global',
|
||||
'--workspace-path', workspaceDir,
|
||||
'--sandbox', 'developer',
|
||||
'--noproxy'
|
||||
],
|
||||
collectionDir
|
||||
);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${result.code}.\n--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
const written = fs.readFileSync(path.join(workspaceDir, 'environments', 'Global.yml'), 'utf8');
|
||||
expect(written).toMatch(/name:\s*globalNum[\s\S]*?type:\s*number[\s\S]*?data:\s*['"]?99/);
|
||||
expect(written).toMatch(/name:\s*globalBool[\s\S]*?type:\s*boolean[\s\S]*?data:\s*['"]?false/);
|
||||
}, 60_000);
|
||||
|
||||
// --env-file is the only CLI surface that supports JSON env files. uid + custom fields on
|
||||
// untouched entries must survive the rewrite.
|
||||
it('persists typed env vars to a --env-file JSON file and preserves per-entry uid / custom fields', async () => {
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'bruno.json'),
|
||||
JSON.stringify({ version: '1', name: 'json-env-collection', type: 'collection' }, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'collection.bru'),
|
||||
'meta {\n name: json-env-collection\n seq: 1\n}\n'
|
||||
);
|
||||
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'External.json'),
|
||||
JSON.stringify({
|
||||
name: 'External',
|
||||
uid: 'env-uid-abc',
|
||||
variables: [
|
||||
{ name: 'host', value: baseUrl, uid: 'var-host', custom: 'keep-host' },
|
||||
{ name: 'untouched', value: 'stays-put', uid: 'var-untouched', custom: 'keep-me' }
|
||||
]
|
||||
}, null, 2) + '\n'
|
||||
);
|
||||
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'set-typed-vars.bru'),
|
||||
`meta {
|
||||
name: set-typed-vars
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setEnvVar("port", 3000);
|
||||
bru.setEnvVar("enabled", true);
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const result = await runCli([
|
||||
'run', 'set-typed-vars.bru',
|
||||
'--env-file', 'External.json',
|
||||
'--sandbox', 'developer',
|
||||
'--noproxy'
|
||||
]);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${result.code}.\n--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
const written = JSON.parse(fs.readFileSync(path.join(tmpDir, 'External.json'), 'utf8'));
|
||||
expect(written.uid).toBe('env-uid-abc');
|
||||
const byName = Object.fromEntries(written.variables.map((v) => [v.name, v]));
|
||||
expect(byName.port).toMatchObject({ value: 3000, dataType: 'number' });
|
||||
expect(byName.enabled).toMatchObject({ value: true, dataType: 'boolean' });
|
||||
expect(byName.untouched).toMatchObject({
|
||||
value: 'stays-put',
|
||||
uid: 'var-untouched',
|
||||
custom: 'keep-me'
|
||||
});
|
||||
expect(byName.host).toMatchObject({ uid: 'var-host', custom: 'keep-host' });
|
||||
}, 60_000);
|
||||
|
||||
// --env-file infers format from extension — yml/bru wiring is covered separately by --env
|
||||
// and --global-env. These tests prove the --env-file <path>.{yml,bru} branches.
|
||||
it('persists typed env vars to a --env-file YAML file with type/data blocks', async () => {
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'bruno.json'),
|
||||
JSON.stringify({ version: '1', name: 'yml-envfile-collection', type: 'collection' }, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'collection.bru'),
|
||||
'meta {\n name: yml-envfile-collection\n seq: 1\n}\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'External.yml'),
|
||||
`name: External\nvariables:\n - name: host\n value: ${baseUrl}\n enabled: true\n secret: false\n`
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'set-typed-vars.bru'),
|
||||
`meta {
|
||||
name: set-typed-vars
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setEnvVar("port", 3000);
|
||||
bru.setEnvVar("enabled", true);
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const result = await runCli([
|
||||
'run', 'set-typed-vars.bru',
|
||||
'--env-file', 'External.yml',
|
||||
'--sandbox', 'developer',
|
||||
'--noproxy'
|
||||
]);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${result.code}.\n--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
const written = fs.readFileSync(path.join(tmpDir, 'External.yml'), 'utf8');
|
||||
expect(written).toMatch(/name:\s*port[\s\S]*?type:\s*number[\s\S]*?data:\s*['"]?3000/);
|
||||
expect(written).toMatch(/name:\s*enabled[\s\S]*?type:\s*boolean[\s\S]*?data:\s*['"]?true/);
|
||||
expect(written).toMatch(/name:\s*host[\s\S]*?value:\s*['"]?http:\/\/127\.0\.0\.1/);
|
||||
expect(written).not.toMatch(/name:\s*host[\s\S]*?type:\s*string/);
|
||||
}, 60_000);
|
||||
|
||||
// JSON natively types values via JSON.parse — no dataType tag needed on seed. Script touches
|
||||
// an unrelated key, forcing a full-env echo; seeded values must survive intact with
|
||||
// auto-annotated dataType.
|
||||
it('preserves pre-existing native typed values in --env-file JSON when script touches unrelated keys', async () => {
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'bruno.json'),
|
||||
JSON.stringify({ version: '1', name: 'json-types-collection', type: 'collection' }, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'collection.bru'),
|
||||
'meta {\n name: json-types-collection\n seq: 1\n}\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'External.json'),
|
||||
JSON.stringify({
|
||||
name: 'External',
|
||||
variables: [
|
||||
{ name: 'host', value: baseUrl },
|
||||
{ name: 'seedNum', value: 42 },
|
||||
{ name: 'seedBool', value: true },
|
||||
{ name: 'seedObj', value: { region: 'eu', port: 3000 } },
|
||||
{ name: 'seedArr', value: [1, 2, 3] }
|
||||
]
|
||||
}, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'touch-unrelated.bru'),
|
||||
`meta {
|
||||
name: touch-unrelated
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setEnvVar("trigger", "x");
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const result = await runCli([
|
||||
'run', 'touch-unrelated.bru',
|
||||
'--env-file', 'External.json',
|
||||
'--sandbox', 'developer',
|
||||
'--noproxy'
|
||||
]);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${result.code}.\n--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
const written = JSON.parse(fs.readFileSync(path.join(tmpDir, 'External.json'), 'utf8'));
|
||||
const byName = Object.fromEntries(written.variables.map((v) => [v.name, v]));
|
||||
expect(byName.seedNum).toMatchObject({ value: 42, dataType: 'number' });
|
||||
expect(byName.seedBool).toMatchObject({ value: true, dataType: 'boolean' });
|
||||
expect(byName.seedObj).toMatchObject({ value: { region: 'eu', port: 3000 }, dataType: 'object' });
|
||||
// Arrays are `typeof === 'object'` in JS, so they get dataType: 'object'.
|
||||
expect(byName.seedArr).toMatchObject({ value: [1, 2, 3], dataType: 'object' });
|
||||
expect(byName.host.value).toBe(baseUrl);
|
||||
expect(byName.host.dataType).toBeUndefined();
|
||||
expect(byName.trigger).toMatchObject({ value: 'x' });
|
||||
expect(byName.trigger.dataType).toBeUndefined();
|
||||
}, 60_000);
|
||||
|
||||
it('persists typed env vars to a --env-file .bru file with @dataType annotations', async () => {
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'bruno.json'),
|
||||
JSON.stringify({ version: '1', name: 'bru-envfile-collection', type: 'collection' }, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'collection.bru'),
|
||||
'meta {\n name: bru-envfile-collection\n seq: 1\n}\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'External.bru'),
|
||||
`vars {\n host: ${baseUrl}\n}\n`
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'set-typed-vars.bru'),
|
||||
`meta {
|
||||
name: set-typed-vars
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setEnvVar("port", 3000);
|
||||
bru.setEnvVar("enabled", true);
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const result = await runCli([
|
||||
'run', 'set-typed-vars.bru',
|
||||
'--env-file', 'External.bru',
|
||||
'--sandbox', 'developer',
|
||||
'--noproxy'
|
||||
]);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${result.code}.\n--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
const written = fs.readFileSync(path.join(tmpDir, 'External.bru'), 'utf8');
|
||||
expect(written).toMatch(/@number\s+port:\s*3000/);
|
||||
expect(written).toMatch(/@boolean\s+enabled:\s*true/);
|
||||
expect(written).not.toMatch(/@string\s+host/);
|
||||
expect(written).toMatch(/host:\s*http:\/\/127\.0\.0\.1/);
|
||||
}, 60_000);
|
||||
|
||||
// --env-var values are transient. Even when a script write triggers full-env echo, the
|
||||
// override must NOT replace the on-disk secret.
|
||||
it('does not persist --env-var override values into the env file', async () => {
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'bruno.json'),
|
||||
JSON.stringify({ version: '1', name: 'override-leak-collection', type: 'collection' }, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'collection.bru'),
|
||||
'meta {\n name: override-leak-collection\n seq: 1\n}\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'environments', 'Test.bru'),
|
||||
`vars {\n host: ${baseUrl}\n token: real-secret-on-disk\n}\n`
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'set-unrelated.bru'),
|
||||
`meta {
|
||||
name: set-unrelated
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setEnvVar("unrelated", "value");
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const result = await runCli([
|
||||
'run', 'set-unrelated.bru',
|
||||
'--env', 'Test',
|
||||
'--env-var', 'token=transient-cli-value',
|
||||
'--sandbox', 'developer',
|
||||
'--noproxy'
|
||||
]);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${result.code}.\n--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
const envContent = fs.readFileSync(path.join(tmpDir, 'environments', 'Test.bru'), 'utf8');
|
||||
expect(envContent).toMatch(/token:\s*real-secret-on-disk/);
|
||||
expect(envContent).not.toContain('transient-cli-value');
|
||||
expect(envContent).toMatch(/unrelated:\s*value/);
|
||||
}, 60_000);
|
||||
|
||||
// When a post-response script throws after writing a var, run-single-request.js calls
|
||||
// syncVariableUpdates with `error.partialResults`. The on-disk env file must reflect the
|
||||
// pre-throw write.
|
||||
it('persists vars written before a post-response script error (partial results)', async () => {
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'bruno.json'),
|
||||
JSON.stringify({ version: '1', name: 'partial-results-collection', type: 'collection' }, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'collection.bru'),
|
||||
'meta {\n name: partial-results-collection\n seq: 1\n}\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'environments', 'Test.bru'),
|
||||
`vars {\n host: ${baseUrl}\n}\n`
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'set-then-throw.bru'),
|
||||
`meta {
|
||||
name: set-then-throw
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setEnvVar("beforeThrow", 42);
|
||||
bru.setCollectionVar("collBeforeThrow", true);
|
||||
throw new Error("boom");
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const result = await runCli([
|
||||
'run', 'set-then-throw.bru', '--env', 'Test', '--sandbox', 'developer', '--noproxy'
|
||||
]);
|
||||
|
||||
// Pin failure exit first — otherwise the persistence assertion below could pass for the
|
||||
// wrong reason (e.g. the script never ran).
|
||||
expect(result.code).not.toBe(0);
|
||||
|
||||
const envContent = fs.readFileSync(path.join(tmpDir, 'environments', 'Test.bru'), 'utf8');
|
||||
expect(envContent).toMatch(/@number\s+beforeThrow:\s*42/);
|
||||
|
||||
const collectionBru = fs.readFileSync(path.join(tmpDir, 'collection.bru'), 'utf8');
|
||||
expect(collectionBru).toMatch(/@boolean\s+collBeforeThrow:\s*true/);
|
||||
}, 60_000);
|
||||
|
||||
// Vars set from inside a `tests {}` block reach disk through the same syncVariableUpdates
|
||||
// path as the post-response script, but via run-single-request.js:881 instead of :802.
|
||||
// Sandbox-agnostic plumbing — runtime equivalence is already covered by the dual-sandbox
|
||||
// happy-path tests above; single-sandbox here matches the analogous L577 case.
|
||||
it('persists vars set inside a tests {} block', async () => {
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'bruno.json'),
|
||||
JSON.stringify({ version: '1', name: 'tests-block-persist-collection', type: 'collection' }, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'collection.bru'),
|
||||
'meta {\n name: tests-block-persist-collection\n seq: 1\n}\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'environments', 'Test.bru'),
|
||||
`vars {\n host: ${baseUrl}\n}\n`
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'set-from-tests.bru'),
|
||||
`meta {
|
||||
name: set-from-tests
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("sets vars from the tests block", function () {
|
||||
bru.setEnvVar("envFromTests", 42);
|
||||
bru.setCollectionVar("collFromTests", true);
|
||||
expect(true).to.equal(true);
|
||||
});
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const result = await runCli([
|
||||
'run', 'set-from-tests.bru', '--env', 'Test', '--sandbox', 'developer', '--noproxy'
|
||||
]);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${result.code}.\n--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
const envContent = fs.readFileSync(path.join(tmpDir, 'environments', 'Test.bru'), 'utf8');
|
||||
expect(envContent).toMatch(/@number\s+envFromTests:\s*42/);
|
||||
|
||||
const collectionBru = fs.readFileSync(path.join(tmpDir, 'collection.bru'), 'utf8');
|
||||
expect(collectionBru).toMatch(/@boolean\s+collFromTests:\s*true/);
|
||||
}, 60_000);
|
||||
|
||||
it('persists env/collection vars mutated as a side effect of vars:post-response expressions', async () => {
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'bruno.json'),
|
||||
JSON.stringify({ version: '1', name: 'vars-side-effect-collection', type: 'collection' }, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'collection.bru'),
|
||||
'meta {\n name: vars-side-effect-collection\n seq: 1\n}\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'environments', 'Test.bru'),
|
||||
`vars {\n host: ${baseUrl}\n}\n`
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'set-from-vars-block.bru'),
|
||||
`meta {
|
||||
name: set-from-vars-block
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
vars:post-response {
|
||||
envSideEffect: bru.setEnvVar("envFromVars", 42)
|
||||
collSideEffect: bru.setCollectionVar("collFromVars", true)
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const result = await runCli([
|
||||
'run', 'set-from-vars-block.bru', '--env', 'Test', '--sandbox', 'developer', '--noproxy'
|
||||
]);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${result.code}.\n--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
const envContent = fs.readFileSync(path.join(tmpDir, 'environments', 'Test.bru'), 'utf8');
|
||||
expect(envContent).toMatch(/@number\s+envFromVars:\s*42/);
|
||||
|
||||
const collectionBru = fs.readFileSync(path.join(tmpDir, 'collection.bru'), 'utf8');
|
||||
expect(collectionBru).toMatch(/@boolean\s+collFromVars:\s*true/);
|
||||
}, 60_000);
|
||||
|
||||
// A throw at the top of `tests {}` (outside any test() callback) is caught by test-runtime,
|
||||
// which attaches in-flight env/collection mutations to `error.partialResults`. The CLI
|
||||
// catch at run-single-request.js:914 syncs those partials so pre-throw writes still land
|
||||
// on disk — same contract as the post-response partial-results case above.
|
||||
it('persists vars written before a tests-block script error (partial results)', async () => {
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'bruno.json'),
|
||||
JSON.stringify({ version: '1', name: 'tests-block-partial-results-collection', type: 'collection' }, null, 2) + '\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'collection.bru'),
|
||||
'meta {\n name: tests-block-partial-results-collection\n seq: 1\n}\n'
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'environments', 'Test.bru'),
|
||||
`vars {\n host: ${baseUrl}\n}\n`
|
||||
);
|
||||
writeFixtureFile(
|
||||
path.join(tmpDir, 'set-then-throw-in-tests.bru'),
|
||||
`meta {
|
||||
name: set-then-throw-in-tests
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
bru.setEnvVar("beforeThrow", 42);
|
||||
bru.setCollectionVar("collBeforeThrow", true);
|
||||
throw new Error("boom");
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const result = await runCli([
|
||||
'run', 'set-then-throw-in-tests.bru', '--env', 'Test', '--sandbox', 'developer', '--noproxy'
|
||||
]);
|
||||
|
||||
// Pin failure exit first — otherwise the persistence assertion below could pass for the
|
||||
// wrong reason (e.g. the script never ran).
|
||||
expect(result.code).not.toBe(0);
|
||||
|
||||
const envContent = fs.readFileSync(path.join(tmpDir, 'environments', 'Test.bru'), 'utf8');
|
||||
expect(envContent).toMatch(/@number\s+beforeThrow:\s*42/);
|
||||
|
||||
const collectionBru = fs.readFileSync(path.join(tmpDir, 'collection.bru'), 'utf8');
|
||||
expect(collectionBru).toMatch(/@boolean\s+collBeforeThrow:\s*true/);
|
||||
}, 60_000);
|
||||
});
|
||||
797
packages/bruno-cli/tests/utils/persist-variables.spec.js
Normal file
797
packages/bruno-cli/tests/utils/persist-variables.spec.js
Normal file
@@ -0,0 +1,797 @@
|
||||
const { describe, it, expect, beforeEach, afterEach, jest: jestObj } = require('@jest/globals');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
applyVariableUpdates,
|
||||
persistVariableUpdates,
|
||||
mergeScriptVarsIntoEnvList,
|
||||
mergeScriptVarsIntoCollectionVarsList
|
||||
} = require('../../src/utils/persist-variables');
|
||||
|
||||
// Each test gets a unique temp dir — mkdtempSync prevents cross-test contamination if any
|
||||
// future test shares a filename, and lets the file work safely under concurrent jest workers.
|
||||
let tmpBase;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-cli-persist-vars-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const writeFile = (name, content) => {
|
||||
const filePath = path.join(tmpBase, name);
|
||||
fs.writeFileSync(filePath, content);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
describe('mergeScriptVarsIntoEnvList', () => {
|
||||
it('updates existing enabled var values', () => {
|
||||
const variables = [
|
||||
{ name: 'host', value: 'old', enabled: true, type: 'text', secret: false }
|
||||
];
|
||||
const merged = mergeScriptVarsIntoEnvList(variables, { host: 'new' });
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].value).toBe('new');
|
||||
});
|
||||
|
||||
it('appends new keys not present in the file', () => {
|
||||
const variables = [];
|
||||
const merged = mergeScriptVarsIntoEnvList(variables, { token: 'abc' });
|
||||
expect(merged).toEqual([
|
||||
{ name: 'token', value: 'abc', enabled: true, type: 'text', secret: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes enabled keys that disappeared from script output', () => {
|
||||
const variables = [
|
||||
{ name: 'a', value: '1', enabled: true, type: 'text', secret: false },
|
||||
{ name: 'b', value: '2', enabled: true, type: 'text', secret: false }
|
||||
];
|
||||
const merged = mergeScriptVarsIntoEnvList(variables, { a: '1' });
|
||||
expect(merged.map((v) => v.name)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('preserves disabled variables even when absent from script output', () => {
|
||||
const variables = [
|
||||
{ name: 'a', value: '1', enabled: false, type: 'text', secret: false }
|
||||
];
|
||||
const merged = mergeScriptVarsIntoEnvList(variables, { b: '2' });
|
||||
const names = merged.map((v) => v.name).sort();
|
||||
expect(names).toEqual(['a', 'b']);
|
||||
expect(merged.find((v) => v.name === 'a').enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores the internal __name__ key', () => {
|
||||
const merged = mergeScriptVarsIntoEnvList([], { __name__: 'dev', host: 'x' });
|
||||
expect(merged).toEqual([
|
||||
{ name: 'host', value: 'x', enabled: true, type: 'text', secret: false }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeScriptVarsIntoCollectionVarsList', () => {
|
||||
it('updates, adds, and removes collection vars symmetrically', () => {
|
||||
const variables = [
|
||||
{ name: 'keep', value: '1', enabled: true, type: 'request' },
|
||||
{ name: 'gone', value: '2', enabled: true, type: 'request' },
|
||||
{ name: 'disabled', value: '3', enabled: false, type: 'request' }
|
||||
];
|
||||
const merged = mergeScriptVarsIntoCollectionVarsList(variables, { keep: '1-updated', fresh: '4' });
|
||||
const byName = Object.fromEntries(merged.map((v) => [v.name, v]));
|
||||
expect(byName.keep.value).toBe('1-updated');
|
||||
expect(byName.fresh.value).toBe('4');
|
||||
expect(byName.disabled).toBeDefined();
|
||||
expect(byName.gone).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyVariableUpdates', () => {
|
||||
it('mirrors dirty scopes into shared in-memory maps', () => {
|
||||
const envVariables = { host: 'old', __name__: 'dev' };
|
||||
const runtimeVariables = { stale: '1' };
|
||||
const globalEnvVars = { region: 'us' };
|
||||
const request = { collectionVariables: { color: 'red' } };
|
||||
|
||||
applyVariableUpdates(
|
||||
{
|
||||
envVariables: { host: 'new', token: 'abc', __name__: 'dev' },
|
||||
runtimeVariables: { fresh: '2' },
|
||||
globalEnvironmentVariables: { region: 'eu' },
|
||||
collectionVariables: { color: 'blue' }
|
||||
},
|
||||
{ envVariables, runtimeVariables, globalEnvVars, request }
|
||||
);
|
||||
|
||||
expect(envVariables).toEqual({ host: 'new', token: 'abc', __name__: 'dev' });
|
||||
expect(runtimeVariables).toEqual({ fresh: '2' });
|
||||
expect(globalEnvVars).toEqual({ region: 'eu' });
|
||||
expect(request.collectionVariables).toEqual({ color: 'blue' });
|
||||
});
|
||||
|
||||
it('leaves untouched scopes alone when result fields are null', () => {
|
||||
const envVariables = { host: 'old', __name__: 'dev' };
|
||||
const runtimeVariables = { keep: '1' };
|
||||
applyVariableUpdates(
|
||||
{ envVariables: null, runtimeVariables: null, globalEnvironmentVariables: null, collectionVariables: null },
|
||||
{ envVariables, runtimeVariables, globalEnvVars: {}, request: {} }
|
||||
);
|
||||
expect(envVariables).toEqual({ host: 'old', __name__: 'dev' });
|
||||
expect(runtimeVariables).toEqual({ keep: '1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistVariableUpdates — env file', () => {
|
||||
it('round-trips a yml env file', () => {
|
||||
const filePath = writeFile('dev.yml',
|
||||
'name: dev\nvariables:\n - name: host\n value: old\n - name: token\n value: keep\n'
|
||||
);
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { host: 'new', extra: 'added', __name__: 'dev' } },
|
||||
{ envFile: { path: filePath, format: 'yml' } }
|
||||
);
|
||||
const written = fs.readFileSync(filePath, 'utf8');
|
||||
expect(written).toMatch(/host/);
|
||||
expect(written).toMatch(/new/);
|
||||
expect(written).toMatch(/extra/);
|
||||
expect(written).not.toMatch(/keep/);
|
||||
expect(written).not.toMatch(/__name__/);
|
||||
});
|
||||
|
||||
it('round-trips a json env file', () => {
|
||||
const filePath = writeFile('dev.json', JSON.stringify({
|
||||
name: 'dev',
|
||||
variables: [
|
||||
{ name: 'host', value: 'old' },
|
||||
{ name: 'token', value: 'keep' }
|
||||
]
|
||||
}, null, 2));
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { host: 'new', extra: 'added', __name__: 'dev' } },
|
||||
{ envFile: { path: filePath, format: 'json' } }
|
||||
);
|
||||
const written = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
const byName = Object.fromEntries(written.variables.map((v) => [v.name, v.value]));
|
||||
expect(byName.host).toBe('new');
|
||||
expect(byName.extra).toBe('added');
|
||||
expect(byName.token).toBeUndefined();
|
||||
});
|
||||
|
||||
// Regression guard: the JSON normalizer (parseEnvironmentJson) only declares the four
|
||||
// fields it knows about. We must NOT round-trip through it for the on-disk write — entries
|
||||
// should keep uid, custom metadata, and (for natural typed values) dataType.
|
||||
it('preserves uid / dataType / unknown fields on json entries', () => {
|
||||
const filePath = writeFile('dev.json', JSON.stringify({
|
||||
name: 'dev',
|
||||
uid: 'env-uid-123',
|
||||
variables: [
|
||||
// Natural JS number with dataType tag — script will echo it back as `42` (number).
|
||||
{ name: 'port', value: 42, dataType: 'number', uid: 'var-1', custom: 'keep-me' },
|
||||
{ name: 'host', value: 'old', uid: 'var-2' }
|
||||
]
|
||||
}, null, 2));
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { port: 42, host: 'new', __name__: 'dev' } },
|
||||
{ envFile: { path: filePath, format: 'json' } }
|
||||
);
|
||||
const written = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
expect(written.uid).toBe('env-uid-123');
|
||||
const byName = Object.fromEntries(written.variables.map((v) => [v.name, v]));
|
||||
expect(byName.port).toMatchObject({
|
||||
value: 42,
|
||||
dataType: 'number',
|
||||
uid: 'var-1',
|
||||
custom: 'keep-me'
|
||||
});
|
||||
expect(byName.host).toMatchObject({ value: 'new', uid: 'var-2' });
|
||||
});
|
||||
|
||||
it('content-comparison guard skips rewrites that are byte-identical', () => {
|
||||
const filePath = writeFile('dev.yml',
|
||||
'name: dev\nvariables:\n - name: host\n value: same\n'
|
||||
);
|
||||
// Spy on writeFileSync instead of comparing mtimes — some filesystems (HFS+, FAT32) have
|
||||
// coarse mtime resolution and would silently pass even if a write occurred.
|
||||
const spy = jestObj.spyOn(fs, 'writeFileSync');
|
||||
try {
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { host: 'same', __name__: 'dev' } },
|
||||
{ envFile: { path: filePath, format: 'yml' } }
|
||||
);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op when the env file does not exist', () => {
|
||||
const filePath = path.join(tmpBase, 'missing.yml');
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { host: 'new' } },
|
||||
{ envFile: { path: filePath, format: 'yml' } }
|
||||
);
|
||||
expect(fs.existsSync(filePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves disabled vars when the script set unrelated keys', () => {
|
||||
const filePath = writeFile('dev.yml',
|
||||
'name: dev\nvariables:\n - name: host\n value: x\n - name: stash\n value: y\n disabled: true\n'
|
||||
);
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { host: 'x', extra: 'z' } },
|
||||
{ envFile: { path: filePath, format: 'yml' } }
|
||||
);
|
||||
const written = fs.readFileSync(filePath, 'utf8');
|
||||
expect(written).toMatch(/stash/);
|
||||
expect(written).toMatch(/disabled:\s*true/);
|
||||
});
|
||||
|
||||
// End-to-end of the `bru.deleteEnvVar` flow: the runtime returns an envVariables map that
|
||||
// simply omits the deleted key, and that omission must reach disk. (The merge filter is
|
||||
// covered as a unit at the mergeScriptVarsIntoEnvList level; this verifies the full
|
||||
// persistEnvFile path including the file write.)
|
||||
it('deletes an enabled key from disk when the script removed it from the env map', () => {
|
||||
const filePath = writeFile('dev.bru',
|
||||
'vars {\n host: keep\n token: gone\n}\n'
|
||||
);
|
||||
persistVariableUpdates(
|
||||
// Note: `token` absent — simulates `bru.deleteEnvVar("token")`.
|
||||
{ envVariables: { host: 'keep', __name__: 'dev' } },
|
||||
{ envFile: { path: filePath, format: 'bru' } }
|
||||
);
|
||||
const written = fs.readFileSync(filePath, 'utf8');
|
||||
expect(written).toMatch(/host:\s*keep/);
|
||||
expect(written).not.toMatch(/token/);
|
||||
});
|
||||
});
|
||||
|
||||
// Design contract: runtime vars live for the duration of the run and never reach disk.
|
||||
// A future refactor that wires runtime persistence by accident would be a leak risk
|
||||
// (runtime is where ephemeral state like generated tokens lives) — guard against it.
|
||||
describe('persistVariableUpdates — runtimeVariables are NEVER persisted', () => {
|
||||
it('does not touch any file when only runtimeVariables is dirtied', () => {
|
||||
const envPath = writeFile('dev.yml',
|
||||
'name: dev\nvariables:\n - name: host\n value: original\n'
|
||||
);
|
||||
const globalPath = writeFile('global.yml',
|
||||
'name: global\nvariables:\n - name: region\n value: us\n'
|
||||
);
|
||||
const collectionRootPath = writeFile('collection.bru',
|
||||
'meta {\n name: t\n seq: 1\n}\n'
|
||||
);
|
||||
const collection = {
|
||||
format: 'bru',
|
||||
brunoConfig: { name: 't' },
|
||||
root: { meta: { name: 't', seq: 1 }, request: { vars: { req: [] } } }
|
||||
};
|
||||
|
||||
const spy = jestObj.spyOn(fs, 'writeFileSync');
|
||||
try {
|
||||
persistVariableUpdates(
|
||||
// Only runtimeVariables dirtied; envVariables/globalEnvironmentVariables/collectionVariables null.
|
||||
{
|
||||
envVariables: null,
|
||||
globalEnvironmentVariables: null,
|
||||
collectionVariables: null,
|
||||
runtimeVariables: { ephemeral: 'should-not-persist' }
|
||||
},
|
||||
{
|
||||
envFile: { path: envPath, format: 'yml' },
|
||||
globalEnvFile: { path: globalPath, format: 'yml' },
|
||||
collection,
|
||||
collectionRootPath
|
||||
}
|
||||
);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
// Sanity check: files on disk unchanged.
|
||||
expect(fs.readFileSync(envPath, 'utf8')).toMatch(/original/);
|
||||
expect(fs.readFileSync(globalPath, 'utf8')).toMatch(/region/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistVariableUpdates — collection vars', () => {
|
||||
it('writes collection vars back to collection.bru', () => {
|
||||
const collectionRootPath = writeFile('collection.bru',
|
||||
'meta {\n name: test\n seq: 1\n}\n\nvars:pre-request {\n k1: old\n}\n'
|
||||
);
|
||||
const collection = {
|
||||
format: 'bru',
|
||||
brunoConfig: { name: 'test' },
|
||||
root: {
|
||||
meta: { name: 'test', seq: 1 },
|
||||
request: {
|
||||
vars: { req: [{ name: 'k1', value: 'old', enabled: true, type: 'request' }] }
|
||||
}
|
||||
}
|
||||
};
|
||||
persistVariableUpdates(
|
||||
{ collectionVariables: { k1: 'new', k2: 'fresh' } },
|
||||
{ collection, collectionRootPath }
|
||||
);
|
||||
const written = fs.readFileSync(collectionRootPath, 'utf8');
|
||||
expect(written).toMatch(/k1:\s*new/);
|
||||
expect(written).toMatch(/k2:\s*fresh/);
|
||||
expect(collection.root.request.vars.req.map((v) => v.name).sort()).toEqual(['k1', 'k2']);
|
||||
});
|
||||
|
||||
it('does nothing if no collectionRootPath given', () => {
|
||||
const collection = { format: 'bru', root: {} };
|
||||
expect(() => persistVariableUpdates(
|
||||
{ collectionVariables: { k1: 'v' } },
|
||||
{ collection, collectionRootPath: undefined }
|
||||
)).not.toThrow();
|
||||
});
|
||||
|
||||
it('writes collection vars back to opencollection.yml for yml-format collections', () => {
|
||||
const collectionRootPath = writeFile('opencollection.yml',
|
||||
'opencollection: 1.0.0\ninfo:\n name: yml-collection\nrequest:\n vars:\n - name: k1\n value: old\n'
|
||||
);
|
||||
const collection = {
|
||||
format: 'yml',
|
||||
brunoConfig: { name: 'yml-collection' },
|
||||
root: {
|
||||
meta: null,
|
||||
request: {
|
||||
headers: [],
|
||||
auth: { mode: 'none' },
|
||||
script: { req: null, res: null },
|
||||
tests: null,
|
||||
vars: { req: [{ uid: 'v1', name: 'k1', value: 'old', enabled: true, type: 'request' }], res: [] }
|
||||
}
|
||||
}
|
||||
};
|
||||
persistVariableUpdates(
|
||||
{
|
||||
collectionVariables: {
|
||||
k1: 'new',
|
||||
k2: 'fresh',
|
||||
count: 42,
|
||||
flag: false,
|
||||
cfg: { tier: 'premium', limit: 100 }
|
||||
}
|
||||
},
|
||||
{ collection, collectionRootPath }
|
||||
);
|
||||
const written = fs.readFileSync(collectionRootPath, 'utf8');
|
||||
expect(written).toMatch(/name:\s*k1/);
|
||||
expect(written).toMatch(/value:\s*new/);
|
||||
expect(written).toMatch(/name:\s*k2/);
|
||||
expect(written).toMatch(/value:\s*fresh/);
|
||||
// Typed collection vars round-trip via the OC `{ type, data }` struct.
|
||||
expect(written).toMatch(/name:\s*count[\s\S]*?type:\s*number[\s\S]*?data:\s*['"]?42/);
|
||||
expect(written).toMatch(/name:\s*flag[\s\S]*?type:\s*boolean[\s\S]*?data:\s*['"]?false/);
|
||||
expect(written).toMatch(/name:\s*cfg[\s\S]*?type:\s*object[\s\S]*?data:[\s\S]*?tier/);
|
||||
// Plain strings stay as raw `value: ...` — no type/data block, no `type: string`.
|
||||
expect(written).not.toMatch(/name:\s*k1[\s\S]*?type:\s*string/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistVariableUpdates — .bru env format', () => {
|
||||
it('round-trips a .bru env file', () => {
|
||||
const filePath = writeFile('dev.bru',
|
||||
'vars {\n host: old\n token: keep\n}\n'
|
||||
);
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { host: 'new', extra: 'added', __name__: 'dev' } },
|
||||
{ envFile: { path: filePath, format: 'bru' } }
|
||||
);
|
||||
const written = fs.readFileSync(filePath, 'utf8');
|
||||
expect(written).toMatch(/host:\s*new/);
|
||||
expect(written).toMatch(/extra:\s*added/);
|
||||
expect(written).not.toMatch(/token:\s*keep/);
|
||||
expect(written).not.toMatch(/__name__/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistVariableUpdates — global env file', () => {
|
||||
it('routes globalEnvironmentVariables result to the globalEnvFile descriptor', () => {
|
||||
const envPath = writeFile('dev.yml',
|
||||
'name: dev\nvariables:\n - name: host\n value: stale\n'
|
||||
);
|
||||
const globalPath = writeFile('global.yml',
|
||||
'name: global\nvariables:\n - name: region\n value: us\n'
|
||||
);
|
||||
persistVariableUpdates(
|
||||
{ globalEnvironmentVariables: { region: 'eu', extra: 'new' } },
|
||||
{
|
||||
envFile: { path: envPath, format: 'yml' },
|
||||
globalEnvFile: { path: globalPath, format: 'yml' }
|
||||
}
|
||||
);
|
||||
// global file got the script's payload
|
||||
expect(fs.readFileSync(globalPath, 'utf8')).toMatch(/value:\s*eu/);
|
||||
// env file untouched — no envVariables in result
|
||||
expect(fs.readFileSync(envPath, 'utf8')).toMatch(/stale/);
|
||||
});
|
||||
|
||||
// Defense-in-depth: the runtime can't currently leak an --env-var override into the
|
||||
// globalEnvironmentVariables result (envVariables and globalEnvironmentVariables are
|
||||
// separate maps in the bru sandbox). But if a user script ever copies between the two
|
||||
// — e.g. `bru.setGlobalEnvVar('token', bru.getVar('token'))` — the override must still
|
||||
// be filtered before reaching the global env file.
|
||||
it('respects envVarOverrides when persisting to the global env file', () => {
|
||||
const globalPath = writeFile('global.yml',
|
||||
'name: global\nvariables:\n - name: token\n value: real-global-secret\n - name: region\n value: us\n'
|
||||
);
|
||||
persistVariableUpdates(
|
||||
// Simulates a user script that copied the env override into the global env scope.
|
||||
{ globalEnvironmentVariables: { token: 'transient-cli-value', region: 'eu' } },
|
||||
{
|
||||
globalEnvFile: { path: globalPath, format: 'yml' },
|
||||
envVarOverrides: new Map([['token', 'transient-cli-value']])
|
||||
}
|
||||
);
|
||||
const written = fs.readFileSync(globalPath, 'utf8');
|
||||
// token's on-disk value must NOT be the transient override
|
||||
expect(written).not.toMatch(/transient-cli-value/);
|
||||
expect(written).toMatch(/real-global-secret/);
|
||||
// unrelated keys still update
|
||||
expect(written).toMatch(/value:\s*eu/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('typed-value inference', () => {
|
||||
it('infers number / boolean / object dataType for newly-added env vars', () => {
|
||||
const filePath = writeFile('typed.yml', 'name: typed\nvariables: []\n');
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { count: 42, flag: true, cfg: { port: 3000 } } },
|
||||
{ envFile: { path: filePath, format: 'yml' } }
|
||||
);
|
||||
const { parseEnvironment } = require('@usebruno/filestore');
|
||||
const reparsed = parseEnvironment(fs.readFileSync(filePath, 'utf8'), { format: 'yml' });
|
||||
const byName = Object.fromEntries(reparsed.variables.map((v) => [v.name, v]));
|
||||
expect(byName.count).toMatchObject({ value: 42, dataType: 'number' });
|
||||
expect(byName.flag).toMatchObject({ value: true, dataType: 'boolean' });
|
||||
expect(byName.cfg).toMatchObject({ value: { port: 3000 }, dataType: 'object' });
|
||||
});
|
||||
|
||||
it('preserves existing dataType when script writes the same typed value', () => {
|
||||
const filePath = writeFile('typed.yml',
|
||||
'name: typed\nvariables:\n - name: count\n value:\n type: number\n data: "1"\n'
|
||||
);
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { count: 5 } },
|
||||
{ envFile: { path: filePath, format: 'yml' } }
|
||||
);
|
||||
const { parseEnvironment } = require('@usebruno/filestore');
|
||||
const reparsed = parseEnvironment(fs.readFileSync(filePath, 'utf8'), { format: 'yml' });
|
||||
expect(reparsed.variables[0]).toMatchObject({ name: 'count', value: 5, dataType: 'number' });
|
||||
});
|
||||
|
||||
it('drops dataType when a previously typed key is set back to a string', () => {
|
||||
const filePath = writeFile('typed.yml',
|
||||
'name: typed\nvariables:\n - name: val\n value:\n type: number\n data: 1\n'
|
||||
);
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { val: 'now-a-string' } },
|
||||
{ envFile: { path: filePath, format: 'yml' } }
|
||||
);
|
||||
const written = fs.readFileSync(filePath, 'utf8');
|
||||
expect(written).not.toMatch(/type:\s*number/);
|
||||
expect(written).toMatch(/value:\s*now-a-string/);
|
||||
});
|
||||
|
||||
it('infers dataType for newly-added collection vars', () => {
|
||||
const collectionRootPath = writeFile('collection.bru', 'meta {\n name: t\n seq: 1\n}\n');
|
||||
const collection = {
|
||||
format: 'bru',
|
||||
brunoConfig: { name: 't' },
|
||||
root: { meta: { name: 't', seq: 1 }, request: { vars: { req: [] } } }
|
||||
};
|
||||
persistVariableUpdates(
|
||||
{ collectionVariables: { count: 7, flag: false } },
|
||||
{ collection, collectionRootPath }
|
||||
);
|
||||
const countVar = collection.root.request.vars.req.find((v) => v.name === 'count');
|
||||
const flagVar = collection.root.request.vars.req.find((v) => v.name === 'flag');
|
||||
expect(countVar.dataType).toBe('number');
|
||||
expect(flagVar.dataType).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
// The inference rule (getDataTypeFromValue in @usebruno/common/utils) is purely:
|
||||
// string → drop dataType (string is the implicit default)
|
||||
// number → 'number'
|
||||
// boolean → 'boolean'
|
||||
// object → 'object' (arrays are typeof 'object' in JS, so they go here too)
|
||||
// null / undefined → 'string' (treated as no-type-info → drop)
|
||||
// Existing dataType on disk does NOT influence the inference — only the new JS value's type.
|
||||
describe('typed-value inference matrix — dataType derived from the new JS value type', () => {
|
||||
const { parseEnvironment } = require('@usebruno/filestore');
|
||||
|
||||
// Tuple shape: [ label, scriptValue, expectedDataType, expectedValue ]
|
||||
// - label : human-readable name interpolated into the test title
|
||||
// - scriptValue : the value `bru.setEnvVar('v', X)` puts in the env map
|
||||
// - expectedDataType : what `dataType` should end up on disk (or undefined if absent)
|
||||
// - expectedValue : what the value should round-trip to through the yml parser
|
||||
it.each([
|
||||
['string', 'hello', undefined, 'hello'],
|
||||
['number', 42, 'number', 42],
|
||||
['number (zero)', 0, 'number', 0],
|
||||
['boolean (true)', true, 'boolean', true],
|
||||
['boolean (false)', false, 'boolean', false],
|
||||
['object', { port: 3000 }, 'object', { port: 3000 }],
|
||||
['array (typeof object)', [1, 2, 3], 'object', [1, 2, 3]],
|
||||
// null collapses to '' through yml round-trip; the inference rule still treats it as
|
||||
// string (no dataType). undefined behaves the same way.
|
||||
['null (→ string, empty)', null, undefined, '']
|
||||
])('script writes %s → on-disk dataType is %s', (_label, value, expectedDataType, expectedValue) => {
|
||||
const filePath = writeFile('inference.yml',
|
||||
'name: t\nvariables:\n - name: v\n value: original\n'
|
||||
);
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { v: value } },
|
||||
{ envFile: { path: filePath, format: 'yml' } }
|
||||
);
|
||||
const reparsed = parseEnvironment(fs.readFileSync(filePath, 'utf8'), { format: 'yml' });
|
||||
expect(reparsed.variables[0].value).toEqual(expectedValue);
|
||||
if (expectedDataType === undefined) {
|
||||
expect(reparsed.variables[0].dataType).toBeUndefined();
|
||||
} else {
|
||||
expect(reparsed.variables[0].dataType).toBe(expectedDataType);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cross-type transitions: the rule above means inference IGNORES the existing dataType on
|
||||
// disk. Whatever type the script writes is what lands on disk. This matters because a
|
||||
// user-written `dataType: number` annotation can be silently dropped (or flipped to another
|
||||
// type) the first time a script writes a different-typed value to that key.
|
||||
describe('typed-value inference: cross-type transitions ignore the existing on-disk dataType', () => {
|
||||
const { parseEnvironment, stringifyEnvironment } = require('@usebruno/filestore');
|
||||
|
||||
const seedYmlEnv = (seedVar) => stringifyEnvironment(
|
||||
{
|
||||
name: 't',
|
||||
variables: [{ name: 'v', enabled: true, secret: false, ...seedVar }]
|
||||
},
|
||||
{ format: 'yml' }
|
||||
);
|
||||
|
||||
// Tuple shape: [ label, seed, scriptValue, expected ]
|
||||
// - label : human-readable name interpolated into the test title
|
||||
// - seed : { value, dataType? } — variable's initial on-disk state.
|
||||
// Omit dataType to seed a plain string.
|
||||
// - scriptValue : the value `bru.setEnvVar('v', X)` puts in the env map after parse
|
||||
// - expected : { value, dataType } end state on disk after persistVariableUpdates;
|
||||
// `dataType: undefined` asserts the field is absent on disk
|
||||
it.each([
|
||||
['number → boolean (annotation flips)', { value: 42, dataType: 'number' }, true, { value: true, dataType: 'boolean' }],
|
||||
['number → object', { value: 42, dataType: 'number' }, { x: 1 }, { value: { x: 1 }, dataType: 'object' }],
|
||||
['boolean → number', { value: true, dataType: 'boolean' }, 99, { value: 99, dataType: 'number' }],
|
||||
['object → string (annotation dropped)', { value: { port: 3000 }, dataType: 'object' }, 'now-a-string', { value: 'now-a-string', dataType: undefined }],
|
||||
['string → number (annotation added)', { value: 'was-a-string' }, 42, { value: 42, dataType: 'number' }]
|
||||
])('%s', (_label, seed, scriptValue, expected) => {
|
||||
const filePath = writeFile('xform.yml', seedYmlEnv(seed));
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { v: scriptValue } },
|
||||
{ envFile: { path: filePath, format: 'yml' } }
|
||||
);
|
||||
const reparsed = parseEnvironment(fs.readFileSync(filePath, 'utf8'), { format: 'yml' });
|
||||
const entry = reparsed.variables[0];
|
||||
expect(entry.value).toEqual(expected.value);
|
||||
if (expected.dataType === undefined) {
|
||||
expect(entry.dataType).toBeUndefined();
|
||||
} else {
|
||||
expect(entry.dataType).toBe(expected.dataType);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('script-driven typed vars: disk content has the right dataType annotations', () => {
|
||||
// The .bru serializer emits `@number\n port: 3000` (annotation on its own line) — see
|
||||
// packages/bruno-lang/v2/src/utils.js serializeAnnotations + serializeVar.
|
||||
it('persists @number/@boolean/@object annotations in a .bru env file', () => {
|
||||
const filePath = writeFile('Test.bru', 'vars {\n host: https://example.test\n}\n');
|
||||
|
||||
persistVariableUpdates(
|
||||
{
|
||||
envVariables: {
|
||||
host: 'https://example.test',
|
||||
envNum: 42,
|
||||
envBool: true,
|
||||
envObj: { port: 3000, ssl: true },
|
||||
envStr: 'hello'
|
||||
}
|
||||
},
|
||||
{ envFile: { path: filePath, format: 'bru' } }
|
||||
);
|
||||
|
||||
const written = fs.readFileSync(filePath, 'utf8');
|
||||
expect(written).toMatch(/@number\s+envNum:\s*42/);
|
||||
expect(written).toMatch(/@boolean\s+envBool:\s*true/);
|
||||
expect(written).toMatch(/@object\s+envObj:/);
|
||||
expect(written).toContain('"port": 3000');
|
||||
expect(written).toContain('"ssl": true');
|
||||
// 'string' is the implicit default — never materialized as an annotation.
|
||||
expect(written).not.toMatch(/@string\s+envStr/);
|
||||
expect(written).toMatch(/envStr:\s*hello/);
|
||||
});
|
||||
|
||||
it('persists @number/@boolean/@object annotations in collection.bru for collection vars', () => {
|
||||
const collectionRootPath = writeFile(
|
||||
'collection.bru',
|
||||
'meta {\n name: typed-collection\n seq: 1\n}\n'
|
||||
);
|
||||
const collection = {
|
||||
format: 'bru',
|
||||
brunoConfig: { name: 'typed-collection' },
|
||||
root: {
|
||||
meta: { name: 'typed-collection', seq: 1 },
|
||||
request: { vars: { req: [] } }
|
||||
}
|
||||
};
|
||||
|
||||
persistVariableUpdates(
|
||||
{
|
||||
collectionVariables: {
|
||||
collNum: 7,
|
||||
collBool: false,
|
||||
collObj: { region: 'eu', retries: 3 },
|
||||
collStr: 'plain'
|
||||
}
|
||||
},
|
||||
{ collection, collectionRootPath }
|
||||
);
|
||||
|
||||
const written = fs.readFileSync(collectionRootPath, 'utf8');
|
||||
expect(written).toMatch(/@number\s+collNum:\s*7/);
|
||||
expect(written).toMatch(/@boolean\s+collBool:\s*false/);
|
||||
expect(written).toMatch(/@object\s+collObj:/);
|
||||
expect(written).toContain('"region": "eu"');
|
||||
expect(written).toContain('"retries": 3');
|
||||
expect(written).not.toMatch(/@string\s+collStr/);
|
||||
expect(written).toMatch(/collStr:\s*plain/);
|
||||
});
|
||||
|
||||
// The yml serializer encodes typed values as a `{ type, data }` block instead of `@dataType`
|
||||
// decorators — see packages/bruno-filestore/src/formats/yml/common/datatype.ts.
|
||||
it('persists typed global env vars to a yml global env file with type/data blocks', () => {
|
||||
const globalPath = writeFile(
|
||||
'global.yml',
|
||||
'name: global\nvariables:\n - name: baseUrl\n value: https://example.test\n'
|
||||
);
|
||||
|
||||
persistVariableUpdates(
|
||||
{
|
||||
globalEnvironmentVariables: {
|
||||
baseUrl: 'https://example.test',
|
||||
globalNum: 99,
|
||||
globalBool: false,
|
||||
globalObj: { tier: 'premium', limit: 100 }
|
||||
}
|
||||
},
|
||||
{ globalEnvFile: { path: globalPath, format: 'yml' } }
|
||||
);
|
||||
|
||||
const written = fs.readFileSync(globalPath, 'utf8');
|
||||
expect(written).toMatch(/name:\s*globalNum[\s\S]*?type:\s*number[\s\S]*?data:\s*['"]?99/);
|
||||
expect(written).toMatch(/name:\s*globalBool[\s\S]*?type:\s*boolean[\s\S]*?data:\s*['"]?false/);
|
||||
expect(written).toMatch(/name:\s*globalObj[\s\S]*?type:\s*object[\s\S]*?data:[\s\S]*?tier/);
|
||||
// 'string' values stay as raw `value: ...` — no type/data block.
|
||||
expect(written).toMatch(/name:\s*baseUrl[\s\S]*?value:\s*https:\/\/example\.test/);
|
||||
expect(written).not.toMatch(/name:\s*baseUrl[\s\S]*?type:\s*string/);
|
||||
});
|
||||
|
||||
// Regression guard: a script that writes a string to a previously typed key must drop the
|
||||
// annotation so the disk shape matches the new value's inferred dataType.
|
||||
it('drops the @number annotation in a .bru env file when a script downgrades the value to a string', () => {
|
||||
const filePath = writeFile(
|
||||
'Test.bru',
|
||||
'vars {\n @number\n count: 1\n}\n'
|
||||
);
|
||||
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { count: 'now-a-string' } },
|
||||
{ envFile: { path: filePath, format: 'bru' } }
|
||||
);
|
||||
|
||||
const written = fs.readFileSync(filePath, 'utf8');
|
||||
expect(written).not.toMatch(/@number/);
|
||||
expect(written).toMatch(/count:\s*now-a-string/);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression guards for --env-var leak: CLI overrides commonly carry secret material
|
||||
// (the CLI can't decrypt secret vars at rest, so users pass them in transiently).
|
||||
// Those values must never reach disk, and the file's existing entry must be preserved.
|
||||
describe('mergeScriptVarsIntoEnvList — --env-var overrides', () => {
|
||||
it('does not persist an override value even when the script returns it back in the env map', () => {
|
||||
const variables = [
|
||||
{ name: 'token', value: 'real-secret', enabled: true, type: 'text', secret: true },
|
||||
{ name: 'host', value: 'localhost', enabled: true, type: 'text', secret: false }
|
||||
];
|
||||
// Runtime returns the full env map (because some other var was dirtied), including the override.
|
||||
const scriptVars = { token: 'transient-cli-value', host: 'api.example.com' };
|
||||
|
||||
const merged = mergeScriptVarsIntoEnvList(variables, scriptVars, {
|
||||
overrides: new Map([['token', 'transient-cli-value']])
|
||||
});
|
||||
|
||||
const tokenEntry = merged.find((v) => v.name === 'token');
|
||||
expect(tokenEntry).toBeDefined();
|
||||
expect(tokenEntry.value).toBe('real-secret');
|
||||
expect(tokenEntry.secret).toBe(true);
|
||||
|
||||
const hostEntry = merged.find((v) => v.name === 'host');
|
||||
expect(hostEntry.value).toBe('api.example.com');
|
||||
});
|
||||
|
||||
it('does not drop an overridden file entry when the script does not echo it back', () => {
|
||||
const variables = [
|
||||
{ name: 'token', value: 'real-secret', enabled: true, type: 'text', secret: true }
|
||||
];
|
||||
// Script touched something else; runtime returns env without `token` since the override
|
||||
// was filtered out before this call (or simply was never in the map).
|
||||
const merged = mergeScriptVarsIntoEnvList(variables, { other: 'x' }, {
|
||||
overrides: new Map([['token', 'transient-cli-value']])
|
||||
});
|
||||
|
||||
expect(merged.find((v) => v.name === 'token')).toMatchObject({ value: 'real-secret', secret: true });
|
||||
});
|
||||
|
||||
it('does not append a new entry for an override key when the script echoes back the override value', () => {
|
||||
const merged = mergeScriptVarsIntoEnvList([], { token: 'transient', x: '1' }, {
|
||||
overrides: new Map([['token', 'transient']])
|
||||
});
|
||||
|
||||
expect(merged.find((v) => v.name === 'token')).toBeUndefined();
|
||||
expect(merged.find((v) => v.name === 'x')).toBeDefined();
|
||||
});
|
||||
|
||||
// A deliberate `bru.setEnvVar('token', 'rotated')` must persist even when the CLI also
|
||||
// injected `--env-var token=transient`. We can tell it apart from the leak because the
|
||||
// script's value differs from the injected override value.
|
||||
it('persists a deliberate same-named script write that differs from the override value', () => {
|
||||
const variables = [
|
||||
{ name: 'token', value: 'real', enabled: true, type: 'text', secret: true }
|
||||
];
|
||||
const merged = mergeScriptVarsIntoEnvList(variables, { token: 'rotated' }, {
|
||||
overrides: new Map([['token', 'transient-cli-value']])
|
||||
});
|
||||
|
||||
const tokenEntry = merged.find((v) => v.name === 'token');
|
||||
expect(tokenEntry).toBeDefined();
|
||||
expect(tokenEntry.value).toBe('rotated');
|
||||
// Existing on-disk metadata (secret flag) is preserved during the update.
|
||||
expect(tokenEntry.secret).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistVariableUpdates — disk error resilience', () => {
|
||||
// CI runs may execute against read-only mounts. The design choice is to log + continue;
|
||||
// the try/catch lives in run-single-request.js. persistVariableUpdates itself surfaces the
|
||||
// throw, which is what we verify here — the caller is the one that swallows it.
|
||||
// fs.writeFileSync is mocked to throw EACCES so the assertion holds on every OS (root in
|
||||
// a container can write through a chmod 0o444, and Windows doesn't honor unix bits).
|
||||
it('surfaces fs errors to the caller (caller is expected to catch and warn)', () => {
|
||||
const filePath = path.join(tmpBase, 'Locked.bru');
|
||||
fs.writeFileSync(filePath, 'vars {\n host: old\n}\n');
|
||||
|
||||
const writeErr = Object.assign(new Error('locked'), { code: 'EACCES' });
|
||||
const spy = jestObj.spyOn(fs, 'writeFileSync').mockImplementation(() => {
|
||||
throw writeErr;
|
||||
});
|
||||
|
||||
let thrown;
|
||||
try {
|
||||
persistVariableUpdates(
|
||||
{ envVariables: { host: 'new' } },
|
||||
{ envFile: { path: filePath, format: 'bru' } }
|
||||
);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(thrown).toBe(writeErr);
|
||||
expect(thrown.code).toBe('EACCES');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user