+
+
+
+
+
- {errorMessage}
+
+ File
setErrorMessage('')}
- style={{ color: theme.status.danger.text }}
+ className={getTabClassname(IMPORT_TABS.GITHUB)}
+ onClick={handleTabSelect(IMPORT_TABS.GITHUB)}
+ data-testid="github-tab"
>
-
+
+ Git Repository
+
+
+
+ URL
- )}
- {tab === IMPORT_TABS.FILE && (
-
- )}
- {tab === IMPORT_TABS.GITHUB && (
-
- )}
- {tab === IMPORT_TABS.URL && (
-
- )}
-
-
+ {errorMessage && (
+
+
+
+ {errorMessage}
+
+
setErrorMessage('')}
+ style={{ color: theme.status.danger.text }}
+ >
+
+
+
+
+ )}
+
+ {tab === IMPORT_TABS.FILE && (
+
+ )}
+ {tab === IMPORT_TABS.GITHUB && (
+
+ )}
+ {tab === IMPORT_TABS.URL && (
+
+ )}
+
+
+
);
};
diff --git a/packages/bruno-app/src/components/SkippedPathsWarning/StyledWrapper.js b/packages/bruno-app/src/components/SkippedPathsWarning/StyledWrapper.js
new file mode 100644
index 000000000..ed3386f19
--- /dev/null
+++ b/packages/bruno-app/src/components/SkippedPathsWarning/StyledWrapper.js
@@ -0,0 +1,62 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ color: ${(props) => props.theme.status.warning.text};
+ background-color: ${(props) => props.theme.status.warning.background};
+ border: 1px solid ${(props) => props.theme.status.warning.border};
+ border-radius: ${(props) => props.theme.border.radius.base};
+ padding: 0.375rem 0.5rem;
+ font-size: ${(props) => props.theme.font.size.sm};
+
+ .scan-warning-icon {
+ color: ${(props) => props.theme.status.warning.text};
+ flex-shrink: 0;
+ }
+
+ .scan-warning-action {
+ background: transparent;
+ border: 0;
+ padding: 0;
+ color: inherit;
+ font-weight: 600;
+ text-decoration: underline;
+ cursor: pointer;
+ flex-shrink: 0;
+ }
+
+ .scan-warning-list {
+ list-style: none;
+ margin: 0.5rem 0 0;
+ padding: 0;
+ max-height: 8rem;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ .scan-warning-list li {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ padding: 0.25rem 0;
+ border-top: 1px solid ${(props) => props.theme.status.warning.border};
+ }
+
+ .scan-warning-list li:first-child {
+ border-top: 0;
+ }
+
+ .scan-warning-path {
+ font-family: ${(props) => props.theme.font.codeFont};
+ font-size: ${(props) => props.theme.font.size.xs};
+ word-break: break-all;
+ }
+
+ .scan-warning-reason {
+ font-size: ${(props) => props.theme.font.size.xs};
+ opacity: 0.85;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/SkippedPathsWarning/index.js b/packages/bruno-app/src/components/SkippedPathsWarning/index.js
new file mode 100644
index 000000000..77b3a6a99
--- /dev/null
+++ b/packages/bruno-app/src/components/SkippedPathsWarning/index.js
@@ -0,0 +1,40 @@
+import React, { useState } from 'react';
+import { IconAlertTriangle } from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+
+const SkippedPathsWarning = ({ paths, itemNoun }) => {
+ const [showDetails, setShowDetails] = useState(false);
+
+ if (!paths || paths.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {paths.length} {itemNoun} were skipped because their config could not be read.
+
+
+
+ {showDetails && (
+
+ {paths.map((pathname) => (
+ -
+ {pathname}
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default SkippedPathsWarning;
diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js
index c877c77c7..f5c8a8080 100644
--- a/packages/bruno-electron/src/app/collections.js
+++ b/packages/bruno-electron/src/app/collections.js
@@ -224,6 +224,7 @@ const openCollectionsByPathname = async (win, watcher, collectionPaths, options
};
module.exports = {
+ getCollectionConfigFile,
openCollection,
openCollectionDialog,
openCollectionsByPathname,
diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js
index 09dd7af62..e81662fb4 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -57,7 +57,7 @@ const {
isCollectionRootBruFile,
scanForBrunoFiles
} = require('../utils/filesystem');
-const { openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
+const { getCollectionConfigFile, openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common');
const { moveRequestUid, deleteRequestUid, syncExampleUidsCache } = require('../cache/requestUids');
const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies');
@@ -2459,9 +2459,30 @@ const registerMainEventHandlers = (mainWindow, watcher) => {
app.addRecentDocument(pathname);
});
- ipcMain.handle('renderer:scan-for-bruno-files', (event, dir) => {
+ ipcMain.handle('renderer:scan-for-bruno-files', async (event, dir) => {
try {
- return scanForBrunoFiles(dir);
+ const collectionPaths = await scanForBrunoFiles(dir);
+
+ const scanResults = await Promise.all(
+ collectionPaths.map(async (pathname) => {
+ try {
+ const brunoConfig = await getCollectionConfigFile(pathname);
+
+ return {
+ pathname,
+ name: brunoConfig.name
+ };
+ } catch (error) {
+ console.warn(`Skipping invalid Bruno collection at ${pathname}: ${error.message}`);
+ return { pathname, skipped: true };
+ }
+ })
+ );
+
+ return {
+ items: scanResults.filter((result) => !result.skipped),
+ skippedItems: scanResults.filter((result) => result.skipped).map(({ pathname }) => pathname)
+ };
} catch (error) {
throw new Error(error.message);
}
diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js
index 9a56525ae..8df0a261f 100644
--- a/packages/bruno-electron/src/utils/filesystem.js
+++ b/packages/bruno-electron/src/utils/filesystem.js
@@ -490,7 +490,7 @@ const scanForBrunoFiles = async (dir) => {
return;
}
scanDir(fullPath);
- } else if (file === 'bruno.json') {
+ } else if ((file === 'bruno.json' || file === 'opencollection.yml') && !brunoFolders.includes(currentDir)) {
brunoFolders.push(currentDir);
}
});
diff --git a/tests/import/bulk-import/001-multiple-files-upload.spec.ts b/tests/import/bulk-import/001-multiple-files-upload.spec.ts
index 8b2bbf4d0..e60088797 100644
--- a/tests/import/bulk-import/001-multiple-files-upload.spec.ts
+++ b/tests/import/bulk-import/001-multiple-files-upload.spec.ts
@@ -32,7 +32,9 @@ test.describe('Multiple Files Upload', () => {
await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import');
// Check that the Collections count shows 2 collections in the Bulk Import modal
- await expect(bulkImportModal.getByText('Collections (2)')).toBeVisible();
+ const collectionsHeading = bulkImportModal.getByTestId('selection-heading').filter({ hasText: 'Collections' });
+ await expect(collectionsHeading).toBeVisible();
+ await expect(collectionsHeading.getByTestId('selection-count')).toHaveText('2');
// Verify collection names are displayed
await expect(bulkImportModal.getByText('Sample Postman Collection')).toBeVisible();
diff --git a/tests/import/bulk-import/002-all-collection-types.spec.ts b/tests/import/bulk-import/002-all-collection-types.spec.ts
index 285dad9e4..98da740d6 100644
--- a/tests/import/bulk-import/002-all-collection-types.spec.ts
+++ b/tests/import/bulk-import/002-all-collection-types.spec.ts
@@ -34,7 +34,9 @@ test.describe('All Collection Types Bulk Import', () => {
await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import');
// Check that the Collections count shows 4 collections in the Bulk Import modal
- await expect(bulkImportModal.getByText('Collections (4)')).toBeVisible();
+ const collectionsHeading = bulkImportModal.getByTestId('selection-heading').filter({ hasText: 'Collections' });
+ await expect(collectionsHeading).toBeVisible();
+ await expect(collectionsHeading.getByTestId('selection-count')).toHaveText('4');
await expect(bulkImportModal.getByText('Sample Postman Collection')).toBeVisible();
await expect(bulkImportModal.getByText('Sample Insomnia Collection')).toBeVisible();
await expect(bulkImportModal.getByText('Sample Bruno Collection')).toBeVisible();
diff --git a/tests/import/bulk-import/003-selection-list-viewport.spec.ts b/tests/import/bulk-import/003-selection-list-viewport.spec.ts
index 4dbe50841..8cc29dad1 100644
--- a/tests/import/bulk-import/003-selection-list-viewport.spec.ts
+++ b/tests/import/bulk-import/003-selection-list-viewport.spec.ts
@@ -16,14 +16,13 @@ const getFullyVisibleRowNames = async (list: Locator) => {
const rect = item.getBoundingClientRect();
return rect.top >= listRect.top && rect.bottom <= listRect.bottom;
})
- .map((item) => item.textContent?.trim())
+ .map((item) => item.querySelector('.selection-item-title')?.textContent?.trim())
.filter(Boolean);
});
};
test.describe('Bulk Import Selection List', () => {
const testDataDir = path.join(__dirname, '../test-data');
- const expectedVisibleRows = 5;
test.afterEach(async ({ page }) => {
await closeAllCollections(page);
@@ -61,16 +60,18 @@ test.describe('Bulk Import Selection List', () => {
const bulkImportModal = page.getByRole('dialog');
await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import');
- await expect(bulkImportModal.getByText('Collections (10)')).toBeVisible();
+ const collectionsHeading = bulkImportModal.getByTestId('selection-heading').filter({ hasText: 'Collections' });
+ await expect(collectionsHeading).toBeVisible();
+ await expect(collectionsHeading.getByTestId('selection-count')).toHaveText('10');
- const collectionList = bulkImportModal.locator('.selection-list').first();
+ const collectionList = collectionsHeading.locator('..').getByTestId('selection-list');
await expect(collectionList).toBeVisible();
const initialVisibleRows = await getFullyVisibleRowNames(collectionList);
- expect(initialVisibleRows).toHaveLength(expectedVisibleRows);
+ expect(initialVisibleRows.length).toBeGreaterThan(0);
+ expect(initialVisibleRows.length).toBeLessThan(10);
expect(initialVisibleRows[0]).toBe(getViewportCollectionName(1));
- expect(initialVisibleRows[expectedVisibleRows - 1]).toBe(getViewportCollectionName(expectedVisibleRows));
- expect(initialVisibleRows).not.toContain(getViewportCollectionName(expectedVisibleRows + 1));
+ expect(initialVisibleRows).not.toContain(getViewportCollectionName(10));
await collectionList.evaluate((list) => {
list.scrollTop = list.scrollHeight;
@@ -78,7 +79,7 @@ test.describe('Bulk Import Selection List', () => {
await expect(async () => {
const scrolledVisibleRows = await getFullyVisibleRowNames(collectionList);
- expect(scrolledVisibleRows).toHaveLength(expectedVisibleRows);
+ expect(scrolledVisibleRows.length).toBeGreaterThan(0);
expect(scrolledVisibleRows).toContain(getViewportCollectionName(9));
expect(scrolledVisibleRows).toContain(getViewportCollectionName(10));
}).toPass({ timeout: 5000 });
diff --git a/tests/import/bulk-import/004-select-all.spec.ts b/tests/import/bulk-import/004-select-all.spec.ts
new file mode 100644
index 000000000..7b0c2b789
--- /dev/null
+++ b/tests/import/bulk-import/004-select-all.spec.ts
@@ -0,0 +1,137 @@
+import { test, expect } from '../../../playwright';
+import * as path from 'path';
+import * as fs from 'fs/promises';
+import { closeAllCollections } from '../../utils/page';
+
+const getCollectionName = (index: number) => `Select All Collection ${String(index).padStart(2, '0')}`;
+
+test.describe('Bulk Import - Select all', () => {
+ const testDataDir = path.join(__dirname, '../test-data');
+
+ test.afterEach(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test('Select all toggles every collection on, then off, and reflects indeterminate state', async ({
+ page,
+ createTmpDir
+ }) => {
+ const sourceFile = path.join(testDataDir, 'sample-postman.json');
+ const tempDir = await createTmpDir('bulk-import-select-all');
+ const sourceContent = JSON.parse(await fs.readFile(sourceFile, 'utf-8'));
+
+ const importFiles: string[] = [];
+ const totalCollections = 6;
+ for (let index = 1; index <= totalCollections; index++) {
+ const filePath = path.join(tempDir, `sample-postman-${index}.json`);
+ const fileContent = {
+ ...sourceContent,
+ info: {
+ ...sourceContent.info,
+ name: getCollectionName(index)
+ }
+ };
+
+ await fs.writeFile(filePath, JSON.stringify(fileContent, null, 2), 'utf-8');
+ importFiles.push(filePath);
+ }
+
+ await page.getByTestId('collections-header-add-menu').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
+
+ const importModal = page.getByRole('dialog');
+ await importModal.waitFor({ state: 'visible' });
+ await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
+
+ await page.setInputFiles('input[type="file"]', importFiles);
+ await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
+
+ const bulkImportModal = page.getByRole('dialog');
+ await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import');
+
+ const collectionsSection = bulkImportModal.getByTestId('selection-section-collections');
+ await expect(collectionsSection.getByTestId('selection-count')).toHaveText(String(totalCollections));
+
+ const collectionList = collectionsSection.getByTestId('selection-list');
+ const itemCheckboxes = collectionList.locator('.selection-item input[type="checkbox"]');
+ const selectAllToggle = collectionsSection.getByTestId('selection-select-all-toggle');
+ const selectAllCheckbox = selectAllToggle.locator('input[type="checkbox"]');
+
+ await expect(itemCheckboxes).toHaveCount(totalCollections);
+
+ await test.step('Bulk import opens with every collection pre-selected', async () => {
+ await expect(selectAllCheckbox).toBeChecked();
+ for (let i = 0; i < totalCollections; i++) {
+ await expect(itemCheckboxes.nth(i)).toBeChecked();
+ }
+ });
+
+ await test.step('Clicking Select all unchecks every collection', async () => {
+ await selectAllToggle.click();
+ await expect(selectAllCheckbox).not.toBeChecked();
+ for (let i = 0; i < totalCollections; i++) {
+ await expect(itemCheckboxes.nth(i)).not.toBeChecked();
+ }
+ });
+
+ await test.step('Clicking Select all again rechecks every collection', async () => {
+ await selectAllToggle.click();
+ await expect(selectAllCheckbox).toBeChecked();
+ for (let i = 0; i < totalCollections; i++) {
+ await expect(itemCheckboxes.nth(i)).toBeChecked();
+ }
+ });
+
+ await test.step('Unchecking a single collection puts Select all into the indeterminate state', async () => {
+ await collectionList.locator('.selection-item').first().click();
+ const checkedCount = await itemCheckboxes.evaluateAll(
+ (nodes) => nodes.filter((node) => (node as HTMLInputElement).checked).length
+ );
+ expect(checkedCount).toBe(totalCollections - 1);
+ const isIndeterminate = await selectAllCheckbox.evaluate(
+ (node) => (node as HTMLInputElement).indeterminate
+ );
+ expect(isIndeterminate).toBe(true);
+ });
+
+ await test.step('Clicking Select all from indeterminate selects every collection', async () => {
+ await selectAllToggle.click();
+ await expect(selectAllCheckbox).toBeChecked();
+ const isIndeterminate = await selectAllCheckbox.evaluate(
+ (node) => (node as HTMLInputElement).indeterminate
+ );
+ expect(isIndeterminate).toBe(false);
+ for (let i = 0; i < totalCollections; i++) {
+ await expect(itemCheckboxes.nth(i)).toBeChecked();
+ }
+ });
+
+ await test.step('Search narrows Select all to the filtered subset only', async () => {
+ await selectAllToggle.click();
+ await expect(selectAllCheckbox).not.toBeChecked();
+
+ const searchInput = collectionsSection.getByTestId('selection-search-input');
+ await searchInput.fill('01');
+
+ const visibleCount = await itemCheckboxes.count();
+ expect(visibleCount).toBeGreaterThan(0);
+ expect(visibleCount).toBeLessThan(totalCollections);
+
+ await selectAllToggle.click();
+ await expect(selectAllCheckbox).toBeChecked();
+ for (let i = 0; i < visibleCount; i++) {
+ await expect(itemCheckboxes.nth(i)).toBeChecked();
+ }
+
+ await searchInput.fill('');
+ await expect(itemCheckboxes).toHaveCount(totalCollections);
+ const isIndeterminate = await selectAllCheckbox.evaluate(
+ (node) => (node as HTMLInputElement).indeterminate
+ );
+ expect(isIndeterminate).toBe(true);
+ });
+
+ await page.getByTestId('modal-close-button').click();
+ await expect(page.locator('.bruno-modal-backdrop')).toHaveCount(0);
+ });
+});