mirror of
https://github.com/readur/readur.git
synced 2025-12-21 06:10:45 -06:00
feat(tests): implement frontend integration tests
This commit is contained in:
29
.claude/agents/code-reviewer.md
Normal file
29
.claude/agents/code-reviewer.md
Normal 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.
|
||||
24
.github/workflows/test-integration.yml
vendored
24
.github/workflows/test-integration.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/test-unit.yml
vendored
4
.github/workflows/test-unit.yml
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
155
frontend/src/components/SyncProgress/ProgressStatistics.tsx
Normal file
155
frontend/src/components/SyncProgress/ProgressStatistics.tsx
Normal 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;
|
||||
193
frontend/src/components/SyncProgress/SyncProgressDisplay.tsx
Normal file
193
frontend/src/components/SyncProgress/SyncProgressDisplay.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
4
frontend/src/components/SyncProgress/index.ts
Normal file
4
frontend/src/components/SyncProgress/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { SyncProgressDisplay } from './SyncProgressDisplay';
|
||||
export { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
|
||||
export { ProgressStatistics } from './ProgressStatistics';
|
||||
export default SyncProgressDisplay;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
136
frontend/src/hooks/useSyncProgress.ts
Normal file
136
frontend/src/hooks/useSyncProgress.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
107
frontend/src/services/syncProgress/SyncProgressManager.ts
Normal file
107
frontend/src/services/syncProgress/SyncProgressManager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
3
frontend/src/services/syncProgress/index.ts
Normal file
3
frontend/src/services/syncProgress/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { SyncProgressManager, ConnectionStatus, SyncProgressState, SyncProgressEvents } from './SyncProgressManager';
|
||||
export { WebSocketSyncProgressManager } from './WebSocketSyncProgressManager';
|
||||
export { MockSyncProgressManager } from './MockSyncProgressManager';
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
43
frontend/vitest.unit.config.ts
Normal file
43
frontend/vitest.unit.config.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user