diff --git a/app/src/components/auth/LoginPage.test.tsx b/app/src/components/auth/LoginPage.test.tsx index 5975b1d..7702e47 100644 --- a/app/src/components/auth/LoginPage.test.tsx +++ b/app/src/components/auth/LoginPage.test.tsx @@ -68,30 +68,28 @@ describe('LoginPage', () => { ).toBeInTheDocument(); // Check form fields - expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByTestId('email-input')).toBeInTheDocument(); + expect(screen.getByTestId('password-input')).toBeInTheDocument(); // Check submit button - expect( - screen.getByRole('button', { name: /sign in/i }) - ).toBeInTheDocument(); + expect(screen.getByTestId('login-button')).toBeInTheDocument(); }); it('renders form fields with correct placeholders', () => { render(); - const emailInput = screen.getByPlaceholderText('your@email.com'); - const passwordInput = screen.getByPlaceholderText('Your password'); + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); - expect(emailInput).toBeInTheDocument(); - expect(passwordInput).toBeInTheDocument(); + expect(emailInput).toHaveAttribute('placeholder', 'your@email.com'); + expect(passwordInput).toHaveAttribute('placeholder', 'Your password'); }); it('renders required fields as required', () => { render(); - const emailInput = screen.getByLabelText(/email/i); - const passwordInput = screen.getByLabelText(/password/i); + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); expect(emailInput).toBeRequired(); expect(passwordInput).toBeRequired(); @@ -100,7 +98,8 @@ describe('LoginPage', () => { it('submit button is not loading initially', () => { render(); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const submitButton = screen.getByTestId('login-button'); + expect(submitButton).toHaveRole('button'); expect(submitButton).not.toHaveAttribute('data-loading', 'true'); }); }); @@ -109,7 +108,7 @@ describe('LoginPage', () => { it('updates email input value when typed', () => { render(); - const emailInput = screen.getByLabelText(/email/i); + const emailInput = screen.getByTestId('email-input'); fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); expect((emailInput as HTMLInputElement).value).toBe('test@example.com'); @@ -118,7 +117,7 @@ describe('LoginPage', () => { it('updates password input value when typed', () => { render(); - const passwordInput = screen.getByLabelText(/password/i); + const passwordInput = screen.getByTestId('password-input'); fireEvent.change(passwordInput, { target: { value: 'password123' } }); expect((passwordInput as HTMLInputElement).value).toBe('password123'); @@ -127,8 +126,8 @@ describe('LoginPage', () => { it('clears form values when inputs are cleared', () => { render(); - const emailInput = screen.getByLabelText(/email/i); - const passwordInput = screen.getByLabelText(/password/i); + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); // Set values fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); @@ -147,9 +146,9 @@ describe('LoginPage', () => { it('calls login function with correct credentials on form submit', async () => { render(); - const emailInput = screen.getByLabelText(/email/i); - const passwordInput = screen.getByLabelText(/password/i); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); + const submitButton = screen.getByTestId('login-button'); // Fill in the form fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); @@ -170,8 +169,8 @@ describe('LoginPage', () => { render(); const form = screen.getByRole('form'); - const emailInput = screen.getByLabelText(/email/i); - const passwordInput = screen.getByLabelText(/password/i); + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); // Fill in the form fireEvent.change(emailInput, { target: { value: 'user@test.com' } }); @@ -195,9 +194,9 @@ describe('LoginPage', () => { render(); - const emailInput = screen.getByLabelText(/email/i); - const passwordInput = screen.getByLabelText(/password/i); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); + const submitButton = screen.getByTestId('login-button'); // Fill in the form fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); @@ -228,9 +227,9 @@ describe('LoginPage', () => { render(); - const emailInput = screen.getByLabelText(/email/i); - const passwordInput = screen.getByLabelText(/password/i); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); + const submitButton = screen.getByTestId('login-button'); // Fill in the form fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); @@ -265,7 +264,7 @@ describe('LoginPage', () => { it('prevents form submission with empty fields', () => { render(); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const submitButton = screen.getByTestId('login-button'); // Try to submit without filling fields fireEvent.click(submitButton); @@ -277,8 +276,8 @@ describe('LoginPage', () => { it('prevents form submission with only email filled', () => { render(); - const emailInput = screen.getByLabelText(/email/i); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const emailInput = screen.getByTestId('email-input'); + const submitButton = screen.getByTestId('login-button'); // Fill only email fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); @@ -293,8 +292,8 @@ describe('LoginPage', () => { it('prevents form submission with only password filled', () => { render(); - const passwordInput = screen.getByLabelText(/password/i); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const passwordInput = screen.getByTestId('password-input'); + const submitButton = screen.getByTestId('login-button'); // Fill only password fireEvent.change(passwordInput, { target: { value: 'password123' } }); @@ -311,9 +310,9 @@ describe('LoginPage', () => { it('handles special characters in email and password', async () => { render(); - const emailInput = screen.getByLabelText(/email/i); - const passwordInput = screen.getByLabelText(/password/i); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); + const submitButton = screen.getByTestId('login-button'); const specialEmail = 'user+test@example-domain.com'; const specialPassword = 'P@ssw0rd!#$%'; @@ -333,9 +332,9 @@ describe('LoginPage', () => { it('handles very long email and password values', async () => { render(); - const emailInput = screen.getByLabelText(/email/i); - const passwordInput = screen.getByLabelText(/password/i); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); + const submitButton = screen.getByTestId('login-button'); const longEmail = 'a'.repeat(100) + '@example.com'; const longPassword = 'p'.repeat(200); @@ -352,9 +351,9 @@ describe('LoginPage', () => { it('resets loading state after successful login', async () => { render(); - const emailInput = screen.getByLabelText(/email/i); - const passwordInput = screen.getByLabelText(/password/i); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); + const submitButton = screen.getByTestId('login-button'); fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); fireEvent.change(passwordInput, { target: { value: 'password123' } }); @@ -374,8 +373,8 @@ describe('LoginPage', () => { it('has proper form structure with labels', () => { render(); - const emailInput = screen.getByLabelText(/email/i); - const passwordInput = screen.getByLabelText(/password/i); + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); expect(emailInput).toBeInTheDocument(); expect(passwordInput).toBeInTheDocument(); @@ -386,8 +385,8 @@ describe('LoginPage', () => { it('has proper input types', () => { render(); - const emailInput = screen.getByLabelText(/email/i); - const passwordInput = screen.getByLabelText(/password/i); + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); expect(emailInput).toHaveAttribute('type', 'email'); expect(passwordInput).toHaveAttribute('type', 'password'); @@ -396,7 +395,7 @@ describe('LoginPage', () => { it('submit button has proper type', () => { render(); - const submitButton = screen.getByRole('button', { name: /sign in/i }); + const submitButton = screen.getByTestId('login-button'); expect(submitButton).toHaveAttribute('type', 'submit'); }); }); diff --git a/app/src/components/auth/LoginPage.tsx b/app/src/components/auth/LoginPage.tsx index daa47a3..8475d49 100644 --- a/app/src/components/auth/LoginPage.tsx +++ b/app/src/components/auth/LoginPage.tsx @@ -43,6 +43,7 @@ const LoginPage: React.FC = () => { type="email" label="Email" placeholder="your@email.com" + data-testid="email-input" required value={email} onChange={(event) => setEmail(event.currentTarget.value)} @@ -51,12 +52,13 @@ const LoginPage: React.FC = () => { setPassword(event.currentTarget.value)} /> - diff --git a/app/src/components/modals/file/CreateFileModal.test.tsx b/app/src/components/modals/file/CreateFileModal.test.tsx index ffc3fd7..d013ba4 100644 --- a/app/src/components/modals/file/CreateFileModal.test.tsx +++ b/app/src/components/modals/file/CreateFileModal.test.tsx @@ -62,15 +62,15 @@ describe('CreateFileModal', () => { render(); expect(screen.getByText('Create New File')).toBeInTheDocument(); - expect(screen.getByLabelText(/file name/i)).toBeInTheDocument(); - expect(screen.getByText('Cancel')).toBeInTheDocument(); - expect(screen.getByText('Create')).toBeInTheDocument(); + expect(screen.getByTestId('file-name-input')).toBeInTheDocument(); + expect(screen.getByTestId('cancel-create-button')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-create-button')).toBeInTheDocument(); }); it('calls setNewFileModalVisible when modal is closed', () => { render(); - const cancelButton = screen.getByText('Cancel'); + const cancelButton = screen.getByTestId('cancel-create-button'); fireEvent.click(cancelButton); expect(mockModalContext.setNewFileModalVisible).toHaveBeenCalledWith( @@ -83,7 +83,7 @@ describe('CreateFileModal', () => { it('updates file name input when typed', () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); + const fileNameInput = screen.getByTestId('file-name-input'); fireEvent.change(fileNameInput, { target: { value: 'test-file.md' } }); expect((fileNameInput as HTMLInputElement).value).toBe('test-file.md'); @@ -92,8 +92,8 @@ describe('CreateFileModal', () => { it('handles form submission with valid file name', async () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); - const createButton = screen.getByText('Create'); + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-button'); fireEvent.change(fileNameInput, { target: { value: 'new-document.md' } }); fireEvent.click(createButton); @@ -106,7 +106,7 @@ describe('CreateFileModal', () => { it('prevents submission with empty file name', () => { render(); - const createButton = screen.getByText('Create'); + const createButton = screen.getByTestId('confirm-create-button'); fireEvent.click(createButton); // Should not call the function with empty name @@ -116,8 +116,8 @@ describe('CreateFileModal', () => { it('closes modal after successful file creation', async () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); - const createButton = screen.getByText('Create'); + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-button'); fireEvent.change(fileNameInput, { target: { value: 'test.md' } }); fireEvent.click(createButton); @@ -136,8 +136,8 @@ describe('CreateFileModal', () => { it('clears input after successful submission', async () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); - const createButton = screen.getByText('Create'); + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-button'); fireEvent.change(fileNameInput, { target: { value: 'test.md' } }); fireEvent.click(createButton); @@ -156,14 +156,20 @@ describe('CreateFileModal', () => { it('has cancel and create buttons', () => { render(); - expect(screen.getByText('Cancel')).toBeInTheDocument(); - expect(screen.getByText('Create')).toBeInTheDocument(); + const confirmButton = screen.getByTestId('confirm-create-button'); + const cancelButton = screen.getByTestId('cancel-create-button'); + + expect(confirmButton).toBeInTheDocument(); + expect(cancelButton).toBeInTheDocument(); + + expect(confirmButton).toHaveRole('button'); + expect(cancelButton).toHaveRole('button'); }); it('closes modal when cancel button is clicked', () => { render(); - const cancelButton = screen.getByText('Cancel'); + const cancelButton = screen.getByTestId('cancel-create-button'); fireEvent.click(cancelButton); expect(mockModalContext.setNewFileModalVisible).toHaveBeenCalledWith( @@ -174,8 +180,8 @@ describe('CreateFileModal', () => { it('calls onCreateFile when create button is clicked with valid input', async () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); - const createButton = screen.getByText('Create'); + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-button'); fireEvent.change(fileNameInput, { target: { value: 'test.md' } }); fireEvent.click(createButton); @@ -191,8 +197,8 @@ describe('CreateFileModal', () => { it('handles special characters in file names', async () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); - const createButton = screen.getByText('Create'); + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-button'); const specialFileName = 'file-with_special.chars (1).md'; fireEvent.change(fileNameInput, { target: { value: specialFileName } }); @@ -206,8 +212,8 @@ describe('CreateFileModal', () => { it('handles long file names', async () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); - const createButton = screen.getByText('Create'); + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-button'); const longFileName = 'a'.repeat(100) + '.md'; fireEvent.change(fileNameInput, { target: { value: longFileName } }); @@ -221,8 +227,8 @@ describe('CreateFileModal', () => { it('handles file names without extensions', async () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); - const createButton = screen.getByText('Create'); + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-button'); fireEvent.change(fileNameInput, { target: { value: 'README' } }); fireEvent.click(createButton); @@ -235,8 +241,8 @@ describe('CreateFileModal', () => { it('handles unicode characters in file names', async () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); - const createButton = screen.getByText('Create'); + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-button'); const unicodeFileName = 'ファイル名.md'; fireEvent.change(fileNameInput, { target: { value: unicodeFileName } }); @@ -250,8 +256,8 @@ describe('CreateFileModal', () => { it('trims whitespace from file names', async () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); - const createButton = screen.getByText('Create'); + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-button'); fireEvent.change(fileNameInput, { target: { value: ' spaced-file.md ' }, @@ -270,8 +276,8 @@ describe('CreateFileModal', () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); - const createButton = screen.getByText('Create'); + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-button'); fireEvent.change(fileNameInput, { target: { value: 'test.md' } }); fireEvent.click(createButton); @@ -289,8 +295,8 @@ describe('CreateFileModal', () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); - const createButton = screen.getByText('Create'); + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-button'); fireEvent.change(fileNameInput, { target: { value: 'test.md' } }); fireEvent.click(createButton); @@ -310,7 +316,7 @@ describe('CreateFileModal', () => { it('has proper form labels and structure', () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); + const fileNameInput = screen.getByTestId('file-name-input'); expect(fileNameInput).toBeInTheDocument(); expect(fileNameInput.tagName).toBe('INPUT'); expect(fileNameInput).toHaveAttribute('type', 'text'); @@ -332,7 +338,7 @@ describe('CreateFileModal', () => { it('supports keyboard navigation', () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); + const fileNameInput = screen.getByTestId('file-name-input'); // Check that the input is focusable (not disabled or readonly) expect(fileNameInput).not.toHaveAttribute('disabled'); @@ -356,7 +362,7 @@ describe('CreateFileModal', () => { expect(screen.getByText('Create New File')).toBeInTheDocument(); // Should have form elements - expect(screen.getByLabelText(/file name/i)).toBeInTheDocument(); + expect(screen.getByTestId('file-name-input')).toBeInTheDocument(); }); }); @@ -366,8 +372,8 @@ describe('CreateFileModal', () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); - const createButton = screen.getByText('Create'); + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-button'); fireEvent.change(fileNameInput, { target: { value: 'custom-test.md' } }); fireEvent.click(createButton); @@ -392,7 +398,7 @@ describe('CreateFileModal', () => { it('submits form via Enter key', async () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); + const fileNameInput = screen.getByTestId('file-name-input'); fireEvent.change(fileNameInput, { target: { value: 'enter-test.md' } }); fireEvent.keyDown(fileNameInput, { key: 'Enter', code: 'Enter' }); @@ -405,7 +411,7 @@ describe('CreateFileModal', () => { it('does not submit empty form via Enter key', () => { render(); - const fileNameInput = screen.getByLabelText(/file name/i); + const fileNameInput = screen.getByTestId('file-name-input'); fireEvent.keyDown(fileNameInput, { key: 'Enter', code: 'Enter' }); // Should not call the function diff --git a/app/src/components/modals/file/CreateFileModal.tsx b/app/src/components/modals/file/CreateFileModal.tsx index d52c114..655a736 100644 --- a/app/src/components/modals/file/CreateFileModal.tsx +++ b/app/src/components/modals/file/CreateFileModal.tsx @@ -38,6 +38,7 @@ const CreateFileModal: React.FC = ({ onCreateFile }) => { label="File Name" type="text" placeholder="Enter file name" + data-testid="file-name-input" value={fileName} onChange={(event) => setFileName(event.currentTarget.value)} onKeyDown={handleKeyDown} @@ -48,10 +49,16 @@ const CreateFileModal: React.FC = ({ onCreateFile }) => { - + diff --git a/app/src/components/modals/file/DeleteFileModal.test.tsx b/app/src/components/modals/file/DeleteFileModal.test.tsx new file mode 100644 index 0000000..13098ac --- /dev/null +++ b/app/src/components/modals/file/DeleteFileModal.test.tsx @@ -0,0 +1,532 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import DeleteFileModal from './DeleteFileModal'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock ModalContext with modal always open +const mockModalContext = { + newFileModalVisible: false, + setNewFileModalVisible: vi.fn(), + deleteFileModalVisible: true, + setDeleteFileModalVisible: vi.fn(), + commitMessageModalVisible: false, + setCommitMessageModalVisible: vi.fn(), + settingsModalVisible: false, + setSettingsModalVisible: vi.fn(), + switchWorkspaceModalVisible: false, + setSwitchWorkspaceModalVisible: vi.fn(), + createWorkspaceModalVisible: false, + setCreateWorkspaceModalVisible: vi.fn(), +}; + +vi.mock('../../../contexts/ModalContext', () => ({ + useModalContext: () => mockModalContext, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('DeleteFileModal', () => { + const mockOnDeleteFile = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockOnDeleteFile.mockResolvedValue(undefined); + + // Reset modal context mocks + mockModalContext.setDeleteFileModalVisible.mockClear(); + }); + + describe('Modal Visibility', () => { + it('renders modal when open with file selected', () => { + render( + + ); + + expect(screen.getByText('Delete File')).toBeInTheDocument(); + expect( + screen.getByText(/Are you sure you want to delete "test-file.md"?/) + ).toBeInTheDocument(); + const cancelButton = screen.getByTestId('cancel-delete-button'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + expect(cancelButton).toBeInTheDocument(); + expect(deleteButton).toBeInTheDocument(); + + expect(cancelButton).toHaveTextContent('Cancel'); + expect(deleteButton).toHaveTextContent('Delete'); + + expect(cancelButton).toHaveRole('button'); + expect(deleteButton).toHaveRole('button'); + }); + + it('renders modal when open with no file selected', () => { + render( + + ); + + expect(screen.getByText('Delete File')).toBeInTheDocument(); + // Should still render the confirmation text with null file + expect( + screen.getByText(/Are you sure you want to delete/) + ).toBeInTheDocument(); + }); + + it('calls setDeleteFileModalVisible when modal is closed', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-button'); + fireEvent.click(cancelButton); + + expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith( + false + ); + }); + }); + + describe('File Deletion', () => { + it('handles file deletion with valid file', async () => { + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-button'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDeleteFile).toHaveBeenCalledWith('document.md'); + }); + }); + + it('does not call onDeleteFile when no file is selected', () => { + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-button'); + fireEvent.click(deleteButton); + + // Should not call the function when no file is selected + expect(mockOnDeleteFile).not.toHaveBeenCalled(); + }); + + it('closes modal after successful file deletion', async () => { + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-button'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDeleteFile).toHaveBeenCalledWith('test.md'); + }); + + await waitFor(() => { + expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith( + false + ); + }); + }); + + it('handles deletion of files with special characters', async () => { + const specialFileName = 'file-with_special.chars (1).md'; + render( + + ); + + expect( + screen.getByText( + `Are you sure you want to delete "${specialFileName}"?` + ) + ).toBeInTheDocument(); + + const deleteButton = screen.getByTestId('confirm-delete-button'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDeleteFile).toHaveBeenCalledWith(specialFileName); + }); + }); + + it('handles deletion of files with unicode characters', async () => { + const unicodeFileName = 'ファイル名.md'; + render( + + ); + + expect( + screen.getByText( + `Are you sure you want to delete "${unicodeFileName}"?` + ) + ).toBeInTheDocument(); + + const deleteButton = screen.getByTestId('confirm-delete-button'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDeleteFile).toHaveBeenCalledWith(unicodeFileName); + }); + }); + + it('handles very long file names', async () => { + const longFileName = 'a'.repeat(100) + '.md'; + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-button'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDeleteFile).toHaveBeenCalledWith(longFileName); + }); + }); + }); + + describe('Modal Actions', () => { + it('has cancel and delete buttons', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-button'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + expect(cancelButton).toHaveRole('button'); + expect(deleteButton).toHaveRole('button'); + }); + + it('closes modal when cancel button is clicked', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-button'); + fireEvent.click(cancelButton); + + expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith( + false + ); + }); + }); + + describe('Error Handling', () => { + it('handles deletion errors gracefully', async () => { + mockOnDeleteFile.mockRejectedValue(new Error('File deletion failed')); + + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-button'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDeleteFile).toHaveBeenCalledWith('test.md'); + }); + + // Modal should handle the error gracefully (not crash) + expect(screen.getByText('Delete File')).toBeInTheDocument(); + }); + + it('does not close modal when deletion fails', async () => { + mockOnDeleteFile.mockRejectedValue(new Error('File deletion failed')); + + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-button'); + + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDeleteFile).toHaveBeenCalledWith('test.md'); + }); + + // Modal should remain open when deletion fails + expect( + mockModalContext.setDeleteFileModalVisible + ).not.toHaveBeenCalledWith(false); + }); + }); + + describe('Accessibility', () => { + it('has proper modal structure', () => { + render( + + ); + + // Modal should have proper title + expect(screen.getByText('Delete File')).toBeInTheDocument(); + + // Should have confirmation text + expect( + screen.getByText(/Are you sure you want to delete/) + ).toBeInTheDocument(); + }); + + it('has proper button roles', () => { + render( + + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThanOrEqual(2); // Cancel and Delete buttons + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + const deleteButton = screen.getByRole('button', { name: /delete/i }); + + expect(cancelButton).toBeInTheDocument(); + expect(deleteButton).toBeInTheDocument(); + }); + + it('has proper confirmation message structure', () => { + render( + + ); + + // Check that the file name is properly quoted in the message + expect( + screen.getByText(/Are you sure you want to delete "important-file.md"?/) + ).toBeInTheDocument(); + }); + + it('supports keyboard navigation', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-button'); + const deleteButton = screen.getByTestId('confirm-delete-button'); + + // Buttons should be focusable + expect(cancelButton).not.toHaveAttribute('disabled'); + expect(deleteButton).not.toHaveAttribute('disabled'); + + // Should handle keyboard events + fireEvent.keyDown(deleteButton, { key: 'Enter', code: 'Enter' }); + fireEvent.keyDown(cancelButton, { key: 'Escape', code: 'Escape' }); + }); + }); + + describe('Component Props', () => { + it('accepts and uses onDeleteFile prop correctly', async () => { + const customMockDelete = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + const deleteButton = screen.getByText('Delete'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(customMockDelete).toHaveBeenCalledWith('custom-test.md'); + }); + }); + + it('handles different selectedFile prop values', () => { + const testCases = [ + 'simple.md', + 'folder/nested.md', + 'file with spaces.md', + 'UPPERCASE.MD', + null, + ]; + + testCases.forEach((fileName) => { + const { unmount } = render( + + ); + + expect(screen.getByText('Delete File')).toBeInTheDocument(); + unmount(); + }); + }); + + it('handles function prop correctly', () => { + const testFunction = vi.fn(); + + expect(() => { + render( + + ); + }).not.toThrow(); + + expect(screen.getByText('Delete File')).toBeInTheDocument(); + }); + }); + + describe('File Path Edge Cases', () => { + it('handles file paths with folders', async () => { + const nestedFilePath = 'folder/subfolder/deep-file.md'; + render( + + ); + + const deleteButton = screen.getByText('Delete'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDeleteFile).toHaveBeenCalledWith(nestedFilePath); + }); + }); + + it('handles files without extensions', async () => { + render( + + ); + + const deleteButton = screen.getByText('Delete'); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDeleteFile).toHaveBeenCalledWith('README'); + }); + }); + + it('handles empty string as selectedFile', () => { + render( + + ); + + const deleteButton = screen.getByText('Delete'); + fireEvent.click(deleteButton); + + // Should not call the function with empty string + expect(mockOnDeleteFile).not.toHaveBeenCalled(); + }); + }); + + describe('User Interaction Flow', () => { + it('completes full deletion flow successfully', async () => { + render( + + ); + + // 1. Modal opens and shows file name + expect( + screen.getByText('Are you sure you want to delete "complete-test.md"?') + ).toBeInTheDocument(); + + // 2. User clicks delete + const deleteButton = screen.getByTestId('confirm-delete-button'); + fireEvent.click(deleteButton); + + // 3. Deletion function is called + await waitFor(() => { + expect(mockOnDeleteFile).toHaveBeenCalledWith('complete-test.md'); + }); + + // 4. Modal closes + await waitFor(() => { + expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith( + false + ); + }); + }); + + it('allows user to cancel deletion', () => { + render( + + ); + + // User clicks cancel instead of delete + const cancelButton = screen.getByTestId('cancel-delete-button'); + fireEvent.click(cancelButton); + + // Should close modal without calling delete function + expect(mockOnDeleteFile).not.toHaveBeenCalled(); + expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith( + false + ); + }); + }); +}); diff --git a/app/src/components/modals/file/DeleteFileModal.tsx b/app/src/components/modals/file/DeleteFileModal.tsx index 8ba7f22..48b0bc7 100644 --- a/app/src/components/modals/file/DeleteFileModal.tsx +++ b/app/src/components/modals/file/DeleteFileModal.tsx @@ -33,10 +33,15 @@ const DeleteFileModal: React.FC = ({ - diff --git a/app/src/components/modals/git/CommitMessageModal.test.tsx b/app/src/components/modals/git/CommitMessageModal.test.tsx new file mode 100644 index 0000000..3d1414b --- /dev/null +++ b/app/src/components/modals/git/CommitMessageModal.test.tsx @@ -0,0 +1,516 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import CommitMessageModal from './CommitMessageModal'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock ModalContext with modal always open +const mockModalContext = { + newFileModalVisible: false, + setNewFileModalVisible: vi.fn(), + deleteFileModalVisible: false, + setDeleteFileModalVisible: vi.fn(), + commitMessageModalVisible: true, + setCommitMessageModalVisible: vi.fn(), + settingsModalVisible: false, + setSettingsModalVisible: vi.fn(), + switchWorkspaceModalVisible: false, + setSwitchWorkspaceModalVisible: vi.fn(), + createWorkspaceModalVisible: false, + setCreateWorkspaceModalVisible: vi.fn(), +}; + +vi.mock('../../../contexts/ModalContext', () => ({ + useModalContext: () => mockModalContext, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('CommitMessageModal', () => { + const mockOnCommitAndPush = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockOnCommitAndPush.mockResolvedValue(undefined); + + // Reset modal context mocks + mockModalContext.setCommitMessageModalVisible.mockClear(); + }); + + describe('Modal Visibility', () => { + it('renders modal when open', () => { + render(); + + expect(screen.getByText('Enter Commit Message')).toBeInTheDocument(); + expect(screen.getByTestId('commit-message-input')).toBeInTheDocument(); + expect(screen.getByTestId('cancel-commit-button')).toBeInTheDocument(); + expect(screen.getByTestId('commit-button')).toBeInTheDocument(); + }); + + it('calls setCommitMessageModalVisible when modal is closed', () => { + render(); + + const cancelButton = screen.getByText('Cancel'); + fireEvent.click(cancelButton); + + expect( + mockModalContext.setCommitMessageModalVisible + ).toHaveBeenCalledWith(false); + }); + }); + + describe('Form Interaction', () => { + it('updates commit message input when typed', () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + fireEvent.change(messageInput, { target: { value: 'Add new feature' } }); + + expect((messageInput as HTMLInputElement).value).toBe('Add new feature'); + }); + + it('handles form submission with valid commit message', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + fireEvent.change(messageInput, { + target: { value: 'Fix bug in editor' }, + }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Fix bug in editor'); + }); + }); + + it('prevents submission with empty commit message', () => { + render(); + + const commitButton = screen.getByTestId('commit-button'); + fireEvent.click(commitButton); + + // Should not call the function with empty message + expect(mockOnCommitAndPush).not.toHaveBeenCalled(); + }); + + it('closes modal after successful commit', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + fireEvent.change(messageInput, { + target: { value: 'Update documentation' }, + }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith( + 'Update documentation' + ); + }); + + await waitFor(() => { + expect( + mockModalContext.setCommitMessageModalVisible + ).toHaveBeenCalledWith(false); + }); + }); + + it('clears input after successful submission', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + fireEvent.change(messageInput, { target: { value: 'Initial commit' } }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Initial commit'); + }); + + await waitFor(() => { + expect((messageInput as HTMLInputElement).value).toBe(''); + }); + }); + }); + + describe('Modal Actions', () => { + it('has cancel and commit buttons', () => { + render(); + + const commitButton = screen.getByTestId('commit-button'); + expect(commitButton).toHaveRole('button'); + + const cancelButton = screen.getByTestId('cancel-commit-button'); + expect(cancelButton).toHaveRole('button'); + }); + + it('closes modal when cancel button is clicked', () => { + render(); + + const cancelButton = screen.getByTestId('cancel-commit-button'); + fireEvent.click(cancelButton); + + expect( + mockModalContext.setCommitMessageModalVisible + ).toHaveBeenCalledWith(false); + }); + + it('calls onCommitAndPush when commit button is clicked with valid input', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + fireEvent.change(messageInput, { + target: { value: 'Refactor components' }, + }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledTimes(1); + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Refactor components'); + }); + }); + }); + + describe('Commit Message Validation', () => { + it('handles short commit messages', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + fireEvent.change(messageInput, { target: { value: 'Fix' } }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Fix'); + }); + }); + + it('handles long commit messages', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + const longMessage = + 'This is a very long commit message that describes all the changes made in great detail including what was changed, why it was changed, and how it affects the overall system architecture'; + fireEvent.change(messageInput, { target: { value: longMessage } }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith(longMessage); + }); + }); + + it('handles commit messages with special characters', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + const specialMessage = 'Fix: issue #123 - handle "quotes" & symbols!'; + fireEvent.change(messageInput, { target: { value: specialMessage } }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith(specialMessage); + }); + }); + + it('handles commit messages with unicode characters', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + const unicodeMessage = '修正: エラーを修正しました 🐛'; + fireEvent.change(messageInput, { target: { value: unicodeMessage } }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith(unicodeMessage); + }); + }); + + it('trims whitespace from commit messages', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + fireEvent.change(messageInput, { + target: { value: ' Update README ' }, + }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Update README'); + }); + }); + }); + + describe('Error Handling', () => { + it('handles commit errors gracefully', async () => { + mockOnCommitAndPush.mockRejectedValue(new Error('Git push failed')); + + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + fireEvent.change(messageInput, { target: { value: 'Test commit' } }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Test commit'); + }); + + // Modal should handle the error gracefully (not crash) + expect(screen.getByText('Enter Commit Message')).toBeInTheDocument(); + }); + + it('does not close modal when commit fails', async () => { + mockOnCommitAndPush.mockRejectedValue(new Error('Network error')); + + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + fireEvent.change(messageInput, { target: { value: 'Failed commit' } }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Failed commit'); + }); + + // Modal should remain open when commit fails + expect( + mockModalContext.setCommitMessageModalVisible + ).not.toHaveBeenCalledWith(false); + }); + + it('handles authentication errors', async () => { + mockOnCommitAndPush.mockRejectedValue(new Error('Authentication failed')); + + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + fireEvent.change(messageInput, { target: { value: 'Auth test' } }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Auth test'); + }); + + // Should not crash the component + expect(screen.getByText('Enter Commit Message')).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('has proper form labels and structure', () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + expect(messageInput).toBeInTheDocument(); + expect(messageInput.tagName).toBe('INPUT'); + expect(messageInput).toHaveAttribute('type', 'text'); + }); + + it('has proper button roles', () => { + render(); + + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThanOrEqual(2); // Cancel and Commit buttons + + const cancelButton = screen.getByTestId('cancel-commit-button'); + const commitButton = screen.getByTestId('commit-button'); + + expect(cancelButton).toBeInTheDocument(); + expect(commitButton).toBeInTheDocument(); + }); + + it('supports keyboard navigation', () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + + // Check that the input is focusable (not disabled or readonly) + expect(messageInput).not.toHaveAttribute('disabled'); + expect(messageInput).not.toHaveAttribute('readonly'); + + // Check that the input can receive keyboard events + fireEvent.keyDown(messageInput, { key: 'a' }); + fireEvent.change(messageInput, { target: { value: 'test' } }); + + expect((messageInput as HTMLInputElement).value).toBe('test'); + + // Verify the input is accessible via keyboard navigation + expect(messageInput).toHaveAttribute('type', 'text'); + expect(messageInput).toHaveAccessibleName(); // Has proper label + }); + + it('has proper modal structure', () => { + render(); + + // Modal should have proper title + expect(screen.getByText('Enter Commit Message')).toBeInTheDocument(); + + // Should have form elements + expect(screen.getByTestId('commit-message-input')).toBeInTheDocument(); + }); + }); + + describe('Component Props', () => { + it('accepts and uses onCommitAndPush prop correctly', async () => { + const customMockCommit = vi.fn().mockResolvedValue(undefined); + + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + fireEvent.change(messageInput, { + target: { value: 'Custom commit message' }, + }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(customMockCommit).toHaveBeenCalledWith('Custom commit message'); + }); + }); + + it('handles function prop correctly', () => { + const testFunction = vi.fn(); + + expect(() => { + render(); + }).not.toThrow(); + + expect(screen.getByText('Enter Commit Message')).toBeInTheDocument(); + }); + }); + + describe('Form Submission Edge Cases', () => { + it('submits form via Enter key', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + + fireEvent.change(messageInput, { target: { value: 'Enter key commit' } }); + fireEvent.keyDown(messageInput, { key: 'Enter', code: 'Enter' }); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Enter key commit'); + }); + }); + + it('does not submit empty form via Enter key', () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + fireEvent.keyDown(messageInput, { key: 'Enter', code: 'Enter' }); + + // Should not call the function + expect(mockOnCommitAndPush).not.toHaveBeenCalled(); + }); + + it('handles rapid successive submissions without crashing', () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('commit-button'); + + fireEvent.change(messageInput, { target: { value: 'Rapid commit' } }); + + // Rapidly click multiple times - should not crash + fireEvent.click(commitButton); + fireEvent.click(commitButton); + fireEvent.click(commitButton); + + // Verify component is still functional + expect(screen.getByText('Enter Commit Message')).toBeInTheDocument(); + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Rapid commit'); + }); + }); + + describe('User Interaction Flow', () => { + it('completes full commit flow successfully', async () => { + render(); + + // 1. Modal opens and shows input + expect(screen.getByTestId('commit-message-input')).toBeInTheDocument(); + + // 2. User types commit message + const messageInput = screen.getByTestId('commit-message-input'); + fireEvent.change(messageInput, { + target: { value: 'Complete flow test' }, + }); + + // 3. User clicks commit + const commitButton = screen.getByTestId('commit-button'); + fireEvent.click(commitButton); + + // 4. Commit function is called + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Complete flow test'); + }); + + // 5. Modal closes and input clears + await waitFor(() => { + expect( + mockModalContext.setCommitMessageModalVisible + ).toHaveBeenCalledWith(false); + }); + }); + + it('allows user to cancel commit', () => { + render(); + + // User types message but then cancels + const messageInput = screen.getByTestId('commit-message-input'); + fireEvent.change(messageInput, { + target: { value: 'Cancel this commit' }, + }); + + const cancelButton = screen.getByTestId('cancel-commit-button'); + fireEvent.click(cancelButton); + + // Should close modal without calling commit function + expect(mockOnCommitAndPush).not.toHaveBeenCalled(); + expect( + mockModalContext.setCommitMessageModalVisible + ).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/app/src/components/modals/git/CommitMessageModal.tsx b/app/src/components/modals/git/CommitMessageModal.tsx index a8df024..db9c25b 100644 --- a/app/src/components/modals/git/CommitMessageModal.tsx +++ b/app/src/components/modals/git/CommitMessageModal.tsx @@ -14,13 +14,21 @@ const CommitMessageModal: React.FC = ({ useModalContext(); const handleSubmit = async (): Promise => { - if (message) { - await onCommitAndPush(message); + const commitMessage = message.trim(); + if (commitMessage) { + await onCommitAndPush(commitMessage); setMessage(''); setCommitMessageModalVisible(false); } }; + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void handleSubmit(); + } + }; + return ( = ({ > setMessage(event.currentTarget.value)} + onKeyDown={handleKeyDown} mb="md" w="100%" /> @@ -42,10 +53,16 @@ const CommitMessageModal: React.FC = ({ - +