mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: Add default sample collection on first app launch (#5536)
* feat: Add Default Sample Collection On First Launch * feat(initial-load): add attribute for app readiness --------- Co-authored-by: Bijin Bruno <bijin@usebruno.com>
This commit is contained in:
@@ -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 (
|
||||
// <ErrorCapture>
|
||||
<div className="flex flex-col h-screen max-h-screen overflow-hidden">
|
||||
<div
|
||||
<div id="main-container" className="flex flex-col h-screen max-h-screen overflow-hidden">
|
||||
<div
|
||||
ref={mainSectionRef}
|
||||
className="flex-1 min-h-0 flex"
|
||||
data-app-state="loading"
|
||||
style={{
|
||||
height: isConsoleOpen ? `calc(100vh - 22px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 22px)'
|
||||
}}
|
||||
@@ -80,7 +100,7 @@ export default function Main() {
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
</div>
|
||||
|
||||
|
||||
<Devtools mainSectionRef={mainSectionRef} />
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
||||
97
packages/bruno-electron/src/app/onboarding.js
Normal file
97
packages/bruno-electron/src/app/onboarding.js
Normal file
@@ -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;
|
||||
55
packages/bruno-electron/src/assets/sample-collection.json
Normal file
55
packages/bruno-electron/src/assets/sample-collection.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"version": "1",
|
||||
"uid": "1igyn4u00000000000232",
|
||||
"name": "Sample API Collection",
|
||||
"items": [
|
||||
{
|
||||
"uid": "1igyn4u00000000000001",
|
||||
"type": "http-request",
|
||||
"name": "Get Users",
|
||||
"seq": 1,
|
||||
"request": {
|
||||
"url": "https://jsonplaceholder.typicode.com/users",
|
||||
"method": "GET",
|
||||
"headers": [],
|
||||
"params": [],
|
||||
"body": {
|
||||
"mode": "none"
|
||||
},
|
||||
"auth": {
|
||||
"mode": "none"
|
||||
},
|
||||
"script": {
|
||||
"req": "",
|
||||
"res": ""
|
||||
},
|
||||
"vars": {
|
||||
"req": [],
|
||||
"res": []
|
||||
},
|
||||
"assertions": [],
|
||||
"tests": "",
|
||||
"docs": "This request retrieves a list of users from the JSONPlaceholder API."
|
||||
}
|
||||
}
|
||||
],
|
||||
"environments": [],
|
||||
"activeEnvironmentUid": null,
|
||||
"root": {
|
||||
"request": {
|
||||
"headers": [],
|
||||
"auth": {
|
||||
"mode": "none"
|
||||
},
|
||||
"script": {
|
||||
"req": "",
|
||||
"res": ""
|
||||
},
|
||||
"vars": {
|
||||
"req": [],
|
||||
"res": []
|
||||
},
|
||||
"tests": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
|
||||
const { safeParseJSON, safeStringifyJSON } = require('./utils/common');
|
||||
const { getDomainsWithCookies } = require('./utils/cookies');
|
||||
const { cookiesStore } = require('./store/cookies');
|
||||
const onboardUser = require('./app/onboarding');
|
||||
|
||||
const lastOpenedCollections = new LastOpenedCollections();
|
||||
|
||||
@@ -178,6 +179,10 @@ app.on('ready', async () => {
|
||||
return safeParseJSON(safeStringifyJSON(_));
|
||||
})]);
|
||||
}
|
||||
|
||||
// Handle onboarding
|
||||
await onboardUser(mainWindow, lastOpenedCollections);
|
||||
|
||||
// Send cookies list after renderer is ready
|
||||
try {
|
||||
cookiesStore.initializeCookies();
|
||||
@@ -186,6 +191,8 @@ app.on('ready', async () => {
|
||||
} catch (err) {
|
||||
console.error('Failed to load cookies for renderer', err);
|
||||
}
|
||||
|
||||
mainWindow.webContents.send('main:app-loaded');
|
||||
});
|
||||
|
||||
// register all ipc handlers
|
||||
|
||||
@@ -44,6 +44,9 @@ const defaultPreferences = {
|
||||
beta: {
|
||||
grpc: false,
|
||||
nodevm: false
|
||||
},
|
||||
onboarding: {
|
||||
hasLaunchedBefore: false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,6 +86,9 @@ const preferencesSchema = Yup.object().shape({
|
||||
beta: Yup.object({
|
||||
grpc: Yup.boolean(),
|
||||
nodevm: Yup.boolean()
|
||||
}),
|
||||
onboarding: Yup.object({
|
||||
hasLaunchedBefore: Yup.boolean()
|
||||
})
|
||||
});
|
||||
|
||||
@@ -176,6 +182,14 @@ const preferencesUtil = {
|
||||
},
|
||||
isBetaFeatureEnabled: (featureName) => {
|
||||
return get(getPreferences(), `beta.${featureName}`, false);
|
||||
},
|
||||
hasLaunchedBefore: () => {
|
||||
return get(getPreferences(), 'onboarding.hasLaunchedBefore', false);
|
||||
},
|
||||
markAsLaunched: () => {
|
||||
const preferences = getPreferences();
|
||||
preferences.onboarding.hasLaunchedBefore = true;
|
||||
preferencesStore.savePreferences(preferences);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
128
packages/bruno-electron/src/utils/collection-import.js
Normal file
128
packages/bruno-electron/src/utils/collection-import.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { ipcMain } = require('electron');
|
||||
const { sanitizeName, createDirectory, writeFile, safeWriteFileSync, getCollectionStats } = require('./filesystem');
|
||||
const { generateUidBasedOnHash, stringifyJson } = require('./common');
|
||||
const { stringifyRequestViaWorker, stringifyCollection, stringifyEnvironment, stringifyFolder } = require('@usebruno/filestore');
|
||||
|
||||
/**
|
||||
* Recursively find a unique folder name by appending incremental numbers
|
||||
*/
|
||||
async function findUniqueFolderName(baseName, collectionLocation, counter = 0) {
|
||||
const folderName = counter === 0 ? baseName : `${baseName} - ${counter}`;
|
||||
const collectionPath = path.join(collectionLocation, sanitizeName(folderName));
|
||||
|
||||
if (fs.existsSync(collectionPath)) {
|
||||
return findUniqueFolderName(baseName, collectionLocation, counter + 1);
|
||||
}
|
||||
|
||||
return folderName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a collection - shared logic used by both IPC handler and onboarding service
|
||||
*/
|
||||
async function importCollection(collection, collectionLocation, mainWindow, lastOpenedCollections, uniqueFolderName = null) {
|
||||
// Use provided unique folder name or use collection name
|
||||
let folderName = uniqueFolderName ? sanitizeName(uniqueFolderName) : sanitizeName(collection.name);
|
||||
let collectionPath = path.join(collectionLocation, folderName);
|
||||
|
||||
if (fs.existsSync(collectionPath)) {
|
||||
throw new Error(`collection: ${collectionPath} already exists`);
|
||||
}
|
||||
|
||||
// Recursive function to parse the collection items and create files/folders
|
||||
const parseCollectionItems = async (items = [], currentPath) => {
|
||||
for (const item of items) {
|
||||
if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) {
|
||||
let sanitizedFilename = sanitizeName(item.filename || `${item.name}.bru`);
|
||||
const content = await stringifyRequestViaWorker(item);
|
||||
const filePath = path.join(currentPath, sanitizedFilename);
|
||||
safeWriteFileSync(filePath, content);
|
||||
}
|
||||
if (item.type === 'folder') {
|
||||
let sanitizedFolderName = sanitizeName(item.filename || item.name);
|
||||
const folderPath = path.join(currentPath, sanitizedFolderName);
|
||||
fs.mkdirSync(folderPath);
|
||||
|
||||
if (item.root?.meta?.name) {
|
||||
const folderBruFilePath = path.join(folderPath, 'folder.bru');
|
||||
item.root.meta.seq = item.seq;
|
||||
const folderContent = await stringifyFolder(item.root);
|
||||
safeWriteFileSync(folderBruFilePath, folderContent);
|
||||
}
|
||||
|
||||
if (item.items && item.items.length) {
|
||||
await parseCollectionItems(item.items, folderPath);
|
||||
}
|
||||
}
|
||||
// Handle items of type 'js'
|
||||
if (item.type === 'js') {
|
||||
let sanitizedFilename = sanitizeName(item.filename || `${item.name}.js`);
|
||||
const filePath = path.join(currentPath, sanitizedFilename);
|
||||
safeWriteFileSync(filePath, item.fileContent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const parseEnvironments = async (environments = [], collectionPath) => {
|
||||
const envDirPath = path.join(collectionPath, 'environments');
|
||||
if (!fs.existsSync(envDirPath)) {
|
||||
fs.mkdirSync(envDirPath);
|
||||
}
|
||||
|
||||
for (const env of environments) {
|
||||
const content = await stringifyEnvironment(env);
|
||||
let sanitizedEnvFilename = sanitizeName(`${env.name}.bru`);
|
||||
const filePath = path.join(envDirPath, sanitizedEnvFilename);
|
||||
safeWriteFileSync(filePath, content);
|
||||
}
|
||||
};
|
||||
|
||||
const getBrunoJsonConfig = (collection) => {
|
||||
let brunoConfig = collection.brunoConfig;
|
||||
|
||||
if (!brunoConfig) {
|
||||
brunoConfig = {
|
||||
version: '1',
|
||||
name: collection.name,
|
||||
type: 'collection',
|
||||
ignore: ['node_modules', '.git']
|
||||
};
|
||||
}
|
||||
|
||||
return brunoConfig;
|
||||
};
|
||||
|
||||
await createDirectory(collectionPath);
|
||||
|
||||
const uid = generateUidBasedOnHash(collectionPath);
|
||||
let brunoConfig = getBrunoJsonConfig(collection);
|
||||
const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
|
||||
|
||||
// Write the Bruno configuration to a file
|
||||
await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
|
||||
|
||||
const collectionContent = await stringifyCollection(collection.root);
|
||||
await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
|
||||
|
||||
const { size, filesCount } = await getCollectionStats(collectionPath);
|
||||
brunoConfig.size = size;
|
||||
brunoConfig.filesCount = filesCount;
|
||||
|
||||
mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
|
||||
ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
|
||||
|
||||
lastOpenedCollections.add(collectionPath);
|
||||
|
||||
// create folder and files based on collection
|
||||
await parseCollectionItems(collection.items, collectionPath);
|
||||
await parseEnvironments(collection.environments, collectionPath);
|
||||
|
||||
return { collectionPath, uid, brunoConfig };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
importCollection,
|
||||
findUniqueFolderName
|
||||
};
|
||||
@@ -16,9 +16,10 @@ test.describe('Cross-Collection Drag and Drop for folder', () => {
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Create a folder in the first collection
|
||||
// Look for the collection menu button (usually three dots or similar)
|
||||
await page.locator('.collection-actions').hover();
|
||||
await page.locator('.collection-actions .icon').click();
|
||||
// Look for the collection menu button for the source collection specifically
|
||||
const sourceCollectionContainer1 = page.locator('.collection-name').filter({ hasText: 'source-collection' });
|
||||
await sourceCollectionContainer1.locator('.collection-actions').hover();
|
||||
await sourceCollectionContainer1.locator('.collection-actions .icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
|
||||
|
||||
// Fill folder name in the modal
|
||||
|
||||
10
tests/onboarding/init-user-data/preferences.json
Normal file
10
tests/onboarding/init-user-data/preferences.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/packages/bruno-tests/collection"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true
|
||||
}
|
||||
}
|
||||
}
|
||||
126
tests/onboarding/sample-collection.spec.ts
Normal file
126
tests/onboarding/sample-collection.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user