diff --git a/components/dashboard/StatsCardSkeleton.error-resilience.test.tsx b/components/dashboard/StatsCardSkeleton.error-resilience.test.tsx new file mode 100644 index 000000000..f0c6ecba9 --- /dev/null +++ b/components/dashboard/StatsCardSkeleton.error-resilience.test.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, fireEvent } from '@testing-library/react'; +import StatsCardSkeleton from './StatsCardSkeleton'; + +type BoundaryProps = { + children: React.ReactNode; + onError?: (error: Error, info: React.ErrorInfo) => void; + onReset?: () => void; +}; +type BoundaryState = { hasError: boolean }; + +class RecoveryBoundary extends React.Component { + state: BoundaryState = { hasError: false }; + + static getDerivedStateFromError(): BoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + this.props.onError?.(error, info); + } + + reset = () => { + this.props.onReset?.(); + this.setState({ hasError: false }); + }; + + render() { + if (this.state.hasError) { + return ( +
+ + +
+ ); + } + return this.props.children; + } +} + +function ThrowingChild(): React.ReactElement { + throw new Error('Simulated database connectivity error'); +} + +// Creates a child whose failure is controlled from the OUTSIDE (via +// stopThrowing), not from within its own render. This keeps it failing +// consistently through React's internal render retries, and only clears +// once the user explicitly resets. +function createControllableThrowingChild() { + let shouldThrow = true; + const ControllableChild = (): React.ReactElement => { + if (shouldThrow) { + throw new Error('Simulated database connectivity error'); + } + return
Stats loaded
; + }; + return { + ControllableChild, + stopThrowing: () => { + shouldThrow = false; + }, + }; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('StatsCardSkeleton - Error Resilience', () => { + it('renders a clean recovery UI with the skeleton instead of crashing when a nested child throws a runtime exception', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { getByTestId, container } = render( + + + + ); + + expect(getByTestId('recovery-panel')).toBeDefined(); + expect(container.querySelectorAll('.shimmer').length).toBe(16); + }); + + it('recovers with the skeleton fallback when a simulated database connectivity error occurs', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + function DbDependentChild(): React.ReactElement { + throw new Error('DB connection refused: ECONNREFUSED'); + } + + const { getByTestId } = render( + + + + ); + + expect(getByTestId('recovery-panel')).toBeDefined(); + expect(getByTestId('recovery-panel').querySelectorAll('.shimmer').length).toBe(16); + }); + + it('logs the caught exception to a dev-telemetry tracker while still rendering the skeleton fallback', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const telemetryTracker = vi.fn(); + + const { getByTestId } = render( + telemetryTracker(error.message)}> + + + ); + + expect(telemetryTracker).toHaveBeenCalledTimes(1); + expect(telemetryTracker).toHaveBeenCalledWith('Simulated database connectivity error'); + expect(getByTestId('recovery-panel')).toBeDefined(); + }); + + it('provides a working reset path on the recovery panel that clears the error state once the failure has passed', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const { ControllableChild, stopThrowing } = createControllableThrowingChild(); + + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId('recovery-panel')).toBeDefined(); + + fireEvent.click(getByTestId('reset-button')); + + expect(queryByTestId('recovery-panel')).toBeNull(); + expect(getByTestId('real-content')).toBeDefined(); + }); + + it('remains stable and renders a consistent shimmer structure across repeated mount and unmount cycles', () => { + for (let i = 0; i < 5; i += 1) { + const { container, unmount } = render(); + expect(container.querySelectorAll('.shimmer').length).toBe(16); + unmount(); + } + }); +});