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(); + }); +});