- Fix the threading service to properly handle rate limiting.

- Fix the clipboard functionality to work on non local hosts and https
- Improvements in sockets on front-end and backend. Storing session in local browser storage for reconnect. Logic to prevent socket echos coausing rerender and performance issues.
- Fixes and udpates to re-ordering logic in adding a new task, reordering items on the task table.
- Allowing assignee to not be hardcoded enum.
- Fix to Document Version Control (Improvements still needed in the Milkdown editor conversion to store in the docs.
- Adding types to remove [any] typescript issues.
This commit is contained in:
sean-eskerium
2025-08-20 02:28:02 -04:00
parent cd22089b87
commit 1b5196d70f
44 changed files with 3490 additions and 331 deletions

View File

@@ -0,0 +1,176 @@
import { render, screen } from '@testing-library/react'
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import React from 'react'
// Component that throws an error for testing
const ThrowError: React.FC<{ shouldThrow: boolean }> = ({ shouldThrow }) => {
if (shouldThrow) {
throw new Error('Test error message')
}
return <div>No error</div>
}
// Mock console.error to suppress error output in tests
const originalError = console.error
beforeEach(() => {
console.error = vi.fn()
})
afterEach(() => {
console.error = originalError
})
describe('ErrorBoundary Component', () => {
test('renders children when there is no error', () => {
render(
<ErrorBoundary>
<div>Test content</div>
</ErrorBoundary>
)
expect(screen.getByText('Test content')).toBeInTheDocument()
})
test('catches errors and displays fallback UI', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
// Should show error fallback
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument()
expect(screen.queryByText('No error')).not.toBeInTheDocument()
})
test('displays custom error fallback when provided', () => {
const CustomFallback = ({ error }: { error: Error }) => (
<div>Custom error: {error.message}</div>
)
render(
<ErrorBoundary errorFallback={CustomFallback}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
expect(screen.getByText('Custom error: Test error message')).toBeInTheDocument()
})
test('renders different UI for page-level errors', () => {
render(
<ErrorBoundary isPageLevel={true}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
// Page-level errors should have specific styling
const errorContainer = screen.getByText(/Something went wrong/i).closest('div')
expect(errorContainer?.className).toContain('min-h-screen')
})
test('renders different UI for component-level errors', () => {
render(
<ErrorBoundary isPageLevel={false}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
// Component-level errors should have different styling
const errorContainer = screen.getByText(/Something went wrong/i).closest('div')
expect(errorContainer?.className).not.toContain('min-h-screen')
expect(errorContainer?.className).toContain('rounded-lg')
})
test('passes error object to error fallback', () => {
const error = new Error('Specific error message')
const CustomFallback = ({ error: err }: { error: Error }) => (
<div>
<div>Error occurred</div>
<div>{err.message}</div>
</div>
)
render(
<ErrorBoundary errorFallback={CustomFallback}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
expect(screen.getByText('Error occurred')).toBeInTheDocument()
expect(screen.getByText('Test error message')).toBeInTheDocument()
})
test('handles multiple error boundaries at different levels', () => {
const OuterFallback = () => <div>Outer error</div>
const InnerFallback = () => <div>Inner error</div>
render(
<ErrorBoundary errorFallback={OuterFallback}>
<div>
<ErrorBoundary errorFallback={InnerFallback}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
</div>
</ErrorBoundary>
)
// Inner boundary should catch the error
expect(screen.getByText('Inner error')).toBeInTheDocument()
expect(screen.queryByText('Outer error')).not.toBeInTheDocument()
})
test('recovers when error condition is resolved', () => {
const { rerender } = render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
// Error is shown
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument()
// When component no longer throws, it should recover
rerender(
<ErrorBoundary>
<ThrowError shouldThrow={false} />
</ErrorBoundary>
)
// Note: React Error Boundaries don't automatically recover,
// so the error state persists. This is expected behavior.
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument()
})
test('logs errors to console in development', () => {
const consoleErrorSpy = vi.spyOn(console, 'error')
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
// Error should be logged
expect(consoleErrorSpy).toHaveBeenCalled()
})
test('renders with suspense wrapper when specified', () => {
// Testing SuspenseErrorBoundary variant
const LazyComponent = React.lazy(() =>
Promise.resolve({ default: () => <div>Lazy loaded</div> })
)
render(
<ErrorBoundary>
<React.Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</React.Suspense>
</ErrorBoundary>
)
// Should show loading initially
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,163 @@
import { render, screen } from '@testing-library/react'
import { describe, test, expect, vi } from 'vitest'
import { MainLayout } from '@/components/layouts/MainLayout'
import { BrowserRouter } from 'react-router-dom'
// Mock the child components
vi.mock('@/components/layouts/SideNavigation', () => ({
SideNavigation: () => <nav data-testid="side-navigation">Side Navigation</nav>
}))
vi.mock('@/components/DisconnectScreenOverlay', () => ({
DisconnectScreenOverlay: () => null // Usually hidden
}))
// Mock contexts
vi.mock('@/contexts/SettingsContext', () => ({
useSettings: () => ({
settings: {
enableProjects: true,
theme: 'dark'
},
updateSettings: vi.fn()
})
}))
describe('MainLayout Component', () => {
const renderWithRouter = (children: React.ReactNode) => {
return render(
<BrowserRouter>
{children}
</BrowserRouter>
)
}
test('renders children correctly', () => {
renderWithRouter(
<MainLayout>
<div>Page content</div>
</MainLayout>
)
expect(screen.getByText('Page content')).toBeInTheDocument()
})
test('renders side navigation', () => {
renderWithRouter(
<MainLayout>
<div>Content</div>
</MainLayout>
)
expect(screen.getByTestId('side-navigation')).toBeInTheDocument()
})
test('applies layout structure classes', () => {
const { container } = renderWithRouter(
<MainLayout>
<div>Content</div>
</MainLayout>
)
// Check for flex layout
const layoutContainer = container.querySelector('.flex')
expect(layoutContainer).toBeInTheDocument()
// Check for main content area
const mainContent = container.querySelector('main')
expect(mainContent).toBeInTheDocument()
expect(mainContent?.className).toContain('flex-1')
})
test('renders multiple children', () => {
renderWithRouter(
<MainLayout>
<div>First child</div>
<div>Second child</div>
<section>Third child</section>
</MainLayout>
)
expect(screen.getByText('First child')).toBeInTheDocument()
expect(screen.getByText('Second child')).toBeInTheDocument()
expect(screen.getByText('Third child')).toBeInTheDocument()
})
test('maintains responsive layout', () => {
const { container } = renderWithRouter(
<MainLayout>
<div>Responsive content</div>
</MainLayout>
)
const mainContent = container.querySelector('main')
expect(mainContent?.className).toContain('overflow-x-hidden')
expect(mainContent?.className).toContain('overflow-y-auto')
})
test('applies dark mode background classes', () => {
const { container } = renderWithRouter(
<MainLayout>
<div>Dark mode content</div>
</MainLayout>
)
const layoutContainer = container.firstChild as HTMLElement
expect(layoutContainer.className).toContain('bg-gray-50')
expect(layoutContainer.className).toContain('dark:bg-black')
})
test('renders empty children gracefully', () => {
const { container } = renderWithRouter(
<MainLayout>
{null}
{undefined}
{false}
</MainLayout>
)
// Should still render the layout structure
expect(container.querySelector('.flex')).toBeInTheDocument()
expect(screen.getByTestId('side-navigation')).toBeInTheDocument()
})
test('handles complex nested components', () => {
renderWithRouter(
<MainLayout>
<div className="page-container">
<header>
<h1>Page Title</h1>
</header>
<section>
<article>
<p>Article content</p>
</article>
</section>
</div>
</MainLayout>
)
expect(screen.getByText('Page Title')).toBeInTheDocument()
expect(screen.getByText('Article content')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument()
})
test('preserves child component props', () => {
renderWithRouter(
<MainLayout>
<div
id="test-id"
className="custom-class"
data-testid="custom-content"
>
Custom content
</div>
</MainLayout>
)
const customDiv = screen.getByTestId('custom-content')
expect(customDiv).toHaveAttribute('id', 'test-id')
expect(customDiv).toHaveClass('custom-class')
expect(customDiv).toHaveTextContent('Custom content')
})
})

View File

@@ -0,0 +1,288 @@
import { describe, test, expect, vi, beforeEach } from 'vitest'
// Mock data for testing
const mockTasks = [
{
id: 'task-1',
title: 'First task',
description: 'Description 1',
status: 'todo',
assignee: 'User',
task_order: 1,
feature: 'feature-1'
},
{
id: 'task-2',
title: 'Second task',
description: 'Description 2',
status: 'todo',
assignee: 'AI IDE Agent',
task_order: 2,
feature: 'feature-1'
},
{
id: 'task-3',
title: 'Third task',
description: 'Description 3',
status: 'todo',
assignee: 'Archon',
task_order: 3,
feature: 'feature-2'
},
{
id: 'task-4',
title: 'Fourth task',
description: 'Description 4',
status: 'doing',
assignee: 'User',
task_order: 1,
feature: 'feature-2'
}
]
describe('TasksTab - Task Reordering', () => {
let reorderTasks: any
let handleReorderTasks: any
beforeEach(() => {
vi.resetModules()
})
describe('Sequential Ordering System', () => {
test('maintains sequential order (1, 2, 3, ...) after reordering', () => {
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
// Move task from index 0 to index 2
const reordered = moveTask(tasks, 0, 2)
// Check that task_order is sequential
expect(reordered[0].task_order).toBe(1)
expect(reordered[1].task_order).toBe(2)
expect(reordered[2].task_order).toBe(3)
})
test('updates task_order for all affected tasks', () => {
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
// Move last task to first position
const reordered = moveTask(tasks, 2, 0)
expect(reordered[0].id).toBe('task-3')
expect(reordered[0].task_order).toBe(1)
expect(reordered[1].id).toBe('task-1')
expect(reordered[1].task_order).toBe(2)
expect(reordered[2].id).toBe('task-2')
expect(reordered[2].task_order).toBe(3)
})
test('handles moving task within same status column', () => {
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
// Move middle task to end
const reordered = moveTask(tasks, 1, 2)
expect(reordered[0].id).toBe('task-1')
expect(reordered[1].id).toBe('task-3')
expect(reordered[2].id).toBe('task-2')
// All should have sequential ordering
reordered.forEach((task, index) => {
expect(task.task_order).toBe(index + 1)
})
})
})
describe('Batch Reorder Persistence', () => {
test('batches multiple reorder operations', () => {
const persistBatch = vi.fn()
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
// Simulate multiple rapid reorders
const reordered1 = moveTask(tasks, 0, 2)
const reordered2 = moveTask(reordered1, 1, 0)
// In actual implementation, these would be debounced
// and sent as a single batch update
expect(reordered2[0].task_order).toBe(1)
expect(reordered2[1].task_order).toBe(2)
expect(reordered2[2].task_order).toBe(3)
})
test('preserves lastUpdate timestamp for optimistic updates', () => {
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
const timestamp = Date.now()
const reordered = moveTask(tasks, 0, 2, timestamp)
// All reordered tasks should have the lastUpdate timestamp
reordered.forEach(task => {
expect(task.lastUpdate).toBe(timestamp)
})
})
})
describe('Race Condition Prevention', () => {
test('ignores updates for deleted tasks', () => {
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
const deletedTaskId = 'task-2'
// Remove task-2 to simulate deletion
const afterDeletion = tasks.filter(t => t.id !== deletedTaskId)
// Try to reorder with deleted task - should handle gracefully
const reordered = afterDeletion.map((task, index) => ({
...task,
task_order: index + 1
}))
expect(reordered.length).toBe(2)
expect(reordered.find(t => t.id === deletedTaskId)).toBeUndefined()
})
test('handles concurrent updates with temporary task replacement', () => {
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
const tempTask = { ...tasks[0], title: 'Temporary update' }
// Replace task temporarily (optimistic update)
const withTemp = tasks.map(t =>
t.id === tempTask.id ? tempTask : t
)
expect(withTemp[0].title).toBe('Temporary update')
expect(withTemp[0].id).toBe(tasks[0].id)
})
test('maintains order consistency during concurrent operations', () => {
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
// Simulate two concurrent reorder operations
const reorder1 = moveTask([...tasks], 0, 2)
const reorder2 = moveTask([...tasks], 2, 1)
// Both should maintain sequential ordering
reorder1.forEach((task, index) => {
expect(task.task_order).toBe(index + 1)
})
reorder2.forEach((task, index) => {
expect(task.task_order).toBe(index + 1)
})
})
})
describe('Cross-Status Reordering', () => {
test('handles moving task to different status column', () => {
const todoTasks = mockTasks.filter(t => t.status === 'todo')
const doingTasks = mockTasks.filter(t => t.status === 'doing')
// Move first todo task to doing column
const taskToMove = todoTasks[0]
const updatedTask = { ...taskToMove, status: 'doing' }
// Update todo column (remove task)
const newTodoTasks = todoTasks.slice(1).map((task, index) => ({
...task,
task_order: index + 1
}))
// Update doing column (add task at position)
const newDoingTasks = [
updatedTask,
...doingTasks
].map((task, index) => ({
...task,
task_order: index + 1
}))
// Verify sequential ordering in both columns
expect(newTodoTasks.every((t, i) => t.task_order === i + 1)).toBe(true)
expect(newDoingTasks.every((t, i) => t.task_order === i + 1)).toBe(true)
})
})
describe('Edge Cases', () => {
test('handles empty task list', () => {
const tasks: any[] = []
const reordered = moveTask(tasks, 0, 0)
expect(reordered).toEqual([])
})
test('handles single task', () => {
const tasks = [mockTasks[0]]
const reordered = moveTask(tasks, 0, 0)
expect(reordered[0].task_order).toBe(1)
expect(reordered.length).toBe(1)
})
test('handles invalid indices gracefully', () => {
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
// Try to move with out-of-bounds index
const reordered = moveTask(tasks, 10, 0)
// Should return tasks unchanged
expect(reordered).toEqual(tasks)
})
test('preserves task data during reorder', () => {
const tasks = [...mockTasks.filter(t => t.status === 'todo')]
const originalTask = { ...tasks[0] }
const reordered = moveTask(tasks, 0, 2)
const movedTask = reordered.find(t => t.id === originalTask.id)
// All properties except task_order should be preserved
expect(movedTask?.title).toBe(originalTask.title)
expect(movedTask?.description).toBe(originalTask.description)
expect(movedTask?.assignee).toBe(originalTask.assignee)
expect(movedTask?.feature).toBe(originalTask.feature)
})
})
describe('Flexible Assignee Support', () => {
test('supports any assignee name string', () => {
const customAssignees = [
'prp-executor',
'prp-validator',
'Custom Agent',
'test-agent-123'
]
customAssignees.forEach(assignee => {
const task = { ...mockTasks[0], assignee }
expect(task.assignee).toBe(assignee)
expect(typeof task.assignee).toBe('string')
})
})
test('handles empty assignee gracefully', () => {
const task = { ...mockTasks[0], assignee: '' }
expect(task.assignee).toBe('')
// Should default to 'AI IDE Agent' in UI
const displayAssignee = task.assignee || 'AI IDE Agent'
expect(displayAssignee).toBe('AI IDE Agent')
})
})
})
// Helper function to simulate task reordering
function moveTask(tasks: any[], fromIndex: number, toIndex: number, timestamp?: number): any[] {
if (fromIndex < 0 || fromIndex >= tasks.length ||
toIndex < 0 || toIndex >= tasks.length) {
return tasks
}
const result = [...tasks]
const [movedTask] = result.splice(fromIndex, 1)
result.splice(toIndex, 0, movedTask)
// Update task_order to be sequential
return result.map((task, index) => ({
...task,
task_order: index + 1,
...(timestamp ? { lastUpdate: timestamp } : {})
}))
}