improve: migration & default workspace handling

This commit is contained in:
naman-bruno
2026-01-06 21:57:25 +05:30
parent 8300abe086
commit a57ecde1d0
3 changed files with 1145 additions and 52 deletions

View File

@@ -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;
}

View File

@@ -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(),

View File

@@ -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();
});
});
});