diff --git a/README.md b/README.md index b6862708..f8ad357c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Built with modern web technologies and Web3 integration, this frontend serves as ### Core Capabilities - **🏠 Property Discovery**: Browse and search tokenized real estate properties with advanced filtering - **💰 Wallet Integration**: Connect MetaMask, WalletConnect, and other Web3 wallets seamlessly +- **⚙️ Optimized Developer Diagnostics**: Memoized error test scenarios for faster interactive debugging and smoother rendering +- **🎯 Route-Level Error Handling**: Full-page route fallback UI with retry and home navigation using `RouteErrorBoundary` - **🔗 Smart Contract Interaction**: Execute property purchases, transfers, and management through intuitive UI - **📊 Real-Time Data**: Live property valuations, market trends, and portfolio analytics - **🔐 Web3 Authentication**: Secure wallet-based authentication with multi-network support diff --git a/docs/adr/ADR-005-error-handling.md b/docs/adr/ADR-005-error-handling.md index 77cb3f68..e6878d6f 100644 --- a/docs/adr/ADR-005-error-handling.md +++ b/docs/adr/ADR-005-error-handling.md @@ -73,6 +73,7 @@ class ValidationError extends Error { - `` wraps the entire app and shows a full-page error screen for catastrophic failures - `` wraps major page sections (property list, wallet panel) and shows inline fallback UI +- `` renders route-level full-screen fallback UI for page-specific failures with retry and home navigation - `` wraps individual widgets and renders a compact error state ### Global Error Handler diff --git a/src/components/error/ErrorTestSuite.tsx b/src/components/error/ErrorTestSuite.tsx index 07e08904..8ae65470 100644 --- a/src/components/error/ErrorTestSuite.tsx +++ b/src/components/error/ErrorTestSuite.tsx @@ -1,12 +1,11 @@ 'use client'; -import React, { useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { EnhancedErrorBoundary, ErrorBoundaryPresets } from './EnhancedErrorBoundary'; import { ErrorFactory } from '@/utils/errorFactory'; -import { AppError, ErrorCategory, ErrorSeverity, ErrorRecoveryAction } from '@/types/errors'; +import { ErrorCategory, ErrorSeverity, ErrorRecoveryAction } from '@/types/errors'; import { Bug, Wifi, Camera, Wallet, AlertTriangle } from 'lucide-react'; interface TestScenario { @@ -19,170 +18,302 @@ interface TestScenario { icon: React.ReactNode; } -export function ErrorTestSuite() { - const [activeTest, setActiveTest] = useState(null); - const [testResults, setTestResults] = useState>([]); - - const testScenarios: TestScenario[] = [ - { - id: 'web3-connection', - name: 'Web3 Connection Error', - description: 'Simulates a wallet connection failure', - category: ErrorCategory.WEB3, - severity: ErrorSeverity.HIGH, - icon: , - triggerError: () => { - throw ErrorFactory.createWeb3Error( - 'Failed to connect to wallet', - 'Unable to connect to your wallet. Please ensure MetaMask is installed and unlocked.', - { - context: { walletType: 'MetaMask', chainId: 1 }, - recoveryAction: ErrorRecoveryAction.RECONNECT, - } - ); - }, +interface TestResult { + id: string; + success: boolean; + error?: string; +} + +type TestStatus = 'running' | 'caught' | 'failed' | 'pending'; + +interface ScenarioStatus { + status: TestStatus; + color: string; +} + +const TEST_SCENARIOS: TestScenario[] = [ + { + id: 'web3-connection', + name: 'Web3 Connection Error', + description: 'Simulates a wallet connection failure', + category: ErrorCategory.WEB3, + severity: ErrorSeverity.HIGH, + icon: , + triggerError: () => { + throw ErrorFactory.createWeb3Error( + 'Failed to connect to wallet', + 'Unable to connect to your wallet. Please ensure MetaMask is installed and unlocked.', + { + context: { walletType: 'MetaMask', chainId: 1 }, + recoveryAction: ErrorRecoveryAction.RECONNECT, + } + ); }, - { - id: 'network-timeout', - name: 'Network Timeout', - description: 'Simulates a network request timeout', - category: ErrorCategory.NETWORK, - severity: ErrorSeverity.MEDIUM, - icon: , - triggerError: () => { - throw ErrorFactory.createNetworkError( - 'Network request timed out', - 'Network request timed out. Please check your connection and try again.', - { - context: { timeout: 30000, url: '/api/properties' }, - recoveryAction: ErrorRecoveryAction.RETRY, - } - ); - }, + }, + { + id: 'network-timeout', + name: 'Network Timeout', + description: 'Simulates a network request timeout', + category: ErrorCategory.NETWORK, + severity: ErrorSeverity.MEDIUM, + icon: , + triggerError: () => { + throw ErrorFactory.createNetworkError( + 'Network request timed out', + 'Network request timed out. Please check your connection and try again.', + { + context: { timeout: 30000, url: '/api/properties' }, + recoveryAction: ErrorRecoveryAction.RETRY, + } + ); }, - { - id: 'ar-camera', - name: 'AR Camera Error', - description: 'Simulates an AR camera permission error', - category: ErrorCategory.AR, - severity: ErrorSeverity.MEDIUM, - icon: , - triggerError: () => { - throw ErrorFactory.createARError( - 'Camera access denied', - 'Camera permission is required for AR features. Please grant camera access to continue.', - { - context: { permission: 'camera', device: 'mobile' }, - recoveryAction: ErrorRecoveryAction.GRANT_PERMISSION, - isRecoverable: true, - } - ); - }, + }, + { + id: 'ar-camera', + name: 'AR Camera Error', + description: 'Simulates an AR camera permission error', + category: ErrorCategory.AR, + severity: ErrorSeverity.MEDIUM, + icon: , + triggerError: () => { + throw ErrorFactory.createARError( + 'Camera access denied', + 'Camera permission is required for AR features. Please grant camera access to continue.', + { + context: { permission: 'camera', device: 'mobile' }, + recoveryAction: ErrorRecoveryAction.GRANT_PERMISSION, + isRecoverable: true, + } + ); }, - { - id: 'validation-form', - name: 'Form Validation Error', - description: 'Simulates a form validation failure', - category: ErrorCategory.VALIDATION, - severity: ErrorSeverity.LOW, - icon: , - triggerError: () => { - throw ErrorFactory.createValidationError( - 'Invalid email format', - 'Please enter a valid email address.', - { - context: { field: 'email', value: 'invalid-email' }, - recoveryAction: ErrorRecoveryAction.RETRY, - } - ); - }, + }, + { + id: 'validation-form', + name: 'Form Validation Error', + description: 'Simulates a form validation failure', + category: ErrorCategory.VALIDATION, + severity: ErrorSeverity.LOW, + icon: , + triggerError: () => { + throw ErrorFactory.createValidationError( + 'Invalid email format', + 'Please enter a valid email address.', + { + context: { field: 'email', value: 'invalid-email' }, + recoveryAction: ErrorRecoveryAction.RETRY, + } + ); }, - { - id: 'ui-component', - name: 'UI Component Error', - description: 'Simulates a React component error', - category: ErrorCategory.UI, - severity: ErrorSeverity.LOW, - icon: , - triggerError: () => { - throw ErrorFactory.createUIError( - 'Component failed to render', - 'A component failed to load properly. Refreshing the page may help.', - { - context: { component: 'PropertyCard', props: { id: 123 } }, - recoveryAction: ErrorRecoveryAction.REFRESH, - } - ); - }, + }, + { + id: 'ui-component', + name: 'UI Component Error', + description: 'Simulates a React component error', + category: ErrorCategory.UI, + severity: ErrorSeverity.LOW, + icon: , + triggerError: () => { + throw ErrorFactory.createUIError( + 'Component failed to render', + 'A component failed to load properly. Refreshing the page may help.', + { + context: { component: 'PropertyCard', props: { id: 123 } }, + recoveryAction: ErrorRecoveryAction.REFRESH, + } + ); }, - { - id: 'permission-denied', - name: 'Permission Denied', - description: 'Simulates a location permission error', - category: ErrorCategory.PERMISSION, - severity: ErrorSeverity.MEDIUM, - icon: , - triggerError: () => { - throw ErrorFactory.createPermissionError( - 'Location permission denied', - 'Location access is required for property discovery. Please enable location services.', - { - context: { permission: 'geolocation', feature: 'nearby-properties' }, - recoveryAction: ErrorRecoveryAction.GRANT_PERMISSION, - } - ); - }, + }, + { + id: 'permission-denied', + name: 'Permission Denied', + description: 'Simulates a location permission error', + category: ErrorCategory.PERMISSION, + severity: ErrorSeverity.MEDIUM, + icon: , + triggerError: () => { + throw ErrorFactory.createPermissionError( + 'Location permission denied', + 'Location access is required for property discovery. Please enable location services.', + { + context: { permission: 'geolocation', feature: 'nearby-properties' }, + recoveryAction: ErrorRecoveryAction.GRANT_PERMISSION, + } + ); }, - { - id: 'resource-not-found', - name: 'Resource Not Found', - description: 'Simulates a missing resource error', - category: ErrorCategory.RESOURCE, - severity: ErrorSeverity.MEDIUM, - icon: , - triggerError: () => { - throw ErrorFactory.createResourceError( - 'Property image not found', - 'Unable to load property images. Please check your connection and try again.', - { - context: { resource: 'image', url: '/api/properties/123/image' }, - recoveryAction: ErrorRecoveryAction.REFRESH, - } - ); - }, + }, + { + id: 'resource-not-found', + name: 'Resource Not Found', + description: 'Simulates a missing resource error', + category: ErrorCategory.RESOURCE, + severity: ErrorSeverity.MEDIUM, + icon: , + triggerError: () => { + throw ErrorFactory.createResourceError( + 'Property image not found', + 'Unable to load property images. Please check your connection and try again.', + { + context: { resource: 'image', url: '/api/properties/123/image' }, + recoveryAction: ErrorRecoveryAction.REFRESH, + } + ); }, - ]; + }, +]; + +const getScenarioStatus = ( + scenarioId: string, + activeTest: string | null, + resultMap: Map +): ScenarioStatus => { + if (activeTest === scenarioId) { + return { status: 'running', color: 'text-blue-600' }; + } + + const result = resultMap.get(scenarioId); - const runTest = (scenario: TestScenario) => { + if (!result) { + return { status: 'pending', color: 'text-gray-600' }; + } + + return result.success + ? { status: 'caught', color: 'text-green-600' } + : { status: 'failed', color: 'text-red-600' }; +}; + +const SummaryCard = memo( + ({ + scenarioName, + status, + color, + }: { + scenarioName: string; + status: TestStatus; + color: string; + }) => ( +
+
{status.toUpperCase()}
+
{scenarioName}
+
+ ) +); +SummaryCard.displayName = 'SummaryCard'; + +const ScenarioCard = memo( + ({ + scenario, + status, + color, + onRun, + }: { + scenario: TestScenario; + status: TestStatus; + color: string; + onRun: () => void; + }) => ( + + +
+
+ {scenario.icon} +
+
+ {scenario.name} +
{status.status.toUpperCase()}
+
+
+
+ +

{scenario.description}

+ +
+
+ Category: + {scenario.category} +
+
+ Severity: + {scenario.severity} +
+
+ + +
+
+ ) +); +ScenarioCard.displayName = 'ScenarioCard'; + +export function ErrorTestSuite() { + const [activeTest, setActiveTest] = useState(null); + const [testResults, setTestResults] = useState>([]); + + const testResultsMap = useMemo( + () => new Map(testResults.map(result => [result.id, result])), + [testResults] + ); + + const timeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); + + const scenarioStatusMap = useMemo(() => { + const map = new Map(); + + TEST_SCENARIOS.forEach(scenario => { + map.set(scenario.id, getScenarioStatus(scenario.id, activeTest, testResultsMap)); + }); + + return map; + }, [activeTest, testResultsMap]); + + const runTest = useCallback((scenario: TestScenario) => { setActiveTest(scenario.id); - + try { scenario.triggerError(); setTestResults(prev => [...prev, { id: scenario.id, success: false }]); } catch (error: unknown) { - // Expected behavior - error should be caught by boundary - setTestResults(prev => [...prev, { - id: scenario.id, - success: true, - error: error instanceof Error ? error.message : 'Unknown error' - }]); + setTestResults(prev => [ + ...prev, + { + id: scenario.id, + success: true, + error: error instanceof Error ? error.message : 'Unknown error', + }, + ]); + } + + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); } - - setTimeout(() => setActiveTest(null), 2000); - }; - const clearResults = () => { + timeoutRef.current = window.setTimeout(() => setActiveTest(null), 2000); + }, []); + + const runTestCallbacks = useMemo( + () => + new Map(TEST_SCENARIOS.map(scenario => [scenario.id, () => runTest(scenario)])), + [runTest] + ); + + const clearResults = useCallback(() => { setTestResults([]); setActiveTest(null); - }; - - const getTestStatus = (scenarioId: string) => { - const result = testResults.find(r => r.id === scenarioId); - if (activeTest === scenarioId) return { status: 'running', color: 'text-blue-600' }; - if (result?.success) return { status: 'caught', color: 'text-green-600' }; - if (result) return { status: 'failed', color: 'text-red-600' }; - return { status: 'pending', color: 'text-gray-600' }; - }; + }, []); return (
@@ -208,17 +339,15 @@ export function ErrorTestSuite() {
- {testScenarios.map(scenario => { - const status = getTestStatus(scenario.id); + {TEST_SCENARIOS.map(scenario => { + const status = scenarioStatusMap.get(scenario.id)!; return ( -
-
- {status.status.toUpperCase()} -
-
- {scenario.name} -
-
+ ); })}
@@ -228,50 +357,17 @@ export function ErrorTestSuite() { {/* Test Scenarios */}
- {testScenarios.map(scenario => { - const status = getTestStatus(scenario.id); - + {TEST_SCENARIOS.map(scenario => { + const status = scenarioStatusMap.get(scenario.id)!; + return ( - - -
-
- {scenario.icon} -
-
- {scenario.name} -
- {status.status.toUpperCase()} -
-
-
-
- -

- {scenario.description} -

- -
-
- Category: - {scenario.category} -
-
- Severity: - {scenario.severity} -
-
- - -
-
+ ); })}
diff --git a/src/components/error/RouteErrorBoundary.tsx b/src/components/error/RouteErrorBoundary.tsx index 94ba58a3..36ff94bc 100644 --- a/src/components/error/RouteErrorBoundary.tsx +++ b/src/components/error/RouteErrorBoundary.tsx @@ -7,6 +7,7 @@ import { Home, RefreshCw, AlertTriangle, Search } from 'lucide-react'; import type { AppError } from '@/types/errors'; import { ErrorCategory } from '@/types/errors'; import { getWalletErrorMessage } from '@/utils/errorHandling'; +import { getRouteErrorIconConfig, getRouteErrorTitle } from './routeErrorHelpers'; interface RouteErrorBoundaryProps { error: AppError; @@ -27,51 +28,17 @@ const RouteErrorFallback: React.FC = ({ onRetry, onNavigateHome, }) => { - const getErrorIcon = () => { - switch (error.category) { - case ErrorCategory.WEB3: - return ( -
- -
- ); - case ErrorCategory.NETWORK: - return ( -
- -
- ); - default: - return ( -
- -
- ); - } - }; + const { bgClass, iconClass } = getRouteErrorIconConfig(error.category); - const getErrorTitle = () => { - switch (error.category) { - case ErrorCategory.WEB3: - return 'Wallet Connection Error'; - case ErrorCategory.NETWORK: - return 'Network Error'; - case ErrorCategory.VALIDATION: - return 'Validation Error'; - case ErrorCategory.PERMISSION: - return 'Permission Error'; - case ErrorCategory.AUTHENTICATION: - return 'Authentication Error'; - default: - return 'Something went wrong'; - } - }; + const getErrorTitle = () => getRouteErrorTitle(error.category) return (
- {getErrorIcon()} +
+ +

{getErrorTitle()} diff --git a/src/components/error/__tests__/ErrorTestSuite.test.tsx b/src/components/error/__tests__/ErrorTestSuite.test.tsx new file mode 100644 index 00000000..df8574c0 --- /dev/null +++ b/src/components/error/__tests__/ErrorTestSuite.test.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import userEvent from '@testing-library/user-event' +import { render, screen, waitFor } from '@testing-library/react' +import { ErrorTestSuite } from '@/components/error/ErrorTestSuite' + +describe('ErrorTestSuite', () => { + it('renders the test suite and instructions', () => { + render() + + expect(screen.getByRole('heading', { name: /Error Boundary Test Suite/i })).toBeInTheDocument() + expect(screen.getByText(/Test different error scenarios to verify error boundary functionality/i)).toBeInTheDocument() + expect(screen.getByText(/How to use:/i)).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: /Test Error/i }).length).toBeGreaterThan(0) + }) + + it('updates scenario status after running a test', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ delay: null }) + + render() + + const firstTestButton = screen.getAllByRole('button', { name: /Test Error/i })[0] + await user.click(firstTestButton) + + expect(firstTestButton).toBeDisabled() + expect(screen.getByText(/Running.../i)).toBeInTheDocument() + expect(await screen.findByText(/CAUGHT/i)).toBeInTheDocument() + + jest.runAllTimers() + await waitFor(() => expect(firstTestButton).not.toBeDisabled()) + jest.useRealTimers() + }) + + it('shows and clears the summary panel after a test run', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ delay: null }) + + render() + + await user.click(screen.getAllByRole('button', { name: /Test Error/i })[0]) + expect(await screen.findByText(/CAUGHT/i)).toBeInTheDocument() + expect(screen.getByText(/Test Results/i)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /Clear Results/i })) + + expect(screen.queryByText(/Test Results/i)).not.toBeInTheDocument() + jest.useRealTimers() + }) +}) diff --git a/src/components/error/__tests__/RouteErrorBoundary.test.tsx b/src/components/error/__tests__/RouteErrorBoundary.test.tsx new file mode 100644 index 00000000..a04b72f6 --- /dev/null +++ b/src/components/error/__tests__/RouteErrorBoundary.test.tsx @@ -0,0 +1,67 @@ +const pushMock = jest.fn() + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: pushMock, + replace: jest.fn(), + refresh: jest.fn(), + back: jest.fn(), + forward: jest.fn(), + prefetch: jest.fn(), + }), + useSearchParams: () => new URLSearchParams(), + usePathname: () => '/', +})) + +import React from 'react' +import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/react' +import { RouteErrorBoundary } from '@/components/error/RouteErrorBoundary' +import { ErrorCategory, ErrorSeverity, type AppError } from '@/types/errors' + +describe('RouteErrorBoundary', () => { + const resetError = jest.fn() + const mockError = { + id: 'error-123', + category: ErrorCategory.NETWORK, + severity: ErrorSeverity.HIGH, + message: 'Unable to reach the network', + userMessage: 'Cannot reach network at this time.', + timestamp: new Date(), + context: { endpoint: '/properties' }, + isRecoverable: false, + shouldReport: true, + } as AppError + + beforeEach(() => { + pushMock.mockClear() + resetError.mockClear() + }) + + it('renders the expected error UI for a network error', () => { + render() + + expect(screen.getByRole('heading', { name: /Network Error/i })).toBeInTheDocument() + expect(screen.getByText(/Cannot reach network at this time./i)).toBeInTheDocument() + expect(screen.getByText(/Error in:/i)).toBeInTheDocument() + expect(screen.getByText('Properties')).toBeInTheDocument() + expect(screen.getByText(/Error Details/i)).toBeInTheDocument() + }) + + it('calls resetError when the retry button is clicked', async () => { + render() + + await userEvent.click(screen.getByRole('button', { name: /try again/i })) + + expect(resetError).toHaveBeenCalledTimes(1) + }) + + it('navigates home and resets the error when the home button is clicked', async () => { + render() + + await userEvent.click(screen.getByRole('button', { name: /go home/i })) + + expect(pushMock).toHaveBeenCalledWith('/') + expect(resetError).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/error/__tests__/routeErrorHelpers.test.ts b/src/components/error/__tests__/routeErrorHelpers.test.ts new file mode 100644 index 00000000..c1f38821 --- /dev/null +++ b/src/components/error/__tests__/routeErrorHelpers.test.ts @@ -0,0 +1,41 @@ +import { ErrorCategory } from '@/types/errors' +import { getRouteErrorIconConfig, getRouteErrorTitle } from '@/components/error/routeErrorHelpers' + +describe('routeErrorHelpers', () => { + describe('getRouteErrorTitle', () => { + it('returns the expected title for WEB3 errors', () => { + expect(getRouteErrorTitle(ErrorCategory.WEB3)).toBe('Wallet Connection Error') + }) + + it('returns the expected title for NETWORK errors', () => { + expect(getRouteErrorTitle(ErrorCategory.NETWORK)).toBe('Network Error') + }) + + it('returns a fallback title for unknown error categories', () => { + expect(getRouteErrorTitle(ErrorCategory.UNKNOWN)).toBe('Something went wrong') + }) + }) + + describe('getRouteErrorIconConfig', () => { + it('returns orange styling for WEB3 errors', () => { + expect(getRouteErrorIconConfig(ErrorCategory.WEB3)).toEqual({ + bgClass: 'bg-orange-100 dark:bg-orange-900/20', + iconClass: 'text-orange-600 dark:text-orange-400', + }) + }) + + it('returns red styling for NETWORK errors', () => { + expect(getRouteErrorIconConfig(ErrorCategory.NETWORK)).toEqual({ + bgClass: 'bg-red-100 dark:bg-red-900/20', + iconClass: 'text-red-600 dark:text-red-400', + }) + }) + + it('returns neutral styling for unknown categories', () => { + expect(getRouteErrorIconConfig(ErrorCategory.UNKNOWN)).toEqual({ + bgClass: 'bg-gray-100 dark:bg-gray-800', + iconClass: 'text-gray-600 dark:text-gray-400', + }) + }) + }) +}) diff --git a/src/components/error/routeErrorHelpers.ts b/src/components/error/routeErrorHelpers.ts new file mode 100644 index 00000000..7239ff9c --- /dev/null +++ b/src/components/error/routeErrorHelpers.ts @@ -0,0 +1,43 @@ +import { ErrorCategory } from '@/types/errors' + +export interface RouteErrorIconConfig { + bgClass: string + iconClass: string +} + +export const getRouteErrorIconConfig = (category: ErrorCategory): RouteErrorIconConfig => { + switch (category) { + case ErrorCategory.WEB3: + return { + bgClass: 'bg-orange-100 dark:bg-orange-900/20', + iconClass: 'text-orange-600 dark:text-orange-400', + } + case ErrorCategory.NETWORK: + return { + bgClass: 'bg-red-100 dark:bg-red-900/20', + iconClass: 'text-red-600 dark:text-red-400', + } + default: + return { + bgClass: 'bg-gray-100 dark:bg-gray-800', + iconClass: 'text-gray-600 dark:text-gray-400', + } + } +} + +export const getRouteErrorTitle = (category: ErrorCategory): string => { + switch (category) { + case ErrorCategory.WEB3: + return 'Wallet Connection Error' + case ErrorCategory.NETWORK: + return 'Network Error' + case ErrorCategory.VALIDATION: + return 'Validation Error' + case ErrorCategory.PERMISSION: + return 'Permission Error' + case ErrorCategory.AUTHENTICATION: + return 'Authentication Error' + default: + return 'Something went wrong' + } +}