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 7563cf666..27caa3a65 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -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'; } diff --git a/tests/snapshots/workspace.spec.ts b/tests/snapshots/workspace.spec.ts new file mode 100644 index 000000000..d25db0e80 --- /dev/null +++ b/tests/snapshots/workspace.spec.ts @@ -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); + }); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 316d27f5d..c23a5767d 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -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 };