From da1823f2b300a216fbba1b13e1a1b1cde7e9e07c Mon Sep 17 00:00:00 2001 From: Abhishek S Lal Date: Fri, 13 Mar 2026 11:39:38 +0530 Subject: [PATCH 1/6] fix(collection-watcher): guard against events firing after collection deletion When deleting an OpenAPI-synced collection, saveBrunoConfig() writes to bruno.json which creates buffered chokidar events (80ms stabilityThreshold). If the collection directory is removed before those events fire, getCollectionFormat() throws "No collection configuration found" for each .bru file in the collection. Add fs.existsSync(collectionPath) guards in the change, unlink, and unlinkDir handlers to bail out early when the collection root no longer exists. --- packages/bruno-electron/src/app/collection-watcher.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js index a1f5621c24..e94b2c4bc8 100644 --- a/packages/bruno-electron/src/app/collection-watcher.js +++ b/packages/bruno-electron/src/app/collection-watcher.js @@ -421,6 +421,7 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => { }; const change = async (win, pathname, collectionUid, collectionPath) => { + if (!fs.existsSync(collectionPath)) return; if (isBrunoConfigFile(pathname, collectionPath)) { try { const content = fs.readFileSync(pathname, 'utf8'); @@ -552,6 +553,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => { }; const unlink = (win, pathname, collectionUid, collectionPath) => { + if (!fs.existsSync(collectionPath)) return; console.log(`watcher unlink: ${pathname}`); if (isEnvironmentsFolder(pathname, collectionPath)) { @@ -579,6 +581,7 @@ const unlink = (win, pathname, collectionUid, collectionPath) => { }; const unlinkDir = async (win, pathname, collectionUid, collectionPath) => { + if (!fs.existsSync(collectionPath)) return; const envDirectory = path.join(collectionPath, 'environments'); if (path.normalize(pathname) === path.normalize(envDirectory)) { From c26dfcedf180ba98b7348fb70b5581a00a5dc036 Mon Sep 17 00:00:00 2001 From: Abhishek S Lal Date: Fri, 13 Mar 2026 14:47:53 +0530 Subject: [PATCH 2/6] fix(workspaces): ensure collection watcher stops before deletion Added logic to remove the collection from the watcher when deleting files, preventing chokidar from firing events on a directory that is being removed. This change enhances stability during collection deletions by ensuring the watcher is properly managed. --- .../ReduxStore/slices/workspaces/actions.js | 8 ++ .../src/app/collection-watcher.js | 89 ++++++++++--------- 2 files changed, 55 insertions(+), 42 deletions(-) diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index 5761d605d0..61f98f53cc 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -158,6 +158,14 @@ export const removeCollectionFromWorkspaceAction = (workspaceUid, collectionPath (c) => normalizePath(c.pathname) === normalizedCollectionPath ); + // Remove collection from the watcher + // When deleting files, stop the collection watcher first to prevent + // chokidar from firing events on a directory that's being removed + if (deleteFiles && collection) { + const workspaceId = workspace.pathname || workspace.uid || 'default'; + await ipcRenderer.invoke('renderer:remove-collection', collection.pathname, collection.uid, workspaceId); + } + await ipcRenderer.invoke('renderer:remove-collection-from-workspace', workspaceUid, workspace.pathname, diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js index e94b2c4bc8..efacc1a524 100644 --- a/packages/bruno-electron/src/app/collection-watcher.js +++ b/packages/bruno-electron/src/app/collection-watcher.js @@ -421,7 +421,6 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => { }; const change = async (win, pathname, collectionUid, collectionPath) => { - if (!fs.existsSync(collectionPath)) return; if (isBrunoConfigFile(pathname, collectionPath)) { try { const content = fs.readFileSync(pathname, 'utf8'); @@ -553,60 +552,66 @@ const change = async (win, pathname, collectionUid, collectionPath) => { }; const unlink = (win, pathname, collectionUid, collectionPath) => { - if (!fs.existsSync(collectionPath)) return; - console.log(`watcher unlink: ${pathname}`); - - if (isEnvironmentsFolder(pathname, collectionPath)) { - return unlinkEnvironmentFile(win, pathname, collectionUid); - } - - const format = getCollectionFormat(collectionPath); - if (hasRequestExtension(pathname, format)) { - const basename = path.basename(pathname); - const dirname = path.dirname(pathname); + try { + console.log(`watcher unlink: ${pathname}`); - if (basename === 'opencollection.yml' && path.normalize(dirname) === path.normalize(collectionPath)) { - return; + if (isEnvironmentsFolder(pathname, collectionPath)) { + return unlinkEnvironmentFile(win, pathname, collectionUid); } - const file = { - meta: { - collectionUid, - pathname, - name: basename + const format = getCollectionFormat(collectionPath); + if (hasRequestExtension(pathname, format)) { + const basename = path.basename(pathname); + const dirname = path.dirname(pathname); + + if (basename === 'opencollection.yml' && path.normalize(dirname) === path.normalize(collectionPath)) { + return; } - }; - win.webContents.send('main:collection-tree-updated', 'unlink', file); + + const file = { + meta: { + collectionUid, + pathname, + name: basename + } + }; + win.webContents.send('main:collection-tree-updated', 'unlink', file); + } + } catch (err) { + console.error(`Error processing unlink event for: ${pathname}`, err); } }; const unlinkDir = async (win, pathname, collectionUid, collectionPath) => { - if (!fs.existsSync(collectionPath)) return; - const envDirectory = path.join(collectionPath, 'environments'); - - if (path.normalize(pathname) === path.normalize(envDirectory)) { - return; - } + try { + const envDirectory = path.join(collectionPath, 'environments'); - const format = getCollectionFormat(collectionPath); - const folderFilePath = path.join(pathname, `folder.${format}`); + if (path.normalize(pathname) === path.normalize(envDirectory)) { + return; + } - let name = path.basename(pathname); + const format = getCollectionFormat(collectionPath); + const folderFilePath = path.join(pathname, `folder.${format}`); - if (fs.existsSync(folderFilePath)) { - let folderFileContent = fs.readFileSync(folderFilePath, 'utf8'); - let folderData = await parseFolder(folderFileContent, { format }); - name = folderData?.meta?.name || name; - } + let name = path.basename(pathname); - const directory = { - meta: { - collectionUid, - pathname, - name + if (fs.existsSync(folderFilePath)) { + let folderFileContent = fs.readFileSync(folderFilePath, 'utf8'); + let folderData = await parseFolder(folderFileContent, { format }); + name = folderData?.meta?.name || name; } - }; - win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory); + + const directory = { + meta: { + collectionUid, + pathname, + name + } + }; + win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory); + } catch (err) { + console.error(`Error processing unlinkDir event for: ${pathname}`, err); + } }; const onWatcherSetupComplete = (win, watchPath, collectionUid, watcher) => { From 1ad7ac9c03f02b47a71d189a35ea831870e32367 Mon Sep 17 00:00:00 2001 From: Abhishek S Lal Date: Fri, 13 Mar 2026 15:05:55 +0530 Subject: [PATCH 3/6] refactor(workspaces): remove redundant collection watcher logic during deletion Eliminated the logic for stopping the collection watcher before deletion, streamlining the action for removing collections from workspaces. This change simplifies the code and maintains functionality without unnecessary complexity. --- .../src/providers/ReduxStore/slices/workspaces/actions.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index 61f98f53cc..5761d605d0 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -158,14 +158,6 @@ export const removeCollectionFromWorkspaceAction = (workspaceUid, collectionPath (c) => normalizePath(c.pathname) === normalizedCollectionPath ); - // Remove collection from the watcher - // When deleting files, stop the collection watcher first to prevent - // chokidar from firing events on a directory that's being removed - if (deleteFiles && collection) { - const workspaceId = workspace.pathname || workspace.uid || 'default'; - await ipcRenderer.invoke('renderer:remove-collection', collection.pathname, collection.uid, workspaceId); - } - await ipcRenderer.invoke('renderer:remove-collection-from-workspace', workspaceUid, workspace.pathname, From 4d48d9b5a8a6d5998f4f4bdd0f191adba5cae5d8 Mon Sep 17 00:00:00 2001 From: Abhishek S Lal Date: Fri, 13 Mar 2026 17:53:31 +0530 Subject: [PATCH 4/6] test(collection): add integration test for collection deletion functionality Introduced a new test suite to verify the deletion of collections from the workspace overview. The test ensures that collections are properly removed from both the UI and the file system, confirming the absence of any uncaught errors during the deletion process. This addition enhances the test coverage for collection management features. --- .../delete/delete-collection.spec.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/collection/delete/delete-collection.spec.ts diff --git a/tests/collection/delete/delete-collection.spec.ts b/tests/collection/delete/delete-collection.spec.ts new file mode 100644 index 0000000000..d3ecc0d379 --- /dev/null +++ b/tests/collection/delete/delete-collection.spec.ts @@ -0,0 +1,67 @@ +import fs from 'fs'; +import path from 'path'; +import { test, expect } from '../../../playwright'; +import { createCollection, createRequest } from '../../utils/page'; + +test.describe('Delete collection', () => { + test('Delete collection from workspace overview removes files from disk', async ({ page, createTmpDir }) => { + const collectionName = 'delete-test-collection'; + const tmpDir = await createTmpDir(collectionName); + const collectionPath = path.join(tmpDir, collectionName); + + // Create a collection with a request + await createCollection(page, collectionName, tmpDir); + await createRequest(page, 'ping', collectionName, { url: 'http://localhost:8081/ping' }); + + // Verify collection directory exists on disk + expect(fs.existsSync(collectionPath)).toBe(true); + + // Capture any uncaught errors during deletion + const pageErrors: Error[] = []; + page.on('pageerror', (error) => pageErrors.push(error)); + + // Navigate to Workspace + await page.locator('.home-button').click(); + + // Navigate to workspace overview + const overviewTab = page.locator('.request-tab').filter({ hasText: 'Overview' }); + await overviewTab.click(); + + // Find the collection card and open its menu + const collectionCard = page.locator('.collection-card').filter({ hasText: collectionName }); + await collectionCard.waitFor({ state: 'visible', timeout: 5000 }); + await collectionCard.locator('.collection-menu').click(); + + // Click Delete from the dropdown + await page.locator('.dropdown-item').filter({ hasText: 'Delete' }).click(); + + // Wait for delete confirmation modal + const deleteModal = page.locator('.bruno-modal').filter({ hasText: 'Delete Collection' }); + await deleteModal.waitFor({ state: 'visible', timeout: 5000 }); + + // Type 'delete' to confirm + await deleteModal.locator('#delete-confirm-input').fill('delete'); + + // Click the Delete button + await deleteModal.getByRole('button', { name: 'Delete', exact: true }).click(); + + // Wait for modal to close and success toast + await deleteModal.waitFor({ state: 'hidden', timeout: 10000 }); + + // Verify collection is removed from workspace overview + await expect( + page.locator('.collection-card').filter({ hasText: collectionName }) + ).not.toBeVisible(); + + // Verify collection is removed from sidebar + await expect( + page.locator('#sidebar-collection-name').filter({ hasText: collectionName }) + ).not.toBeVisible(); + + // Verify collection directory is deleted from disk + expect(fs.existsSync(collectionPath)).toBe(false); + + // Verify no uncaught JS errors occurred during deletion + expect(pageErrors).toHaveLength(0); + }); +}); From 7ab3295955df062b7bdecc2e5ce66670f245db0c Mon Sep 17 00:00:00 2001 From: Abhishek S Lal Date: Fri, 13 Mar 2026 20:18:46 +0530 Subject: [PATCH 5/6] feat(collection): implement deleteCollectionFromOverview utility function Added a new utility function to delete a collection directly from the workspace overview page. This function encapsulates the steps required to navigate the UI, confirm deletion, and ensure the collection is removed from both the interface and the file system. Updated the corresponding test to utilize this new function, enhancing code reusability and test clarity. --- .../delete/delete-collection.spec.ts | 33 ++--------------- tests/utils/page/actions.ts | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/tests/collection/delete/delete-collection.spec.ts b/tests/collection/delete/delete-collection.spec.ts index d3ecc0d379..bb033672dc 100644 --- a/tests/collection/delete/delete-collection.spec.ts +++ b/tests/collection/delete/delete-collection.spec.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import { test, expect } from '../../../playwright'; -import { createCollection, createRequest } from '../../utils/page'; +import { createCollection, createRequest, deleteCollectionFromOverview } from '../../utils/page'; test.describe('Delete collection', () => { test('Delete collection from workspace overview removes files from disk', async ({ page, createTmpDir }) => { @@ -20,35 +20,10 @@ test.describe('Delete collection', () => { const pageErrors: Error[] = []; page.on('pageerror', (error) => pageErrors.push(error)); - // Navigate to Workspace - await page.locator('.home-button').click(); + // Navigate to Workspace and delete collection from overview + await deleteCollectionFromOverview(page, collectionName); - // Navigate to workspace overview - const overviewTab = page.locator('.request-tab').filter({ hasText: 'Overview' }); - await overviewTab.click(); - - // Find the collection card and open its menu - const collectionCard = page.locator('.collection-card').filter({ hasText: collectionName }); - await collectionCard.waitFor({ state: 'visible', timeout: 5000 }); - await collectionCard.locator('.collection-menu').click(); - - // Click Delete from the dropdown - await page.locator('.dropdown-item').filter({ hasText: 'Delete' }).click(); - - // Wait for delete confirmation modal - const deleteModal = page.locator('.bruno-modal').filter({ hasText: 'Delete Collection' }); - await deleteModal.waitFor({ state: 'visible', timeout: 5000 }); - - // Type 'delete' to confirm - await deleteModal.locator('#delete-confirm-input').fill('delete'); - - // Click the Delete button - await deleteModal.getByRole('button', { name: 'Delete', exact: true }).click(); - - // Wait for modal to close and success toast - await deleteModal.waitFor({ state: 'hidden', timeout: 10000 }); - - // Verify collection is removed from workspace overview + // Verify collection is removed from overview await expect( page.locator('.collection-card').filter({ hasText: collectionName }) ).not.toBeVisible(); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 30fd5ca4cc..c19d9b2e85 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -323,6 +323,42 @@ const deleteRequest = async (page, requestName: string, collectionName: string) }); }; +/** + * Delete a collection permanently from disk via the workspace overview page + * @param page - The page object + * @param collectionName - The name of the collection to delete + * @returns void + */ +const deleteCollectionFromOverview = async (page: Page, collectionName: string) => { + await test.step(`Delete collection "${collectionName}" from workspace overview`, async () => { + // Navigate to workspace overview + await page.locator('.home-button').click(); + const overviewTab = page.locator('.request-tab').filter({ hasText: 'Overview' }); + await overviewTab.click(); + + // Find the collection card and open its menu + const collectionCard = page.locator('.collection-card').filter({ hasText: collectionName }); + await collectionCard.waitFor({ state: 'visible', timeout: 5000 }); + await collectionCard.locator('.collection-menu').click(); + + // Click Delete from the dropdown + await page.locator('.dropdown-item').filter({ hasText: 'Delete' }).click(); + + // Wait for delete confirmation modal + const deleteModal = page.locator('.bruno-modal').filter({ hasText: 'Delete Collection' }); + await deleteModal.waitFor({ state: 'visible', timeout: 5000 }); + + // Type 'delete' to confirm + await deleteModal.locator('#delete-confirm-input').fill('delete'); + + // Click the Delete button + await deleteModal.getByRole('button', { name: 'Delete', exact: true }).click(); + + // Wait for modal to close + await deleteModal.waitFor({ state: 'hidden', timeout: 10000 }); + }); +}; + /** * Import a collection from a file * @param page - The page object @@ -1020,6 +1056,7 @@ export { createTransientRequest, fillRequestUrl, deleteRequest, + deleteCollectionFromOverview, importCollection, removeCollection, createFolder, From edce01092f3eadbdc9cc52e83ec2832f21ad8e2b Mon Sep 17 00:00:00 2001 From: Abhishek S Lal Date: Fri, 13 Mar 2026 23:17:12 +0530 Subject: [PATCH 6/6] fix(collection-watcher): add guards for collection path existence and error handling Enhanced the unlink and unlinkDir functions to check for the existence of the collection path before proceeding. Added error handling for the getCollectionFormat function to prevent crashes when the collection format cannot be retrieved. These changes improve stability and robustness during collection deletion operations. --- .../src/app/collection-watcher.js | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js index efacc1a524..c4aece50ac 100644 --- a/packages/bruno-electron/src/app/collection-watcher.js +++ b/packages/bruno-electron/src/app/collection-watcher.js @@ -553,13 +553,22 @@ const change = async (win, pathname, collectionUid, collectionPath) => { const unlink = (win, pathname, collectionUid, collectionPath) => { try { + if (!fs.existsSync(collectionPath)) { + return; + } console.log(`watcher unlink: ${pathname}`); if (isEnvironmentsFolder(pathname, collectionPath)) { return unlinkEnvironmentFile(win, pathname, collectionUid); } - const format = getCollectionFormat(collectionPath); + let format; + try { + format = getCollectionFormat(collectionPath); + } catch (error) { + console.error(`Error getting collection format for: ${collectionPath}`, error); + return; + } if (hasRequestExtension(pathname, format)) { const basename = path.basename(pathname); const dirname = path.dirname(pathname); @@ -584,13 +593,22 @@ const unlink = (win, pathname, collectionUid, collectionPath) => { const unlinkDir = async (win, pathname, collectionUid, collectionPath) => { try { + if (!fs.existsSync(collectionPath)) { + return; + } const envDirectory = path.join(collectionPath, 'environments'); if (path.normalize(pathname) === path.normalize(envDirectory)) { return; } - const format = getCollectionFormat(collectionPath); + let format; + try { + format = getCollectionFormat(collectionPath); + } catch (error) { + console.error(`Error getting collection format for: ${collectionPath}`, error); + return; + } const folderFilePath = path.join(pathname, `folder.${format}`); let name = path.basename(pathname);