feat(tests): implement frontend integration tests

This commit is contained in:
perfectra1n
2025-07-30 19:39:28 -07:00
parent 32983c3fba
commit 8968f023d2
25 changed files with 1419 additions and 1593 deletions

View File

@@ -0,0 +1,29 @@
---
name: code-reviewer
description: Expert code review specialist. Proactively reviews code for quality, security, and maintainability. Use immediately after writing or modifying code.
tools: Read, Grep, Glob, Bash
---
You are a senior code reviewer ensuring high standards of code quality and security.
When invoked:
1. Run git diff to see recent changes
2. Focus on modified files
3. Begin review immediately
Review checklist:
- Code is simple and readable
- Functions and variables are well-named
- No duplicated code
- Proper error handling
- No exposed secrets or API keys
- Input validation implemented
- Good test coverage
- Performance considerations addressed
Provide feedback organized by priority:
- Critical issues (must fix)
- Warnings (should fix)
- Suggestions (consider improving)
Include specific examples of how to fix issues.

View File

@@ -169,3 +169,27 @@ jobs:
kill $(cat readur.pid) || true
rm readur.pid
fi
frontend-integration-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm install
- name: Run frontend integration tests
run: npm run test:integration

View File

@@ -94,5 +94,5 @@ jobs:
- name: Run type checking
run: npm run type-check
- name: Run unit tests
run: npm test -- --run
- name: Run frontend unit tests
run: npm run test:unit

View File

@@ -8,6 +8,10 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:unit": "vitest --run --config vitest.unit.config.ts",
"test:integration": "vitest --run --config vitest.integration.config.ts",
"test:unit:watch": "vitest --config vitest.unit.config.ts",
"test:integration:watch": "vitest --config vitest.integration.config.ts",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { Chip, IconButton, Tooltip } from '@mui/material';
import { Refresh as RefreshIcon } from '@mui/icons-material';
import { ConnectionStatus } from '../../services/syncProgress';
interface ConnectionStatusIndicatorProps {
connectionStatus: ConnectionStatus;
isActive?: boolean;
onReconnect?: () => void;
}
export const ConnectionStatusIndicator: React.FC<ConnectionStatusIndicatorProps> = ({
connectionStatus,
isActive = false,
onReconnect
}) => {
const getStatusConfig = () => {
switch (connectionStatus) {
case 'connecting':
return { label: 'Connecting...', color: 'warning' as const };
case 'reconnecting':
return { label: 'Reconnecting...', color: 'warning' as const };
case 'connected':
return isActive
? { label: 'Live', color: 'success' as const }
: { label: 'Connected', color: 'info' as const };
case 'disconnected':
case 'error':
return { label: 'Disconnected', color: 'error' as const };
case 'failed':
return { label: 'Connection Failed', color: 'error' as const };
default:
return { label: 'Unknown', color: 'default' as const };
}
};
const { label, color } = getStatusConfig();
const showReconnect = (connectionStatus === 'failed' || connectionStatus === 'error') && onReconnect;
return (
<>
<Chip size="small" label={label} color={color} />
{showReconnect && (
<Tooltip title="Reconnect">
<IconButton onClick={onReconnect} size="small" color="primary">
<RefreshIcon />
</IconButton>
</Tooltip>
)}
</>
);
};
export default ConnectionStatusIndicator;

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { Box, Typography, LinearProgress, Chip, useTheme, alpha } from '@mui/material';
import { Warning as WarningIcon, Error as ErrorIcon, Timer as TimerIcon } from '@mui/icons-material';
import { SyncProgressInfo } from '../../services/api';
interface ProgressStatisticsProps {
progressInfo: SyncProgressInfo;
phaseColor: string;
}
export const ProgressStatistics: React.FC<ProgressStatisticsProps> = ({
progressInfo,
phaseColor
}) => {
const theme = useTheme();
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const formatDuration = (seconds: number): string => {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
};
return (
<>
{/* Progress Bar */}
{progressInfo.files_found > 0 && (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="body2" color="text.secondary">
Files Progress
</Typography>
<Typography variant="body2" color="text.secondary">
{progressInfo.files_processed} / {progressInfo.files_found} files ({progressInfo.files_progress_percent.toFixed(1)}%)
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progressInfo.files_progress_percent}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: alpha(phaseColor, 0.2),
'& .MuiLinearProgress-bar': {
backgroundColor: phaseColor,
},
}}
/>
</Box>
)}
{/* Statistics Grid */}
<Box display="grid" gridTemplateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={2}>
<Box>
<Typography variant="body2" color="text.secondary">
Directories
</Typography>
<Typography variant="h6">
{progressInfo.directories_processed} / {progressInfo.directories_found}
</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Data Processed
</Typography>
<Typography variant="h6">
{formatBytes(progressInfo.bytes_processed)}
</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Processing Rate
</Typography>
<Typography variant="h6">
{progressInfo.processing_rate_files_per_sec.toFixed(1)} files/sec
</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Elapsed Time
</Typography>
<Typography variant="h6">
{formatDuration(progressInfo.elapsed_time_secs)}
</Typography>
</Box>
</Box>
{/* Estimated Time Remaining */}
{progressInfo.estimated_time_remaining_secs && progressInfo.estimated_time_remaining_secs > 0 && (
<Box display="flex" alignItems="center" gap={1}>
<TimerIcon color="action" />
<Typography variant="body2" color="text.secondary">
Estimated time remaining: {formatDuration(progressInfo.estimated_time_remaining_secs)}
</Typography>
</Box>
)}
{/* Current Operations */}
{progressInfo.current_directory && (
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Current Directory
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
{progressInfo.current_directory}
</Typography>
{progressInfo.current_file && (
<>
<Typography variant="body2" color="text.secondary" gutterBottom sx={{ mt: 1 }}>
Current File
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
{progressInfo.current_file}
</Typography>
</>
)}
</Box>
)}
{/* Errors and Warnings */}
{(progressInfo.errors > 0 || progressInfo.warnings > 0) && (
<Box display="flex" gap={2}>
{progressInfo.errors > 0 && (
<Chip
icon={<ErrorIcon />}
label={`${progressInfo.errors} error${progressInfo.errors !== 1 ? 's' : ''}`}
color="error"
size="small"
/>
)}
{progressInfo.warnings > 0 && (
<Chip
icon={<WarningIcon />}
label={`${progressInfo.warnings} warning${progressInfo.warnings !== 1 ? 's' : ''}`}
color="warning"
size="small"
/>
)}
</Box>
)}
</>
);
};
export default ProgressStatistics;

View File

