Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/extension/ipc/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@ const registerCollectionIpc = (watcher: CollectionWatcherInterface): void => {

// Collect request files before rename for UID mapping updates
const itemCollectionPath = collectionPath;
const collectionUid = itemCollectionPath ? generateUidBasedOnHash(itemCollectionPath) : null;
const collectionUid = itemCollectionPath ? generateUidBasedOnHash(path.resolve(itemCollectionPath)) : null;
const requestFiles = itemCollectionPath ? searchForRequestFiles(oldPath, itemCollectionPath) : [];

fs.renameSync(oldPath, newPath);
Expand Down Expand Up @@ -736,7 +736,9 @@ const registerCollectionIpc = (watcher: CollectionWatcherInterface): void => {
}
};

const collectionUid = generateUidBasedOnHash(collectionPath);
// path.resolve converts posixified paths (from Redux) back to native separators
// so the UID matches what was generated when the collection was first opened
const collectionUid = generateUidBasedOnHash(path.resolve(collectionPath));
const stats = fs.statSync(itemPath);
if (stats.isDirectory()) {
const requestFiles = await searchForRequestFiles(itemPath, collectionPath);
Expand Down Expand Up @@ -1427,7 +1429,7 @@ const registerCollectionIpc = (watcher: CollectionWatcherInterface): void => {
return null;
}

const uid = generateUidBasedOnHash(collectionPath);
const uid = generateUidBasedOnHash(path.resolve(collectionPath));
const collectionName = path.basename(collectionPath);

const buildTree = (dirPath: string): Array<{
Expand Down
29 changes: 16 additions & 13 deletions src/extension/store/default-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,22 +221,25 @@ class DefaultWorkspaceManager {
config.collections = [];
}

const exists = config.collections.some(c => c.path === collectionPath);
if (exists) return true;

const name = collectionName || path.basename(collectionPath);

// Normalize to relative path for consistent storage
let relativePath = collectionPath;
try {
relativePath = path.relative(workspacePath!, collectionPath);
// If the relative path goes outside the workspace, use absolute path
if (relativePath.startsWith('..')) {
relativePath = collectionPath;
const rel = path.relative(workspacePath!, collectionPath);
if (!rel.startsWith('..')) {
relativePath = rel;
}
} catch {
relativePath = collectionPath;
}
} catch { }

// Check for existing entry by resolving stored paths to absolute for comparison
const exists = config.collections.some(c => {
const resolved = path.isAbsolute(c.path)
? c.path
: path.resolve(workspacePath!, c.path);
return path.normalize(resolved) === path.normalize(collectionPath);
});
if (exists) return true;

const name = collectionName || path.basename(collectionPath);
config.collections.push({
name,
path: relativePath
Expand All @@ -255,7 +258,7 @@ class DefaultWorkspaceManager {
const resolvedPath = path.isAbsolute(c.path)
? c.path
: path.resolve(workspacePath!, c.path);
return resolvedPath !== collectionPath;
return path.normalize(resolvedPath) !== path.normalize(collectionPath);
});

return this.saveWorkspaceConfig(config);
Expand Down
11 changes: 6 additions & 5 deletions src/extension/utils/workspace-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,16 +318,17 @@ const addCollectionToWorkspace = async (
config.collections = [];
}

const normalizedCollection: CollectionEntry = {
name: collection.name.trim(),
path: collection.path.trim()
};
const normalizedCollection = normalizeCollectionEntry(workspacePath, collection);

if (collection.remote && typeof collection.remote === 'string') {
normalizedCollection.remote = collection.remote.trim();
}

const existingIndex = config.collections.findIndex((c) => c.path === normalizedCollection.path);
// Compare normalized paths to avoid duplicates from absolute/relative mismatches
const existingIndex = config.collections.findIndex((c) => {
const existingNormalized = normalizeCollectionEntry(workspacePath, c);
return existingNormalized.path === normalizedCollection.path;
});

if (existingIndex >= 0) {
config.collections[existingIndex] = normalizedCollection;
Expand Down
17 changes: 17 additions & 0 deletions tests/e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ function writeVSCodeSettings(userDataDir: string): void {
'workbench.editor.untitled.hint': 'hidden',
'security.workspace.trust.enabled': false,
'extensions.ignoreRecommendations': true,
'github.copilot.enable': false,
'workbench.welcomePage.walkthroughs.openOnInstall': false,
'workbench.accounts.experimental.showEntitlements': false,
'accessibility.signUpPlaceholder': false,
}, null, 2)
);
}
Expand Down Expand Up @@ -111,6 +115,19 @@ async function waitForWorkbench(page: Page): Promise<void> {
await page.waitForSelector('.monaco-workbench', { timeout: 30_000 });
// Wait for the activity bar to be rendered
await page.waitForSelector('.activitybar', { timeout: 20_000 });

// Dismiss the GitHub sign-in dialog if it appears (VS Code 1.116+)
try {
const skipButton = page.locator('text=Skip');
const continueButton = page.locator('text=Continue without Signing In');
const dismissTarget = skipButton.or(continueButton);
await dismissTarget.first().click({ timeout: 3_000 });
// Wait for the dialog to close
await page.waitForTimeout(1_000);
} catch {
// Dialog didn't appear — that's fine
}

// Small extra buffer for extension activation
await new Promise(r => setTimeout(r, 2_000));
}
Expand Down
103 changes: 103 additions & 0 deletions tests/e2e/specs/collection-removal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { test, expect } from '../fixtures';
import {
openBrunoSidebar,
createCollection,
removeCollection,
createFolder,
deleteItem,
expandCollection,
} from '../utils/actions';

test.describe('Collection removal', () => {

test('Removed collection does not reappear when creating a new collection', async ({ page, tmpDir }) => {
const sidebar = await openBrunoSidebar(page);

// Step 1: Create collection A
const collectionA = 'Collection A';
await createCollection(page, sidebar, collectionA, tmpDir);

const rowA = sidebar
.locator('[data-testid="sidebar-collection-row"]')
.filter({ hasText: collectionA });
await expect(rowA).toBeVisible();

// Step 2: Remove collection A
await removeCollection(page, sidebar, collectionA);
await expect(rowA).not.toBeVisible({ timeout: 10_000 });

// Step 3: Create collection B — this triggers workspace-config-updated
// which re-reads workspace.yml. If collection A wasn't properly removed,
// it will reappear here.
const collectionB = 'Collection B';
await createCollection(page, sidebar, collectionB, tmpDir);

const rowB = sidebar
.locator('[data-testid="sidebar-collection-row"]')
.filter({ hasText: collectionB });
await expect(rowB).toBeVisible();

// Step 4: Verify collection A is still gone
await expect(rowA).not.toBeVisible();

// Step 5: Count total collections — should be exactly 1 (Collection B)
const allCollections = sidebar.locator('[data-testid="sidebar-collection-row"]');
// Filter to only the ones we created (exclude any pre-existing workspace collections)
const ourCollections = allCollections.filter({ hasText: /Collection [AB]/ });
await expect(ourCollections).toHaveCount(1);
});

test('Removing and recreating a collection with the same name works', async ({ page, tmpDir }) => {
const fs = require('fs');
const path = require('path');
const sidebar = await openBrunoSidebar(page);
const collectionName = 'Ephemeral Collection';

// Create → remove → recreate in a different subfolder
const dir1 = path.join(tmpDir, 'round1');
fs.mkdirSync(dir1, { recursive: true });
await createCollection(page, sidebar, collectionName, dir1);
await removeCollection(page, sidebar, collectionName);

const row = sidebar
.locator('[data-testid="sidebar-collection-row"]')
.filter({ hasText: collectionName });
await expect(row).not.toBeVisible({ timeout: 10_000 });

// Recreate with the same name in a different folder to avoid filesystem conflict
const dir2 = path.join(tmpDir, 'round2');
fs.mkdirSync(dir2, { recursive: true });
await createCollection(page, sidebar, collectionName, dir2);
await expect(row).toBeVisible();

// Should be exactly 1 instance, not 2
const matches = sidebar
.locator('[data-testid="sidebar-collection-row"]')
.filter({ hasText: collectionName });
await expect(matches).toHaveCount(1);
});

test('Deleted folder disappears from the sidebar', async ({ page, tmpDir }) => {
const sidebar = await openBrunoSidebar(page);
const collectionName = 'Folder Delete Test';
const folderName = 'my-folder';

// Create a collection and a folder inside it
await createCollection(page, sidebar, collectionName, tmpDir);
await createFolder(sidebar, collectionName, folderName);

// Expand the collection to see the folder
await expandCollection(sidebar, collectionName);
const folderRow = sidebar
.locator('[data-testid="sidebar-collection-item-row"]')
.filter({ hasText: folderName });
await expect(folderRow).toBeVisible({ timeout: 10_000 });

// Delete the folder
await deleteItem(sidebar, folderName);

// Verify it's gone
await expect(folderRow).not.toBeVisible({ timeout: 10_000 });
});

});
148 changes: 148 additions & 0 deletions tests/e2e/utils/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,154 @@ export async function sendRequest(
}
}

