From 8b0f41e3cb7a1ad6ec10286d10a018270ddd49b5 Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Mon, 15 Dec 2025 15:10:53 +0530 Subject: [PATCH] fix: default workspace error checking (#6379) * fix: default workspace error checking * add: tests * fixes * fix * fixes * fixes * fix * fixes * fix * chore: close app context in tests --------- Co-authored-by: Bijin A B --- packages/bruno-electron/src/app/apiSpecs.js | 10 +- .../src/app/workspace-watcher.js | 11 +- packages/bruno-electron/src/ipc/workspace.js | 88 ++++--- .../src/store/default-workspace.js | 92 +++---- .../src/utils/workspace-config.js | 13 +- tests/utils/page/actions.ts | 3 +- .../default-workspace.spec.ts | 230 ++++++++++++++++++ .../default-workspace/migration.spec.ts | 208 ++++++++++++++++ 8 files changed, 562 insertions(+), 93 deletions(-) create mode 100644 tests/workspace/default-workspace/default-workspace.spec.ts create mode 100644 tests/workspace/default-workspace/migration.spec.ts diff --git a/packages/bruno-electron/src/app/apiSpecs.js b/packages/bruno-electron/src/app/apiSpecs.js index 7bd68ba18..8980d2f52 100644 --- a/packages/bruno-electron/src/app/apiSpecs.js +++ b/packages/bruno-electron/src/app/apiSpecs.js @@ -1,7 +1,7 @@ const { dialog, ipcMain } = require('electron'); const { normalizeAndResolvePath } = require('../utils/filesystem'); const { generateUidBasedOnHash } = require('../utils/common'); -const { generateYamlContent } = require('../utils/workspace-config'); +const { generateYamlContent, getWorkspaceUid } = require('../utils/workspace-config'); const normalizeWorkspaceConfig = (config) => { return { @@ -75,8 +75,12 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => { fs.writeFileSync(workspaceFilePath, updatedYamlContent); const normalizedConfig = normalizeWorkspaceConfig(workspaceConfig); - const workspaceUid = generateUidBasedOnHash(options.workspacePath); - win.webContents.send('main:workspace-config-updated', options.workspacePath, workspaceUid, normalizedConfig); + const workspaceUid = getWorkspaceUid(options.workspacePath); + const isDefault = workspaceUid === 'default'; + win.webContents.send('main:workspace-config-updated', options.workspacePath, workspaceUid, { + ...normalizedConfig, + type: isDefault ? 'default' : normalizedConfig.type + }); } } } diff --git a/packages/bruno-electron/src/app/workspace-watcher.js b/packages/bruno-electron/src/app/workspace-watcher.js index b63ca0d7d..261e0e6da 100644 --- a/packages/bruno-electron/src/app/workspace-watcher.js +++ b/packages/bruno-electron/src/app/workspace-watcher.js @@ -4,6 +4,7 @@ const path = require('path'); const chokidar = require('chokidar'); const yaml = require('js-yaml'); const { generateUidBasedOnHash, uuid } = require('../utils/common'); +const { getWorkspaceUid } = require('../utils/workspace-config'); const { parseEnvironment } = require('@usebruno/filestore'); const EnvironmentSecretsStore = require('../store/env-secrets'); const { decryptStringSafe } = require('../utils/encryption'); @@ -42,9 +43,13 @@ const handleWorkspaceFileChange = (win, workspacePath) => { return; } - const workspaceUid = generateUidBasedOnHash(workspacePath); + const workspaceUid = getWorkspaceUid(workspacePath); + const isDefault = workspaceUid === 'default'; - win.webContents.send('main:workspace-config-updated', workspacePath, workspaceUid, workspaceConfig); + win.webContents.send('main:workspace-config-updated', workspacePath, workspaceUid, { + ...workspaceConfig, + type: isDefault ? 'default' : workspaceConfig.type + }); } catch (error) { console.error('Error handling workspace file change:', error); } @@ -123,7 +128,7 @@ class WorkspaceWatcher { addWatcher(win, workspacePath) { const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); const environmentsDir = path.join(workspacePath, 'environments'); - const workspaceUid = generateUidBasedOnHash(workspacePath); + const workspaceUid = getWorkspaceUid(workspacePath); if (this.watchers[workspacePath]) { this.watchers[workspacePath].close(); diff --git a/packages/bruno-electron/src/ipc/workspace.js b/packages/bruno-electron/src/ipc/workspace.js index 99ef566a7..ed66128a6 100644 --- a/packages/bruno-electron/src/ipc/workspace.js +++ b/packages/bruno-electron/src/ipc/workspace.js @@ -3,13 +3,11 @@ const path = require('path'); const fsExtra = require('fs-extra'); const { ipcMain, dialog } = require('electron'); const { createDirectory, sanitizeName } = require('../utils/filesystem'); -const { generateUidBasedOnHash } = require('../utils/common'); const yaml = require('js-yaml'); const LastOpenedWorkspaces = require('../store/last-opened-workspaces'); const { defaultWorkspaceManager } = require('../store/default-workspace'); const { globalEnvironmentsManager } = require('../store/workspace-environments'); -// Workspace configuration module (includes path and validation utilities) const { createWorkspaceConfig, readWorkspaceConfig, @@ -22,7 +20,8 @@ const { getWorkspaceCollections, normalizeCollectionEntry, validateWorkspacePath, - validateWorkspaceDirectory + validateWorkspaceDirectory, + getWorkspaceUid } = require('../utils/workspace-config'); const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { @@ -49,21 +48,25 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { await createDirectory(path.join(dirPath, 'collections')); - const workspaceUid = generateUidBasedOnHash(dirPath); + const workspaceUid = getWorkspaceUid(dirPath); + const isDefault = workspaceUid === 'default'; const workspaceConfig = createWorkspaceConfig(workspaceName); await writeWorkspaceConfig(dirPath, workspaceConfig); lastOpenedWorkspaces.add(dirPath); - mainWindow.webContents.send('main:workspace-opened', dirPath, workspaceUid, workspaceConfig); + mainWindow.webContents.send('main:workspace-opened', dirPath, workspaceUid, { + ...workspaceConfig, + type: isDefault ? 'default' : workspaceConfig.type + }); if (workspaceWatcher) { workspaceWatcher.addWatcher(mainWindow, dirPath); } return { - workspaceConfig, + workspaceConfig: { ...workspaceConfig, type: isDefault ? 'default' : workspaceConfig.type }, workspaceUid, workspacePath: dirPath }; @@ -79,18 +82,20 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { const workspaceConfig = readWorkspaceConfig(workspacePath); validateWorkspaceConfig(workspaceConfig); - const workspaceUid = generateUidBasedOnHash(workspacePath); + const workspaceUid = getWorkspaceUid(workspacePath); + const isDefault = workspaceUid === 'default'; + const configWithType = { ...workspaceConfig, type: isDefault ? 'default' : workspaceConfig.type }; lastOpenedWorkspaces.add(workspacePath); - mainWindow.webContents.send('main:workspace-opened', workspacePath, workspaceUid, workspaceConfig); + mainWindow.webContents.send('main:workspace-opened', workspacePath, workspaceUid, configWithType); if (workspaceWatcher) { workspaceWatcher.addWatcher(mainWindow, workspacePath); } return { - workspaceConfig, + workspaceConfig: configWithType, workspaceUid, workspacePath }; @@ -117,18 +122,20 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { const workspaceConfig = readWorkspaceConfig(workspacePath); validateWorkspaceConfig(workspaceConfig); - const workspaceUid = generateUidBasedOnHash(workspacePath); + const workspaceUid = getWorkspaceUid(workspacePath); + const isDefault = workspaceUid === 'default'; + const configWithType = { ...workspaceConfig, type: isDefault ? 'default' : workspaceConfig.type }; lastOpenedWorkspaces.add(workspacePath); - mainWindow.webContents.send('main:workspace-opened', workspacePath, workspaceUid, workspaceConfig); + mainWindow.webContents.send('main:workspace-opened', workspacePath, workspaceUid, configWithType); if (workspaceWatcher) { workspaceWatcher.addWatcher(mainWindow, workspacePath); } return { - workspaceConfig, + workspaceConfig: configWithType, workspaceUid, workspacePath }; @@ -338,8 +345,12 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { const updatedCollections = await addCollectionToWorkspace(workspacePath, normalizedCollection); const workspaceConfig = readWorkspaceConfig(workspacePath); - const workspaceUid = generateUidBasedOnHash(workspacePath); - mainWindow.webContents.send('main:workspace-config-updated', workspacePath, workspaceUid, workspaceConfig); + const workspaceUid = getWorkspaceUid(workspacePath); + const isDefault = workspaceUid === 'default'; + mainWindow.webContents.send('main:workspace-config-updated', workspacePath, workspaceUid, { + ...workspaceConfig, + type: isDefault ? 'default' : workspaceConfig.type + }); return updatedCollections; } catch (error) { @@ -374,13 +385,16 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { try { const result = await removeCollectionFromWorkspace(workspacePath, collectionPath); - // Delete collection files if it's a workspace collection if (result.shouldDeleteFiles && result.removedCollection && fs.existsSync(collectionPath)) { await fsExtra.remove(collectionPath); } - const correctWorkspaceUid = generateUidBasedOnHash(workspacePath); - mainWindow.webContents.send('main:workspace-config-updated', workspacePath, correctWorkspaceUid, result.updatedConfig); + const correctWorkspaceUid = getWorkspaceUid(workspacePath); + const isDefault = correctWorkspaceUid === 'default'; + mainWindow.webContents.send('main:workspace-config-updated', workspacePath, correctWorkspaceUid, { + ...result.updatedConfig, + type: isDefault ? 'default' : result.updatedConfig.type + }); return true; } catch (error) { @@ -425,20 +439,12 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { ipcMain.handle('renderer:get-default-workspace', async (event) => { try { const result = await defaultWorkspaceManager.ensureDefaultWorkspaceExists(); - if (!result) { return null; } const { workspacePath, workspaceUid } = result; - - const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); - if (!fs.existsSync(workspaceFilePath)) { - return null; - } - - const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8'); - const workspaceConfig = yaml.load(yamlContent); + const workspaceConfig = readWorkspaceConfig(workspacePath); return { workspaceConfig: { @@ -449,32 +455,30 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { workspacePath }; } catch (error) { + console.error('Error getting default workspace:', error); return null; } }); ipcMain.on('main:renderer-ready', async (win) => { try { + // Load default workspace const defaultResult = await defaultWorkspaceManager.ensureDefaultWorkspaceExists(); if (defaultResult) { const { workspacePath, workspaceUid } = defaultResult; - const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); + const workspaceConfig = readWorkspaceConfig(workspacePath); - if (fs.existsSync(workspaceFilePath)) { - const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8'); - const workspaceConfig = yaml.load(yamlContent); + win.webContents.send('main:workspace-opened', workspacePath, workspaceUid, { + ...workspaceConfig, + type: 'default' + }); - win.webContents.send('main:workspace-opened', workspacePath, workspaceUid, { - ...workspaceConfig, - type: 'default' - }); - - if (workspaceWatcher) { - workspaceWatcher.addWatcher(win, workspacePath); - } + if (workspaceWatcher) { + workspaceWatcher.addWatcher(win, workspacePath); } } + // Load other workspaces const workspacePaths = lastOpenedWorkspaces.getAll(); const invalidPaths = []; @@ -485,9 +489,13 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { try { const workspaceConfig = readWorkspaceConfig(workspacePath); validateWorkspaceConfig(workspaceConfig); - const workspaceUid = generateUidBasedOnHash(workspacePath); + const workspaceUid = getWorkspaceUid(workspacePath); + const isDefault = workspaceUid === 'default'; - win.webContents.send('main:workspace-opened', workspacePath, workspaceUid, workspaceConfig); + win.webContents.send('main:workspace-opened', workspacePath, workspaceUid, { + ...workspaceConfig, + type: isDefault ? 'default' : workspaceConfig.type + }); if (workspaceWatcher) { workspaceWatcher.addWatcher(win, workspacePath); diff --git a/packages/bruno-electron/src/store/default-workspace.js b/packages/bruno-electron/src/store/default-workspace.js index 5f07a7d35..44c27f6f4 100644 --- a/packages/bruno-electron/src/store/default-workspace.js +++ b/packages/bruno-electron/src/store/default-workspace.js @@ -2,17 +2,18 @@ const fs = require('fs'); const path = require('path'); const { app } = require('electron'); const { generateUidBasedOnHash } = require('../utils/common'); -const { writeFile, createDirectory } = require('../utils/filesystem'); +const { writeFile } = require('../utils/filesystem'); const { getPreferences, savePreferences } = require('./preferences'); const { globalEnvironmentsStore } = require('./global-environments'); -const { generateYamlContent } = require('../utils/workspace-config'); +const { generateYamlContent, readWorkspaceConfig, validateWorkspaceConfig } = require('../utils/workspace-config'); const OPENCOLLECTION_VERSION = '1.0.0'; +const WORKSPACE_TYPE = 'workspace'; +const DEFAULT_WORKSPACE_UID = 'default'; class DefaultWorkspaceManager { constructor() { this.defaultWorkspacePath = null; - this.defaultWorkspaceUid = null; this.initializationPromise = null; } @@ -27,16 +28,7 @@ class DefaultWorkspaceManager { } getDefaultWorkspaceUid() { - const workspacePath = this.getDefaultWorkspacePath(); - if (!workspacePath) { - return null; - } - - if (!this.defaultWorkspaceUid) { - this.defaultWorkspaceUid = generateUidBasedOnHash(workspacePath); - } - - return this.defaultWorkspaceUid; + return DEFAULT_WORKSPACE_UID; } async setDefaultWorkspacePath(workspacePath) { @@ -48,11 +40,29 @@ class DefaultWorkspaceManager { await savePreferences(preferences); this.defaultWorkspacePath = workspacePath; - this.defaultWorkspaceUid = generateUidBasedOnHash(workspacePath); return workspacePath; } + isValidDefaultWorkspace(workspacePath) { + if (!workspacePath || !fs.existsSync(workspacePath)) { + return false; + } + + const workspaceYmlPath = path.join(workspacePath, 'workspace.yml'); + if (!fs.existsSync(workspaceYmlPath)) { + return false; + } + + try { + const config = readWorkspaceConfig(workspacePath); + validateWorkspaceConfig(config); + return true; + } catch (error) { + return false; + } + } + async ensureDefaultWorkspaceExists() { if (this.initializationPromise) { return this.initializationPromise; @@ -60,7 +70,8 @@ class DefaultWorkspaceManager { const existingPath = this.getDefaultWorkspacePath(); - if (existingPath && fs.existsSync(existingPath)) { + if (this.isValidDefaultWorkspace(existingPath)) { + this.defaultWorkspacePath = existingPath; return { workspacePath: existingPath, workspaceUid: this.getDefaultWorkspaceUid() @@ -70,17 +81,15 @@ class DefaultWorkspaceManager { this.initializationPromise = (async () => { try { const shouldMigrate = this.needsMigration(); - const newWorkspacePath = await this.initializeDefaultWorkspace(null, { migrateFromPreferences: shouldMigrate }); - const workspaceYmlPath = path.join(newWorkspacePath, 'workspace.yml'); - if (!fs.existsSync(workspaceYmlPath)) { - this.defaultWorkspacePath = null; - return null; - } else { - return { - workspacePath: newWorkspacePath, - workspaceUid: this.getDefaultWorkspaceUid() - }; - } + const newWorkspacePath = await this.initializeDefaultWorkspace({ migrateFromPreferences: shouldMigrate }); + + return { + workspacePath: newWorkspacePath, + workspaceUid: this.getDefaultWorkspaceUid() + }; + } catch (error) { + console.error('Failed to initialize default workspace:', error); + return null; } finally { this.initializationPromise = null; } @@ -89,35 +98,28 @@ class DefaultWorkspaceManager { return this.initializationPromise; } - async initializeDefaultWorkspace(workspacePath = null, options = {}) { + async initializeDefaultWorkspace(options = {}) { const { migrateFromPreferences = true } = options; - if (!workspacePath) { - const configDir = app.getPath('userData'); - const baseWorkspacePath = path.join(configDir, 'default-workspace'); + const configDir = app.getPath('userData'); + const baseWorkspacePath = path.join(configDir, 'default-workspace'); - let finalPath = baseWorkspacePath; - let counter = 1; - while (fs.existsSync(finalPath)) { - finalPath = `${baseWorkspacePath}-${counter}`; - counter++; - } - - workspacePath = finalPath; + let workspacePath = baseWorkspacePath; + let counter = 1; + while (fs.existsSync(workspacePath)) { + workspacePath = `${baseWorkspacePath}-${counter}`; + counter++; } - if (!fs.existsSync(workspacePath)) { - await createDirectory(workspacePath); - } - - await createDirectory(path.join(workspacePath, 'collections')); - await createDirectory(path.join(workspacePath, 'environments')); + fs.mkdirSync(workspacePath, { recursive: true }); + fs.mkdirSync(path.join(workspacePath, 'collections'), { recursive: true }); + fs.mkdirSync(path.join(workspacePath, 'environments'), { recursive: true }); const workspaceConfig = { opencollection: OPENCOLLECTION_VERSION, info: { name: 'My Workspace', - type: 'default' + type: WORKSPACE_TYPE }, collections: [], specs: [], diff --git a/packages/bruno-electron/src/utils/workspace-config.js b/packages/bruno-electron/src/utils/workspace-config.js index a0cf646a4..4bef7bdd6 100644 --- a/packages/bruno-electron/src/utils/workspace-config.js +++ b/packages/bruno-electron/src/utils/workspace-config.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); const { writeFile, validateName } = require('./filesystem'); +const { generateUidBasedOnHash } = require('./common'); const WORKSPACE_TYPE = 'workspace'; const OPENCOLLECTION_VERSION = '1.0.0'; @@ -354,6 +355,15 @@ const removeApiSpecFromWorkspace = async (workspacePath, apiSpecPath) => { }; }; +const getWorkspaceUid = (workspacePath) => { + const { defaultWorkspaceManager } = require('../store/default-workspace'); + const defaultWorkspacePath = defaultWorkspaceManager.getDefaultWorkspacePath(); + if (defaultWorkspacePath && path.normalize(workspacePath) === path.normalize(defaultWorkspacePath)) { + return defaultWorkspaceManager.getDefaultWorkspaceUid(); + } + return generateUidBasedOnHash(workspacePath); +}; + module.exports = { makeRelativePath, normalizeCollectionEntry, @@ -371,5 +381,6 @@ module.exports = { getWorkspaceApiSpecs, addApiSpecToWorkspace, removeApiSpecFromWorkspace, - generateYamlContent + generateYamlContent, + getWorkspaceUid }; diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 10438af01..0590cc074 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -76,7 +76,8 @@ const createCollection = async (page, collectionName: string, collectionLocation } await createCollectionModal.getByRole('button', { name: 'Create', exact: true }).click(); - await createCollectionModal.waitFor({ state: 'detached' }); + await createCollectionModal.waitFor({ state: 'detached', timeout: 15000 }); + await page.waitForTimeout(200); if (options.openWithSandboxMode != undefined) { await openCollectionAndAcceptSandbox(page, collectionName, options.openWithSandboxMode); diff --git a/tests/workspace/default-workspace/default-workspace.spec.ts b/tests/workspace/default-workspace/default-workspace.spec.ts new file mode 100644 index 000000000..4d94b1c5f --- /dev/null +++ b/tests/workspace/default-workspace/default-workspace.spec.ts @@ -0,0 +1,230 @@ +import path from 'path'; +import fs from 'fs'; +import { test, expect } from '../../../playwright'; + +test.describe('Default Workspace', () => { + test.describe('First Launch', () => { + test('should create default workspace with "My Workspace" name on first launch', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-first-launch'); + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // Verify the workspace name is "My Workspace" in the title bar + const workspaceName = page.locator('.workspace-name'); + await expect(workspaceName).toContainText('My Workspace'); + + await app.context().close(); + await app.close(); + }); + }); + + test.describe('Persistence', () => { + test('should persist default workspace across app restarts', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-persistence'); + + // First launch + const app1 = await launchElectronApp({ userDataPath }); + const page1 = await app1.firstWindow(); + await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + await expect(page1.locator('.workspace-name')).toContainText('My Workspace'); + + await app1.close(); + + // Second launch - same workspace should be loaded + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + await expect(page2.locator('.workspace-name')).toContainText('My Workspace'); + + await app2.context().close(); + await app2.close(); + }); + }); + + test.describe('Recovery - Creates NEW workspace (never modifies existing)', () => { + test('should create NEW workspace when existing workspace.yml is deleted', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-recovery-deleted'); + + // Create a corrupted default workspace BEFORE launching app + const defaultWorkspacePath = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(defaultWorkspacePath, { recursive: true }); + fs.mkdirSync(path.join(defaultWorkspacePath, 'collections'), { recursive: true }); + // Note: NOT creating workspace.yml - simulating deleted file + + // Create preferences pointing to the corrupted workspace + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + general: { + defaultWorkspacePath: defaultWorkspacePath + } + }) + ); + + // Launch app - should create NEW workspace + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // Should show "My Workspace" + await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + + // Old directory should still exist (never deleted) + expect(fs.existsSync(defaultWorkspacePath)).toBe(true); + + // New workspace directory should have been created (default-workspace-1 since default-workspace exists) + const newWorkspacePath = path.join(userDataPath, 'default-workspace-1'); + expect(fs.existsSync(newWorkspacePath)).toBe(true); + expect(fs.existsSync(path.join(newWorkspacePath, 'workspace.yml'))).toBe(true); + + await app.context().close(); + await app.close(); + }); + + test('should create NEW workspace when workspace.yml has invalid YAML', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-recovery-invalid'); + + // Create workspace with invalid YAML BEFORE launching app + const defaultWorkspacePath = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(defaultWorkspacePath, { recursive: true }); + fs.writeFileSync(path.join(defaultWorkspacePath, 'workspace.yml'), 'invalid: yaml: [[['); + + // Create preferences pointing to the corrupted workspace + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + general: { + defaultWorkspacePath: defaultWorkspacePath + } + }) + ); + + // Launch app - should create NEW 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'); + + // Old corrupted file should still exist (never deleted) + const oldContent = fs.readFileSync(path.join(defaultWorkspacePath, 'workspace.yml'), 'utf8'); + expect(oldContent).toContain('invalid: yaml: [[['); + + // New workspace should have been created + const newWorkspacePath = path.join(userDataPath, 'default-workspace-1'); + expect(fs.existsSync(newWorkspacePath)).toBe(true); + + await app.context().close(); + await app.close(); + }); + + test('should create NEW workspace when workspace.yml has wrong type', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-recovery-wrong-type'); + + // Create workspace with wrong type BEFORE launching app + const defaultWorkspacePath = path.join(userDataPath, 'default-workspace'); + fs.mkdirSync(defaultWorkspacePath, { recursive: true }); + fs.writeFileSync(path.join(defaultWorkspacePath, 'workspace.yml'), `opencollection: 1.0.0 +info: + name: My Workspace + type: collection +collections: +specs: +docs: '' +`); + + // Create preferences pointing to the invalid workspace + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + general: { + defaultWorkspacePath: defaultWorkspacePath + } + }) + ); + + // Launch app + 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'); + + // New workspace should have been created + const newWorkspacePath = path.join(userDataPath, 'default-workspace-1'); + expect(fs.existsSync(newWorkspacePath)).toBe(true); + + await app.context().close(); + await app.close(); + }); + + test('should create NEW workspace when directory does not exist', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-recovery-dir-missing'); + + // Create preferences pointing to non-existent directory + const nonExistentPath = path.join(userDataPath, 'non-existent-workspace'); + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + general: { + defaultWorkspacePath: nonExistentPath + } + }) + ); + + // Launch app + 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'); + + // New workspace should have been created (default-workspace since non-existent doesn't block) + const newWorkspacePath = path.join(userDataPath, 'default-workspace'); + expect(fs.existsSync(newWorkspacePath)).toBe(true); + expect(fs.existsSync(path.join(newWorkspacePath, 'workspace.yml'))).toBe(true); + + await app.context().close(); + await app.close(); + }); + }); + + test.describe('UI Behavior', () => { + test('should display default workspace in workspace dropdown', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-ui-dropdown'); + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // Click on workspace name to open dropdown + await page.locator('.workspace-name-container').click(); + + // Verify default workspace is shown + const workspaceItem = page.locator('.workspace-item, .dropdown-item').filter({ hasText: 'My Workspace' }); + await expect(workspaceItem.first()).toBeVisible(); + + await app.context().close(); + await app.close(); + }); + + test('should not show pin button for default workspace', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-ui-no-pin'); + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await page.locator('.workspace-name-container').click(); + + const workspaceItem = page.locator('.workspace-item').filter({ hasText: 'My Workspace' }); + // Default workspace should NOT have pin button + await expect(workspaceItem.locator('.pin-btn')).not.toBeVisible(); + + await app.context().close(); + await app.close(); + }); + }); +}); diff --git a/tests/workspace/default-workspace/migration.spec.ts b/tests/workspace/default-workspace/migration.spec.ts new file mode 100644 index 000000000..b6ec9e34e --- /dev/null +++ b/tests/workspace/default-workspace/migration.spec.ts @@ -0,0 +1,208 @@ +import path from 'path'; +import fs from 'fs'; +import { test, expect } from '../../../playwright'; + +const env = { + DISABLE_SAMPLE_COLLECTION_IMPORT: 'false' +}; + +test.describe('Default Workspace Migration', () => { + test.describe('Migration from lastOpenedCollections', () => { + test('should migrate collections from lastOpenedCollections to new workspace', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-migration'); + + await test.step('Setup test collection and preferences', async () => { + const testCollectionPath = path.join(userDataPath, 'my-old-collection'); + fs.mkdirSync(testCollectionPath, { recursive: true }); + fs.writeFileSync( + path.join(testCollectionPath, 'bruno.json'), + JSON.stringify({ + version: '1', + name: 'My Old Collection', + type: 'collection' + }) + ); + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + lastOpenedCollections: [testCollectionPath] + }) + ); + }); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Verify workspace UI', async () => { + await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + }); + + await test.step('Verify workspace filesystem artifacts', async () => { + const workspacePath = path.join(userDataPath, 'default-workspace'); + expect(fs.existsSync(workspacePath)).toBe(true); + + const workspaceYmlPath = path.join(workspacePath, 'workspace.yml'); + expect(fs.existsSync(workspaceYmlPath)).toBe(true); + const workspaceYml = fs.readFileSync(workspaceYmlPath, 'utf8'); + expect(workspaceYml).toContain('collections:'); + expect(workspaceYml).toContain('my-old-collection'); + }); + + await test.step('Cleanup', async () => { + await app.context().close(); + await app.close(); + }); + }); + + test('should migrate multiple collections from lastOpenedCollections', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-migration-multiple'); + + // Create multiple test collections + const collection1Path = path.join(userDataPath, 'collection-1'); + const collection2Path = path.join(userDataPath, 'collection-2'); + + for (const collPath of [collection1Path, collection2Path]) { + fs.mkdirSync(collPath, { recursive: true }); + fs.writeFileSync( + path.join(collPath, 'bruno.json'), + JSON.stringify({ + version: '1', + name: path.basename(collPath), + type: 'collection' + }) + ); + } + + // Create old-style preferences + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + lastOpenedCollections: [collection1Path, collection2Path] + }) + ); + + // Launch app + 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.yml has both collections + const workspacePath = path.join(userDataPath, 'default-workspace'); + const workspaceYmlPath = path.join(workspacePath, 'workspace.yml'); + expect(fs.existsSync(workspaceYmlPath)).toBe(true); + const workspaceYml = fs.readFileSync(workspaceYmlPath, 'utf8'); + expect(workspaceYml).toContain('collection-1'); + expect(workspaceYml).toContain('collection-2'); + + await app.context().close(); + await app.close(); + }); + }); + + test.describe('Migration does not affect existing users', () => { + test('should skip sample collection when user has existing collections', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-existing-user'); + + // Create a test collection (simulating existing user) + const oldCollectionPath = path.join(userDataPath, 'old-user-collection'); + fs.mkdirSync(oldCollectionPath, { recursive: true }); + fs.writeFileSync( + path.join(oldCollectionPath, 'bruno.json'), + JSON.stringify({ + version: '1', + name: 'Old User Collection', + type: 'collection' + }) + ); + + // Create old-style preferences with lastOpenedCollections + fs.writeFileSync( + path.join(userDataPath, 'preferences.json'), + JSON.stringify({ + lastOpenedCollections: [oldCollectionPath] + }) + ); + + // Launch app - sample collection should NOT be created (existing user) + const app = await launchElectronApp({ userDataPath, dotEnv: env }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + // Verify default workspace is created + await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + + // Sample collection should NOT be created (because user has existing collections) + const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection'); + await expect(sampleCollection).not.toBeVisible(); + + await app.context().close(); + await app.close(); + }); + }); + + test.describe('No duplicate workspaces on restart', () => { + test('should reuse existing workspace on subsequent launches', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-reuse'); + + // First launch - creates workspace + const app1 = await launchElectronApp({ userDataPath }); + const page1 = await app1.firstWindow(); + await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + await expect(page1.locator('.workspace-name')).toContainText('My Workspace'); + + // Verify initial workspace was created + const workspacePath = path.join(userDataPath, 'default-workspace'); + expect(fs.existsSync(workspacePath)).toBe(true); + const originalYmlContent = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8'); + + await app1.context().close(); + await app1.close(); + + // Second launch - should reuse existing workspace + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + await expect(page2.locator('.workspace-name')).toContainText('My Workspace'); + + // workspace.yml should NOT have been modified + const currentYmlContent = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8'); + expect(currentYmlContent).toBe(originalYmlContent); + + // No new workspace should have been created + expect(fs.existsSync(path.join(userDataPath, 'default-workspace-1'))).toBe(false); + + await app2.context().close(); + await app2.close(); + }); + }); + + test.describe('Clean installation', () => { + test('should create empty workspace on fresh install without old preferences', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('default-workspace-clean'); + + // Launch with completely empty user data (no preferences file) + 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 was created + const workspacePath = path.join(userDataPath, 'default-workspace'); + expect(fs.existsSync(workspacePath)).toBe(true); + + // Verify workspace has empty collections section + const workspaceYmlPath = path.join(workspacePath, 'workspace.yml'); + expect(fs.existsSync(workspaceYmlPath)).toBe(true); + const workspaceYml = fs.readFileSync(workspaceYmlPath, 'utf8'); + // Collections should be empty (just the key) + expect(workspaceYml).toMatch(/collections:\s*\n/); + + await app.context().close(); + await app.close(); + }); + }); +});