From a57ecde1d0ca3d2eb1dda85d3a979da277c53897 Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Tue, 6 Jan 2026 21:57:25 +0530 Subject: [PATCH] improve: migration & default workspace handling --- .../src/store/default-workspace.js | 241 ++++- .../bruno-electron/src/store/preferences.js | 6 +- .../recovery-and-backup.spec.ts | 950 ++++++++++++++++++ 3 files changed, 1145 insertions(+), 52 deletions(-) create mode 100644 tests/workspace/default-workspace/recovery-and-backup.spec.ts diff --git a/packages/bruno-electron/src/store/default-workspace.js b/packages/bruno-electron/src/store/default-workspace.js index 4c66ae66d..e3c41668a 100644 --- a/packages/bruno-electron/src/store/default-workspace.js +++ b/packages/bruno-electron/src/store/default-workspace.js @@ -16,6 +16,7 @@ const OPENCOLLECTION_VERSION = '1.0.0'; const WORKSPACE_TYPE = 'workspace'; const DEFAULT_WORKSPACE_UID = 'default'; const MAX_WORKSPACE_CREATION_ATTEMPTS = 20; +const GLOBAL_ENV_BACKUP_FILE = 'global-environments-backup.json'; class DefaultWorkspaceManager { constructor() { @@ -23,6 +24,107 @@ class DefaultWorkspaceManager { this.initializationPromise = null; } + /** + * Finds all existing default workspace directories sorted by number (latest first) + */ + findExistingDefaultWorkspaces() { + const configDir = app.getPath('userData'); + const baseWorkspacePath = path.join(configDir, 'default-workspace'); + const workspaces = []; + + // Check base path + if (fs.existsSync(baseWorkspacePath)) { + workspaces.push({ path: baseWorkspacePath, index: 0 }); + } + + // Check numbered paths + for (let i = 1; i < MAX_WORKSPACE_CREATION_ATTEMPTS; i++) { + const numberedPath = `${baseWorkspacePath}-${i}`; + if (fs.existsSync(numberedPath)) { + workspaces.push({ path: numberedPath, index: i }); + } + } + + // Sort by index descending (latest first) + return workspaces.sort((a, b) => b.index - a.index).map((w) => w.path); + } + + /** + * Finds the latest valid default workspace from existing directories + */ + findLatestValidWorkspace() { + const workspaces = this.findExistingDefaultWorkspaces(); + for (const workspacePath of workspaces) { + if (this.isValidDefaultWorkspace(workspacePath)) { + return workspacePath; + } + } + return null; + } + + /** + * Recovers collections and environments from an existing workspace directory + */ + recoverDataFromWorkspace(workspacePath) { + const recovered = { collections: [], environments: [], activeEnvironmentUid: null }; + + try { + // Try to read workspace config for collections + const config = readWorkspaceConfig(workspacePath); + if (config.collections && Array.isArray(config.collections)) { + recovered.collections = config.collections.filter((c) => { + if (!isValidCollectionEntry(c)) return false; + const collectionPath = path.isAbsolute(c.path) ? c.path : path.resolve(workspacePath, c.path); + return isValidCollectionDirectory(collectionPath); + }); + } + if (config.activeEnvironmentUid) { + recovered.activeEnvironmentUid = config.activeEnvironmentUid; + } + } catch (error) { + console.error('Failed to read workspace config during recovery:', error); + } + + // Try to read environments from workspace environments directory + const envDir = path.join(workspacePath, 'environments'); + if (fs.existsSync(envDir)) { + try { + const envFiles = fs.readdirSync(envDir).filter((f) => f.endsWith('.yml')); + for (const file of envFiles) { + const envPath = path.join(envDir, file); + recovered.environments.push({ path: envPath, name: path.basename(file, '.yml') }); + } + } catch (error) { + console.error('Failed to read environments during recovery:', error); + } + } + + return recovered; + } + + /** + * Backs up global environments to filesystem + */ + backupGlobalEnvironments() { + try { + const globalEnvironments = globalEnvironmentsStore.getGlobalEnvironments(); + const activeUid = globalEnvironmentsStore.getActiveGlobalEnvironmentUid(); + + if (globalEnvironments && globalEnvironments.length > 0) { + const configDir = app.getPath('userData'); + const backupPath = path.join(configDir, GLOBAL_ENV_BACKUP_FILE); + const backup = { + environments: globalEnvironments, + activeGlobalEnvironmentUid: activeUid, + backupDate: new Date().toISOString() + }; + fs.writeFileSync(backupPath, JSON.stringify(backup, null, 2), 'utf8'); + } + } catch (error) { + console.error('Failed to backup global environments:', error); + } + } + getDefaultWorkspacePath() { if (this.defaultWorkspacePath) { return this.defaultWorkspacePath; @@ -43,7 +145,11 @@ class DefaultWorkspaceManager { preferences.general = {}; } preferences.general.defaultWorkspacePath = workspacePath; - await savePreferences(preferences); + try { + await savePreferences(preferences); + } catch (error) { + console.error('Failed to save preferences:', error); + } this.defaultWorkspacePath = workspacePath; @@ -76,6 +182,7 @@ class DefaultWorkspaceManager { const existingPath = this.getDefaultWorkspacePath(); + // Case 1: Valid workspace exists at stored path if (this.isValidDefaultWorkspace(existingPath)) { this.defaultWorkspacePath = existingPath; return { @@ -86,8 +193,25 @@ class DefaultWorkspaceManager { this.initializationPromise = (async () => { try { + // Case 2: No path in preferences - check for existing default workspaces + if (!existingPath) { + const latestValid = this.findLatestValidWorkspace(); + if (latestValid) { + await this.setDefaultWorkspacePath(latestValid); + return { workspacePath: latestValid, workspaceUid: this.getDefaultWorkspaceUid() }; + } + } + + // Case 3: Path exists but workspace is broken - try recovery + const hasExistingPath = existingPath && fs.existsSync(existingPath); + const recoverySource = hasExistingPath ? existingPath : this.findExistingDefaultWorkspaces()[0]; + const recoveredData = recoverySource ? this.recoverDataFromWorkspace(recoverySource) : null; + const shouldMigrate = this.needsMigration(); - const newWorkspacePath = await this.initializeDefaultWorkspace({ migrateFromPreferences: shouldMigrate }); + const newWorkspacePath = await this.initializeDefaultWorkspace({ + migrateFromPreferences: shouldMigrate, + recoveredData + }); return { workspacePath: newWorkspacePath, @@ -105,7 +229,7 @@ class DefaultWorkspaceManager { } async initializeDefaultWorkspace(options = {}) { - const { migrateFromPreferences = true } = options; + const { migrateFromPreferences = true, recoveredData = null } = options; const configDir = app.getPath('userData'); const baseWorkspacePath = path.join(configDir, 'default-workspace'); @@ -136,9 +260,31 @@ class DefaultWorkspaceManager { docs: '' }; - let migrationCleanupFn = null; + // Copy recovered environments to new workspace + if (recoveredData?.environments?.length > 0) { + const envDir = path.join(workspacePath, 'environments'); + for (const env of recoveredData.environments) { + try { + const destPath = path.join(envDir, `${env.name}.yml`); + if (fs.existsSync(env.path)) { + fs.copyFileSync(env.path, destPath); + } + } catch (error) { + console.error('Failed to copy environment:', env.name, error); + } + } + if (recoveredData.activeEnvironmentUid) { + workspaceConfig.activeEnvironmentUid = recoveredData.activeEnvironmentUid; + } + } + + // Apply recovered collections first (lower priority) + if (recoveredData?.collections?.length > 0) { + workspaceConfig.collections = recoveredData.collections; + } + if (migrateFromPreferences) { - migrationCleanupFn = await this.migrateFromPreferences(workspacePath, workspaceConfig); + await this.migrateFromPreferences(workspacePath, workspaceConfig); } const yamlContent = generateYamlContent(workspaceConfig); @@ -146,10 +292,6 @@ class DefaultWorkspaceManager { await this.setDefaultWorkspacePath(workspacePath); - if (migrationCleanupFn) { - migrationCleanupFn(); - } - return workspacePath; } @@ -157,14 +299,18 @@ class DefaultWorkspaceManager { const Store = require('electron-store'); const preferencesStore = new Store({ name: 'preferences' }); - let shouldClearGlobalEnvStore = false; - let shouldDeleteWorkspaceDocs = false; - try { const lastOpenedCollections = preferencesStore.get('lastOpenedCollections', []); if (lastOpenedCollections && lastOpenedCollections.length > 0) { - const seenPaths = new Set(); + // Build set of existing paths from recovered collections + const existingPaths = new Set( + (workspaceConfig.collections || []).map((c) => { + const collPath = path.isAbsolute(c.path) ? c.path : path.resolve(workspacePath, c.path); + return path.normalize(collPath); + }) + ); + const collections = lastOpenedCollections .map((collectionPath) => { if (!collectionPath || typeof collectionPath !== 'string') { @@ -173,27 +319,26 @@ class DefaultWorkspaceManager { const absolutePath = path.resolve(collectionPath); const normalizedPath = path.normalize(absolutePath); - if (seenPaths.has(normalizedPath)) { + if (existingPaths.has(normalizedPath)) { return null; } - seenPaths.add(normalizedPath); + existingPaths.add(normalizedPath); if (!isValidCollectionDirectory(absolutePath)) { return null; } - const collectionName = path.basename(absolutePath); - - return { - path: absolutePath, - name: collectionName - }; + return { path: absolutePath, name: path.basename(absolutePath) }; }) .filter((collection) => isValidCollectionEntry(collection)); - workspaceConfig.collections = collections; + // Merge: preference collections come after recovered ones + workspaceConfig.collections = [...(workspaceConfig.collections || []), ...collections]; } + // Backup global environments before migrating + this.backupGlobalEnvironments(); + const globalEnvironments = globalEnvironmentsStore.getGlobalEnvironments(); const activeGlobalEnvironmentUid = globalEnvironmentsStore.getActiveGlobalEnvironmentUid(); @@ -201,57 +346,53 @@ class DefaultWorkspaceManager { const { stringifyEnvironment } = require('@usebruno/filestore'); const environmentsDir = path.join(workspacePath, 'environments'); + // Get existing environment names to avoid overwriting recovered ones + let existingEnvNames = []; + if (fs.existsSync(environmentsDir)) { + try { + existingEnvNames = fs.readdirSync(environmentsDir) + .filter((f) => f.endsWith('.yml')) + .map((f) => f.replace('.yml', '')); + } catch (error) { + console.error('Failed to read environments directory:', error); + } + } + const existingEnvs = new Set(existingEnvNames); + for (const env of globalEnvironments) { if (!env || !env.name || typeof env.name !== 'string') { continue; } + // Skip if environment already exists from recovery + if (existingEnvs.has(env.name)) { + continue; + } + const envFilePath = path.join(environmentsDir, `${env.name}.yml`); - - const environment = { - name: env.name, - variables: env.variables || [] - }; - + const environment = { name: env.name, variables: env.variables || [] }; const content = stringifyEnvironment(environment, { format: 'yml' }); await writeFile(envFilePath, content); - if (env.uid === activeGlobalEnvironmentUid) { - const newUid = generateUidBasedOnHash(envFilePath); - workspaceConfig.activeEnvironmentUid = newUid; + if (env.uid === activeGlobalEnvironmentUid && !workspaceConfig.activeEnvironmentUid) { + workspaceConfig.activeEnvironmentUid = generateUidBasedOnHash(envFilePath); } } - - shouldClearGlobalEnvStore = true; } const defaultWorkspaceDocs = preferencesStore.get('preferences.defaultWorkspaceDocs', ''); - if (defaultWorkspaceDocs) { + if (defaultWorkspaceDocs && !workspaceConfig.docs) { workspaceConfig.docs = defaultWorkspaceDocs; - shouldDeleteWorkspaceDocs = true; } } catch (error) { console.error('Failed to migrate from preferences:', error); } - - return () => { - try { - if (shouldClearGlobalEnvStore) { - const globalEnvStore = new Store({ name: 'global-environments' }); - globalEnvStore.clear(); - } - if (shouldDeleteWorkspaceDocs) { - preferencesStore.delete('preferences.defaultWorkspaceDocs'); - } - } catch (cleanupError) { - console.error('Failed to cleanup after migration:', cleanupError); - } - }; } needsMigration() { const workspacePath = this.getDefaultWorkspacePath(); - if (workspacePath && fs.existsSync(workspacePath)) { + // Only skip migration if workspace is valid, not just if it exists + if (workspacePath && this.isValidDefaultWorkspace(workspacePath)) { return false; } diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index 7673fdf0f..b8a815f32 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -50,7 +50,8 @@ const defaultPreferences = { hasLaunchedBefore: false }, general: { - defaultCollectionLocation: '' + defaultCollectionLocation: '', + defaultWorkspacePath: '' }, autoSave: { enabled: false, @@ -103,7 +104,8 @@ const preferencesSchema = Yup.object().shape({ hasLaunchedBefore: Yup.boolean() }), general: Yup.object({ - defaultCollectionLocation: Yup.string().max(1024).nullable() + defaultCollectionLocation: Yup.string().max(1024).nullable(), + defaultWorkspacePath: Yup.string().max(1024).nullable() }), autoSave: Yup.object({ enabled: Yup.boolean(), diff --git a/tests/workspace/default-workspace/recovery-and-backup.spec.ts b/tests/workspace/default-workspace/recovery-and-backup.spec.ts new file mode 100644 index 000000000..123f59ffc --- /dev/null +++ b/tests/workspace/default-workspace/recovery-and-backup.spec.ts @@ -0,0 +1,950 @@ +import path from 'path'; +import fs from 'fs'; +import { test, expect } from '../../../playwright'; + +test.describe('Default Workspace Recovery and Backup', () => { + test.describe('Global Environments Backup', () => { + test('should create backup file for global environments during migration', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('global-env-backup'); + + // Setup: Create global-environments.json + const globalEnvData = { + environments: [ + { + uid: 'env1abcdefghijk123456', + name: 'Production', + variables: [ + { uid: 'var1abcdefghijk123456', name: 'API_URL', value: 'https://api.prod.com', secret: false, type: 'text', enabled: true } + ] + }, + { + uid: 'env2abcdefghijk123456', + name: 'Staging', + variables: [ + { uid: 'var2abcdefghijk123456', name: 'API_URL', value: 'https://api.staging.com', secret: false, type: 'text', enabled: true } + ] + } + ], + activeGlobalEnvironmentUid: 'env1abcdefghijk123456' + }; + fs.writeFileSync( + path.join(userDataPath, 'global-environments.json'), + JSON.stringify(globalEnvData) + ); + + // Also add lastOpenedCollections to trigger migration + const collectionPath = path.join(userDataPath, 'test-collection'); + fs.mkdirSync(collectionPath, { recursive: true }); + fs.writeFileSync( + path.join(collectionPath, 'bruno.json'), + JSON.stringify({ version: '1', name: 'Test', type: 'collection' }) + ); + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ lastOpenedCollections: [collectionPath] }) + ); + + // Launch app - should trigger migration and create backup + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // Verify backup file was created + const backupPath = path.join(userDataPath, 'global-environments-backup.json'); + expect(fs.existsSync(backupPath)).toBe(true); + + // Verify backup content + const backup = JSON.parse(fs.readFileSync(backupPath, 'utf8')); + expect(backup.environments).toHaveLength(2); + expect(backup.environments[0].name).toBe('Production'); + expect(backup.environments[1].name).toBe('Staging'); + expect(backup.activeGlobalEnvironmentUid).toBe('env1abcdefghijk123456'); + expect(backup.backupDate).toBeDefined(); + + await app.context().close(); + await app.close(); + }); + + test('should preserve global environments backup across multiple app restarts', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('global-env-backup-persist'); + + // Setup: Create legacy global environments + const globalEnvData = { + environments: [ + { uid: 'env1abcdefghijk123456', name: 'Dev', variables: [] } + ], + activeGlobalEnvironmentUid: 'env1abcdefghijk123456' + }; + fs.writeFileSync( + path.join(userDataPath, 'global-environments.json'), + JSON.stringify(globalEnvData) + ); + + // Add collection to trigger migration + const collectionPath = path.join(userDataPath, 'test-collection'); + fs.mkdirSync(collectionPath, { recursive: true }); + fs.writeFileSync( + path.join(collectionPath, 'bruno.json'), + JSON.stringify({ version: '1', name: 'Test', type: 'collection' }) + ); + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ lastOpenedCollections: [collectionPath] }) + ); + + // First launch + const app1 = await launchElectronApp({ userDataPath }); + const page1 = await app1.firstWindow(); + await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + await app1.close(); + + // Verify backup exists + const backupPath = path.join(userDataPath, 'global-environments-backup.json'); + expect(fs.existsSync(backupPath)).toBe(true); + const backupContentAfterFirst = fs.readFileSync(backupPath, 'utf8'); + + // Second launch - backup should still exist + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // Backup should not be modified on second launch + expect(fs.existsSync(backupPath)).toBe(true); + const backupContentAfterSecond = fs.readFileSync(backupPath, 'utf8'); + expect(backupContentAfterSecond).toBe(backupContentAfterFirst); + + await app2.context().close(); + await app2.close(); + }); + }); + + test.describe('lastOpenedCollections Preservation', () => { + test('should NOT delete lastOpenedCollections from preferences after migration', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('preserve-last-opened'); + + // Setup: Create a valid collection + const collectionPath = path.join(userDataPath, 'my-collection'); + fs.mkdirSync(collectionPath, { recursive: true }); + fs.writeFileSync( + path.join(collectionPath, 'bruno.json'), + JSON.stringify({ version: '1', name: 'My Collection', type: 'collection' }) + ); + + // Setup: Create preferences with lastOpenedCollections + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ lastOpenedCollections: [collectionPath] }) + ); + + // Launch app - triggers migration + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + await app.close(); + + // Verify lastOpenedCollections is still in preferences + const prefsPath = path.join(userDataPath, 'preferences.json'); + const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf8')); + expect(prefs.lastOpenedCollections).toBeDefined(); + expect(prefs.lastOpenedCollections).toContain(collectionPath); + }); + }); + + test.describe('Workspace Discovery (No Path in Preferences)', () => { + test('should find and use existing valid default workspace when path not in preferences', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('discover-existing'); + + // Setup: Create a valid default workspace manually (without setting in preferences) + const workspacePath = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(workspacePath, { recursive: true }); + fs.mkdirSync(path.join(workspacePath, 'collections'), { recursive: true }); + fs.mkdirSync(path.join(workspacePath, 'environments'), { recursive: true }); + fs.writeFileSync( + path.join(workspacePath, 'workspace.yml'), + `opencollection: 1.0.0 +info: + name: "My Workspace" + type: workspace +collections: +specs: +docs: '' +` + ); + + // Create empty preferences (no defaultWorkspacePath) + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({}) + ); + + // Launch app - should discover and use existing workspace + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // UI always shows "My Workspace" + await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + + // Should NOT create a new workspace + expect(fs.existsSync(path.join(userDataPath, 'default-workspace-1'))).toBe(false); + + // Preferences should now have the path set (electron-store saves under 'preferences' key) + const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8')); + expect(prefs.preferences?.general?.defaultWorkspacePath).toBe(workspacePath); + + await app.context().close(); + await app.close(); + }); + + test('should find latest numbered workspace when multiple exist and path not in preferences', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('discover-numbered'); + + // Setup: Create multiple numbered workspaces + const workspace0 = path.join(userDataPath, 'default-workspace'); + const workspace1 = path.join(userDataPath, 'default-workspace-1'); + const workspace2 = path.join(userDataPath, 'default-workspace-2'); + + for (const wsPath of [workspace0, workspace1, workspace2]) { + fs.mkdirSync(wsPath, { recursive: true }); + fs.mkdirSync(path.join(wsPath, 'environments'), { recursive: true }); + fs.writeFileSync( + path.join(wsPath, 'workspace.yml'), + `opencollection: 1.0.0 +info: + name: "My Workspace" + type: workspace +collections: +specs: +docs: '' +` + ); + } + + // Create empty preferences + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({}) + ); + + // Launch app - should use workspace-2 (latest/highest number) + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + + // Verify the correct workspace was selected (workspace-2) + const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8')); + expect(prefs.preferences?.general?.defaultWorkspacePath).toBe(workspace2); + + // No new workspace should be created + expect(fs.existsSync(path.join(userDataPath, 'default-workspace-3'))).toBe(false); + + await app.context().close(); + await app.close(); + }); + + test('should skip invalid workspaces and use latest valid one', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('discover-skip-invalid'); + + // Setup: Create workspaces where latest is invalid + const workspace0 = path.join(userDataPath, 'default-workspace'); + const workspace1 = path.join(userDataPath, 'default-workspace-1'); + const workspace2 = path.join(userDataPath, 'default-workspace-2'); + + // workspace-0: valid + fs.mkdirSync(workspace0, { recursive: true }); + fs.writeFileSync( + path.join(workspace0, 'workspace.yml'), + `opencollection: 1.0.0 +info: + name: "My Workspace" + type: workspace +collections: +specs: +docs: '' +` + ); + + // workspace-1: valid (should be selected as highest valid) + fs.mkdirSync(workspace1, { recursive: true }); + fs.writeFileSync( + path.join(workspace1, 'workspace.yml'), + `opencollection: 1.0.0 +info: + name: "My Workspace" + type: workspace +collections: +specs: +docs: '' +` + ); + + // workspace-2: invalid (corrupt YAML) + fs.mkdirSync(workspace2, { recursive: true }); + fs.writeFileSync(path.join(workspace2, 'workspace.yml'), 'invalid: yaml: [[['); + + // Create empty preferences + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({}) + ); + + // Launch app - should skip workspace-2, use workspace-1 + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + + // Verify workspace-1 was selected (not workspace-2 which is broken) + const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8')); + expect(prefs.preferences?.general?.defaultWorkspacePath).toBe(workspace1); + + await app.context().close(); + await app.close(); + }); + }); + + test.describe('Recovery from Broken Workspace', () => { + test('should recover collections from broken workspace to new workspace', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('recover-collections'); + + // Setup: Create a valid collection + const collectionPath = path.join(userDataPath, 'external-collection'); + fs.mkdirSync(collectionPath, { recursive: true }); + fs.writeFileSync( + path.join(collectionPath, 'bruno.json'), + JSON.stringify({ version: '1', name: 'External Collection', type: 'collection' }) + ); + + // Setup: Create a "broken" workspace with valid workspace.yml but invalid internal state + const brokenWorkspace = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(brokenWorkspace, { recursive: true }); + fs.mkdirSync(path.join(brokenWorkspace, 'environments'), { recursive: true }); + // Write a valid workspace.yml that references the collection + fs.writeFileSync( + path.join(brokenWorkspace, 'workspace.yml'), + `opencollection: 1.0.0 +info: + name: "Old Workspace" + type: workspace +collections: + - name: "External Collection" + path: "${collectionPath}" +specs: +docs: '' +` + ); + + // Now corrupt it + fs.writeFileSync(path.join(brokenWorkspace, 'workspace.yml'), 'invalid: yaml: [[['); + + // Set preferences to point to broken workspace + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + general: { defaultWorkspacePath: brokenWorkspace } + }) + ); + + // Launch app - should recover collections and create new workspace + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // New workspace should be created + const newWorkspace = path.join(userDataPath, 'default-workspace-1'); + expect(fs.existsSync(newWorkspace)).toBe(true); + + await app.context().close(); + await app.close(); + }); + + test('should recover environments from broken workspace to new workspace', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('recover-envs'); + + // Setup: Create a workspace with environments + const brokenWorkspace = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(brokenWorkspace, { recursive: true }); + const envDir = path.join(brokenWorkspace, 'environments'); + fs.mkdirSync(envDir, { recursive: true }); + + // Create environment files + fs.writeFileSync( + path.join(envDir, 'production.yml'), + `name: production +variables: + - uid: var1 + name: API_URL + value: https://api.prod.com + enabled: true + secret: false + type: text +` + ); + fs.writeFileSync( + path.join(envDir, 'staging.yml'), + `name: staging +variables: + - uid: var2 + name: API_URL + value: https://api.staging.com + enabled: true + secret: false + type: text +` + ); + + // Create valid workspace.yml first + fs.writeFileSync( + path.join(brokenWorkspace, 'workspace.yml'), + `opencollection: 1.0.0 +info: + name: "Old Workspace" + type: workspace +collections: +specs: +docs: '' +` + ); + + // Now corrupt it + fs.writeFileSync(path.join(brokenWorkspace, 'workspace.yml'), 'broken: [[['); + + // Set preferences + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + general: { defaultWorkspacePath: brokenWorkspace } + }) + ); + + // Launch app + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // New workspace should have recovered environments + const newWorkspace = path.join(userDataPath, 'default-workspace-1'); + const newEnvDir = path.join(newWorkspace, 'environments'); + expect(fs.existsSync(newEnvDir)).toBe(true); + expect(fs.existsSync(path.join(newEnvDir, 'production.yml'))).toBe(true); + expect(fs.existsSync(path.join(newEnvDir, 'staging.yml'))).toBe(true); + + await app.context().close(); + await app.close(); + }); + + test('should use lastOpenedCollections as fallback when workspace config parsing fails', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('recover-fallback'); + + // Setup: Create a valid collection + const collectionPath = path.join(userDataPath, 'fallback-collection'); + fs.mkdirSync(collectionPath, { recursive: true }); + fs.writeFileSync( + path.join(collectionPath, 'bruno.json'), + JSON.stringify({ version: '1', name: 'Fallback Collection', type: 'collection' }) + ); + + // Setup: Create broken workspace with NO valid config to recover from + const brokenWorkspace = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(brokenWorkspace, { recursive: true }); + fs.writeFileSync(path.join(brokenWorkspace, 'workspace.yml'), 'totally: broken: [[['); + + // Set preferences with lastOpenedCollections AND point to broken workspace + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + general: { defaultWorkspacePath: brokenWorkspace }, + lastOpenedCollections: [collectionPath] + }) + ); + + // Launch app + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // New workspace should have the collection from lastOpenedCollections + const newWorkspace = path.join(userDataPath, 'default-workspace-1'); + expect(fs.existsSync(newWorkspace)).toBe(true); + + const workspaceYml = fs.readFileSync(path.join(newWorkspace, 'workspace.yml'), 'utf8'); + expect(workspaceYml).toContain('fallback-collection'); + + await app.context().close(); + await app.close(); + }); + }); + + test.describe('Recovery from Non-Existent Workspace Path', () => { + test('should recover from previously created workspace when path in preferences does not exist', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('recover-from-old'); + + // Setup: Create a valid collection + const collectionPath = path.join(userDataPath, 'old-collection'); + fs.mkdirSync(collectionPath, { recursive: true }); + fs.writeFileSync( + path.join(collectionPath, 'bruno.json'), + JSON.stringify({ version: '1', name: 'Old Collection', type: 'collection' }) + ); + + // Setup: Create an old default workspace (simulating previously created) + const oldWorkspace = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(oldWorkspace, { recursive: true }); + fs.mkdirSync(path.join(oldWorkspace, 'environments'), { recursive: true }); + fs.writeFileSync( + path.join(oldWorkspace, 'workspace.yml'), + `opencollection: 1.0.0 +info: + name: "My Workspace" + type: workspace +collections: + - name: "Old Collection" + path: "${collectionPath}" +specs: +docs: '' +` + ); + + // Set preferences to point to non-existent path + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + general: { defaultWorkspacePath: '/non/existent/path/workspace' } + }) + ); + + // Launch app - should find and use the existing valid workspace + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + + // Since path doesn't exist but we have a valid workspace, it should use it + // OR create a new one recovering from the existing one + const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8')); + // Either uses the existing workspace or creates workspace-1 + const usedExisting = prefs.preferences?.general?.defaultWorkspacePath === oldWorkspace; + const createdNew = fs.existsSync(path.join(userDataPath, 'default-workspace-1')); + expect(usedExisting || createdNew).toBe(true); + + await app.context().close(); + await app.close(); + }); + + test('should recover from latest workspace when path does not exist and multiple workspaces exist', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('recover-from-latest'); + + // Create collection + const collectionPath = path.join(userDataPath, 'latest-collection'); + fs.mkdirSync(collectionPath, { recursive: true }); + fs.writeFileSync( + path.join(collectionPath, 'bruno.json'), + JSON.stringify({ version: '1', name: 'Latest Collection', type: 'collection' }) + ); + + // Create older collection + const oldCollectionPath = path.join(userDataPath, 'old-collection'); + fs.mkdirSync(oldCollectionPath, { recursive: true }); + fs.writeFileSync( + path.join(oldCollectionPath, 'bruno.json'), + JSON.stringify({ version: '1', name: 'Old Collection', type: 'collection' }) + ); + + // Create workspace-0 (older) + const workspace0 = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(workspace0, { recursive: true }); + fs.mkdirSync(path.join(workspace0, 'environments'), { recursive: true }); + fs.writeFileSync( + path.join(workspace0, 'workspace.yml'), + `opencollection: 1.0.0 +info: + name: "My Workspace" + type: workspace +collections: + - name: "Old Collection" + path: "${oldCollectionPath}" +specs: +docs: '' +` + ); + + // Create workspace-1 (newer - should be used) + const workspace1 = path.join(userDataPath, 'default-workspace-1'); + fs.mkdirSync(workspace1, { recursive: true }); + fs.mkdirSync(path.join(workspace1, 'environments'), { recursive: true }); + fs.writeFileSync( + path.join(workspace1, 'workspace.yml'), + `opencollection: 1.0.0 +info: + name: "My Workspace" + type: workspace +collections: + - name: "Latest Collection" + path: "${collectionPath}" +specs: +docs: '' +` + ); + + // Set preferences to non-existent path + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + general: { defaultWorkspacePath: '/deleted/workspace/path' } + }) + ); + + // Launch app - should use workspace-1 (latest valid) + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + + // Verify workspace-1 was used (or workspace-2 was created recovering from workspace-1) + const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8')); + const usedWorkspace1 = prefs.preferences?.general?.defaultWorkspacePath === workspace1; + const createdWorkspace2 = fs.existsSync(path.join(userDataPath, 'default-workspace-2')); + expect(usedWorkspace1 || createdWorkspace2).toBe(true); + + await app.context().close(); + await app.close(); + }); + }); + + test.describe('App Restart After Breaking Workspace', () => { + test('should recover data after workspace is corrupted between app restarts', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('restart-after-break'); + + // Setup collection + const collectionPath = path.join(userDataPath, 'important-collection'); + fs.mkdirSync(collectionPath, { recursive: true }); + fs.writeFileSync( + path.join(collectionPath, 'bruno.json'), + JSON.stringify({ version: '1', name: 'Important Collection', type: 'collection' }) + ); + + // First launch - creates workspace + const app1 = await launchElectronApp({ userDataPath }); + const page1 = await app1.firstWindow(); + await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // Verify workspace was created + const workspacePath = path.join(userDataPath, 'default-workspace'); + expect(fs.existsSync(workspacePath)).toBe(true); + + await app1.close(); + + // Now add collection to the workspace + const workspaceYmlPath = path.join(workspacePath, 'workspace.yml'); + fs.writeFileSync( + workspaceYmlPath, + `opencollection: 1.0.0 +info: + name: "My Workspace" + type: workspace +collections: + - name: "Important Collection" + path: "${collectionPath}" +specs: +docs: '' +` + ); + + // Create environment in workspace + const envDir = path.join(workspacePath, 'environments'); + fs.mkdirSync(envDir, { recursive: true }); + fs.writeFileSync( + path.join(envDir, 'myenv.yml'), + `name: myenv +variables: + - uid: v1 + name: KEY + value: secret123 + enabled: true + secret: false + type: text +` + ); + + // CORRUPT the workspace + fs.writeFileSync(workspaceYmlPath, 'corrupted: [[['); + + // Second launch - should recover + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // New workspace should exist + const newWorkspace = path.join(userDataPath, 'default-workspace-1'); + expect(fs.existsSync(newWorkspace)).toBe(true); + + // Environment should be recovered + expect(fs.existsSync(path.join(newWorkspace, 'environments', 'myenv.yml'))).toBe(true); + + await app2.context().close(); + await app2.close(); + }); + + test('should handle workspace deleted between app restarts', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('restart-after-delete'); + + // First launch - creates workspace + const app1 = await launchElectronApp({ userDataPath }); + const page1 = await app1.firstWindow(); + await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + const workspacePath = path.join(userDataPath, 'default-workspace'); + expect(fs.existsSync(workspacePath)).toBe(true); + + await app1.close(); + + // DELETE the workspace directory + fs.rmSync(workspacePath, { recursive: true, force: true }); + expect(fs.existsSync(workspacePath)).toBe(false); + + // Second launch - should create new workspace + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // New workspace should be created at default-workspace (since it was deleted) + expect(fs.existsSync(workspacePath)).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'workspace.yml'))).toBe(true); + + await app2.context().close(); + await app2.close(); + }); + + test('should preserve all data through multiple corruption and recovery cycles', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('multiple-recovery-cycles'); + + // Create collection + const collectionPath = path.join(userDataPath, 'persistent-collection'); + fs.mkdirSync(collectionPath, { recursive: true }); + fs.writeFileSync( + path.join(collectionPath, 'bruno.json'), + JSON.stringify({ version: '1', name: 'Persistent Collection', type: 'collection' }) + ); + + // Create preferences with lastOpenedCollections (no global environments for simpler test) + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ lastOpenedCollections: [collectionPath] }) + ); + + // First launch + const app1 = await launchElectronApp({ userDataPath }); + const page1 = await app1.firstWindow(); + await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + await app1.close(); + + // Verify workspace-0 created + const ws0 = path.join(userDataPath, 'default-workspace'); + expect(fs.existsSync(ws0)).toBe(true); + + // Add an environment to workspace-0 + const envDir0 = path.join(ws0, 'environments'); + fs.mkdirSync(envDir0, { recursive: true }); + fs.writeFileSync( + path.join(envDir0, 'PersistentEnv.yml'), + `name: PersistentEnv +variables: [] +` + ); + + // Corrupt workspace-0 + fs.writeFileSync(path.join(ws0, 'workspace.yml'), 'broken1: [[['); + + // Second launch - recovery to workspace-1 + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + await app2.close(); + + // Verify workspace-1 created with recovered data + const ws1 = path.join(userDataPath, 'default-workspace-1'); + expect(fs.existsSync(ws1)).toBe(true); + expect(fs.existsSync(path.join(ws1, 'environments', 'PersistentEnv.yml'))).toBe(true); + + const ws1Yml = fs.readFileSync(path.join(ws1, 'workspace.yml'), 'utf8'); + expect(ws1Yml).toContain('persistent-collection'); + + // Corrupt workspace-1 + fs.writeFileSync(path.join(ws1, 'workspace.yml'), 'broken2: [[['); + + // Third launch - recovery to workspace-2 + const app3 = await launchElectronApp({ userDataPath }); + const page3 = await app3.firstWindow(); + await page3.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // Verify workspace-2 created with all data preserved + const ws2 = path.join(userDataPath, 'default-workspace-2'); + expect(fs.existsSync(ws2)).toBe(true); + expect(fs.existsSync(path.join(ws2, 'environments', 'PersistentEnv.yml'))).toBe(true); + + const ws2Yml = fs.readFileSync(path.join(ws2, 'workspace.yml'), 'utf8'); + expect(ws2Yml).toContain('persistent-collection'); + + await app3.context().close(); + await app3.close(); + }); + }); + + test.describe('Edge Cases', () => { + test('should handle empty environments directory during recovery', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('empty-env-dir'); + + // Create workspace with empty environments dir + const workspace = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(workspace, { recursive: true }); + fs.mkdirSync(path.join(workspace, 'environments'), { recursive: true }); + fs.writeFileSync(path.join(workspace, 'workspace.yml'), 'broken: [[['); + + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ general: { defaultWorkspacePath: workspace } }) + ); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // Should not crash, new workspace created + const newWorkspace = path.join(userDataPath, 'default-workspace-1'); + expect(fs.existsSync(newWorkspace)).toBe(true); + + await app.context().close(); + await app.close(); + }); + + test('should handle missing environments directory during recovery', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('missing-env-dir'); + + // Create workspace WITHOUT environments dir + const workspace = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(workspace, { recursive: true }); + fs.writeFileSync(path.join(workspace, 'workspace.yml'), 'broken: [[['); + + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ general: { defaultWorkspacePath: workspace } }) + ); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // Should not crash + expect(fs.existsSync(path.join(userDataPath, 'default-workspace-1'))).toBe(true); + + await app.context().close(); + await app.close(); + }); + + test('should deduplicate collections between recovered and preference sources', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('dedup-collections'); + + // Create collection + const collectionPath = path.join(userDataPath, 'shared-collection'); + fs.mkdirSync(collectionPath, { recursive: true }); + fs.writeFileSync( + path.join(collectionPath, 'bruno.json'), + JSON.stringify({ version: '1', name: 'Shared Collection', type: 'collection' }) + ); + + // Create workspace with the collection (but it will be corrupted) + const workspace = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(workspace, { recursive: true }); + fs.mkdirSync(path.join(workspace, 'environments'), { recursive: true }); + // Workspace is created but immediately corrupted - no valid config to recover collections from + fs.writeFileSync(path.join(workspace, 'workspace.yml'), 'broken: [[['); + + // Add same collection to lastOpenedCollections + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + general: { defaultWorkspacePath: workspace }, + lastOpenedCollections: [collectionPath] + }) + ); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // New workspace should have collection only ONCE (no duplicates) + const newWorkspace = path.join(userDataPath, 'default-workspace-1'); + const yml = fs.readFileSync(path.join(newWorkspace, 'workspace.yml'), 'utf8'); + + // Count collection entries by counting "- name:" patterns (each collection has one) + const collectionEntries = yml.match(/- name:/g); + expect(collectionEntries).toHaveLength(1); + + await app.context().close(); + await app.close(); + }); + + test('should not overwrite recovered environments with global environments of same name', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('env-no-overwrite'); + + // Create workspace with environment + const workspace = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(workspace, { recursive: true }); + const envDir = path.join(workspace, 'environments'); + fs.mkdirSync(envDir, { recursive: true }); + + // Environment in workspace (should be preserved) + fs.writeFileSync( + path.join(envDir, 'Production.yml'), + `name: Production +variables: + - uid: v1 + name: URL + value: workspace-value + enabled: true + secret: false + type: text +` + ); + + // Corrupt workspace.yml + fs.writeFileSync(path.join(workspace, 'workspace.yml'), 'broken: [[['); + + // Create global environments with same name but different value + fs.writeFileSync( + path.join(userDataPath, 'global-environments.json'), + JSON.stringify({ + environments: [{ + uid: 'env1abcdefghijk123456', + name: 'Production', + variables: [{ uid: 'var1abcdefghijk123456', name: 'URL', value: 'global-value', secret: false, type: 'text', enabled: true }] + }], + activeGlobalEnvironmentUid: 'env1abcdefghijk123456' + }) + ); + + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ general: { defaultWorkspacePath: workspace } }) + ); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // Check new workspace has the recovered environment (not overwritten by global) + const newWorkspace = path.join(userDataPath, 'default-workspace-1'); + const envContent = fs.readFileSync(path.join(newWorkspace, 'environments', 'Production.yml'), 'utf8'); + expect(envContent).toContain('workspace-value'); + expect(envContent).not.toContain('global-value'); + + await app.context().close(); + await app.close(); + }); + }); +});