diff --git a/frontend/src/components/HomeComponents/SetupGuide/SetupGuide.test.tsx b/frontend/src/components/HomeComponents/SetupGuide/SetupGuide.test.tsx index aa1dbf43..bf261e8a 100644 --- a/frontend/src/components/HomeComponents/SetupGuide/SetupGuide.test.tsx +++ b/frontend/src/components/HomeComponents/SetupGuide/SetupGuide.test.tsx @@ -10,8 +10,23 @@ jest.mock('@/components/utils/URLs', () => ({ // Mock CopyableCode component jest.mock('./CopyableCode', () => ({ - CopyableCode: ({ text }: { text: string }) => ( -
{text}
+ CopyableCode: ({ + text, + copyText, + isSensitive, + }: { + text: string; + copyText: string; + isSensitive?: boolean; + }) => ( +
+ {text} +
), })); @@ -21,6 +36,19 @@ jest.mock('./utils', () => ({ exportConfigSetup: (props: any) => mockExportConfigSetup(props), })); +// Mock Button component +jest.mock('@/components/ui/button', () => ({ + Button: ({ children, variant, onClick }: any) => ( + + ), +})); + const defaultProps = { name: 'Test User', encryption_secret: 'secret123', @@ -28,6 +56,10 @@ const defaultProps = { }; describe('SetupGuide', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('renders setup guide sections', () => { render(); @@ -40,6 +72,13 @@ describe('SetupGuide', () => { expect(screen.getByText('SYNC')).toBeInTheDocument(); }); + test('renders main heading with gradient text', () => { + render(); + + expect(screen.getByText('Setup')).toBeInTheDocument(); + expect(screen.getByText('Guide')).toBeInTheDocument(); + }); + test('renders configuration commands using props', () => { render(); @@ -58,31 +97,255 @@ describe('SetupGuide', () => { ).toBeInTheDocument(); }); + test('renders all CopyableCode components with correct props', () => { + render(); + + const copyableCodes = screen.getAllByTestId('copyable-code'); + expect(copyableCodes.length).toBe(5); + + // Check prerequisites CopyableCode + expect(copyableCodes[0]).toHaveAttribute('data-text', 'task --version'); + expect(copyableCodes[0]).toHaveAttribute('data-copytext', 'task --version'); + expect(copyableCodes[0]).toHaveAttribute('data-issensitive', 'false'); + + // Check encryption secret CopyableCode (sensitive) + expect(copyableCodes[1]).toHaveAttribute( + 'data-text', + `task config sync.encryption_secret ${defaultProps.encryption_secret}` + ); + expect(copyableCodes[1]).toHaveAttribute('data-issensitive', 'true'); + + // Check origin CopyableCode + expect(copyableCodes[2]).toHaveAttribute( + 'data-text', + 'task config sync.server.origin https://test-container' + ); + expect(copyableCodes[2]).toHaveAttribute('data-issensitive', 'false'); + + // Check client ID CopyableCode (sensitive) + expect(copyableCodes[3]).toHaveAttribute( + 'data-text', + `task config sync.server.client_id ${defaultProps.uuid}` + ); + expect(copyableCodes[3]).toHaveAttribute('data-issensitive', 'true'); + + // Check sync init CopyableCode + expect(copyableCodes[4]).toHaveAttribute('data-text', 'task sync init'); + expect(copyableCodes[4]).toHaveAttribute('data-issensitive', 'false'); + }); + + test('renders instructional text content', () => { + render(); + + expect( + screen.getByText(/Ensure that Taskwarrior 3.0 or greater is installed/) + ).toBeInTheDocument(); + expect( + screen.getByText(/You will need an encryption secret/) + ).toBeInTheDocument(); + expect( + screen.getByText(/Configure Taskwarrior with these commands/) + ).toBeInTheDocument(); + expect( + screen.getByText(/For more information about how this works/) + ).toBeInTheDocument(); + expect( + screen.getByText(/Finally, setup the sync for your Taskwarrior client/) + ).toBeInTheDocument(); + }); + test('clicking download configuration triggers download logic', () => { - mockExportConfigSetup.mockReturnValue('config-content'); + const configContent = 'test-config-content'; + mockExportConfigSetup.mockReturnValue(configContent); // Polyfill missing browser APIs + const createObjectURLSpy = jest.fn(() => 'blob:http://localhost/file'); + const revokeObjectURLSpy = jest.fn(); + Object.defineProperty(global.URL, 'createObjectURL', { writable: true, - value: jest.fn(() => 'blob:http://localhost/file'), + value: createObjectURLSpy, }); Object.defineProperty(global.URL, 'revokeObjectURL', { writable: true, - value: jest.fn(), + value: revokeObjectURLSpy, }); - const appendSpy = jest.spyOn(document.body, 'appendChild'); - const removeSpy = jest.spyOn(document.body, 'removeChild'); + const originalAppendChild = document.body.appendChild.bind(document.body); + const originalRemoveChild = document.body.removeChild.bind(document.body); + + const appendSpy = jest + .spyOn(document.body, 'appendChild') + .mockImplementation((node) => { + // Actually append to DOM so removeChild works + return originalAppendChild(node); + }); + + const removeSpy = jest + .spyOn(document.body, 'removeChild') + .mockImplementation((node) => { + // Only remove if it's actually a child + if (node.parentNode === document.body) { + return originalRemoveChild(node); + } + return node; + }); render(); - fireEvent.click(screen.getByText(/DOWNLOAD CONFIGURATION/i)); + // Click on the h3 element which has the onClick handler + const downloadHeading = screen + .getByText(/DOWNLOAD CONFIGURATION/i) + .closest('h3'); + expect(downloadHeading).toBeInTheDocument(); + + if (downloadHeading) { + fireEvent.click(downloadHeading); + } expect(mockExportConfigSetup).toHaveBeenCalledWith(defaultProps); - expect(URL.createObjectURL).toHaveBeenCalled(); - expect(URL.revokeObjectURL).toHaveBeenCalled(); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(revokeObjectURLSpy).toHaveBeenCalled(); expect(appendSpy).toHaveBeenCalled(); expect(removeSpy).toHaveBeenCalled(); + + // Verify blob was created with correct content + expect(createObjectURLSpy.mock.calls.length).toBeGreaterThan(0); + const firstCall = createObjectURLSpy.mock.calls[0] as unknown[]; + if (firstCall && firstCall.length > 0) { + const blobCall = firstCall[0] as unknown; + expect(blobCall).toBeInstanceOf(Blob); + if (blobCall instanceof Blob) { + expect(blobCall.type).toBe('text/plain;charset=utf-8'); + } + } + + // Verify link was configured correctly + const linkElement = appendSpy.mock.calls.find( + (call) => call[0] instanceof HTMLAnchorElement + )?.[0] as HTMLAnchorElement; + if (linkElement) { + expect(linkElement.download).toBe('taskwarrior-setup.txt'); + } + + appendSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + test('download configuration creates blob with correct type', () => { + const configContent = 'config-content'; + mockExportConfigSetup.mockReturnValue(configContent); + + const createObjectURLSpy = jest.fn(() => 'blob:http://localhost/file'); + Object.defineProperty(global.URL, 'createObjectURL', { + writable: true, + value: createObjectURLSpy, + }); + + Object.defineProperty(global.URL, 'revokeObjectURL', { + writable: true, + value: jest.fn(), + }); + + const originalAppendChild = document.body.appendChild.bind(document.body); + const originalRemoveChild = document.body.removeChild.bind(document.body); + + const appendSpy = jest + .spyOn(document.body, 'appendChild') + .mockImplementation((node) => { + return originalAppendChild(node); + }); + + const removeSpy = jest + .spyOn(document.body, 'removeChild') + .mockImplementation((node) => { + if (node.parentNode === document.body) { + return originalRemoveChild(node); + } + return node; + }); + + render(); + + const downloadHeading = screen + .getByText(/DOWNLOAD CONFIGURATION/i) + .closest('h3'); + if (downloadHeading) { + fireEvent.click(downloadHeading); + } + + expect(createObjectURLSpy.mock.calls.length).toBeGreaterThan(0); + const firstCall = createObjectURLSpy.mock.calls[0] as unknown[]; + if (firstCall && firstCall.length > 0) { + const blob = firstCall[0] as unknown; + if (blob instanceof Blob) { + expect(blob.type).toBe('text/plain;charset=utf-8'); + } + } + + appendSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + test('download configuration sets correct filename', () => { + mockExportConfigSetup.mockReturnValue('config-content'); + + Object.defineProperty(global.URL, 'createObjectURL', { + writable: true, + value: jest.fn(() => 'blob:http://localhost/file'), + }); + + Object.defineProperty(global.URL, 'revokeObjectURL', { + writable: true, + value: jest.fn(), + }); + + const originalAppendChild = document.body.appendChild.bind(document.body); + const originalRemoveChild = document.body.removeChild.bind(document.body); + + const appendSpy = jest + .spyOn(document.body, 'appendChild') + .mockImplementation((node) => { + return originalAppendChild(node); + }); + + const removeSpy = jest + .spyOn(document.body, 'removeChild') + .mockImplementation((node) => { + if (node.parentNode === document.body) { + return originalRemoveChild(node); + } + return node; + }); + + render(); + + const downloadHeading = screen + .getByText(/DOWNLOAD CONFIGURATION/i) + .closest('h3'); + if (downloadHeading) { + fireEvent.click(downloadHeading); + } + + const linkElement = appendSpy.mock.calls.find( + (call) => call[0] instanceof HTMLAnchorElement + )?.[0] as HTMLAnchorElement; + + if (linkElement) { + expect(linkElement.download).toBe('taskwarrior-setup.txt'); + } + + appendSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + test('renders download configuration button', () => { + render(); + + const downloadText = screen.getByText(/DOWNLOAD CONFIGURATION/i); + expect(downloadText).toBeInTheDocument(); + expect(downloadText.closest('button')).toBeInTheDocument(); + expect(downloadText.closest('h3')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/HomeComponents/SetupGuide/__tests__/CopyableCode.test.tsx b/frontend/src/components/HomeComponents/SetupGuide/__tests__/CopyableCode.test.tsx index b96a6175..8ecc7e5c 100644 --- a/frontend/src/components/HomeComponents/SetupGuide/__tests__/CopyableCode.test.tsx +++ b/frontend/src/components/HomeComponents/SetupGuide/__tests__/CopyableCode.test.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { CopyableCode } from '../CopyableCode'; import { toast } from 'react-toastify'; @@ -16,10 +17,66 @@ jest.mock('lucide-react', () => ({ EyeOff: () => , })); +// Mock Tooltip component +jest.mock('@/components/ui/tooltip', () => { + return { + __esModule: true, + default: ({ + children, + title, + }: { + children: React.ReactNode; + title: string; + }) => ( +
+ {children} +
+ ), + }; +}); + +// Mock react-copy-to-clipboard +jest.mock('react-copy-to-clipboard', () => { + return { + __esModule: true, + default: ({ + children, + onCopy, + }: { + children: React.ReactNode; + text: string; + onCopy?: () => void; + }) => { + // Clone children and add onClick handler that calls onCopy + const childrenWithClick = React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + const originalOnClick = (child as any).props?.onClick; + return React.cloneElement(child as React.ReactElement, { + onClick: (e: React.MouseEvent) => { + if (originalOnClick) { + originalOnClick(e); + } + if (onCopy) { + onCopy(); + } + }, + }); + } + return child; + }); + return <>{childrenWithClick}; + }, + }; +}); + const sampleText = 'Sample code'; const sampleCopyText = 'Copy this text'; describe('CopyableCode', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders correctly with given text', () => { render(); @@ -30,7 +87,13 @@ describe('CopyableCode', () => { it('copies text to clipboard and shows toast message', async () => { render(); - fireEvent.click(screen.getByTestId('copy-icon')); + const copyButton = screen.getByTestId('copy-icon').closest('button'); + expect(copyButton).toBeInTheDocument(); + + if (copyButton) { + // Click the button which is wrapped by CopyToClipboard + fireEvent.click(copyButton); + } await waitFor(() => { expect(toast.success).toHaveBeenCalledWith( @@ -47,13 +110,216 @@ describe('CopyableCode', () => { ); }); }); -}); -describe('SetupGuide component using snapshot', () => { - test('renders correctly', () => { - const { asFragment } = render( - + it('does not render sensitive toggle button when isSensitive is false', () => { + render( + + ); + + expect(screen.queryByTestId('eye-icon')).not.toBeInTheDocument(); + expect(screen.queryByTestId('eye-off-icon')).not.toBeInTheDocument(); + }); + + it('renders sensitive toggle button when isSensitive is true', () => { + render( + + ); + + expect(screen.getByTestId('eye-off-icon')).toBeInTheDocument(); + }); + + it('shows full text when isSensitive is true and showSensitive is true', () => { + render( + + ); + + expect(screen.getByText(sampleText)).toBeInTheDocument(); + }); + + it('masks sensitive value when isSensitive is true and showSensitive is false', () => { + const sensitiveText = 'task config sync.encryption_secret my-secret-key'; + render( + + ); + + const toggleButton = screen.getByTestId('eye-off-icon').closest('button'); + expect(toggleButton).toBeInTheDocument(); + + if (toggleButton) { + fireEvent.click(toggleButton); + } + + const maskedValue = '•'.repeat('my-secret-key'.length); + expect( + screen.getByText(`task config sync.encryption_secret ${maskedValue}`) + ).toBeInTheDocument(); + }); + + it('toggles between showing and hiding sensitive value', () => { + const sensitiveText = 'task config sync.encryption_secret secret123'; + render( + + ); + + expect(screen.getByText(sensitiveText)).toBeInTheDocument(); + expect(screen.getByTestId('eye-off-icon')).toBeInTheDocument(); + + const toggleButton = screen.getByTestId('eye-off-icon').closest('button'); + expect(toggleButton).toBeInTheDocument(); + + if (toggleButton) { + fireEvent.click(toggleButton); + } + + const maskedValue = '•'.repeat('secret123'.length); + expect( + screen.getByText(`task config sync.encryption_secret ${maskedValue}`) + ).toBeInTheDocument(); + expect(screen.getByTestId('eye-icon')).toBeInTheDocument(); + + const showButton = screen.getByTestId('eye-icon').closest('button'); + if (showButton) { + fireEvent.click(showButton); + } + + expect(screen.getByText(sensitiveText)).toBeInTheDocument(); + expect(screen.getByTestId('eye-off-icon')).toBeInTheDocument(); + }); + + it('masks only the last part of text with multiple words', () => { + const multiWordText = 'command part1 part2 secret-value'; + render( + + ); + + const toggleButton = screen.getByTestId('eye-off-icon').closest('button'); + if (toggleButton) { + fireEvent.click(toggleButton); + } + + const maskedValue = '•'.repeat('secret-value'.length); + expect( + screen.getByText(`command part1 part2 ${maskedValue}`) + ).toBeInTheDocument(); + }); + + it('handles single word text correctly when masking', () => { + const singleWord = 'secret-key'; + render( + + ); + + const toggleButton = screen.getByTestId('eye-off-icon').closest('button'); + if (toggleButton) { + fireEvent.click(toggleButton); + } + + const maskedValue = '•'.repeat(singleWord.length); + expect(screen.getByText(maskedValue)).toBeInTheDocument(); + }); + + it('handles empty string text correctly', () => { + const { container } = render( + + ); + + const toggleButton = screen.getByTestId('eye-off-icon').closest('button'); + if (toggleButton) { + fireEvent.click(toggleButton); + } + + // Check that the code element exists and is empty + const codeElement = container.querySelector('code'); + expect(codeElement).toBeInTheDocument(); + expect(codeElement?.textContent).toBe(''); + }); + + it('has correct aria-label for toggle button when showing sensitive value', () => { + render( + + ); + + const toggleButton = screen.getByTestId('eye-off-icon').closest('button'); + expect(toggleButton).toHaveAttribute('aria-label', 'Hide sensitive value'); + }); + + it('has correct aria-label for toggle button when hiding sensitive value', () => { + const sensitiveText = 'task config sync.encryption_secret secret123'; + render( + + ); + + const toggleButton = screen.getByTestId('eye-off-icon').closest('button'); + if (toggleButton) { + fireEvent.click(toggleButton); + } + + const showButton = screen.getByTestId('eye-icon').closest('button'); + expect(showButton).toHaveAttribute('aria-label', 'Show sensitive value'); + }); + + it('has correct tooltip title for toggle button', () => { + render( + + ); + + const tooltips = screen.getAllByTestId('tooltip'); + const toggleTooltip = tooltips.find((tooltip) => + tooltip.getAttribute('data-title')?.includes('sensitive') + ); + expect(toggleTooltip).toBeInTheDocument(); + expect(toggleTooltip?.getAttribute('data-title')).toBe( + 'Hide sensitive value' + ); + }); + + it('has correct tooltip title for copy button', () => { + render(); + + const tooltips = screen.getAllByTestId('tooltip'); + const copyTooltip = tooltips.find( + (tooltip) => tooltip.getAttribute('data-title') === 'Copy to clipboard' ); - expect(asFragment()).toMatchSnapshot(); + expect(copyTooltip).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/HomeComponents/SetupGuide/__tests__/utils.test.ts b/frontend/src/components/HomeComponents/SetupGuide/__tests__/utils.test.ts index f59241eb..8dac9855 100644 --- a/frontend/src/components/HomeComponents/SetupGuide/__tests__/utils.test.ts +++ b/frontend/src/components/HomeComponents/SetupGuide/__tests__/utils.test.ts @@ -1,39 +1,53 @@ -import { url } from '@/components/utils/URLs'; import { exportConfigSetup } from '../utils'; +import { Props } from '@/components/utils/types'; +import { url } from '@/components/utils/URLs'; describe('exportConfigSetup', () => { - test('generate correct configuration setup', () => { - const props = { - encryption_secret: 'encryptionSecret', - uuid: 'clients-uuid', - } as any; + const mockProps: Props = { + name: 'Test User', + encryption_secret: 'test-secret-123', + uuid: 'test-uuid-456', + }; - const result = exportConfigSetup(props); + it('should generate configuration content with all required fields', () => { + const result = exportConfigSetup(mockProps); + expect(result).toContain('Configure Taskwarrior with these commands'); expect(result).toContain( - 'Configure Taskwarrior with these commands, run these commands one block at a time' + `task config sync.encryption_secret ${mockProps.encryption_secret}` ); - expect(result).toContain( - `task config sync.encryption_secret ${props.encryption_secret}` + `task config sync.server.origin ${url.containerOrigin}` ); - expect(result).toContain( - `task config sync.server.origin ${url.containerOrigin}` + `task config sync.server.client_id ${mockProps.uuid}` ); + expect(result).toContain('task-sync(5) manpage'); + }); + + it('should join all lines with newline characters', () => { + const result = exportConfigSetup(mockProps); + const lines = result.split('\n'); - expect(result).toContain(`task config sync.server.client_id ${props.uuid}`); + expect(lines.length).toBeGreaterThan(1); + expect(result).toMatch(/\n/); }); - test('returns string seprated with newline', () => { - const props = { - encryption_secret: 'encryptionSecret', - uuid: 'clients-uuid', - } as any; + it('should include encryption secret in the output', () => { + const result = exportConfigSetup(mockProps); - const result = exportConfigSetup(props); + expect(result).toContain(mockProps.encryption_secret); + }); - const lines = result.split('\n'); - expect(lines.length).toBeGreaterThan(1); + it('should include UUID in the output', () => { + const result = exportConfigSetup(mockProps); + + expect(result).toContain(mockProps.uuid); + }); + + it('should include container origin URL in the output', () => { + const result = exportConfigSetup(mockProps); + + expect(result).toContain(url.containerOrigin); }); });