@@ -0,0 +1,193 @@
import React, { useState, useCallback } from 'react';
import {
Box,
Typography,
Collapse,
IconButton,
Tooltip,
useTheme,
alpha,
Card,
CardContent,
Stack,
Alert,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
Speed as SpeedIcon,
Folder as FolderIcon,
TextSnippet as FileIcon,
Storage as StorageIcon,
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
} from '@mui/icons-material';
import { SyncProgressInfo } from '../../services/api';
import { useSyncProgress } from '../../hooks/useSyncProgress';
import { ConnectionStatus } from '../../services/syncProgress';
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
import { ProgressStatistics } from './ProgressStatistics';
import { SyncProgressManager } from '../../services/syncProgress';
interface SyncProgressDisplayProps {
sourceId: string;
sourceName: string;
isVisible: boolean;
onClose?: () => void;
manager?: SyncProgressManager;
}
export const SyncProgressDisplay: React.FC<SyncProgressDisplayProps> = ({
sourceId,
sourceName,
isVisible,
onClose,
manager,
}) => {
const theme = useTheme();
const [isExpanded, setIsExpanded] = useState(true);
// Handle WebSocket connection errors
const handleWebSocketError = useCallback((error: Error) => {
console.error('WebSocket connection error in SyncProgressDisplay:', error);
}, []);
// Handle connection status changes
const handleConnectionStatusChange = useCallback((status: ConnectionStatus) => {
console.log(`Connection status changed to: ${status}`);
}, []);
// Use the sync progress hook
const {
progressInfo,
connectionStatus,
isConnected,
reconnect,
disconnect,
} = useSyncProgress({
sourceId,
enabled: isVisible && !!sourceId,
onError: handleWebSocketError,
onConnectionStatusChange: handleConnectionStatusChange,
manager,
});
const getPhaseColor = (phase: string) => {
switch (phase) {
case 'initializing':
case 'evaluating':
return theme.palette.info.main;
case 'discovering_directories':
case 'discovering_files':
return theme.palette.warning.main;
case 'processing_files':
return theme.palette.primary.main;
case 'saving_metadata':
return theme.palette.secondary.main;
case 'completed':
return theme.palette.success.main;
case 'failed':
return theme.palette.error.main;
default:
return theme.palette.grey[500];
}
};
const getPhaseIcon = (phase: string) => {
switch (phase) {
case 'discovering_directories':
return <FolderIcon />;
case 'discovering_files':
case 'processing_files':
return <FileIcon />;
case 'saving_metadata':
return <StorageIcon />;
case 'completed':
return <CheckCircleIcon />;
case 'failed':
return <ErrorIcon />;
default:
return <SpeedIcon />;
}
};
if (!isVisible || (!progressInfo && connectionStatus === 'disconnected' && !isConnected)) {
return null;
}
const phaseColor = progressInfo ? getPhaseColor(progressInfo.phase) : theme.palette.grey[500];
return (
<Card
sx={{
mb: 2,
border: progressInfo?.is_active ? `2px solid ${phaseColor}` : '1px solid',
borderColor: progressInfo?.is_active ? phaseColor : theme.palette.divider,
backgroundColor: progressInfo?.is_active
? alpha(phaseColor, 0.05)
: theme.palette.background.paper,
}}
>
<CardContent sx={{ pb: isExpanded ? 2 : '16px !important' }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={isExpanded ? 2 : 0}>
<Box display="flex" alignItems="center" gap={2}>
{progressInfo && (
<Box
sx={{
color: phaseColor,
display: 'flex',
alignItems: 'center',
}}
>
{getPhaseIcon(progressInfo.phase)}
</Box>
)}
<Box>
<Typography variant="h6" component="div">
{sourceName} - Sync Progress
</Typography>
{progressInfo && (
<Typography variant="body2" color="text.secondary">
{progressInfo.phase_description}
</Typography>
)}
</Box>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<ConnectionStatusIndicator
connectionStatus={connectionStatus}
isActive={progressInfo?.is_active}
onReconnect={reconnect}
/>
<Tooltip title={isExpanded ? "Collapse" : "Expand"}>
<IconButton
onClick={() => setIsExpanded(!isExpanded)}
size="small"
>
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Tooltip>
</Box>
</Box>
<Collapse in={isExpanded}>
{progressInfo ? (
<Stack spacing={2}>
<ProgressStatistics
progressInfo={progressInfo}
phaseColor={phaseColor}
/>
</Stack>
) : (
<Alert severity="info">
Waiting for sync progress information...
</Alert>
)}
</Collapse>
</CardContent>
</Card>
);
};
export default SyncProgressDisplay;

View File

@@ -0,0 +1,80 @@
import { describe, test, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ConnectionStatusIndicator } from '../ConnectionStatusIndicator';
describe('ConnectionStatusIndicator', () => {
test('should display connecting status', () => {
render(<ConnectionStatusIndicator connectionStatus="connecting" />);
expect(screen.getByText('Connecting...')).toBeInTheDocument();
});
test('should display reconnecting status', () => {
render(<ConnectionStatusIndicator connectionStatus="reconnecting" />);
expect(screen.getByText('Reconnecting...')).toBeInTheDocument();
});
test('should display connected status when not active', () => {
render(<ConnectionStatusIndicator connectionStatus="connected" isActive={false} />);
expect(screen.getByText('Connected')).toBeInTheDocument();
});
test('should display live status when active', () => {
render(<ConnectionStatusIndicator connectionStatus="connected" isActive={true} />);
expect(screen.getByText('Live')).toBeInTheDocument();
});
test('should display disconnected status', () => {
render(<ConnectionStatusIndicator connectionStatus="disconnected" />);
expect(screen.getByText('Disconnected')).toBeInTheDocument();
});
test('should display error status', () => {
render(<ConnectionStatusIndicator connectionStatus="error" />);
expect(screen.getByText('Disconnected')).toBeInTheDocument();
});
test('should display failed status', () => {
render(<ConnectionStatusIndicator connectionStatus="failed" />);
expect(screen.getByText('Connection Failed')).toBeInTheDocument();
});
test('should show reconnect button on failure', () => {
const onReconnect = vi.fn();
render(
<ConnectionStatusIndicator
connectionStatus="failed"
onReconnect={onReconnect}
/>
);
const reconnectButton = screen.getByRole('button', { name: /reconnect/i });
expect(reconnectButton).toBeInTheDocument();
fireEvent.click(reconnectButton);
expect(onReconnect).toHaveBeenCalled();
});
test('should show reconnect button on error', () => {
const onReconnect = vi.fn();
render(
<ConnectionStatusIndicator
connectionStatus="error"
onReconnect={onReconnect}
/>
);
expect(screen.getByRole('button', { name: /reconnect/i })).toBeInTheDocument();
});
test('should not show reconnect button when connected', () => {
const onReconnect = vi.fn();
render(
<ConnectionStatusIndicator
connectionStatus="connected"
onReconnect={onReconnect}
/>
);
expect(screen.queryByRole('button', { name: /reconnect/i })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,149 @@
import { describe, test, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ProgressStatistics } from '../ProgressStatistics';
import { SyncProgressInfo } from '../../../services/api';
const createMockProgressInfo = (overrides: Partial<SyncProgressInfo> = {}): SyncProgressInfo => ({
source_id: 'test-source-123',
phase: 'processing_files',
phase_description: 'Processing files',
elapsed_time_secs: 120,
directories_found: 10,
directories_processed: 7,
files_found: 50,
files_processed: 30,
bytes_processed: 1024000,
processing_rate_files_per_sec: 2.5,
files_progress_percent: 60.0,
estimated_time_remaining_secs: 80,
current_directory: '/Documents/Projects',
current_file: 'important-document.pdf',
errors: 0,
warnings: 0,
is_active: true,
...overrides,
});
describe('ProgressStatistics', () => {
test('should display file progress', () => {
const progressInfo = createMockProgressInfo();
render(<ProgressStatistics progressInfo={progressInfo} phaseColor="#1976d2" />);
expect(screen.getByText('30 / 50 files (60.0%)')).toBeInTheDocument();
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '60');
});
test('should not show progress bar when no files found', () => {
const progressInfo = createMockProgressInfo({ files_found: 0 });
render(<ProgressStatistics progressInfo={progressInfo} phaseColor="#1976d2" />);
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
test('should display directory statistics', () => {
const progressInfo = createMockProgressInfo();
render(<ProgressStatistics progressInfo={progressInfo} phaseColor="#1976d2" />);
expect(screen.getByText('7 / 10')).toBeInTheDocument();
});
test('should format bytes correctly', () => {
const testCases = [
{ bytes: 0, expected: '0 B' },
{ bytes: 1024, expected: '1 KB' },
{ bytes: 1048576, expected: '1 MB' },
{ bytes: 1073741824, expected: '1 GB' },
];
testCases.forEach(({ bytes, expected }) => {
const { rerender } = render(
<ProgressStatistics
progressInfo={createMockProgressInfo({ bytes_processed: bytes })}
phaseColor="#1976d2"
/>
);
expect(screen.getByText(expected)).toBeInTheDocument();
rerender(<div />); // Clear for next test
});
});
test('should display processing rate', () => {
const progressInfo = createMockProgressInfo({ processing_rate_files_per_sec: 3.7 });
render(<ProgressStatistics progressInfo={progressInfo} phaseColor="#1976d2" />);
expect(screen.getByText('3.7 files/sec')).toBeInTheDocument();
});
test('should format duration correctly', () => {
const testCases = [
{ seconds: 45, expected: '45s' },
{ seconds: 90, expected: '1m 30s' },
{ seconds: 3661, expected: '1h 1m' },
];
testCases.forEach(({ seconds, expected }) => {
const { rerender } = render(
<ProgressStatistics
progressInfo={createMockProgressInfo({ elapsed_time_secs: seconds })}
phaseColor="#1976d2"
/>
);
expect(screen.getByText(expected)).toBeInTheDocument();
rerender(<div />); // Clear for next test
});
});
test('should display estimated time remaining', () => {
const progressInfo = createMockProgressInfo({ estimated_time_remaining_secs: 150 });
render(<ProgressStatistics progressInfo={progressInfo} phaseColor="#1976d2" />);
expect(screen.getByText(/Estimated time remaining: 2m 30s/)).toBeInTheDocument();
});
test('should not show estimated time when unavailable', () => {
const progressInfo = createMockProgressInfo({ estimated_time_remaining_secs: undefined });
render(<ProgressStatistics progressInfo={progressInfo} phaseColor="#1976d2" />);
expect(screen.queryByText(/Estimated time remaining/)).not.toBeInTheDocument();
});
test('should display current directory and file', () => {
const progressInfo = createMockProgressInfo();
render(<ProgressStatistics progressInfo={progressInfo} phaseColor="#1976d2" />);
expect(screen.getByText('/Documents/Projects')).toBeInTheDocument();
expect(screen.getByText('important-document.pdf')).toBeInTheDocument();
});
test('should not show current file when unavailable', () => {
const progressInfo = createMockProgressInfo({ current_file: undefined });
render(<ProgressStatistics progressInfo={progressInfo} phaseColor="#1976d2" />);
expect(screen.getByText('/Documents/Projects')).toBeInTheDocument();
expect(screen.queryByText('Current File')).not.toBeInTheDocument();
});
test('should display errors and warnings', () => {
const progressInfo = createMockProgressInfo({ errors: 2, warnings: 5 });
render(<ProgressStatistics progressInfo={progressInfo} phaseColor="#1976d2" />);
expect(screen.getByText('2 errors')).toBeInTheDocument();
expect(screen.getByText('5 warnings')).toBeInTheDocument();
});
test('should handle singular error/warning labels', () => {
const progressInfo = createMockProgressInfo({ errors: 1, warnings: 1 });
render(<ProgressStatistics progressInfo={progressInfo} phaseColor="#1976d2" />);
expect(screen.getByText('1 error')).toBeInTheDocument();
expect(screen.getByText('1 warning')).toBeInTheDocument();
});
test('should not show errors/warnings when zero', () => {
const progressInfo = createMockProgressInfo({ errors: 0, warnings: 0 });
render(<ProgressStatistics progressInfo={progressInfo} phaseColor="#1976d2" />);
expect(screen.queryByText(/error/)).not.toBeInTheDocument();
expect(screen.queryByText(/warning/)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,164 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen, waitFor, act } from '@testing-library/react';
import { renderWithProviders } from '../../../test/test-utils';
import { SyncProgressDisplay } from '../SyncProgressDisplay';
import { MockSyncProgressManager } from '../../../services/syncProgress';
import { SyncProgressInfo } from '../../../services/api';
// Integration tests using MockSyncProgressManager
// These tests verify the component works with the actual hook and service layer
let mockManager: MockSyncProgressManager;
// Don't mock useSyncProgress - instead inject MockSyncProgressManager
const createMockProgressInfo = (overrides: Partial<SyncProgressInfo> = {}): SyncProgressInfo => ({
source_id: 'test-source-123',
phase: 'processing_files',
phase_description: 'Processing files',
elapsed_time_secs: 120,
directories_found: 10,
directories_processed: 7,
files_found: 50,
files_processed: 30,
bytes_processed: 1024000,
processing_rate_files_per_sec: 2.5,
files_progress_percent: 60.0,
estimated_time_remaining_secs: 80,
current_directory: '/Documents/Projects',
current_file: 'document.pdf',
errors: 0,
warnings: 0,
is_active: true,
...overrides,
});
describe('SyncProgressDisplay - Integration Tests', () => {
beforeEach(() => {
mockManager = new MockSyncProgressManager();
vi.clearAllMocks();
});
afterEach(() => {
mockManager.destroy();
});
test('should handle full sync progress lifecycle', async () => {
renderWithProviders(
<SyncProgressDisplay
sourceId="test-source-123"
sourceName="Test Source"
isVisible={true}
manager={mockManager}
/>
);
// Should start with connecting
await waitFor(() => {
expect(screen.getByText('Connecting...')).toBeInTheDocument();
});
// Should show connected
await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
// Simulate progress update
const mockProgress = createMockProgressInfo();
await act(async () => {
mockManager.simulateProgress(mockProgress);
});
// Should show live status and progress
await waitFor(() => {
expect(screen.getByText('Live')).toBeInTheDocument();
expect(screen.getByText('Processing files')).toBeInTheDocument();
});
// Simulate heartbeat ending sync
await act(async () => {
mockManager.simulateHeartbeat({
source_id: 'test-source-123',
is_active: false,
timestamp: Date.now(),
});
});
// Should clear progress info
await waitFor(() => {
expect(screen.getByText('Waiting for sync progress information...')).toBeInTheDocument();
});
});
test('should handle connection errors and recovery', async () => {
renderWithProviders(
<SyncProgressDisplay
sourceId="test-source-123"
sourceName="Test Source"
isVisible={true}
manager={mockManager}
/>
);
// Wait for connection
await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
// Simulate connection failure
await act(async () => {
mockManager.simulateConnectionStatus('failed');
});
await waitFor(() => {
expect(screen.getByText('Connection Failed')).toBeInTheDocument();
});
// Should show reconnect option
expect(screen.getByRole('button', { name: /reconnect/i })).toBeInTheDocument();
});
test('should handle visibility changes', async () => {
const { rerender } = renderWithProviders(
<SyncProgressDisplay
sourceId="test-source-123"
sourceName="Test Source"
isVisible={true}
manager={mockManager}
/>
);
// Wait for connection
await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
// Hide component
rerender(
<SyncProgressDisplay
sourceId="test-source-123"
sourceName="Test Source"
isVisible={false}
manager={mockManager}
/>
);
// Should not be visible
expect(screen.queryByText('Test Source - Sync Progress')).not.toBeInTheDocument();
// Show again
rerender(
<SyncProgressDisplay
sourceId="test-source-123"
sourceName="Test Source"
isVisible={true}
manager={mockManager}
/>
);
// Should reconnect
await waitFor(() => {
expect(screen.getByText('Connecting...')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,4 @@
export { SyncProgressDisplay } from './SyncProgressDisplay';
export { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
export { ProgressStatistics } from './ProgressStatistics';
export default SyncProgressDisplay;

View File

@@ -1,347 +0,0 @@
import React, { useState, useCallback } from 'react';
import {
Box,
Typography,
LinearProgress,
Chip,
Collapse,
IconButton,
Tooltip,
useTheme,
alpha,
Fade,
Card,
CardContent,
Stack,
Alert,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
Speed as SpeedIcon,
Folder as FolderIcon,
TextSnippet as FileIcon,
Storage as StorageIcon,
Warning as WarningIcon,
Error as ErrorIcon,
CheckCircle as CheckCircleIcon,
Timer as TimerIcon,
Sync as SyncIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import { SyncProgressInfo } from '../services/api';
import { formatDistanceToNow } from 'date-fns';
import { useSyncProgressWebSocket, ConnectionStatus } from '../hooks/useSyncProgressWebSocket';
interface SyncProgressDisplayProps {
sourceId: string;
sourceName: string;
isVisible: boolean;
onClose?: () => void;
}
export const SyncProgressDisplay: React.FC<SyncProgressDisplayProps> = ({
sourceId,
sourceName,
isVisible,
onClose,
}) => {
const theme = useTheme();
const [isExpanded, setIsExpanded] = useState(true);
// Handle WebSocket connection errors
const handleWebSocketError = useCallback((error: any) => {
console.error('WebSocket connection error in SyncProgressDisplay:', error);
}, []);
// Handle connection status changes
const handleConnectionStatusChange = useCallback((status: ConnectionStatus) => {
console.log(`Connection status changed to: ${status}`);
}, []);
// Use the WebSocket hook for sync progress updates
const {
progressInfo,
connectionStatus,
isConnected,
reconnect,
disconnect,
} = useSyncProgressWebSocket({
sourceId,
enabled: isVisible && !!sourceId,
onError: handleWebSocketError,
onConnectionStatusChange: handleConnectionStatusChange,
});
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const formatDuration = (seconds: number): string => {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
};
const getPhaseColor = (phase: string) => {
switch (phase) {
case 'initializing':
case 'evaluating':
return theme.palette.info.main;
case 'discovering_directories':
case 'discovering_files':
return theme.palette.warning.main;
case 'processing_files':
return theme.palette.primary.main;
case 'saving_metadata':
return theme.palette.secondary.main;
case 'completed':
return theme.palette.success.main;
case 'failed':
return theme.palette.error.main;
default:
return theme.palette.grey[500];
}
};
const getPhaseIcon = (phase: string) => {
switch (phase) {
case 'discovering_directories':
return <FolderIcon />;
case 'discovering_files':
case 'processing_files':
return <FileIcon />;
case 'saving_metadata':
return <StorageIcon />;
case 'completed':
return <CheckCircleIcon />;
case 'failed':
return <ErrorIcon />;
default:
return <SpeedIcon />;
}
};
if (!isVisible || (!progressInfo && connectionStatus === 'disconnected' && !isConnected)) {
return null;
}
return (
<Card
sx={{
mb: 2,
border: progressInfo?.is_active ? `2px solid ${getPhaseColor(progressInfo.phase)}` : '1px solid',
borderColor: progressInfo?.is_active ? getPhaseColor(progressInfo.phase) : theme.palette.divider,
backgroundColor: progressInfo?.is_active
? alpha(getPhaseColor(progressInfo.phase), 0.05)
: theme.palette.background.paper,
}}
>
<CardContent sx={{ pb: isExpanded ? 2 : '16px !important' }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={isExpanded ? 2 : 0}>
<Box display="flex" alignItems="center" gap={2}>
{progressInfo && (
<Box
sx={{
color: getPhaseColor(progressInfo.phase),
display: 'flex',
alignItems: 'center',
}}
>
{getPhaseIcon(progressInfo.phase)}
</Box>
)}
<Box>
<Typography variant="h6" component="div">
{sourceName} - Sync Progress
</Typography>
{progressInfo && (
<Typography variant="body2" color="text.secondary">
{progressInfo.phase_description}
</Typography>
)}
</Box>
</Box>
<Box display="flex" alignItems="center" gap={1}>
{connectionStatus === 'connecting' && (
<Chip size="small" label="Connecting..." color="warning" />
)}
{connectionStatus === 'reconnecting' && (
<Chip size="small" label="Reconnecting..." color="warning" />
)}
{connectionStatus === 'connected' && progressInfo?.is_active && (
<Chip size="small" label="Live" color="success" />
)}
{connectionStatus === 'connected' && !progressInfo?.is_active && (
<Chip size="small" label="Connected" color="info" />
)}
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
<Chip size="small" label="Disconnected" color="error" />
)}
{connectionStatus === 'failed' && (
<Chip size="small" label="Connection Failed" color="error" />
)}
{/* Add manual reconnect button for failed connections */}
{(connectionStatus === 'failed' || connectionStatus === 'error') && (
<Tooltip title="Reconnect">
<IconButton
onClick={reconnect}
size="small"
color="primary"
>
<RefreshIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title={isExpanded ? "Collapse" : "Expand"}>
<IconButton
onClick={() => setIsExpanded(!isExpanded)}
size="small"
>
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Tooltip>
</Box>
</Box>
<Collapse in={isExpanded}>
{progressInfo ? (
<Stack spacing={2}>
{/* Progress Bar */}
{progressInfo.files_found > 0 && (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="body2" color="text.secondary">
Files Progress
</Typography>
<Typography variant="body2" color="text.secondary">
{progressInfo.files_processed} / {progressInfo.files_found} files ({progressInfo.files_progress_percent.toFixed(1)}%)
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progressInfo.files_progress_percent}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: alpha(getPhaseColor(progressInfo.phase), 0.2),
'& .MuiLinearProgress-bar': {
backgroundColor: getPhaseColor(progressInfo.phase),
},
}}
/>
</Box>
)}
{/* Statistics Grid */}
<Box display="grid" gridTemplateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={2}>
<Box>
<Typography variant="body2" color="text.secondary">
Directories
</Typography>
<Typography variant="h6">
{progressInfo.directories_processed} / {progressInfo.directories_found}
</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Data Processed
</Typography>
<Typography variant="h6">
{formatBytes(progressInfo.bytes_processed)}
</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Processing Rate
</Typography>
<Typography variant="h6">
{progressInfo.processing_rate_files_per_sec.toFixed(1)} files/sec
</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Elapsed Time
</Typography>
<Typography variant="h6">
{formatDuration(progressInfo.elapsed_time_secs)}
</Typography>
</Box>
</Box>
{/* Estimated Time Remaining */}
{progressInfo.estimated_time_remaining_secs && progressInfo.estimated_time_remaining_secs > 0 && (
<Box display="flex" alignItems="center" gap={1}>
<TimerIcon color="action" />
<Typography variant="body2" color="text.secondary">
Estimated time remaining: {formatDuration(progressInfo.estimated_time_remaining_secs)}
</Typography>
</Box>
)}
{/* Current Operations */}
{progressInfo.current_directory && (
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Current Directory
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
{progressInfo.current_directory}
</Typography>
{progressInfo.current_file && (
<>
<Typography variant="body2" color="text.secondary" gutterBottom sx={{ mt: 1 }}>
Current File
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
{progressInfo.current_file}
</Typography>
</>
)}
</Box>
)}
{/* Errors and Warnings */}
{(progressInfo.errors > 0 || progressInfo.warnings > 0) && (
<Box display="flex" gap={2}>
{progressInfo.errors > 0 && (
<Chip
icon={<ErrorIcon />}
label={`${progressInfo.errors} error${progressInfo.errors !== 1 ? 's' : ''}`}
color="error"
size="small"
/>
)}
{progressInfo.warnings > 0 && (
<Chip
icon={<WarningIcon />}
label={`${progressInfo.warnings} warning${progressInfo.warnings !== 1 ? 's' : ''}`}
color="warning"
size="small"
/>
)}
</Box>
)}
</Stack>
) : (
<Alert severity="info">
Waiting for sync progress information...
</Alert>
)}
</Collapse>
</CardContent>
</Card>
);
};
export default SyncProgressDisplay;

View File

@@ -1,118 +0,0 @@
import { describe, test, expect, vi, beforeAll } from 'vitest';
// Mock the API service before importing the component
beforeAll(() => {
// Mock WebSocket globally
global.WebSocket = vi.fn().mockImplementation(() => ({
close: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
send: vi.fn(),
onopen: null,
onmessage: null,
onerror: null,
onclose: null,
readyState: 0,
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3,
}));
// Mock localStorage for token access
Object.defineProperty(global, 'localStorage', {
value: {
getItem: vi.fn(() => 'mock-jwt-token'),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
},
writable: true,
});
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
origin: 'http://localhost:3000',
href: 'http://localhost:3000',
protocol: 'http:',
host: 'localhost:3000',
},
writable: true,
});
});
// Mock WebSocket class for SyncProgressDisplay
class MockSyncProgressWebSocket {
constructor(private sourceId: string) {}
connect(): Promise<void> {
return Promise.resolve();
}
addEventListener(eventType: string, callback: (data: any) => void): void {}
removeEventListener(eventType: string, callback: (data: any) => void): void {}
close(): void {}
getReadyState(): number { return 1; }
isConnected(): boolean { return true; }
}
// Mock the services/api module
vi.mock('../../services/api', () => ({
sourcesService: {
createSyncProgressWebSocket: vi.fn().mockImplementation((sourceId: string) =>
new MockSyncProgressWebSocket(sourceId)
),
},
SyncProgressInfo: {},
}));
// Simple compilation and type safety test for SyncProgressDisplay
describe('SyncProgressDisplay Compilation Tests', () => {
test('should import and compile correctly', async () => {
// Test that the component can be imported without runtime errors
const component = await import('../SyncProgressDisplay');
expect(component.SyncProgressDisplay).toBeDefined();
expect(component.default).toBeDefined();
}, 10000); // Increase timeout to 10 seconds
test('should accept correct prop types', () => {
// Test TypeScript compilation by defining expected props
interface ExpectedProps {
sourceId: string;
sourceName: string;
isVisible: boolean;
onClose?: () => void;
}
const validProps: ExpectedProps = {
sourceId: 'test-123',
sourceName: 'Test Source',
isVisible: true,
onClose: () => console.log('closed'),
};
// If this compiles, the types are correct
expect(validProps.sourceId).toBe('test-123');
expect(validProps.sourceName).toBe('Test Source');
expect(validProps.isVisible).toBe(true);
expect(typeof validProps.onClose).toBe('function');
});
test('should handle minimal required props', () => {
interface MinimalProps {
sourceId: string;
sourceName: string;
isVisible: boolean;
}
const minimalProps: MinimalProps = {
sourceId: 'minimal-test',
sourceName: 'Minimal Test Source',
isVisible: false,
};
expect(minimalProps.sourceId).toBe('minimal-test');
expect(minimalProps.isVisible).toBe(false);
});
});

View File

@@ -1,740 +0,0 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen, fireEvent, waitFor, act } from '@testing-library/react';
// Create a mock for the hook FIRST, before any component imports
const mockUseSyncProgressWebSocket = vi.fn();
vi.mock('../../hooks/useSyncProgressWebSocket', () => ({
useSyncProgressWebSocket: mockUseSyncProgressWebSocket,
}));
// Use the automatic mocking with __mocks__ directory
vi.mock('../../services/api');
import SyncProgressDisplay from '../SyncProgressDisplay';
import { renderWithProviders } from '../../test/test-utils';
// Define SyncProgressInfo type locally for tests
interface SyncProgressInfo {
source_id: string;
phase: string;
phase_description: string;
elapsed_time_secs: number;
directories_found: number;
directories_processed: number;
files_found: number;
files_processed: number;
bytes_processed: number;
processing_rate_files_per_sec: number;
files_progress_percent: number;
estimated_time_remaining_secs?: number;
current_directory: string;
current_file?: string;
errors: number;
warnings: number;
is_active: boolean;
}
// Use the automatic mocking with __mocks__ directory
vi.mock('../../services/api');
// Import mock helpers directly from the mock file
import { getMockSyncProgressWebSocket, resetMockSyncProgressWebSocket, MockSyncProgressWebSocket } from '../../services/__mocks__/api';
// Import the mocked services
import { sourcesService } from '../../services/api';
// Define ConnectionStatus type locally
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error' | 'failed';
// Create a mock for the hook
const mockUseSyncProgressWebSocket = vi.fn();
vi.mock('../../hooks/useSyncProgressWebSocket', () => ({
useSyncProgressWebSocket: mockUseSyncProgressWebSocket,
ConnectionStatus: {} as any,
}));
// Create mock progress data factory
const createMockProgressInfo = (overrides: Partial<SyncProgressInfo> = {}): SyncProgressInfo => ({
source_id: 'test-source-123',
phase: 'processing_files',
phase_description: 'Downloading and processing files',
elapsed_time_secs: 120,
directories_found: 10,
directories_processed: 7,
files_found: 50,
files_processed: 30,
bytes_processed: 1024000,
processing_rate_files_per_sec: 2.5,
files_progress_percent: 60.0,
estimated_time_remaining_secs: 80,
current_directory: '/Documents/Projects',
current_file: 'important-document.pdf',
errors: 0,
warnings: 1,
is_active: true,
...overrides,
});
// Helper functions to simulate hook state changes
let currentMockState = {
progressInfo: null as SyncProgressInfo | null,
connectionStatus: 'connecting' as ConnectionStatus,
isConnected: false,
reconnect: vi.fn(),
disconnect: vi.fn(),
};
const mockHookState = (overrides: Partial<typeof currentMockState>) => {
currentMockState = {
...currentMockState,
...overrides,
};
mockUseSyncProgressWebSocket.mockReturnValue(currentMockState);
};
// Helper functions to simulate WebSocket events that update the hook state
const simulateProgressUpdate = (progressInfo: SyncProgressInfo) => {
mockHookState({
progressInfo,
connectionStatus: 'connected',
isConnected: true
});
// Also trigger the mock WebSocket's progress event for completeness
const mockWS = getMockSyncProgressWebSocket();
if (mockWS) {
mockWS.simulateProgress(progressInfo);
}
};
const simulateConnectionStatusChange = (status: ConnectionStatus) => {
mockHookState({
connectionStatus: status,
isConnected: status === 'connected'
});
// Also trigger the mock WebSocket's connection status event
const mockWS = getMockSyncProgressWebSocket();
if (mockWS) {
mockWS.simulateConnectionStatus(status);
}
};
const simulateHeartbeatUpdate = (heartbeatData: { source_id: string; is_active: boolean; timestamp: number }) => {
// If heartbeat indicates sync is not active, clear progress info
if (!heartbeatData.is_active) {
mockHookState({ progressInfo: null });
}
// Also trigger the mock WebSocket's heartbeat event
const mockWS = getMockSyncProgressWebSocket();
if (mockWS) {
mockWS.simulateHeartbeat(heartbeatData);
}
};
const renderComponent = (props: Partial<React.ComponentProps<typeof SyncProgressDisplay>> = {}) => {
const defaultProps = {
sourceId: 'test-source-123',
sourceName: 'Test WebDAV Source',
isVisible: true,
...props,
};
return renderWithProviders(<SyncProgressDisplay {...defaultProps} />);
};
describe('SyncProgressDisplay Component', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset the mock WebSocket instance
resetMockSyncProgressWebSocket();
// Initialize the mock hook with default state
currentMockState = {
progressInfo: null,
connectionStatus: 'disconnected',
isConnected: false,
reconnect: vi.fn(),
disconnect: vi.fn(),
};
mockUseSyncProgressWebSocket.mockReturnValue(currentMockState);
// Mock localStorage for token access
Object.defineProperty(global, 'localStorage', {
value: {
getItem: vi.fn(() => 'mock-jwt-token'),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
},
writable: true,
});
// Mock window.location for consistent URL construction
Object.defineProperty(window, 'location', {
value: {
origin: 'http://localhost:3000',
href: 'http://localhost:3000',
protocol: 'http:',
host: 'localhost:3000',
},
writable: true,
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Visibility and Rendering', () => {
test('should not render when isVisible is false', () => {
renderComponent({ isVisible: false });
expect(screen.queryByText((content, element) => {
return element?.textContent === 'Test WebDAV Source - Sync Progress';
})).not.toBeInTheDocument();
});
test('should render when isVisible is true', async () => {
// Start with connecting status so component will be visible
mockHookState({ connectionStatus: 'connecting', isConnected: false });
renderComponent({ isVisible: true });
// Component should be visible immediately with connecting status
await waitFor(() => {
expect(screen.getByText(/Test WebDAV Source/)).toBeInTheDocument();
expect(screen.getByText(/Sync Progress/)).toBeInTheDocument();
});
});
test('should show connecting status initially', async () => {
// Set connecting status for this test
mockHookState({ connectionStatus: 'connecting', isConnected: false });
renderComponent();
// Wait for the component to be visible and show connecting status
await waitFor(() => {
expect(screen.getByText('Connecting...')).toBeInTheDocument();
});
});
test('should render with custom source name', async () => {
// Set connecting status so component will be visible
mockHookState({ connectionStatus: 'connecting', isConnected: false });
renderComponent({ sourceName: 'My Custom Source' });
// Wait for the component to be visible with custom source name
await waitFor(() => {
expect(screen.getByText(/My Custom Source/)).toBeInTheDocument();
expect(screen.getByText(/Sync Progress/)).toBeInTheDocument();
});
});
});
describe('WebSocket Connection Management', () => {
test('should create WebSocket connection when visible', async () => {
renderComponent();
// Verify that the WebSocket service was called
await waitFor(() => {
expect(sourcesService.createSyncProgressWebSocket).toHaveBeenCalledWith('test-source-123');
});
});
test('should handle successful connection', async () => {
renderComponent();
// Simulate successful connection
await waitFor(() => {
simulateConnectionStatusChange('connected');
});
// Should show connected status when there's progress data
const mockProgress = createMockProgressInfo();
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('Live')).toBeInTheDocument();
});
});
test('should handle connection error', async () => {
renderComponent();
await waitFor(() => {
simulateConnectionStatusChange('error');
});
await waitFor(() => {
expect(screen.getByText('Disconnected')).toBeInTheDocument();
});
});
test('should show reconnecting status', async () => {
renderComponent();
await waitFor(() => {
simulateConnectionStatusChange('reconnecting');
});
await waitFor(() => {
expect(screen.getByText('Reconnecting...')).toBeInTheDocument();
});
});
test('should show connection failed status', async () => {
renderComponent();
await waitFor(() => {
simulateConnectionStatusChange('failed');
});
await waitFor(() => {
expect(screen.getByText('Connection Failed')).toBeInTheDocument();
});
});
test('should close WebSocket connection on unmount', () => {
const { unmount } = renderComponent();
// The WebSocket should be closed when component unmounts
// This is handled by the useSyncProgressWebSocket hook cleanup
unmount();
// Since we're using a custom hook, we can't directly test the WebSocket close
// but we can verify the component unmounts without errors
expect(screen.queryByText('Test WebDAV Source - Sync Progress')).not.toBeInTheDocument();
});
test('should handle visibility changes correctly', () => {
const { rerender } = renderComponent({ isVisible: true });
// Component should be visible initially
expect(screen.getByText('Test WebDAV Source - Sync Progress')).toBeInTheDocument();
// Hide the component
rerender(
<SyncProgressDisplay
sourceId="test-source-123"
sourceName="Test WebDAV Source"
isVisible={false}
/>
);
// Component should not be visible
expect(screen.queryByText('Test WebDAV Source - Sync Progress')).not.toBeInTheDocument();
});
});
describe('Progress Data Display', () => {
test('should display progress information correctly', async () => {
renderComponent();
const mockProgress = createMockProgressInfo();
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('Downloading and processing files')).toBeInTheDocument();
expect(screen.getByText('30 / 50 files (60.0%)')).toBeInTheDocument();
expect(screen.getByText('7 / 10')).toBeInTheDocument(); // Directories
expect(screen.getByText('1000 KB')).toBeInTheDocument(); // Bytes processed
expect(screen.getByText('2.5 files/sec')).toBeInTheDocument(); // Processing rate
expect(screen.getByText('2m 0s')).toBeInTheDocument(); // Elapsed time
});
});
test('should display current directory and file', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
current_directory: '/Documents/Important',
current_file: 'presentation.pptx',
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('/Documents/Important')).toBeInTheDocument();
expect(screen.getByText('presentation.pptx')).toBeInTheDocument();
});
});
test('should display estimated time remaining', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
estimated_time_remaining_secs: 300, // 5 minutes
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText(/Estimated time remaining: 5m 0s/)).toBeInTheDocument();
});
});
test('should display errors and warnings', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
errors: 3,
warnings: 5,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('3 errors')).toBeInTheDocument();
expect(screen.getByText('5 warnings')).toBeInTheDocument();
});
});
test('should handle singular error/warning labels', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
errors: 1,
warnings: 1,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('1 error')).toBeInTheDocument();
expect(screen.getByText('1 warning')).toBeInTheDocument();
});
});
test('should not show errors/warnings when count is zero', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
errors: 0,
warnings: 0,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.queryByText(/error/)).not.toBeInTheDocument();
expect(screen.queryByText(/warning/)).not.toBeInTheDocument();
});
});
test('should not show estimated time when not available', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
estimated_time_remaining_secs: undefined,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.queryByText(/Estimated time remaining/)).not.toBeInTheDocument();
});
});
});
describe('Phase Indicators and Colors', () => {
const testPhases = [
{ phase: 'initializing', color: 'info', description: 'Initializing sync operation' },
{ phase: 'discovering_files', color: 'warning', description: 'Discovering files to sync' },
{ phase: 'processing_files', color: 'primary', description: 'Downloading and processing files' },
{ phase: 'completed', color: 'success', description: 'Sync completed successfully' },
{ phase: 'failed', color: 'error', description: 'Sync failed: Connection timeout' },
];
testPhases.forEach(({ phase, description }) => {
test(`should display correct phase description for ${phase}`, async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
phase,
phase_description: description,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText(description)).toBeInTheDocument();
});
});
});
});
describe('Progress Bar', () => {
test('should show progress bar when files are found', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
files_found: 100,
files_processed: 75,
files_progress_percent: 75.0,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
const progressBar = screen.getByRole('progressbar');
expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveAttribute('aria-valuenow', '75');
});
});
test('should not show progress bar when no files found', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
files_found: 0,
files_processed: 0,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});
});
describe('Expand/Collapse Functionality', () => {
test('should be expanded by default', () => {
renderComponent();
expect(screen.getByText('Waiting for sync progress information...')).toBeInTheDocument();
});
test('should collapse when collapse button is clicked', async () => {
renderComponent();
const collapseButton = screen.getByLabelText('Collapse');
fireEvent.click(collapseButton);
await waitFor(() => {
// After clicking collapse, the button should change to expand
expect(screen.getByLabelText('Expand')).toBeInTheDocument();
// The content is still in DOM but hidden by Material-UI Collapse
const collapseElement = screen.getByText('Waiting for sync progress information...').closest('.MuiCollapse-root');
expect(collapseElement).toHaveClass('MuiCollapse-hidden');
});
});
test('should expand when expand button is clicked', async () => {
renderComponent();
// First collapse
const collapseButton = screen.getByLabelText('Collapse');
fireEvent.click(collapseButton);
await waitFor(() => {
expect(screen.getByLabelText('Expand')).toBeInTheDocument();
const collapseElement = screen.getByText('Waiting for sync progress information...').closest('.MuiCollapse-root');
expect(collapseElement).toHaveClass('MuiCollapse-hidden');
});
// Then expand
const expandButton = screen.getByLabelText('Expand');
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByLabelText('Collapse')).toBeInTheDocument();
const collapseElement = screen.getByText('Waiting for sync progress information...').closest('.MuiCollapse-root');
expect(collapseElement).toHaveClass('MuiCollapse-entered');
});
});
});
describe('Data Formatting', () => {
test('should format bytes correctly', async () => {
renderComponent();
// Test 1.0 KB case
const mockProgress1KB = createMockProgressInfo({ bytes_processed: 1024 });
simulateProgressUpdate(mockProgress1KB);
await waitFor(() => {
expect(screen.getByText('1 KB')).toBeInTheDocument();
});
});
test('should format zero bytes correctly', async () => {
renderComponent();
// Test 0 B case
const mockProgress0 = createMockProgressInfo({ bytes_processed: 0 });
simulateProgressUpdate(mockProgress0);
await waitFor(() => {
expect(screen.getByText('0 B')).toBeInTheDocument();
});
});
test('should format duration correctly', async () => {
renderComponent();
const testCases = [
{ seconds: 30, expected: '30s' },
{ seconds: 90, expected: '1m 30s' },
{ seconds: 3661, expected: '1h 1m' },
];
for (const { seconds, expected } of testCases) {
const mockProgress = createMockProgressInfo({ elapsed_time_secs: seconds });
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText(expected)).toBeInTheDocument();
});
}
});
});
describe('Heartbeat Handling', () => {
test('should clear progress info on inactive heartbeat', async () => {
renderComponent();
// First set some progress
const mockProgress = createMockProgressInfo();
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('Downloading and processing files')).toBeInTheDocument();
});
// Then send inactive heartbeat using WebSocket simulation
simulateHeartbeatUpdate({
source_id: 'test-source-123',
is_active: false,
timestamp: Date.now()
});
await waitFor(() => {
expect(screen.getByText('Waiting for sync progress information...')).toBeInTheDocument();
});
});
});
describe('Error Handling', () => {
test('should handle WebSocket connection errors gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
renderComponent();
// Wait for WebSocket to be created
await waitFor(() => {
expect(sourcesService.createSyncProgressWebSocket).toHaveBeenCalledWith('test-source-123');
});
// Simulate WebSocket error
const mockWS = getMockSyncProgressWebSocket();
if (mockWS) {
act(() => {
mockWS.simulateError({ error: 'Connection failed' });
});
// Verify error was logged by the component's error handler
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('WebSocket connection error in SyncProgressDisplay:', { error: 'Connection failed' });
});
}
consoleSpy.mockRestore();
});
test('should show manual reconnect option after connection failure', async () => {
renderComponent();
// Simulate connection failure
simulateConnectionStatusChange('failed');
await waitFor(() => {
expect(screen.getByText('Connection Failed')).toBeInTheDocument();
// Should show reconnect button
expect(screen.getByRole('button', { name: /reconnect/i })).toBeInTheDocument();
});
});
test('should trigger reconnect when reconnect button is clicked', async () => {
renderComponent();
// Simulate connection failure
simulateConnectionStatusChange('failed');
await waitFor(() => {
const reconnectButton = screen.getByRole('button', { name: /reconnect/i });
expect(reconnectButton).toBeInTheDocument();
// Click the reconnect button
fireEvent.click(reconnectButton);
});
// The reconnect function should be called (indirectly through the hook)
// We can verify this by checking that the WebSocket service is called again
expect(sourcesService.createSyncProgressWebSocket).toHaveBeenCalledWith('test-source-123');
});
});
describe('Edge Cases', () => {
test('should handle missing current_file gracefully', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
current_directory: '/Documents',
current_file: undefined,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('/Documents')).toBeInTheDocument();
expect(screen.queryByText('Current File')).not.toBeInTheDocument();
});
});
test('should handle zero processing rate', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
processing_rate_files_per_sec: 0.0,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('0.0 files/sec')).toBeInTheDocument();
});
});
test('should handle very large numbers', async () => {
renderComponent();
const mockProgress = createMockProgressInfo({
bytes_processed: 1099511627776, // 1 TB
files_found: 999999,
files_processed: 500000,
});
simulateProgressUpdate(mockProgress);
await waitFor(() => {
expect(screen.getByText('1 TB')).toBeInTheDocument();
// Check for the large file numbers - they might be split across multiple elements
expect(screen.getByText(/500000/)).toBeInTheDocument();
expect(screen.getByText(/999999/)).toBeInTheDocument();
});
});
});
describe('Accessibility', () => {
test('should have proper ARIA labels', () => {
renderComponent();
expect(screen.getByLabelText('Collapse')).toBeInTheDocument();
});
test('should have accessible progress bar', async () => {
renderComponent();
const mockProgress = createMockProgressInfo();
simulateProgressUpdate(mockProgress);
await waitFor(() => {
const progressBar = screen.getByRole('progressbar');
expect(progressBar).toHaveAttribute('aria-valuenow', '60');
expect(progressBar).toHaveAttribute('aria-valuemin', '0');
expect(progressBar).toHaveAttribute('aria-valuemax', '100');
});
});
});
});

