mirror of
https://github.com/coleam00/Archon.git
synced 2026-01-06 14:48:00 -05:00
- 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:
176
archon-ui-main/test/components/ErrorBoundary.test.tsx
Normal file
176
archon-ui-main/test/components/ErrorBoundary.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
163
archon-ui-main/test/components/layouts/MainLayout.test.tsx
Normal file
163
archon-ui-main/test/components/layouts/MainLayout.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
288
archon-ui-main/test/components/project-tasks/TasksTab.test.tsx
Normal file
288
archon-ui-main/test/components/project-tasks/TasksTab.test.tsx
Normal 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 } : {})
|
||||
}))
|
||||
}
|
||||
195
archon-ui-main/test/services/socketIOService.test.ts
Normal file
195
archon-ui-main/test/services/socketIOService.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
|
||||
// Mock socket.io-client
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
connected: true,
|
||||
id: 'test-socket-id'
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('socketIOService - Shared Instance Pattern', () => {
|
||||
let socketIOService: any
|
||||
let knowledgeSocketIO: any
|
||||
let taskUpdateSocketIO: any
|
||||
let projectListSocketIO: any
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset all mocks
|
||||
vi.resetAllMocks()
|
||||
vi.resetModules()
|
||||
|
||||
// Import fresh instances
|
||||
const module = await import('../../src/services/socketIOService')
|
||||
socketIOService = module
|
||||
knowledgeSocketIO = module.knowledgeSocketIO
|
||||
taskUpdateSocketIO = module.taskUpdateSocketIO
|
||||
projectListSocketIO = module.projectListSocketIO
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('creates only a single shared socket instance', () => {
|
||||
// All exported instances should be the same object
|
||||
expect(knowledgeSocketIO).toBe(taskUpdateSocketIO)
|
||||
expect(taskUpdateSocketIO).toBe(projectListSocketIO)
|
||||
expect(knowledgeSocketIO).toBe(projectListSocketIO)
|
||||
})
|
||||
|
||||
test('socket.io is called only once despite multiple exports', () => {
|
||||
// The io function should only be called once to create the shared instance
|
||||
expect(io).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('all services share the same socket connection', () => {
|
||||
// Get the internal socket from each service
|
||||
const knowledgeSocket = knowledgeSocketIO.socket
|
||||
const taskSocket = taskUpdateSocketIO.socket
|
||||
const projectSocket = projectListSocketIO.socket
|
||||
|
||||
// All should reference the same socket instance
|
||||
expect(knowledgeSocket).toBe(taskSocket)
|
||||
expect(taskSocket).toBe(projectSocket)
|
||||
})
|
||||
|
||||
test('operations from different services use the same socket', () => {
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
// Subscribe to events from different service exports
|
||||
knowledgeSocketIO.on('knowledge_update', mockCallback)
|
||||
taskUpdateSocketIO.on('task_update', mockCallback)
|
||||
projectListSocketIO.on('project_update', mockCallback)
|
||||
|
||||
// All operations should use the same underlying socket
|
||||
const socket = knowledgeSocketIO.socket
|
||||
expect(socket.on).toHaveBeenCalledWith('knowledge_update', expect.any(Function))
|
||||
expect(socket.on).toHaveBeenCalledWith('task_update', expect.any(Function))
|
||||
expect(socket.on).toHaveBeenCalledWith('project_update', expect.any(Function))
|
||||
})
|
||||
|
||||
test('disconnecting one service disconnects all', () => {
|
||||
// Disconnect using one service
|
||||
knowledgeSocketIO.disconnect()
|
||||
|
||||
// Check that the shared socket was disconnected
|
||||
const socket = knowledgeSocketIO.socket
|
||||
expect(socket.disconnect).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Verify all services report as disconnected
|
||||
expect(knowledgeSocketIO.isConnected()).toBe(false)
|
||||
expect(taskUpdateSocketIO.isConnected()).toBe(false)
|
||||
expect(projectListSocketIO.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
test('operation tracking is shared across all service exports', () => {
|
||||
// Add operation from one service
|
||||
const operationId = 'test-op-123'
|
||||
knowledgeSocketIO.addOperation(operationId)
|
||||
|
||||
// Check if operation is tracked in all services
|
||||
expect(knowledgeSocketIO.isOwnOperation(operationId)).toBe(true)
|
||||
expect(taskUpdateSocketIO.isOwnOperation(operationId)).toBe(true)
|
||||
expect(projectListSocketIO.isOwnOperation(operationId)).toBe(true)
|
||||
})
|
||||
|
||||
test('removing operation from one service removes from all', () => {
|
||||
const operationId = 'test-op-456'
|
||||
|
||||
// Add operation
|
||||
taskUpdateSocketIO.addOperation(operationId)
|
||||
expect(knowledgeSocketIO.isOwnOperation(operationId)).toBe(true)
|
||||
|
||||
// Remove operation using different service
|
||||
projectListSocketIO.removeOperation(operationId)
|
||||
|
||||
// Verify removed from all
|
||||
expect(knowledgeSocketIO.isOwnOperation(operationId)).toBe(false)
|
||||
expect(taskUpdateSocketIO.isOwnOperation(operationId)).toBe(false)
|
||||
expect(projectListSocketIO.isOwnOperation(operationId)).toBe(false)
|
||||
})
|
||||
|
||||
test('echo suppression works across all service exports', () => {
|
||||
const operationId = 'echo-test-789'
|
||||
const callback = vi.fn()
|
||||
|
||||
// Subscribe to event
|
||||
knowledgeSocketIO.on('test_event', callback, true) // skipOwnOperations = true
|
||||
|
||||
// Add operation from different service export
|
||||
taskUpdateSocketIO.addOperation(operationId)
|
||||
|
||||
// Simulate event with operation ID
|
||||
const eventData = { operationId, data: 'test' }
|
||||
const handler = knowledgeSocketIO.socket.on.mock.calls[0][1]
|
||||
handler(eventData)
|
||||
|
||||
// Callback should not be called due to echo suppression
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
|
||||
// Simulate event without operation ID
|
||||
const externalEvent = { data: 'external' }
|
||||
handler(externalEvent)
|
||||
|
||||
// Callback should be called for external events
|
||||
expect(callback).toHaveBeenCalledWith(externalEvent)
|
||||
})
|
||||
|
||||
test('connection state is synchronized across all exports', () => {
|
||||
const mockSocket = knowledgeSocketIO.socket
|
||||
|
||||
// Simulate connected state
|
||||
mockSocket.connected = true
|
||||
expect(knowledgeSocketIO.isConnected()).toBe(true)
|
||||
expect(taskUpdateSocketIO.isConnected()).toBe(true)
|
||||
expect(projectListSocketIO.isConnected()).toBe(true)
|
||||
|
||||
// Simulate disconnected state
|
||||
mockSocket.connected = false
|
||||
expect(knowledgeSocketIO.isConnected()).toBe(false)
|
||||
expect(taskUpdateSocketIO.isConnected()).toBe(false)
|
||||
expect(projectListSocketIO.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
test('emitting from any service uses the shared socket', () => {
|
||||
const mockSocket = knowledgeSocketIO.socket
|
||||
|
||||
// Emit from different services
|
||||
knowledgeSocketIO.emit('event1', { data: 1 })
|
||||
taskUpdateSocketIO.emit('event2', { data: 2 })
|
||||
projectListSocketIO.emit('event3', { data: 3 })
|
||||
|
||||
// All should use the same socket
|
||||
expect(mockSocket.emit).toHaveBeenCalledTimes(3)
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('event1', { data: 1 }, undefined)
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('event2', { data: 2 }, undefined)
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('event3', { data: 3 }, undefined)
|
||||
})
|
||||
|
||||
test('prevents multiple socket connections when switching tabs', () => {
|
||||
// Simulate tab switching by importing the module multiple times
|
||||
// In a real scenario, this would happen when components unmount/remount
|
||||
|
||||
// First "tab"
|
||||
const socket1 = knowledgeSocketIO.socket
|
||||
|
||||
// Simulate switching tabs (in reality, components would remount)
|
||||
// But the shared instance pattern prevents new connections
|
||||
const socket2 = taskUpdateSocketIO.socket
|
||||
const socket3 = projectListSocketIO.socket
|
||||
|
||||
// All should be the same instance
|
||||
expect(socket1).toBe(socket2)
|
||||
expect(socket2).toBe(socket3)
|
||||
|
||||
// io should still only be called once
|
||||
expect(io).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
238
archon-ui-main/test/utils/operationTracker.test.ts
Normal file
238
archon-ui-main/test/utils/operationTracker.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest'
|
||||
import { OperationTracker } from '../../src/utils/operationTracker'
|
||||
|
||||
// Mock uuid
|
||||
vi.mock('uuid', () => ({
|
||||
v4: vi.fn(() => 'mock-uuid-123')
|
||||
}))
|
||||
|
||||
describe('OperationTracker', () => {
|
||||
let tracker: OperationTracker
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = new OperationTracker()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('generateOperationId', () => {
|
||||
test('generates unique operation IDs', () => {
|
||||
const id1 = tracker.generateOperationId()
|
||||
const id2 = tracker.generateOperationId()
|
||||
|
||||
expect(id1).toBe('mock-uuid-123')
|
||||
expect(id2).toBe('mock-uuid-123') // Same because mock always returns same value
|
||||
|
||||
// In real implementation, these would be different
|
||||
expect(id1).toBeTruthy()
|
||||
expect(id2).toBeTruthy()
|
||||
})
|
||||
|
||||
test('returns string IDs', () => {
|
||||
const id = tracker.generateOperationId()
|
||||
expect(typeof id).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('addOperation', () => {
|
||||
test('adds operation to tracking set', () => {
|
||||
const operationId = 'test-op-1'
|
||||
tracker.addOperation(operationId)
|
||||
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(true)
|
||||
})
|
||||
|
||||
test('handles multiple operations', () => {
|
||||
tracker.addOperation('op-1')
|
||||
tracker.addOperation('op-2')
|
||||
tracker.addOperation('op-3')
|
||||
|
||||
expect(tracker.isOwnOperation('op-1')).toBe(true)
|
||||
expect(tracker.isOwnOperation('op-2')).toBe(true)
|
||||
expect(tracker.isOwnOperation('op-3')).toBe(true)
|
||||
})
|
||||
|
||||
test('handles duplicate operations gracefully', () => {
|
||||
const operationId = 'duplicate-op'
|
||||
|
||||
tracker.addOperation(operationId)
|
||||
tracker.addOperation(operationId) // Add same ID again
|
||||
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeOperation', () => {
|
||||
test('removes operation from tracking', () => {
|
||||
const operationId = 'temp-op'
|
||||
|
||||
tracker.addOperation(operationId)
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(true)
|
||||
|
||||
tracker.removeOperation(operationId)
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(false)
|
||||
})
|
||||
|
||||
test('handles removing non-existent operation', () => {
|
||||
// Should not throw error
|
||||
expect(() => {
|
||||
tracker.removeOperation('non-existent')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('removes only specified operation', () => {
|
||||
tracker.addOperation('op-1')
|
||||
tracker.addOperation('op-2')
|
||||
tracker.addOperation('op-3')
|
||||
|
||||
tracker.removeOperation('op-2')
|
||||
|
||||
expect(tracker.isOwnOperation('op-1')).toBe(true)
|
||||
expect(tracker.isOwnOperation('op-2')).toBe(false)
|
||||
expect(tracker.isOwnOperation('op-3')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOwnOperation', () => {
|
||||
test('returns true for tracked operations', () => {
|
||||
const operationId = 'tracked-op'
|
||||
tracker.addOperation(operationId)
|
||||
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for untracked operations', () => {
|
||||
expect(tracker.isOwnOperation('untracked-op')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false after operation is removed', () => {
|
||||
const operationId = 'temp-op'
|
||||
|
||||
tracker.addOperation(operationId)
|
||||
tracker.removeOperation(operationId)
|
||||
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
test('removes all tracked operations', () => {
|
||||
tracker.addOperation('op-1')
|
||||
tracker.addOperation('op-2')
|
||||
tracker.addOperation('op-3')
|
||||
|
||||
tracker.clear()
|
||||
|
||||
expect(tracker.isOwnOperation('op-1')).toBe(false)
|
||||
expect(tracker.isOwnOperation('op-2')).toBe(false)
|
||||
expect(tracker.isOwnOperation('op-3')).toBe(false)
|
||||
})
|
||||
|
||||
test('works with empty tracker', () => {
|
||||
expect(() => tracker.clear()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('echo suppression scenarios', () => {
|
||||
test('prevents processing own operations', () => {
|
||||
const operationId = tracker.generateOperationId()
|
||||
tracker.addOperation(operationId)
|
||||
|
||||
// Simulate receiving an event with our operation ID
|
||||
const event = { operationId, data: 'some data' }
|
||||
|
||||
// Should identify as own operation (skip processing)
|
||||
if (tracker.isOwnOperation(event.operationId)) {
|
||||
// Skip processing
|
||||
expect(true).toBe(true) // Operation should be skipped
|
||||
} else {
|
||||
// Process event
|
||||
expect(false).toBe(true) // Should not reach here
|
||||
}
|
||||
})
|
||||
|
||||
test('allows processing external operations', () => {
|
||||
const externalOpId = 'external-op-123'
|
||||
|
||||
// Simulate receiving an event from another client
|
||||
const event = { operationId: externalOpId, data: 'external data' }
|
||||
|
||||
// Should not identify as own operation
|
||||
if (!tracker.isOwnOperation(event.operationId)) {
|
||||
// Process event
|
||||
expect(true).toBe(true) // Operation should be processed
|
||||
} else {
|
||||
// Skip processing
|
||||
expect(false).toBe(true) // Should not reach here
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup patterns', () => {
|
||||
test('supports operation cleanup after completion', () => {
|
||||
const operationId = tracker.generateOperationId()
|
||||
tracker.addOperation(operationId)
|
||||
|
||||
// Simulate operation completion
|
||||
setTimeout(() => {
|
||||
tracker.removeOperation(operationId)
|
||||
}, 100)
|
||||
|
||||
// Initially tracked
|
||||
expect(tracker.isOwnOperation(operationId)).toBe(true)
|
||||
|
||||
// After cleanup (would be false after timeout)
|
||||
// Note: In real tests, would use fake timers or promises
|
||||
})
|
||||
|
||||
test('handles batch cleanup', () => {
|
||||
const operations = ['op-1', 'op-2', 'op-3', 'op-4', 'op-5']
|
||||
|
||||
// Add all operations
|
||||
operations.forEach(op => tracker.addOperation(op))
|
||||
|
||||
// Remove specific operations
|
||||
tracker.removeOperation('op-2')
|
||||
tracker.removeOperation('op-4')
|
||||
|
||||
expect(tracker.isOwnOperation('op-1')).toBe(true)
|
||||
expect(tracker.isOwnOperation('op-2')).toBe(false)
|
||||
expect(tracker.isOwnOperation('op-3')).toBe(true)
|
||||
expect(tracker.isOwnOperation('op-4')).toBe(false)
|
||||
expect(tracker.isOwnOperation('op-5')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory management', () => {
|
||||
test('does not accumulate unlimited operations', () => {
|
||||
// Add many operations
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
tracker.addOperation(`op-${i}`)
|
||||
}
|
||||
|
||||
// Clear to prevent memory leaks
|
||||
tracker.clear()
|
||||
|
||||
// Verify all cleared
|
||||
expect(tracker.isOwnOperation('op-0')).toBe(false)
|
||||
expect(tracker.isOwnOperation('op-999')).toBe(false)
|
||||
})
|
||||
|
||||
test('supports operation TTL pattern', () => {
|
||||
// This test demonstrates a pattern for auto-cleanup
|
||||
const operationWithTTL = (id: string, ttlMs: number) => {
|
||||
tracker.addOperation(id)
|
||||
|
||||
setTimeout(() => {
|
||||
tracker.removeOperation(id)
|
||||
}, ttlMs)
|
||||
}
|
||||
|
||||
const opId = 'ttl-op'
|
||||
operationWithTTL(opId, 5000) // 5 second TTL
|
||||
|
||||
// Initially tracked
|
||||
expect(tracker.isOwnOperation(opId)).toBe(true)
|
||||
// Would be removed after TTL expires
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user