diff --git a/eslint.config.js b/eslint.config.js index 19439f54e..5b4569600 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,8 +20,8 @@ module.exports = runESMImports().then(() => defineConfig([ parser: require('@typescript-eslint/parser'), parserOptions: { ecmaVersion: 'latest', - sourceType: 'module', - }, + sourceType: 'module' + } }, files: [ './eslint.config.js', @@ -44,11 +44,11 @@ module.exports = runESMImports().then(() => defineConfig([ indent: 2, quotes: 'single', semi: true, - arrowParens: false, jsx: true, }).rules, + '@stylistic/comma-dangle': ['error', 'never'], '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }], - '@stylistic/arrow-parens': ['error', 'as-needed'], + '@stylistic/arrow-parens': ['error', 'always'], '@stylistic/curly-newline': ['error', { multiline: true, minElements: 2, @@ -60,6 +60,7 @@ module.exports = runESMImports().then(() => defineConfig([ '@stylistic/function-call-spacing': ['error', 'never'], '@stylistic/multiline-ternary': ['off'], '@stylistic/padding-line-between-statements': ['off'], + '@stylistic/jsx-one-expression-per-line': ['off'], '@stylistic/semi-style': ['error', 'last'], '@stylistic/max-len': ['off'], '@stylistic/jsx-one-expression-per-line': ['off'], diff --git a/package-lock.json b/package-lock.json index 1e239eb97..c8d786cd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20286,6 +20286,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidusage": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz", + "integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pify": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", @@ -30248,6 +30260,7 @@ "lodash": "^4.17.21", "mime-types": "^2.1.35", "nanoid": "3.3.8", + "pidusage": "^4.0.1", "qs": "^6.11.0", "socks-proxy-agent": "^8.0.2", "tough-cookie": "^6.0.0", diff --git a/packages/bruno-app/src/components/Devtools/Console/index.js b/packages/bruno-app/src/components/Devtools/Console/index.js index e87e38d37..5705eecb4 100644 --- a/packages/bruno-app/src/components/Devtools/Console/index.js +++ b/packages/bruno-app/src/components/Devtools/Console/index.js @@ -12,7 +12,8 @@ import { IconCode, IconChevronDown, IconTerminal2, - IconNetwork + IconNetwork, + IconDashboard, } from '@tabler/icons'; import { closeConsole, @@ -24,10 +25,12 @@ import { updateNetworkFilter, toggleAllNetworkFilters } from 'providers/ReduxStore/slices/logs'; + import NetworkTab from './NetworkTab'; import RequestDetailsPanel from './RequestDetailsPanel'; // import DebugTab from './DebugTab'; import ErrorDetailsPanel from './ErrorDetailsPanel'; +import Performance from '../Performance'; import StyledWrapper from './StyledWrapper'; const LogIcon = ({ type }) => { @@ -384,6 +387,8 @@ const Console = () => { ); case 'network': return ; + case 'performance': + return ; // case 'debug': // return ; default: @@ -484,6 +489,14 @@ const Console = () => { Network + + {/* diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index 3ad176bd4..1f92f0610 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -26,6 +26,7 @@ import { isElectron } from 'utils/common/platform'; import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments'; import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index'; import { addLog } from 'providers/ReduxStore/slices/logs'; +import { updateSystemResources } from 'providers/ReduxStore/slices/performance'; const useIpcEvents = () => { const dispatch = useDispatch(); @@ -145,6 +146,10 @@ const useIpcEvents = () => { })); }); + const removeSystemResourcesListener = ipcRenderer.on('main:filesync-system-resources', resourceData => { + dispatch(updateSystemResources(resourceData)); + }); + const removeConfigUpdatesListener = ipcRenderer.on('main:bruno-config-update', (val) => dispatch(brunoConfigUpdateEvent(val)) ); @@ -209,6 +214,7 @@ const useIpcEvents = () => { removeCollectionOauth2CredentialsUpdatesListener(); removeCollectionLoadingStateListener(); removePersistentEnvVariablesUpdateListener(); + removeSystemResourcesListener(); }; }, [isElectron]); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js index 8ed528073..d5abee753 100644 --- a/packages/bruno-app/src/providers/ReduxStore/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/index.js @@ -7,6 +7,7 @@ import tabsReducer from './slices/tabs'; import notificationsReducer from './slices/notifications'; import globalEnvironmentsReducer from './slices/global-environments'; import logsReducer from './slices/logs'; +import performanceReducer from './slices/performance'; import { draftDetectMiddleware } from './middlewares/draft/middleware'; const isDevEnv = () => { @@ -25,7 +26,8 @@ export const store = configureStore({ tabs: tabsReducer, notifications: notificationsReducer, globalEnvironments: globalEnvironmentsReducer, - logs: logsReducer + logs: logsReducer, + performance: performanceReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware) }); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index 3269d6a24..7fe639bd1 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -25,6 +25,9 @@ const initialState = { font: { codeFont: 'default' }, + general: { + defaultCollectionLocation: '' + }, beta: { grpc: false, websocket: false, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 822414590..a114776c5 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -133,6 +133,13 @@ export const collectionsSlice = createSlice({ state.collections.push(collection); } }, + collapseFullCollection: (state, action) => { + const { collectionUid } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (collection) { + collapseAllItemsInCollection(collection); + } + }, updateCollectionMountStatus: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); if (collection) { @@ -2931,6 +2938,7 @@ export const { saveRequest, deleteRequestDraft, newEphemeralHttpRequest, + collapseFullCollection, toggleCollection, toggleCollectionItem, requestUrlChanged, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/performance.js b/packages/bruno-app/src/providers/ReduxStore/slices/performance.js new file mode 100644 index 000000000..efd7b01d3 --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/performance.js @@ -0,0 +1,28 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + systemResources: { + cpu: 0, + memory: 0, + pid: null, + uptime: 0, + lastUpdated: null, + }, +}; + +export const performanceSlice = createSlice({ + name: 'performance', + initialState, + reducers: { + updateSystemResources: (state, action) => { + state.systemResources = { + ...state.systemResources, + ...action.payload, + lastUpdated: new Date().toISOString(), + }; + }, + }, +}); + +export const { updateSystemResources } = performanceSlice.actions; +export default performanceSlice.reducer; diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js index 3d9593c44..a91fa706e 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js @@ -12,31 +12,130 @@ let CodeMirror; const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; const { get } = require('lodash'); -if (!SERVER_RENDERED) { - CodeMirror = require('codemirror'); +const COPY_ICON_SVG_TEXT = ` + + + + +`; - const renderVarInfo = (token, options, cm, pos) => { - // Extract variable name and value based on token - const { variableName, variableValue } = extractVariableInfo(token.string, options.variables); +const CHECKMARK_ICON_SVG_TEXT = ` + + + +`; - if (variableValue === undefined) { +const COPY_SUCCESS_COLOR = '#22c55e'; + +export const COPY_SUCCESS_TIMEOUT = 1000; + +const getCopyButton = variableValue => { + const copyButton = document.createElement('button'); + + copyButton.className = 'copy-button'; + copyButton.style.backgroundColor = 'transparent'; + copyButton.style.border = 'none'; + copyButton.style.color = 'inherit'; + copyButton.style.cursor = 'pointer'; + copyButton.style.padding = '2px'; + copyButton.style.opacity = '0.7'; + copyButton.style.transition = 'opacity 0.2s ease'; + copyButton.style.display = 'flex'; + copyButton.style.alignItems = 'center'; + copyButton.style.justifyContent = 'center'; + + copyButton.innerHTML = COPY_ICON_SVG_TEXT; + + let isCopied = false; + + copyButton.addEventListener('mouseenter', () => { + if (isCopied) { return; } - const into = document.createElement('div'); - const descriptionDiv = document.createElement('div'); - descriptionDiv.className = 'info-description'; + copyButton.style.opacity = '1'; + }); - if (options?.variables?.maskedEnvVariables?.includes(variableName)) { - descriptionDiv.appendChild(document.createTextNode('*****')); - } else { - descriptionDiv.appendChild(document.createTextNode(variableValue)); + copyButton.addEventListener('mouseleave', () => { + if (isCopied) { + return; } - into.appendChild(descriptionDiv); + copyButton.style.opacity = '0.7'; + }); - return into; - }; + copyButton.addEventListener('click', e => { + e.stopPropagation(); + + // Prevent clicking if showing success checkmark + if (isCopied) { + return; + } + + navigator.clipboard + .writeText(variableValue) + .then(() => { + isCopied = true; + copyButton.innerHTML = CHECKMARK_ICON_SVG_TEXT; + copyButton.style.opacity = '1'; + copyButton.style.color = COPY_SUCCESS_COLOR; + copyButton.style.cursor = 'default'; + copyButton.classList.add('copy-success'); + + setTimeout(() => { + isCopied = false; + copyButton.innerHTML = COPY_ICON_SVG_TEXT; + copyButton.style.opacity = '0.7'; + copyButton.style.color = 'inherit'; + copyButton.style.cursor = 'pointer'; + copyButton.classList.remove('copy-success'); + }, COPY_SUCCESS_TIMEOUT); + }) + .catch(err => { + console.error('Failed to copy to clipboard:', err.message); + }); + }); + + return copyButton; +}; + +export const renderVarInfo = (token, options, cm, pos) => { + // Extract variable name and value based on token + const { variableName, variableValue } = extractVariableInfo(token.string, options.variables); + + if (variableValue === undefined) { + return; + } + + const into = document.createElement('div'); + + const contentDiv = document.createElement('div'); + contentDiv.style.display = 'flex'; + contentDiv.style.alignItems = 'center'; + contentDiv.style.gap = '8px'; + contentDiv.className = 'info-content'; + + const descriptionDiv = document.createElement('div'); + descriptionDiv.className = 'info-description'; + descriptionDiv.style.flex = '1'; + + if (options?.variables?.maskedEnvVariables?.includes(variableName)) { + descriptionDiv.appendChild(document.createTextNode('*****')); + } else { + descriptionDiv.appendChild(document.createTextNode(variableValue)); + } + + const copyButton = getCopyButton(variableValue); + + contentDiv.appendChild(descriptionDiv); + contentDiv.appendChild(copyButton); + into.appendChild(contentDiv); + + return into; +}; + +if (!SERVER_RENDERED) { + CodeMirror = require('codemirror'); CodeMirror.defineOption('brunoVarInfo', false, function (cm, options, old) { if (old && old !== CodeMirror.Init) { diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js index 5002097c2..0a2a161dc 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js @@ -1,5 +1,5 @@ import { interpolate } from '@usebruno/common'; -import { extractVariableInfo } from './brunoVarInfo'; +import { COPY_SUCCESS_TIMEOUT, extractVariableInfo, renderVarInfo } from './brunoVarInfo'; // Mock the dependencies jest.mock('@usebruno/common', () => ({ @@ -225,3 +225,120 @@ describe('extractVariableInfo', () => { }); }); }); + +describe('renderVarInfo', () => { + let clipboardText = ''; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + // setup mock clipboard + clipboardText = ''; + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: jest.fn(text => { + if (text === 'cause-clipboard-error') { + return Promise.reject(new Error('Clipboard error')); + } + + clipboardText = text; + + return Promise.resolve(); + }), + }, + configurable: true, + }); + + // mock console.error + console.error = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function setupRender(variables) { + const result = renderVarInfo({ string: '{{apiKey}}' }, { variables }); + const contentDiv = result.querySelector('.info-content'); + const descriptionDiv = contentDiv.querySelector('.info-description'); + const copyButton = contentDiv.querySelector('.copy-button'); + + return { result, contentDiv, descriptionDiv, copyButton }; + } + + describe('popup functionality', () => { + it('should create a popup', () => { + const { result } = setupRender({ apiKey: 'test-value' }); + + expect(result).toBeDefined(); + }); + + it('should create a popup with the correct variable name and value', () => { + const { descriptionDiv } = setupRender({ apiKey: 'test-value' }); + + expect(descriptionDiv.textContent).toBe('test-value'); + }); + + it('should correctly mask the variable value in the popup', () => { + const { descriptionDiv } = setupRender({ + apiKey: 'test-value', + maskedEnvVariables: ['apiKey'], + }); + + expect(descriptionDiv.textContent).toBe('*****'); + }); + }); + + describe('copy button functionality', () => { + it('should create a copy button', () => { + const { copyButton } = setupRender({ apiKey: 'test-value' }); + + expect(copyButton).toBeDefined(); + }); + + it('should copy the variable value to the clipboard', async () => { + const { copyButton } = setupRender({ apiKey: 'test-value' }); + + await copyButton.click(); + + expect(clipboardText).toBe('test-value'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value'); + }); + + it('should copy the variable value of masked variables to the clipboard', async () => { + const { copyButton } = setupRender({ apiKey: 'test-value', maskedEnvVariables: ['apiKey'] }); + + await copyButton.click(); + + expect(clipboardText).toBe('test-value'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value'); + }); + + it('should show a success checkmark when the variable value is copied', async () => { + const { copyButton } = setupRender({ apiKey: 'test-value' }); + + expect(copyButton.classList.contains('copy-success')).toBe(false); + + await copyButton.click(); + + expect(copyButton.classList.contains('copy-success')).toBe(true); + + jest.advanceTimersByTime(COPY_SUCCESS_TIMEOUT); + + expect(copyButton.classList.contains('copy-success')).toBe(false); + }); + + it('should log to the console when the variable value is not copied', async () => { + const { copyButton } = setupRender({ apiKey: 'cause-clipboard-error' }); + + await copyButton.click(); + + // wait for .catch() microtask to run + await Promise.resolve(); + + expect(clipboardText).toBe(''); + expect(console.error).toHaveBeenCalledWith('Failed to copy to clipboard:', 'Clipboard error'); + }); + }); +}); diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index 56ff911a4..884f5dc14 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -66,6 +66,7 @@ "lodash": "^4.17.21", "mime-types": "^2.1.35", "nanoid": "3.3.8", + "pidusage": "^4.0.1", "qs": "^6.11.0", "socks-proxy-agent": "^8.0.2", "tough-cookie": "^6.0.0", diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js index a6b7a178c..46ea976ef 100644 --- a/packages/bruno-electron/src/app/collections.js +++ b/packages/bruno-electron/src/app/collections.js @@ -42,15 +42,36 @@ const getCollectionConfigFile = async (pathname) => { }; const openCollectionDialog = async (win, watcher) => { - const { filePaths } = await dialog.showOpenDialog(win, { - properties: ['openDirectory', 'createDirectory'] + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + properties: ['openDirectory', 'createDirectory', 'multiSelections'] }); - if (filePaths && filePaths[0]) { - const resolvedPath = path.resolve(filePaths[0]); - if (isDirectory(resolvedPath)) { - openCollection(win, watcher, resolvedPath); - } else { - console.error(`[ERROR] Cannot open unknown folder: "${resolvedPath}"`); + + if (!canceled && filePaths?.length > 0) { + // Using Set to remove duplicates + const { openCollectionPromises, invalidPaths } = [...new Set(filePaths)].reduce((acc, filePath) => { + const resolvedPath = path.resolve(filePath); + + if (isDirectory(resolvedPath)) { + // Open each valid collection in parallel + acc.openCollectionPromises.push(openCollection(win, watcher, resolvedPath).catch((err) => { + console.error(`[ERROR] Failed to open collection at "${resolvedPath}":`, err.message); + return { error: err, path: resolvedPath }; + })); + } else { + acc.invalidPaths.push(resolvedPath); + console.error(`[ERROR] Cannot open unknown folder: "${resolvedPath}"`); + } + + return acc; + }, + { openCollectionPromises: [], invalidPaths: [] }); + + // Wait for all valid collections to be opened + await Promise.all(openCollectionPromises); + + // Notify about any invalid paths + if (invalidPaths.length > 0) { + win.webContents.send('main:display-error', `Some selected folders could not be opened: ${invalidPaths.join(', ')}`); } } }; @@ -78,7 +99,7 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => { } catch (err) { if (!options.dontSendDisplayErrors) { win.webContents.send('main:display-error', { - error: err.message || 'An error occurred while opening the local collection' + message: err.message || 'An error occurred while opening the local collection' }); } } diff --git a/packages/bruno-electron/src/app/system-monitor.js b/packages/bruno-electron/src/app/system-monitor.js new file mode 100644 index 000000000..48fc27852 --- /dev/null +++ b/packages/bruno-electron/src/app/system-monitor.js @@ -0,0 +1,71 @@ +const pidusage = require('pidusage'); + +class SystemMonitor { + constructor() { + this.intervalId = null; + this.isMonitoring = false; + this.startTime = Date.now(); + } + + start(win, intervalMs = 2000) { + if (this.isMonitoring) { + return; + } + + this.isMonitoring = true; + this.startTime = Date.now(); + + // Emit initial stats + this.emitSystemStats(win); + + // Set up periodic monitoring + this.intervalId = setInterval(() => { + this.emitSystemStats(win); + }, intervalMs); + } + + stop() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + this.isMonitoring = false; + } + + async emitSystemStats(win) { + try { + const pid = process.pid; + const stats = await pidusage(pid); + const uptime = (Date.now() - this.startTime) / 1000; + + const systemResources = { + cpu: stats.cpu || 0, + memory: stats.memory || 0, + pid: pid, + uptime: uptime, + timestamp: new Date().toISOString(), + }; + + win.webContents.send('main:filesync-system-resources', systemResources); + } catch (error) { + console.error('Error getting system stats:', error); + + // Fallback stats if pidusage fails + const fallbackStats = { + cpu: 0, + memory: process.memoryUsage().rss, + pid: process.pid, + uptime: (Date.now() - this.startTime) / 1000, + timestamp: new Date().toISOString(), + }; + + win.webContents.send('main:filesync-system-resources', fallbackStats); + } + } + + isRunning() { + return this.isMonitoring; + } +} + +module.exports = SystemMonitor; diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 267f3fb5e..c36d60ea7 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -45,8 +45,10 @@ const { safeParseJSON, safeStringifyJSON } = require('./utils/common'); const { getDomainsWithCookies } = require('./utils/cookies'); const { cookiesStore } = require('./store/cookies'); const onboardUser = require('./app/onboarding'); +const SystemMonitor = require('./app/system-monitor'); const lastOpenedCollections = new LastOpenedCollections(); +const systemMonitor = new SystemMonitor(); // Reference: https://content-security-policy.com/ const contentSecurityPolicy = [ @@ -202,6 +204,9 @@ app.on('ready', async () => { } mainWindow.webContents.send('main:app-loaded'); + + // Start system monitoring for FileSync + systemMonitor.start(mainWindow); }); // register all ipc handlers @@ -220,6 +225,9 @@ app.on('before-quit', () => { } catch (err) { console.warn('Failed to flush cookies on quit', err); } + + // Stop system monitoring + systemMonitor.stop(); }); app.on('window-all-closed', app.quit); diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index 004d0e860..586d9bf95 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -47,6 +47,9 @@ const defaultPreferences = { }, onboarding: { hasLaunchedBefore: false + }, + general: { + defaultCollectionLocation: '' } }; @@ -89,6 +92,9 @@ const preferencesSchema = Yup.object().shape({ }), onboarding: Yup.object({ hasLaunchedBefore: Yup.boolean() + }), + general: Yup.object({ + defaultCollectionLocation: Yup.string().max(1024).nullable() }) }); diff --git a/tests/collection/moving-requests/tag-persistence.spec.ts b/tests/collection/moving-requests/tag-persistence.spec.ts index 94ae26526..c96a6f50b 100644 --- a/tests/collection/moving-requests/tag-persistence.spec.ts +++ b/tests/collection/moving-requests/tag-persistence.spec.ts @@ -1,6 +1,12 @@ import { test, expect } from '../../../playwright'; +import { closeAllCollections } from '../../utils/page'; test.describe('Tag persistence', () => { + test.afterAll(async ({ pageWithUserData: page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); + test('Verify tag persistence while moving requests within a collection', async ({ pageWithUserData: page, createTmpDir }) => { // Create first collection - click dropdown menu first await page.getByLabel('Create Collection').click(); diff --git a/tests/collection/open/open-multiple-collections.spec.ts b/tests/collection/open/open-multiple-collections.spec.ts new file mode 100644 index 000000000..2647d6ef3 --- /dev/null +++ b/tests/collection/open/open-multiple-collections.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '../../../playwright'; +import * as path from 'path'; +import * as fs from 'fs'; + +import { closeAllCollections } from '../../utils/page'; + +test.describe('Open Multiple Collections', () => { + let originalShowOpenDialog; + + test.beforeAll(async ({ electronApp }) => { + // save the original showOpenDialog function + await electronApp.evaluate(({ dialog }) => { + originalShowOpenDialog = dialog.showOpenDialog; + }); + }); + + test.afterAll(async ({ electronApp }) => { + // restore the original showOpenDialog function + await electronApp.evaluate(({ dialog }) => { + dialog.showOpenDialog = originalShowOpenDialog; + }); + }); + + test('Should open multiple collections using Open Collection feature', async ({ + page, + electronApp, + createTmpDir + }) => { + // Create two test collections with proper bruno.json files + const collection1Dir = await createTmpDir('collection-1'); + const collection2Dir = await createTmpDir('collection-2'); + + // Create bruno.json for first collection + const collection1Config = { + version: '1', + name: 'Test Collection 1', + type: 'collection' + }; + // Create bruno.json for second collection + const collection2Config = { + version: '1', + name: 'Test Collection 2', + type: 'collection' + }; + + fs.writeFileSync(path.join(collection1Dir, 'bruno.json'), JSON.stringify(collection1Config, null, 2)); + fs.writeFileSync(path.join(collection2Dir, 'bruno.json'), JSON.stringify(collection2Config, null, 2)); + + // Mock the electron dialog to return multiple folder selections + await electronApp.evaluate(({ dialog }, { collection1Dir, collection2Dir }) => { + dialog.showOpenDialog = async () => ({ + canceled: false, + filePaths: [collection1Dir, collection2Dir] + }); + }, + { collection1Dir, collection2Dir }); + + await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible(); + + // Click on Open Collection(s) button + await page.getByRole('button', { name: 'Open Collection' }).click(); + + // Wait for both collections to appear in the sidebar + const collection1Element = page.locator('#sidebar-collection-name').getByText('Test Collection 1'); + const collection2Element = page.locator('#sidebar-collection-name').getByText('Test Collection 2'); + + await expect(collection1Element).toBeVisible(); + await expect(collection2Element).toBeVisible(); + + // cleanup: close all collections + await closeAllCollections(page); + }); + + test('Should handle invalid collection path and display error', async ({ + page, + electronApp, + createTmpDir + }) => { + // Directory without bruno.json file + const collection1Dir = await createTmpDir('collection-1'); + const collection2Dir = 'invalid-collection-path'; + + // Mock the electron dialog to return multiple folder selections + await electronApp.evaluate(({ dialog }, { collection1Dir, collection2Dir }) => { + dialog.showOpenDialog = async () => ({ + canceled: false, + filePaths: [collection1Dir, collection2Dir] + }); + }, + { collection1Dir, collection2Dir }); + + await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible(); + + // Click on Open Collection(s) button + await page.getByRole('button', { name: 'Open Collection' }).click(); + + // Verify no collections were opened + await expect(page.locator('#sidebar-collection-name')).toHaveCount(0); + + // Verify invalid collection error + const invalidCollectionError = page.getByText('The collection is not valid (bruno.json not found)').first(); + await expect(invalidCollectionError).toBeVisible(); + + // Verify invalid path error + const invalidPathError = page.getByText('Some selected folders could not be opened').getByText('invalid-collection-path').first(); + await expect(invalidPathError).toBeVisible(); + }); +}); diff --git a/tests/preferences/default-collection-location/collection/bruno.json b/tests/preferences/default-collection-location/collection/bruno.json new file mode 100644 index 000000000..03a26e1f7 --- /dev/null +++ b/tests/preferences/default-collection-location/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "collection", + "type": "collection" +} diff --git a/tests/preferences/default-collection-location/collection/collection.bru b/tests/preferences/default-collection-location/collection/collection.bru new file mode 100644 index 000000000..408d3bd10 --- /dev/null +++ b/tests/preferences/default-collection-location/collection/collection.bru @@ -0,0 +1,5 @@ +meta { + name: collection + type: collection + version: 1.0.0 +} \ No newline at end of file diff --git a/tests/preferences/default-collection-location/collection/environments/Test.bru b/tests/preferences/default-collection-location/collection/environments/Test.bru new file mode 100644 index 000000000..e597f9c24 --- /dev/null +++ b/tests/preferences/default-collection-location/collection/environments/Test.bru @@ -0,0 +1,3 @@ +vars { + host: https://www.httpfaker.org +} diff --git a/tests/preferences/default-collection-location/collection/request.bru b/tests/preferences/default-collection-location/collection/request.bru new file mode 100644 index 000000000..baa0764c4 --- /dev/null +++ b/tests/preferences/default-collection-location/collection/request.bru @@ -0,0 +1,11 @@ +meta { + name: request + type: http + seq: 1 +} + +post { + url: {{host}}/api/echo + body: text + auth: none +} \ No newline at end of file diff --git a/tests/preferences/default-collection-location/default-collection-location.spec.js b/tests/preferences/default-collection-location/default-collection-location.spec.js new file mode 100644 index 000000000..8194d854c --- /dev/null +++ b/tests/preferences/default-collection-location/default-collection-location.spec.js @@ -0,0 +1,84 @@ +import { test, expect } from '../../../playwright'; + +test.describe('Default Collection Location Feature', () => { + test('Should hydrate the default location from preferences', async ({ pageWithUserData: page }) => { + // open preferences + await page.locator('.preferences-button').click(); + + // verify the default location is pre-filled + const defaultLocationInput = page.locator('.default-collection-location-input'); + await expect(defaultLocationInput).toHaveValue('/tmp/bruno-collections'); + + // close the preferences + await page.locator('[data-test-id="modal-close-button"]').click(); + + // wait for 2 seconds + await page.waitForTimeout(2000); + }); + + test('Should save empty default location', async ({ pageWithUserData: page }) => { + // open preferences + await page.locator('.preferences-button').click(); + + // clear the default location field + const defaultLocationInput = page.locator('.default-collection-location-input'); + await defaultLocationInput.clear(); + + // save preferences + await page.getByRole('button', { name: 'Save' }).click(); + + // verify success message + await expect(page.locator('text=Preferences saved successfully')).toBeVisible(); + + // wait for 2 seconds + await page.waitForTimeout(2000); + }); + + test('Should save a valid default location', async ({ pageWithUserData: page }) => { + // open preferences + await page.locator('.preferences-button').click(); + + // set a default location + const defaultLocationInput = page.locator('.default-collection-location-input'); + + // fill the default location input + await defaultLocationInput.fill('/tmp/bruno-collections'); + + // save preferences + await page.getByRole('button', { name: 'Save' }).click(); + + // verify success message + await expect(page.locator('text=Preferences saved successfully')).toBeVisible(); + + // wait for 2 seconds + await page.waitForTimeout(2000); + }); + + test('Should use default location in Create Collection modal', async ({ pageWithUserData: page }) => { + // test Create Collection modal + await page.locator('[data-testid="create-collection"]').click(); + + // verify the default location is pre-filled + const collectionLocationInput = page.getByLabel('Location'); + await expect(collectionLocationInput).toHaveValue('/tmp/bruno-collections'); + + // cancel the collection creation + await page.getByRole('button', { name: 'Cancel' }).click(); + + // wait for 2 seconds + await page.waitForTimeout(2000); + }); + + test('Should use default location in Clone Collection modal', async ({ pageWithUserData: page }) => { + // open the clone collection modal + await page.locator('[data-testid="collection-actions"]').click(); + await page.getByTestId('clone-collection').click(); + + // verify the default location is pre-filled + const cloneLocationInput = page.getByLabel('Location'); + await expect(cloneLocationInput).toHaveValue('/tmp/bruno-collections'); + + // wait for 2 seconds + await page.waitForTimeout(2000); + }); +}); diff --git a/tests/preferences/default-collection-location/init-user-data/collection-security.json b/tests/preferences/default-collection-location/init-user-data/collection-security.json new file mode 100644 index 000000000..e60afe806 --- /dev/null +++ b/tests/preferences/default-collection-location/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/preferences/default-collection-location/collection", + "securityConfig": { + "jsSandboxMode": "developer" + } + } + ] +} \ No newline at end of file diff --git a/tests/preferences/default-collection-location/init-user-data/preferences.json b/tests/preferences/default-collection-location/init-user-data/preferences.json new file mode 100644 index 000000000..fa1553037 --- /dev/null +++ b/tests/preferences/default-collection-location/init-user-data/preferences.json @@ -0,0 +1,9 @@ +{ + "maximized": true, + "lastOpenedCollections": ["{{projectRoot}}/tests/preferences/default-collection-location/collection"], + "preferences": { + "general": { + "defaultCollectionLocation": "/tmp/bruno-collections" + } + } +} diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts new file mode 100644 index 000000000..e9f970488 --- /dev/null +++ b/tests/utils/page/actions.ts @@ -0,0 +1,11 @@ +const closeAllCollections = async (page) => { + const numberOfCollections = await page.locator('.collection-name').count(); + + for (let i = 0; i < numberOfCollections; i++) { + await page.locator('.collection-name').first().locator('.collection-actions').click(); + await page.locator('.dropdown-item').getByText('Close').click(); + await page.getByRole('button', { name: 'Close' }).click(); + } +}; + +export { closeAllCollections }; diff --git a/tests/utils/page/index.ts b/tests/utils/page/index.ts new file mode 100644 index 000000000..485f1b10a --- /dev/null +++ b/tests/utils/page/index.ts @@ -0,0 +1 @@ +export * from './actions'; diff --git a/tests/utils/pageUtils/actions.js b/tests/utils/page/navigation.ts similarity index 100% rename from tests/utils/pageUtils/actions.js rename to tests/utils/page/navigation.ts diff --git a/tests/utils/pageUtils/index.js b/tests/utils/pageUtils/index.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/utils/pageUtils/navigation.js b/tests/utils/pageUtils/navigation.js deleted file mode 100644 index e69de29bb..000000000