diff --git a/package-lock.json b/package-lock.json
index 24e03ee5b..c6d7b974b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
index 1050d68b6..ce2e13678 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
@@ -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
-
- {
+ 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 (
//
-
-
-
-
-
- {showHomePage ? (
-
- ) : (
- <>
-
-
- >
- )}
-
-
-
+
+
+
+
+
+ {showHomePage ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
-
-
-
+
+
+
//
);
}
diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js
index 904bf7c39..eadcca336 100644
--- a/packages/bruno-cli/src/runner/run-single-request.js
+++ b/packages/bruno-cli/src/runner/run-single-request.js
@@ -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 || [];
}
diff --git a/packages/bruno-electron/.env.sample b/packages/bruno-electron/.env.sample
index b75f94661..b2d42d055 100644
--- a/packages/bruno-electron/.env.sample
+++ b/packages/bruno-electron/.env.sample
@@ -1 +1,2 @@
-BRUNO_INFO_ENDPOINT = http://localhost:8081
\ No newline at end of file
+BRUNO_INFO_ENDPOINT = http://localhost:8081
+DISABLE_SAMPLE_COLLECTION_IMPORT = false
\ No newline at end of file
diff --git a/packages/bruno-electron/src/app/onboarding.js b/packages/bruno-electron/src/app/onboarding.js
new file mode 100644
index 000000000..05ebe4d4b
--- /dev/null
+++ b/packages/bruno-electron/src/app/onboarding.js
@@ -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;
diff --git a/packages/bruno-electron/src/assets/sample-collection.json b/packages/bruno-electron/src/assets/sample-collection.json
new file mode 100644
index 000000000..869ff4868
--- /dev/null
+++ b/packages/bruno-electron/src/assets/sample-collection.json
@@ -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": ""
+ }
+ }
+}
diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js
index e9026d83c..01f8a8494 100644
--- a/packages/bruno-electron/src/index.js
+++ b/packages/bruno-electron/src/index.js
@@ -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
diff --git a/packages/bruno-electron/src/ipc/network/cert-utils.js b/packages/bruno-electron/src/ipc/network/cert-utils.js
index 0c51f34d9..7c2e43d38 100644
--- a/packages/bruno-electron/src/ipc/network/cert-utils.js
+++ b/packages/bruno-electron/src/ipc/network/cert-utils.js
@@ -27,7 +27,7 @@ const getCertsAndProxyConfig = async ({
}
let caCertFilePath = preferencesUtil.shouldUseCustomCaCertificate() && preferencesUtil.getCustomCaCertificateFilePath();
- let caCertificatesData = await getCACertificates({
+ let caCertificatesData = getCACertificates({
caCertFilePath,
shouldKeepDefaultCerts: preferencesUtil.shouldKeepDefaultCaCertificates()
});
diff --git a/packages/bruno-electron/src/ipc/network/interpolate-vars.js b/packages/bruno-electron/src/ipc/network/interpolate-vars.js
index 4aef1e73f..0aff18f19 100644
--- a/packages/bruno-electron/src/ipc/network/interpolate-vars.js
+++ b/packages/bruno-electron/src/ipc/network/interpolate-vars.js
@@ -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) {
diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js
index bf2fde374..44600907a 100644
--- a/packages/bruno-electron/src/store/preferences.js
+++ b/packages/bruno-electron/src/store/preferences.js
@@ -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);
}
};
diff --git a/packages/bruno-electron/src/utils/collection-import.js b/packages/bruno-electron/src/utils/collection-import.js
new file mode 100644
index 000000000..4207a5d23
--- /dev/null
+++ b/packages/bruno-electron/src/utils/collection-import.js
@@ -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
+};
diff --git a/packages/bruno-electron/tests/network/interpolate-vars.spec.js b/packages/bruno-electron/tests/network/interpolate-vars.spec.js
index c288db77f..88abdd811 100644
--- a/packages/bruno-electron/tests/network/interpolate-vars.spec.js
+++ b/packages/bruno-electron/tests/network/interpolate-vars.spec.js
@@ -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.
diff --git a/packages/bruno-js/package.json b/packages/bruno-js/package.json
index ae7749692..bcae171a4 100644
--- a/packages/bruno-js/package.json
+++ b/packages/bruno-js/package.json
@@ -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"
}
-}
\ No newline at end of file
+}
diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js
index de8e37ba1..1b7263c67 100644
--- a/packages/bruno-js/src/runtime/test-runtime.js
+++ b/packages/bruno-js/src/runtime/test-runtime.js
@@ -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
diff --git a/packages/bruno-js/src/sandbox/bundle-libraries.js b/packages/bruno-js/src/sandbox/bundle-libraries.js
index 1545ef5cd..cd62ed710 100644
--- a/packages/bruno-js/src/sandbox/bundle-libraries.js
+++ b/packages/bruno-js/src/sandbox/bundle-libraries.js
@@ -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
};
`;
diff --git a/packages/bruno-js/src/sandbox/quickjs/index.js b/packages/bruno-js/src/sandbox/quickjs/index.js
index d1340e92d..c95381a15 100644
--- a/packages/bruno-js/src/sandbox/quickjs/index.js
+++ b/packages/bruno-js/src/sandbox/quickjs/index.js
@@ -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 `
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/lib/crypto-utils.js b/packages/bruno-js/src/sandbox/quickjs/shims/lib/crypto-utils.js
new file mode 100644
index 000000000..50933d5fe
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/lib/crypto-utils.js
@@ -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;
\ No newline at end of file
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/lib/crypto-utils.spec.js b/packages/bruno-js/src/sandbox/quickjs/shims/lib/crypto-utils.spec.js
new file mode 100644
index 000000000..969ef514d
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/lib/crypto-utils.spec.js
@@ -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);
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/lib/utils.js b/packages/bruno-js/src/sandbox/quickjs/shims/lib/utils.js
new file mode 100644
index 000000000..757d14c52
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/lib/utils.js
@@ -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
+}
\ No newline at end of file
diff --git a/packages/bruno-requests/rollup.config.js b/packages/bruno-requests/rollup.config.js
index df607603d..435b23bb1 100644
--- a/packages/bruno-requests/rollup.config.js
+++ b/packages/bruno-requests/rollup.config.js
@@ -38,6 +38,6 @@ module.exports = [
typescript({ tsconfig: './tsconfig.json' }),
terser()
],
- external: ['axios', 'qs', 'ws', 'system-ca']
+ external: ['axios', 'qs', 'ws']
}
];
diff --git a/packages/bruno-requests/src/utils/ca-cert.ts b/packages/bruno-requests/src/utils/ca-cert.ts
index b15315ab1..e304281dd 100644
--- a/packages/bruno-requests/src/utils/ca-cert.ts
+++ b/packages/bruno-requests/src/utils/ca-cert.ts
@@ -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 {
+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} - CA certificates and their count
+ * @returns {T_CACertificatesResult} - CA certificates and their count
*/
-const getCACertificates = async ({ caCertFilePath, shouldKeepDefaultCerts = true }: T_CACertificatesOptions): Promise => {
+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;
}
diff --git a/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-js/crypto-js-pre-request-script.bru b/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-js/crypto-js-pre-request-script.bru
index 8385847c9..30a784bc3 100644
--- a/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-js/crypto-js-pre-request-script.bru
+++ b/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-js/crypto-js-pre-request-script.bru
@@ -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');
});
-
}
diff --git a/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-utils/getRandomValues.bru b/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-utils/getRandomValues.bru
new file mode 100644
index 000000000..c07e30530
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-utils/getRandomValues.bru
@@ -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
+}
diff --git a/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-utils/randomBytes.bru b/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-utils/randomBytes.bru
new file mode 100644
index 000000000..356f8de1c
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-utils/randomBytes.bru
@@ -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
+}
diff --git a/packages/bruno-tests/collection/scripting/inbuilt modules/utils.js b/packages/bruno-tests/collection/scripting/inbuilt modules/utils.js
new file mode 100644
index 000000000..5b9b86174
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/inbuilt modules/utils.js
@@ -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
+}
\ No newline at end of file
diff --git a/playwright/index.ts b/playwright/index.ts
index bca567793..8b476ac55 100644
--- a/playwright/index.ts
+++ b/playwright/index.ts
@@ -15,9 +15,9 @@ export const test = baseTest.extend<
},
{
createTmpDir: (tag?: string) => Promise;
- launchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string }) => Promise;
+ launchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string; dotEnv?: Record }) => Promise;
electronApp: ElectronApplication;
- reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string }) => Promise;
+ reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string; dotEnv?: Record }) => Promise;
}
>({
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 = {};
- 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;
}
diff --git a/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts b/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts
index 9fce391ff..00f745fe2 100644
--- a/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts
+++ b/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts
@@ -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
diff --git a/tests/onboarding/init-user-data/preferences.json b/tests/onboarding/init-user-data/preferences.json
new file mode 100644
index 000000000..dfc1e3acc
--- /dev/null
+++ b/tests/onboarding/init-user-data/preferences.json
@@ -0,0 +1,10 @@
+{
+ "lastOpenedCollections": [
+ "{{projectRoot}}/packages/bruno-tests/collection"
+ ],
+ "preferences": {
+ "onboarding": {
+ "hasLaunchedBefore": true
+ }
+ }
+}
diff --git a/tests/onboarding/sample-collection.spec.ts b/tests/onboarding/sample-collection.spec.ts
new file mode 100644
index 000000000..a229676a4
--- /dev/null
+++ b/tests/onboarding/sample-collection.spec.ts
@@ -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();
+ });
+});
| |