Merge branch 'usebruno:main' into feature/add-raw-file-request-body-option

This commit is contained in:
zachary-berdell-elliott 2024-11-28 08:44:04 -07:00 committed by GitHub
commit 77879913ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 790 additions and 140 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

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

38
package-lock.json generated
View File

@ -9515,7 +9515,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -13912,6 +13911,12 @@
"uc.micro": "^1.0.1"
}
},
"node_modules/load-script": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
"integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==",
"license": "MIT"
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
@ -14426,6 +14431,12 @@
"node": ">= 0.6"
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@ -17154,6 +17165,28 @@
"node": ">=6"
}
},
"node_modules/react-player": {
"version": "2.16.0",
"resolved": "https://registry.npmjs.org/react-player/-/react-player-2.16.0.tgz",
"integrity": "sha512-mAIPHfioD7yxO0GNYVFD1303QFtI3lyyQZLY229UEAp/a10cSW+hPcakg0Keq8uWJxT2OiT/4Gt+Lc9bD6bJmQ==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.0.0",
"load-script": "^1.0.0",
"memoize-one": "^5.1.1",
"prop-types": "^15.7.2",
"react-fast-compare": "^3.0.1"
},
"peerDependencies": {
"react": ">=16.6.0"
}
},
"node_modules/react-player/node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "7.2.9",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz",
@ -20575,6 +20608,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",
@ -20688,7 +20722,7 @@
},
"packages/bruno-electron": {
"name": "bruno",
"version": "v1.34.0",
"version": "v1.34.2",
"dependencies": {
"@aws-sdk/credential-providers": "3.658.1",
"@usebruno/common": "0.1.0",

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

@ -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

@ -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

@ -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

@ -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 = () => {

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

@ -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

@ -820,6 +820,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 = {};
@ -855,6 +872,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,
@ -866,6 +890,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

@ -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,13 @@ 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() };
}
}
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,11 +49,15 @@ 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')) {

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

@ -41,6 +41,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

@ -211,6 +211,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 +264,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 +317,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 +327,7 @@ const handler = async function (argv) {
filename,
cacert,
ignoreTruststore,
disableCookies,
env,
envVar,
insecure,
@ -312,7 +339,10 @@ const handler = async function (argv) {
reporterHtml,
sandbox,
testsOnly,
bail
bail,
reporterSkipAllHeaders,
reporterSkipHeaders,
clientCertConfig
} = argv;
const collectionPath = process.cwd();
@ -330,6 +360,41 @@ const handler = async function (argv) {
const brunoConfig = JSON.parse(brunoConfigFile);
const collectionRoot = getCollectionRoot(collectionPath);
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);
if (!pathExists) {
@ -392,6 +457,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`));
@ -525,6 +593,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

@ -20,6 +20,7 @@ const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-he
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
const path = require('path');
const { createFormData } = require('../utils/common');
const { getCookieStringForUrl, saveCookies, shouldUseCookies } = require('../utils/cookies');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const onConsoleLog = (type, args) => {
@ -178,6 +179,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);
@ -220,6 +229,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) {
response = err.response;

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

@ -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

@ -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

@ -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

@ -5,7 +5,7 @@ meta {
}
get {
url: https://gloutnikov.com/post/2024/bruno.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

@ -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

@ -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)