View File

@@ -16,7 +16,7 @@ interface AuthContextType {
logout: () => void
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)

View File

@@ -0,0 +1,136 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { SyncProgressManager, SyncProgressState, ConnectionStatus, WebSocketSyncProgressManager } from '../services/syncProgress';
import { SyncProgressInfo } from '../services/api';
export interface UseSyncProgressOptions {
sourceId: string;
enabled?: boolean;
onError?: (error: Error) => void;
onConnectionStatusChange?: (status: ConnectionStatus) => void;
// Allow injecting a custom manager for testing
manager?: SyncProgressManager;
}
export interface UseSyncProgressReturn {
progressInfo: SyncProgressInfo | null;
connectionStatus: ConnectionStatus;
isConnected: boolean;
reconnect: () => void;
disconnect: () => void;
}
/**
* React hook for managing sync progress state
* Uses the SyncProgressManager abstraction for clean separation of concerns
*/
export const useSyncProgress = ({
sourceId,
enabled = true,
onError,
onConnectionStatusChange,
manager: injectedManager
}: UseSyncProgressOptions): UseSyncProgressReturn => {
const [state, setState] = useState<SyncProgressState>({
progressInfo: null,
connectionStatus: 'disconnected',
lastUpdate: Date.now()
});
const managerRef = useRef<SyncProgressManager | null>(null);
const mountedRef = useRef(true);
// Create or use injected manager
useEffect(() => {
if (!managerRef.current) {
managerRef.current = injectedManager || new WebSocketSyncProgressManager();
}
return () => {
if (!injectedManager && managerRef.current) {
managerRef.current.destroy();
managerRef.current = null;
}
};
}, [injectedManager]);
// Set up event listeners
useEffect(() => {
const manager = managerRef.current;
if (!manager) return;
const handleStateChange = (newState: SyncProgressState) => {
if (mountedRef.current) {
setState(newState);
}
};
const handleConnectionStatusChange = (status: ConnectionStatus) => {
if (mountedRef.current) {
onConnectionStatusChange?.(status);
}
};
const handleError = (error: Error) => {
if (mountedRef.current) {
onError?.(error);
}
};
manager.on('stateChange', handleStateChange);
manager.on('connectionStatusChange', handleConnectionStatusChange);
manager.on('error', handleError);
// Set initial state
setState(manager.getState());
return () => {
manager.off('stateChange', handleStateChange);
manager.off('connectionStatusChange', handleConnectionStatusChange);
manager.off('error', handleError);
};
}, [onConnectionStatusChange, onError]);
// Handle connection lifecycle
useEffect(() => {
const manager = managerRef.current;
if (!manager) return;
if (enabled && sourceId) {
manager.connect(sourceId).catch(error => {
console.error('Failed to connect to sync progress:', error);
});
} else {
manager.disconnect();
}
return () => {
manager.disconnect();
};
}, [enabled, sourceId]);
// Track mounted state
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// Callbacks
const reconnect = useCallback(() => {
managerRef.current?.reconnect();
}, []);
const disconnect = useCallback(() => {
managerRef.current?.disconnect();
}, []);
return {
progressInfo: state.progressInfo,
connectionStatus: state.connectionStatus,
isConnected: state.connectionStatus === 'connected',
reconnect,
disconnect
};
};
export default useSyncProgress;

