Merge branch 'main' into fix/remove-jsonbigint-from-cli

This commit is contained in:
lohit 2024-12-12 17:31:59 +05:30 committed by GitHub
commit 4dcaaab52c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
79 changed files with 3716 additions and 4542 deletions

View File

@ -2,11 +2,6 @@ name: Bru CLI Tests (npm)
on:
workflow_dispatch:
inputs:
build:
description: 'Test Bru CLI (npm)'
required: true
default: 'true'
# Assign permissions for unit tests to be reported.
# See https://github.com/dorny/test-reporter/issues/168
@ -20,7 +15,10 @@ permissions:
jobs:
test:
name: CLI Tests
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3

View File

@ -5,6 +5,9 @@ on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
unit-test:
name: Unit Tests

2
.nvmrc
View File

@ -1 +1 @@
v20.9.0
v22.11.0

6580
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -32,3 +32,5 @@ yarn-error.log*
# next.js
.next/
out/
.env

View File

@ -0,0 +1,16 @@
module.exports = {
rootDir: '.',
moduleNameMapper: {
'^assets/(.*)$': '<rootDir>/src/assets/$1',
'^components/(.*)$': '<rootDir>/src/components/$1',
'^hooks/(.*)$': '<rootDir>/src/hooks/$1',
'^themes/(.*)$': '<rootDir>/src/themes/$1',
'^api/(.*)$': '<rootDir>/src/api/$1',
'^pageComponents/(.*)$': '<rootDir>/src/pageComponents/$1',
'^providers/(.*)$': '<rootDir>/src/providers/$1',
'^utils/(.*)$': '<rootDir>/src/utils/$1'
},
clearMocks: true,
moduleDirectories: ['node_modules', 'src'],
testEnvironment: 'node'
};

View File

@ -66,6 +66,7 @@
"react-i18next": "^15.0.1",
"react-inspector": "^6.0.2",
"react-pdf": "9.1.1",
"react-player": "^2.16.0",
"react-redux": "^7.2.6",
"react-tooltip": "^5.5.2",
"sass": "^1.46.0",

View File

