Merge branch 'main' into feat/websocket-engine

This commit is contained in:
Siddharth Gelera
2025-09-17 16:17:33 +05:30
31 changed files with 1010 additions and 157 deletions

80
package-lock.json generated
View File

@@ -8739,12 +8739,6 @@
"resolved": "packages/bruno-converters",
"link": true
},
"node_modules/@usebruno/crypto-js": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/@usebruno/crypto-js/-/crypto-js-3.1.9.tgz",
"integrity": "sha512-khvEnRF6/UVDw4df06j+6lFWGNDYWlcWnxfmEgU2o/CdsGY291NC1Cexz99ud7sbGBQP2d8JUXZe4zXPkGNJpQ==",
"license": "MIT"
},
"node_modules/@usebruno/filestore": {
"resolved": "packages/bruno-filestore",
"link": true
@@ -9942,16 +9936,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -14299,13 +14283,6 @@
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT",
"optional": true
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@@ -18566,28 +18543,6 @@
"lz-string": "bin/bin.js"
}
},
"node_modules/macos-export-certificate-and-key": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/macos-export-certificate-and-key/-/macos-export-certificate-and-key-1.2.4.tgz",
"integrity": "sha512-y5QZEywlBNKd+EhPZ1Hz1FmDbbeQKtuVHJaTlawdl7vXw9bi/4tJB2xSMwX4sMVcddy3gbQ8K0IqXAi2TpDo2g==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"dependencies": {
"bindings": "^1.5.0",
"node-addon-api": "^4.3.0"
}
},
"node_modules/macos-export-certificate-and-key/node_modules/node-addon-api": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
"license": "MIT",
"optional": true
},
"node_modules/magic-string": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz",
@@ -24873,16 +24828,6 @@
"jscat": "bundle.js"
}
},
"node_modules/system-ca": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/system-ca/-/system-ca-2.0.1.tgz",
"integrity": "sha512-9ZDV9yl8ph6Op67wDGPr4LykX86usE9x3le+XZSHfVMiiVJ5IRgmCWjLgxyz35ju9H3GDIJJZm4ogAeIfN5cQQ==",
"license": "Apache-2.0",
"optionalDependencies": {
"macos-export-certificate-and-key": "^1.2.0",
"win-export-certificate-and-key": "^2.1.0"
}
},
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
@@ -26359,28 +26304,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/win-export-certificate-and-key": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/win-export-certificate-and-key/-/win-export-certificate-and-key-2.1.0.tgz",
"integrity": "sha512-WeMLa/2uNZcS/HWGKU2G1Gzeh3vHpV/UFvwLhJLKxPHYFAbubxxVcJbqmPXaqySWK1Ymymh16zKK5WYIJ3zgzA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"dependencies": {
"bindings": "^1.5.0",
"node-addon-api": "^3.1.0"
}
},
"node_modules/win-export-certificate-and-key/node_modules/node-addon-api": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz",
"integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==",
"license": "MIT",
"optional": true
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -31829,7 +31752,6 @@
"license": "MIT",
"dependencies": {
"@usebruno/common": "0.1.0",
"@usebruno/crypto-js": "^3.1.9",
"@usebruno/query": "0.1.0",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
@@ -31839,7 +31761,7 @@
"chai": "^4.3.7",
"chai-string": "^1.5.0",
"cheerio": "^1.0.0",
"crypto-js": "^4.1.1",
"crypto-js": "^4.2.0",
"json-query": "^2.2.2",
"lodash": "^4.17.21",
"moment": "^2.29.4",

View File

@@ -5,7 +5,7 @@ import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCh
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import MultiLineEditor from 'components/MultiLineEditor';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
@@ -214,7 +214,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
<SingleLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}

View File

@@ -3,7 +3,7 @@ import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
@@ -147,7 +147,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
</td>
<td className="flex flex-row flex-nowrap">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
<SingleLineEditor
theme={storedTheme}
name={`${index}.value`}
value={variable.value}

View File

@@ -7,6 +7,7 @@ import Sidebar from 'components/Sidebar';
import StatusBar from 'components/StatusBar';
// import ErrorCapture from 'components/ErrorCapture';
import { useSelector } from 'react-redux';
import { isElectron } from 'utils/common/platform';
import StyledWrapper from './StyledWrapper';
import 'codemirror/theme/material.css';
import 'codemirror/theme/monokai.css';
@@ -62,34 +63,53 @@ export default function Main() {
'is-dragging': isDragging
});
useEffect(() => {
if (!isElectron()) {
return;
}
const { ipcRenderer } = window;
const removeAppLoadedListener = ipcRenderer.on('main:app-loaded', () => {
if (mainSectionRef.current) {
mainSectionRef.current.setAttribute('data-app-state', 'loaded');
}
});
return () => {
removeAppLoadedListener();
};
}, []);
return (
// <ErrorCapture>
<div className="flex flex-col h-screen max-h-screen overflow-hidden">
<div
ref={mainSectionRef}
className="flex-1 min-h-0 flex"
style={{
height: isConsoleOpen ? `calc(100vh - 22px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 22px)'
}}
>
<StyledWrapper className={className} style={{ height: '100%', zIndex: 1 }}>
<Sidebar />
<section className="flex flex-grow flex-col overflow-hidden">
{showHomePage ? (
<Welcome />
) : (
<>
<RequestTabs />
<RequestTabPanel key={activeTabUid} />
</>
)}
</section>
</StyledWrapper>
</div>
<div id="main-container" className="flex flex-col h-screen max-h-screen overflow-hidden">
<div
ref={mainSectionRef}
className="flex-1 min-h-0 flex"
data-app-state="loading"
style={{
height: isConsoleOpen ? `calc(100vh - 22px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 22px)'
}}
>
<StyledWrapper className={className} style={{ height: '100%', zIndex: 1 }}>
<Sidebar />
<section className="flex flex-grow flex-col overflow-hidden">
{showHomePage ? (
<Welcome />
) : (
<>
<RequestTabs />
<RequestTabPanel key={activeTabUid} />
</>
)}
</section>
</StyledWrapper>
</div>
<Devtools mainSectionRef={mainSectionRef} />
<StatusBar />
</div>
<Devtools mainSectionRef={mainSectionRef} />
<StatusBar />
</div>
// </ErrorCapture>
);
}

