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,
+ },
});