fix(snapshot): normalize renderer:get-last-opened-workspaces output to avoid reactivating a deleted workspace (#8033)

* fix: normalize workspace paths during workspace switch to prevent stale state

* chore: test text

* tests(snapshot): more workspace coverage
This commit is contained in:
Sid
2026-05-18 21:24:58 +05:30
committed by GitHub
parent e8468ac9a5
commit cea883eda2
3 changed files with 304 additions and 2 deletions

View File

@@ -808,6 +808,7 @@ export const workspaceOpenedEvent = (workspacePath, workspaceUid, workspaceConfi
try {
const snapshot = await ipcRenderer.invoke('renderer:snapshot:get');
const activeWorkspacePath = snapshot?.activeWorkspacePath;
const normalizedWorkspacePath = normalizePath(workspacePath || '');
const currentState = getState();
if (!currentState.app.snapshotReady && snapshot?.extras?.devTools) {
@@ -822,7 +823,23 @@ export const workspaceOpenedEvent = (workspacePath, workspaceUid, workspaceConfi
}
if (activeWorkspacePath) {
shouldSwitch = workspacePath === activeWorkspacePath;
const normalizedActiveWorkspacePath = normalizePath(activeWorkspacePath);
shouldSwitch = normalizedWorkspacePath === normalizedActiveWorkspacePath;
// If the snapshot points to a workspace that no longer exists on disk,
// fall back to the default workspace instead of leaving stale active state.
if (!shouldSwitch && workspaceConfig.type === 'default') {
const lastOpenedWorkspacePaths = await ipcRenderer.invoke('renderer:get-last-opened-workspaces').catch(() => []);
const normalizedLastOpenedWorkspacePaths = new Set(
(Array.isArray(lastOpenedWorkspacePaths) ? lastOpenedWorkspacePaths : [])
.map((pathname) => normalizePath(pathname))
);
const hasActiveWorkspacePath = normalizedLastOpenedWorkspacePaths.has(normalizedActiveWorkspacePath);
if (!hasActiveWorkspacePath) {
shouldSwitch = true;
}
}
} else {
shouldSwitch = !activeWorkspaceUid || workspaceConfig.type === 'default';
}

View File

@@ -0,0 +1,267 @@
import path from 'path';
import { test, expect, closeElectronApp } from '../../playwright';
import {
createCollection,
createRequest,
openRequest,
openWorkspaceFromDialog,
waitForReadyPage
} from '../utils/page';
import fs from 'fs';
const buildWorkspaceYml = (workspaceName: string) => [
'opencollection: 1.0.0',
'info:',
` name: ${workspaceName}`,
' type: workspace',
'collections:',
'specs: []',
'docs: \'\'',
''
].join('\n');
test.describe('Snapshot: Deleted Workspace Restoration', () => {
test('falls back to default workspace when saved workspace is deleted', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-workspace-state');
const workspacePath = await createTmpDir('demo-workspace');
const defaultCollectionPath = await createTmpDir('default-workspace-col');
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), buildWorkspaceYml('Demo Workspace'));
const app = await launchElectronApp({ userDataPath });
const page = await waitForReadyPage(app);
await test.step('Create a collection in default workspace and mount it', async () => {
await createCollection(page, 'Default Workspace Col', defaultCollectionPath);
await createRequest(page, 'Default Workspace Req', 'Default Workspace Col', {
url: 'https://echo.usebruno.com',
method: 'GET'
});
await openRequest(page, 'Default Workspace Col', 'Default Workspace Req', { persist: true });
await expect(page.getByRole('tab', { name: 'Default Workspace Req' })).toBeVisible({ timeout: 10000 });
});
await test.step('Open Demo Workspace and switch to it', async () => {
await openWorkspaceFromDialog(app, page, workspacePath);
await expect(page.getByTestId('workspace-name')).toHaveText('Demo Workspace', { timeout: 10000 });
});
await test.step('Close and restart app', async () => {
await page.waitForTimeout(2000);
await closeElectronApp(app);
});
await test.step('Open after deleting workspace', async () => {
await fs.promises.rm(workspacePath, { force: true, recursive: true });
const app2 = await launchElectronApp({ userDataPath });
const page2 = await waitForReadyPage(app2);
await expect(page2.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 10000 });
await expect(page2.getByTestId('sidebar-collection-row').filter({ hasText: 'Default Workspace Col' })).toBeVisible({ timeout: 10000 });
await openRequest(page2, 'Default Workspace Col', 'Default Workspace Req');
await expect(page2.getByRole('tab', { name: 'Default Workspace Req' })).toBeVisible({ timeout: 10000 });
await page2.getByTestId('workspace-menu').click();
await expect(page2.locator('.workspace-item.active')).toContainText('My Workspace');
await expect(page2.locator('.workspace-item').filter({ hasText: 'Demo Workspace' })).toHaveCount(0);
await closeElectronApp(app2);
});
});
test('falls back to default workspace when saved workspace exists but workspace.yml is missing', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-workspace-missing-yml');
const workspacePath = await createTmpDir('demo-workspace-missing-yml');
const defaultCollectionPath = await createTmpDir('default-workspace-col-missing-yml');
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), buildWorkspaceYml('Demo Workspace'));
const app = await launchElectronApp({ userDataPath });
const page = await waitForReadyPage(app);
await test.step('Create collection and request in default workspace', async () => {
await createCollection(page, 'Default Workspace Col', defaultCollectionPath);
await createRequest(page, 'Default Workspace Req', 'Default Workspace Col', {
url: 'https://echo.usebruno.com',
method: 'GET'
});
await openRequest(page, 'Default Workspace Col', 'Default Workspace Req', { persist: true });
await expect(page.getByRole('tab', { name: 'Default Workspace Req' })).toBeVisible({ timeout: 10000 });
});
await test.step('Switch to demo workspace and restart', async () => {
await openWorkspaceFromDialog(app, page, workspacePath);
await expect(page.getByTestId('workspace-name')).toHaveText('Demo Workspace', { timeout: 10000 });
await page.waitForTimeout(2000);
await closeElectronApp(app);
});
await test.step('Delete only workspace.yml and verify fallback', async () => {
await fs.promises.unlink(path.join(workspacePath, 'workspace.yml'));
const app2 = await launchElectronApp({ userDataPath });
const page2 = await waitForReadyPage(app2);
await expect(page2.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 10000 });
await expect(page2.getByTestId('sidebar-collection-row').filter({ hasText: 'Default Workspace Col' })).toBeVisible({ timeout: 10000 });
await page2.getByTestId('workspace-menu').click();
await expect(page2.locator('.workspace-item.active')).toContainText('My Workspace');
await expect(page2.locator('.workspace-item').filter({ hasText: 'Demo Workspace' })).toHaveCount(0);
await closeElectronApp(app2);
});
});
test('falls back to default workspace when saved workspace.yml is malformed', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-workspace-malformed-yml');
const workspacePath = await createTmpDir('demo-workspace-malformed-yml');
const defaultCollectionPath = await createTmpDir('default-workspace-col-malformed-yml');
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), buildWorkspaceYml('Demo Workspace'));
const app = await launchElectronApp({ userDataPath });
const page = await waitForReadyPage(app);
await test.step('Create collection and request in default workspace', async () => {
await createCollection(page, 'Default Workspace Col', defaultCollectionPath);
await createRequest(page, 'Default Workspace Req', 'Default Workspace Col', {
url: 'https://echo.usebruno.com',
method: 'GET'
});
await openRequest(page, 'Default Workspace Col', 'Default Workspace Req', { persist: true });
});
await test.step('Switch to demo workspace and restart', async () => {
await openWorkspaceFromDialog(app, page, workspacePath);
await expect(page.getByTestId('workspace-name')).toHaveText('Demo Workspace', { timeout: 10000 });
await page.waitForTimeout(2000);
await closeElectronApp(app);
});
await test.step('Corrupt workspace.yml and verify fallback', async () => {
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), 'invalid: yaml: [[[');
const app2 = await launchElectronApp({ userDataPath });
const page2 = await waitForReadyPage(app2);
await expect(page2.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 10000 });
await openRequest(page2, 'Default Workspace Col', 'Default Workspace Req');
await expect(page2.getByRole('tab', { name: 'Default Workspace Req' })).toBeVisible({ timeout: 10000 });
await page2.getByTestId('workspace-menu').click();
await expect(page2.locator('.workspace-item').filter({ hasText: 'Demo Workspace' })).toHaveCount(0);
await closeElectronApp(app2);
});
});
test('does not restore stale tabs from deleted workspace and remains interactive', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-workspace-stale-tabs-deleted');
const workspacePath = await createTmpDir('demo-workspace-stale-tabs');
const defaultCollectionPath = await createTmpDir('default-workspace-col-stale-tabs');
const deletedWorkspaceCollectionPath = await createTmpDir('deleted-workspace-col');
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), buildWorkspaceYml('Demo Workspace'));
const app = await launchElectronApp({ userDataPath });
const page = await waitForReadyPage(app);
await test.step('Create request in default workspace', async () => {
await createCollection(page, 'Default Workspace Col', defaultCollectionPath);
await createRequest(page, 'Default Workspace Req', 'Default Workspace Col', {
url: 'https://echo.usebruno.com',
method: 'GET'
});
await openRequest(page, 'Default Workspace Col', 'Default Workspace Req', { persist: true });
});
await test.step('Switch to demo workspace and open a request there', async () => {
await openWorkspaceFromDialog(app, page, workspacePath);
await expect(page.getByTestId('workspace-name')).toHaveText('Demo Workspace', { timeout: 10000 });
await createCollection(page, 'Deleted Workspace Col', deletedWorkspaceCollectionPath);
await createRequest(page, 'Deleted Workspace Req', 'Deleted Workspace Col', {
url: 'https://echo.usebruno.com',
method: 'GET'
});
await openRequest(page, 'Deleted Workspace Col', 'Deleted Workspace Req', { persist: true });
await expect(page.getByRole('tab', { name: 'Deleted Workspace Req' })).toBeVisible({ timeout: 10000 });
});
await test.step('Close app, delete active workspace, and verify stale tab is not restored', async () => {
await page.waitForTimeout(2000);
await closeElectronApp(app);
await fs.promises.rm(workspacePath, { recursive: true, force: true });
const app2 = await launchElectronApp({ userDataPath });
const page2 = await waitForReadyPage(app2);
await expect(page2.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 10000 });
await expect(page2.getByRole('tab', { name: 'Deleted Workspace Req' })).toHaveCount(0);
await openRequest(page2, 'Default Workspace Col', 'Default Workspace Req');
await expect(page2.getByRole('tab', { name: 'Default Workspace Req' })).toBeVisible({ timeout: 10000 });
await closeElectronApp(app2);
});
});
test('falls back when active workspace and active tab belong to malformed workspace snapshot', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-workspace-malformed-with-active-tab');
const workspacePath = await createTmpDir('demo-workspace-malformed-active-tab');
const defaultCollectionPath = await createTmpDir('default-workspace-col-malformed-active-tab');
const malformedWorkspaceCollectionPath = await createTmpDir('malformed-workspace-col');
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), buildWorkspaceYml('Demo Workspace'));
const app = await launchElectronApp({ userDataPath });
const page = await waitForReadyPage(app);
await test.step('Create default workspace request', async () => {
await createCollection(page, 'Default Workspace Col', defaultCollectionPath);
await createRequest(page, 'Default Workspace Req', 'Default Workspace Col', {
url: 'https://echo.usebruno.com',
method: 'GET'
});
await openRequest(page, 'Default Workspace Col', 'Default Workspace Req', { persist: true });
});
await test.step('Switch to demo workspace, create active request, and close app', async () => {
await openWorkspaceFromDialog(app, page, workspacePath);
await expect(page.getByTestId('workspace-name')).toHaveText('Demo Workspace', { timeout: 10000 });
await createCollection(page, 'Malformed Workspace Col', malformedWorkspaceCollectionPath);
await createRequest(page, 'Malformed Workspace Req', 'Malformed Workspace Col', {
url: 'https://echo.usebruno.com',
method: 'GET'
});
await openRequest(page, 'Malformed Workspace Col', 'Malformed Workspace Req', { persist: true });
await expect(page.getByRole('tab', { name: 'Malformed Workspace Req' })).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(2000);
await closeElectronApp(app);
});
await test.step('Corrupt workspace config and verify app recovers to default workspace', async () => {
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), 'broken: [[[');
const app2 = await launchElectronApp({ userDataPath });
const page2 = await waitForReadyPage(app2);
await expect(page2.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 10000 });
await expect(page2.getByRole('tab', { name: 'Malformed Workspace Req' })).toHaveCount(0);
await page2.getByTestId('workspace-menu').click();
await expect(page2.locator('.workspace-item.active')).toContainText('My Workspace');
await expect(page2.locator('.workspace-item').filter({ hasText: 'Demo Workspace' })).toHaveCount(0);
await page2.keyboard.press('Escape');
await openRequest(page2, 'Default Workspace Col', 'Default Workspace Req');
await expect(page2.getByRole('tab', { name: 'Default Workspace Req' })).toBeVisible({ timeout: 10000 });
await closeElectronApp(app2);
});
});
});

View File

@@ -1428,6 +1428,23 @@ const openExampleFromSidebar = async (page: Page, requestName: string, exampleNa
await exampleRow.click();
};
type DialogOptions = {
showOpenDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>;
};
const openWorkspaceFromDialog = async (app: any, page: any, targetPath: string) => {
await app.evaluate(
({ dialog }: { dialog: DialogOptions }, workspacePath: string) => {
dialog.showOpenDialog = () =>
Promise.resolve({ canceled: false, filePaths: [workspacePath] });
},
targetPath
);
await page.getByTestId('workspace-menu').click();
await page.locator('.dropdown-item').filter({ hasText: 'Open workspace' }).click();
};
export {
waitForReadyPage,
closeAllCollections,
@@ -1479,7 +1496,8 @@ export {
typeIntoField,
readField,
createExampleFromSidebar,
openExampleFromSidebar
openExampleFromSidebar,
openWorkspaceFromDialog
};
export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };