diff --git a/package.json b/package.json index 89d9182..68ee688 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,10 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/jsdom": "^28.0.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "^10.2.1", @@ -40,6 +44,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", "husky": "^9.1.7", + "jsdom": "^29.1.1", "lint-staged": "^16.4.0", "prettier": "^3.8.3", "typescript-eslint": "^8.58.2", diff --git a/src/components/issue/IssueDetail.test.tsx b/src/components/issue/IssueDetail.test.tsx new file mode 100644 index 0000000..1263476 --- /dev/null +++ b/src/components/issue/IssueDetail.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from '@testing-library/react'; +import { IssueDetail } from './IssueDetail'; +import { describe, it, expect } from 'vitest'; +import type { RankedIssue } from '../../lib/types'; +import React from 'react'; + +const mockIssue: RankedIssue = { + number: 101, + title: 'Fix styling of buttons', + body: 'Please fix the styled buttons on the home screen.', + updated_at: new Date().toISOString(), + score: 90, + user: { login: 'github_user', avatar_url: '', html_url: '' }, + labels: [{ name: 'ui-bug', color: 'ff0000' }], + assignees: [], + comments_count: 1, + created_at: new Date().toISOString(), + html_url: 'https://github.com/example/repo/issues/101', + state: 'open', + analysis: { + summary: 'Simple styling adjustment.', + status: 'active', + progress_estimate: 'early', + is_actionable_code_change: true, + not_mergeable_reason: null, + complexity: 1, + skills_required: ['CSS'], + newcomer_friendliness: 5, + doability_score: 95, + analysis_notes: 'Notes here.', + }, + comments: [ + { + id: 1, + user: { login: 'commenter', avatar_url: '', html_url: '' }, + body: 'I will take a look.', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ], +}; + +describe('IssueDetail component', () => { + it('renders select issue placeholder when issue is null', () => { + render(); + expect(screen.getByText('Select an issue to view details')).toBeInTheDocument(); + }); + + it('renders all details when issue is provided', () => { + render(); + + expect(screen.getByText('#101 — Fix styling of buttons')).toBeInTheDocument(); + expect(screen.getByText('github_user')).toBeInTheDocument(); + expect(screen.getByText('1 comment')).toBeInTheDocument(); + expect(screen.getByText('ui-bug')).toBeInTheDocument(); + + // AI Analysis section + expect(screen.getByText('Simple styling adjustment.')).toBeInTheDocument(); + expect(screen.getByText('Notes here.')).toBeInTheDocument(); + + // Comments section + expect(screen.getByText('@commenter')).toBeInTheDocument(); + expect(screen.getByText('I will take a look.')).toBeInTheDocument(); + + // Issue Body + expect( + screen.getByText('Please fix the styled buttons on the home screen.'), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/issue/IssueDetail.tsx b/src/components/issue/IssueDetail.tsx index d06f809..6894757 100644 --- a/src/components/issue/IssueDetail.tsx +++ b/src/components/issue/IssueDetail.tsx @@ -130,7 +130,8 @@ export function IssueDetail({ issue }: IssueDetailProps) { {formatTimestamp(issue.created_at)} - {issue.comments_count} comments + {issue.comments_count}{' '} + {issue.comments_count === 1 ? 'comment' : 'comments'} Updated {formatTimeAgo(issue.updated_at)} diff --git a/src/components/issue/IssueList.test.tsx b/src/components/issue/IssueList.test.tsx new file mode 100644 index 0000000..f8819a7 --- /dev/null +++ b/src/components/issue/IssueList.test.tsx @@ -0,0 +1,146 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { IssueList } from './IssueList'; +import { describe, it, expect, vi } from 'vitest'; +import type { RankedIssue } from '../../lib/types'; +import React from 'react'; + +const mockIssues: RankedIssue[] = [ + { + number: 1, + title: 'Bug in routing', + body: null, + updated_at: new Date().toISOString(), + score: 80, + user: { login: 'coder1', avatar_url: '', html_url: '' }, + labels: [{ name: 'bug', color: 'red' }], + assignees: [], + comments_count: 0, + created_at: new Date().toISOString(), + html_url: '', + state: 'open', + }, + { + number: 2, + title: 'Style updates for login page', + body: null, + updated_at: new Date().toISOString(), + score: 60, + user: { login: 'designer1', avatar_url: '', html_url: '' }, + labels: [{ name: 'design', color: 'blue' }], + assignees: [], + comments_count: 0, + created_at: new Date().toISOString(), + html_url: '', + state: 'open', + }, +]; + +describe('IssueList component', () => { + it('renders issues correctly', () => { + render( + , + ); + + expect(screen.getByText('Bug in routing')).toBeInTheDocument(); + expect(screen.getByText('Style updates for login page')).toBeInTheDocument(); + expect(screen.getByText('2 items')).toBeInTheDocument(); + }); + + it('triggers onSearchChange when typing in search input', () => { + const onSearchChange = vi.fn(); + render( + , + ); + + const input = screen.getByPlaceholderText(/Search issues.../); + fireEvent.change(input, { target: { value: 'routing' } }); + expect(onSearchChange).toHaveBeenCalledWith('routing'); + }); + + it('filters issues based on searchQuery prop', () => { + render( + , + ); + + expect(screen.getByText('Bug in routing')).toBeInTheDocument(); + expect(screen.queryByText('Style updates for login page')).not.toBeInTheDocument(); + expect(screen.getByText('1 items')).toBeInTheDocument(); + }); + + it('triggers onSelect when an issue item is clicked', () => { + const onSelect = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText('Style updates for login page')); + expect(onSelect).toHaveBeenCalledWith(2); + }); + + it('shows no issues found message when filtered list is empty', () => { + render( + , + ); + + expect(screen.getByText('No issues match your search.')).toBeInTheDocument(); + }); + + it('verifies that scrollIntoView is called on the selected item', () => { + const scrollMock = vi.fn(); + const originalGetElementById = document.getElementById; + document.getElementById = vi.fn().mockReturnValue({ + scrollIntoView: scrollMock, + }); + + render( + , + ); + + expect(document.getElementById).toHaveBeenCalledWith('issue-0'); + expect(scrollMock).toHaveBeenCalledWith({ block: 'nearest', behavior: 'smooth' }); + + document.getElementById = originalGetElementById; + }); +}); diff --git a/src/components/issue/IssueListItem.test.tsx b/src/components/issue/IssueListItem.test.tsx new file mode 100644 index 0000000..e9492d0 --- /dev/null +++ b/src/components/issue/IssueListItem.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { IssueListItem } from './IssueListItem'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { RankedIssue } from '../../lib/types'; +import React from 'react'; + +const getMockIssue = (): RankedIssue => ({ + number: 101, + title: 'Optimize database queries', + body: null, + updated_at: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago + score: 95, + user: { login: 'octocat', avatar_url: '', html_url: '' }, + labels: [], + assignees: [], + comments_count: 0, + created_at: new Date(Date.now() - 7200000).toISOString(), + html_url: '', + state: 'open', +}); + +describe('IssueListItem component', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-18T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders standard fields correctly', () => { + const onClick = vi.fn(); + render(); + + expect(screen.getByText('1.')).toBeInTheDocument(); + expect(screen.getByText('#101')).toBeInTheDocument(); + expect(screen.getByText('Optimize database queries')).toBeInTheDocument(); + expect(screen.getByText('[95]')).toBeInTheDocument(); + expect(screen.getByText('1h ago')).toBeInTheDocument(); + }); + + it('triggers onClick when clicked', () => { + const onClick = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Optimize database queries')); + expect(onClick).toHaveBeenCalled(); + }); + + it('renders status prefix tag when analysis exists', () => { + const issueWithAnalysis: RankedIssue = { + ...getMockIssue(), + analysis: { + summary: 'Discussion required', + status: 'discussion', + progress_estimate: 'not_started', + is_actionable_code_change: false, + not_mergeable_reason: null, + complexity: 1, + skills_required: [], + newcomer_friendliness: 5, + doability_score: 50, + analysis_notes: '', + }, + }; + + render( + , + ); + expect(screen.getByText('[DISCUSSION]')).toBeInTheDocument(); + }); +}); diff --git a/src/components/issue/IssueMetadata.test.tsx b/src/components/issue/IssueMetadata.test.tsx new file mode 100644 index 0000000..cc611a7 --- /dev/null +++ b/src/components/issue/IssueMetadata.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react'; +import { IssueMetadata } from './IssueMetadata'; +import { describe, it, expect } from 'vitest'; +import type { AnalysisResult } from '../../lib/types'; +import React from 'react'; + +const mockAnalysis: AnalysisResult = { + summary: 'This is a summary', + status: 'active', + progress_estimate: 'early', + is_actionable_code_change: true, + not_mergeable_reason: null, + complexity: 3, + skills_required: ['React', 'TypeScript'], + newcomer_friendliness: 4, + doability_score: 80, + analysis_notes: 'Some notes', +}; + +describe('IssueMetadata component', () => { + it('renders all analysis metrics correctly', () => { + render(); + + expect(screen.getByText('Doability')).toBeInTheDocument(); + expect(screen.getByText('80/100')).toBeInTheDocument(); + + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + + expect(screen.getByText('Complexity')).toBeInTheDocument(); + expect(screen.getByText(/Moderate/)).toBeInTheDocument(); + + expect(screen.getByText('Friendliness')).toBeInTheDocument(); + expect(screen.getByText(/Beginner Friendly/)).toBeInTheDocument(); + + expect(screen.getByText('React')).toBeInTheDocument(); + expect(screen.getByText('TypeScript')).toBeInTheDocument(); + }); + + it('renders merge blocker when not_mergeable_reason is provided', () => { + const analysisWithBlocker: AnalysisResult = { + ...mockAnalysis, + not_mergeable_reason: 'Missing details', + }; + render(); + + expect(screen.getByText('Blocker')).toBeInTheDocument(); + expect(screen.getByText('Missing details')).toBeInTheDocument(); + }); + + it('renders "none specified" when skills_required is empty', () => { + const analysisNoSkills: AnalysisResult = { + ...mockAnalysis, + skills_required: [], + }; + render(); + + expect(screen.getByText('none specified')).toBeInTheDocument(); + }); +}); diff --git a/src/components/terminal/TerminalInput.test.tsx b/src/components/terminal/TerminalInput.test.tsx index e69de29..7fa17b1 100644 --- a/src/components/terminal/TerminalInput.test.tsx +++ b/src/components/terminal/TerminalInput.test.tsx @@ -0,0 +1,40 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { TerminalInput } from './TerminalInput'; +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; + +describe('TerminalInput component', () => { + it('renders correctly', () => { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + render(); + expect(screen.getByPlaceholderText('Type here...')).toBeInTheDocument(); + }); + + it('calls onChange when typing', () => { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + render(); + const input = screen.getByPlaceholderText('Type here...'); + fireEvent.change(input, { target: { value: 'hello' } }); + expect(onChange).toHaveBeenCalledWith('hello'); + }); + + it('calls onSubmit when Enter is pressed', () => { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + render(); + const input = screen.getByPlaceholderText('Type here...'); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + expect(onSubmit).toHaveBeenCalled(); + }); + + it('clears input when Escape is pressed', () => { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + render(); + const input = screen.getByPlaceholderText('Type here...'); + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' }); + expect(onChange).toHaveBeenCalledWith(''); + }); +}); diff --git a/src/components/ui/Button.test.tsx b/src/components/ui/Button.test.tsx new file mode 100644 index 0000000..f536318 --- /dev/null +++ b/src/components/ui/Button.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react'; +import { Button } from './Button'; +import { describe, it, expect } from 'vitest'; + +describe('Button component', () => { + it('renders children correctly', () => { + render(); + expect(screen.getByText('Click Me')).toBeInTheDocument(); + }); + + it('shows loading state when loading prop is true', () => { + render(); + expect(screen.getByText('⏳')).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeDisabled(); + }); +}); diff --git a/src/components/ui/Input.test.tsx b/src/components/ui/Input.test.tsx new file mode 100644 index 0000000..9b0ca9b --- /dev/null +++ b/src/components/ui/Input.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import { Input } from './Input'; +import { describe, it, expect } from 'vitest'; +import React from 'react'; + +describe('Input component', () => { + it('renders correctly', () => { + render(); + expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument(); + }); + + it('passes ref correctly', () => { + const ref = React.createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + }); + + it('applies error styles when hasError is true', () => { + render(); + const input = screen.getByTestId('error-input'); + expect(input).toHaveStyle({ border: '1px solid var(--status-error)' }); + }); +}); diff --git a/src/components/ui/MarkdownRenderer.test.tsx b/src/components/ui/MarkdownRenderer.test.tsx new file mode 100644 index 0000000..dc2469e --- /dev/null +++ b/src/components/ui/MarkdownRenderer.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import { MarkdownRenderer } from './MarkdownRenderer'; +import { describe, it, expect } from 'vitest'; +import React from 'react'; + +describe('MarkdownRenderer component', () => { + it('renders markdown content correctly', () => { + render(); + // Testing specific output depends on react-markdown, we just test if the component renders the text + expect(screen.getByText('Hello World')).toBeInTheDocument(); + }); + + it('renders code blocks correctly', () => { + render(); + expect(screen.getByText('inline code')).toBeInTheDocument(); + expect(screen.getByText('inline code').tagName).toBe('CODE'); + }); + + it('renders links correctly', () => { + render(); + const link = screen.getByRole('link', { name: 'GitHub' }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', 'https://github.com'); + }); +}); diff --git a/src/components/ui/Panel.test.tsx b/src/components/ui/Panel.test.tsx new file mode 100644 index 0000000..9ac0af9 --- /dev/null +++ b/src/components/ui/Panel.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import { Panel } from './Panel'; +import { describe, it, expect } from 'vitest'; +import React from 'react'; + +describe('Panel component', () => { + it('renders children correctly', () => { + render(Panel Content); + expect(screen.getByText('Panel Content')).toBeInTheDocument(); + }); + + it('renders title when provided', () => { + render(Content); + expect(screen.getByText('[ Test Title ]')).toBeInTheDocument(); + }); + + it('renders headerRight when provided', () => { + render( + Action}> + Content + , + ); + expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/ProgressBar.test.tsx b/src/components/ui/ProgressBar.test.tsx new file mode 100644 index 0000000..824f1be --- /dev/null +++ b/src/components/ui/ProgressBar.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import { ProgressBar } from './ProgressBar'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; + +describe('ProgressBar component', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders correctly with current and total', () => { + render(); + expect(screen.getByText(/5\/10 \(50%\)/)).toBeInTheDocument(); + }); + + it('renders label when provided', () => { + render(); + expect(screen.getByText('Processing')).toBeInTheDocument(); + }); + + it('hides count when showCount is false', () => { + render(); + expect(screen.queryByText(/5\/10 \(50%\)/)).not.toBeInTheDocument(); + }); + + it('shows ETA when startTime is provided', () => { + const startTime = Date.now(); + render(); + expect(screen.getByText(/ETA:/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/ScrollArea.test.tsx b/src/components/ui/ScrollArea.test.tsx new file mode 100644 index 0000000..8079ef4 --- /dev/null +++ b/src/components/ui/ScrollArea.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import { ScrollArea } from './ScrollArea'; +import { describe, it, expect } from 'vitest'; +import React from 'react'; + +describe('ScrollArea component', () => { + it('renders children correctly', () => { + render(Scrollable Content); + expect(screen.getByText('Scrollable Content')).toBeInTheDocument(); + }); + + it('applies maxHeight when provided', () => { + render(Scroll Area Content); + const element = screen.getByText('Scroll Area Content'); + expect(element.style.maxHeight).toBe('200px'); + }); + + it('passes innerRef correctly', () => { + const ref = React.createRef(); + render(Content); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..b6a8d47 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,4 @@ +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +window.HTMLElement.prototype.scrollIntoView = vi.fn(); diff --git a/vite-env.d.ts b/vite-env.d.ts index e92b4bd..6560e54 100644 --- a/vite-env.d.ts +++ b/vite-env.d.ts @@ -1,4 +1,5 @@ /// +/// interface ImportMetaEnv { readonly VITE_GITHUB_TOKEN: string; diff --git a/vite.config.ts b/vite.config.ts index 0e77097..80a6bac 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; @@ -14,4 +14,9 @@ export default defineConfig({ port: 5173, open: true, }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + globals: true, + }, });