mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-24 21:25:45 +00:00
Merge branch 'main' into feat/websocket-engine
This commit is contained in:
80
package-lock.json
generated
80
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 || [];
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
BRUNO_INFO_ENDPOINT = http://localhost:8081
|
||||
BRUNO_INFO_ENDPOINT = http://localhost:8081
|
||||
DISABLE_SAMPLE_COLLECTION_IMPORT = false
|
||||
100
packages/bruno-electron/src/app/onboarding.js
Normal file
100
packages/bruno-electron/src/app/onboarding.js
Normal 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;
|
||||
55
packages/bruno-electron/src/assets/sample-collection.json
Normal file
55
packages/bruno-electron/src/assets/sample-collection.json
Normal 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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -27,7 +27,7 @@ const getCertsAndProxyConfig = async ({
|
||||
}
|
||||
|
||||
let caCertFilePath = preferencesUtil.shouldUseCustomCaCertificate() && preferencesUtil.getCustomCaCertificateFilePath();
|
||||
let caCertificatesData = await getCACertificates({
|
||||
let caCertificatesData = getCACertificates({
|
||||
caCertFilePath,
|
||||
shouldKeepDefaultCerts: preferencesUtil.shouldKeepDefaultCaCertificates()
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
128
packages/bruno-electron/src/utils/collection-import.js
Normal file
128
packages/bruno-electron/src/utils/collection-import.js
Normal 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
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
`;
|
||||
|
||||
@@ -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 `
|
||||
|
||||
104
packages/bruno-js/src/sandbox/quickjs/shims/lib/crypto-utils.js
Normal file
104
packages/bruno-js/src/sandbox/quickjs/shims/lib/crypto-utils.js
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
48
packages/bruno-js/src/sandbox/quickjs/shims/lib/utils.js
Normal file
48
packages/bruno-js/src/sandbox/quickjs/shims/lib/utils.js
Normal 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
|
||||
}
|
||||
@@ -38,6 +38,6 @@ module.exports = [
|
||||
typescript({ tsconfig: './tsconfig.json' }),
|
||||
terser()
|
||||
],
|
||||
external: ['axios', 'qs', 'ws', 'system-ca']
|
||||
external: ['axios', 'qs', 'ws']
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
10
tests/onboarding/init-user-data/preferences.json
Normal file
10
tests/onboarding/init-user-data/preferences.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/packages/bruno-tests/collection"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true
|
||||
}
|
||||
}
|
||||
}
|
||||
133
tests/onboarding/sample-collection.spec.ts
Normal file
133
tests/onboarding/sample-collection.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user