@ -26,6 +26,12 @@ const StyledWrapper = styled.div`
.CodeMirror-dialog {
overflow: visible;
input {
background: transparent;
border: 1px solid #d3d6db;
outline: none;
border-radius: 0px;
}
}
#search-results-count {
@ -82,6 +88,14 @@ const StyledWrapper = styled.div`
.CodeMirror-search-hint {
display: inline;
}
.cm-s-default span.cm-property {
color: #1f61a0 !important;
}
.cm-s-default span.cm-variable {
color: #397d13 !important;
}
`;
export default StyledWrapper;

View File

@ -15,7 +15,7 @@ import { JSHINT } from 'jshint';
import stripJsonComments from 'strip-json-comments';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const TAB_SIZE = 2;
if (!SERVER_RENDERED) {
@ -58,13 +58,14 @@ if (!SERVER_RENDERED) {
'req.getExecutionMode()',
'bru',
'bru.cwd()',
'bru.getEnvName(key)',
'bru.getEnvName()',
'bru.getProcessEnv(key)',
'bru.hasEnvVar(key)',
'bru.getEnvVar(key)',
'bru.getFolderVar(key)',
'bru.getCollectionVar(key)',
'bru.setEnvVar(key,value)',
'bru.deleteEnvVar(key)',
'bru.hasVar(key)',
'bru.getVar(key)',
'bru.setVar(key,value)',
@ -189,32 +190,8 @@ export default class CodeEditor extends React.Component {
'Cmd-Y': 'foldAll',
'Ctrl-I': 'unfoldAll',
'Cmd-I': 'unfoldAll',
'Cmd-/': (cm) => {
// comment/uncomment every selected line(s)
const selections = cm.listSelections();
selections.forEach((range) => {
for (let i = range.from().line; i <= range.to().line; i++) {
const selectedLine = cm.getLine(i);
// if commented line, remove comment
if (selectedLine.trim().startsWith('//')) {
cm.replaceRange(
selectedLine.replace(/^(\s*)\/\/\s?/, '$1'),
{ line: i, ch: 0 },
{ line: i, ch: selectedLine.length }
);
continue;
}
// otherwise add comment
cm.replaceRange(
selectedLine.search(/\S|$/) >= TAB_SIZE
? ' '.repeat(TAB_SIZE) + '// ' + selectedLine.trim()
: '// ' + selectedLine,
{ line: i, ch: 0 },
{ line: i, ch: selectedLine.length }
);
}
});
}
'Ctrl-/': 'toggleComment',
'Cmd-/': 'toggleComment'
},
foldOptions: {
widget: (from, to) => {
@ -281,9 +258,9 @@ export default class CodeEditor extends React.Component {
while (end < currentLine.length && /[^{}();\s\[\]\,]/.test(currentLine.charAt(end))) ++end;
while (start && /[^{}();\s\[\]\,]/.test(currentLine.charAt(start - 1))) --start;
let curWord = start != end && currentLine.slice(start, end);
//Qualify if autocomplete will be shown
// Qualify if autocomplete will be shown
if (
/^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|\s)\w*/.test(event.key) &&
/^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/.test(event.key) &&
curWord.length > 0 &&
!/\/\/|\/\*|.*{{|`[^$]*{|`[^{]*$/.test(currentLine.slice(0, end)) &&
/(?<!\d)[a-zA-Z\._]$/.test(curWord)

View File

@ -10,6 +10,12 @@ import Modal from 'components/Modal';
const CreateEnvironment = ({ collection, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
// todo: Add this to global env too.
const validateEnvironmentName = (name) => {
return !collection?.environments?.some((env) => env?.name?.toLowerCase().trim() === name?.toLowerCase().trim());
};
const formik = useFormik({
enableReinitialize: true,
initialValues: {
@ -17,9 +23,10 @@ const CreateEnvironment = ({ collection, onClose }) => {
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
.min(1, 'Must be at least 1 character')
.max(50, 'Must be 50 characters or less')
.required('Name is required')
.test('duplicate-name', 'Environment already exists', validateEnvironmentName)
}),
onSubmit: (values) => {
dispatch(addEnvironment(values.name, collection.uid))

View File

@ -5,7 +5,7 @@ import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');

View File

@ -54,6 +54,14 @@ const StyledWrapper = styled.div`
.CodeMirror-search-hint {
display: inline;
}
.cm-s-default span.cm-property {
color: #1f61a0 !important;
}
.cm-s-default span.cm-variable {
color: #397d13 !important;
}
`;
export default StyledWrapper;

View File

@ -19,7 +19,7 @@ import { IconWand } from '@tabler/icons';
import onHasCompletion from './onHasCompletion';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');

View File

@ -10,6 +10,7 @@ const StyledWrapper = styled.div`
align-items: center;
justify-content: center;
width: 10px;
min-width: 10px;
padding: 0;
cursor: col-resize;
background: transparent;

View File

@ -1,14 +1,41 @@
import React, { useState, useEffect } from 'react';
import CodeEditor from 'components/CodeEditor/index';
import { get } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { Document, Page } from 'react-pdf';
import { useState } from 'react';
import 'pdfjs-dist/build/pdf.worker';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
import { GlobalWorkerOptions } from 'pdfjs-dist/build/pdf';
GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.min.mjs';
import ReactPlayer from 'react-player';
const VideoPreview = React.memo(({ contentType, dataBuffer }) => {
const [videoUrl, setVideoUrl] = useState(null);
useEffect(() => {
const videoType = contentType.split(';')[0];
const byteArray = Buffer.from(dataBuffer, 'base64');
const blob = new Blob([byteArray], { type: videoType });
const url = URL.createObjectURL(blob);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [contentType, dataBuffer]);
if (!videoUrl) return <div>Loading video...</div>;
return (
<ReactPlayer
url={videoUrl}
controls
muted={true}
width="100%"
height="100%"
onError={(e) => console.error('Error loading video:', e)}
/>
);
});
const QueryResultPreview = ({
previewTab,
@ -73,9 +100,7 @@ const QueryResultPreview = ({
);
}
case 'preview-video': {
return (
<video controls src={`data:${contentType.replace(/\;(.*)/, '')};base64,${dataBuffer}`} className="mx-auto" />
);
return <VideoPreview contentType={contentType} dataBuffer={dataBuffer} />;
}
default:
case 'raw': {

View File

@ -17,7 +17,6 @@ const RenameCollection = ({ collection, onClose }) => {
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),
onSubmit: (values) => {

View File

@ -8,7 +8,7 @@ import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme/index';
let posthogClient = null;
const posthogApiKey = 'phc_7gtqSrrdZRohiozPMLIacjzgHbUlhalW1Bu16uYijMR';
const posthogApiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
const getPosthogClient = () => {
if (posthogClient) {
return posthogClient;

View File

@ -39,6 +39,14 @@ const StyledWrapper = styled.div`
textarea.curl-command {
min-height: 150px;
}
.dropdown {
width: fit-content;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
`;
export default StyledWrapper;

View File

@ -1,4 +1,4 @@
import React, { useRef, useEffect, useCallback } from 'react';
import React, { useRef, useEffect, useCallback, forwardRef, useState } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
@ -12,6 +12,8 @@ import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelect
import { getDefaultRequestPaneTab } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { getRequestFromCurlCommand } from 'utils/curl';
import Dropdown from 'components/Dropdown';
import { IconCaretDown } from '@tabler/icons';
const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
const dispatch = useDispatch();
@ -19,6 +21,39 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
const {
brunoConfig: { presets: collectionPresets = {} }
} = collection;
const [curlRequestTypeDetected, setCurlRequestTypeDetected] = useState(null);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end auth-type-label select-none">
{curlRequestTypeDetected === 'http-request' ? "HTTP" : "GraphQL"}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
// This function analyzes a given cURL command string and determines whether the request is a GraphQL or HTTP request.
const identifyCurlRequestType = (url, headers, body) => {
if (url.endsWith('/graphql')) {
setCurlRequestTypeDetected('graphql-request');
return;
}
const contentType = headers?.find((h) => h.name.toLowerCase() === 'content-type')?.value;
if (contentType && contentType.includes('application/graphql')) {
setCurlRequestTypeDetected('graphql-request');
return;
}
setCurlRequestTypeDetected('http-request');
};
const curlRequestTypeChange = (type) => {
setCurlRequestTypeDetected(type);
};
const getRequestType = (collectionPresets) => {
if (!collectionPresets || !collectionPresets.requestType) {
@ -99,11 +134,11 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
} else if (values.requestType === 'from-curl') {
const request = getRequestFromCurlCommand(values.curlCommand);
const request = getRequestFromCurlCommand(values.curlCommand, curlRequestTypeDetected);
dispatch(
newHttpRequest({
requestName: values.requestName,
requestType: 'http-request',
requestType: curlRequestTypeDetected,
requestUrl: request.url,
requestMethod: request.method,
collectionUid: collection.uid,
@ -158,6 +193,12 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
formik.setFieldValue('requestType', 'from-curl');
formik.setFieldValue('curlCommand', pastedData);
// Identify the request type
const request = getRequestFromCurlCommand(pastedData);
if (request) {
identifyCurlRequestType(request.url, request.headers, request.body);
}
// Prevent the default paste behavior to avoid pasting into the textarea
event.preventDefault();
}
@ -165,6 +206,18 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
[formik]
);
const handleCurlCommandChange = (event) => {
formik.handleChange(event);
if (event.target.name === 'curlCommand') {
const curlCommand = event.target.value;
const request = getRequestFromCurlCommand(curlCommand);
if (request) {
identifyCurlRequestType(request.url, request.headers, request.body);
}
}
};
return (
<StyledWrapper>
<Modal size="md" title="New Request" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
@ -279,15 +332,37 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
</>
) : (
<div className="mt-4">
<label htmlFor="request-url" className="block font-semibold">
cURL Command
</label>
<div className="flex justify-between">
<label htmlFor="request-url" className="block font-semibold">
cURL Command
</label>
<Dropdown className="dropdown" onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
curlRequestTypeChange('http-request');
}}
>
HTTP
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
curlRequestTypeChange('graphql-request');
}}
>
GraphQL
</div>
</Dropdown>
</div>
<textarea
name="curlCommand"
placeholder="Enter cURL request here.."
className="block textbox w-full mt-4 curl-command"
value={formik.values.curlCommand}
onChange={formik.handleChange}
onChange={handleCurlCommandChange}
></textarea>
{formik.touched.curlCommand && formik.errors.curlCommand ? (
<div className="text-red-500">{formik.errors.curlCommand}</div>

View File

@ -184,7 +184,7 @@ const Sidebar = () => {
Star
</GitHubButton> */}
</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.34.2</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.36.0</div>
</div>
</div>
</div>

View File

@ -6,7 +6,7 @@ import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');

View File

@ -233,7 +233,7 @@ const GlobalStyle = createGlobalStyle`
}
.CodeMirror-hint-active {
background: #89f !important;
background: #08f !important;
color: #fff !important;
}
`;

View File

@ -10,7 +10,7 @@ import 'codemirror/theme/material.css';
import 'codemirror/theme/monokai.css';
import 'codemirror/addon/scroll/simplescrollbars.css';
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
require('codemirror/mode/javascript/javascript');
require('codemirror/mode/xml/xml');

View File

@ -8,7 +8,6 @@ import ReduxStore from 'providers/ReduxStore';
import ThemeProvider from 'providers/Theme/index';
import ErrorBoundary from './ErrorBoundary';
import '../styles/app.scss';
import '../styles/globals.css';
import 'codemirror/lib/codemirror.css';
import 'graphiql/graphiql.min.css';
@ -31,7 +30,7 @@ function SafeHydrate({ children }) {
}
function NoSsr({ children }) {
const SERVER_RENDERED = typeof navigator === 'undefined';
const SERVER_RENDERED = typeof window === 'undefined';
if (SERVER_RENDERED) {
return null;

View File

@ -13,7 +13,7 @@ import platformLib from 'platform';
import { uuid } from 'utils/common';
const { publicRuntimeConfig } = getConfig();
const posthogApiKey = 'phc_7gtqSrrdZRohiozPMLIacjzgHbUlhalW1Bu16uYijMR';
const posthogApiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
let posthogClient = null;
const isPlaywrightTestRunning = () => {
@ -60,7 +60,7 @@ const trackStart = () => {
event: 'start',
properties: {
os: platformLib.os.family,
version: '1.34.2'
version: '1.36.0'
}
});
};

View File

@ -92,9 +92,7 @@ export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, get
const uid = uuid();
ipcRenderer
.invoke('renderer:create-global-environment', { name, uid, variables })
.then(
dispatch(_addGlobalEnvironment({ name, uid, variables }))
)
.then(() => dispatch(_addGlobalEnvironment({ name, uid, variables })))
.then(resolve)
.catch(reject);
});
@ -108,9 +106,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
const uid = uuid();
ipcRenderer
.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables })
.then(() => {
dispatch(_copyGlobalEnvironment({ name, uid, variables: baseEnv.variables }))
})
.then(() => dispatch(_copyGlobalEnvironment({ name, uid, variables: baseEnv.variables })))
.then(resolve)
.catch(reject);
});
@ -127,9 +123,7 @@ export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (d
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:rename-global-environment', { name: newName, environmentUid }))
.then(
dispatch(_renameGlobalEnvironment({ name: newName, environmentUid }))
)
.then(() => dispatch(_renameGlobalEnvironment({ name: newName, environmentUid })))
.then(resolve)
.catch(reject);
});
@ -151,9 +145,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
environmentUid,
variables
}))
.then(
dispatch(_saveGlobalEnvironment({ environmentUid, variables }))
)
.then(() => dispatch(_saveGlobalEnvironment({ environmentUid, variables })))
.then(resolve)
.catch((error) => {
reject(error);
@ -165,9 +157,7 @@ export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:select-global-environment', { environmentUid })
.then(
dispatch(_selectGlobalEnvironment({ environmentUid }))
)
.then(() => dispatch(_selectGlobalEnvironment({ environmentUid })))
.then(resolve)
.catch(reject);
});
@ -177,9 +167,7 @@ export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:delete-global-environment', { environmentUid })
.then(
dispatch(_deleteGlobalEnvironment({ environmentUid }))
)
.then(() => dispatch(_deleteGlobalEnvironment({ environmentUid })))
.then(resolve)
.catch(reject);
});
@ -195,7 +183,6 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) =>
const environment = globalEnvironments?.find(env => env?.uid == environmentUid);
if (!environment || !environmentUid) {
console.error('Global Environment not found');
return resolve();
}
@ -228,9 +215,7 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) =>
environmentUid,
variables
}))
.then(
dispatch(_saveGlobalEnvironment({ environmentUid, variables }))
)
.then(() => dispatch(_saveGlobalEnvironment({ environmentUid, variables })))
.then(resolve)
.catch((error) => {
reject(error);

View File

@ -9,6 +9,7 @@ const getReadNotificationIds = () => {
return readNotificationIds;
} catch (err) {
toast.error('An error occurred while fetching read notifications');
return [];
}
};
@ -58,14 +59,16 @@ export const notificationSlice = createSlice({
});
},
markNotificationAsRead: (state, action) => {
if (state.readNotificationIds.includes(action.payload.notificationId)) return;
const { notificationId } = action.payload;
if (state.readNotificationIds.includes(notificationId)) return;
const notification = state.notifications.find(
(notification) => notification.id === action.payload.notificationId
(notification) => notification.id === notificationId
);
if (!notification) return;
state.readNotificationIds.push(action.payload.notificationId);
state.readNotificationIds.push(notificationId);
setReadNotificationsIds(state.readNotificationIds);
notification.read = true;
},

View File