View File

@@ -79,7 +79,7 @@ import { useNavigate } from 'react-router-dom';
import api, { queueService, sourcesService, ErrorHelper, ErrorCodes } from '../services/api';
import { formatDistanceToNow } from 'date-fns';
import { useAuth } from '../contexts/AuthContext';
import SyncProgressDisplay from '../components/SyncProgressDisplay';
import SyncProgressDisplay from '../components/SyncProgress';
interface Source {
id: string;

View File

@@ -1,368 +0,0 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import SettingsPage from '../SettingsPage';
import { AuthContext } from '../../contexts/AuthContext';
import api, { ocrService } from '../../services/api';
// Mock the API
vi.mock('../../services/api', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
},
ocrService: {
getAvailableLanguages: vi.fn(),
},
queueService: {
getQueueStats: vi.fn(),
},
}));
const mockedApi = vi.mocked(api);
const mockedOcrService = vi.mocked(ocrService);
const theme = createTheme();
const mockAuthContext = {
user: {
id: 'user-123',
username: 'testuser',
email: 'test@example.com',
created_at: '2023-01-01T00:00:00Z',
},
login: vi.fn(),
logout: vi.fn(),
loading: false,
};
const renderWithProviders = (component: React.ReactElement) => {
return render(
<BrowserRouter>
<ThemeProvider theme={theme}>
<AuthContext.Provider value={mockAuthContext}>
{component}
</AuthContext.Provider>
</ThemeProvider>
</BrowserRouter>
);
};
describe('Settings Page - OCR Language Integration', () => {
const mockSettingsResponse = {
data: {
ocrLanguage: 'eng',
concurrentOcrJobs: 2,
ocrTimeoutSeconds: 300,
maxFileSizeMb: 50,
allowedFileTypes: ['pdf', 'png', 'jpg'],
autoRotateImages: true,
enableImagePreprocessing: true,
searchResultsPerPage: 20,
searchSnippetLength: 200,
fuzzySearchThreshold: 0.7,
retentionDays: null,
enableAutoCleanup: false,
enableCompression: true,
memoryLimitMb: 1024,
cpuPriority: 'normal',
enableBackgroundOcr: true,
ocrPageSegmentationMode: 3,
ocrEngineMode: 3,
ocrMinConfidence: 30,
ocrDpi: 300,
ocrEnhanceContrast: true,
ocrRemoveNoise: true,
ocrDetectOrientation: true,
ocrWhitelistChars: '',
ocrBlacklistChars: '',
ocrBrightnessBoost: 0,
ocrContrastMultiplier: 1.0,
ocrNoiseReductionLevel: 1,
ocrSharpeningStrength: 0,
ocrMorphologicalOperations: false,
ocrAdaptiveThresholdWindowSize: 15,
ocrHistogramEqualization: false,
ocrUpscaleFactor: 1.0,
ocrMaxImageWidth: 4000,
ocrMaxImageHeight: 4000,
saveProcessedImages: false,
ocrQualityThresholdBrightness: 50,
ocrQualityThresholdContrast: 20,
ocrQualityThresholdNoise: 80,
ocrQualityThresholdSharpness: 30,
ocrSkipEnhancement: false,
},
};
const mockLanguagesResponse = {
data: {
languages: [
{ code: 'eng', name: 'English' },
{ code: 'spa', name: 'Spanish' },
{ code: 'fra', name: 'French' },
{ code: 'deu', name: 'German' },
{ code: 'ita', name: 'Italian' },
],
current_user_language: 'eng',
},
};
const mockQueueStatsResponse = {
data: {
total_jobs: 0,
pending_jobs: 0,
processing_jobs: 0,
completed_jobs: 0,
failed_jobs: 0,
},
};
beforeEach(() => {
vi.clearAllMocks();
mockedApi.get.mockImplementation((url) => {
if (url === '/settings') return Promise.resolve(mockSettingsResponse);
if (url === '/labels?include_counts=true') return Promise.resolve({ data: [] });
return Promise.reject(new Error(`Unexpected GET request to ${url}`));
});
mockedOcrService.getAvailableLanguages.mockResolvedValue(mockLanguagesResponse);
vi.mocked(require('../../services/api').queueService.getQueueStats).mockResolvedValue(mockQueueStatsResponse);
});
afterEach(() => {
vi.clearAllMocks();
});
it('loads and displays current OCR language in settings', async () => {
renderWithProviders(<SettingsPage />);
await waitFor(() => {
expect(mockedApi.get).toHaveBeenCalledWith('/settings');
expect(mockedOcrService.getAvailableLanguages).toHaveBeenCalled();
});
// Should display the OCR language selector
expect(screen.getByText('OCR Language')).toBeInTheDocument();
});
it('successfully changes OCR language and saves settings', async () => {
const mockUpdateResponse = { data: { success: true } };
mockedApi.put.mockResolvedValueOnce(mockUpdateResponse);
renderWithProviders(<SettingsPage />);
// Wait for page to load
await waitFor(() => {
expect(screen.getByText('OCR Language')).toBeInTheDocument();
});
// Find and open the language selector
const languageSelector = screen.getByLabelText('OCR Language');
fireEvent.mouseDown(languageSelector);
// Wait for dropdown options to appear
await waitFor(() => {
expect(screen.getByText('Spanish')).toBeInTheDocument();
});
// Select Spanish
fireEvent.click(screen.getByText('Spanish'));
// Find and click the save button
const saveButton = screen.getByText('Save Changes');
fireEvent.click(saveButton);
// Verify the API call was made with updated settings
await waitFor(() => {
expect(mockedApi.put).toHaveBeenCalledWith('/settings', {
...mockSettingsResponse.data,
ocrLanguage: 'spa',
});
});
// Should show success message
await waitFor(() => {
expect(screen.getByText('Settings saved successfully')).toBeInTheDocument();
});
});
it('handles OCR language loading errors gracefully', async () => {
mockedOcrService.getAvailableLanguages.mockRejectedValueOnce(new Error('Failed to load languages'));
renderWithProviders(<SettingsPage />);
await waitFor(() => {
expect(mockedOcrService.getAvailableLanguages).toHaveBeenCalled();
});
// Should still render the page but with error state in language selector
expect(screen.getByText('OCR Language')).toBeInTheDocument();
});
it('handles settings save errors appropriately', async () => {
const mockError = new Error('Failed to save settings');
mockedApi.put.mockRejectedValueOnce(mockError);
renderWithProviders(<SettingsPage />);
// Wait for page to load
await waitFor(() => {
expect(screen.getByText('OCR Language')).toBeInTheDocument();
});
// Change a setting
const languageSelector = screen.getByLabelText('OCR Language');
fireEvent.mouseDown(languageSelector);
await waitFor(() => {
expect(screen.getByText('French')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('French'));
// Try to save
const saveButton = screen.getByText('Save Changes');
fireEvent.click(saveButton);
// Should show error message
await waitFor(() => {
expect(screen.getByText(/Failed to save settings/)).toBeInTheDocument();
});
});
it('preserves other settings when changing OCR language', async () => {
const mockUpdateResponse = { data: { success: true } };
mockedApi.put.mockResolvedValueOnce(mockUpdateResponse);
renderWithProviders(<SettingsPage />);
// Wait for page to load
await waitFor(() => {
expect(screen.getByText('OCR Language')).toBeInTheDocument();
});
// Change OCR language
const languageSelector = screen.getByLabelText('OCR Language');
fireEvent.mouseDown(languageSelector);
await waitFor(() => {
expect(screen.getByText('German')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('German'));
// Save settings
const saveButton = screen.getByText('Save Changes');
fireEvent.click(saveButton);
// Verify all original settings are preserved except OCR language
await waitFor(() => {
expect(mockedApi.put).toHaveBeenCalledWith('/settings', {
...mockSettingsResponse.data,
ocrLanguage: 'deu',
});
});
});
it('shows loading state while fetching languages', async () => {
// Make the language fetch hang
mockedOcrService.getAvailableLanguages.mockImplementation(() => new Promise(() => {}));
renderWithProviders(<SettingsPage />);
await waitFor(() => {
expect(screen.getByText('OCR Language')).toBeInTheDocument();
});
// Should show loading indicator in the language selector
expect(screen.getByTestId('loading-languages')).toBeInTheDocument();
});
it('handles empty language list', async () => {
mockedOcrService.getAvailableLanguages.mockResolvedValueOnce({
data: {
languages: [],
current_user_language: null,
},
});
renderWithProviders(<SettingsPage />);
await waitFor(() => {
expect(mockedOcrService.getAvailableLanguages).toHaveBeenCalled();
});
// Should still render the language selector
expect(screen.getByText('OCR Language')).toBeInTheDocument();
// Open the dropdown
const languageSelector = screen.getByLabelText('OCR Language');
fireEvent.mouseDown(languageSelector);
// Should show "No languages available"
await waitFor(() => {
expect(screen.getByText('No languages available')).toBeInTheDocument();
});
});
it('indicates current user language in the dropdown', async () => {
renderWithProviders(<SettingsPage />);
await waitFor(() => {
expect(screen.getByText('OCR Language')).toBeInTheDocument();
});
// Open the language selector
const languageSelector = screen.getByLabelText('OCR Language');
fireEvent.mouseDown(languageSelector);
// Should show current language indicator
await waitFor(() => {
expect(screen.getByText('(Current)')).toBeInTheDocument();
});
});
it('updates language selector when settings are reloaded', async () => {
const { rerender } = renderWithProviders(<SettingsPage />);
// Initial load
await waitFor(() => {
expect(screen.getByText('OCR Language')).toBeInTheDocument();
});
// Update mock to return different language
const updatedSettingsResponse = {
...mockSettingsResponse,
data: {
...mockSettingsResponse.data,
ocrLanguage: 'spa',
},
};
mockedApi.get.mockImplementation((url) => {
if (url === '/settings') return Promise.resolve(updatedSettingsResponse);
if (url === '/labels?include_counts=true') return Promise.resolve({ data: [] });
return Promise.reject(new Error(`Unexpected GET request to ${url}`));
});
// Rerender component
rerender(
<BrowserRouter>
<ThemeProvider theme={theme}>
<AuthContext.Provider value={mockAuthContext}>
<SettingsPage />
</AuthContext.Provider>
</ThemeProvider>
</BrowserRouter>
);
// Should reflect the updated language
await waitFor(() => {
const languageSelector = screen.getByLabelText('OCR Language');
expect(languageSelector).toHaveValue('spa');
});
});
});

View File

@@ -0,0 +1,85 @@
import { SyncProgressManager } from './SyncProgressManager';
import { SyncProgressInfo } from '../api';
/**
* Mock implementation of SyncProgressManager for testing
* Provides controllable sync progress updates without WebSocket dependencies
*/
export class MockSyncProgressManager extends SyncProgressManager {
private connected = false;
private sourceId: string | null = null;
async connect(sourceId: string): Promise<void> {
this.sourceId = sourceId;
this.updateState({ connectionStatus: 'connecting' });
// Simulate async connection
await new Promise(resolve => setTimeout(resolve, 10));
this.connected = true;
this.updateState({ connectionStatus: 'connected' });
}
disconnect(): void {
this.connected = false;
this.sourceId = null;
this.updateState({
connectionStatus: 'disconnected',
progressInfo: null
});
}
reconnect(): void {
if (!this.sourceId) return;
const sourceId = this.sourceId;
this.disconnect();
setTimeout(() => {
this.connect(sourceId).catch(console.error);
}, 10);
}
// Test helper methods
/**
* Simulate a progress update
*/
simulateProgress(progressInfo: SyncProgressInfo): void {
if (!this.connected) {
console.warn('Cannot simulate progress: not connected');
return;
}
this.updateState({ progressInfo });
}
/**
* Simulate a connection status change
*/
simulateConnectionStatus(status: ConnectionStatus): void {
this.updateState({ connectionStatus: status });
if (status === 'connected') {
this.connected = true;
} else if (status === 'disconnected' || status === 'failed') {
this.connected = false;
}
}
/**
* Simulate a heartbeat
*/
simulateHeartbeat(data: { source_id: string; is_active: boolean; timestamp: number }): void {
if (!this.connected) return;
if (!data.is_active) {
this.updateState({ progressInfo: null });
}
}
/**
* Simulate an error
*/
simulateError(error: Error): void {
this.handleError(error);
}
}

View File

@@ -0,0 +1,107 @@
import { EventEmitter } from 'events';
import { SyncProgressInfo } from '../api';
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error' | 'failed';
export interface SyncProgressState {
progressInfo: SyncProgressInfo | null;
connectionStatus: ConnectionStatus;
lastUpdate: number;
}
export interface SyncProgressEvents {
'stateChange': (state: SyncProgressState) => void;
'progressUpdate': (progressInfo: SyncProgressInfo) => void;
'connectionStatusChange': (status: ConnectionStatus) => void;
'error': (error: Error) => void;
}
/**
* Abstract base class for sync progress management
* Provides a clean interface for components to consume sync progress data
* without being coupled to WebSocket implementation details
*/
export abstract class SyncProgressManager extends EventEmitter {
protected state: SyncProgressState = {
progressInfo: null,
connectionStatus: 'disconnected',
lastUpdate: Date.now()
};
constructor() {
super();
this.setMaxListeners(20); // Prevent memory leak warnings
}
/**
* Get current state
*/
getState(): SyncProgressState {
return { ...this.state };
}
/**
* Connect to sync progress source
*/
abstract connect(sourceId: string): Promise<void>;
/**
* Disconnect from sync progress source
*/
abstract disconnect(): void;
/**
* Reconnect to sync progress source
*/
abstract reconnect(): void;
/**
* Check if currently connected
*/
isConnected(): boolean {
return this.state.connectionStatus === 'connected';
}
/**
* Update state and emit events
*/
protected updateState(updates: Partial<SyncProgressState>): void {
const prevState = this.state;
this.state = {
...this.state,
...updates,
lastUpdate: Date.now()
};
// Emit state change event
this.emit('stateChange', this.getState());
// Emit specific events for what changed
if (updates.progressInfo !== undefined && updates.progressInfo !== prevState.progressInfo) {
if (updates.progressInfo) {
this.emit('progressUpdate', updates.progressInfo);
}
}
if (updates.connectionStatus !== undefined && updates.connectionStatus !== prevState.connectionStatus) {
this.emit('connectionStatusChange', updates.connectionStatus);
}
}
/**
* Handle errors
*/
protected handleError(error: Error): void {
console.error('SyncProgressManager error:', error);
this.emit('error', error);
this.updateState({ connectionStatus: 'error' });
}
/**
* Clean up resources
*/
destroy(): void {
this.disconnect();
this.removeAllListeners();
}
}

View File

@@ -0,0 +1,154 @@
import { SyncProgressManager, ConnectionStatus } from './SyncProgressManager';
import { SyncProgressWebSocket, SyncProgressInfo, sourcesService } from '../api';
/**
* WebSocket-based implementation of SyncProgressManager
* Handles real-time sync progress updates via WebSocket connection
*/
export class WebSocketSyncProgressManager extends SyncProgressManager {
private ws: SyncProgressWebSocket | null = null;
private sourceId: string | null = null;
private reconnectTimeout: NodeJS.Timeout | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 1000; // Start with 1 second
async connect(sourceId: string): Promise<void> {
// Clean up any existing connection
this.cleanup();
this.sourceId = sourceId;
this.reconnectAttempts = 0;
try {
this.updateState({ connectionStatus: 'connecting' });
// Create WebSocket connection
this.ws = sourcesService.createSyncProgressWebSocket(sourceId);
// Set up event listeners
this.setupEventListeners();
// Connect
await this.ws.connect();
// Connection successful
this.updateState({ connectionStatus: 'connected' });
console.log(`Successfully connected to sync progress for source: ${sourceId}`);
} catch (error) {
this.handleConnectionError(error);
throw error;
}
}
disconnect(): void {
this.cleanup();
this.updateState({
connectionStatus: 'disconnected',
progressInfo: null
});
}
reconnect(): void {
if (!this.sourceId) {
console.warn('Cannot reconnect: no source ID available');
return;
}
console.log(`Reconnecting to sync progress for source: ${this.sourceId}`);
this.disconnect();
// Use setTimeout to ensure cleanup is complete
setTimeout(() => {
if (this.sourceId) {
this.connect(this.sourceId).catch(error => {
console.error('Reconnection failed:', error);
});
}
}, 100);
}
private setupEventListeners(): void {
if (!this.ws) return;
// Progress updates
this.ws.addEventListener('progress', (data: SyncProgressInfo) => {
console.log('Received sync progress update:', data);
this.updateState({ progressInfo: data });
});
// Heartbeat messages
this.ws.addEventListener('heartbeat', (data: any) => {
console.log('Received heartbeat:', data);
// Clear progress info if sync is not active
if (data && !data.is_active) {
this.updateState({ progressInfo: null });
}
});
// Connection status changes
this.ws.addEventListener('connectionStatus', (status: ConnectionStatus) => {
console.log(`WebSocket connection status changed to: ${status}`);
this.updateState({ connectionStatus: status });
// Handle automatic reconnection for certain statuses
if (status === 'disconnected' || status === 'error') {
this.scheduleReconnect();
}
});
// Errors
this.ws.addEventListener('error', (error: any) => {
this.handleError(new Error(error.message || 'WebSocket error'));
});
}
private handleConnectionError(error: any): void {
console.error('WebSocket connection error:', error);
this.updateState({ connectionStatus: 'error' });
this.handleError(error instanceof Error ? error : new Error(String(error)));
this.scheduleReconnect();
}
private scheduleReconnect(): void {
// Don't reconnect if we've exceeded max attempts
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.updateState({ connectionStatus: 'failed' });
return;
}
// Don't schedule if already scheduled
if (this.reconnectTimeout) return;
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Exponential backoff
console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
this.updateState({ connectionStatus: 'reconnecting' });
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
this.reconnect();
}, delay);
}
private cleanup(): void {
// Clear reconnect timeout
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
// Close WebSocket
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
destroy(): void {
this.cleanup();
super.destroy();
}
}

View File

@@ -0,0 +1,3 @@
export { SyncProgressManager, ConnectionStatus, SyncProgressState, SyncProgressEvents } from './SyncProgressManager';
export { WebSocketSyncProgressManager } from './WebSocketSyncProgressManager';
export { MockSyncProgressManager } from './MockSyncProgressManager';

View File

@@ -7,6 +7,10 @@ const CLIENT_PORT = process.env.CLIENT_PORT || '5173'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['src/test/setup.ts'],
},
server: {
port: parseInt(CLIENT_PORT),
proxy: {

View File

@@ -1,25 +1,36 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
const BACKEND_PORT = process.env.BACKEND_PORT || '8000'
const CLIENT_PORT = process.env.CLIENT_PORT || '5173'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
mockReset: true,
clearMocks: true,
restoreMocks: true,
include: [
'**/*.e2e.test.{js,jsx,ts,tsx}',
'**/*.integration.test.{js,jsx,ts,tsx}'
],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/cypress/**',
'**/.{idea,git,cache,output,temp}/**',
'**/e2e/**'
],
globals: true,
setupFiles: ['src/test/setup.ts'],
include: ['**/*.integration.test.{js,jsx,ts,tsx}'],
},
server: {
port: parseInt(CLIENT_PORT),
proxy: {
'/api': {
target: `http://localhost:${BACKEND_PORT}`,
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
assetsDir: 'assets',
rollupOptions: {
onwarn(warning, warn) {
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
return
}
warn(warning)
}
}
},
})

View File

@@ -0,0 +1,43 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
const BACKEND_PORT = process.env.BACKEND_PORT || '8000'
const CLIENT_PORT = process.env.CLIENT_PORT || '5173'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['src/test/setup.ts'],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/cypress/**',
'**/.{idea,git,cache,output,temp}/**',
'**/e2e/**',
'**/*.integration.test.{js,jsx,ts,tsx}',
],
},
server: {
port: parseInt(CLIENT_PORT),
proxy: {
'/api': {
target: `http://localhost:${BACKEND_PORT}`,
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
assetsDir: 'assets',
rollupOptions: {
onwarn(warning, warn) {
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
return
}
warn(warning)
}
}
},
})