diff --git a/app/src/components/navigation/UserMenu.test.tsx b/app/src/components/navigation/UserMenu.test.tsx
new file mode 100644
index 0000000..a1f744b
--- /dev/null
+++ b/app/src/components/navigation/UserMenu.test.tsx
@@ -0,0 +1,175 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../../test/utils';
+import UserMenu from './UserMenu';
+import { UserRole } from '../../types/models';
+
+// Mock the contexts
+vi.mock('../../contexts/AuthContext', () => ({
+ useAuth: vi.fn(),
+}));
+
+// Mock the settings components
+vi.mock('../settings/account/AccountSettings', () => ({
+ default: ({ opened, onClose }: { opened: boolean; onClose: () => void }) => (
+
+
+
+ ),
+}));
+
+vi.mock('../settings/admin/AdminDashboard', () => ({
+ default: ({ opened, onClose }: { opened: boolean; onClose: () => void }) => (
+
+
+
+ ),
+}));
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('UserMenu', () => {
+ const mockLogout = vi.fn();
+ const mockUser = {
+ id: 1,
+ email: 'test@example.com',
+ displayName: 'Test User',
+ role: UserRole.Editor,
+ createdAt: '2024-01-01T00:00:00Z',
+ lastWorkspaceId: 1,
+ };
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ const { useAuth } = await import('../../contexts/AuthContext');
+ vi.mocked(useAuth).mockReturnValue({
+ user: mockUser,
+ logout: mockLogout,
+ loading: false,
+ initialized: true,
+ login: vi.fn(),
+ refreshToken: vi.fn(),
+ refreshUser: vi.fn(),
+ });
+ });
+
+ it('renders user avatar and shows user info when clicked', async () => {
+ const { getByLabelText, getByText } = render(
+
+
+
+ );
+
+ // Find and click the avatar
+ const avatar = getByLabelText('User menu');
+ fireEvent.click(avatar);
+
+ // Check if user info is displayed in popover
+ await waitFor(() => {
+ expect(getByText('Test User')).toBeInTheDocument();
+ });
+ });
+
+ it('shows admin dashboard option for admin users only', async () => {
+ // Test admin user sees admin option
+ const { useAuth } = await import('../../contexts/AuthContext');
+ vi.mocked(useAuth).mockReturnValue({
+ user: { ...mockUser, role: UserRole.Admin },
+ logout: mockLogout,
+ loading: false,
+ initialized: true,
+ login: vi.fn(),
+ refreshToken: vi.fn(),
+ refreshUser: vi.fn(),
+ });
+
+ const { getByLabelText, getByText } = render(
+
+
+
+ );
+
+ const avatar = getByLabelText('User menu');
+ fireEvent.click(avatar);
+
+ await waitFor(() => {
+ expect(getByText('Admin Dashboard')).toBeInTheDocument();
+ });
+ });
+
+ it('opens account settings modal when clicked', async () => {
+ const { getByLabelText, getByText, getByTestId } = render(
+
+
+
+ );
+
+ const avatar = getByLabelText('User menu');
+ fireEvent.click(avatar);
+
+ await waitFor(() => {
+ const accountSettingsButton = getByText('Account Settings');
+ fireEvent.click(accountSettingsButton);
+ });
+
+ await waitFor(() => {
+ const modal = getByTestId('account-settings-modal');
+ expect(modal).toHaveAttribute('data-opened', 'true');
+ });
+ });
+
+ it('calls logout when logout button is clicked', async () => {
+ const { getByLabelText, getByText } = render(
+
+
+
+ );
+
+ const avatar = getByLabelText('User menu');
+ fireEvent.click(avatar);
+
+ await waitFor(() => {
+ const logoutButton = getByText('Logout');
+ fireEvent.click(logoutButton);
+ });
+
+ expect(mockLogout).toHaveBeenCalledOnce();
+ });
+
+ it('displays user email when displayName is not available', async () => {
+ const { useAuth } = await import('../../contexts/AuthContext');
+ const userWithoutDisplayName = {
+ id: mockUser.id,
+ email: mockUser.email,
+ role: mockUser.role,
+ createdAt: mockUser.createdAt,
+ lastWorkspaceId: mockUser.lastWorkspaceId,
+ };
+
+ vi.mocked(useAuth).mockReturnValue({
+ user: userWithoutDisplayName,
+ logout: mockLogout,
+ loading: false,
+ initialized: true,
+ login: vi.fn(),
+ refreshToken: vi.fn(),
+ refreshUser: vi.fn(),
+ });
+
+ const { getByLabelText, getByText } = render(
+
+
+
+ );
+
+ const avatar = getByLabelText('User menu');
+ fireEvent.click(avatar);
+
+ await waitFor(() => {
+ expect(getByText('test@example.com')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/app/src/components/navigation/UserMenu.tsx b/app/src/components/navigation/UserMenu.tsx
index c7177b8..6d30bca 100644
--- a/app/src/components/navigation/UserMenu.tsx
+++ b/app/src/components/navigation/UserMenu.tsx
@@ -47,6 +47,10 @@ const UserMenu: React.FC = () => {
radius="xl"
style={{ cursor: 'pointer' }}
onClick={() => setOpened((o) => !o)}
+ aria-label="User menu"
+ aria-expanded={opened}
+ aria-haspopup="menu"
+ role="button"
>
diff --git a/app/src/components/navigation/WorkspaceSwitcher.test.tsx b/app/src/components/navigation/WorkspaceSwitcher.test.tsx
new file mode 100644
index 0000000..12516d2
--- /dev/null
+++ b/app/src/components/navigation/WorkspaceSwitcher.test.tsx
@@ -0,0 +1,232 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../../test/utils';
+import WorkspaceSwitcher from './WorkspaceSwitcher';
+import { Theme } from '../../types/models';
+
+// Mock the hooks and contexts
+vi.mock('../../hooks/useWorkspace', () => ({
+ useWorkspace: vi.fn(),
+}));
+
+vi.mock('../../contexts/ModalContext', () => ({
+ useModalContext: vi.fn(),
+}));
+
+// Mock API
+vi.mock('../../api/workspace', () => ({
+ listWorkspaces: vi.fn(),
+}));
+
+// Mock the CreateWorkspaceModal component
+vi.mock('../modals/workspace/CreateWorkspaceModal', () => ({
+ default: ({
+ onWorkspaceCreated,
+ }: {
+ onWorkspaceCreated: (workspace: {
+ name: string;
+ createdAt: number;
+ }) => void;
+ }) => (
+
+
+
+ ),
+}));
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('WorkspaceSwitcher', () => {
+ const mockSwitchWorkspace = vi.fn();
+ const mockSetSettingsModalVisible = vi.fn();
+ const mockSetCreateWorkspaceModalVisible = vi.fn();
+
+ const mockCurrentWorkspace = {
+ id: 1,
+ name: 'Current Workspace',
+ createdAt: '2024-01-01T00:00:00Z',
+ theme: Theme.Light,
+ autoSave: false,
+ showHiddenFiles: false,
+ gitEnabled: false,
+ gitUrl: '',
+ gitUser: '',
+ gitToken: '',
+ gitAutoCommit: false,
+ gitCommitMsgTemplate: '${action} ${filename}',
+ gitCommitName: '',
+ gitCommitEmail: '',
+ };
+
+ const mockWorkspaces = [
+ mockCurrentWorkspace,
+ {
+ id: 2,
+ name: 'Other Workspace',
+ createdAt: '2024-01-02T00:00:00Z',
+ theme: Theme.Dark,
+ autoSave: true,
+ showHiddenFiles: true,
+ gitEnabled: true,
+ gitUrl: 'https://github.com/test/repo',
+ gitUser: 'testuser',
+ gitToken: 'token',
+ gitAutoCommit: true,
+ gitCommitMsgTemplate: 'Auto: ${action} ${filename}',
+ gitCommitName: 'Test User',
+ gitCommitEmail: 'test@example.com',
+ },
+ ];
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ const { useWorkspace } = await import('../../hooks/useWorkspace');
+ vi.mocked(useWorkspace).mockReturnValue({
+ currentWorkspace: mockCurrentWorkspace,
+ workspaces: [],
+ settings: mockCurrentWorkspace,
+ updateSettings: vi.fn(),
+ loading: false,
+ colorScheme: 'light',
+ updateColorScheme: vi.fn(),
+ switchWorkspace: mockSwitchWorkspace,
+ deleteCurrentWorkspace: vi.fn(),
+ });
+
+ const { useModalContext } = await import('../../contexts/ModalContext');
+ vi.mocked(useModalContext).mockReturnValue({
+ newFileModalVisible: false,
+ setNewFileModalVisible: vi.fn(),
+ deleteFileModalVisible: false,
+ setDeleteFileModalVisible: vi.fn(),
+ commitMessageModalVisible: false,
+ setCommitMessageModalVisible: vi.fn(),
+ settingsModalVisible: false,
+ setSettingsModalVisible: mockSetSettingsModalVisible,
+ switchWorkspaceModalVisible: false,
+ setSwitchWorkspaceModalVisible: vi.fn(),
+ createWorkspaceModalVisible: false,
+ setCreateWorkspaceModalVisible: mockSetCreateWorkspaceModalVisible,
+ });
+
+ const { listWorkspaces } = await import('../../api/workspace');
+ vi.mocked(listWorkspaces).mockResolvedValue(mockWorkspaces);
+ });
+
+ it('renders current workspace name', () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText('Current Workspace')).toBeInTheDocument();
+ });
+
+ it('shows "No workspace" when no current workspace', async () => {
+ const { useWorkspace } = await import('../../hooks/useWorkspace');
+ vi.mocked(useWorkspace).mockReturnValue({
+ currentWorkspace: null,
+ workspaces: [],
+ settings: mockCurrentWorkspace,
+ updateSettings: vi.fn(),
+ loading: false,
+ colorScheme: 'light',
+ updateColorScheme: vi.fn(),
+ switchWorkspace: mockSwitchWorkspace,
+ deleteCurrentWorkspace: vi.fn(),
+ });
+
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText('No workspace')).toBeInTheDocument();
+ });
+
+ it('opens popover and shows workspace list when clicked', async () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ // Click to open popover
+ const trigger = getByText('Current Workspace');
+ fireEvent.click(trigger);
+
+ // Should see the workspaces header and workspace list
+ await waitFor(() => {
+ expect(getByText('Workspaces')).toBeInTheDocument();
+ expect(getByText('Other Workspace')).toBeInTheDocument();
+ });
+ });
+
+ it('switches workspace when another workspace is clicked', async () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ // Open popover and click on other workspace
+ const trigger = getByText('Current Workspace');
+ fireEvent.click(trigger);
+
+ await waitFor(() => {
+ const otherWorkspace = getByText('Other Workspace');
+ fireEvent.click(otherWorkspace);
+ });
+
+ expect(mockSwitchWorkspace).toHaveBeenCalledWith('Other Workspace');
+ });
+
+ it('opens create workspace modal when create button is clicked', async () => {
+ const { getByText, getByLabelText } = render(
+
+
+
+ );
+
+ // Open popover and click create button
+ const trigger = getByText('Current Workspace');
+ fireEvent.click(trigger);
+
+ await waitFor(() => {
+ const createButton = getByLabelText('Create New Workspace');
+ fireEvent.click(createButton);
+ });
+
+ expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(true);
+ });
+
+ it('opens settings modal when settings button is clicked', async () => {
+ const { getByText, getByLabelText } = render(
+
+
+
+ );
+
+ // Open popover and click settings button
+ const trigger = getByText('Current Workspace');
+ fireEvent.click(trigger);
+
+ await waitFor(() => {
+ const settingsButton = getByLabelText('Workspace Settings');
+ fireEvent.click(settingsButton);
+ });
+
+ expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(true);
+ });
+});
diff --git a/app/src/components/navigation/WorkspaceSwitcher.tsx b/app/src/components/navigation/WorkspaceSwitcher.tsx
index 74d2722..21f0f4d 100644
--- a/app/src/components/navigation/WorkspaceSwitcher.tsx
+++ b/app/src/components/navigation/WorkspaceSwitcher.tsx
@@ -95,6 +95,7 @@ const WorkspaceSwitcher: React.FC = () => {
@@ -152,6 +153,7 @@ const WorkspaceSwitcher: React.FC = () => {
variant="subtle"
size="lg"
color={getConditionalColor(theme, true)}
+ aria-label="Workspace Settings"
onClick={(e) => {
e.stopPropagation();
setSettingsModalVisible(true);