@ -23,18 +23,19 @@
--color-method-options: rgb(52 52 52);
--color-method-head: rgb(52 52 52);
}
:root,.graphiql-container,.CodeMirror-info,.CodeMirror-lint-tooltip,reach-portal {
/* Required CSS variables after upgrading GraphiQL from v1.5.9 to v2.4.7 */
/* Colors */
--color-primary: 320, 95%, 43% !important;
--color-secondary: 242, 51%, 61% !important;
--color-tertiary: 188, 100%, 36% !important;
--color-info: 208, 100%, 46% !important;
--color-success: 158, 60%, 42% !important;
--color-warning: 36, 100%, 41% !important;
--color-error: 13, 93%, 58% !important;
--color-neutral: 219, 28%, 32% !important;
--color-base: 219, 28%, 100% !important;
--color-primary: 0, 0%, 0% !important;
--color-secondary: 0, 0%, 0% !important;
--color-tertiary: 0, 0%, 0% !important;
--color-info: 0, 0%, 0% !important;
--color-success: 0, 0%, 0% !important;
--color-warning: 0, 0%, 0% !important;
--color-error: 0, 0%, 0% !important;
--color-neutral: 0, 0%, 0% !important;
--color-base: 0, 0%, 100% !important;
/* Color alpha values */
--alpha-secondary: 0.76 !important;
@ -43,6 +44,59 @@
--alpha-background-medium: 0.1 !important;
--alpha-background-light: 0.07 !important;
--font-family: Consolas,Inconsolata,Droid Sans Mono,Monaco,monospace;
--font-family-mono: 'Fira Code', monospace;
--font-size-hint: .75rem;
--font-size-inline-code: .8125rem;
--font-size-body: .8rem;
--font-size-h4: 1.125rem;
--font-size-h3: 1.375rem;
--font-size-h2: 1.8125rem;
--font-weight-regular: 400;
--font-weight-medium: 500;
--line-height: 1.5;
--px-2: 0px;
--px-4: 0px;
--px-6: 2px;
--px-8: 8px;
--px-10: 10px;
--px-12: 12px;
--px-16: 16px;
--px-20: 20px;
--px-24: 24px;
--border-radius-2: 0px !important;
--border-radius-4: 0px !important;
--border-radius-8: 0px !important;
--border-radius-12: 0px !important;
--popover-box-shadow: 0px 0px 1px #000 !important;
--popover-border: none;
--sidebar-width: 60px;
--toolbar-width: 40px;
--session-header-height: 51px
}
/* Required CSS variables after upgrading GraphiQL from v1.5.9 to v2.4.7 */
.graphiql-container, .CodeMirror-info, .CodeMirror-lint-tooltip, reach-portal {
/* General Colors */
--color-primary: 0, 0%, 0% !important;
--color-secondary: 0, 0%, 0% !important;
--color-tertiary: 0, 0%, 0% !important;
--color-info: 0, 0%, 0% !important;
--color-success: 0, 0%, 0% !important;
--color-warning: 0, 0%, 0% !important;
--color-error: 0, 0%, 0% !important;
--color-base: 0, 0%, 100% !important;
--color-neutral: 0, 0%, 60% !important;
/* Color alpha values */
--alpha-secondary: 0.76 !important;
--alpha-tertiary: 0.5 !important;
--alpha-background-heavy: 0.15 !important;
--alpha-background-medium: 0.1 !important;
--alpha-background-light: 0.07 !important;
--font-family: Consolas,Inconsolata,Droid Sans Mono,Monaco,monospace;
--font-family-mono: 'Fira Code', monospace;
--font-size-hint: .75rem;
--font-size-inline-code: .8125rem;
--font-size-body: .9375rem;
@ -52,15 +106,15 @@
--font-weight-regular: 400;
--font-weight-medium: 500;
--line-height: 1.5;
--px-2: 2px;
--px-4: 4px;
--px-6: 6px;
--px-8: 8px;
--px-10: 10px;
--px-12: 12px;
--px-16: 16px;
--px-20: 20px;
--px-24: 24px;
--px-2: 2px !important;
--px-4: 4px !important;
--px-6: 6px !important;
--px-8: 8px !important;
--px-10: 10px !important;
--px-12: 12px !important;
--px-16: 16px !important;
--px-20: 20px !important;
--px-24: 24px !important;
--border-radius-2: 2px !important;
--border-radius-4: 2px !important;
--border-radius-8: 2px !important;
@ -72,6 +126,15 @@
--session-header-height: 51px
}
.CodeMirror-dialog {
--px-4: 0px !important;
--px-12: 2px !important;
}
.graphiql-container {
background: transparent !important;
}
html,
body {
margin: 0;

View File

@ -19,16 +19,23 @@ const createContentType = (mode) => {
}
};
/**
* Creates a list of enabled headers for the request, ensuring no duplicate content-type headers.
*
* @param {Object} request - The request object.
* @param {Object[]} headers - The array of header objects, each containing name, value, and enabled properties.
* @returns {Object[]} - An array of enabled headers with normalized names and values.
*/
const createHeaders = (request, headers) => {
const enabledHeaders = headers
.filter((header) => header.enabled)
.map((header) => ({
name: header.name,
name: header.name.toLowerCase(),
value: header.value
}));
const contentType = createContentType(request.body?.mode);
if (contentType !== '') {
if (contentType !== '' && !enabledHeaders.some((header) => header.name === 'content-type')) {
enabledHeaders.push({ name: 'content-type', value: contentType });
}

View File

@ -1,5 +1,5 @@
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');

View File

@ -12,7 +12,7 @@ import brunoCommon from '@usebruno/common';
const { interpolate } = brunoCommon;
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const { get } = require('lodash');
if (!SERVER_RENDERED) {
@ -44,8 +44,11 @@ if (!SERVER_RENDERED) {
const into = document.createElement('div');
const descriptionDiv = document.createElement('div');
descriptionDiv.className = 'info-description';
descriptionDiv.appendChild(document.createTextNode(variableValue));
if (options?.variables?.maskedEnvVariables?.includes(variableName)) {
descriptionDiv.appendChild(document.createTextNode('*****'));
} else {
descriptionDiv.appendChild(document.createTextNode(variableValue));
}
into.appendChild(descriptionDiv);
return into;

View File

@ -6,7 +6,7 @@
*/
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');

View File

@ -303,7 +303,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
script: si.request.script,
vars: si.request.vars,
assertions: si.request.assertions,
tests: si.request.tests
tests: si.request.tests,
docs: si.request.docs
};
// Handle auth object dynamically
@ -815,6 +816,23 @@ export const getEnvironmentVariables = (collection) => {
return variables;
};
export const getEnvironmentVariablesMasked = (collection) => {
// Return an empty array if the collection is invalid or not provided
if (!collection || !collection.activeEnvironmentUid) {
return [];
}
// Find the active environment in the collection
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
if (!environment || !environment.variables) {
return [];
}
// Filter the environment variables to get only the masked (secret) ones
return environment.variables
.filter((variable) => variable.name && variable.value && variable.enabled && variable.secret)
.map((variable) => variable.name);
};
const getPathParams = (item) => {
let pathParams = {};
@ -850,6 +868,13 @@ export const getAllVariables = (collection, item) => {
const { globalEnvironmentVariables = {} } = collection;
const { processEnvVariables = {}, runtimeVariables = {} } = collection;
const mergedVariables = {
...folderVariables,
...requestVariables,
...runtimeVariables
};
const maskedEnvVariables = getEnvironmentVariablesMasked(collection);
const filteredMaskedEnvVariables = maskedEnvVariables.filter((key) => !(key in mergedVariables));
return {
...globalEnvironmentVariables,
@ -861,6 +886,7 @@ export const getAllVariables = (collection, item) => {
pathParams: {
...pathParams
},
maskedEnvVariables: filteredMaskedEnvVariables,
process: {
env: {
...processEnvVariables

View File

@ -1,7 +1,7 @@
import get from 'lodash/get';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
@ -52,17 +52,33 @@ export class MaskedEditor {
/** Replaces all rendered characters, with the provided character. */
maskContent = () => {
const content = this.editor.getValue();
const lineCount = this.editor.lineCount();
if (lineCount === 0) return;
this.editor.operation(() => {
// Clear previous masked text
this.editor.getAllMarks().forEach((mark) => mark.clear());
// Apply new masked text
for (let i = 0; i < content.length; i++) {
if (content[i] !== '\n') {
const maskedNode = document.createTextNode(this.maskChar);
if (content.length <= 500) {
for (let i = 0; i < content.length; i++) {
if (content[i] !== '\n') {
const maskedNode = document.createTextNode(this.maskChar);
this.editor.markText(
{ line: this.editor.posFromIndex(i).line, ch: this.editor.posFromIndex(i).ch },
{ line: this.editor.posFromIndex(i + 1).line, ch: this.editor.posFromIndex(i + 1).ch },
{ replacedWith: maskedNode, handleMouseEvents: true }
);
}
}
} else {
for (let line = 0; line < lineCount; line++) {
const lineLength = this.editor.getLine(line).length;
const maskedNode = document.createTextNode('*'.repeat(lineLength));
this.editor.markText(
{ line: this.editor.posFromIndex(i).line, ch: this.editor.posFromIndex(i).ch },
{ line: this.editor.posFromIndex(i + 1).line, ch: this.editor.posFromIndex(i + 1).ch },
{ replacedWith: maskedNode, handleMouseEvents: true }
{ line, ch: 0 },
{ line, ch: lineLength },
{ replacedWith: maskedNode, handleMouseEvents: false }
);
}
}

View File

@ -151,7 +151,15 @@ export const relativeDate = (dateString) => {
export const humanizeDate = (dateString) => {
// See this discussion for why .split is necessary
// https://stackoverflow.com/questions/7556591/is-the-javascript-date-object-always-one-day-off
const date = new Date(dateString.split('-'));
if (!dateString || typeof dateString !== 'string') {
return 'Invalid Date';
}
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return 'Invalid Date';
}
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',

View File

@ -58,6 +58,18 @@ describe('common utils', () => {
it('should return invalid date if the date is invalid', () => {
expect(humanizeDate('9999-99-99')).toBe('Invalid Date');
});
it('should return "Invalid Date" if the date is null', () => {
expect(humanizeDate(null)).toBe('Invalid Date');
});
it('should return a humanized date for a valid date in ISO format', () => {
expect(humanizeDate('2024-11-28T00:00:00Z')).toBe('November 28, 2024');
});
it('should return "Invalid Date" for a non-date string', () => {
expect(humanizeDate('some random text')).toBe('Invalid Date');
});
});
describe('relativeDate', () => {

View File

@ -36,6 +36,12 @@ function getQueries(request) {
return queries;
}
/**
* Converts request data to a string based on its content type.
*
* @param {Object} request - The request object containing data and headers.
* @returns {Object} An object containing the data string.
*/
function getDataString(request) {
if (typeof request.data === 'number') {
request.data = request.data.toString();
@ -44,7 +50,15 @@ function getDataString(request) {
const contentType = getContentType(request.headers);
if (contentType && contentType.includes('application/json')) {
return { data: request.data.toString() };
try {
const parsedData = JSON.parse(request.data);
return { data: JSON.stringify(parsedData) };
} catch (error) {
console.error('Failed to parse JSON data:', error);
return { data: request.data.toString() };
}
} else if (contentType && contentType.includes('application/xml')) {
return { data: request.data };
}
const parsedQueryString = querystring.parse(request.data, { sort: false });

View File

@ -2,7 +2,7 @@ import { forOwn } from 'lodash';
import { convertToCodeMirrorJson } from 'utils/common';
import curlToJson from './curl-to-json';
export const getRequestFromCurlCommand = (curlCommand) => {
export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-request') => {
const parseFormData = (parsedBody) => {
const formData = [];
forOwn(parsedBody, (value, key) => {
@ -12,6 +12,22 @@ export const getRequestFromCurlCommand = (curlCommand) => {
return formData;
};
const parseGraphQL = (text) => {
try {
const graphql = JSON.parse(text);
return {
query: graphql.query,
variables: JSON.stringify(graphql.variables, null, 2)
};
} catch (e) {
return {
query: '',
variables: ''
};
}
};
try {
if (!curlCommand || typeof curlCommand !== 'string' || curlCommand.length === 0) {
return null;
@ -24,6 +40,8 @@ export const getRequestFromCurlCommand = (curlCommand) => {
Object.keys(parsedHeaders).map((key) => ({ name: key, value: parsedHeaders[key], enabled: true }));
const contentType = headers?.find((h) => h.name.toLowerCase() === 'content-type')?.value;
const parsedBody = request.data;
const body = {
mode: 'none',
json: null,
@ -31,14 +49,18 @@ export const getRequestFromCurlCommand = (curlCommand) => {
xml: null,
sparql: null,
multipartForm: null,
formUrlEncoded: null
formUrlEncoded: null,
graphql: null
};
const parsedBody = request.data;
if (parsedBody && contentType && typeof contentType === 'string') {
if (contentType.includes('application/json')) {
if (requestType === 'graphql-request' && (contentType.includes('application/json') || contentType.includes('application/graphql'))) {
body.mode = 'graphql';
body.graphql = parseGraphQL(parsedBody);
} else if (contentType.includes('application/json')) {
body.mode = 'json';
body.json = convertToCodeMirrorJson(parsedBody);
} else if (contentType.includes('text/xml')) {
} else if (contentType.includes('xml')) {
body.mode = 'xml';
body.xml = parsedBody;
} else if (contentType.includes('application/x-www-form-urlencoded')) {

View File

@ -271,7 +271,7 @@ const resolveRefs = (spec, components = spec?.components, visitedItems = new Set
// Recursively resolve references in nested objects
for (const prop in spec) {
spec[prop] = resolveRefs(spec[prop], components, visitedItems);
spec[prop] = resolveRefs(spec[prop], components, new Set(visitedItems));
}
return spec;
@ -316,7 +316,7 @@ const getDefaultUrl = (serverObject) => {
url = url.replace(`{${variableName}}`, sub);
});
}
return url.endsWith('/') ? url : `${url}/`;
return url.endsWith('/') ? url.slice(0, -1) : url;
};
const getSecurity = (apiSpec) => {
@ -353,7 +353,7 @@ const openAPIRuntimeExpressionToScript = (expression) => {
return expression;
};
const parseOpenApiCollection = (data) => {
export const parseOpenApiCollection = (data) => {
const brunoCollection = {
name: '',
uid: uuid(),

View File

@ -0,0 +1,67 @@
import { parseOpenApiCollection } from './openapi-collection';
import { uuid } from 'utils/common';
jest.mock('utils/common');
describe('openapi importer util functions', () => {
afterEach(jest.clearAllMocks);
it('should convert openapi object to bruno collection correctly', async () => {
const input = {
openapi: '3.0.3',
info: {
title: 'Sample API with Multiple Servers',
description: 'API spec with multiple servers.',
version: '1.0.0'
},
servers: [
{ url: 'https://api.example.com/v1', description: 'Production Server' },
{ url: 'https://staging-api.example.com/v1', description: 'Staging Server' },
{ url: 'http://localhost:3000/v1', description: 'Local Server' }
],
paths: {
'/users': {
get: {
summary: 'Get a list of users',
parameters: [
{ name: 'page', in: 'query', required: false, schema: { type: 'integer' } },
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer' } }
],
responses: {
'200': { description: 'A list of users' }
}
}
}
}
};
const expectedOutput = {
name: 'Sample API with Multiple Servers',
version: '1',
items: [
{
name: 'Get a list of users',
type: 'http-request',
request: {
url: '{{baseUrl}}/users',
method: 'GET',
params: [
{ name: 'page', value: '', enabled: false, type: 'query' },
{ name: 'limit', value: '', enabled: false, type: 'query' }
]
}
}
],
environments: [
{ name: 'Production Server', variables: [{ name: 'baseUrl', value: 'https://api.example.com/v1' }] },
{ name: 'Staging Server', variables: [{ name: 'baseUrl', value: 'https://staging-api.example.com/v1' }] },
{ name: 'Local Server', variables: [{ name: 'baseUrl', value: 'http://localhost:3000/v1' }] }
]
};
const result = await parseOpenApiCollection(input);
expect(result).toMatchObject(expectedOutput);
expect(uuid).toHaveBeenCalledTimes(10);
});
});

View File

@ -11,6 +11,9 @@ describe('postmanTranslation function', () => {
pm.collectionVariables.set('key', 'value');
const data = pm.response.json();
pm.expect(pm.environment.has('key')).to.be.true;
postman.setEnvironmentVariable('key', 'value');
postman.getEnvironmentVariable('key');
postman.clearEnvironmentVariable('key');
`;
const expectedOutput = `
bru.getEnvVar('key');
@ -21,6 +24,9 @@ describe('postmanTranslation function', () => {
bru.setVar('key', 'value');
const data = res.getBody();
expect(bru.getEnvVar('key') !== undefined && bru.getEnvVar('key') !== null).to.be.true;
bru.setEnvVar('key', 'value');
bru.getEnvVar('key');
bru.deleteEnvVar('key');
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
@ -151,3 +157,13 @@ test('should handle response commands', () => {
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should handle tests object', () => {
const inputScript = `
tests['Status code is 200'] = responseCode.code === 200;
`;
const expectedOutput = `
test("Status code is 200", function() { expect(Boolean(responseCode.code === 200)).to.be.true; });
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});

View File

@ -17,7 +17,13 @@ const replacements = {
'pm\\.response\\.code': 'res.getStatus()',
'pm\\.response\\.text\\(': 'res.getBody()?.toString(',
'pm\\.expect\\.fail\\(': 'expect.fail(',
'pm\\.response\\.responseTime': 'res.getResponseTime()'
'pm\\.response\\.responseTime': 'res.getResponseTime()',
'pm\\.environment\\.name': 'bru.getEnvName()',
"tests\\['([^']+)'\\]\\s*=\\s*([^;]+);": 'test("$1", function() { expect(Boolean($2)).to.be.true; });',
// deprecated translations
'postman\\.setEnvironmentVariable\\(': 'bru.setEnvVar(',
'postman\\.getEnvironmentVariable\\(': 'bru.getEnvVar(',
'postman\\.clearEnvironmentVariable\\(': 'bru.deleteEnvVar(',
};
const extendedReplacements = Object.keys(replacements).reduce((acc, key) => {

View File

@ -40,6 +40,7 @@
"lodash": "^4.17.21",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^4.1.3",
"@usebruno/vm2": "^3.9.13",
"xmlbuilder": "^15.1.1",
"yargs": "^17.6.2"

View File

@ -93,8 +93,68 @@ const printRunSummary = (results) => {
};
};
const createCollectionFromPath = (collectionPath) => {
const environmentsPath = path.join(collectionPath, `environments`);
const getFilesInOrder = (collectionPath) => {
let collection = {
pathname: collectionPath
};
const traverse = (currentPath) => {
const filesInCurrentDir = fs.readdirSync(currentPath);
if (currentPath.includes('node_modules')) {
return;
}
const currentDirItems = [];
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (
stats.isDirectory() &&
filePath !== environmentsPath &&
!filePath.startsWith('.git') &&
!filePath.startsWith('node_modules')
) {
let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) }
const folderBruFilePath = path.join(filePath, 'folder.bru');
const folderBruFileExists = fs.existsSync(folderBruFilePath);
if(folderBruFileExists) {
const folderBruContent = fs.readFileSync(folderBruFilePath, 'utf8');
let folderBruJson = collectionBruToJson(folderBruContent);
folderItem.root = folderBruJson;
}
currentDirItems.push(folderItem);
}
}
for (const file of filesInCurrentDir) {
if (['collection.bru', 'folder.bru'].includes(file)) {
continue;
}
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (!stats.isDirectory() && path.extname(filePath) === '.bru') {
const bruContent = fs.readFileSync(filePath, 'utf8');
const bruJson = bruToJson(bruContent);
currentDirItems.push({
name: file,
pathname: filePath,
...bruJson
});
}
}
return currentDirItems
};
collection.items = traverse(collectionPath);
return collection;
};
return getFilesInOrder(collectionPath);
};
const getBruFilesRecursively = (dir, testsOnly) => {
const environmentsPath = 'environments';
const collection = {};
const getFilesInOrder = (dir) => {
let bruJsons = [];
@ -211,6 +271,11 @@ const builder = async (yargs) => {
description:
'The specified custom CA certificate (--cacert) will be used exclusively and the default truststore is ignored, if this option is specified. Evaluated in combination with "--cacert" only.'
})
.option('disable-cookies', {
type: 'boolean',
default: false,
description: 'Automatically save and sent cookies with requests'
})
.option('env', {
describe: 'Environment variables',
type: 'string'
@ -259,10 +324,30 @@ const builder = async (yargs) => {
type: 'boolean',
description: 'Stop execution after a failure of a request, test, or assertion'
})
.option('reporter-skip-all-headers', {
type: 'boolean',
description: 'Omit headers from the reporter output',
default: false
})
.option('reporter-skip-headers', {
type: 'array',
description: 'Skip specific headers from the reporter output',
default: []
})
.option('client-cert-config', {
type: 'string',
description: 'Path to the Client certificate config file used for securing the connection in the request'
})
.example('$0 run request.bru', 'Run a request')
.example('$0 run request.bru --env local', 'Run a request with the environment set to local')
.example('$0 run folder', 'Run all requests in a folder')
.example('$0 run folder -r', 'Run all requests in a folder recursively')
.example('$0 run --reporter-skip-all-headers', 'Run all requests in a folder recursively with omitted headers from the reporter output')
.example(
'$0 run --reporter-skip-headers "Authorization"',
'Run all requests in a folder recursively with skipped headers from the reporter output'
)
.example(
'$0 run request.bru --env local --env-var secret=xxx',
'Run a request with the environment set to local and overwrite the variable secret with value xxx'
@ -292,7 +377,8 @@ const builder = async (yargs) => {
.example(
'$0 run folder --cacert myCustomCA.pem --ignore-truststore',
'Use a custom CA certificate exclusively when validating the peers of the requests in the specified folder.'
);
)
.example('$0 run --client-cert-config client-cert-config.json', 'Run a request with Client certificate configurations');
};
const handler = async function (argv) {
@ -301,6 +387,7 @@ const handler = async function (argv) {
filename,
cacert,
ignoreTruststore,
disableCookies,
env,
envVar,
insecure,
@ -312,7 +399,10 @@ const handler = async function (argv) {
reporterHtml,
sandbox,
testsOnly,
bail
bail,
reporterSkipAllHeaders,
reporterSkipHeaders,
clientCertConfig
} = argv;
const collectionPath = process.cwd();
@ -329,6 +419,47 @@ const handler = async function (argv) {
const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
const brunoConfig = JSON.parse(brunoConfigFile);
const collectionRoot = getCollectionRoot(collectionPath);
let collection = createCollectionFromPath(collectionPath);
collection = {
brunoConfig,
root: collectionRoot,
...collection
}
if (clientCertConfig) {
try {
const clientCertConfigExists = await exists(clientCertConfig);
if (!clientCertConfigExists) {
console.error(chalk.red(`Client Certificate Config file "${clientCertConfig}" does not exist.`));
process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND);
}
const clientCertConfigFileContent = fs.readFileSync(clientCertConfig, 'utf8');
let clientCertConfigJson;
try {
clientCertConfigJson = JSON.parse(clientCertConfigFileContent);
} catch (err) {
console.error(chalk.red(`Failed to parse Client Certificate Config JSON: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_INVALID_JSON);
}
if (clientCertConfigJson?.enabled && Array.isArray(clientCertConfigJson?.certs)) {
if (brunoConfig.clientCertificates) {
brunoConfig.clientCertificates.certs.push(...clientCertConfigJson.certs);
} else {
brunoConfig.clientCertificates = { certs: clientCertConfigJson.certs };
}
console.log(chalk.green(`Client certificates has been added`));
} else {
console.warn(chalk.yellow(`Client certificate configuration is enabled, but it either contains no valid "certs" array or the added configuration has been set to false`));
}
} catch (err) {
console.error(chalk.red(`Unexpected error: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_UNKNOWN);
}
}
if (filename && filename.length) {
const pathExists = await exists(filename);
@ -392,6 +523,9 @@ const handler = async function (argv) {
if (insecure) {
options['insecure'] = true;
}
if (disableCookies) {
options['disableCookies'] = true;
}
if (cacert && cacert.length) {
if (insecure) {
console.error(chalk.red(`Ignoring the cacert option since insecure connections are enabled`));
@ -516,7 +650,8 @@ const handler = async function (argv) {
processEnvVars,
brunoConfig,
collectionRoot,
runtime
runtime,
collection
);
results.push({
@ -525,6 +660,35 @@ const handler = async function (argv) {
suitename: bruFilepath.replace('.bru', '')
});
if (reporterSkipAllHeaders) {
results.forEach((result) => {
result.request.headers = {};
result.response.headers = {};
});
}
const deleteHeaderIfExists = (headers, header) => {
if (headers && headers[header]) {
delete headers[header];
}
};
if (reporterSkipHeaders?.length) {
results.forEach((result) => {
if (result.request?.headers) {
reporterSkipHeaders.forEach((header) => {
deleteHeaderIfExists(result.request.headers, header);
});
}
if (result.response?.headers) {
reporterSkipHeaders.forEach((header) => {
deleteHeaderIfExists(result.response.headers, header);
});
}
});
}
// bail if option is set and there is a failure
if (bail) {
const requestFailure = result?.error;

View File

@ -13,14 +13,17 @@ const getContentType = (headers = {}) => {
return contentType;
};
const interpolateVars = (request, envVars = {}, runtimeVariables = {}, processEnvVars = {}) => {
const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => {
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
// we clone envVars because we don't want to modify the original object
envVars = cloneDeep(envVars);
envVariables = cloneDeep(envVariables);
// envVars can inturn have values as {{process.env.VAR_NAME}}
// so we need to interpolate envVars first with processEnvVars
forOwn(envVars, (value, key) => {
envVars[key] = interpolate(value, {
forOwn(envVariables, (value, key) => {
envVariables[key] = interpolate(value, {
process: {
env: {
...processEnvVars
@ -36,7 +39,10 @@ const interpolateVars = (request, envVars = {}, runtimeVariables = {}, processEn
// runtimeVariables take precedence over envVars
const combinedVars = {
...envVars,
...collectionVariables,
...envVariables,
...folderVariables,
...requestVariables,
...runtimeVariables,
process: {
env: {

View File

@ -1,23 +1,223 @@
const { get, each, filter } = require('lodash');
const { get, each, filter, find, compact } = require('lodash');
const fs = require('fs');
const os = require('os');
const decomment = require('decomment');
const crypto = require('node:crypto');
const prepareRequest = (request, collectionRoot) => {
const headers = {};
let contentTypeDefined = false;
const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
// collection headers
each(get(collectionRoot, 'request.headers', []), (h) => {
if (h.enabled) {
headers[h.name] = h.value;
if (h.name.toLowerCase() === 'content-type') {
contentTypeDefined = true;
}
let collectionHeaders = get(collection, 'root.request.headers', []);
collectionHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
}
});
each(request.headers, (h) => {
for (let i of requestTreePath) {
if (i.type === 'folder') {
let _headers = get(i, 'root.request.headers', []);
_headers.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
}
});
} else {
const _headers = i?.draft ? get(i, 'draft.request.headers', []) : get(i, 'request.headers', []);
_headers.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
}
});
}
}
request.headers = Array.from(headers, ([name, value]) => ({ name, value, enabled: true }));
};
const mergeVars = (collection, request, requestTreePath) => {
let reqVars = new Map();
let collectionRequestVars = get(collection, 'root.request.vars.req', []);
let collectionVariables = {};
collectionRequestVars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
collectionVariables[_var.name] = _var.value;
}
});
let folderVariables = {};
let requestVariables = {};
for (let i of requestTreePath) {
if (i.type === 'folder') {
let vars = get(i, 'root.request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
folderVariables[_var.name] = _var.value;
}
});
} else {
const vars = i?.draft ? get(i, 'draft.request.vars.req', []) : get(i, 'request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
requestVariables[_var.name] = _var.value;
}
});
}
}
request.collectionVariables = collectionVariables;
request.folderVariables = folderVariables;
request.requestVariables = requestVariables;
if(request?.vars) {
request.vars.req = Array.from(reqVars, ([name, value]) => ({
name,
value,
enabled: true,
type: 'request'
}));
}
let resVars = new Map();
let collectionResponseVars = get(collection, 'root.request.vars.res', []);
collectionResponseVars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
}
});
for (let i of requestTreePath) {
if (i.type === 'folder') {
let vars = get(i, 'root.request.vars.res', []);
vars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
}
});
} else {
const vars = i?.draft ? get(i, 'draft.request.vars.res', []) : get(i, 'request.vars.res', []);
vars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
}
});
}
}
if(request?.vars) {
request.vars.res = Array.from(resVars, ([name, value]) => ({
name,
value,
enabled: true,
type: 'response'
}));
}
};
const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
let collectionPreReqScript = get(collection, 'root.request.script.req', '');
let collectionPostResScript = get(collection, 'root.request.script.res', '');
let collectionTests = get(collection, 'root.request.tests', '');
let combinedPreReqScript = [];
let combinedPostResScript = [];
let combinedTests = [];
for (let i of requestTreePath) {
if (i.type === 'folder') {
let preReqScript = get(i, 'root.request.script.req', '');
if (preReqScript && preReqScript.trim() !== '') {
combinedPreReqScript.push(preReqScript);
}
let postResScript = get(i, 'root.request.script.res', '');
if (postResScript && postResScript.trim() !== '') {
combinedPostResScript.push(postResScript);
}
let tests = get(i, 'root.request.tests', '');
if (tests && tests?.trim?.() !== '') {
combinedTests.push(tests);
}
}
}
request.script.req = compact([collectionPreReqScript, ...combinedPreReqScript, request?.script?.req || '']).join(os.EOL);
if (scriptFlow === 'sequential') {
request.script.res = compact([collectionPostResScript, ...combinedPostResScript, request?.script?.res || '']).join(os.EOL);
} else {
request.script.res = compact([request?.script?.res || '', ...combinedPostResScript.reverse(), collectionPostResScript]).join(os.EOL);
}
if (scriptFlow === 'sequential') {
request.tests = compact([collectionTests, ...combinedTests, request?.tests || '']).join(os.EOL);
} else {
request.tests = compact([request?.tests || '', ...combinedTests.reverse(), collectionTests]).join(os.EOL);
}
};
const findItem = (items = [], pathname) => {
return find(items, (i) => i.pathname === pathname);
};
const findItemInCollection = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return findItem(flattenedItems, pathname);
};
const findParentItemInCollection = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return find(flattenedItems, (item) => {
return item.items && find(item.items, (i) => i.pathname === pathname);
});
};
const flattenItems = (items = []) => {
const flattenedItems = [];
const flatten = (itms, flattened) => {
each(itms, (i) => {
flattened.push(i);
if (i.items && i.items.length) {
flatten(i.items, flattened);
}
});
};
flatten(items, flattenedItems);
return flattenedItems;
};
const getTreePathFromCollectionToItem = (collection, _item) => {
let path = [];
let item = findItemInCollection(collection, _item.pathname);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item.pathname);
}
return path;
};
const prepareRequest = (item = {}, collection = {}) => {
const request = item?.request;
const brunoConfig = get(collection, 'brunoConfig', {});
const headers = {};
let contentTypeDefined = false;
const scriptFlow = brunoConfig?.scripts?.flow ?? 'sandwich';
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
if (requestTreePath && requestTreePath.length > 0) {
mergeHeaders(collection, request, requestTreePath);
mergeScripts(collection, request, requestTreePath, scriptFlow);
mergeVars(collection, request, requestTreePath);
}
each(get(request, 'headers', []), (h) => {
if (h.enabled) {
headers[h.name] = h.value;
if (h.name.toLowerCase() === 'content-type') {
@ -34,7 +234,7 @@ const prepareRequest = (request, collectionRoot) => {
responseType: 'arraybuffer'
};
const collectionAuth = get(collectionRoot, 'request.auth');
const collectionAuth = get(collection, 'root.request.auth');
if (collectionAuth && request.auth.mode === 'inherit') {
if (collectionAuth.mode === 'basic') {
axiosRequest.auth = {
@ -151,10 +351,19 @@ const prepareRequest = (request, collectionRoot) => {
axiosRequest.data = graphqlQuery;
}
if (request.script && request.script.length) {
if (request.script) {
axiosRequest.script = request.script;
}
if (request.tests) {
axiosRequest.tests = request.tests;
}
axiosRequest.vars = request.vars;
axiosRequest.collectionVariables = request.collectionVariables;
axiosRequest.folderVariables = request.folderVariables;
axiosRequest.requestVariables = request.requestVariables;
return axiosRequest;
};

View File

@ -20,6 +20,7 @@ const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-he
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
const path = require('path');
const { createFormData, parseDataFromResponse } = require('../utils/common');
const { getCookieStringForUrl, saveCookies, shouldUseCookies } = require('../utils/cookies');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const onConsoleLog = (type, args) => {
@ -35,13 +36,17 @@ const runSingleRequest = async function (
processEnvVars,
brunoConfig,
collectionRoot,
runtime
runtime,
collection
) {
try {
let request;
let nextRequestName;
request = prepareRequest(bruJson.request, collectionRoot);
let item = {
pathname: path.join(collectionPath, filename),
...bruJson
}
request = prepareRequest(item, collection);
request.__bruno__executionMode = 'cli';
@ -49,10 +54,7 @@ const runSingleRequest = async function (
scriptingConfig.runtime = runtime;
// run pre request script
const requestScriptFile = compact([
get(collectionRoot, 'request.script.req'),
get(bruJson, 'request.script.req')
]).join(os.EOL);
const requestScriptFile = get(request, 'script.req');
if (requestScriptFile?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
const result = await scriptRuntime.runRequestScript(
@ -178,6 +180,14 @@ const runSingleRequest = async function (
});
}
//set cookies if enabled
if (!options.disableCookies) {
const cookieString = getCookieStringForUrl(request.url);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
request.headers['cookie'] = cookieString;
}
}
// stringify the request url encoded params
if (request.headers['content-type'] === 'application/x-www-form-urlencoded') {
request.data = qs.stringify(request.data);
@ -223,6 +233,11 @@ const runSingleRequest = async function (
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
//save cookies if enabled
if (!options.disableCookies) {
saveCookies(request.url, response.headers);
}
} catch (err) {
if (err?.response) {
const { data } = parseDataFromResponse(err?.response);
@ -251,7 +266,7 @@ const runSingleRequest = async function (
data: null,
responseTime: 0
},
error: err.message,
error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!',
assertionResults: [],
testResults: [],
nextRequestName: nextRequestName
@ -282,10 +297,7 @@ const runSingleRequest = async function (
}
// run post response script
const responseScriptFile = compact([
get(collectionRoot, 'request.script.res'),
get(bruJson, 'request.script.res')
]).join(os.EOL);
const responseScriptFile = get(request, 'script.res');
if (responseScriptFile?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
const result = await scriptRuntime.runResponseScript(
@ -330,7 +342,7 @@ const runSingleRequest = async function (
// run tests
let testResults = [];
const testFile = compact([get(collectionRoot, 'request.tests'), get(bruJson, 'request.tests')]).join(os.EOL);
const testFile = get(request, 'tests');
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const result = await testRuntime.runTests(

View File

@ -58,7 +58,7 @@ const bruToJson = (bru) => {
body: _.get(json, 'body', {}),
vars: _.get(json, 'vars', []),
assertions: _.get(json, 'assertions', []),
script: _.get(json, 'script', ''),
script: _.get(json, 'script', {}),
tests: _.get(json, 'tests', '')
}
};

View File

@ -0,0 +1,100 @@
const { Cookie, CookieJar } = require('tough-cookie');
const each = require('lodash/each');
const cookieJar = new CookieJar();
const addCookieToJar = (setCookieHeader, requestUrl) => {
const cookie = Cookie.parse(setCookieHeader, { loose: true });
cookieJar.setCookieSync(cookie, requestUrl, {
ignoreError: true // silently ignore things like parse errors and invalid domains
});
};
const getCookiesForUrl = (url) => {
return cookieJar.getCookiesSync(url);
};
const getCookieStringForUrl = (url) => {
const cookies = getCookiesForUrl(url);
if (!Array.isArray(cookies) || !cookies.length) {
return '';
}
const validCookies = cookies.filter((cookie) => !cookie.expires || cookie.expires > Date.now());
return validCookies.map((cookie) => cookie.cookieString()).join('; ');
};
const getDomainsWithCookies = () => {
return new Promise((resolve, reject) => {
const domainCookieMap = {};
cookieJar.store.getAllCookies((err, cookies) => {
if (err) {
return reject(err);
}
cookies.forEach((cookie) => {
if (!domainCookieMap[cookie.domain]) {
domainCookieMap[cookie.domain] = [cookie];
} else {
domainCookieMap[cookie.domain].push(cookie);
}
});
const domains = Object.keys(domainCookieMap);
const domainsWithCookies = [];
each(domains, (domain) => {
const cookies = domainCookieMap[domain];
const validCookies = cookies.filter((cookie) => !cookie.expires || cookie.expires > Date.now());
if (validCookies.length) {
domainsWithCookies.push({
domain,
cookies: validCookies,
cookieString: validCookies.map((cookie) => cookie.cookieString()).join('; ')
});
}
});
resolve(domainsWithCookies);
});
});
};
const deleteCookiesForDomain = (domain) => {
return new Promise((resolve, reject) => {
cookieJar.store.removeCookies(domain, null, (err) => {
if (err) {
return reject(err);
}
return resolve();
});
});
};
const saveCookies = (url, headers) => {
let setCookieHeaders = [];
if (headers['set-cookie']) {
setCookieHeaders = Array.isArray(headers['set-cookie'])
? headers['set-cookie']
: [headers['set-cookie']];
for (let setCookieHeader of setCookieHeaders) {
if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
addCookieToJar(setCookieHeader, url);
}
}
}
}
module.exports = {
addCookieToJar,
getCookiesForUrl,
getCookieStringForUrl,
getDomainsWithCookies,
deleteCookiesForDomain,
saveCookies
};

View File

@ -9,7 +9,7 @@ describe('prepare-request: prepareRequest', () => {
const expected = `{
\"test\": \"{{someVar}}\"
}`;
const result = prepareRequest({ body });
const result = prepareRequest({ request: { body } });
expect(result.data).toEqual(expected);
});
@ -18,7 +18,7 @@ describe('prepare-request: prepareRequest', () => {
const expected = `{
\"test\": {{someVar}}
}`;
const result = prepareRequest({ body });
const result = prepareRequest({ request: { body } });
expect(result.data).toEqual(expected);
});
});

View File

@ -1,5 +1,5 @@
{
"version": "v1.34.2",
"version": "v1.36.0",
"name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com",

View File

@ -2,7 +2,7 @@ const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
const { hasBruExtension } = require('../utils/filesystem');
const { hasBruExtension, isWSLPath, normalizeAndResolvePath, normalizeWslPath } = require('../utils/filesystem');
const { bruToEnvJson, bruToJson, collectionBruToJson } = require('../bru');
const { dotenvToJson } = require('@usebruno/lang');
@ -445,11 +445,11 @@ class Watcher {
ignoreInitial: false,
usePolling: watchPath.startsWith('\\\\') || forcePolling ? true : false,
ignored: (filepath) => {
const normalizedPath = filepath.replace(/\\/g, '/');
const normalizedPath = isWSLPath(filepath) ? normalizeWslPath(filepath) : normalizeAndResolvePath(filepath);
const relativePath = path.relative(watchPath, normalizedPath);
return ignores.some((ignorePattern) => {
const normalizedIgnorePattern = ignorePattern.replace(/\\/g, '/');
const normalizedIgnorePattern = isWSLPath(ignorePattern) ? normalizeWslPath(ignorePattern) : ignorePattern.replace(/\\/g, '/');
return relativePath === normalizedIgnorePattern || relativePath.startsWith(normalizedIgnorePattern);
});
},

View File

@ -1,5 +1,7 @@
const _ = require('lodash');
const fs = require('fs');
const fsExtra = require('fs-extra');
const os = require('os');
const path = require('path');
const { ipcMain, shell, dialog, app } = require('electron');
const { envJsonToBru, bruToJson, jsonToBru, jsonToCollectionBru } = require('../bru');
@ -17,7 +19,9 @@ const {
isWSLPath,
normalizeWslPath,
normalizeAndResolvePath,
safeToRename
safeToRename,
isWindowsOS,
isValidFilename
} = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@ -201,7 +205,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (fs.existsSync(pathname)) {
throw new Error(`path: ${pathname} already exists`);
}
if (!isValidFilename(request.name)) {
throw new Error(`path: ${request.name}.bru is not a valid filename`);
}
const content = jsonToBru(request);
await writeFile(pathname, content);
} catch (error) {
@ -358,23 +364,35 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const newBruFilePath = bruFile.replace(oldPath, newPath);
moveRequestUid(bruFile, newBruFilePath);
}
return fs.renameSync(oldPath, newPath);
if (isWindowsOS() && !isWSLPath(oldPath)) {
const tempDir = path.join(os.tmpdir(), `temp-folder-${Date.now()}`);
await fsExtra.copy(oldPath, tempDir);
await fsExtra.move(tempDir, newPath, { overwrite: true });
await fsExtra.remove(oldPath);
} else {
await fs.renameSync(oldPath, newPath);
}
return newPath;
}
const isBru = hasBruExtension(oldPath);
if (!isBru) {
if (!hasBruExtension(oldPath)) {
throw new Error(`path: ${oldPath} is not a bru file`);
}
// update name in file and save new copy, then delete old copy
const data = fs.readFileSync(oldPath, 'utf8');
const jsonData = bruToJson(data);
if (!isValidFilename(newName)) {
throw new Error(`path: ${newName} is not a valid filename`);
}
// update name in file and save new copy, then delete old copy
const data = await fs.promises.readFile(oldPath, 'utf8'); // Use async read
const jsonData = bruToJson(data);
jsonData.name = newName;
moveRequestUid(oldPath, newPath);
const content = jsonToBru(jsonData);
await fs.unlinkSync(oldPath);
await fs.promises.unlink(oldPath);
await writeFile(newPath, content);
return newPath;

View File

@ -63,6 +63,10 @@ class GlobalEnvironmentsStore {
addGlobalEnvironment({ uid, name, variables = [] }) {
let globalEnvironments = this.getGlobalEnvironments();
const existingEnvironment = globalEnvironments.find(env => env?.name == name);
if (existingEnvironment) {
throw new Error('Environment with the same name already exists');
}
globalEnvironments.push({
uid,
name,

View File

@ -6,10 +6,34 @@ const { safeStorage } = require('electron');
const ELECTRONSAFESTORAGE_ALGO = '00';
const AES256_ALGO = '01';
// AES-256 encryption and decryption functions
function deriveKeyAndIv(password, keyLength, ivLength) {
const key = Buffer.alloc(keyLength);
const iv = Buffer.alloc(ivLength);
const derivedBytes = [];
let lastHash = null;
while (Buffer.concat(derivedBytes).length < keyLength + ivLength) {
const hash = crypto.createHash('md5');
if (lastHash) {
hash.update(lastHash);
}
hash.update(Buffer.from(password, 'utf8'));
lastHash = hash.digest();
derivedBytes.push(lastHash);
}
const concatenatedBytes = Buffer.concat(derivedBytes);
concatenatedBytes.copy(key, 0, 0, keyLength);
concatenatedBytes.copy(iv, 0, keyLength, keyLength + ivLength);
return { key, iv };
}
function aes256Encrypt(data) {
const key = machineIdSync();
const cipher = crypto.createCipher('aes-256-cbc', key);
const rawKey = machineIdSync();
const iv = Buffer.alloc(16, 0); // Default IV for new encryption
const key = crypto.createHash('sha256').update(rawKey).digest(); // Derive a 32-byte key
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
@ -17,14 +41,28 @@ function aes256Encrypt(data) {
}
function aes256Decrypt(data) {
const key = machineIdSync();
const decipher = crypto.createDecipher('aes-256-cbc', key);
let decrypted = decipher.update(data, 'hex', 'utf8');
decrypted += decipher.final('utf8');
const rawKey = machineIdSync();
return decrypted;
// Attempt to decrypt using new method first
const iv = Buffer.alloc(16, 0); // Default IV for new encryption
const key = crypto.createHash('sha256').update(rawKey).digest(); // Derive a 32-byte key
try {
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(data, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (err) {
// If decryption fails, fall back to old key derivation
const { key: oldKey, iv: oldIv } = deriveKeyAndIv(rawKey, 32, 16);
const decipher = crypto.createDecipheriv('aes-256-cbc', oldKey, oldIv);
let decrypted = decipher.update(data, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}
// electron safe storage encryption and decryption functions
function safeStorageEncrypt(str) {
let encryptedStringBuffer = safeStorage.encryptString(str);

View File

@ -160,6 +160,24 @@ const sanitizeDirectoryName = (name) => {
return name.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '-');
};
const isWindowsOS = () => {
return os.platform() === 'win32';
}
const isValidFilename = (fileName) => {
const inValidChars = /[\\/:*?"<>|]/;
if (!fileName || inValidChars.test(fileName)) {
return false;
}
if (fileName.endsWith(' ') || fileName.endsWith('.') || fileName.startsWith('.')) {
return false;
}
return true;
};
const safeToRename = (oldPath, newPath) => {
try {
// If the new path doesn't exist, it's safe to rename
@ -170,7 +188,7 @@ const safeToRename = (oldPath, newPath) => {
const oldStat = fs.statSync(oldPath);
const newStat = fs.statSync(newPath);
if (os.platform() === 'win32') {
if (isWindowsOS()) {
// Windows-specific comparison:
// Check if both files have the same birth time, size (Since, Win FAT-32 doesn't use inodes)
@ -204,5 +222,7 @@ module.exports = {
searchForFiles,
searchForBruFiles,
sanitizeDirectoryName,
safeToRename
isWindowsOS,
safeToRename,
isValidFilename
};

View File

@ -22,6 +22,13 @@ describe('Encryption and Decryption Tests', () => {
expect(() => decryptString('garbage')).toThrow('Decrypt failed: unrecognized string format');
});
it.skip('string encrypted using createCipher (< node 20) should be decrypted properly', () => {
const encryptedString = '$01:2738e0e6a38bcde5fd80141ceadc9b67bc7b1fca7e398c552c1ca2bace28eb57';
const decryptedValue = decryptString(encryptedString);
expect(decryptedValue).toBe('bruno is awesome');
});
it('decrypt should throw an error for invalid algorithm', () => {
const invalidAlgo = '$99:abcdefg';

View File

@ -65,6 +65,10 @@ class Bru {
this.envVariables[key] = value;
}
deleteEnvVar(key) {
delete this.envVariables[key];
}
getGlobalEnvVar(key) {
return this._interpolate(this.globalEnvironmentVariables[key]);
}

View File

@ -39,6 +39,12 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'setEnvVar', setEnvVar);
setEnvVar.dispose();
let deleteEnvVar = vm.newFunction('deleteEnvVar', function (key) {
return marshallToVm(bru.deleteEnvVar(vm.dump(key)), vm);
});
vm.setProp(bruObject, 'deleteEnvVar', deleteEnvVar);
deleteEnvVar.dispose();
let getGlobalEnvVar = vm.newFunction('getGlobalEnvVar', function (key) {
return marshallToVm(bru.getGlobalEnvVar(vm.dump(key)), vm);
});

View File

@ -24,7 +24,7 @@ get(data, '..items[?]', { id: 2, amount: 20 })
```
Array mapping [?] with corresponding mapper function
```js
get(data, '..items[?].amount', i => i.amount + 10)
get(data, '..items..amount[?]', amt => amt + 10)
```
### Publish to Npm Registry

View File

@ -1,6 +1,7 @@
headers {
check: again
token: {{collection_pre_var_token}}
collection-header: collection-header-value
}
auth {
@ -14,6 +15,28 @@ auth:bearer {
vars:pre-request {
collection_pre_var: collection_pre_var_value
collection_pre_var_token: {{request_pre_var_token}}
collection-var: collection-var-value
}
script:pre-request {
// used by `scripting/js/folder-collection script-tests`
const shouldTestCollectionScripts = bru.getVar('should-test-collection-scripts');
if(shouldTestCollectionScripts) {
bru.setVar('collection-var-set-by-collection-script', 'collection-var-value-set-by-collection-script');
}
}
tests {
// used by `scripting/js/folder-collection script-tests`
const shouldTestCollectionScripts = bru.getVar('should-test-collection-scripts');
const collectionVar = bru.getVar("collection-var-set-by-collection-script");
if (shouldTestCollectionScripts && collectionVar) {
test("collection level test - should get the var that was set by the collection script", function() {
expect(collectionVar).to.equal("collection-var-value-set-by-collection-script");
});
bru.setVar('collection-var-set-by-collection-script', null);
bru.setVar('should-test-collection-scripts', null);
}
}
docs {

View File

@ -5,7 +5,7 @@ meta {
}
get {
url: https://www.usebruno.com/images/landing-2.png
url: https://www.usebruno.com/favicon.ico
body: none
auth: none
}
@ -13,7 +13,7 @@ get {
tests {
test("should return parsed xml", function() {
const headers = res.getHeaders();
expect(headers['content-type']).to.eql("image/png");
expect(headers['content-type']).to.eql("image/x-icon");
});
}

View File

@ -0,0 +1,7 @@
meta {
name: bru
}
vars:pre-request {
folder-var: folder-var-value
}

View File

@ -0,0 +1,18 @@
meta {
name: getCollectionVar
type: http
seq: 9
}
get {
url: {{host}}/ping
body: none
auth: none
}
tests {
test("should get collection var in scripts", function() {
const testVar = bru.getCollectionVar("collection-var");
expect(testVar).to.equal("collection-var-value");
});
}

View File

@ -0,0 +1,18 @@
meta {
name: getFolderVar
type: http
seq: 8
}
get {
url: {{host}}/ping
body: none
auth: none
}
tests {
test("should get folder var in scripts", function() {
const testVar = bru.getFolderVar("folder-var");
expect(testVar).to.equal("folder-var-value");
});
}

View File

@ -0,0 +1,22 @@
meta {
name: getRequestVar
type: http
seq: 7
}
get {
url: {{host}}/ping
body: none
auth: none
}
vars:pre-request {
request-var: request-var-value
}
tests {
test("should get request var in scripts", function() {
const testVar = bru.getRequestVar("request-var");
expect(testVar).to.equal("request-var-value");
});
}

View File

@ -0,0 +1,16 @@
meta {
name: folder-collection script-tests pre
type: http
seq: 4
}
post {
url: {{echo-host}}
body: none
auth: none
}
script:pre-request {
bru.setVar('should-test-collection-scripts', true);
bru.setVar('should-test-folder-scripts', true);
}

View File

@ -0,0 +1,28 @@
meta {
name: folder-collection script-tests
type: http
seq: 5
}
post {
url: {{echo-host}}
body: none
auth: none
}
script:pre-request {
// do not delete - the collection/folder scripts/tests run during this request execution
}
tests {
const collectionHeader = req.getHeader("collection-header");
const folderHeader = req.getHeader("folder-header");
test("should get the header value set at collection level", function() {
expect(collectionHeader).to.equal("collection-header-value");
});
test("should get the header value set at folder level", function() {
expect(folderHeader).to.equal("folder-header-value");
});
}

View File

@ -0,0 +1,28 @@
meta {
name: js
}
headers {
folder-header: folder-header-value
}
script:pre-request {
// used by `scripting/js/folder-collection script-tests`
const shouldTestFolderScripts = bru.getVar('should-test-folder-scripts');
if(shouldTestFolderScripts) {
bru.setVar('folder-var-set-by-folder-script', 'folder-var-value-set-by-folder-script');
}
}
tests {
// used by `scripting/js/folder-collection script-tests`
const shouldTestFolderScripts = bru.getVar('should-test-folder-scripts');
const folderVar = bru.getVar("folder-var-set-by-folder-script");
if (shouldTestFolderScripts && folderVar) {
test("folder level test - should get the var that was set by the folder script", function() {
expect(folderVar).to.equal("folder-var-value-set-by-folder-script");
});
bru.setVar('folder-var-set-by-folder-script', null);
bru.setVar('should-test-folder-scripts', null);
}
}

View File

@ -40,7 +40,7 @@ assert {
script:pre-request {
bru.setVar("rUser", {
full_name: 'Bruno',
age: 4,
age: 5,
'fav-food': ['egg', 'meat'],
'want.attention': true
});
@ -49,7 +49,7 @@ script:pre-request {
tests {
test("should return json", function() {
const expectedResponse = `Hi, I am Bruno,
I am 4 years old.
I am 5 years old.
My favorite food is egg and meat.
I like attention: true`;
expect(res.getBody()).to.equal(expectedResponse);

View File

@ -0,0 +1,12 @@
{
"version": "1",
"name": "sandwich_exec",
"type": "collection",
"ignore": [
"node_modules",
".git"
],
"scripts": {
"flow": "sandwich"
}
}

View File

@ -0,0 +1,13 @@
script:pre-request {
console.log("collection pre");
}
script:post-response {
{
console.log("collection post");
const sequence = bru.getVar('sequence') || [];
sequence.push(1);
bru.setVar('sequence', sequence);
console.log("sequence", bru.getVar('sequence'));
}
}

View File

@ -0,0 +1,16 @@
meta {
name: folder
}
script:pre-request {
console.log("folder pre");
}
script:post-response {
{
const sequence = bru.getVar('sequence') || [];
sequence.push(2);
bru.setVar('sequence', sequence);
}
console.log("folder post");
}

View File

@ -0,0 +1,33 @@
meta {
name: request
type: http
seq: 1
}
get {
url: https://www.example.com
body: none
auth: none
}
script:pre-request {
console.log("request pre");
}
script:post-response {
{
console.log("request post");
const sequence = bru.getVar('sequence') || [];
sequence.push(3);
bru.setVar('sequence', sequence);
}
}
tests {
test("sandwich script execution is proper", function() {
const sequence = bru.getVar('sequence');
bru.setVar('sequence', null);
expect(sequence.toString()).to.equal([3,2,1].toString());
});
}

View File

@ -0,0 +1,12 @@
{
"version": "1",
"name": "sequential_exec",
"type": "collection",
"ignore": [
"node_modules",
".git"
],
"scripts": {
"flow": "sequential"
}
}

View File

@ -0,0 +1,12 @@
script:pre-request {
console.log("collection pre");
}
script:post-response {
{
console.log("collection post");
const sequence = bru.getVar('sequence') || [];
sequence.push(1);
bru.setVar('sequence', sequence);
}
}

View File

@ -0,0 +1,16 @@
meta {
name: folder
}
script:pre-request {
console.log("folder pre");
}
script:post-response {
{
console.log("folder post");
const sequence = bru.getVar('sequence') || [];
sequence.push(2);
bru.setVar('sequence', sequence);
}
}

View File

@ -0,0 +1,34 @@
meta {
name: request
type: http
seq: 1
}
get {
url: https://www.example.com
body: none
auth: none
}
script:pre-request {
console.log("request pre");
}
script:post-response {
{
console.log("request post");
const sequence = bru.getVar('sequence') || [];
sequence.push(3);
bru.setVar('sequence', sequence);
console.log("sequence", bru.getVar('sequence'));
}
}
tests {
test("sequential script execution is proper", function() {
const sequence = bru.getVar('sequence');
bru.setVar('sequence', null);
expect(sequence.toString()).to.equal([1,2,3].toString());
});
}

View File

@ -44,12 +44,12 @@ Bruno is offline-only. There are no plans to add cloud-sync to Bruno, ever. We v
![bruno](assets/images/landing-2.png) <br /><br />
## Golden Edition
## Commercial Versions
Majority of our features are free and open source.
We strive to strike a harmonious balance between [open-source principles and sustainability](https://github.com/usebruno/bruno/discussions/269)
You can buy the [Golden Edition](https://www.usebruno.com/pricing) for a one-time payment of **$19**! <br/>
You can explore our [paid versions](https://www.usebruno.com/pricing) to see if there are additional features that you or your team may find useful! <br/>
## Table of Contents
- [Installation](#installation)