View File

@@ -158,7 +158,7 @@ const runSingleRequest = async function (
httpsAgentRequestFields['rejectUnauthorized'] = false;
} else {
const caCertFilePath = options['cacert'];
let caCertificatesData = await getCACertificates({ caCertFilePath, shouldKeepDefaultCerts: !options['ignoreTruststore'] });
let caCertificatesData = getCACertificates({ caCertFilePath, shouldKeepDefaultCerts: !options['ignoreTruststore'] });
let caCertificates = caCertificatesData.caCertificates;
httpsAgentRequestFields['ca'] = caCertificates || [];
}

View File

@@ -1 +1,2 @@
BRUNO_INFO_ENDPOINT = http://localhost:8081
BRUNO_INFO_ENDPOINT = http://localhost:8081
DISABLE_SAMPLE_COLLECTION_IMPORT = false

View File

@@ -0,0 +1,100 @@
const fs = require('node:fs');
const path = require('node:path');
const { app } = require('electron');
const { preferencesUtil } = require('../store/preferences');
const { importCollection, findUniqueFolderName } = require('../utils/collection-import');
/**
* Get the default location for collections
* Tries documents first, then desktop, then userData as fallback
*/
function getDefaultCollectionLocation() {
const preferredPaths = ['documents', 'desktop', 'userData'];
for (const pathType of preferredPaths) {
try {
return app.getPath(pathType);
} catch (error) {
console.warn(`Failed to get ${pathType} path:`, error.message);
// Continue to next path
}
}
// This should never happen since userData should always be available
throw new Error('No valid collection location found');
}
/**
* Import sample collection for new users
*/
async function importSampleCollection(collectionLocation, mainWindow, lastOpenedCollections) {
// Handle both development and production paths
const sampleCollectionPath = app.isPackaged
? path.join(process.resourcesPath, 'sample-collection.json')
: path.join(app.getAppPath(), 'src/assets/sample-collection.json');
if (!fs.existsSync(sampleCollectionPath)) {
throw new Error(`Sample collection file not found at: ${sampleCollectionPath}`);
}
const sampleCollectionData = fs.readFileSync(sampleCollectionPath, 'utf8');
const sampleCollection = JSON.parse(sampleCollectionData);
const collectionName = await findUniqueFolderName('Sample API Collection', collectionLocation);
const collectionToImport = {
...sampleCollection,
name: collectionName
};
try {
const {
collectionPath: createdPath,
uid,
brunoConfig
} = await importCollection(
collectionToImport,
collectionLocation,
mainWindow,
lastOpenedCollections,
collectionName
);
return { collectionPath: createdPath, uid, brunoConfig };
} catch (error) {
console.error('Failed to import sample collection:', error);
throw error;
}
}
/**
* Onboard new users by creating a sample collection
*/
async function onboardUser(mainWindow, lastOpenedCollections) {
try {
if (preferencesUtil.hasLaunchedBefore()) {
return;
}
if (process.env.DISABLE_SAMPLE_COLLECTION_IMPORT !== 'true') {
// Onboarding was added later;
// if a collection already exists, user is old → skip onboarding
const collections = await lastOpenedCollections.getAll();
if (collections.length > 0) {
preferencesUtil.markAsLaunched();
return;
}
const collectionLocation = getDefaultCollectionLocation();
await importSampleCollection(collectionLocation, mainWindow, lastOpenedCollections);
}
preferencesUtil.markAsLaunched();
} catch (error) {
console.error('Failed to handle onboarding:', error);
// Still mark as launched to prevent retry on next startup
preferencesUtil.markAsLaunched();
}
}
module.exports = onboardUser;

View File

@@ -0,0 +1,55 @@
{
"version": "1",
"uid": "1igyn4u00000000000232",
"name": "Sample API Collection",
"items": [
{
"uid": "1igyn4u00000000000001",
"type": "http-request",
"name": "Get Users",
"seq": 1,
"request": {
"url": "https://jsonplaceholder.typicode.com/users",
"method": "GET",
"headers": [],
"params": [],
"body": {
"mode": "none"
},
"auth": {
"mode": "none"
},
"script": {
"req": "",
"res": ""
},
"vars": {
"req": [],
"res": []
},
"assertions": [],
"tests": "",
"docs": "This request retrieves a list of users from the JSONPlaceholder API."
}
}
],
"environments": [],
"activeEnvironmentUid": null,
"root": {
"request": {
"headers": [],
"auth": {
"mode": "none"
},
"script": {
"req": "",
"res": ""
},
"vars": {
"req": [],
"res": []
},
"tests": ""
}
}
}

View File

