diff --git a/frontend/src/components/TelemetryDashboard.jsx b/frontend/src/components/TelemetryDashboard.jsx index d06ae380..a7009565 100644 --- a/frontend/src/components/TelemetryDashboard.jsx +++ b/frontend/src/components/TelemetryDashboard.jsx @@ -29,19 +29,56 @@ import { /** * TelemetryDashboard -- displays application performance metrics and error logs. + * + * Error Handling: + * - Displays visible error state when analytics data fails to load + * - Keeps refresh and export actions available during errors + * - Allows retry after failed loads + * * Note: Shared sub-components (MetricCard, AlertPanel, etc.) must be defined * at the top level to avoid "symbol has already been declared" errors during * Vite/Rollup transformation when build optimizations are enabled. */ + +const DEFAULT_ERROR_MESSAGE = 'Failed to load telemetry data'; + +/** + * Extract a user-friendly error message from an exception. + * @param {Error} err - The error object + * @returns {string} User-friendly error message + */ +function extractErrorMessage(err) { + if (!err) return DEFAULT_ERROR_MESSAGE; + + if (err.message) { + return err.message; + } + + if (typeof err === 'string') { + return err; + } + + return DEFAULT_ERROR_MESSAGE; +} + +/** + * TelemetryDashboard component displays application performance metrics. + * + * @param {Object} props - Component props + * @param {Function} props.addToast - Function to display toast notifications + * @returns {JSX.Element} The telemetry dashboard UI + */ export default function TelemetryDashboard({ addToast }) { const { demoEnabled } = useDemoMode(); const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [exporting, setExporting] = useState(false); const [syncing, setSyncing] = useState(false); const loadData = useCallback(() => { setLoading(true); + setError(null); if (demoEnabled) { setTimeout(() => { @@ -81,7 +118,12 @@ export default function TelemetryDashboard({ addToast }) { try { const data = analytics.getSummary(); setSummary(data); + setError(null); } catch (err) { + // Extract error message for display, fallback to generic message + const errorMessage = extractErrorMessage(err); + setError(errorMessage); + // Keep console logging for debugging purposes console.error('Failed to load telemetry:', err); } finally { setLoading(false); @@ -170,6 +212,55 @@ export default function TelemetryDashboard({ addToast }) { ); } + // Render error state with retry and export options + if (error) { + return ( +
+
+
+
+
+
+ ); + } + const vitalsSummary = computeVitalsSummary(summary?.webVitals || {}); const tipFunnel = computeTipFunnel(summary || {}); const batchFunnel = computeBatchFunnel(summary || {}); diff --git a/frontend/src/test/TelemetryDashboard.error.test.jsx b/frontend/src/test/TelemetryDashboard.error.test.jsx new file mode 100644 index 00000000..4b415268 --- /dev/null +++ b/frontend/src/test/TelemetryDashboard.error.test.jsx @@ -0,0 +1,477 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('../lib/analytics', () => ({ + analytics: { + getSummary: vi.fn(), + reset: vi.fn(), + }, +})); + +import TelemetryDashboard from '../components/TelemetryDashboard'; +import { analytics } from '../lib/analytics'; + +vi.mock('../context/DemoContext', () => ({ + useDemoMode: vi.fn(() => ({ + demoEnabled: false, + setDemoBalance: vi.fn(), + })), +})); + +vi.mock('../lib/telemetry-env', () => ({ + getEnvironmentLabel: vi.fn(() => 'PRODUCTION'), + getEnvironmentColor: vi.fn(() => 'green'), +})); + +vi.mock('../lib/telemetry-vitals', () => ({ + computeVitalsSummary: vi.fn(() => ({ + vitals: [], + overallScore: null, + coreVitalsPassing: false, + })), +})); + +vi.mock('../lib/telemetry-funnel', () => ({ + computeTipFunnel: vi.fn(() => ({ + stages: [], + overallConversion: 0, + cancelled: 0, + failed: 0, + })), + computeBatchFunnel: vi.fn(() => ({ + stages: [], + overallConversion: 0, + cancelled: 0, + failed: 0, + })), + computeWalletDropOff: vi.fn(() => ({ + connections: 0, + disconnections: 0, + retentionRate: 0, + dropOffRate: 0, + })), + identifyDropOffPoints: vi.fn(() => []), +})); + +vi.mock('../lib/telemetry-export', () => ({ + downloadExport: vi.fn(() => 'export.json'), + exportToCsv: vi.fn(() => 'export.csv'), + copyToClipboard: vi.fn(), +})); + +vi.mock('../lib/telemetry-sink', () => ({ + isSinkEnabled: vi.fn(() => false), + getSinkConfig: vi.fn(() => ({ endpoint: '' })), + sendSnapshot: vi.fn(), +})); + +vi.mock('../lib/telemetry-storage', () => ({ + getStorageUsage: vi.fn(() => ({ telemetryBytes: 0 })), +})); + +describe('TelemetryDashboard error handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Basic error display tests + it('displays error state when analytics.getSummary throws', async () => { + const errorMessage = 'Analytics service unavailable'; + analytics.getSummary.mockImplementation(() => { + throw new Error(errorMessage); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument(); + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); + + it('shows retry button in error state', async () => { + analytics.getSummary.mockImplementation(() => { + throw new Error('Service error'); + }); + + render(); + + await waitFor(() => { + const retryButton = screen.getByRole('button', { name: /retry/i }); + expect(retryButton).toBeInTheDocument(); + }); + }); + + // Button availability tests + it('keeps export buttons available during error state', async () => { + analytics.getSummary.mockImplementation(() => { + throw new Error('Service error'); + }); + + render(); + + await waitFor(() => { + const exportJsonButton = screen.getByLabelText('Export telemetry data as JSON'); + const exportCsvButton = screen.getByLabelText('Export telemetry data as CSV'); + expect(exportJsonButton).toBeInTheDocument(); + expect(exportCsvButton).toBeInTheDocument(); + }); + }); + + // Retry functionality tests + it('allows retry after error', async () => { + const user = userEvent.setup(); + analytics.getSummary.mockImplementation(() => { + throw new Error('Service error'); + }); + + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument(); + }); + + analytics.getSummary.mockReturnValue({ + sessions: 10, + totalPageViews: 50, + tipsConfirmed: 5, + tipCompletionRate: '50', + tipDropOffRate: '50', + batchTipsConfirmed: 1, + batchCompletionRate: '100', + totalErrors: 0, + sortedPages: [], + sortedErrors: [], + webVitals: {}, + walletConnections: 5, + walletDisconnections: 0, + firstSeen: Date.now(), + lastSeen: Date.now(), + }); + + const retryButton = screen.getByRole('button', { name: /retry/i }); + await user.click(retryButton); + + await waitFor(() => { + expect(screen.queryByText('Failed to Load Telemetry Data')).not.toBeInTheDocument(); + }); + }); + + it('clears error when data loads successfully', async () => { + const user = userEvent.setup(); + analytics.getSummary.mockImplementation(() => { + throw new Error('Service error'); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument(); + }); + + analytics.getSummary.mockReturnValue({ + sessions: 10, + totalPageViews: 50, + tipsConfirmed: 5, + tipCompletionRate: '50', + tipDropOffRate: '50', + batchTipsConfirmed: 1, + batchCompletionRate: '100', + totalErrors: 0, + sortedPages: [], + sortedErrors: [], + webVitals: {}, + walletConnections: 5, + walletDisconnections: 0, + firstSeen: Date.now(), + lastSeen: Date.now(), + }); + + const retryButton = screen.getByRole('button', { name: /retry/i }); + await user.click(retryButton); + + await waitFor(() => { + expect(screen.queryByText('Failed to Load Telemetry Data')).not.toBeInTheDocument(); + }); + }); + + // Error message handling tests + it('displays generic error message for unknown errors', async () => { + analytics.getSummary.mockImplementation(() => { + throw new Error(); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument(); + }); + }); + + it('shows error with specific message from exception', async () => { + const specificError = 'Database connection timeout'; + analytics.getSummary.mockImplementation(() => { + throw new Error(specificError); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(specificError)).toBeInTheDocument(); + }); + }); + + it('maintains error state across re-renders', async () => { + analytics.getSummary.mockImplementation(() => { + throw new Error('Persistent error'); + }); + + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument(); + }); + + rerender(); + + expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument(); + }); + + it('exports JSON even when data load fails', async () => { + const user = userEvent.setup(); + + analytics.getSummary.mockImplementation(() => { + throw new Error('Service error'); + }); + + const addToast = vi.fn(); + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument(); + }); + + const exportJsonButton = screen.getByLabelText('Export telemetry data as JSON'); + await user.click(exportJsonButton); + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Exported'), 'success'); + }); + }); + + it('exports CSV even when data load fails', async () => { + const user = userEvent.setup(); + + analytics.getSummary.mockImplementation(() => { + throw new Error('Service error'); + }); + + const addToast = vi.fn(); + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument(); + }); + + const exportCsvButton = screen.getByLabelText('Export telemetry data as CSV'); + await user.click(exportCsvButton); + + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Exported'), 'success'); + }); + }); + + it('displays error message in monospace font', async () => { + const errorMessage = 'Connection refused: localhost:5432'; + analytics.getSummary.mockImplementation(() => { + throw new Error(errorMessage); + }); + + render(); + + await waitFor(() => { + const errorElement = screen.getByText(errorMessage); + expect(errorElement).toBeInTheDocument(); + expect(errorElement.className).toContain('font-mono'); + }); + }); + + it('shows alert icon in error state', async () => { + analytics.getSummary.mockImplementation(() => { + throw new Error('Service error'); + }); + + const { container } = render(); + + await waitFor(() => { + const alertIcon = container.querySelector('svg[class*="text-red-500"]'); + expect(alertIcon).toBeInTheDocument(); + }); + }); + + it('clears loading state when error occurs', async () => { + analytics.getSummary.mockImplementation(() => { + throw new Error('Service error'); + }); + + render(); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument(); + }); + }); + + it('error container has alert role for accessibility', async () => { + analytics.getSummary.mockImplementation(() => { + throw new Error('Service error'); + }); + + render(); + + await waitFor(() => { + const alertContainer = screen.getByRole('alert'); + expect(alertContainer).toBeInTheDocument(); + expect(alertContainer).toHaveAttribute('aria-live', 'assertive'); + }); + }); + + it('buttons have descriptive aria labels in error state', async () => { + analytics.getSummary.mockImplementation(() => { + throw new Error('Service error'); + }); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText('Retry loading telemetry data')).toBeInTheDocument(); + expect(screen.getByLabelText('Export telemetry data as JSON')).toBeInTheDocument(); + expect(screen.getByLabelText('Export telemetry data as CSV')).toBeInTheDocument(); + }); + }); + + it('export buttons are enabled in error state', async () => { + analytics.getSummary.mockImplementation(() => { + throw new Error('Service error'); + }); + + render(); + + await waitFor(() => { + const exportJsonButton = screen.getByLabelText('Export telemetry data as JSON'); + const exportCsvButton = screen.getByLabelText('Export telemetry data as CSV'); + expect(exportJsonButton).not.toBeDisabled(); + expect(exportCsvButton).not.toBeDisabled(); + }); + }); + + it('handles string errors gracefully', async () => { + analytics.getSummary.mockImplementation(() => { + throw 'String error message'; + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument(); + expect(screen.getByText('String error message')).toBeInTheDocument(); + }); + }); + + it('does not show error state when data loads successfully', async () => { + analytics.getSummary.mockReturnValue({ + sessions: 10, + totalPageViews: 50, + tipsConfirmed: 5, + tipCompletionRate: '50', + tipDropOffRate: '50', + batchTipsConfirmed: 1, + batchCompletionRate: '100', + totalErrors: 0, + sortedPages: [], + sortedErrors: [], + webVitals: {}, + walletConnections: 5, + walletDisconnections: 0, + firstSeen: Date.now(), + lastSeen: Date.now(), + }); + + render(); + + await waitFor(() => { + expect(screen.queryByText('Failed to Load Telemetry Data')).not.toBeInTheDocument(); + expect(screen.getByText('Telemetry Dashboard')).toBeInTheDocument(); + }); + }); + + it('handles null error with fallback message', async () => { + analytics.getSummary.mockImplementation(() => { + throw null; + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument(); + }); + }); + + it('retry button triggers data reload', async () => { + const user = userEvent.setup(); + let callCount = 0; + + analytics.getSummary.mockImplementation(() => { + callCount++; + if (callCount === 1) { + throw new Error('First attempt failed'); + } + return { + sessions: 10, + totalPageViews: 50, + tipsConfirmed: 5, + tipCompletionRate: '50', + tipDropOffRate: '50', + batchTipsConfirmed: 1, + batchCompletionRate: '100', + totalErrors: 0, + sortedPages: [], + sortedErrors: [], + webVitals: {}, + walletConnections: 5, + walletDisconnections: 0, + firstSeen: Date.now(), + lastSeen: Date.now(), + }; + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument(); + }); + + const retryButton = screen.getByLabelText('Retry loading telemetry data'); + await user.click(retryButton); + + await waitFor(() => { + expect(callCount).toBe(2); + expect(screen.queryByText('Failed to Load Telemetry Data')).not.toBeInTheDocument(); + }); + }); + + it('error message container has proper styling', async () => { + analytics.getSummary.mockImplementation(() => { + throw new Error('Service error'); + }); + + const { container } = render(); + + await waitFor(() => { + const errorMessage = screen.getByText('Service error'); + expect(errorMessage.className).toContain('bg-gray-50'); + expect(errorMessage.className).toContain('border'); + }); + }); +});