+
+
+ ❌
+
+
+```
+
+**ChatMessage components - Missing roles**
+Messages should have proper ARIA roles for screen readers.
+
+### Action
+
+**Fix header semantics (App.tsx):**
+```typescript
+
+```
+
+**Fix error icon (AssistantMessage.tsx):**
+```typescript
+
+
+
+
+
+
+// Import from lucide-react
+import { AlertCircle } from 'lucide-react';
+```
+
+**Add message roles:**
+```typescript
+// UserBubble.tsx
+
+
+// AssistantMessage.tsx
+
+```
+
+### Why Important?
+- Better SEO
+- Screen reader compatibility
+- WCAG compliance
+- Professional UX
+
+### Files to modify
+- `src/App.tsx` (lines 12-15)
+- `src/components/chat/ChatMessage/AssistantMessage.tsx` (line 21)
+- `src/components/chat/ChatMessage/UserBubble.tsx`
+
+---
+
+## Task 5: Optimize with React.memo
+
+**Status:** 🔲 Pending
+**Priority:** 🟡 Medium
+**Time:** ~15 min
+**Files:** Multiple
+
+### Problem
+Message components re-render unnecessarily on every parent update:
+- `UserBubble` is not memoized
+- `AssistantMessage` is not memoized
+- ChatMessage wrapper could benefit from memo
+- Large message lists cause performance issues
+
+### Locations
+All message components in `src/components/chat/ChatMessage/`
+
+### Action
+
+**Memoize UserBubble:**
+```typescript
+import { memo } from 'react';
+
+export const UserBubble = memo(({ content }: UserBubbleProps) => {
+ return (
+ // ... component code
+ );
+});
+
+UserBubble.displayName = 'UserBubble';
+```
+
+**Memoize AssistantMessage:**
+```typescript
+import { memo } from 'react';
+
+export const AssistantMessage = memo(({
+ content,
+ isTyping = false
+}: AssistantMessageProps) => {
+ return (
+ // ... component code
+ );
+});
+
+AssistantMessage.displayName = 'AssistantMessage';
+```
+
+**Optimize ChatMessage parent:**
+```typescript
+import { memo } from 'react';
+
+export const ChatMessage = memo(({ message }: ChatMessageProps) => {
+ if (message.role === Roles.USER) {
+ return
;
+ }
+
+ return (
+
+ );
+});
+
+ChatMessage.displayName = 'ChatMessage';
+```
+
+### Performance Testing
+
+Before and after:
+```bash
+# Open React DevTools
+# Enable "Highlight updates when components render"
+# Send multiple messages and observe re-renders
+```
+
+### Why Important?
+- Prevents unnecessary re-renders
+- Better performance with long message lists
+- Smoother typing animation
+- Better UX on slower devices
+
+### Files to modify
+- `src/components/chat/ChatMessage/UserBubble.tsx`
+- `src/components/chat/ChatMessage/AssistantMessage.tsx`
+- `src/components/chat/ChatMessage/index.tsx`
+
+---
+
+## Checklist
+
+- [x] Task 1: Extract magic numbers to constants
+- [ ] Task 2: Remove unused parameters in ReactMarkdown
+- [ ] Task 3: Add env variable validation
+- [ ] Task 4: Improve semantic HTML
+- [ ] Task 5: Optimize with React.memo
+
+## Verification
+
+```bash
+# Type check
+pnpm ts-check
+
+# Build check
+pnpm build
+
+# Test performance
+# 1. Open DevTools > React Profiler
+# 2. Record session while sending messages
+# 3. Compare before/after memo optimization
+```
diff --git a/.claude/tasks/04-testing.md b/.claude/tasks/04-testing.md
new file mode 100644
index 0000000..cfc221b
--- /dev/null
+++ b/.claude/tasks/04-testing.md
@@ -0,0 +1,633 @@
+---
+title: Testing Improvements
+category: testing
+priority: medium
+total_tasks: 5
+estimated_time: 135min
+status: pending
+created: 2025-11-06
+updated: 2025-11-06
+---
+
+# 🧪 Testing Improvements
+
+Improve test coverage and quality, especially for critical business logic.
+
+---
+
+## Task 0: Fix Failing Test - "shows AI response after loading"
+
+**Status:** ✅ Completed
+**Priority:** 🔴 Critical
+**Time:** ~15 min
+**File:** `src/App.test.tsx`
+
+### Problem
+Test is currently skipped (line 64) and failing with:
+```
+Expected 3 messages but got 2
+```
+
+### Details
+- **Test:** "shows AI response after loading" (line 64-99)
+- **Expected:** 3 messages (initial AI + user + error response)
+- **Actual:** 2 messages
+- **Context:** Test may have broken after adding comprehensive error handling to `useChatWebhook.ts`
+
+### Root Cause Analysis Needed
+1. Check if the error handling changes in `useChatWebhook.ts` affected the test
+2. Verify if the test was previously passing or if it revealed a pre-existing bug
+3. The test expects an error message to be added but it might not be appearing
+
+### Likely Issues
+- The new error handling in `useChatWebhook.ts` (lines 44-72) includes:
+ - try/catch wrapper
+ - HTTP status validation
+ - Response structure validation
+- The test mocks console.error, which might interfere with error propagation
+- React Query's error handling might need adjustment in the test
+
+### Action Required
+1. Remove `.skip` from line 64
+2. Debug why only 2 messages appear instead of 3
+3. Either fix the test or fix the underlying code issue
+4. Verify the error message is properly displayed to users
+
+### Files to investigate
+- `src/App.test.tsx` (line 64-99)
+- `src/hooks/useChatWebhook.ts` (error handling at lines 44-72)
+
+---
+
+## Current Coverage Status
+
+| Component/Hook | Tests | Coverage | Priority |
+|----------------|-------|----------|----------|
+| useChatWebhook | ❌ None | 0% | 🔴 Critical |
+| useTypingText | ❌ None | 0% | 🔴 Critical |
+| App.tsx | ✅ Basic | ~40% | 🟡 Improve |
+| ChatContainer | ✅ Basic | ~40% | 🟡 Improve |
+| ChatInput | ✅ Good | ~80% | 🟢 Good |
+| ChatMessage | ✅ Good | ~70% | 🟢 Good |
+| TypingIndicator | ✅ Minimal | ~20% | 🟡 Improve |
+
+**Overall: ~35% coverage**
+
+---
+
+## Task 1: Create Tests for useChatWebhook Hook
+
+**Status:** ✅ Completed
+**Priority:** 🔴 Critical
+**Time:** ~60 min
+**File:** `src/hooks/useChatWebhook.test.tsx`
+
+### Problem
+**Fixed! Now has comprehensive test coverage** for the most critical hook in the application:
+- Handles all API communication
+- Message state management
+- Error handling
+- Simulation mode logic
+
+### Test Cases Needed
+
+#### 1. Success Path with Real Webhook
+```typescript
+describe('useChatWebhook - with real webhook', () => {
+ it('should send message and receive response', async () => {
+ // Mock fetch with successful response
+ // Send message
+ // Verify loading states
+ // Verify response added to messages
+ });
+
+ it('should handle streaming/typing animation', async () => {
+ // Verify isTyping flag
+ // Verify message content updates progressively
+ });
+
+ it('should deduplicate messages correctly', async () => {
+ // Send same message twice quickly
+ // Verify only one request made
+ });
+});
+```
+
+#### 2. Error Path
+```typescript
+describe('useChatWebhook - error handling', () => {
+ it('should handle network errors gracefully', async () => {
+ // Mock fetch rejection
+ // Verify error message displayed
+ // Verify error message format
+ });
+
+ it('should handle HTTP error responses', async () => {
+ // Mock 500 response
+ // Verify error handling
+ });
+
+ it('should handle malformed JSON responses', async () => {
+ // Mock invalid JSON
+ // Verify error handling
+ });
+
+ it('should handle missing output field', async () => {
+ // Mock response without 'output' field
+ // Verify error handling
+ });
+});
+```
+
+#### 3. Simulation Mode
+```typescript
+describe('useChatWebhook - simulation mode', () => {
+ it('should use simulation when no webhook URL provided', async () => {
+ // Initialize with undefined webhook URL
+ // Send message
+ // Verify simulated response
+ });
+
+ it('should respect simulationDelay option', async () => {
+ // Set custom delay
+ // Verify timing
+ });
+});
+```
+
+#### 4. Message Management
+```typescript
+describe('useChatWebhook - message state', () => {
+ it('should add user message immediately', () => {
+ // Send message
+ // Verify user message in messages array instantly
+ });
+
+ it('should add assistant message after response', async () => {
+ // Send message
+ // Wait for response
+ // Verify assistant message added
+ });
+
+ it('should maintain message order', async () => {
+ // Send multiple messages
+ // Verify chronological order
+ });
+});
+```
+
+### Implementation Template
+
+```typescript
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { useChatWebhook } from './useChatWebhook';
+import type { ReactNode } from 'react';
+
+describe('useChatWebhook', () => {
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ // Mock fetch
+ global.fetch = vi.fn();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+
+ it('should send message and receive response', async () => {
+ const mockResponse = { output: 'Hello from webhook!' };
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResponse,
+ });
+
+ const { result } = renderHook(
+ () => useChatWebhook('https://webhook.example.com'),
+ { wrapper }
+ );
+
+ await act(async () => {
+ result.current.sendMessage('Hello');
+ });
+
+ await waitFor(() => {
+ expect(result.current.messages).toHaveLength(2);
+ });
+
+ expect(result.current.messages[0]).toMatchObject({
+ role: 'user',
+ content: 'Hello',
+ });
+
+ expect(result.current.messages[1]).toMatchObject({
+ role: 'assistant',
+ content: 'Hello from webhook!',
+ });
+ });
+
+ // Add more test cases...
+});
+```
+
+### Files to create
+- `src/hooks/useChatWebhook.test.ts`
+
+---
+
+## Task 2: Create Tests for useTypingText Hook
+
+**Status:** ✅ Completed
+**Priority:** 🔴 Critical
+**Time:** ~30 min
+**File:** `src/hooks/useTypingText.test.tsx`
+
+### Problem
+**Completed! Now has comprehensive test coverage** for the typing animation hook:
+- Controls text reveal animation
+- Manages timing and speed
+- Handles completion callbacks
+
+### Test Cases Needed
+
+#### 1. Basic Functionality
+```typescript
+describe('useTypingText - basic functionality', () => {
+ it('should reveal text progressively', async () => {
+ // Render hook with full text
+ // Verify text reveals character by character
+ });
+
+ it('should complete with full text', async () => {
+ // Wait for completion
+ // Verify displayedText matches fullText
+ });
+
+ it('should call onComplete when finished', async () => {
+ // Mock onComplete callback
+ // Wait for completion
+ // Verify callback called once
+ });
+});
+```
+
+#### 2. Speed and Timing
+```typescript
+describe('useTypingText - speed configuration', () => {
+ it('should respect charsPerTick setting', async () => {
+ // Test with charsPerTick=1 vs charsPerTick=10
+ // Verify different reveal speeds
+ });
+
+ it('should use 50ms interval by default', async () => {
+ // Mock timers
+ // Verify interval timing
+ });
+});
+```
+
+#### 3. Edge Cases
+```typescript
+describe('useTypingText - edge cases', () => {
+ it('should handle empty text', () => {
+ // Test with empty string
+ // Verify no errors
+ });
+
+ it('should handle text change mid-animation', async () => {
+ // Start animation
+ // Change fullText mid-way
+ // Verify animation restarts
+ });
+
+ it('should cleanup on unmount', () => {
+ // Verify interval cleared
+ // No memory leaks
+ });
+});
+```
+
+### Implementation Template
+
+```typescript
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useTypingText } from './useTypingText';
+
+describe('useTypingText', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should reveal text progressively', async () => {
+ const { result } = renderHook(() =>
+ useTypingText({ fullText: 'Hello World', charsPerTick: 1 })
+ );
+
+ expect(result.current.displayedText).toBe('');
+
+ // Advance 50ms
+ vi.advanceTimersByTime(50);
+ await waitFor(() => {
+ expect(result.current.displayedText).toBe('H');
+ });
+
+ // Advance another 50ms
+ vi.advanceTimersByTime(50);
+ await waitFor(() => {
+ expect(result.current.displayedText).toBe('He');
+ });
+ });
+
+ it('should call onComplete when finished', async () => {
+ const onComplete = vi.fn();
+
+ const { result } = renderHook(() =>
+ useTypingText({
+ fullText: 'Hi',
+ charsPerTick: 10,
+ onComplete,
+ })
+ );
+
+ // Fast-forward until complete
+ vi.advanceTimersByTime(100);
+
+ await waitFor(() => {
+ expect(result.current.displayedText).toBe('Hi');
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ // Add more test cases...
+});
+```
+
+### Files to create
+- `src/hooks/useTypingText.test.ts`
+
+---
+
+## Task 3: Improve Existing Tests
+
+**Status:** 🔲 Pending
+**Priority:** 🟡 Medium
+**Time:** ~20 min
+**Files:** Multiple test files
+
+### Problem
+Existing tests have quality issues:
+- Using fragile selectors (getByRole vs getByTestId)
+- Missing edge case coverage
+- Some tests are too broad
+
+### Specific Improvements
+
+#### 1. App.test.tsx
+**Issue:** Line 53 uses generic `getByRole("button")`
+```typescript
+// ❌ Current - fragile
+const sendButton = screen.getByRole("button");
+
+// ✅ Better - specific
+const sendButton = screen.getByTestId("chat-submit");
+```
+
+**Issue:** Lines 65-98 test error handling but name says "shows AI response"
+```typescript
+// ❌ Current
+it("shows AI response after loading", async () => {
+ // Actually tests error handling
+});
+
+// ✅ Better - split into two tests
+it("shows AI response after successful webhook", async () => {
+ // Test success path
+});
+
+it("handles webhook errors gracefully", async () => {
+ // Test error path
+});
+```
+
+#### 2. ChatContainer.test.tsx
+**Add:** Scroll behavior test
+```typescript
+it('should scroll to bottom when new message added', async () => {
+ const { rerender } = render(
+
+ );
+
+ // Add message
+ const newMessages = [{ id: '1', role: 'user', content: 'Hi' }];
+ rerender(
);
+
+ // Verify scroll position
+ const viewport = screen.getByTestId('chat-container')
+ .querySelector('[data-radix-scroll-area-viewport]');
+
+ await waitFor(() => {
+ expect(viewport?.scrollTop).toBe(viewport?.scrollHeight);
+ });
+});
+```
+
+#### 3. TypingIndicator.test.tsx
+**Add:** Animation and accessibility tests
+```typescript
+it('should have accessible label', () => {
+ render(
);
+ expect(screen.getByLabelText('AI is typing')).toBeInTheDocument();
+});
+
+it('should animate dots', async () => {
+ render(
);
+ const dots = screen.getAllByTestId(/typing-dot-/);
+ expect(dots).toHaveLength(3);
+ // Verify animation classes applied
+});
+```
+
+### Action Items
+
+**Files to modify:**
+- `src/App.test.tsx` (improve selectors, split tests)
+- `src/components/chat/ChatContainer.test.tsx` (add scroll test)
+- `src/components/chat/TypingIndicator.test.tsx` (add a11y tests)
+
+---
+
+## Task 4: Add Edge Case Tests
+
+**Status:** 🔲 Pending
+**Priority:** 🟡 Medium
+**Time:** ~10 min per case
+**Files:** Various
+
+### Missing Edge Cases
+
+#### 1. Empty Message List
+```typescript
+// ChatContainer.test.tsx
+it('should render empty state', () => {
+ render(
);
+ expect(screen.queryByRole('article')).not.toBeInTheDocument();
+});
+```
+
+#### 2. Very Long Messages
+```typescript
+// ChatMessage.test.tsx
+it('should handle very long messages without overflow', () => {
+ const longContent = 'A'.repeat(10000);
+ render(
);
+
+ // Verify no horizontal scroll
+ // Verify word wrapping
+});
+```
+
+#### 3. Rapid Consecutive Messages
+```typescript
+// App.test.tsx or integration test
+it('should handle rapid message submissions', async () => {
+ render(
);
+ const input = screen.getByTestId('chat-input');
+ const button = screen.getByTestId('chat-submit');
+
+ // Send 3 messages quickly
+ await userEvent.type(input, 'Message 1');
+ await userEvent.click(button);
+
+ await userEvent.type(input, 'Message 2');
+ await userEvent.click(button);
+
+ await userEvent.type(input, 'Message 3');
+ await userEvent.click(button);
+
+ // Verify all messages sent correctly
+ // Verify no race conditions
+});
+```
+
+#### 4. Markdown XSS Attempts
+```typescript
+// AssistantMessage.test.tsx
+it('should sanitize dangerous markdown', () => {
+ const xssAttempt = '';
+
+ render(
);
+
+ // Verify script not executed
+ // Verify safe rendering
+ expect(screen.queryByText(/alert/)).not.toBeInTheDocument();
+});
+
+it('should safely render markdown links', () => {
+ const content = '[Click me](javascript:alert("XSS"))';
+
+ render(
);
+
+ const link = screen.getByRole('link');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ // Verify javascript: protocol blocked
+});
+```
+
+#### 5. Keyboard Navigation
+```typescript
+// ChatInput.test.tsx
+it('should support tab navigation', async () => {
+ render(
);
+
+ // Tab to textarea
+ await userEvent.tab();
+ expect(screen.getByTestId('chat-input')).toHaveFocus();
+
+ // Tab to button
+ await userEvent.tab();
+ expect(screen.getByTestId('chat-submit')).toHaveFocus();
+});
+
+it('should submit on Enter, new line on Shift+Enter', async () => {
+ const onSubmit = vi.fn();
+ render(
);
+
+ const input = screen.getByTestId('chat-input');
+ await userEvent.type(input, 'Hello');
+
+ // Shift+Enter should NOT submit
+ await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ // Enter should submit
+ await userEvent.keyboard('{Enter}');
+ expect(onSubmit).toHaveBeenCalled();
+});
+```
+
+### Files to modify
+- `src/components/chat/ChatContainer.test.tsx`
+- `src/components/chat/ChatMessage/index.test.tsx`
+- `src/components/chat/ChatMessage/AssistantMessage.test.tsx` (create)
+- `src/components/chat/ChatInput.test.tsx`
+- `src/App.test.tsx`
+
+---
+
+## Checklist
+
+- [x] Task 0: Fix failing "shows AI response after loading" test
+- [x] Task 1: Create comprehensive useChatWebhook tests (19 tests, all passing)
+- [x] Task 2: Create useTypingText tests (11 tests, all passing)
+- [ ] Task 3: Improve existing test quality
+- [ ] Task 4: Add edge case coverage
+
+## Verification
+
+```bash
+# Run all tests
+pnpm test
+
+# Run with coverage
+pnpm test -- --coverage
+
+# Target: >80% coverage for critical paths
+```
+
+## Coverage Goals
+
+| Component/Hook | Current | Target |
+|----------------|---------|--------|
+| useChatWebhook | 0% | 90%+ |
+| useTypingText | 0% | 85%+ |
+| App.tsx | 40% | 80%+ |
+| ChatContainer | 40% | 75%+ |
+| ChatInput | 80% | 90%+ |
+| ChatMessage | 70% | 85%+ |
+| **Overall** | **~35%** | **80%+** |
diff --git a/.claude/tasks/README.md b/.claude/tasks/README.md
new file mode 100644
index 0000000..1af21de
--- /dev/null
+++ b/.claude/tasks/README.md
@@ -0,0 +1,49 @@
+---
+title: Tasks Overview
+created: 2025-11-06
+updated: 2025-11-06
+total_tasks: 21
+---
+
+# 📋 Project Tasks Overview
+
+This directory contains organized tasks for improving the simple-chat project.
+
+## 📊 Task Summary
+
+| Category | Tasks | Priority |
+|----------|-------|----------|
+| [Documentation](./01-documentation.md) | 7 | 🟢 Low |
+| [Critical Fixes](./02-critical-fixes.md) | 5 | 🔴 High |
+| [Code Quality](./03-code-quality.md) | 5 | 🟡 Medium |
+| [Testing](./04-testing.md) | 4 | 🟡 Medium |
+
+**Total: 21 tasks**
+
+## 🎯 Quick Actions
+
+### Start here (High Priority)
+1. [Critical Fixes](./02-critical-fixes.md) - 5 urgent items
+2. [Testing](./04-testing.md) - Core hooks need tests
+
+### Then improve
+3. [Code Quality](./03-code-quality.md) - Clean up and optimize
+4. [Documentation](./01-documentation.md) - Sync docs with reality
+
+## 📝 Task File Format
+
+Each task file uses YAML frontmatter with:
+- `priority`: high/medium/low
+- `category`: documentation/critical/quality/testing
+- `estimated_time`: rough estimate in minutes
+- `status`: pending/in_progress/completed
+
+## 🔄 Workflow
+
+1. Pick a task from the appropriate file
+2. Update status to `in_progress`
+3. Complete the work
+4. Update status to `completed`
+5. Commit with reference to task file
+
+Example commit: `fix: remove console.log (ref: .claude/tasks/02-critical-fixes.md#task-1)`
diff --git a/.gitignore b/.gitignore
index 5c11ae1..9d5a123 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,4 @@ dist
# Build files
/build
+tsconfig.tsbuildinfo
diff --git a/CLAUDE.md b/CLAUDE.md
index 2abc738..bdef22a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,5 +1,19 @@
# Claude Assistance Guide
+## Task Management
+When starting work or asked about tasks, check the `.claude/tasks/` directory for active tasks and priorities. This directory contains organized task lists with specific requirements and implementation details.
+
+## Interaction Guidelines
+**IMPORTANT: Use AskUserQuestion tool when:**
+- Requirements or specifications are ambiguous or unclear
+- Multiple implementation approaches are possible and user preference is needed
+- Making architectural decisions that affect the codebase structure
+- Uncertain about desired behavior or feature scope
+- Before making potentially breaking changes
+- When task instructions could be interpreted in different ways
+
+**Do NOT assume or guess** - always clarify with the user first to ensure the implementation matches their expectations.
+
## Build Commands
- Install dependencies: `pnpm install`
- Start dev server: `pnpm dev`
@@ -28,9 +42,103 @@
- Components are added to `src/components/ui/`
- Customize components through Tailwind classes
- **Tests**: Vitest for unit and component testing
- - Use data-test-id attributes for test selectors instead of relying on roles
- - Example: `
`
- - Access in tests with `screen.getByTestId('submit-button')`
+ - **Testing guidelines**: See `.claude/guidelines/TESTING.md` for all testing best practices and conventions
+
+## Git Commit Guidelines
+
+### Commit Philosophy
+- **Atomic commits**: Each commit should represent a single logical change
+- **Be pragmatic**: Don't over-split trivial changes (e.g., fixing a typo and formatting can be one commit)
+- **Confidence index**: Add confidence level for non-trivial changes
+
+### Gitmoji Convention
+Use gitmoji prefixes for clear commit intent. Common ones:
+
+| Gitmoji | Code | When to use |
+|---------|------|-------------|
+| ✨ | `:sparkles:` | New feature |
+| 🐛 | `:bug:` | Bug fix |
+| 🔧 | `:wrench:` | Configuration changes |
+| 📝 | `:memo:` | Documentation |
+| ♻️ | `:recycle:` | Refactoring |
+| ✅ | `:white_check_mark:` | Add/update tests |
+| 🎨 | `:art:` | Improve structure/format |
+| ⚡️ | `:zap:` | Performance improvement |
+| ♿️ | `:wheelchair:` | Accessibility improvements |
+| 🔥 | `:fire:` | Remove code/files |
+| 🚨 | `:rotating_light:` | Fix linter warnings |
+| 🔒️ | `:lock:` | Security fixes |
+
+See full list: [gitmoji.dev](https://gitmoji.dev)
+
+### Commit Message Format
+```
+
:
+
+[Optional body explaining what and why]
+
+[Confidence: X% - only for non-trivial changes]
+[Optional reference to tasks: ref: .claude/tasks/XX-category.md#task-N]
+```
+
+### Examples
+
+**Simple, high confidence (omit confidence):**
+```
+🐛 fix: remove debug console.log in AssistantMessage
+```
+
+**Medium confidence with explanation:**
+```
+♻️ refactor: extract magic numbers to constants
+
+Moved hardcoded values (inputHeight, simulationDelay) to src/lib/constants.ts
+for better maintainability.
+
+Confidence: 85%
+ref: .claude/tasks/03-code-quality.md#task-1
+```
+
+**Complex change, lower confidence:**
+```
+✨ feat: add webhook response validation
+
+Added try/catch wrapper and JSON validation to useChatWebhook.
+Validates response.output exists before using it.
+
+Confidence: 70% - needs testing with actual webhook endpoints to verify
+all edge cases are handled correctly.
+
+ref: .claude/tasks/02-critical-fixes.md#task-3
+```
+
+### Confidence Index Guidelines
+
+| Confidence | When to use |
+|------------|-------------|
+| 95-100% | Trivial changes (typos, comments, obvious bugs) |
+| 85-95% | Well-tested changes with clear requirements |
+| 70-85% | Non-trivial changes that need validation |
+| 50-70% | Experimental or complex changes |
+| <50% | Proof of concept, requires review |
+
+**Note:** Omit confidence index for trivial changes (typos, formatting, obvious fixes)
+
+### Pragmatic Grouping Rules
+**✅ OK to group together:**
+- Related formatting changes across files
+- Multiple typo fixes in same PR
+- Adding missing aria-labels to multiple components
+- Updating multiple config files for same purpose
+
+**❌ Keep separate:**
+- Feature + Bug fix
+- Refactoring + New functionality
+- Test changes + Implementation (unless tightly coupled)
+- Breaking changes (always isolate)
+
+### Important Note
+**Do NOT** include references to Claude Code or AI assistance in commit messages. Keep commits professional and tool-agnostic. The commit message should describe the change itself, not how it was created.
## Architecture Notes
- RESTful API integration via custom webhooks
diff --git a/package.json b/package.json
index f1b85e0..238b7ea 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@@ -46,6 +47,7 @@
"@vitejs/plugin-react": "^4.3.4",
"globals": "^15.15.0",
"jsdom": "^26.0.0",
+ "msw": "^2.12.0",
"typescript": "~5.7.2",
"vite": "^6.2.6",
"vitest": "^3.0.9"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a4505c9..76eb5e0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -81,6 +81,9 @@ importers:
'@testing-library/react':
specifier: ^16.2.0
version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.0)
'@types/node':
specifier: ^22.13.10
version: 22.13.10
@@ -102,6 +105,9 @@ importers:
jsdom:
specifier: ^26.0.0
version: 26.0.0
+ msw:
+ specifier: ^2.12.0
+ version: 2.12.0(@types/node@22.13.10)(typescript@5.7.3)
typescript:
specifier: ~5.7.2
version: 5.7.3
@@ -110,7 +116,7 @@ importers:
version: 6.2.6(@types/node@22.13.10)(jiti@2.4.2)(lightningcss@1.29.2)
vitest:
specifier: ^3.0.9
- version: 3.0.9(@types/debug@4.1.12)(@types/node@22.13.10)(jiti@2.4.2)(jsdom@26.0.0)(lightningcss@1.29.2)
+ version: 3.0.9(@types/debug@4.1.12)(@types/node@22.13.10)(jiti@2.4.2)(jsdom@26.0.0)(lightningcss@1.29.2)(msw@2.12.0(@types/node@22.13.10)(typescript@5.7.3))
packages:
@@ -457,6 +463,41 @@ packages:
'@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
+ '@inquirer/ansi@1.0.1':
+ resolution: {integrity: sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==}
+ engines: {node: '>=18'}
+
+ '@inquirer/confirm@5.1.19':
+ resolution: {integrity: sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/core@10.3.0':
+ resolution: {integrity: sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/figures@1.0.14':
+ resolution: {integrity: sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==}
+ engines: {node: '>=18'}
+
+ '@inquirer/type@3.0.9':
+ resolution: {integrity: sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
'@jridgewell/gen-mapping@0.3.8':
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
engines: {node: '>=6.0.0'}
@@ -478,6 +519,19 @@ packages:
'@juggle/resize-observer@3.4.0':
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
+ '@mswjs/interceptors@0.40.0':
+ resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==}
+ engines: {node: '>=18'}
+
+ '@open-draft/deferred-promise@2.2.0':
+ resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
+
+ '@open-draft/logger@0.3.0':
+ resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
+
+ '@open-draft/until@2.1.0':
+ resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
+
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
@@ -978,6 +1032,12 @@ packages:
'@types/react-dom':
optional: true
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -1031,6 +1091,9 @@ packages:
'@types/react@19.0.12':
resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==}
+ '@types/statuses@2.0.6':
+ resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
+
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -1171,6 +1234,14 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+ cli-width@4.1.0:
+ resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
+ engines: {node: '>= 12'}
+
+ cliui@8.0.1:
+ resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
+ engines: {node: '>=12'}
+
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1195,6 +1266,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ cookie@1.0.2:
+ resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
+ engines: {node: '>=18'}
+
css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
@@ -1264,6 +1339,9 @@ packages:
electron-to-chromium@1.5.123:
resolution: {integrity: sha512-refir3NlutEZqlKaBLK0tzlVLe5P2wDKS7UQt/3SpibizgsRAPOsqQC3ffw1nlv3ze5gjRQZYHoPymgVZkplFA==}
+ emoji-regex@8.0.0:
+ resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
enhanced-resolve@5.18.1:
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
engines: {node: '>=10.13.0'}
@@ -1340,6 +1418,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
+ get-caller-file@2.0.5:
+ resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+ engines: {node: 6.* || 8.* || >= 10.*}
+
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -1367,6 +1449,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ graphql@16.12.0:
+ resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==}
+ engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
+
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -1395,6 +1481,9 @@ packages:
hastscript@6.0.0:
resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==}
+ headers-polyfill@4.0.3:
+ resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
+
highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
@@ -1445,12 +1534,19 @@ packages:
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
+ is-fullwidth-code-point@3.0.0:
+ resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+ engines: {node: '>=8'}
+
is-hexadecimal@1.0.4:
resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==}
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+ is-node-process@1.2.0:
+ resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==}
+
is-plain-obj@4.1.0:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
@@ -1738,6 +1834,20 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ msw@2.12.0:
+ resolution: {integrity: sha512-jzf2eVnd8+iWXN74dccLrHUw3i3hFVvNVQRWS4vBl2KxaUt7Tdur0Eyda/DODGFkZDu2P5MXaeLe/9Qx8PZkrg==}
+ engines: {node: '>=18'}
+ hasBin: true
+ peerDependencies:
+ typescript: '>= 4.8.x'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ mute-stream@2.0.0:
+ resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -1749,6 +1859,9 @@ packages:
nwsapi@2.2.19:
resolution: {integrity: sha512-94bcyI3RsqiZufXjkr3ltkI86iEl+I7uiHVDtcq9wJUTwYQJ5odHDeSzkkrRzi80jJ8MaeZgqKjH1bAWAFw9bA==}
+ outvariant@1.4.3:
+ resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
+
parse-entities@2.0.0:
resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==}
@@ -1758,6 +1871,9 @@ packages:
parse5@7.2.1:
resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==}
+ path-to-regexp@6.3.0:
+ resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -1877,6 +1993,13 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+ require-directory@2.1.1:
+ resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+ engines: {node: '>=0.10.0'}
+
+ rettime@0.7.0:
+ resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==}
+
rollup@4.40.0:
resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -1902,6 +2025,10 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+ signal-exit@4.1.0:
+ resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+ engines: {node: '>=14'}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1915,12 +2042,27 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
std-env@3.8.1:
resolution: {integrity: sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==}
+ strict-event-emitter@0.5.1:
+ resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
+
+ string-width@4.2.3:
+ resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+ engines: {node: '>=8'}
+
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+ strip-ansi@6.0.1:
+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+ engines: {node: '>=8'}
+
strip-indent@3.0.0:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
@@ -1969,14 +2111,25 @@ packages:
tldts-core@6.1.84:
resolution: {integrity: sha512-NaQa1W76W2aCGjXybvnMYzGSM4x8fvG2AN/pla7qxcg0ZHbooOPhA8kctmOZUDfZyhDL27OGNbwAeig8P4p1vg==}
+ tldts-core@7.0.17:
+ resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==}
+
tldts@6.1.84:
resolution: {integrity: sha512-aRGIbCIF3teodtUFAYSdQONVmDRy21REM3o6JnqWn5ZkQBJJ4gHxhw6OfwQ+WkSAi3ASamrS4N4nyazWx6uTYg==}
hasBin: true
+ tldts@7.0.17:
+ resolution: {integrity: sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==}
+ hasBin: true
+
tough-cookie@5.1.2:
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
engines: {node: '>=16'}
+ tough-cookie@6.0.0:
+ resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
+ engines: {node: '>=16'}
+
tr46@5.1.0:
resolution: {integrity: sha512-IUWnUK7ADYR5Sl1fZlO1INDUhVhatWl7BtJWsIhwJ0UAK7ilzzIa8uIqOO/aYVWHZPJkKbEL+362wrzoeRF7bw==}
engines: {node: '>=18'}
@@ -1993,6 +2146,10 @@ packages:
tw-animate-css@1.2.4:
resolution: {integrity: sha512-yt+HkJB41NAvOffe4NweJU6fLqAlVx/mBX6XmHRp15kq0JxTtOKaIw8pVSWM1Z+n2nXtyi7cW6C9f0WG/F/QAQ==}
+ type-fest@4.41.0:
+ resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
+ engines: {node: '>=16'}
+
typescript@5.7.3:
resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
engines: {node: '>=14.17'}
@@ -2019,6 +2176,9 @@ packages:
unist-util-visit@5.0.0:
resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
+ until-async@3.0.2:
+ resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==}
+
update-browserslist-db@1.1.3:
resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
hasBin: true
@@ -2152,6 +2312,14 @@ packages:
engines: {node: '>=8'}
hasBin: true
+ wrap-ansi@6.2.0:
+ resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+ engines: {node: '>=8'}
+
+ wrap-ansi@7.0.0:
+ resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+ engines: {node: '>=10'}
+
ws@8.18.1:
resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==}
engines: {node: '>=10.0.0'}
@@ -2175,9 +2343,25 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
+ y18n@5.0.8:
+ resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
+ engines: {node: '>=10'}
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+ yargs-parser@21.1.1:
+ resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+ engines: {node: '>=12'}
+
+ yargs@17.7.2:
+ resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
+ engines: {node: '>=12'}
+
+ yoctocolors-cjs@2.1.3:
+ resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
+ engines: {node: '>=18'}
+
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -2463,6 +2647,34 @@ snapshots:
'@floating-ui/utils@0.2.9': {}
+ '@inquirer/ansi@1.0.1': {}
+
+ '@inquirer/confirm@5.1.19(@types/node@22.13.10)':
+ dependencies:
+ '@inquirer/core': 10.3.0(@types/node@22.13.10)
+ '@inquirer/type': 3.0.9(@types/node@22.13.10)
+ optionalDependencies:
+ '@types/node': 22.13.10
+
+ '@inquirer/core@10.3.0(@types/node@22.13.10)':
+ dependencies:
+ '@inquirer/ansi': 1.0.1
+ '@inquirer/figures': 1.0.14
+ '@inquirer/type': 3.0.9(@types/node@22.13.10)
+ cli-width: 4.1.0
+ mute-stream: 2.0.0
+ signal-exit: 4.1.0
+ wrap-ansi: 6.2.0
+ yoctocolors-cjs: 2.1.3
+ optionalDependencies:
+ '@types/node': 22.13.10
+
+ '@inquirer/figures@1.0.14': {}
+
+ '@inquirer/type@3.0.9(@types/node@22.13.10)':
+ optionalDependencies:
+ '@types/node': 22.13.10
+
'@jridgewell/gen-mapping@0.3.8':
dependencies:
'@jridgewell/set-array': 1.2.1
@@ -2482,6 +2694,24 @@ snapshots:
'@juggle/resize-observer@3.4.0': {}
+ '@mswjs/interceptors@0.40.0':
+ dependencies:
+ '@open-draft/deferred-promise': 2.2.0
+ '@open-draft/logger': 0.3.0
+ '@open-draft/until': 2.1.0
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+ strict-event-emitter: 0.5.1
+
+ '@open-draft/deferred-promise@2.2.0': {}
+
+ '@open-draft/logger@0.3.0':
+ dependencies:
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+
+ '@open-draft/until@2.1.0': {}
+
'@radix-ui/number@1.1.0': {}
'@radix-ui/primitive@1.1.1': {}
@@ -2902,6 +3132,10 @@ snapshots:
'@types/react': 19.0.12
'@types/react-dom': 19.0.4(@types/react@19.0.12)
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)':
+ dependencies:
+ '@testing-library/dom': 10.4.0
+
'@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5':
@@ -2967,6 +3201,8 @@ snapshots:
dependencies:
csstype: 3.1.3
+ '@types/statuses@2.0.6': {}
+
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@@ -2991,12 +3227,13 @@ snapshots:
chai: 5.2.0
tinyrainbow: 2.0.0
- '@vitest/mocker@3.0.9(vite@6.2.6(@types/node@22.13.10)(jiti@2.4.2)(lightningcss@1.29.2))':
+ '@vitest/mocker@3.0.9(msw@2.12.0(@types/node@22.13.10)(typescript@5.7.3))(vite@6.2.6(@types/node@22.13.10)(jiti@2.4.2)(lightningcss@1.29.2))':
dependencies:
'@vitest/spy': 3.0.9
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
+ msw: 2.12.0(@types/node@22.13.10)(typescript@5.7.3)
vite: 6.2.6(@types/node@22.13.10)(jiti@2.4.2)(lightningcss@1.29.2)
'@vitest/pretty-format@3.0.9':
@@ -3106,6 +3343,14 @@ snapshots:
dependencies:
clsx: 2.1.1
+ cli-width@4.1.0: {}
+
+ cliui@8.0.1:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 7.0.0
+
clsx@2.1.1: {}
color-convert@2.0.1:
@@ -3124,6 +3369,8 @@ snapshots:
convert-source-map@2.0.0: {}
+ cookie@1.0.2: {}
+
css.escape@1.5.1: {}
cssesc@3.0.0: {}
@@ -3176,6 +3423,8 @@ snapshots:
electron-to-chromium@1.5.123: {}
+ emoji-regex@8.0.0: {}
+
enhanced-resolve@5.18.1:
dependencies:
graceful-fs: 4.2.11
@@ -3262,6 +3511,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
+ get-caller-file@2.0.5: {}
+
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -3290,6 +3541,8 @@ snapshots:
graceful-fs@4.2.11: {}
+ graphql@16.12.0: {}
+
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
@@ -3336,6 +3589,8 @@ snapshots:
property-information: 5.6.0
space-separated-tokens: 1.1.5
+ headers-polyfill@4.0.3: {}
+
highlight.js@10.7.3: {}
highlightjs-vue@1.0.0: {}
@@ -3386,10 +3641,14 @@ snapshots:
is-decimal@2.0.1: {}
+ is-fullwidth-code-point@3.0.0: {}
+
is-hexadecimal@1.0.4: {}
is-hexadecimal@2.0.1: {}
+ is-node-process@1.2.0: {}
+
is-plain-obj@4.1.0: {}
is-potential-custom-element-name@1.0.1: {}
@@ -3866,12 +4125,41 @@ snapshots:
ms@2.1.3: {}
+ msw@2.12.0(@types/node@22.13.10)(typescript@5.7.3):
+ dependencies:
+ '@inquirer/confirm': 5.1.19(@types/node@22.13.10)
+ '@mswjs/interceptors': 0.40.0
+ '@open-draft/deferred-promise': 2.2.0
+ '@types/statuses': 2.0.6
+ cookie: 1.0.2
+ graphql: 16.12.0
+ headers-polyfill: 4.0.3
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+ path-to-regexp: 6.3.0
+ picocolors: 1.1.1
+ rettime: 0.7.0
+ statuses: 2.0.2
+ strict-event-emitter: 0.5.1
+ tough-cookie: 6.0.0
+ type-fest: 4.41.0
+ until-async: 3.0.2
+ yargs: 17.7.2
+ optionalDependencies:
+ typescript: 5.7.3
+ transitivePeerDependencies:
+ - '@types/node'
+
+ mute-stream@2.0.0: {}
+
nanoid@3.3.11: {}
node-releases@2.0.19: {}
nwsapi@2.2.19: {}
+ outvariant@1.4.3: {}
+
parse-entities@2.0.0:
dependencies:
character-entities: 1.2.4
@@ -3895,6 +4183,8 @@ snapshots:
dependencies:
entities: 4.5.0
+ path-to-regexp@6.3.0: {}
+
pathe@2.0.3: {}
pathval@2.0.0: {}
@@ -4043,6 +4333,10 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
+ require-directory@2.1.1: {}
+
+ rettime@0.7.0: {}
+
rollup@4.40.0:
dependencies:
'@types/estree': 1.0.7
@@ -4083,6 +4377,8 @@ snapshots:
siginfo@2.0.0: {}
+ signal-exit@4.1.0: {}
+
source-map-js@1.2.1: {}
space-separated-tokens@1.1.5: {}
@@ -4091,13 +4387,27 @@ snapshots:
stackback@0.0.2: {}
+ statuses@2.0.2: {}
+
std-env@3.8.1: {}
+ strict-event-emitter@0.5.1: {}
+
+ string-width@4.2.3:
+ dependencies:
+ emoji-regex: 8.0.0
+ is-fullwidth-code-point: 3.0.0
+ strip-ansi: 6.0.1
+
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
character-entities-legacy: 3.0.0
+ strip-ansi@6.0.1:
+ dependencies:
+ ansi-regex: 5.0.1
+
strip-indent@3.0.0:
dependencies:
min-indent: 1.0.1
@@ -4134,14 +4444,24 @@ snapshots:
tldts-core@6.1.84: {}
+ tldts-core@7.0.17: {}
+
tldts@6.1.84:
dependencies:
tldts-core: 6.1.84
+ tldts@7.0.17:
+ dependencies:
+ tldts-core: 7.0.17
+
tough-cookie@5.1.2:
dependencies:
tldts: 6.1.84
+ tough-cookie@6.0.0:
+ dependencies:
+ tldts: 7.0.17
+
tr46@5.1.0:
dependencies:
punycode: 2.3.1
@@ -4154,6 +4474,8 @@ snapshots:
tw-animate-css@1.2.4: {}
+ type-fest@4.41.0: {}
+
typescript@5.7.3: {}
undici-types@6.20.0: {}
@@ -4191,6 +4513,8 @@ snapshots:
unist-util-is: 6.0.0
unist-util-visit-parents: 6.0.1
+ until-async@3.0.2: {}
+
update-browserslist-db@1.1.3(browserslist@4.24.4):
dependencies:
browserslist: 4.24.4
@@ -4256,10 +4580,10 @@ snapshots:
jiti: 2.4.2
lightningcss: 1.29.2
- vitest@3.0.9(@types/debug@4.1.12)(@types/node@22.13.10)(jiti@2.4.2)(jsdom@26.0.0)(lightningcss@1.29.2):
+ vitest@3.0.9(@types/debug@4.1.12)(@types/node@22.13.10)(jiti@2.4.2)(jsdom@26.0.0)(lightningcss@1.29.2)(msw@2.12.0(@types/node@22.13.10)(typescript@5.7.3)):
dependencies:
'@vitest/expect': 3.0.9
- '@vitest/mocker': 3.0.9(vite@6.2.6(@types/node@22.13.10)(jiti@2.4.2)(lightningcss@1.29.2))
+ '@vitest/mocker': 3.0.9(msw@2.12.0(@types/node@22.13.10)(typescript@5.7.3))(vite@6.2.6(@types/node@22.13.10)(jiti@2.4.2)(lightningcss@1.29.2))
'@vitest/pretty-format': 3.0.9
'@vitest/runner': 3.0.9
'@vitest/snapshot': 3.0.9
@@ -4318,6 +4642,18 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
+ wrap-ansi@6.2.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
+ wrap-ansi@7.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
ws@8.18.1: {}
xml-name-validator@5.0.0: {}
@@ -4326,6 +4662,22 @@ snapshots:
xtend@4.0.2: {}
+ y18n@5.0.8: {}
+
yallist@3.1.1: {}
+ yargs-parser@21.1.1: {}
+
+ yargs@17.7.2:
+ dependencies:
+ cliui: 8.0.1
+ escalade: 3.2.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ string-width: 4.2.3
+ y18n: 5.0.8
+ yargs-parser: 21.1.1
+
+ yoctocolors-cjs@2.1.3: {}
+
zwitch@2.0.4: {}
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 2eea215..6876a01 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -9,92 +9,177 @@ describe("App", () => {
let queryClient: QueryClient;
beforeEach(() => {
- queryClient = new QueryClient();
+ queryClient = new QueryClient({
+ defaultOptions: {
+ mutations: {
+ retry: false,
+ },
+ queries: {
+ retry: false,
+ },
+ },
+ });
vi.useFakeTimers();
+
+ // Set webhook URL for tests (MSW will intercept this)
+ vi.stubEnv("VITE_WEBHOOK_URL", "https://example.com/webhook");
});
afterEach(() => {
vi.useRealTimers();
+ vi.unstubAllEnvs();
});
it("renders the chat interface with initial AI message", () => {
+ // GIVEN & WHEN: App is rendered
render(
,
);
- // Check header
+ // THEN: Header is displayed
expect(screen.getByText("Simple Chat")).toBeInTheDocument();
- // Check initial AI message
+ // THEN: Initial AI message is shown
expect(
screen.getByText("Hello! How can I help you today?"),
).toBeInTheDocument();
- // Check input form
+ // THEN: Input form is ready
expect(
screen.getByPlaceholderText("Type your message..."),
).toBeInTheDocument();
});
- it("sends user message and shows loading state", async () => {
+ it("sends user message and shows loading state", () => {
+ // GIVEN: App is rendered
render(
,
);
- // Type a message
const textarea = screen.getByPlaceholderText("Type your message...");
- fireEvent.change(textarea, { target: { value: "Hello AI!" } });
+ const submitButton = screen.getByTestId("chat-submit-button");
- // Submit the form
- const submitButton = screen.getByRole("button");
+ // WHEN: User types and submits a message
+ fireEvent.change(textarea, { target: { value: "Hello AI!" } });
fireEvent.click(submitButton);
- // Check user message is displayed
+ // THEN: User message is displayed immediately
expect(screen.getByText("Hello AI!")).toBeInTheDocument();
- // Check loading indicator appears
- const dots = document.querySelectorAll(".animate-bounce");
- expect(dots.length).toBe(3);
+ // THEN: Loading indicator appears
+ expect(screen.getByTestId("typing-indicator")).toBeInTheDocument();
});
it("shows AI response after loading", async () => {
- const originalConsoleError = console.error;
- console.error = vi.fn();
-
+ // GIVEN: App is rendered
render(
,
);
- // Type and send a message
const textarea = screen.getByPlaceholderText("Type your message...");
+ const submitButton = screen.getByTestId("chat-submit-button");
+
+ // WHEN: User sends a message
fireEvent.change(textarea, { target: { value: "Hello AI!" } });
+ fireEvent.click(submitButton);
+
+ // THEN: User message is displayed immediately
+ expect(screen.getByText("Hello AI!")).toBeInTheDocument();
+
+ // WHEN: All timers complete (fetch + typing animation)
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ // THEN: Expected number of messages displayed (initial AI + user + AI response)
+ const messages = screen.getAllByTestId(/message-content/);
+ expect(messages.length).toBe(3);
- const submitButton = screen.getByRole("button");
+ // THEN: AI response contains simulated content
+ expect(messages[2].textContent).toContain("This is a simulated response");
+ });
+
+ it("should handle rapid consecutive message submissions", async () => {
+ // GIVEN: App is rendered
+ render(
+
+
+ ,
+ );
+
+ const textarea = screen.getByPlaceholderText("Type your message...");
+ const submitButton = screen.getByTestId("chat-submit-button");
+
+ // WHEN: User sends multiple messages quickly
+ fireEvent.change(textarea, { target: { value: "Message 1" } });
fireEvent.click(submitButton);
- // Verify loading state
- expect(document.querySelectorAll(".animate-bounce").length).toBe(3);
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
- // Fast-forward timers
- act(() => {
- vi.advanceTimersByTime(1500);
+ fireEvent.change(textarea, { target: { value: "Message 2" } });
+ fireEvent.click(submitButton);
+
+ await act(async () => {
+ await vi.runAllTimersAsync();
});
- // Wait for React Query to process the error
- // This is needed because React Query might take some time to process the error
- await vi.runAllTimersAsync();
+ fireEvent.change(textarea, { target: { value: "Message 3" } });
+ fireEvent.click(submitButton);
- // Check that we have the expected number of messages (3: initial AI + user + error message)
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ // THEN: All messages are sent and responses received
const messages = screen.getAllByTestId(/message-content/);
- expect(messages.length).toBe(3);
+ expect(messages.length).toBeGreaterThanOrEqual(7); // 1 initial + 3 user + 3 AI
+ });
+
+ it("should disable submit button while message is being sent", () => {
+ // GIVEN: App is rendered
+ render(
+
+
+ ,
+ );
+
+ const textarea = screen.getByPlaceholderText("Type your message...");
+ const submitButton = screen.getByTestId("chat-submit-button");
+
+ // WHEN: User sends a message
+ fireEvent.change(textarea, { target: { value: "Hello" } });
+ fireEvent.click(submitButton);
+
+ // THEN: Submit button is disabled while loading
+ expect(submitButton).toBeDisabled();
+ });
+
+ it("should clear input after successful message send", async () => {
+ // GIVEN: App is rendered with input
+ render(
+
+
+ ,
+ );
+
+ const textarea = screen.getByPlaceholderText("Type your message...");
+ const submitButton = screen.getByTestId("chat-submit-button");
+
+ // WHEN: User sends a message
+ fireEvent.change(textarea, { target: { value: "Test message" } });
+ expect(textarea).toHaveValue("Test message");
+
+ fireEvent.click(submitButton);
- // Restore console.error
- console.error = originalConsoleError;
+ // THEN: Input is cleared
+ expect(textarea).toHaveValue("");
});
});
diff --git a/src/components/chat/ChatContainer.test.tsx b/src/components/chat/ChatContainer.test.tsx
index ff6fc96..f2ca893 100644
--- a/src/components/chat/ChatContainer.test.tsx
+++ b/src/components/chat/ChatContainer.test.tsx
@@ -1,6 +1,6 @@
import type { Message } from "@/domain/entities/message";
import { Roles } from "@/domain/entities/roles.enum";
-import { render, screen } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChatContainer } from "./ChatContainer";
@@ -10,6 +10,7 @@ describe("ChatContainer", () => {
});
it("renders messages and chat input", () => {
+ // GIVEN: Messages and handlers
const messages: Message[] = [
{ id: "1", content: "Hello", role: Roles.Chat },
{ id: "2", content: "Hi there!", role: Roles.User },
@@ -18,6 +19,7 @@ describe("ChatContainer", () => {
const handleSubmit = vi.fn();
const setInput = vi.fn();
+ // WHEN: Container is rendered
render(
{
/>,
);
- // Check both messages are displayed
+ // THEN: Both messages are displayed
expect(screen.getByText("Hello")).toBeInTheDocument();
expect(screen.getByText("Hi there!")).toBeInTheDocument();
- // Check user avatar only (AI avatar not in current implementation)
+ // THEN: User avatar is shown
expect(screen.getByText("ME")).toBeInTheDocument();
- // Check input form exists
+ // THEN: Input form exists
expect(
screen.getByPlaceholderText("Type your message..."),
).toBeInTheDocument();
});
it("displays typing indicator when loading", () => {
+ // GIVEN: One message and loading state
const messages: Message[] = [
{ id: "1", content: "Hello", role: Roles.Chat },
];
@@ -49,6 +52,7 @@ describe("ChatContainer", () => {
const handleSubmit = vi.fn();
const setInput = vi.fn();
+ // WHEN: Container renders with isLoading=true
render(
{
/>,
);
- // Check message is displayed
+ // THEN: Message is displayed
expect(screen.getByText("Hello")).toBeInTheDocument();
- // Check typing indicator is present
+ // THEN: Typing indicator with dots is present
expect(screen.getByTestId("typing-indicator")).toBeInTheDocument();
+ expect(screen.getByTestId("typing-dots")).toBeInTheDocument();
+ });
+
+ it("should scroll to bottom when new message is added", async () => {
+ // GIVEN: Initial messages
+ const initialMessages: Message[] = [
+ { id: "1", content: "Hello", role: Roles.Chat },
+ ];
+
+ const { rerender } = render(
+ ,
+ );
+
+ // Clear any initial scroll calls
+ vi.clearAllMocks();
+
+ // WHEN: New message is added
+ const newMessages = [
+ ...initialMessages,
+ { id: "2", content: "New message", role: Roles.User },
+ ];
+
+ rerender(
+ ,
+ );
+
+ // THEN: scrollIntoView should have been called
+ await waitFor(() => {
+ expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
+ });
+ });
+
+ it("should render with no messages (empty state)", () => {
+ // GIVEN: Empty message array
+ // WHEN: Container is rendered
+ render(
+ ,
+ );
+
+ // THEN: No messages are displayed
+ expect(screen.queryByTestId("message-content")).not.toBeInTheDocument();
+
+ // THEN: Input is still available
+ expect(screen.getByTestId("chat-input")).toBeInTheDocument();
+
+ // THEN: Container structure exists
+ expect(screen.getByTestId("chat-container")).toBeInTheDocument();
+ });
+
+ it("should handle multiple rapid message additions", () => {
+ // GIVEN: Large message array (50 messages)
+ const manyMessages: Message[] = Array.from({ length: 50 }, (_, i) => ({
+ id: `${i}`,
+ content: `Message ${i}`,
+ role: i % 2 === 0 ? Roles.Chat : Roles.User,
+ }));
+
+ // WHEN: Container renders with many messages
+ render(
+ ,
+ );
+
+ // THEN: All messages are rendered
+ const messages = screen.getAllByTestId("message-content");
+ expect(messages.length).toBe(50);
+ });
+
+ it("should handle messages with special characters", () => {
+ // GIVEN: Messages with special characters
+ const specialMessages: Message[] = [
+ { id: "1", content: "", role: Roles.User },
+ { id: "2", content: "Emoji test 🚀 💻 ✨", role: Roles.Chat },
+ { id: "3", content: "Unicode test: 你好世界", role: Roles.User },
+ ];
+
+ // WHEN: Container renders
+ render(
+ ,
+ );
+
+ // THEN: All special content is displayed safely
+ expect(
+ screen.getByText(""),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Emoji test 🚀 💻 ✨")).toBeInTheDocument();
+ expect(screen.getByText("Unicode test: 你好世界")).toBeInTheDocument();
+ });
+
+ it("should maintain structure when input changes", () => {
+ // GIVEN: Container with messages
+ const { rerender } = render(
+ ,
+ );
+
+ // WHEN: Input changes (simulating multiline text)
+ rerender(
+ ,
+ );
- // Check animation dots are displayed
- const dots = document.querySelectorAll(".animate-bounce");
- expect(dots.length).toBe(3);
+ // THEN: Container maintains structure
+ expect(screen.getByTestId("chat-container")).toBeInTheDocument();
+ expect(screen.getByTestId("chat-input")).toBeInTheDocument();
});
});
diff --git a/src/components/chat/ChatContainer.tsx b/src/components/chat/ChatContainer.tsx
index 25f3bf4..bc955ac 100644
--- a/src/components/chat/ChatContainer.tsx
+++ b/src/components/chat/ChatContainer.tsx
@@ -4,6 +4,7 @@ import { ResizeObserver } from "@juggle/resize-observer";
import { useEffect, useRef, useState } from "react";
import type { Message } from "@/domain/entities/message";
+import { DEFAULT_INPUT_HEIGHT } from "@/lib/constants";
import { ChatInput } from "./ChatInput";
import { ChatMessage } from "./ChatMessage";
import { TypingIndicator } from "./TypingIndicator";
@@ -27,18 +28,10 @@ export function ChatContainer({
const messagesEndRef = useRef(null);
// Calculate the input height for the bottom padding of the scroll area
- const [inputHeight, setInputHeight] = useState(64); // Default height (48px + 16px padding)
+ const [inputHeight, setInputHeight] = useState(DEFAULT_INPUT_HEIGHT);
const inputRef = useRef(null);
// Scroll to bottom when messages change
- useEffect(() => {
- // Auto-scroll to bottom
- if (scrollViewportRef.current && messagesEndRef.current) {
- messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
- }
- }, []); // Empty dependency array to satisfy the linter
-
- // This will trigger the scroll effect when messages change
useEffect(() => {
if (messages.length > 0) {
// Auto-scroll to bottom
diff --git a/src/components/chat/ChatInput.test.tsx b/src/components/chat/ChatInput.test.tsx
index adf4cb5..7daba58 100644
--- a/src/components/chat/ChatInput.test.tsx
+++ b/src/components/chat/ChatInput.test.tsx
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ChatInput } from "./ChatInput";
@@ -125,4 +126,96 @@ describe("ChatInput", () => {
expect(preventDefaultMock).not.toHaveBeenCalled();
expect(handleSubmit).not.toHaveBeenCalled();
});
+
+ it("should submit on Enter key press", () => {
+ // GIVEN: Component with non-empty input
+ const handleSubmit = vi.fn((e) => e.preventDefault());
+ const setInput = vi.fn();
+
+ render(
+ ,
+ );
+
+ const textarea = screen.getByTestId("chat-input");
+
+ // WHEN: User presses Enter without Shift
+ fireEvent.keyDown(textarea, {
+ key: "Enter",
+ shiftKey: false,
+ });
+
+ // THEN: Form submits
+ expect(handleSubmit).toHaveBeenCalled();
+ });
+
+ it("should support keyboard navigation with Tab", async () => {
+ // GIVEN: User interaction setup and component rendered
+ const user = userEvent.setup();
+ const handleSubmit = vi.fn();
+ const setInput = vi.fn();
+
+ render(
+ ,
+ );
+
+ // WHEN: User tabs through form
+ await user.tab();
+
+ // THEN: Textarea receives focus first
+ expect(screen.getByTestId("chat-input")).toHaveFocus();
+
+ // WHEN: User tabs again
+ await user.tab();
+
+ // THEN: Button receives focus next
+ expect(screen.getByTestId("chat-submit-button")).toHaveFocus();
+ });
+
+ it("should disable submit button when input is whitespace only", () => {
+ // GIVEN: Input with only whitespace (spaces, newlines, tabs)
+ const handleSubmit = vi.fn();
+ const setInput = vi.fn();
+
+ render(
+ ,
+ );
+
+ // THEN: Submit button is disabled
+ const submitButton = screen.getByTestId("chat-submit-button");
+ expect(submitButton).toBeDisabled();
+ });
+
+ it("should have proper ARIA labels for accessibility", () => {
+ // GIVEN & WHEN: Component is rendered
+ const handleSubmit = vi.fn();
+ const setInput = vi.fn();
+
+ render(
+ ,
+ );
+
+ // THEN: Elements have proper aria-labels
+ expect(screen.getByLabelText("Chat message input")).toBeInTheDocument();
+ expect(screen.getByLabelText("Send message")).toBeInTheDocument();
+ });
});
diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx
index 89fe5cf..33656dd 100644
--- a/src/components/chat/ChatInput.tsx
+++ b/src/components/chat/ChatInput.tsx
@@ -25,6 +25,7 @@ export function ChatInput({
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
+ aria-label="Chat message input"
className="min-h-12 max-h-[200px] resize-none flex-1"
data-test-id="chat-input"
onKeyDown={(e) => {
@@ -39,6 +40,7 @@ export function ChatInput({
size="icon"
disabled={isLoading || !input.trim()}
data-test-id="chat-submit-button"
+ aria-label="Send message"
>
diff --git a/src/components/chat/ChatMessage/AssistantMessage.test.tsx b/src/components/chat/ChatMessage/AssistantMessage.test.tsx
new file mode 100644
index 0000000..f59b522
--- /dev/null
+++ b/src/components/chat/ChatMessage/AssistantMessage.test.tsx
@@ -0,0 +1,162 @@
+import type { Message } from "@/domain/entities/message";
+import { Roles } from "@/domain/entities/roles.enum";
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { AssistantMessage } from "./AssistantMessage";
+
+// Mock useTypingText to return full text immediately for testing
+vi.mock("@/hooks/useTypingText", () => ({
+ useTypingText: vi.fn(({ text }) => ({
+ text,
+ isTyping: false,
+ })),
+}));
+
+describe("AssistantMessage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render markdown formatted content", () => {
+ // GIVEN: Message with markdown
+ const message: Message = {
+ id: "1",
+ role: Roles.Chat,
+ content: "**Bold** and *italic* text",
+ };
+
+ // WHEN: Component renders
+ render();
+
+ // THEN: Markdown is rendered
+ const content = screen.getByTestId("message-content");
+ expect(content.querySelector("strong")).toHaveTextContent("Bold");
+ expect(content.querySelector("em")).toHaveTextContent("italic");
+ });
+
+ it("should sanitize external links with security attributes", () => {
+ // GIVEN: Message with external link
+ const message: Message = {
+ id: "1",
+ role: Roles.Chat,
+ content: "[Click here](https://example.com)",
+ };
+
+ // WHEN: Component renders
+ render();
+
+ // THEN: Link has security attributes
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ });
+
+ it("should handle very long content without overflow", () => {
+ // GIVEN: Message with very long content
+ const longContent = "A".repeat(5000);
+ const message: Message = {
+ id: "1",
+ role: Roles.Chat,
+ content: longContent,
+ };
+
+ // WHEN: Component renders
+ render();
+
+ // THEN: Content is displayed
+ const content = screen.getByTestId("message-content");
+ expect(content).toBeInTheDocument();
+ expect(content.textContent).toContain("AAAA");
+ });
+
+ it("should render code blocks", () => {
+ // GIVEN: Message with code block
+ const message: Message = {
+ id: "1",
+ role: Roles.Chat,
+ content: "```javascript\nconst x = 10;\n```",
+ };
+
+ // WHEN: Component renders
+ render();
+
+ // THEN: Code is rendered (text is split by syntax highlighter)
+ const content = screen.getByTestId("message-content");
+ expect(content.textContent).toContain("const");
+ expect(content.textContent).toContain("x");
+ expect(content.textContent).toContain("10");
+ });
+
+ it("should render inline code without syntax highlighting", () => {
+ // GIVEN: Message with inline code
+ const message: Message = {
+ id: "1",
+ role: Roles.Chat,
+ content: "Use `const x = 10` for constants",
+ };
+
+ // WHEN: Component renders
+ render();
+
+ // THEN: Inline code is rendered
+ const content = screen.getByTestId("message-content");
+ expect(content.textContent).toContain("const x = 10");
+ });
+
+ it("should display error messages with emoji prefix", () => {
+ // GIVEN: Error message
+ const message: Message = {
+ id: "1",
+ role: Roles.Chat,
+ content: "Something went wrong",
+ isError: true,
+ };
+
+ // WHEN: Component renders
+ render();
+
+ // THEN: Error emoji is prepended and error styling applied
+ const content = screen.getByTestId("message-content");
+ expect(content.textContent).toContain("❌ Something went wrong");
+ expect(content).toHaveClass("text-orange-700");
+ });
+
+ it("should handle markdown with lists", () => {
+ // GIVEN: Message with ordered list
+ const message: Message = {
+ id: "1",
+ role: Roles.Chat,
+ content:
+ "Here are the steps:\n1. First step\n2. Second step\n3. Third step",
+ };
+
+ // WHEN: Component renders
+ render();
+
+ // THEN: List items are rendered
+ expect(screen.getByText(/First step/)).toBeInTheDocument();
+ expect(screen.getByText(/Second step/)).toBeInTheDocument();
+ expect(screen.getByText(/Third step/)).toBeInTheDocument();
+ });
+
+ it("should handle multiple markdown elements in one message", () => {
+ // GIVEN: Message with multiple markdown elements
+ const message: Message = {
+ id: "1",
+ role: Roles.Chat,
+ content:
+ "# Heading\n\n**Bold text** and *italic text*\n\n- List item 1\n- List item 2\n\n`inline code`",
+ };
+
+ // WHEN: Component renders
+ render();
+
+ // THEN: All elements are rendered
+ const content = screen.getByTestId("message-content");
+ expect(content.querySelector("h1")).toHaveTextContent("Heading");
+ expect(content.querySelector("strong")).toHaveTextContent("Bold text");
+ expect(content.querySelector("em")).toHaveTextContent("italic text");
+ expect(screen.getByText(/List item 1/)).toBeInTheDocument();
+ expect(content.textContent).toContain("inline code");
+ });
+});
diff --git a/src/components/chat/ChatMessage/AssistantMessage.tsx b/src/components/chat/ChatMessage/AssistantMessage.tsx
index 6f37dbe..3eaadfa 100644
--- a/src/components/chat/ChatMessage/AssistantMessage.tsx
+++ b/src/components/chat/ChatMessage/AssistantMessage.tsx
@@ -29,28 +29,29 @@ export const AssistantMessage = ({ message }: ChatMessageProps) => {
{String(children).replace(/\n$/, "")}
) : (
-
-
- {children}
-
-
+
+ {children}
+
);
},
- a({ node, className, children, ...props }) {
+ a({ className, children, ...props }) {
return (
(
- ME
+ {USER_AVATAR_FALLBACK}
diff --git a/src/components/chat/TypingIndicator.test.tsx b/src/components/chat/TypingIndicator.test.tsx
index fba99fb..992c91f 100644
--- a/src/components/chat/TypingIndicator.test.tsx
+++ b/src/components/chat/TypingIndicator.test.tsx
@@ -1,17 +1,32 @@
-import { render } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TypingIndicator } from "./TypingIndicator";
describe("TypingIndicator", () => {
- it("renders the typing indicator with AI avatar", () => {
+ it("renders the typing indicator with animation dots", () => {
+ // GIVEN & WHEN: Component is rendered
render(
);
- // Check the animation dots are displayed
- const dots = document.querySelectorAll(".animate-bounce");
- expect(dots.length).toBe(3);
+ // THEN: Typing indicator with bubble and dots are displayed
+ expect(screen.getByTestId("typing-indicator")).toBeInTheDocument();
+ expect(screen.getByTestId("typing-bubble")).toBeInTheDocument();
+ expect(screen.getByTestId("typing-dots")).toBeInTheDocument();
+ });
+
+ it("should have proper accessibility structure", () => {
+ // GIVEN & WHEN: Component is rendered
+ render(
);
+
+ // THEN: Component has proper test-ids for testing
+ const indicator = screen.getByTestId("typing-indicator");
+ const bubble = screen.getByTestId("typing-bubble");
+ const dots = screen.getByTestId("typing-dots");
+
+ expect(indicator).toBeInTheDocument();
+ expect(bubble).toBeInTheDocument();
+ expect(dots).toBeInTheDocument();
- // Check styling
- const bubbleContainer = document.querySelector(".rounded-2xl");
- expect(bubbleContainer).toHaveClass("bg-muted");
+ // THEN: Dots container has 3 animated dots
+ expect(dots.children).toHaveLength(3);
});
});
diff --git a/src/hooks/useChatWebhook.test.tsx b/src/hooks/useChatWebhook.test.tsx
new file mode 100644
index 0000000..a11b793
--- /dev/null
+++ b/src/hooks/useChatWebhook.test.tsx
@@ -0,0 +1,621 @@
+import { Roles } from "@/domain/entities/roles.enum";
+import { server } from "@/test/mocks/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useChatWebhook } from "./useChatWebhook";
+
+describe("useChatWebhook", () => {
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
{children}
+ );
+
+ describe("with real webhook", () => {
+ it("should send message and receive response", async () => {
+ // GIVEN: MSW mock configured and hook initialized
+ server.use(
+ http.post("https://example.com/webhook", () => {
+ return HttpResponse.json({ output: "Hello from webhook!" });
+ }),
+ );
+
+ const { result } = renderHook(
+ () => useChatWebhook({ webhookUrl: "https://example.com/webhook" }),
+ { wrapper },
+ );
+
+ expect(result.current).toMatchObject({
+ messages: [
+ {
+ content: "Hello! How can I help you today?",
+ role: Roles.Chat,
+ },
+ ],
+ isLoading: false,
+ isError: false,
+ });
+
+ // WHEN: User sends a message
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+
+ expect(result.current).toMatchObject({
+ messages: [
+ { role: Roles.Chat, content: "Hello! How can I help you today?" },
+ { role: Roles.User, content: "Hello" },
+ ],
+ isLoading: true,
+ isError: false,
+ });
+
+ // THEN: Message is sent and response is received
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current).toMatchObject({
+ messages: [
+ { role: Roles.Chat, content: "Hello! How can I help you today?" },
+ { role: Roles.User, content: "Hello" },
+ { role: Roles.Chat, content: "Hello from webhook!" },
+ ],
+ isLoading: false,
+ isError: false,
+ });
+ });
+
+ it("should maintain correct message order", async () => {
+ // GIVEN: Hook initialized with webhook
+ server.use(
+ http.post("https://example.com/webhook", () => {
+ return HttpResponse.json({ output: "Response" });
+ }),
+ );
+
+ const { result } = renderHook(
+ () => useChatWebhook({ webhookUrl: "https://example.com/webhook" }),
+ { wrapper },
+ );
+
+ // WHEN: User sends two messages sequentially
+ act(() => {
+ result.current.sendMessage("First");
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ act(() => {
+ result.current.sendMessage("Second");
+ });
+
+ await waitFor(() => {
+ expect(result.current.messages.length).toBe(5);
+ });
+
+ // THEN: Messages are in correct chronological order
+ expect(result.current.messages).toMatchObject([
+ { role: Roles.Chat, content: "Hello! How can I help you today?" },
+ { role: Roles.User, content: "First" },
+ { role: Roles.Chat, content: "Response" },
+ { role: Roles.User, content: "Second" },
+ { role: Roles.Chat, content: "Response" },
+ ]);
+ });
+
+ it("should add user message immediately even for empty content", () => {
+ // GIVEN: Hook initialized
+ const { result } = renderHook(
+ () => useChatWebhook({ webhookUrl: "https://example.com/webhook" }),
+ { wrapper },
+ );
+
+ // WHEN: User sends empty message
+ act(() => {
+ result.current.sendMessage("");
+ });
+
+ // THEN: User message added but mutation not triggered
+ expect(result.current).toMatchObject({
+ messages: [
+ { role: Roles.Chat, content: "Hello! How can I help you today?" },
+ { role: Roles.User, content: "" },
+ ],
+ isLoading: false,
+ });
+ });
+ });
+
+ describe("error handling", () => {
+ it("should handle network errors gracefully", async () => {
+ // GIVEN: Network error configured and hook initialized
+ server.use(
+ http.post("https://example.com/webhook", () => {
+ return HttpResponse.error();
+ }),
+ );
+
+ const { result } = renderHook(
+ () => useChatWebhook({ webhookUrl: "https://example.com/webhook" }),
+ { wrapper },
+ );
+
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ // WHEN: User sends a message
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+
+ // THEN: Error is handled and error message is displayed
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(result.current).toMatchObject({
+ messages: [
+ { role: Roles.Chat, content: "Hello! How can I help you today?" },
+ { role: Roles.User, content: "Hello" },
+ {
+ role: Roles.Chat,
+ content: "An error occurred. Please try again later.",
+ isError: true,
+ },
+ ],
+ isLoading: false,
+ isError: true,
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ consoleErrorSpy.mockRestore();
+ });
+
+ it("should handle HTTP error responses", async () => {
+ server.use(
+ http.post("https://example.com/webhook", () => {
+ return HttpResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }),
+ );
+
+ const { result } = renderHook(
+ () => useChatWebhook({ webhookUrl: "https://example.com/webhook" }),
+ { wrapper },
+ );
+
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+
+ // Verify error handling
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(result.current).toMatchObject({
+ messages: expect.arrayContaining([
+ expect.objectContaining({
+ role: Roles.Chat,
+ isError: true,
+ }),
+ ]),
+ isError: true,
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it("should handle malformed JSON responses", async () => {
+ server.use(
+ http.post("https://example.com/webhook", () => {
+ return new HttpResponse("not json", {
+ headers: { "Content-Type": "application/json" },
+ });
+ }),
+ );
+
+ const { result } = renderHook(
+ () => useChatWebhook({ webhookUrl: "https://example.com/webhook" }),
+ { wrapper },
+ );
+
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+
+ // Verify error handling for malformed response
+ await waitFor(() => {
+ expect(result.current.messages.length).toBe(3);
+ });
+
+ expect(result.current.messages[2]).toMatchObject({
+ role: Roles.Chat,
+ isError: true,
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it("should handle missing output field in response", async () => {
+ server.use(
+ http.post("https://example.com/webhook", () => {
+ return HttpResponse.json({ wrongField: "data" });
+ }),
+ );
+
+ const { result } = renderHook(
+ () => useChatWebhook({ webhookUrl: "https://example.com/webhook" }),
+ { wrapper },
+ );
+
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+
+ // Verify validation error
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(result.current).toMatchObject({
+ messages: expect.arrayContaining([
+ expect.objectContaining({
+ role: Roles.Chat,
+ isError: true,
+ }),
+ ]),
+ isError: true,
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it("should handle non-string output field", async () => {
+ server.use(
+ http.post("https://example.com/webhook", () => {
+ return HttpResponse.json({ output: 123 });
+ }),
+ );
+
+ const { result } = renderHook(
+ () => useChatWebhook({ webhookUrl: "https://example.com/webhook" }),
+ { wrapper },
+ );
+
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+
+ // Verify type validation error
+ await waitFor(() => {
+ expect(result.current.messages.length).toBe(3);
+ });
+
+ expect(result.current.messages[2]).toMatchObject({
+ role: Roles.Chat,
+ isError: true,
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+ });
+
+ describe("simulation mode", () => {
+ it("should use simulation when no webhook URL provided", async () => {
+ vi.useFakeTimers();
+
+ // GIVEN: Hook initialized without webhook URL
+ const { result } = renderHook(() => useChatWebhook({}), { wrapper });
+
+ // WHEN: User sends a message
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ // THEN: Simulated response is returned
+ expect(result.current.messages).toMatchObject([
+ { role: Roles.Chat, content: "Hello! How can I help you today?" },
+ { role: Roles.User, content: "Hello" },
+ { role: Roles.Chat, content: "This is a simulated response: Hello" },
+ ]);
+
+ vi.useRealTimers();
+ });
+
+ it("should respect simulationDelay option", async () => {
+ vi.useFakeTimers();
+
+ const customDelay = 3000;
+ const { result } = renderHook(
+ () => useChatWebhook({ simulationDelay: customDelay }),
+ { wrapper },
+ );
+
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+
+ // Verify loading before delay completes
+ expect(result.current).toMatchObject({
+ isLoading: true,
+ messages: expect.arrayContaining([
+ expect.objectContaining({ role: Roles.User, content: "Hello" }),
+ ]),
+ });
+
+ // Advance by less than customDelay
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(customDelay - 100);
+ });
+
+ // Still loading
+ expect(result.current).toMatchObject({
+ isLoading: true,
+ });
+
+ // Complete the delay
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(100);
+ });
+
+ // Verify completed
+ expect(result.current).toMatchObject({
+ messages: expect.arrayContaining([
+ expect.objectContaining({
+ content: expect.stringContaining("simulated response"),
+ }),
+ ]),
+ isLoading: false,
+ });
+
+ vi.useRealTimers();
+ });
+
+ it("should use webhookUrl if provided even with simulationDelay set", async () => {
+ server.use(
+ http.post("https://example.com/webhook", () => {
+ return HttpResponse.json({ output: "Real webhook response" });
+ }),
+ );
+
+ const { result } = renderHook(
+ () =>
+ useChatWebhook({
+ webhookUrl: "https://example.com/webhook",
+ simulationDelay: 5000,
+ }),
+ { wrapper },
+ );
+
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+
+ // Should use real webhook, not simulation
+ await waitFor(() => {
+ expect(result.current.messages.length).toBe(3);
+ });
+
+ expect(result.current.messages).toMatchObject([
+ expect.objectContaining({ role: Roles.Chat }),
+ expect.objectContaining({ role: Roles.User, content: "Hello" }),
+ expect.objectContaining({
+ role: Roles.Chat,
+ content: "Real webhook response",
+ }),
+ ]);
+ });
+ });
+
+ describe("message state management", () => {
+ it("should start with default initial messages", () => {
+ const { result } = renderHook(() => useChatWebhook({}), { wrapper });
+
+ expect(result.current.messages).toMatchObject([
+ {
+ role: Roles.Chat,
+ content: "Hello! How can I help you today?",
+ },
+ ]);
+ });
+
+ it("should support custom initial messages", () => {
+ const customMessages = [
+ { id: "1", role: Roles.Chat, content: "Custom greeting" },
+ { id: "2", role: Roles.User, content: "Custom user message" },
+ ];
+
+ const { result } = renderHook(
+ () => useChatWebhook({ initialMessages: customMessages }),
+ { wrapper },
+ );
+
+ expect(result.current.messages).toMatchObject([
+ { content: "Custom greeting" },
+ { content: "Custom user message" },
+ ]);
+ });
+
+ it("should support empty initial messages", () => {
+ const { result } = renderHook(
+ () => useChatWebhook({ initialMessages: [] }),
+ { wrapper },
+ );
+
+ expect(result.current.messages).toHaveLength(0);
+ });
+
+ it("should add user message immediately when sendMessage is called", () => {
+ const { result } = renderHook(() => useChatWebhook({}), { wrapper });
+
+ const initialCount = result.current.messages.length;
+
+ act(() => {
+ result.current.sendMessage("Test message");
+ });
+
+ // Verify user message added synchronously
+ expect(result.current.messages[initialCount]).toMatchObject({
+ role: Roles.User,
+ content: "Test message",
+ });
+ });
+
+ it("should generate unique IDs for messages", async () => {
+ vi.useFakeTimers();
+
+ const { result } = renderHook(() => useChatWebhook({}), { wrapper });
+
+ // Advance time slightly to ensure different timestamps
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1);
+ });
+
+ act(() => {
+ result.current.sendMessage("First");
+ });
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1);
+ await vi.runAllTimersAsync();
+ });
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1);
+ });
+
+ act(() => {
+ result.current.sendMessage("Second");
+ });
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1);
+ await vi.runAllTimersAsync();
+ });
+
+ // Verify all IDs are unique
+ const ids = result.current.messages.map((m) => m.id);
+ const uniqueIds = new Set(ids);
+ expect(uniqueIds.size).toBe(ids.length);
+
+ vi.useRealTimers();
+ });
+
+ it("should not trigger mutation for empty or whitespace-only messages", () => {
+ const { result } = renderHook(() => useChatWebhook({}), { wrapper });
+
+ act(() => {
+ result.current.sendMessage(" ");
+ });
+
+ // User message added but no mutation triggered
+ expect(result.current).toMatchObject({
+ messages: expect.arrayContaining([
+ expect.objectContaining({ content: " " }),
+ ]),
+ isLoading: false,
+ });
+ });
+ });
+
+ describe("loading states", () => {
+ it("should set isLoading to true during mutation", async () => {
+ server.use(
+ http.post("https://example.com/webhook", async () => {
+ // Add delay to make loading state observable
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ return HttpResponse.json({ output: "Response" });
+ }),
+ );
+
+ const { result } = renderHook(
+ () => useChatWebhook({ webhookUrl: "https://example.com/webhook" }),
+ { wrapper },
+ );
+
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+
+ // Should be loading
+ expect(result.current.isLoading).toBe(true);
+
+ // Should be done loading
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ it("should set isError to true on error", async () => {
+ server.use(
+ http.post("https://example.com/webhook", async () => {
+ // Add delay to make loading state observable
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ return HttpResponse.error();
+ }),
+ );
+
+ const { result } = renderHook(
+ () => useChatWebhook({ webhookUrl: "https://example.com/webhook" }),
+ { wrapper },
+ );
+
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+ });
+});
diff --git a/src/hooks/useChatWebhook.ts b/src/hooks/useChatWebhook.ts
index 10c5fed..5f545a7 100644
--- a/src/hooks/useChatWebhook.ts
+++ b/src/hooks/useChatWebhook.ts
@@ -1,8 +1,20 @@
import type { Message } from "@/domain/entities/message";
import { Roles } from "@/domain/entities/roles.enum";
+import { DEFAULT_SIMULATION_DELAY } from "@/lib/constants";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
+// Counter to ensure unique IDs even when Date.now() returns the same value
+let messageIdCounter = 0;
+
+/**
+ * Generate a unique message ID
+ * Uses timestamp + counter to ensure uniqueness even in tests with fake timers
+ */
+function generateMessageId(): string {
+ return `${Date.now()}-${messageIdCounter++}`;
+}
+
interface WebhookResponse {
output: string;
}
@@ -14,7 +26,7 @@ interface ChatWebhookOptions {
}
const DEFAULT_OPTIONS: ChatWebhookOptions = {
- simulationDelay: 1500,
+ simulationDelay: DEFAULT_SIMULATION_DELAY,
initialMessages: [
{ id: "1", content: "Hello! How can I help you today?", role: Roles.Chat },
],
@@ -41,23 +53,39 @@ export function useChatWebhook(options: ChatWebhookOptions = DEFAULT_OPTIONS) {
});
}
- const response = await fetch(webhookUrl, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ message: content }),
- });
+ try {
+ const response = await fetch(webhookUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ message: content }),
+ });
- if (!response.ok) {
- throw new Error(`Webhook error: ${response.statusText}`);
- }
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data: WebhookResponse = await response.json();
- return await response.json();
+ // Validate response structure
+ if (!data || typeof data.output !== "string") {
+ throw new Error("Invalid webhook response format");
+ }
+
+ return data;
+ } catch (error) {
+ console.error("Webhook error:", error);
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : "Failed to communicate with webhook",
+ );
+ }
},
onSuccess: (data) => {
const chatMessage = {
- id: Date.now().toString(),
+ id: generateMessageId(),
content: data.output,
role: Roles.Chat,
};
@@ -66,7 +94,7 @@ export function useChatWebhook(options: ChatWebhookOptions = DEFAULT_OPTIONS) {
},
onError: () => {
const errorMessage = {
- id: Date.now().toString(),
+ id: generateMessageId(),
content: "An error occurred. Please try again later.",
role: Roles.Chat,
isError: true,
@@ -79,7 +107,7 @@ export function useChatWebhook(options: ChatWebhookOptions = DEFAULT_OPTIONS) {
// Function to send a message
const sendMessage = (content: string) => {
const userMessage: Message = {
- id: Date.now().toString(),
+ id: generateMessageId(),
content,
role: Roles.User,
};
diff --git a/src/hooks/useTypingText.test.tsx b/src/hooks/useTypingText.test.tsx
new file mode 100644
index 0000000..170301d
--- /dev/null
+++ b/src/hooks/useTypingText.test.tsx
@@ -0,0 +1,371 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useTypingText } from "./useTypingText";
+
+// Unmock the hook to test the real implementation
+vi.unmock("@/hooks/useTypingText");
+
+describe("useTypingText", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ describe("basic functionality", () => {
+ it("should reveal text progressively", () => {
+ // GIVEN: Hook initialized with text
+ const { result } = renderHook(() =>
+ useTypingText({ text: "Hello World", charsPerTick: 1, speed: 50 }),
+ );
+
+ // THEN: Initial state shows empty text and typing
+ expect(result.current).toMatchObject({
+ text: "",
+ isTyping: true,
+ isDone: false,
+ progress: 0,
+ });
+
+ // WHEN: Time advances by 50ms (one tick)
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: First character is revealed
+ expect(result.current).toMatchObject({
+ text: "H",
+ isTyping: true,
+ isDone: false,
+ });
+
+ // WHEN: Time advances by another 50ms
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: Second character is revealed
+ expect(result.current).toMatchObject({
+ text: "He",
+ isTyping: true,
+ isDone: false,
+ });
+ });
+
+ it("should complete with full text", () => {
+ // GIVEN: Hook initialized with short text
+ const { result } = renderHook(() =>
+ useTypingText({ text: "Hi", charsPerTick: 10, speed: 50 }),
+ );
+
+ // WHEN: Animation completes
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: Full text is displayed and animation is done
+ expect(result.current).toMatchObject({
+ text: "Hi",
+ isTyping: false,
+ isDone: true,
+ progress: 1,
+ });
+ });
+
+ it("should call onComplete when finished", () => {
+ // GIVEN: Hook with onComplete callback
+ const onComplete = vi.fn();
+ const { result } = renderHook(() =>
+ useTypingText({
+ text: "Hi",
+ charsPerTick: 10,
+ speed: 50,
+ onComplete,
+ }),
+ );
+
+ // WHEN: Animation completes
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: Callback is called and state is done
+ expect(result.current).toMatchObject({
+ text: "Hi",
+ isDone: true,
+ });
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("speed and timing configuration", () => {
+ it("should respect charsPerTick setting", () => {
+ // GIVEN: Hook with charsPerTick=5
+ const { result } = renderHook(() =>
+ useTypingText({ text: "Hello World", charsPerTick: 5, speed: 50 }),
+ );
+
+ // WHEN: One tick passes
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: 5 characters are revealed
+ expect(result.current).toMatchObject({
+ text: "Hello",
+ isTyping: true,
+ });
+
+ // WHEN: Another tick passes
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: 5 more characters are revealed
+ expect(result.current).toMatchObject({
+ text: "Hello Worl",
+ isTyping: true,
+ });
+
+ // WHEN: Final tick completes
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: Remaining character is revealed
+ expect(result.current).toMatchObject({
+ text: "Hello World",
+ isDone: true,
+ });
+ });
+
+ it("should use custom speed interval", () => {
+ // GIVEN: Hook with custom speed of 100ms
+ const { result } = renderHook(() =>
+ useTypingText({ text: "Hello", charsPerTick: 1, speed: 100 }),
+ );
+
+ // WHEN: Time advances by 50ms (less than speed)
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: No text revealed yet
+ expect(result.current).toMatchObject({
+ text: "",
+ isTyping: true,
+ });
+
+ // WHEN: Time advances to complete one interval (100ms total)
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: First character is revealed
+ expect(result.current).toMatchObject({
+ text: "H",
+ isTyping: true,
+ });
+
+ // WHEN: Another 100ms passes
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+
+ // THEN: Second character is revealed
+ expect(result.current).toMatchObject({
+ text: "He",
+ isTyping: true,
+ });
+ });
+ });
+
+ describe("edge cases", () => {
+ it("should handle empty text", () => {
+ // GIVEN: Hook initialized with empty text
+ const { result } = renderHook(() =>
+ useTypingText({ text: "", charsPerTick: 10, speed: 50 }),
+ );
+
+ // WHEN: Time advances
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: State shows empty and done
+ expect(result.current).toMatchObject({
+ text: "",
+ isTyping: false,
+ isDone: true,
+ progress: 0,
+ });
+ });
+
+ it("should handle text change mid-animation", () => {
+ // GIVEN: Hook initialized with initial text
+ const { result, rerender } = renderHook(
+ ({ text }) => useTypingText({ text, charsPerTick: 1, speed: 50 }),
+ { initialProps: { text: "Hello" } },
+ );
+
+ // WHEN: Animation starts
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: First character revealed
+ expect(result.current).toMatchObject({
+ text: "H",
+ isTyping: true,
+ });
+
+ // WHEN: Text changes mid-animation
+ act(() => {
+ rerender({ text: "World" });
+ });
+
+ // THEN: Animation restarts with new text
+ expect(result.current).toMatchObject({
+ text: "",
+ isTyping: true,
+ isDone: false,
+ });
+
+ // WHEN: Time advances with new text
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: First character of new text is revealed
+ expect(result.current).toMatchObject({
+ text: "W",
+ isTyping: true,
+ });
+ });
+
+ it("should cleanup interval on unmount", () => {
+ // GIVEN: Hook initialized with text
+ const { result, unmount } = renderHook(() =>
+ useTypingText({ text: "Hello World", charsPerTick: 1, speed: 50 }),
+ );
+
+ // WHEN: Animation starts
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ expect(result.current).toMatchObject({
+ text: "H",
+ isTyping: true,
+ });
+
+ // WHEN: Component unmounts
+ unmount();
+
+ // THEN: No errors occur and timers are cleaned up
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+
+ // Verify no timers are still running
+ expect(vi.getTimerCount()).toBe(0);
+ });
+ });
+
+ describe("progress calculation", () => {
+ it("should calculate progress correctly", () => {
+ // GIVEN: Hook with text "Hello" (5 chars)
+ const { result } = renderHook(() =>
+ useTypingText({ text: "Hello", charsPerTick: 1, speed: 50 }),
+ );
+
+ // WHEN: Initial state
+ // THEN: Progress is 0%
+ expect(result.current.progress).toBe(0);
+
+ // WHEN: 1 character revealed
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: Progress is 20% (1/5)
+ expect(result.current.progress).toBe(0.2);
+
+ // WHEN: 3 characters revealed (2 more ticks)
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+
+ // THEN: Progress is 60% (3/5)
+ expect(result.current.progress).toBe(0.6);
+
+ // WHEN: All characters revealed (2 more ticks)
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+
+ // THEN: Progress is 100% (5/5)
+ expect(result.current).toMatchObject({
+ text: "Hello",
+ progress: 1,
+ isDone: true,
+ });
+ });
+ });
+
+ describe("state management", () => {
+ it("should manage isTyping state correctly", () => {
+ // GIVEN: Hook initialized
+ const { result } = renderHook(() =>
+ useTypingText({ text: "Hi", charsPerTick: 10, speed: 50 }),
+ );
+
+ // THEN: Initially typing
+ expect(result.current.isTyping).toBe(true);
+
+ // WHEN: Animation completes
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ // THEN: No longer typing
+ expect(result.current).toMatchObject({
+ isTyping: false,
+ isDone: true,
+ });
+ });
+
+ it("should reset state when text changes", () => {
+ // GIVEN: Hook with completed animation
+ const { result, rerender } = renderHook(
+ ({ text }) => useTypingText({ text, charsPerTick: 10, speed: 50 }),
+ { initialProps: { text: "Hi" } },
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ expect(result.current).toMatchObject({
+ text: "Hi",
+ isDone: true,
+ isTyping: false,
+ });
+
+ // WHEN: Text changes
+ act(() => {
+ rerender({ text: "Hello" });
+ });
+
+ // THEN: State is reset
+ expect(result.current).toMatchObject({
+ text: "",
+ isDone: false,
+ isTyping: true,
+ progress: 0,
+ });
+ });
+ });
+});
diff --git a/src/hooks/useTypingText.ts b/src/hooks/useTypingText.ts
index a4557d7..cacd52d 100644
--- a/src/hooks/useTypingText.ts
+++ b/src/hooks/useTypingText.ts
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from "react";
+import { DEFAULT_TYPING_SPEED } from "@/lib/constants";
+
interface UseTypingTextOptions {
text: string;
speed?: number;
@@ -15,7 +17,7 @@ interface UseTypingTextOptions {
export const useTypingText = ({
text,
speed = 1,
- charsPerTick = 10,
+ charsPerTick = DEFAULT_TYPING_SPEED,
onComplete,
}: UseTypingTextOptions) => {
const [displayedText, setDisplayedText] = useState("");
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
new file mode 100644
index 0000000..ad0e786
--- /dev/null
+++ b/src/lib/constants.ts
@@ -0,0 +1,16 @@
+/**
+ * UI Layout Constants
+ */
+export const DEFAULT_INPUT_HEIGHT = 64; // pixels
+
+/**
+ * Chat Behavior Constants
+ */
+export const DEFAULT_SIMULATION_DELAY = 1500; // milliseconds
+export const DEFAULT_TYPING_SPEED = 10; // characters per tick
+export const USER_AVATAR_FALLBACK = "ME";
+
+/**
+ * Timing Constants
+ */
+export const TYPING_ANIMATION_INTERVAL = 50; // milliseconds
diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts
new file mode 100644
index 0000000..c7f8d0a
--- /dev/null
+++ b/src/test/mocks/handlers.ts
@@ -0,0 +1,18 @@
+import { http, HttpResponse } from "msw";
+
+export const handlers = [
+ // Mock webhook responses
+ http.post("*/webhook", () => {
+ return HttpResponse.json({
+ output: "This is a simulated response: Hello AI!",
+ });
+ }),
+
+ // Mock specific error scenarios if needed
+ http.post("*/webhook/error", () => {
+ return HttpResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }),
+];
diff --git a/src/test/mocks/server.ts b/src/test/mocks/server.ts
new file mode 100644
index 0000000..5510ef5
--- /dev/null
+++ b/src/test/mocks/server.ts
@@ -0,0 +1,5 @@
+import { setupServer } from "msw/node";
+import { handlers } from "./handlers";
+
+// Setup MSW server for Node.js (tests)
+export const server = setupServer(...handlers);
diff --git a/src/test/setup.ts b/src/test/setup.ts
index b49b4a9..1bdc7a1 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -1,10 +1,22 @@
import "@testing-library/jest-dom";
-import { vi } from "vitest";
+import { afterAll, afterEach, beforeAll, vi } from "vitest";
// Add any global test setup here
// Configure Testing Library to also check for data-test-id attribute
import { configure } from "@testing-library/react";
+// Setup MSW
+import { server } from "./mocks/server";
+
+// Start MSW server before all tests
+beforeAll(() => server.listen({ onUnhandledRequest: "warn" }));
+
+// Reset handlers after each test to avoid test interference
+afterEach(() => server.resetHandlers());
+
+// Clean up after all tests are done
+afterAll(() => server.close());
+
vi.mock("@/hooks/useTypingText", () => ({
useTypingText: vi.fn(({ text }) => {
return { text, isTyping: false };