@@ -35,6 +35,7 @@ const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
const { safeParseJSON, safeStringifyJSON } = require('./utils/common');
const { getDomainsWithCookies } = require('./utils/cookies');
const { cookiesStore } = require('./store/cookies');
const onboardUser = require('./app/onboarding');
const lastOpenedCollections = new LastOpenedCollections();
@@ -178,6 +179,10 @@ app.on('ready', async () => {
return safeParseJSON(safeStringifyJSON(_));
})]);
}
// Handle onboarding
await onboardUser(mainWindow, lastOpenedCollections);
// Send cookies list after renderer is ready
try {
cookiesStore.initializeCookies();
@@ -186,6 +191,8 @@ app.on('ready', async () => {
} catch (err) {
console.error('Failed to load cookies for renderer', err);
}
mainWindow.webContents.send('main:app-loaded');
});
// register all ipc handlers

View File

@@ -27,7 +27,7 @@ const getCertsAndProxyConfig = async ({
}
let caCertFilePath = preferencesUtil.shouldUseCustomCaCertificate() && preferencesUtil.getCustomCaCertificateFilePath();
let caCertificatesData = await getCACertificates({
let caCertificatesData = getCACertificates({
caCertFilePath,
shouldKeepDefaultCerts: preferencesUtil.shouldKeepDefaultCaCertificates()
});

View File

@@ -13,6 +13,11 @@ const getContentType = (headers = {}) => {
return contentType;
};
const getRawQueryString = (url) => {
const queryIndex = url.indexOf('?');
return queryIndex !== -1 ? url.slice(queryIndex) : '';
};
const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
@@ -126,7 +131,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
if (request?.pathParams?.length) {
let url = request.url;
const urlSearchRaw = getRawQueryString(request.url)
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `http://${url}`;
}
@@ -152,7 +157,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
.join('');
const trailingSlash = url.pathname.endsWith('/') ? '/' : '';
request.url = url.origin + urlPathnameInterpolatedWithPathParams + trailingSlash + url.search;
request.url = url.origin + urlPathnameInterpolatedWithPathParams + trailingSlash + urlSearchRaw;
}
if (request.proxy) {

View File

@@ -44,6 +44,9 @@ const defaultPreferences = {
beta: {
grpc: false,
nodevm: false
},
onboarding: {
hasLaunchedBefore: false
}
};
@@ -83,6 +86,9 @@ const preferencesSchema = Yup.object().shape({
beta: Yup.object({
grpc: Yup.boolean(),
nodevm: Yup.boolean()
}),
onboarding: Yup.object({
hasLaunchedBefore: Yup.boolean()
})
});
@@ -176,6 +182,14 @@ const preferencesUtil = {
},
isBetaFeatureEnabled: (featureName) => {
return get(getPreferences(), `beta.${featureName}`, false);
},
hasLaunchedBefore: () => {
return get(getPreferences(), 'onboarding.hasLaunchedBefore', false);
},
markAsLaunched: () => {
const preferences = getPreferences();
preferences.onboarding.hasLaunchedBefore = true;
preferencesStore.savePreferences(preferences);
}
};

View File

@@ -0,0 +1,128 @@
const fs = require('node:fs');
const path = require('node:path');
const { ipcMain } = require('electron');
const { sanitizeName, createDirectory, writeFile, safeWriteFileSync, getCollectionStats } = require('./filesystem');
const { generateUidBasedOnHash, stringifyJson } = require('./common');
const { stringifyRequestViaWorker, stringifyCollection, stringifyEnvironment, stringifyFolder } = require('@usebruno/filestore');
/**
* Recursively find a unique folder name by appending incremental numbers
*/
async function findUniqueFolderName(baseName, collectionLocation, counter = 0) {
const folderName = counter === 0 ? baseName : `${baseName} - ${counter}`;
const collectionPath = path.join(collectionLocation, sanitizeName(folderName));
if (fs.existsSync(collectionPath)) {
return findUniqueFolderName(baseName, collectionLocation, counter + 1);
}
return folderName;
}
/**
* Import a collection - shared logic used by both IPC handler and onboarding service
*/
async function importCollection(collection, collectionLocation, mainWindow, lastOpenedCollections, uniqueFolderName = null) {
// Use provided unique folder name or use collection name
let folderName = uniqueFolderName ? sanitizeName(uniqueFolderName) : sanitizeName(collection.name);
let collectionPath = path.join(collectionLocation, folderName);
if (fs.existsSync(collectionPath)) {
throw new Error(`collection: ${collectionPath} already exists`);
}
// Recursive function to parse the collection items and create files/folders
const parseCollectionItems = async (items = [], currentPath) => {
for (const item of items) {
if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) {
let sanitizedFilename = sanitizeName(item.filename || `${item.name}.bru`);
const content = await stringifyRequestViaWorker(item);
const filePath = path.join(currentPath, sanitizedFilename);
safeWriteFileSync(filePath, content);
}
if (item.type === 'folder') {
let sanitizedFolderName = sanitizeName(item.filename || item.name);
const folderPath = path.join(currentPath, sanitizedFolderName);
fs.mkdirSync(folderPath);
if (item.root?.meta?.name) {
const folderBruFilePath = path.join(folderPath, 'folder.bru');
item.root.meta.seq = item.seq;
const folderContent = await stringifyFolder(item.root);
safeWriteFileSync(folderBruFilePath, folderContent);
}
if (item.items && item.items.length) {
await parseCollectionItems(item.items, folderPath);
}
}
// Handle items of type 'js'
if (item.type === 'js') {
let sanitizedFilename = sanitizeName(item.filename || `${item.name}.js`);
const filePath = path.join(currentPath, sanitizedFilename);
safeWriteFileSync(filePath, item.fileContent);
}
}
};
const parseEnvironments = async (environments = [], collectionPath) => {
const envDirPath = path.join(collectionPath, 'environments');
if (!fs.existsSync(envDirPath)) {
fs.mkdirSync(envDirPath);
}
for (const env of environments) {
const content = await stringifyEnvironment(env);
let sanitizedEnvFilename = sanitizeName(`${env.name}.bru`);
const filePath = path.join(envDirPath, sanitizedEnvFilename);
safeWriteFileSync(filePath, content);
}
};
const getBrunoJsonConfig = (collection) => {
let brunoConfig = collection.brunoConfig;
if (!brunoConfig) {
brunoConfig = {
version: '1',
name: collection.name,
type: 'collection',
ignore: ['node_modules', '.git']
};
}
return brunoConfig;
};
await createDirectory(collectionPath);
const uid = generateUidBasedOnHash(collectionPath);
let brunoConfig = getBrunoJsonConfig(collection);
const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
// Write the Bruno configuration to a file
await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
const collectionContent = await stringifyCollection(collection.root);
await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
const { size, filesCount } = await getCollectionStats(collectionPath);
brunoConfig.size = size;
brunoConfig.filesCount = filesCount;
mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
lastOpenedCollections.add(collectionPath);
// create folder and files based on collection
await parseCollectionItems(collection.items, collectionPath);
await parseEnvironments(collection.environments, collectionPath);
return { collectionPath, uid, brunoConfig };
}
module.exports = {
importCollection,
findUniqueFolderName
};

View File

@@ -54,6 +54,108 @@ describe('interpolate-vars: interpolateVars', () => {
});
});
describe('With path params', () => {
it('keeps the original url search params as is', async () => {
const request = {
method: 'GET',
url: 'http://example.com/:param/?search=hello world',
pathParams: [
{
type: 'path',
name: 'param',
value: 'foobar'
}
]
};
const result = interpolateVars(request, null, null, null);
expect(result.url).toBe('http://example.com/foobar/?search=hello world');
});
it('keeps the original url search params as is even when url might not have protocl ', async () => {
const request = {
method: 'GET',
url: 'example.com/:param/?search=hello world',
pathParams: [
{
type: 'path',
name: 'param',
value: 'foobar'
}
]
};
const result = interpolateVars(request, null, null, null);
expect(result.url).toBe('http://example.com/foobar/?search=hello world');
});
it('keeps the original url search params as is even when encoded', async () => {
const request = {
method: 'GET',
url: 'http://example.com/:param?search=hello%20world',
pathParams: [
{
type: 'path',
name: 'param',
value: 'foobar'
}
]
};
const result = interpolateVars(request, null, null, null);
expect(result.url).toBe('http://example.com/foobar?search=hello%20world');
});
it('keeps the original url search params as is with edge cases', async () => {
const requestOne = {
method: 'GET',
url: 'https://example.com/:param?x=1#section',
pathParams: [
{
type: 'path',
name: 'param',
value: 'foobar'
}
]
};
const requestTwo = {
method: 'GET',
url: 'https://example.com/:param?x?y=2',
pathParams: [
{
type: 'path',
name: 'param',
value: 'foobar'
}
]
};
const resultOne = interpolateVars(requestOne, null, null, null);
expect(resultOne.url).toBe('https://example.com/foobar?x=1#section');
const resultTwo = interpolateVars(requestTwo, null, null, null);
expect(resultTwo.url).toBe('https://example.com/foobar?x?y=2');
});
it('keeps the original url even without search', async () => {
const request = {
method: 'GET',
url: 'http://example.com/:param',
pathParams: [
{
type: 'path',
name: 'param',
value: 'foobar'
}
]
};
const result = interpolateVars(request, null, null, null);
expect(result.url).toBe('http://example.com/foobar');
});
});
describe('With process environment variables', () => {
/*
* It should NOT turn process env vars into literal segments.

View File

@@ -16,7 +16,6 @@
},
"dependencies": {
"@usebruno/common": "0.1.0",
"@usebruno/crypto-js": "^3.1.9",
"@usebruno/query": "0.1.0",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
@@ -26,7 +25,7 @@
"chai": "^4.3.7",
"chai-string": "^1.5.0",
"cheerio": "^1.0.0",
"crypto-js": "^4.1.1",
"crypto-js": "^4.2.0",
"json-query": "^2.2.2",
"lodash": "^4.17.21",
"moment": "^2.29.4",
@@ -45,4 +44,4 @@
"rollup": "3.29.5",
"rollup-plugin-terser": "^7.0.2"
}
}
}

View File

@@ -131,7 +131,8 @@ class TestRuntime {
if (this.runtime === 'quickjs') {
await executeQuickJsVmAsync({
script: testsFile,
context: context
context: context,
collectionPath
});
} else if (this.runtime === 'nodevm') {
await runScriptInNodeVm({
@@ -147,6 +148,7 @@ class TestRuntime {
require: {
context: 'sandbox',
external: true,
builtin: ['*'],
root: [collectionPath, ...additionalContextRootsAbsolute],
mock: {
// node libs

View File

@@ -11,7 +11,7 @@ const bundleLibraries = async () => {
import moment from "moment";
import btoa from "btoa";
import atob from "atob";
import * as CryptoJS from "@usebruno/crypto-js";
import * as cryptoJs from 'crypto-js';
import tv4 from "tv4";
globalThis.expect = expect;
globalThis.assert = assert;
@@ -19,7 +19,6 @@ const bundleLibraries = async () => {
globalThis.btoa = btoa;
globalThis.atob = atob;
globalThis.Buffer = Buffer;
globalThis.CryptoJS = CryptoJS;
globalThis.tv4 = tv4;
globalThis.requireObject = {
...(globalThis.requireObject || {}),
@@ -28,7 +27,7 @@ const bundleLibraries = async () => {
'buffer': { Buffer },
'btoa': btoa,
'atob': atob,
'crypto-js': CryptoJS,
'crypto-js': cryptoJs,
'tv4': tv4
};
`;

View File

@@ -11,6 +11,7 @@ const { newQuickJSWASMModule, memoizePromiseFactory } = require('quickjs-emscrip
const getBundledCode = require('../bundle-browser-rollup');
const addPathShimToContext = require('./shims/lib/path');
const { marshallToVm } = require('./utils');
const addCryptoUtilsShimToContext = require('./shims/lib/crypto-utils');
let QuickJSSyncContext;
const loader = memoizePromiseFactory(() => newQuickJSWASMModule());
@@ -98,6 +99,9 @@ const executeQuickJsVmAsync = async ({ script: externalScript, context: external
const module = await newQuickJSWASMModule();
const vm = module.newContext();
// add crypto utilities required by the crypto-js library in bundledCode
await addCryptoUtilsShimToContext(vm);
const bundledCode = getBundledCode?.toString() || '';
const moduleLoaderCode = function () {
return `

View File

@@ -0,0 +1,104 @@
const crypto = require('node:crypto');
const { marshallToVm } = require('../../utils');
const { serializeTypedArray, deserializeTypedArray } = require('./utils');
/**
* Node.js crypto module shim for QuickJS sandbox
* Implements crypto.randomBytes and crypto.getRandomValues functions
*/
const addCryptoUtilsShimToContext = async (vm) => {
let randomBytesHandle = vm.newFunction('randomBytes', function (sizeHandle) {
try {
let size = vm.dump(sizeHandle);
if (typeof size !== 'number') {
throw new TypeError('The "size" argument must be of type number');
}
size = Math.trunc(size);
if (size < 0) {
throw new RangeError('The "size" argument must be >= 0');
}
if (size > 65536) { // 2^31 - 1 (max safe integer for practical use)
throw new RangeError('The "size" argument is too large');
}
if (size === 0) {
return marshallToVm([], vm);
}
const buffer = crypto.randomBytes(size);
const byteArray = Array.from(buffer);
return marshallToVm(byteArray, vm);
} catch (error) {
const vmError = vm.newError(error.message);
vm.setProp(vmError, 'name', vm.newString(error.name));
throw vmError;
}
});
let getRandomValuesHandle = vm.newFunction('getRandomValues', function (arrayHandle) {
try {
// Receive the serialized array data directly
const serializedArray = vm.dump(arrayHandle);
const typedArray = deserializeTypedArray(serializedArray);
if (typedArray.length === 0) {
return marshallToVm([], vm);
}
if (typedArray.length > 65536) {
throw new Error('getRandomValues: ArrayBufferView byte length exceeds 65536');
}
crypto.getRandomValues(typedArray);
const byteArray = Array.from(typedArray);
return marshallToVm(byteArray, vm);
} catch (error) {
const vmError = vm.newError(error.message);
vm.setProp(vmError, 'name', vm.newString(error.name));
throw vmError;
}
});
// Set the functions in global context
vm.setProp(vm.global, '__bruno__crypto__randomBytes', randomBytesHandle);
vm.setProp(vm.global, '__bruno__crypto__getRandomValues', getRandomValuesHandle);
randomBytesHandle.dispose();
getRandomValuesHandle.dispose();
vm.evalCode(`
// Helper function for typed array serialization
${serializeTypedArray.toString()}
// Create crypto module object following Node.js specifications
const cryptoModule = {
// node.js crypto.randomBytes API
randomBytes: function(size) {
const byteArray = globalThis.__bruno__crypto__randomBytes(size);
return Buffer.from(Array.from(byteArray));
},
// node.js crypto.getRandomValues API
getRandomValues: function(typedArray) {
const serializedTypedArray = serializeTypedArray(typedArray);
typedArray.set(globalThis.__bruno__crypto__getRandomValues(serializedTypedArray));
return typedArray;
},
};
// Make crypto available globally
globalThis.crypto = cryptoModule;
`);
};
module.exports = addCryptoUtilsShimToContext;

View File

@@ -0,0 +1,73 @@
const { describe, it, expect } = require('@jest/globals');
const { newQuickJSWASMModule } = require('quickjs-emscripten');
const addCryptoUtilsShimToContext = require('./crypto-utils');
const getBundledCode = require('../../../bundle-browser-rollup');
describe('crypto-utils shims tests', () => {
let vm, module;
beforeAll(async () => {
module = await newQuickJSWASMModule();
});
beforeEach(async () => {
vm = module.newContext();
await addCryptoUtilsShimToContext(vm);
// required for `Buffer` library usage
const bundledCode = getBundledCode?.toString() || '';
vm.evalCode(
`
(${bundledCode})()
`
);
});
it('should provide crypto.randomBytes function', async () => {
const result = vm.evalCode('typeof crypto.randomBytes');
const handle = vm.unwrapResult(result);
const type = vm.dump(handle);
handle.dispose();
expect(type).toBe('function');
});
it('should provide crypto.getRandomValues function', async () => {
const result = vm.evalCode('typeof crypto.getRandomValues');
const handle = vm.unwrapResult(result);
const type = vm.dump(handle);
handle.dispose();
expect(type).toBe('function');
});
it('should generate random bytes with correct length', async () => {
const result = vm.evalCode('crypto.randomBytes(8).length');
const handle = vm.unwrapResult(result);
const length = vm.dump(handle);
handle.dispose();
expect(length).toBe(8);
});
it('should convert random bytes to hex string', async () => {
const result = vm.evalCode('crypto.randomBytes(4).toString("hex").length');
const handle = vm.unwrapResult(result);
const hexLength = vm.dump(handle);
handle.dispose();
expect(hexLength).toBe(8); // 4 bytes = 8 hex chars
});
it('should fill Uint8Array with getRandomValues', async () => {
const result = vm.evalCode(`
const arr = new Uint8Array(5);
crypto.getRandomValues(arr);
arr.length;
`);
const handle = vm.unwrapResult(result);
const length = vm.dump(handle);
handle.dispose();
expect(length).toBe(5);
});
});

View File

@@ -0,0 +1,48 @@
function serializeTypedArray(ta) {
return {
type: ta.constructor.name,
array: Array.from(ta),
length: ta.length
};
}
function deserializeTypedArray(obj) {
// Allowed typed array constructors for crypto operations
const allowedConstructors = new Set([
'Int8Array',
'Uint8Array',
'Uint8ClampedArray',
'Int16Array',
'Uint16Array',
'Int32Array',
'Uint32Array',
'Float32Array',
'Float64Array',
'BigInt64Array',
'BigUint64Array'
]);
if (!obj || typeof obj !== 'object') {
throw new TypeError('getRandomValues: Invalid typed array object');
}
if (typeof obj.type !== 'string' || !allowedConstructors.has(obj.type)) {
throw new TypeError(`getRandomValues: Invalid or unsupported typed array type: ${obj.type}`);
}
if (!obj.array || typeof obj.length !== 'number') {
throw new TypeError('getRandomValues: Invalid typed array properties');
}
const ctor = globalThis[obj.type];
if (typeof ctor !== 'function') {
throw new TypeError(`getRandomValues: Constructor ${obj.type} is not available`);
}
return new ctor(obj.array, 0, obj.length);
}
module.exports = {
serializeTypedArray,
deserializeTypedArray
}

View File

@@ -38,6 +38,6 @@ module.exports = [
typescript({ tsconfig: './tsconfig.json' }),
terser()
],
external: ['axios', 'qs', 'ws', 'system-ca']
external: ['axios', 'qs', 'ws']
}
];