/**
* Mock the next `sidebar:confirm-remove` IPC call to auto-confirm removal.
* Bypasses the native VS Code modal dialog which is hard to interact with in e2e.
*/
async function mockConfirmRemove(frame: Frame): Promise<void> {
await frame.evaluate(() => {
const ipc = (window as any).ipcRenderer;
const originalInvoke = ipc.invoke.bind(ipc);
ipc.invoke = async (channel: string, ...args: any[]) => {
if (channel === 'sidebar:confirm-remove') {
ipc.invoke = originalInvoke;
return true;
}
return originalInvoke(channel, ...args);
};
});
}

/**
* Remove a collection from the sidebar by opening the context menu,
* clicking Remove, and auto-confirming via IPC mock.
*
* @param page - Playwright Page (VS Code workbench)
* @param sidebar - The sidebar webview Frame
* @param collectionName - Name of the collection to remove
*/
export async function removeCollection(
page: Page,
sidebar: Frame,
collectionName: string
): Promise<void> {
const collectionRow = sidebar
.locator('[data-testid="sidebar-collection-row"]')
.filter({ hasText: collectionName });
await collectionRow.hover();

// Mock the confirmation dialog before triggering removal
await mockConfirmRemove(sidebar);

// Open the 3-dot context menu
await collectionRow.locator('[data-testid="collection-actions"]').click();

// Click "Remove" from the dropdown
await sidebar.locator('[role="menuitem"]').filter({ hasText: 'Remove' }).click();

// Wait for the collection to disappear from the sidebar
await expect(collectionRow).not.toBeVisible({ timeout: 15_000 });
}

