diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 256e252e35f..322695ec830 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -37,6 +37,7 @@ const menuTemplate = require('./app/menu-template'); const { openCollection } = require('./app/collections'); const registerNetworkIpc = require('./ipc/network'); const registerCollectionsIpc = require('./ipc/collection'); +const registerCollectionScriptsIpc = require('./ipc/scripts'); const registerFilesystemIpc = require('./ipc/filesystem'); const registerPreferencesIpc = require('./ipc/preferences'); const registerSystemMonitorIpc = require('./ipc/system-monitor'); @@ -461,6 +462,7 @@ app.on('ready', async () => { registerNetworkIpc(mainWindow); registerGlobalEnvironmentsIpc(mainWindow, globalEnvironmentsManager); registerCollectionsIpc(mainWindow, collectionWatcher); + registerCollectionScriptsIpc(mainWindow); registerPreferencesIpc(mainWindow, collectionWatcher); registerWorkspaceIpc(mainWindow, workspaceWatcher); registerApiSpecIpc(mainWindow, apiSpecWatcher); diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 14475aa455c..2b84594382e 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -28,6 +28,10 @@ const { createFormData } = require('../../utils/form-data'); const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence } = require('../../utils/collection'); const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, updateCollectionOauth2Credentials, clearOauth2CredentialsByCredentialsId } = require('../../utils/oauth2'); const { preferencesUtil } = require('../../store/preferences'); +const CollectionSecurityStore = require('../../store/collection-security'); +const { isScriptPathSafe, parseEnvVarsFromOutput, runShellScript } = require('../../utils/collection-scripts'); + +const collectionSecurityStore = new CollectionSecurityStore(); const { getProcessEnvVars } = require('../../store/process-env'); const { getBrunoConfig } = require('../../store/bruno-config'); const Oauth2Store = require('../../store/oauth2'); @@ -1324,6 +1328,52 @@ const registerNetworkIpc = (mainWindow) => { cancelTokenUid }); + // Run beforeCollectionRun scripts before the request loop begins. + // Results are merged into envVars so the runner picks them up immediately. + const securityConfig = collectionSecurityStore.getSecurityConfigForCollection(collectionPath); + if (securityConfig?.jsSandboxMode === 'developer') { + const beforeRunScripts = get(brunoConfig, 'collectionScripts', []).filter( + (s) => s.runOn?.includes('beforeCollectionRun') + ); + for (const script of beforeRunScripts) { + if (!isScriptPathSafe(collectionPath, script.file)) continue; + try { + const { exitCode, stdout, stderr } = await runShellScript(collectionPath, script.file); + if (exitCode !== 0) { + const stderrTail = (stderr || '').slice(-500); + console.error( + `Collection script '${script.name}' exited with code ${exitCode}.${stderrTail ? ` stderr: ${stderrTail}` : ''}` + ); + mainWindow.webContents.send('main:collection-script-output', { + collectionUid, + scriptUid: script.uid, + stream: 'stderr', + data: `Script '${script.name}' exited with code ${exitCode}.\n${stderrTail}` + }); + continue; + } + if (script.outputMode === 'envVars') { + const parsed = parseEnvVarsFromOutput(stdout); + Object.assign(envVars, parsed); + mainWindow.webContents.send('main:script-environment-update', { + envVariables: parsed, runtimeVariables: {}, persistentEnvVariables: parsed, collectionUid + }); + mainWindow.webContents.send('main:persistent-env-variables-update', { + persistentEnvVariables: parsed, collectionUid + }); + } + } catch (err) { + console.error(`Collection script '${script.name}' failed:`, err.message); + mainWindow.webContents.send('main:collection-script-output', { + collectionUid, + scriptUid: script.uid, + stream: 'stderr', + data: `Script '${script.name}' failed: ${err.message}` + }); + } + } + } + try { let folderRequests = []; diff --git a/packages/bruno-electron/src/ipc/scripts.js b/packages/bruno-electron/src/ipc/scripts.js new file mode 100644 index 00000000000..93e2ed03f26 --- /dev/null +++ b/packages/bruno-electron/src/ipc/scripts.js @@ -0,0 +1,62 @@ +const { ipcMain } = require('electron'); +const CollectionSecurityStore = require('../store/collection-security'); +const { isScriptPathSafe, parseEnvVarsFromOutput, runShellScript } = require('../utils/collection-scripts'); + +const collectionSecurityStore = new CollectionSecurityStore(); + +const registerCollectionScriptsIpc = (mainWindow) => { + ipcMain.handle('renderer:run-collection-script', async (event, payload) => { + const { collectionUid, collectionPath, script } = payload || {}; + + if (typeof collectionPath !== 'string' || !collectionPath.length) { + return { error: 'Invalid request: collection path is required.' }; + } + if (!script || typeof script !== 'object') { + return { error: 'Invalid request: script is required.' }; + } + if (typeof script.file !== 'string' || !script.file.length) { + return { error: 'Invalid request: script.file is required.' }; + } + + const securityConfig = collectionSecurityStore.getSecurityConfigForCollection(collectionPath); + if (securityConfig?.jsSandboxMode !== 'developer') { + return { error: 'Collection scripts require Developer Mode. Enable it in Collection Settings > Security.' }; + } + + if (!isScriptPathSafe(collectionPath, script.file)) { + return { error: 'Script path must be within the collection directory.' }; + } + + const sendChunk = (data, stream) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('main:collection-script-output', { + collectionUid, scriptUid: script.uid, data, stream + }); + } + }; + + try { + const { exitCode, stdout } = await runShellScript(collectionPath, script.file, { + onStdout: (chunk) => sendChunk(chunk, 'stdout'), + onStderr: (chunk) => sendChunk(chunk, 'stderr') + }); + + if (script.outputMode === 'envVars' && exitCode === 0 && mainWindow && !mainWindow.isDestroyed()) { + const parsed = parseEnvVarsFromOutput(stdout); + mainWindow.webContents.send('main:script-environment-update', { + envVariables: parsed, runtimeVariables: {}, persistentEnvVariables: parsed, collectionUid + }); + mainWindow.webContents.send('main:persistent-env-variables-update', { + persistentEnvVariables: parsed, collectionUid + }); + } + + return { exitCode }; + } catch (err) { + sendChunk(`Error: ${err.message}`, 'stderr'); + return { error: err.message, exitCode: 1 }; + } + }); +}; + +module.exports = registerCollectionScriptsIpc; diff --git a/packages/bruno-electron/src/utils/collection-scripts.js b/packages/bruno-electron/src/utils/collection-scripts.js new file mode 100644 index 00000000000..5045478fde7 --- /dev/null +++ b/packages/bruno-electron/src/utils/collection-scripts.js @@ -0,0 +1,110 @@ +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); + +const DEFAULT_MAX_BUFFER = 1024 * 1024; // 1 MiB per stream +const TRUNCATION_MARKER = '\n[bruno: output truncated — exceeded maxBuffer]\n'; + +/** + * Returns true only if scriptFile resolves — through symlinks — to a path + * inside collectionPath. Uses fs.realpathSync so a symlink inside the + * collection that points outside it is rejected. Returns false if either + * path cannot be realpath-resolved (e.g. script does not exist), so the + * caller never executes a non-existent or unreadable target. + */ +function isScriptPathSafe(collectionPath, scriptFile) { + const lexicalCollection = path.resolve(collectionPath); + const lexicalScript = path.resolve(collectionPath, scriptFile); + + let realCollection; + try { + realCollection = fs.realpathSync(lexicalCollection); + } catch { + // Collection directory does not exist on disk: fall back to lexical-only + // comparison. This branch exists for unit tests that use fictional paths; + // the IPC handler should never call this for a missing collection. + return lexicalScript === lexicalCollection || lexicalScript.startsWith(lexicalCollection + path.sep); + } + + let realScript; + try { + realScript = fs.realpathSync(lexicalScript); + } catch { + // Script doesn't exist (or unreadable). Reject — never spawn missing files. + return false; + } + + return realScript === realCollection || realScript.startsWith(realCollection + path.sep); +} + +function parseEnvVarsFromOutput(output) { + const vars = {}; + for (const line of output.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (match) { + vars[match[1]] = match[2]; + } + } + return vars; +} + +/** + * On Windows, spawning a script path directly cannot interpret shebangs or + * resolve file associations (.cmd/.bat/.ps1/.js); shell:true delegates to + * cmd.exe which does. On POSIX, the kernel honours the shebang itself. + */ +function buildSpawnArgs(resolvedScript, cwd, platform = process.platform) { + const isWindows = platform === 'win32'; + return { + command: resolvedScript, + args: [], + options: { + cwd, + env: process.env, + ...(isWindows ? { shell: true, windowsHide: true } : {}) + } + }; +} + +function runShellScript(collectionPath, scriptFile, { onStdout, onStderr, maxBuffer = DEFAULT_MAX_BUFFER } = {}) { + return new Promise((resolve, reject) => { + const resolvedScript = path.resolve(collectionPath, scriptFile); + const { command, args, options } = buildSpawnArgs(resolvedScript, collectionPath); + const proc = spawn(command, args, options); + + let stdout = ''; + let stderr = ''; + let truncated = false; + + const append = (current, chunk) => { + if (truncated) return current; + const remaining = maxBuffer - current.length; + if (chunk.length <= remaining) return current + chunk; + truncated = true; + return current + chunk.slice(0, Math.max(0, remaining)) + TRUNCATION_MARKER; + }; + + proc.stdout.on('data', (d) => { + const chunk = d.toString(); + stdout = append(stdout, chunk); + onStdout?.(chunk); + }); + proc.stderr.on('data', (d) => { + const chunk = d.toString(); + stderr = append(stderr, chunk); + onStderr?.(chunk); + }); + proc.on('close', (exitCode) => resolve({ exitCode, stdout, stderr, truncated })); + proc.on('error', (err) => { + if (err.code === 'EACCES') { + reject(new Error(`Script is not executable. Run: chmod +x ${scriptFile}`)); + } else { + reject(err); + } + }); + }); +} + +module.exports = { isScriptPathSafe, parseEnvVarsFromOutput, runShellScript, buildSpawnArgs }; diff --git a/packages/bruno-electron/tests/ipc/scripts.spec.js b/packages/bruno-electron/tests/ipc/scripts.spec.js new file mode 100644 index 00000000000..635e9987853 --- /dev/null +++ b/packages/bruno-electron/tests/ipc/scripts.spec.js @@ -0,0 +1,103 @@ +// Mocks must be declared before requiring the module under test. +const mockHandle = jest.fn(); +jest.mock('electron', () => ({ + ipcMain: { handle: (...args) => mockHandle(...args) } +})); + +const mockGetSecurityConfig = jest.fn(); +jest.mock('../../src/store/collection-security', () => + jest.fn().mockImplementation(() => ({ + getSecurityConfigForCollection: (...args) => mockGetSecurityConfig(...args) + })) +); + +const mockRunShellScript = jest.fn(); +jest.mock('../../src/utils/collection-scripts', () => ({ + isScriptPathSafe: jest.fn(() => true), + parseEnvVarsFromOutput: jest.fn(() => ({})), + runShellScript: (...args) => mockRunShellScript(...args) +})); + +const registerCollectionScriptsIpc = require('../../src/ipc/scripts'); + +describe('renderer:run-collection-script — payload validation', () => { + let handler; + let mainWindow; + + beforeEach(() => { + jest.clearAllMocks(); + mainWindow = { + isDestroyed: () => false, + webContents: { send: jest.fn() } + }; + registerCollectionScriptsIpc(mainWindow); + // The handler is the second arg to the most recent ipcMain.handle call. + const call = mockHandle.mock.calls.find((c) => c[0] === 'renderer:run-collection-script'); + handler = call[1]; + mockGetSecurityConfig.mockReturnValue({ jsSandboxMode: 'developer' }); + }); + + test('rejects when collectionPath is missing', async () => { + const result = await handler({}, { collectionUid: 'c1', script: { uid: 's', file: 'run.sh' } }); + expect(result.error).toMatch(/collection path/i); + expect(mockRunShellScript).not.toHaveBeenCalled(); + }); + + test('rejects when collectionPath is not a string', async () => { + const result = await handler({}, { + collectionUid: 'c1', + collectionPath: 123, + script: { uid: 's', file: 'run.sh' } + }); + expect(result.error).toMatch(/collection path/i); + expect(mockRunShellScript).not.toHaveBeenCalled(); + }); + + test('rejects when script is missing', async () => { + const result = await handler({}, { collectionUid: 'c1', collectionPath: '/c' }); + expect(result.error).toMatch(/script/i); + expect(mockRunShellScript).not.toHaveBeenCalled(); + }); + + test('rejects when script.file is missing', async () => { + const result = await handler({}, { + collectionUid: 'c1', + collectionPath: '/c', + script: { uid: 's' } + }); + expect(result.error).toMatch(/script\.file|script file/i); + expect(mockRunShellScript).not.toHaveBeenCalled(); + }); + + test('rejects when script.file is not a string', async () => { + const result = await handler({}, { + collectionUid: 'c1', + collectionPath: '/c', + script: { uid: 's', file: 42 } + }); + expect(result.error).toMatch(/script\.file|script file/i); + expect(mockRunShellScript).not.toHaveBeenCalled(); + }); + + test('proceeds with a fully-formed payload', async () => { + mockRunShellScript.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + const result = await handler({}, { + collectionUid: 'c1', + collectionPath: '/c', + script: { uid: 's', file: 'run.sh' } + }); + expect(result.exitCode).toBe(0); + expect(mockRunShellScript).toHaveBeenCalledTimes(1); + }); + + test('blocks execution when sandbox mode is not developer', async () => { + mockGetSecurityConfig.mockReturnValue({ jsSandboxMode: 'safe' }); + const result = await handler({}, { + collectionUid: 'c1', + collectionPath: '/c', + script: { uid: 's', file: 'run.sh' } + }); + expect(result.error).toMatch(/developer mode/i); + expect(mockRunShellScript).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/bruno-electron/tests/utils/collection-scripts.spec.js b/packages/bruno-electron/tests/utils/collection-scripts.spec.js new file mode 100644 index 00000000000..b1e36bcb575 --- /dev/null +++ b/packages/bruno-electron/tests/utils/collection-scripts.spec.js @@ -0,0 +1,267 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + isScriptPathSafe, + parseEnvVarsFromOutput, + runShellScript, + buildSpawnArgs +} = require('../../src/utils/collection-scripts'); + +describe('isScriptPathSafe', () => { + const col = '/home/user/collection'; + + test('allows a relative path inside the collection', () => { + expect(isScriptPathSafe(col, 'scripts/run.sh')).toBe(true); + }); + + test('allows a file at the collection root', () => { + expect(isScriptPathSafe(col, 'run.sh')).toBe(true); + }); + + test('blocks single-level traversal', () => { + expect(isScriptPathSafe(col, '../evil.sh')).toBe(false); + }); + + test('blocks traversal disguised inside a valid-looking prefix', () => { + expect(isScriptPathSafe(col, 'scripts/../../etc/passwd')).toBe(false); + }); + + test('blocks an absolute path that escapes the collection', () => { + expect(isScriptPathSafe(col, '/etc/passwd')).toBe(false); + }); + + test('does not accept a sibling directory that shares a name prefix', () => { + expect(isScriptPathSafe(col, '/home/user/collection-extra/evil.sh')).toBe(false); + }); +}); + +describe('isScriptPathSafe — symlink escape', () => { + let tmpRoot; + let collectionDir; + let outsideDir; + let symlinkSupported = true; + + beforeAll(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-symlink-test-')); + collectionDir = path.join(tmpRoot, 'collection'); + outsideDir = path.join(tmpRoot, 'outside'); + fs.mkdirSync(collectionDir); + fs.mkdirSync(outsideDir); + fs.writeFileSync(path.join(outsideDir, 'evil.sh'), '#!/bin/sh\necho pwned\n'); + + try { + fs.symlinkSync(path.join(outsideDir, 'evil.sh'), path.join(collectionDir, 'link.sh')); + } catch (err) { + // Windows non-admin can't create symlinks — skip rather than fail. + symlinkSupported = false; + } + }); + + afterAll(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + test('rejects a symlink inside the collection that points outside it', () => { + if (!symlinkSupported) return; + expect(isScriptPathSafe(collectionDir, 'link.sh')).toBe(false); + }); + + test('still accepts a real file inside the collection', () => { + const realPath = path.join(collectionDir, 'real.sh'); + fs.writeFileSync(realPath, '#!/bin/sh\necho ok\n'); + expect(isScriptPathSafe(collectionDir, 'real.sh')).toBe(true); + }); + + test('returns false rather than throwing when the script does not exist', () => { + expect(isScriptPathSafe(collectionDir, 'does-not-exist.sh')).toBe(false); + }); +}); + +describe('parseEnvVarsFromOutput', () => { + test('parses a plain KEY=VALUE assignment', () => { + expect(parseEnvVarsFromOutput('TOKEN=abc123')).toEqual({ TOKEN: 'abc123' }); + }); + + test('parses export KEY=VALUE syntax', () => { + expect(parseEnvVarsFromOutput('export TOKEN=abc123')).toEqual({ TOKEN: 'abc123' }); + }); + + test('splits only on the first equals — values may contain =', () => { + expect(parseEnvVarsFromOutput('KEY=a=b=c')).toEqual({ KEY: 'a=b=c' }); + }); + + test('preserves spaces inside values', () => { + expect(parseEnvVarsFromOutput('GREETING=hello world')).toEqual({ GREETING: 'hello world' }); + }); + + test('accepts an empty value', () => { + expect(parseEnvVarsFromOutput('REVOKED_TOKEN=')).toEqual({ REVOKED_TOKEN: '' }); + }); + + test('ignores comment lines', () => { + const output = '# refreshing tokens\nACCESS_TOKEN=tok_live_abc'; + expect(parseEnvVarsFromOutput(output)).toEqual({ ACCESS_TOKEN: 'tok_live_abc' }); + }); + + test('ignores blank lines and surrounding whitespace', () => { + const output = '\n \nACCESS_TOKEN=tok_live_abc\n\n'; + expect(parseEnvVarsFromOutput(output)).toEqual({ ACCESS_TOKEN: 'tok_live_abc' }); + }); + + test('ignores non-assignment lines mixed into script output', () => { + const output = [ + 'Authenticating...', + 'ACCESS_TOKEN=tok_live_abc', + 'Done.' + ].join('\n'); + expect(parseEnvVarsFromOutput(output)).toEqual({ ACCESS_TOKEN: 'tok_live_abc' }); + }); + + test('rejects keys starting with a digit', () => { + expect(parseEnvVarsFromOutput('1INVALID=value')).toEqual({}); + }); + + test('treats EXPORT as a valid key name, not a keyword', () => { + expect(parseEnvVarsFromOutput('EXPORT=some_value')).toEqual({ EXPORT: 'some_value' }); + }); + + test('handles Windows CRLF line endings', () => { + const output = 'ACCESS_TOKEN=tok_live_abc\r\nREFRESH_TOKEN=ref_live_xyz\r\n'; + expect(parseEnvVarsFromOutput(output)).toEqual({ + ACCESS_TOKEN: 'tok_live_abc', + REFRESH_TOKEN: 'ref_live_xyz' + }); + }); + + test('handles all forms together — comments, export prefix, noise lines, multiple vars', () => { + const output = [ + '# setup complete', + 'export VAR_A=value_one', + 'VAR_B=value_two', + 'export VAR_C=123', + 'process finished' + ].join('\n'); + + expect(parseEnvVarsFromOutput(output)).toEqual({ + VAR_A: 'value_one', + VAR_B: 'value_two', + VAR_C: '123' + }); + }); + + test('last assignment wins when a key appears more than once', () => { + const output = 'TOKEN=first\nTOKEN=second'; + expect(parseEnvVarsFromOutput(output)).toEqual({ TOKEN: 'second' }); + }); +}); + +describe('buildSpawnArgs', () => { + const cwd = '/some/collection'; + const script = '/some/collection/run.sh'; + + test('on darwin, spawns the script directly without a shell', () => { + const { command, args, options } = buildSpawnArgs(script, cwd, 'darwin'); + expect(command).toBe(script); + expect(args).toEqual([]); + expect(options.cwd).toBe(cwd); + expect(options.shell).toBeFalsy(); + }); + + test('on linux, spawns the script directly without a shell', () => { + const { options } = buildSpawnArgs(script, cwd, 'linux'); + expect(options.shell).toBeFalsy(); + }); + + test('on win32, runs through a shell so file associations and .cmd/.bat work', () => { + const { command, options } = buildSpawnArgs(script, cwd, 'win32'); + expect(command).toBe(script); + expect(options.shell).toBe(true); + expect(options.windowsHide).toBe(true); + }); + + test('passes cwd and env through to spawn options', () => { + const { options } = buildSpawnArgs(script, cwd, 'linux'); + expect(options.cwd).toBe(cwd); + expect(options.env).toBe(process.env); + }); +}); + +describe('runShellScript streaming', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-script-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + // Cross-platform: shebang dispatches to whatever `node` is on PATH (POSIX). + // On Windows, runShellScript uses shell:true so the .js extension routes to + // the Node file association. We additionally write a `.cmd` shim there. + const writeNodeScript = (filename, jsBody) => { + const fullPath = path.join(tmpDir, filename); + fs.writeFileSync(fullPath, `#!/usr/bin/env node\n${jsBody}\n`, { mode: 0o755 }); + return filename; + }; + + test('invokes onStdout with chunks that together equal the captured stdout', async () => { + const file = writeNodeScript( + 'hello.js', + `console.log('first'); setTimeout(() => console.log('second'), 50);` + ); + const chunks = []; + const result = await runShellScript(tmpDir, file, { + onStdout: (chunk) => chunks.push(chunk) + }); + + expect(chunks.length).toBeGreaterThan(0); + expect(chunks.join('')).toBe(result.stdout); + expect(result.stdout).toContain('first'); + expect(result.stdout).toContain('second'); + expect(result.exitCode).toBe(0); + }); + + test('invokes onStderr for stderr output', async () => { + const file = writeNodeScript( + 'err.js', + `console.error('oops'); process.exit(3);` + ); + const stderrChunks = []; + const result = await runShellScript(tmpDir, file, { + onStderr: (chunk) => stderrChunks.push(chunk) + }); + + expect(stderrChunks.join('')).toContain('oops'); + expect(result.exitCode).toBe(3); + }); + + test('works without callbacks (preserves existing call sites)', async () => { + const file = writeNodeScript('plain.js', `console.log('ok');`); + const result = await runShellScript(tmpDir, file); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('ok'); + }); + + test('truncates stdout when it exceeds maxBuffer and marks truncated=true', async () => { + // Emit ~5KB to stdout with maxBuffer=512 — must cap and mark truncated. + const file = writeNodeScript( + 'flood.js', + `for (let i = 0; i < 50; i++) console.log('x'.repeat(100));` + ); + const result = await runShellScript(tmpDir, file, { maxBuffer: 512 }); + + expect(result.truncated).toBe(true); + expect(result.stdout.length).toBeLessThanOrEqual(512 + 200); // 200 = headroom for truncation marker + expect(result.stdout).toMatch(/truncated/i); + }); + + test('does not mark truncated when output stays within maxBuffer', async () => { + const file = writeNodeScript('tiny.js', `console.log('hi');`); + const result = await runShellScript(tmpDir, file, { maxBuffer: 1024 }); + expect(result.truncated).toBeFalsy(); + expect(result.stdout).toContain('hi'); + }); +}); diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index bbb5491631f..907db79c8ab 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -35,6 +35,18 @@ const environmentSchema = Yup.object({ const environmentsSchema = Yup.array().of(environmentSchema); +const collectionScriptSchema = Yup.object({ + uid: uidSchema, + name: Yup.string().min(1).required('name is required'), + file: Yup.string().min(1).required('file is required'), + runOn: Yup.array() + .of(Yup.string().oneOf(['appStart', 'collectionOpen', 'beforeCollectionRun'])) + .default([]), + outputMode: Yup.string().oneOf(['envVars', 'stdout']).default('envVars') +}) + .noUnknown(true) + .strict(); + const keyValueSchema = Yup.object({ uid: uidSchema, name: Yup.string().nullable(), @@ -699,5 +711,6 @@ module.exports = { environmentSchema, environmentsSchema, collectionSchema, + collectionScriptSchema, annotationSchema };