diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js index b42462547..199fdf895 100644 --- a/packages/bruno-app/src/pages/Bruno/index.js +++ b/packages/bruno-app/src/pages/Bruno/index.js @@ -7,6 +7,7 @@ import Sidebar from 'components/Sidebar'; import StatusBar from 'components/StatusBar'; // import ErrorCapture from 'components/ErrorCapture'; import { useSelector } from 'react-redux'; +import { isElectron } from 'utils/common/platform'; import StyledWrapper from './StyledWrapper'; import 'codemirror/theme/material.css'; import 'codemirror/theme/monokai.css'; @@ -56,12 +57,31 @@ export default function Main() { 'is-dragging': isDragging }); + useEffect(() => { + if (!isElectron()) { + return; + } + + const { ipcRenderer } = window; + + const removeAppLoadedListener = ipcRenderer.on('main:app-loaded', () => { + if (mainSectionRef.current) { + mainSectionRef.current.setAttribute('data-app-state', 'loaded'); + } + }); + + return () => { + removeAppLoadedListener(); + }; + }, []); + return ( // -
-
+
- +
diff --git a/packages/bruno-electron/src/app/onboarding.js b/packages/bruno-electron/src/app/onboarding.js new file mode 100644 index 000000000..082d465f1 --- /dev/null +++ b/packages/bruno-electron/src/app/onboarding.js @@ -0,0 +1,97 @@ +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; + } + + // 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/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/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..9473ebff2 --- /dev/null +++ b/tests/onboarding/sample-collection.spec.ts @@ -0,0 +1,126 @@ +import { test, expect, errors } from '../../playwright'; + +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 }); + 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 }); + 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 }); + 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 }); + 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 }); + 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(); + + const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection'); + await expect(sampleCollection).not.toBeVisible(); + }); +});