/**
* Mock the next `sidebar:prompt-new-folder` IPC call to return a folder name.
* Bypasses the native VS Code input box.
*/
async function mockNewFolderPrompt(frame: Frame, folderName: string): Promise<void> {
await frame.evaluate((name) => {
const ipc = (window as any).ipcRenderer;
const originalInvoke = ipc.invoke.bind(ipc);
ipc.invoke = async (channel: string, ...args: any[]) => {
if (channel === 'sidebar:prompt-new-folder') {
ipc.invoke = originalInvoke;
return name;
}
return originalInvoke(channel, ...args);
};
}, folderName);
}

/**
* Mock the next `sidebar:confirm-delete` IPC call to auto-confirm deletion.
* Bypasses the native VS Code confirmation dialog.
*/
async function mockConfirmDelete(frame: Frame): Promise<void> {
await frame.evaluate(() => {
const ipc = (window as any).ipcRenderer;
const originalInvoke = ipc.invoke.bind(ipc);
ipc.invoke = async (channel: string, ...args: any[]) => {
if (channel === 'sidebar:confirm-delete') {
ipc.invoke = originalInvoke;
return true;
}
return originalInvoke(channel, ...args);
};
});
}

/**
* Create a new folder inside a collection via the sidebar context menu.
*
* @param sidebar - The sidebar webview Frame
* @param collectionName - Name of the parent collection
* @param folderName - Name for the new folder
*/
export async function createFolder(
sidebar: Frame,
collectionName: string,
folderName: string
): Promise<void> {
// Expand collection first so it's mounted
await expandCollection(sidebar, collectionName);

const collectionRow = sidebar
.locator('[data-testid="sidebar-collection-row"]')
.filter({ hasText: collectionName });
await collectionRow.hover();

// Mock the folder name input before opening the menu
await mockNewFolderPrompt(sidebar, folderName);

// Open the 3-dot context menu
await collectionRow.locator('[data-testid="collection-actions"]').click();

// Click "New Folder"
await sidebar.locator('[role="menuitem"]').filter({ hasText: 'New Folder' }).click();

// Wait for the folder to appear in the sidebar
await expect(
sidebar.locator('[data-testid="sidebar-collection-item-row"]').filter({ hasText: folderName })
).toBeVisible({ timeout: 15_000 });
}

/**
* Delete a folder or request from the sidebar by right-clicking and confirming.
*
* @param sidebar - The sidebar webview Frame
* @param itemName - Name of the folder/request to delete
*/
export async function deleteItem(
sidebar: Frame,
itemName: string
): Promise<void> {
const itemRow = sidebar
.locator('[data-testid="sidebar-collection-item-row"]')
.filter({ hasText: itemName });
await itemRow.hover();

// Mock the confirmation dialog
await mockConfirmDelete(sidebar);

// Open the context menu (3-dot icon on the item)
await itemRow.locator('[data-testid="collection-item-menu"]').click();

// Click "Delete"
await sidebar.locator('[role="menuitem"]').filter({ hasText: 'Delete' }).click();

// Wait for the item to disappear
await expect(itemRow).not.toBeVisible({ timeout: 15_000 });
}

/**
* Run a VS Code command via the Command Palette.
*/
Expand Down