diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 89d95c89f..77c477df4 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -360,6 +360,20 @@ const handler = async function (argv) { const runtimeVariables = {}; let envVars = {}; + let envFileDescriptor = null; + let globalEnvFileDescriptor = null; + // --env-var overrides as Map. 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; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 433430a52..f5525fe80 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -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; } diff --git a/packages/bruno-cli/src/utils/persist-variables.js b/packages/bruno-cli/src/utils/persist-variables.js new file mode 100644 index 000000000..80edcfe00 --- /dev/null +++ b/packages/bruno-cli/src/utils/persist-variables.js @@ -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} vars - Flat name→value map; may be null/undefined. + * @returns {Object} 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} target - Object mutated in place. + * @param {Object} 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} scriptVarsRaw - Flat map from the script runtime; may include `__name__`. + * @param {{ overrides?: Map }} [options] - Names → injected override values. + * @returns {Array} 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} scriptVars - Flat map from the script runtime. + * @returns {Array} 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} scriptVars - Flat map of vars the script declared. + * @param {{ overrides?: Map }} [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} 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 + * }} 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 ` flag looks + * up `/environments/.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 +}; diff --git a/packages/bruno-cli/tests/integration/run-typed-persistence.spec.js b/packages/bruno-cli/tests/integration/run-typed-persistence.spec.js new file mode 100644 index 000000000..b31a85b56 --- /dev/null +++ b/packages/bruno-cli/tests/integration/run-typed-persistence.spec.js @@ -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 /environments/.yml. + it.each([ + ['developer'], + ['safe'] + ])('writes typed global env vars back to /environments/.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 .{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); +}); diff --git a/packages/bruno-cli/tests/utils/persist-variables.spec.js b/packages/bruno-cli/tests/utils/persist-variables.spec.js new file mode 100644 index 000000000..1ef6b391d --- /dev/null +++ b/packages/bruno-cli/tests/utils/persist-variables.spec.js @@ -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'); + }); +});