View File

@@ -1,5 +1,4 @@
import { systemCertsAsync, Options as SystemCAOptions } from 'system-ca';
import { rootCertificates } from 'node:tls';
import * as tls from 'node:tls';
import * as fs from 'node:fs';
type T_CACertificatesOptions = {
@@ -19,11 +18,11 @@ type T_CACertificatesResult = {
let systemCertsCache: string[] | undefined;
async function getSystemCerts(systemCAOpts: SystemCAOptions = {}): Promise<string[]> {
function getSystemCerts(): string[] {
if (systemCertsCache) return systemCertsCache;
try {
systemCertsCache = await systemCertsAsync(systemCAOpts);
systemCertsCache = tls.getCACertificates('system');
return systemCertsCache;
} catch (error) {
@@ -88,10 +87,10 @@ function getNodeExtraCACerts(): string[] {
*
* @param caCertFilePath - path to custom CA certificate file
* @param shouldKeepDefaultCerts - whether to keep default CA certificates
* @returns {Promise<T_CACertificatesResult>} - CA certificates and their count
* @returns {T_CACertificatesResult} - CA certificates and their count
*/
const getCACertificates = async ({ caCertFilePath, shouldKeepDefaultCerts = true }: T_CACertificatesOptions): Promise<T_CACertificatesResult> => {
const getCACertificates = ({ caCertFilePath, shouldKeepDefaultCerts = true }: T_CACertificatesOptions): T_CACertificatesResult => {
try {
let caCertificates = '';
let caCertificatesCount = {
@@ -127,20 +126,20 @@ const getCACertificates = async ({ caCertFilePath, shouldKeepDefaultCerts = true
if (shouldKeepDefaultCerts) {
// get system certs
systemCerts = await getSystemCerts();
systemCerts = getSystemCerts();
caCertificatesCount.system = systemCerts.length;
// get root certs
rootCerts = [...rootCertificates];
rootCerts = [...tls.rootCertificates];
caCertificatesCount.root = rootCerts.length;
}
} else {
// get system certs
systemCerts = await getSystemCerts();
systemCerts = getSystemCerts();
caCertificatesCount.system = systemCerts.length;
// get root certs
rootCerts = [...rootCertificates];
rootCerts = [...tls.rootCertificates];
caCertificatesCount.root = rootCerts.length;
}

View File

@@ -10,24 +10,17 @@ get {
auth: none
}
script:pre-request {
var CryptoJS = require("crypto-js");
// Encrypt
var ciphertext = CryptoJS.AES.encrypt('my message', 'secret key 123').toString();
// Decrypt
var bytes = CryptoJS.AES.decrypt(ciphertext, 'secret key 123');
var originalText = bytes.toString(CryptoJS.enc.Utf8);
bru.setVar('crypto-test-message', originalText);
}
tests {
test("crypto message", function() {
const data = bru.getVar('crypto-test-message');
bru.setVar('crypto-test-message', null);
expect(data).to.eql('my message');
var CryptoJS = require("crypto-js");
// Encrypt
var ciphertext = CryptoJS.AES.encrypt('my message', 'secret key 123').toString();
// Decrypt
var bytes = CryptoJS.AES.decrypt(ciphertext, 'secret key 123');
var originalText = bytes.toString(CryptoJS.enc.Utf8);
expect(originalText).to.eql('my message');
});
}

View File

@@ -0,0 +1,43 @@
meta {
name: getRandomValues
type: http
seq: 3
}
post {
url: https://echo.usebruno.com
body: none
auth: inherit
}
assert {
res.status: eq 200
}
tests {
const { doesUint8ArraysWorkAsExpected, getRandomValuesFunction, isUint8Array } = require('./scripting/inbuilt modules/utils.js');
if (!doesUint8ArraysWorkAsExpected()) {
console.warn('Uint8Array does not work as expected in vm2');
return;
}
// check if Uint8Array work as expected
test("should get random values", function() {
const uint8Array = new Uint8Array(32).fill(0);
const randomValueUint8Array = getRandomValuesFunction(new Uint8Array(uint8Array));
const isValueUint8Array = isUint8Array(randomValueUint8Array);
expect(isValueUint8Array).to.be.true;
const plainArray = Array.from(randomValueUint8Array);
expect(plainArray).to.be.of.length(32);
const ogPlainArray = Array.from(uint8Array);
expect(ogPlainArray).to.not.deep.eql(plainArray);
});
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,33 @@
meta {
name: randomBytes
type: http
seq: 4
}
post {
url: https://echo.usebruno.com
body: none
auth: inherit
}
assert {
res.status: eq 200
}
tests {
const { randomBytesFunction, isUint8Array } = require('./scripting/inbuilt modules/utils.js');
test("should get random byte values", function() {
const randomValueUint8Array = randomBytesFunction(32);
const isValueUint8Array = isUint8Array(randomValueUint8Array);
expect(isValueUint8Array).to.be.true;
const plainArray = Array.from(randomValueUint8Array);
expect(plainArray).to.be.of.length(32);
});
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,56 @@
const doesUint8ArraysWorkAsExpected = () => {
try {
const util = require('node:util');
// node:vm - true
// vm2 - false
return util.types.isUint8Array(new Uint8Array(32));
}
catch (err) {
// safe mode [quickjs], will work as expected
return true;
}
}
const isUint8Array = (val) => {
try {
// developer mode [node:vm and vm2]
const util = require('node:util');
return util.types.isUint8Array(val);
}
catch (err) {
// node:util not present in safe mode [quickjs]
return val instanceof Uint8Array;
}
}
const getRandomValuesFunction = (typedArray) => {
try {
// developer mode [node:vm and vm2]
const crypto = require('node:crypto');
return crypto.getRandomValues(typedArray);
}
catch (err) {
// node:crypto not present in safe mode [quickjs] - uses shim
return crypto.getRandomValues(typedArray);
}
}
const randomBytesFunction = (num) => {
try {
// developer mode [node:vm and vm2]
const crypto = require('node:crypto');
return crypto.randomBytes(num);
}
catch (err) {
// node:crypto not present in safe mode [quickjs] - uses shim
return crypto.randomBytes(num);
}
}
module.exports = {
doesUint8ArraysWorkAsExpected,
isUint8Array,
getRandomValuesFunction,
randomBytesFunction
}

View File

@@ -15,9 +15,9 @@ export const test = baseTest.extend<
},
{
createTmpDir: (tag?: string) => Promise<string>;
launchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string }) => Promise<ElectronApplication>;
launchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string; dotEnv?: Record<string, string> }) => Promise<ElectronApplication>;
electronApp: ElectronApplication;
reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string }) => Promise<ElectronApplication>;
reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string; dotEnv?: Record<string, string> }) => Promise<ElectronApplication>;
}
>({
createTmpDir: [
@@ -38,7 +38,7 @@ export const test = baseTest.extend<
launchElectronApp: [
async ({ playwright, createTmpDir }, use, workerInfo) => {
const apps: ElectronApplication[] = [];
await use(async ({ initUserDataPath, userDataPath: providedUserDataPath } = {}) => {
await use(async ({ initUserDataPath, userDataPath: providedUserDataPath, dotEnv = {} } = {}) => {
const userDataPath = providedUserDataPath || (await createTmpDir('electron-userdata'));
// Ensure dir exists when caller supplies their own path
@@ -68,7 +68,9 @@ export const test = baseTest.extend<
args: [electronAppPath],
env: {
...process.env,
ELECTRON_USER_DATA_PATH: userDataPath
ELECTRON_USER_DATA_PATH: userDataPath,
DISABLE_SAMPLE_COLLECTION_IMPORT: 'true',
...dotEnv
}
});
@@ -148,12 +150,12 @@ export const test = baseTest.extend<
reuseOrLaunchElectronApp: [
async ({ launchElectronApp }, use, testInfo) => {
const apps: Record<string, ElectronApplication> = {};
await use(async ({ initUserDataPath, userDataPath } = {}) => {
await use(async ({ initUserDataPath, userDataPath, dotEnv = {} } = {}) => {
const key = userDataPath || initUserDataPath;
if (key && apps[key]) {
return apps[key];
}
const app = await launchElectronApp({ initUserDataPath, userDataPath });
const app = await launchElectronApp({ initUserDataPath, userDataPath, dotEnv });
if (key) {
apps[key] = app;
}

View File

@@ -16,9 +16,10 @@ test.describe('Cross-Collection Drag and Drop for folder', () => {
await page.getByRole('button', { name: 'Save' }).click();
// Create a folder in the first collection
// Look for the collection menu button (usually three dots or similar)
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
// Look for the collection menu button for the source collection specifically
const sourceCollectionContainer1 = page.locator('.collection-name').filter({ hasText: 'source-collection' });
await sourceCollectionContainer1.locator('.collection-actions').hover();
await sourceCollectionContainer1.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
// Fill folder name in the modal

View File

@@ -0,0 +1,10 @@
{
"lastOpenedCollections": [
"{{projectRoot}}/packages/bruno-tests/collection"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true
}
}
}

View File

@@ -0,0 +1,133 @@
import path from 'path';
import { test, expect, errors } from '../../playwright';
const env = {
DISABLE_SAMPLE_COLLECTION_IMPORT: 'false'
};
test.describe('Onboarding', () => {
test('should create sample collection on first launch', async ({ launchElectronApp, createTmpDir }) => {
// Use a fresh app instance to avoid contamination from previous tests
const userDataPath = await createTmpDir('onboarding-fresh');
const app = await launchElectronApp({ userDataPath, dotEnv: env });
const page = await app.firstWindow();
// Verify sample collection appears in sidebar
const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection');
await expect(sampleCollection).toBeVisible();
// Click on the sample collection to open it
await sampleCollection.click();
const modeSaveButton = page.getByRole('button', { name: 'Save' });
await expect(modeSaveButton).toBeVisible();
await modeSaveButton.click();
// Verify the sample request is visible and clickable
const request = page.locator('.collection-item-name').getByText('Get Users');
await expect(request).toBeVisible();
await request.click();
// Verify the URL is set correctly
await expect(page.locator('#request-url')).toContainText('https://jsonplaceholder.typicode.com/users');
// Clean up
await app.close();
});
test('should not create duplicate collections on subsequent launches', async ({ launchElectronApp, createTmpDir }) => {
// Use a fresh app instance to avoid contamination from previous tests
const userDataPath = await createTmpDir('duplicate-collections');
const app = await launchElectronApp({ userDataPath, dotEnv: env });
const page = await app.firstWindow();
// First launch - verify sample collection is created
const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection');
await expect(sampleCollection).toBeVisible();
await sampleCollection.click();
const modeSaveButton = page.getByRole('button', { name: 'Save' });
await expect(modeSaveButton).toBeVisible();
await modeSaveButton.click();
// Verify the sample request
const request = page.locator('.collection-item-name').getByText('Get Users');
await expect(request).toBeVisible();
await request.click();
// Verify the URL is set correctly
await expect(page.locator('#request-url')).toContainText('https://jsonplaceholder.typicode.com/users');
// Close the first app instance
await app.close();
// Restart app - should not create sample collection again
const newApp = await launchElectronApp({ userDataPath, dotEnv: env });
const newPage = await newApp.firstWindow();
// Verify only one sample collection exists
const sampleCollections = newPage.locator('#sidebar-collection-name').getByText('Sample API Collection');
await expect(sampleCollections).toHaveCount(1);
// Verify the collection still works after restart
await sampleCollections.click();
const request2 = newPage.locator('.collection-item-name').getByText('Get Users');
await expect(request2).toBeVisible();
await request2.click();
// Verify the URL is still correct after restart
await expect(newPage.locator('#request-url')).toContainText('https://jsonplaceholder.typicode.com/users');
// Clean up
await newApp.close();
});
test('should not recreate sample collection after user deletes it', async ({ launchElectronApp, reuseOrLaunchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('first-launch');
const app = await launchElectronApp({ userDataPath, dotEnv: env });
const page = await app.firstWindow();
// First launch - sample collection should be created
const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection');
await expect(sampleCollection).toBeVisible();
// User closes the sample collection (right-click to open context menu)
await sampleCollection.click({ button: 'right' });
// Close the sample collection
const closeOption = page.locator('.dropdown-item').getByText('Close');
await expect(closeOption).toBeVisible();
await closeOption.click();
// Handle the confirmation dialog - click the 'Close' button to confirm
const confirmCloseButton = page.getByRole('button', { name: 'Close' });
await expect(confirmCloseButton).toBeVisible();
await confirmCloseButton.click();
// Verify collection is closed (no longer visible in sidebar)
await expect(sampleCollection).not.toBeVisible();
// Restart app - sample collection should NOT be recreated
const newApp = await reuseOrLaunchElectronApp({ userDataPath, dotEnv: env });
const newPage = await newApp.firstWindow();
// Wait for the app to be loaded / onboarding to be completed
await newPage.locator('[data-app-state="loaded"]').waitFor();
// Sample collection should not appear since it's no longer first launch
const sampleCollections = newPage.locator('#sidebar-collection-name').getByText('Sample API Collection');
await expect(sampleCollections).not.toBeVisible();
});
test('should not create sample collection if user has already opened a collection', async ({ pageWithUserData: page }) => {
// Wait for the app to be loaded / onboarding to be completed
await page.locator('[data-app-state="loaded"]').waitFor();
// This test simulates old users who already have a collection opened
const brunoTestbench = page.locator('#sidebar-collection-name').getByText('bruno-testbench');
await expect(brunoTestbench).toBeVisible();
// Verify no sample collection was created since user already has collections
const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection');
await expect(sampleCollection).not.toBeVisible();
});
});