mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: implement temporary workspace creation and confirmation flow (#7462)
* feat: implement temporary workspace creation and confirmation flow * fixes
This commit is contained in:
@@ -160,7 +160,6 @@ const AppTitleBar = () => {
|
||||
|
||||
try {
|
||||
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
|
||||
toast.success('Workspace created!');
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Failed to create workspace');
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const RenameWorkspace = ({ onClose, workspace }) => {
|
||||
.test('unique-name', 'A workspace with this name already exists', function (value) {
|
||||
if (!value) return true;
|
||||
return !workspaces.some((w) =>
|
||||
w.uid !== workspace.uid && w.name.toLowerCase() === value.toLowerCase()
|
||||
w.uid !== workspace.uid && w.name && w.name.toLowerCase() === value.toLowerCase()
|
||||
);
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -27,7 +27,8 @@ const ManageWorkspace = () => {
|
||||
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState({ open: false, workspace: null });
|
||||
|
||||
const sortedWorkspaces = useMemo(() => {
|
||||
return sortWorkspaces(workspaces, preferences);
|
||||
const persistedWorkspaces = workspaces.filter((w) => !w.isCreating);
|
||||
return sortWorkspaces(persistedWorkspaces, preferences);
|
||||
}, [workspaces, preferences]);
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -69,7 +70,6 @@ const ManageWorkspace = () => {
|
||||
|
||||
try {
|
||||
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
|
||||
toast.success('Workspace created!');
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Failed to create workspace');
|
||||
}
|
||||
|
||||
@@ -72,19 +72,46 @@ const StyledWrapper = styled.div`
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.workspace-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 3px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
min-width: 150px;
|
||||
|
||||
&:focus-within {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-name-input {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 3px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
outline: none;
|
||||
min-width: 150px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
.cog-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
IconUpload
|
||||
} from '@tabler/icons';
|
||||
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -24,6 +24,7 @@ import toast from 'react-hot-toast';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import CloseWorkspace from 'components/Sidebar/CloseWorkspace';
|
||||
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
|
||||
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
|
||||
@@ -53,11 +54,17 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
|
||||
const [workspaceNameError, setWorkspaceNameError] = useState('');
|
||||
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
|
||||
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
|
||||
|
||||
const switcherRef = useRef();
|
||||
const workspaceActionsRef = useRef();
|
||||
const workspaceNameInputRef = useRef(null);
|
||||
const workspaceRenameContainerRef = useRef(null);
|
||||
const openingAdvancedRef = useRef(false);
|
||||
const clickedOutsideRef = useRef(false);
|
||||
const handleSaveRef = useRef(null);
|
||||
const tempWorkspaceUidRef = useRef(null);
|
||||
const isSavingRef = useRef(false);
|
||||
|
||||
const onSwitcherCreate = (ref) => (switcherRef.current = ref);
|
||||
const onWorkspaceActionsCreate = (ref) => (workspaceActionsRef.current = ref);
|
||||
@@ -73,17 +80,27 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
}, [isScratchCollection, currentWorkspace?.isNewlyCreated, currentWorkspace?.uid, currentWorkspace?.name, dispatch]);
|
||||
|
||||
const handleCancelWorkspaceRename = useCallback(() => {
|
||||
if (openingAdvancedRef.current) return;
|
||||
if (currentWorkspace?.isCreating) {
|
||||
dispatch(cancelWorkspaceCreation(currentWorkspace.uid));
|
||||
return;
|
||||
}
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
}, []);
|
||||
}, [currentWorkspace?.isCreating, currentWorkspace?.uid, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRenamingWorkspace) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) {
|
||||
handleCancelWorkspaceRename();
|
||||
if (currentWorkspace?.isCreating) {
|
||||
clickedOutsideRef.current = true;
|
||||
handleSaveRef.current?.();
|
||||
} else {
|
||||
handleCancelWorkspaceRename();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,7 +113,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [isRenamingWorkspace, handleCancelWorkspaceRename]);
|
||||
}, [isRenamingWorkspace, handleCancelWorkspaceRename, currentWorkspace?.isCreating]);
|
||||
|
||||
const collectionUpdates = useSelector((state) => state.openapiSync?.collectionUpdates || {});
|
||||
const { theme } = useTheme();
|
||||
@@ -267,28 +284,71 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
};
|
||||
|
||||
const handleSaveWorkspaceRename = () => {
|
||||
const fromOutside = clickedOutsideRef.current;
|
||||
clickedOutsideRef.current = false;
|
||||
|
||||
if (openingAdvancedRef.current) return;
|
||||
if (isSavingRef.current) return;
|
||||
|
||||
const trimmedName = workspaceNameInput?.trim();
|
||||
if (!trimmedName) {
|
||||
if (fromOutside && currentWorkspace?.isCreating) {
|
||||
dispatch(cancelWorkspaceCreation(currentWorkspace.uid));
|
||||
return;
|
||||
}
|
||||
setWorkspaceNameError('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const error = validateWorkspaceName(workspaceNameInput);
|
||||
if (error) {
|
||||
setWorkspaceNameError(error);
|
||||
if (fromOutside && currentWorkspace?.isCreating) {
|
||||
dispatch(cancelWorkspaceCreation(currentWorkspace.uid));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const uid = currentWorkspace?.uid;
|
||||
if (!uid) return;
|
||||
|
||||
dispatch(renameWorkspaceAction(uid, workspaceNameInput))
|
||||
.then(() => {
|
||||
toast.success('Workspace renamed!');
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err?.message || 'An error occurred while renaming the workspace');
|
||||
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
|
||||
});
|
||||
isSavingRef.current = true;
|
||||
|
||||
if (currentWorkspace?.isCreating) {
|
||||
dispatch(confirmWorkspaceCreation(uid, trimmedName))
|
||||
.then(() => {
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
toast.success('Workspace created!');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err?.message || 'An error occurred while creating the workspace');
|
||||
})
|
||||
.finally(() => {
|
||||
isSavingRef.current = false;
|
||||
});
|
||||
} else {
|
||||
dispatch(renameWorkspaceAction(uid, workspaceNameInput))
|
||||
.then(() => {
|
||||
toast.success('Workspace renamed!');
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err?.message || 'An error occurred while renaming the workspace');
|
||||
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
|
||||
})
|
||||
.finally(() => {
|
||||
isSavingRef.current = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Keep ref in sync so click-outside handler always has the latest save logic
|
||||
handleSaveRef.current = handleSaveWorkspaceRename;
|
||||
|
||||
const handleWorkspaceNameChange = (e) => {
|
||||
setWorkspaceNameInput(e.target.value);
|
||||
if (workspaceNameError) {
|
||||
@@ -306,6 +366,27 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAdvancedCreate = () => {
|
||||
openingAdvancedRef.current = true;
|
||||
tempWorkspaceUidRef.current = currentWorkspace?.isCreating ? currentWorkspace.uid : null;
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAdvancedCreateClose = () => {
|
||||
openingAdvancedRef.current = false;
|
||||
setCreateWorkspaceModalOpen(false);
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
setWorkspaceNameError('');
|
||||
const tempUid = tempWorkspaceUidRef.current;
|
||||
tempWorkspaceUidRef.current = null;
|
||||
// Clean up the temp workspace (cancelWorkspaceCreation only switches to default
|
||||
// if the temp workspace was still active, so this is safe after modal success too)
|
||||
if (tempUid) {
|
||||
dispatch(cancelWorkspaceCreation(tempUid));
|
||||
}
|
||||
};
|
||||
|
||||
// Check if workspace actions should be shown
|
||||
const showWorkspaceActions = isScratchCollection
|
||||
&& currentWorkspace
|
||||
@@ -321,30 +402,46 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{createWorkspaceModalOpen && (
|
||||
<CreateWorkspace onClose={handleAdvancedCreateClose} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 py-2 px-4">
|
||||
{/* Left side: Switcher dropdown or rename input */}
|
||||
<div className="collection-switcher">
|
||||
{isRenamingWorkspace ? (
|
||||
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
|
||||
<DisplayIcon size={18} strokeWidth={1.5} />
|
||||
<input
|
||||
ref={workspaceNameInputRef}
|
||||
type="text"
|
||||
className="workspace-name-input"
|
||||
value={workspaceNameInput}
|
||||
onChange={handleWorkspaceNameChange}
|
||||
onKeyDown={handleWorkspaceNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="workspace-input-wrapper">
|
||||
<input
|
||||
ref={workspaceNameInputRef}
|
||||
type="text"
|
||||
className="workspace-name-input"
|
||||
value={workspaceNameInput}
|
||||
onChange={handleWorkspaceNameChange}
|
||||
onKeyDown={handleWorkspaceNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{currentWorkspace?.isCreating && (
|
||||
<button
|
||||
className="cog-btn"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={handleOpenAdvancedCreate}
|
||||
title="Advanced options"
|
||||
>
|
||||
<IconSettings size={13} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveWorkspaceRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
title={currentWorkspace?.isCreating ? 'Create' : 'Save'}
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
@@ -40,7 +40,7 @@ const CreateWorkspace = ({ onClose }) => {
|
||||
if (!value) return true;
|
||||
|
||||
return !workspaces.some((w) =>
|
||||
w.name.toLowerCase() === value.toLowerCase());
|
||||
!w.isCreating && w.name && w.name.toLowerCase() === value.toLowerCase());
|
||||
}),
|
||||
workspaceFolderName: Yup.string()
|
||||
.min(1, 'Must be at least 1 character')
|
||||
|
||||
@@ -4,19 +4,17 @@ import {
|
||||
removeWorkspace,
|
||||
setActiveWorkspace,
|
||||
updateWorkspace,
|
||||
addCollectionToWorkspace,
|
||||
removeCollectionFromWorkspace,
|
||||
updateWorkspaceLoadingState,
|
||||
setWorkspaceScratchCollection
|
||||
} from '../workspaces';
|
||||
import { showHomePage } from '../app';
|
||||
import { createCollection, openCollection, openMultipleCollections, openScratchCollectionEvent } from '../collections/actions';
|
||||
import { removeCollection, addTransientDirectory, updateCollectionMountStatus } from '../collections';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import { clearCollectionState } from '../openapi-sync';
|
||||
import { updateGlobalEnvironments } from '../global-environments';
|
||||
import { addTab, focusTab } from '../tabs';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
@@ -53,20 +51,112 @@ const transformCollection = async (collection, type) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a workspace with a unique name under the given location
|
||||
* Creates a temporary workspace in Redux without touching the filesystem.
|
||||
* The workspace is only persisted to disk when the user confirms the name.
|
||||
*/
|
||||
export const createWorkspaceWithUniqueName = (location) => {
|
||||
return async (dispatch) => {
|
||||
const { uuid: generateUuid } = await import('utils/common');
|
||||
const tempUid = generateUuid();
|
||||
const name = await ipcRenderer?.invoke('renderer:find-unique-folder-name', 'Untitled Workspace', location) || 'Untitled Workspace';
|
||||
const folderName = sanitizeName(name);
|
||||
const result = await dispatch(createWorkspaceAction(name, folderName, location));
|
||||
if (result?.workspaceUid) {
|
||||
dispatch(updateWorkspace({ uid: result.workspaceUid, isNewlyCreated: true }));
|
||||
|
||||
dispatch(createWorkspace({
|
||||
uid: tempUid,
|
||||
name,
|
||||
pathname: null,
|
||||
collections: [],
|
||||
isCreating: true,
|
||||
creationLocation: location
|
||||
}));
|
||||
|
||||
dispatch(updateWorkspace({ uid: tempUid, isNewlyCreated: true }));
|
||||
await dispatch(switchWorkspace(tempUid));
|
||||
|
||||
return { workspaceUid: tempUid };
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirms creation of a temporary workspace by persisting it to the filesystem.
|
||||
*/
|
||||
export const confirmWorkspaceCreation = (tempWorkspaceUid, workspaceName) => {
|
||||
return async (dispatch, getState) => {
|
||||
const tempWorkspace = getState().workspaces.workspaces.find((w) => w.uid === tempWorkspaceUid);
|
||||
if (!tempWorkspace) {
|
||||
throw new Error('Temporary workspace not found');
|
||||
}
|
||||
|
||||
const location = tempWorkspace.creationLocation;
|
||||
if (!location) {
|
||||
throw new Error('Workspace creation location not found');
|
||||
}
|
||||
|
||||
const baseFolderName = sanitizeName(workspaceName);
|
||||
const folderName = await ipcRenderer?.invoke('renderer:find-unique-folder-name', baseFolderName, location) || baseFolderName;
|
||||
|
||||
const result = await ipcRenderer.invoke(
|
||||
'renderer:create-workspace',
|
||||
workspaceName,
|
||||
folderName,
|
||||
location
|
||||
);
|
||||
|
||||
const { workspaceUid: realUid, workspacePath, workspaceConfig } = result;
|
||||
|
||||
// Clean up the temp workspace's scratch collection after IPC succeeds
|
||||
// (doing it before would leave a broken state if the IPC call fails)
|
||||
if (tempWorkspace.scratchCollectionUid) {
|
||||
dispatch(removeCollection({ collectionUid: tempWorkspace.scratchCollectionUid }));
|
||||
}
|
||||
|
||||
// Remove the temporary workspace
|
||||
dispatch(removeWorkspace(tempWorkspaceUid));
|
||||
|
||||
// Ensure the real workspace exists in Redux (the workspace-opened event may or may not have fired yet)
|
||||
const existing = getState().workspaces.workspaces.find((w) => w.uid === realUid);
|
||||
if (!existing) {
|
||||
dispatch(createWorkspace({
|
||||
uid: realUid,
|
||||
pathname: workspacePath,
|
||||
...workspaceConfig
|
||||
}));
|
||||
}
|
||||
|
||||
dispatch(updateWorkspace({ uid: realUid, name: workspaceName }));
|
||||
|
||||
await dispatch(switchWorkspace(realUid));
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancels creation of a temporary workspace, removing it from Redux.
|
||||
* Only switches to default workspace if the temp workspace was the active one.
|
||||
*/
|
||||
export const cancelWorkspaceCreation = (tempWorkspaceUid) => {
|
||||
return async (dispatch, getState) => {
|
||||
const tempWorkspace = getState().workspaces.workspaces.find((w) => w.uid === tempWorkspaceUid);
|
||||
if (!tempWorkspace) return;
|
||||
|
||||
// Clean up the scratch collection if one was mounted
|
||||
if (tempWorkspace.scratchCollectionUid) {
|
||||
dispatch(removeCollection({ collectionUid: tempWorkspace.scratchCollectionUid }));
|
||||
}
|
||||
|
||||
const wasActive = getState().workspaces.activeWorkspaceUid === tempWorkspaceUid;
|
||||
dispatch(removeWorkspace(tempWorkspaceUid));
|
||||
|
||||
// Only switch to default if the cancelled workspace was the active one
|
||||
if (wasActive) {
|
||||
const defaultWorkspace = getState().workspaces.workspaces.find((w) => w.type === 'default');
|
||||
if (defaultWorkspace) {
|
||||
await dispatch(switchWorkspace(defaultWorkspace.uid));
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const createWorkspaceAction = (workspaceName, workspaceFolderName, workspaceLocation) => {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
|
||||
712
tests/workspace/create-workspace/create-workspace.spec.ts
Normal file
712
tests/workspace/create-workspace/create-workspace.spec.ts
Normal file
@@ -0,0 +1,712 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import yaml from 'js-yaml';
|
||||
import { test, expect, closeElectronApp } from '../../../playwright';
|
||||
|
||||
type WorkspaceConfig = {
|
||||
opencollection?: string;
|
||||
info?: { name: string; type: string };
|
||||
collections?: { name?: string; path?: string }[];
|
||||
};
|
||||
|
||||
const initUserDataPath = path.join(__dirname, 'init-user-data');
|
||||
|
||||
function findCreatedWorkspaceDirs(location: string): string[] {
|
||||
return fs.readdirSync(location).filter((e) => {
|
||||
const fullPath = path.join(location, e);
|
||||
return (
|
||||
fs.statSync(fullPath).isDirectory()
|
||||
&& e !== 'default-workspace'
|
||||
&& fs.existsSync(path.join(fullPath, 'workspace.yml'))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Create Workspace', () => {
|
||||
test.describe('Inline Creation Flow', () => {
|
||||
test('should create workspace via inline rename and press Enter', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-enter');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Click "Create workspace" from title bar dropdown', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
});
|
||||
|
||||
await test.step('Verify inline rename input appears with default name', async () => {
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
await expect(renameInput).not.toHaveValue('');
|
||||
});
|
||||
|
||||
await test.step('Verify workspace is NOT yet created on filesystem', async () => {
|
||||
const wsDirs = findCreatedWorkspaceDirs(wsLocation);
|
||||
expect(wsDirs).toHaveLength(0);
|
||||
});
|
||||
|
||||
await test.step('Type workspace name and press Enter to confirm', async () => {
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await renameInput.fill('My Test Workspace');
|
||||
await renameInput.press('Enter');
|
||||
});
|
||||
|
||||
await test.step('Verify workspace created successfully', async () => {
|
||||
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('My Test Workspace', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify workspace folder exists on filesystem', async () => {
|
||||
const wsDirs = findCreatedWorkspaceDirs(wsLocation);
|
||||
expect(wsDirs.length).toBe(1);
|
||||
|
||||
const ymlPath = path.join(wsLocation, wsDirs[0], 'workspace.yml');
|
||||
const config = yaml.load(fs.readFileSync(ymlPath, 'utf8')) as WorkspaceConfig;
|
||||
expect(config?.info?.name).toBe('My Test Workspace');
|
||||
expect(config?.info?.type).toBe('workspace');
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('should create workspace via inline rename and click check icon', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-check');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Click "Create workspace" and fill name', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
await renameInput.fill('Check Icon Workspace');
|
||||
});
|
||||
|
||||
await test.step('Click the check icon to confirm', async () => {
|
||||
await page.locator('.inline-action-btn.save').click();
|
||||
});
|
||||
|
||||
await test.step('Verify workspace created', async () => {
|
||||
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('Check Icon Workspace', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify filesystem', async () => {
|
||||
const wsDirs = findCreatedWorkspaceDirs(wsLocation);
|
||||
expect(wsDirs.length).toBe(1);
|
||||
expect(fs.existsSync(path.join(wsLocation, wsDirs[0], 'workspace.yml'))).toBe(true);
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('should create workspace via inline rename and click outside', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-outside');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Create workspace and fill name', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
await renameInput.fill('Click Outside Workspace');
|
||||
});
|
||||
|
||||
await test.step('Click outside the rename container to confirm', async () => {
|
||||
await page.locator('.app-titlebar').click({ position: { x: 500, y: 10 } });
|
||||
});
|
||||
|
||||
await test.step('Verify workspace created', async () => {
|
||||
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('Click Outside Workspace', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Cancel/Discard Flow', () => {
|
||||
test('should discard temp workspace when pressing Escape', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-escape');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Start workspace creation', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Press Escape to cancel', async () => {
|
||||
await page.locator('.workspace-name-input').press('Escape');
|
||||
});
|
||||
|
||||
await test.step('Verify switched back to default workspace', async () => {
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify no workspace folder created on filesystem', async () => {
|
||||
const wsDirs = findCreatedWorkspaceDirs(wsLocation);
|
||||
expect(wsDirs).toHaveLength(0);
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('should discard temp workspace when clicking X icon', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-x');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Start workspace creation', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Click the X icon to cancel', async () => {
|
||||
await page.locator('.inline-action-btn.cancel').click();
|
||||
});
|
||||
|
||||
await test.step('Verify switched back to default workspace', async () => {
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('should discard temp workspace when clicking outside with empty name', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-outside-empty');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Start workspace creation and clear the name', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
await renameInput.fill('');
|
||||
});
|
||||
|
||||
await test.step('Click outside to trigger cancel with empty name', async () => {
|
||||
await page.locator('.app-titlebar').click({ position: { x: 500, y: 10 } });
|
||||
});
|
||||
|
||||
await test.step('Verify switched back to default workspace', async () => {
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Advanced Modal Flow', () => {
|
||||
test('should create workspace via advanced modal with custom location', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-modal');
|
||||
const customLocation = await createTmpDir('custom-ws-location');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Start inline creation and click settings icon to open advanced modal', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Settings gear icon should be visible during creation
|
||||
const cogBtn = page.locator('.cog-btn');
|
||||
await expect(cogBtn).toBeVisible();
|
||||
await cogBtn.click();
|
||||
});
|
||||
|
||||
await test.step('Fill in the advanced modal form with custom location', async () => {
|
||||
const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' });
|
||||
await modal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Fill workspace name
|
||||
await modal.locator('#workspace-name').fill('Advanced Workspace');
|
||||
|
||||
// Wait for folder name section to appear
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// The location input is read-only and Formik-controlled — .fill() won't update
|
||||
// Formik state. Stub the dialog so the browse() callback sets the custom location.
|
||||
await app.evaluate(
|
||||
({ dialog }, targetPath: string) => {
|
||||
(dialog as any).showOpenDialog = () =>
|
||||
Promise.resolve({ canceled: false, filePaths: [targetPath] });
|
||||
},
|
||||
customLocation
|
||||
);
|
||||
// Click the location input to trigger browse() which calls showOpenDialog
|
||||
await modal.locator('#workspace-location').click();
|
||||
// Verify location was set
|
||||
await expect(modal.locator('#workspace-location')).toHaveValue(customLocation, { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Submit the form', async () => {
|
||||
const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' });
|
||||
await modal.getByRole('button', { name: 'Create Workspace' }).click();
|
||||
});
|
||||
|
||||
await test.step('Verify workspace created', async () => {
|
||||
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('Advanced Workspace', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify filesystem at custom location (NOT default location)', async () => {
|
||||
// Workspace should be at customLocation, not wsLocation
|
||||
const customDirs = findCreatedWorkspaceDirs(customLocation);
|
||||
expect(customDirs.length).toBe(1);
|
||||
|
||||
const config = yaml.load(
|
||||
fs.readFileSync(path.join(customLocation, customDirs[0], 'workspace.yml'), 'utf8')
|
||||
) as WorkspaceConfig;
|
||||
expect(config?.info?.name).toBe('Advanced Workspace');
|
||||
|
||||
// No workspace at the default location
|
||||
const defaultDirs = findCreatedWorkspaceDirs(wsLocation);
|
||||
expect(defaultDirs).toHaveLength(0);
|
||||
});
|
||||
|
||||
await test.step('Verify inline rename input is cleared after modal creation', async () => {
|
||||
await expect(page.locator('.workspace-name-input')).not.toBeVisible();
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('should create workspace via advanced modal at default location', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-modal-default');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Start inline creation and open advanced modal', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 });
|
||||
await page.locator('.cog-btn').click();
|
||||
});
|
||||
|
||||
await test.step('Fill name and keep default location', async () => {
|
||||
const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' });
|
||||
await modal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await modal.locator('#workspace-name').fill('Default Loc Workspace');
|
||||
await page.waitForTimeout(300);
|
||||
});
|
||||
|
||||
await test.step('Submit the form', async () => {
|
||||
const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' });
|
||||
await modal.getByRole('button', { name: 'Create Workspace' }).click();
|
||||
});
|
||||
|
||||
await test.step('Verify workspace created at default location', async () => {
|
||||
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('Default Loc Workspace', { timeout: 5000 });
|
||||
|
||||
const wsDirs = findCreatedWorkspaceDirs(wsLocation);
|
||||
expect(wsDirs.length).toBe(1);
|
||||
|
||||
const config = yaml.load(
|
||||
fs.readFileSync(path.join(wsLocation, wsDirs[0], 'workspace.yml'), 'utf8')
|
||||
) as WorkspaceConfig;
|
||||
expect(config?.info?.name).toBe('Default Loc Workspace');
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('should cancel advanced modal and discard temp workspace', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-modal-cancel');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Start inline creation and open advanced modal', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 });
|
||||
await page.locator('.cog-btn').click();
|
||||
});
|
||||
|
||||
await test.step('Cancel the advanced modal', async () => {
|
||||
const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' });
|
||||
await modal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await modal.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
await test.step('Verify temp workspace discarded and back to default', async () => {
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 });
|
||||
await expect(page.locator('.workspace-name-input')).not.toBeVisible();
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('should show validation error for empty name in modal', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-modal-empty');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Start inline creation and open advanced modal', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 });
|
||||
await page.locator('.cog-btn').click();
|
||||
});
|
||||
|
||||
await test.step('Clear name and try to submit', async () => {
|
||||
const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' });
|
||||
await modal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Ensure name field is empty
|
||||
await modal.locator('#workspace-name').fill('');
|
||||
await modal.getByRole('button', { name: 'Create Workspace' }).click();
|
||||
});
|
||||
|
||||
await test.step('Verify validation error appears and modal stays open', async () => {
|
||||
const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' });
|
||||
await expect(modal).toBeVisible();
|
||||
const error = modal.locator('.text-red-500');
|
||||
await expect(error.first()).toBeVisible({ timeout: 2000 });
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Workspace Name Display', () => {
|
||||
test('should show correct name in title bar dropdown after creation', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-display');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Create a workspace with specific name', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
await renameInput.fill('Display Test WS');
|
||||
await renameInput.press('Enter');
|
||||
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Verify name in title bar', async () => {
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('Display Test WS', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify name in title bar dropdown', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
const wsItem = page.locator('.workspace-item, .dropdown-item').filter({ hasText: 'Display Test WS' });
|
||||
await expect(wsItem.first()).toBeVisible();
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('should persist workspace name after app restart', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const userDataPath = await createTmpDir('create-ws-name-persist');
|
||||
const wsLocation = await createTmpDir('ws-location-persist');
|
||||
|
||||
// First launch: create workspace
|
||||
const app1 = await launchElectronApp({ userDataPath, initUserDataPath, templateVars: { wsLocation } });
|
||||
const page1 = await app1.firstWindow();
|
||||
await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Create workspace', async () => {
|
||||
await page1.locator('.workspace-name-container').click();
|
||||
await page1.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
const renameInput = page1.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
await renameInput.fill('Persisted WS');
|
||||
await renameInput.press('Enter');
|
||||
await expect(page1.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await closeElectronApp(app1);
|
||||
|
||||
// Second launch: verify name persists (reuse same userDataPath)
|
||||
const app2 = await launchElectronApp({ userDataPath });
|
||||
const page2 = await app2.firstWindow();
|
||||
await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Verify workspace name persisted', async () => {
|
||||
await page2.locator('.workspace-name-container').click();
|
||||
const wsItem = page2.locator('.workspace-item, .dropdown-item').filter({ hasText: 'Persisted WS' });
|
||||
await expect(wsItem.first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await closeElectronApp(app2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Edge Cases', () => {
|
||||
test('should handle creating multiple workspaces sequentially', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-multiple');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Create first workspace', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
await renameInput.fill('Workspace One');
|
||||
await renameInput.press('Enter');
|
||||
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('Workspace One', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Create second workspace', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
await renameInput.fill('Workspace Two');
|
||||
await renameInput.press('Enter');
|
||||
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('Workspace Two', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify both workspaces exist in dropdown', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
const wsOne = page.locator('.workspace-item, .dropdown-item').filter({ hasText: 'Workspace One' });
|
||||
const wsTwo = page.locator('.workspace-item, .dropdown-item').filter({ hasText: 'Workspace Two' });
|
||||
await expect(wsOne.first()).toBeVisible();
|
||||
await expect(wsTwo.first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify both workspace folders on filesystem', async () => {
|
||||
const wsDirs = findCreatedWorkspaceDirs(wsLocation);
|
||||
expect(wsDirs.length).toBe(2);
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('should handle creating then cancelling then creating again', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-cancel-retry');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Start creation and cancel with Escape', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 });
|
||||
await page.locator('.workspace-name-input').press('Escape');
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Create again successfully', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
await renameInput.fill('Retry Workspace');
|
||||
await renameInput.press('Enter');
|
||||
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('Retry Workspace', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('should handle workspace name with special characters', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-special');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Create workspace with special characters in name', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
await renameInput.fill('My API & Testing (v2)');
|
||||
await renameInput.press('Enter');
|
||||
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('My API & Testing (v2)', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify workspace name stored correctly in workspace.yml', async () => {
|
||||
const wsDirs = findCreatedWorkspaceDirs(wsLocation);
|
||||
expect(wsDirs.length).toBe(1);
|
||||
|
||||
const config = yaml.load(
|
||||
fs.readFileSync(path.join(wsLocation, wsDirs[0], 'workspace.yml'), 'utf8')
|
||||
) as WorkspaceConfig;
|
||||
expect(config?.info?.name).toBe('My API & Testing (v2)');
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('should show validation error for empty name inline when pressing Enter', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-empty');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Create workspace and clear name', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
await renameInput.fill('');
|
||||
});
|
||||
|
||||
await test.step('Press Enter with empty name - should show error', async () => {
|
||||
await page.locator('.workspace-name-input').press('Enter');
|
||||
const error = page.locator('.workspace-error');
|
||||
await expect(error).toBeVisible({ timeout: 2000 });
|
||||
await expect(error).toContainText('required');
|
||||
});
|
||||
|
||||
await test.step('Verify still in rename mode (not discarded)', async () => {
|
||||
await expect(page.locator('.workspace-name-input')).toBeVisible();
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('should not show settings/cog icon when renaming an existing workspace', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-no-cog');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Create a workspace first', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// During creation, the cog button should be visible
|
||||
await expect(page.locator('.cog-btn')).toBeVisible();
|
||||
|
||||
await renameInput.fill('Existing WS');
|
||||
await renameInput.press('Enter');
|
||||
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Rename existing workspace - cog should NOT be visible', async () => {
|
||||
// Use workspace actions dropdown to start rename
|
||||
const actionsIcon = page.locator('.workspace-actions-trigger');
|
||||
await actionsIcon.click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Rename' }).click();
|
||||
|
||||
// Inline rename input should appear
|
||||
await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Cog button should NOT be visible for existing workspace rename
|
||||
await expect(page.locator('.cog-btn')).not.toBeVisible();
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Workspace Switching After Creation', () => {
|
||||
test('should switch between created workspace and default workspace', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-switch');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Create a new workspace', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
const renameInput = page.locator('.workspace-name-input');
|
||||
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
||||
await renameInput.fill('Switchable WS');
|
||||
await renameInput.press('Enter');
|
||||
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('Switchable WS', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Switch to default workspace via dropdown', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
const defaultWs = page.locator('.workspace-item, .dropdown-item').filter({ hasText: 'My Workspace' });
|
||||
await defaultWs.first().click();
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Switch back to created workspace', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
const createdWs = page.locator('.workspace-item, .dropdown-item').filter({ hasText: 'Switchable WS' });
|
||||
await createdWs.first().click();
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('Switchable WS', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Temp Workspace Isolation', () => {
|
||||
test('should exclude temp workspace from duplicate name validation in advanced modal', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const wsLocation = await createTmpDir('ws-location-no-temp');
|
||||
|
||||
const app = await launchElectronApp({ initUserDataPath, templateVars: { wsLocation } });
|
||||
const page = await app.firstWindow();
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Start creation but do not confirm', async () => {
|
||||
await page.locator('.workspace-name-container').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
||||
await expect(page.locator('.workspace-name-input')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Open advanced modal and verify temp workspace name is not a conflict', async () => {
|
||||
await page.locator('.cog-btn').click();
|
||||
|
||||
const modal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Workspace' });
|
||||
await modal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Fill the same name as temp workspace — should NOT show "already exists" error
|
||||
// since isCreating workspaces are excluded from validation
|
||||
await modal.locator('#workspace-name').fill('Untitled Workspace');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const errorText = modal.locator('.text-red-500');
|
||||
const hasError = await errorText.isVisible().catch(() => false);
|
||||
if (hasError) {
|
||||
const errorContent = await errorText.textContent();
|
||||
expect(errorContent).not.toContain('already exists');
|
||||
}
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
},
|
||||
"general": {
|
||||
"defaultLocation": "{{wsLocation}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user