diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx index f89ce44..08db304 100755 --- a/src/renderer/components/App.tsx +++ b/src/renderer/components/App.tsx @@ -7,6 +7,7 @@ import '../i18n'; import ConfigTab from './ConfigTab'; import DarkModeToggle from './DarkModeToggle'; +import ErrorBoundary from './ErrorBoundary'; import LanguageSelector from './LanguageSelector'; import ProcessedTab from './ProcessedTab'; import SourceTab from './SourceTab'; @@ -60,6 +61,9 @@ const AppContent = () => { } = useApp(); const appWindow = globalThis as Window & typeof globalThis; + const tabFallbackTitle = t('errors.tabCrashedTitle'); + const tabFallbackMessage = t('errors.tabCrashedDescription'); + const retryLabel = t('errors.retryRender'); return (
@@ -134,7 +138,14 @@ const AppContent = () => { aria-labelledby='tab-config' className={`absolute inset-0 overflow-y-auto bg-white dark:bg-gray-800 p-4 text-gray-900 dark:text-gray-100 transition-colors duration-200 ${activeTab === 'config' ? '' : 'hidden'}`} > - + + +
{ aria-labelledby='tab-source' className={`absolute inset-0 overflow-y-auto bg-white dark:bg-gray-800 p-4 text-gray-900 dark:text-gray-100 transition-colors duration-200 ${activeTab === 'source' ? '' : 'hidden'}`} > - + + +
{ aria-labelledby='tab-processed' className={`absolute inset-0 overflow-y-auto bg-white dark:bg-gray-800 p-4 text-gray-900 dark:text-gray-100 transition-colors duration-200 ${activeTab === 'processed' ? '' : 'hidden'}`} > - + + +
@@ -178,12 +203,20 @@ const AppContent = () => { }; const App = () => { + const { t } = useTranslation(); + return ( - - - - - + + + + + + + ); }; diff --git a/src/renderer/components/ErrorBoundary.tsx b/src/renderer/components/ErrorBoundary.tsx new file mode 100644 index 0000000..6b15f09 --- /dev/null +++ b/src/renderer/components/ErrorBoundary.tsx @@ -0,0 +1,85 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; + +type ErrorBoundaryProps = { + children: ReactNode; + fallbackTitle: string; + fallbackMessage: string; + resetLabel: string; + onReset?: () => void; + resetKeys?: ReadonlyArray; +}; + +type ErrorBoundaryState = { + hasError: boolean; +}; + +const haveResetKeysChanged = ( + previousKeys: ReadonlyArray = [], + nextKeys: ReadonlyArray = [] +) => { + if (previousKeys.length !== nextKeys.length) { + return true; + } + + for (let index = 0; index < previousKeys.length; index += 1) { + if (!Object.is(previousKeys[index], nextKeys[index])) { + return true; + } + } + + return false; +}; + +class ErrorBoundary extends Component { + state: ErrorBoundaryState = { + hasError: false, + }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Renderer ErrorBoundary captured an error:', error, errorInfo); + } + + componentDidUpdate(previousProps: ErrorBoundaryProps) { + if (!this.state.hasError) { + return; + } + + if (haveResetKeysChanged(previousProps.resetKeys, this.props.resetKeys)) { + this.setState({ hasError: false }); + } + } + + private readonly handleReset = () => { + this.props.onReset?.(); + this.setState({ hasError: false }); + }; + + render() { + if (!this.state.hasError) { + return this.props.children; + } + + return ( +
+

{this.props.fallbackTitle}

+

{this.props.fallbackMessage}

+ +
+ ); + } +} + +export default ErrorBoundary; diff --git a/src/renderer/i18n/locales/de/common.json b/src/renderer/i18n/locales/de/common.json index 131c4cc..04f3838 100644 --- a/src/renderer/i18n/locales/de/common.json +++ b/src/renderer/i18n/locales/de/common.json @@ -123,6 +123,11 @@ "noFilesSelectedForProcessing": "Es sind keine Dateien zur Verarbeitung ausgewählt. Wechsle zum Quell-Tab und wähle Dateien aus.", "refreshFailed": "Beim Aktualisieren des Inhalts ist ein Fehler aufgetreten. Details in der Konsole.", "noProcessedContentToSave": "Kein verarbeiteter Inhalt zum Speichern vorhanden.", - "saveFailed": "Beim Speichern der Datei ist ein Fehler aufgetreten. Details in der Konsole." + "saveFailed": "Beim Speichern der Datei ist ein Fehler aufgetreten. Details in der Konsole.", + "rendererRootCrashedTitle": "Die App hat einen unerwarteten Fehler festgestellt.", + "rendererRootCrashedDescription": "Bitte erneut versuchen. Wenn das erneut passiert, starte die App neu.", + "tabCrashedTitle": "Dieser Tab konnte nicht gerendert werden.", + "tabCrashedDescription": "Versuche diese Ansicht erneut. Falls das Problem bleibt, wechsle den Tab und versuche es noch einmal.", + "retryRender": "Erneut versuchen" } } diff --git a/src/renderer/i18n/locales/en/common.json b/src/renderer/i18n/locales/en/common.json index 516c3b4..1acf3d3 100644 --- a/src/renderer/i18n/locales/en/common.json +++ b/src/renderer/i18n/locales/en/common.json @@ -123,6 +123,11 @@ "noFilesSelectedForProcessing": "No files are selected for processing. Please go to the Source tab and select files.", "refreshFailed": "An error occurred while refreshing content. Check the console for details.", "noProcessedContentToSave": "No processed content to save.", - "saveFailed": "An error occurred while saving the file. Check the console for details." + "saveFailed": "An error occurred while saving the file. Check the console for details.", + "rendererRootCrashedTitle": "The app hit an unexpected error.", + "rendererRootCrashedDescription": "Please retry. If this keeps happening, restart the app.", + "tabCrashedTitle": "This tab failed to render.", + "tabCrashedDescription": "Retry this view. If the issue persists, switch tabs and try again.", + "retryRender": "Retry" } } diff --git a/src/renderer/i18n/locales/es/common.json b/src/renderer/i18n/locales/es/common.json index 677d6e8..34413c8 100644 --- a/src/renderer/i18n/locales/es/common.json +++ b/src/renderer/i18n/locales/es/common.json @@ -123,6 +123,11 @@ "noFilesSelectedForProcessing": "No hay archivos seleccionados para procesar. Ve a la pestaña Fuente y selecciona archivos.", "refreshFailed": "Se produjo un error al actualizar el contenido. Revisa la consola para más detalles.", "noProcessedContentToSave": "No hay contenido procesado para guardar.", - "saveFailed": "Se produjo un error al guardar el archivo. Revisa la consola para más detalles." + "saveFailed": "Se produjo un error al guardar el archivo. Revisa la consola para más detalles.", + "rendererRootCrashedTitle": "La aplicación encontró un error inesperado.", + "rendererRootCrashedDescription": "Intenta de nuevo. Si vuelve a ocurrir, reinicia la aplicación.", + "tabCrashedTitle": "Esta pestaña no se pudo renderizar.", + "tabCrashedDescription": "Vuelve a intentarlo en esta vista. Si persiste, cambia de pestaña e inténtalo otra vez.", + "retryRender": "Reintentar" } } diff --git a/src/renderer/i18n/locales/fr/common.json b/src/renderer/i18n/locales/fr/common.json index 3118008..db31627 100644 --- a/src/renderer/i18n/locales/fr/common.json +++ b/src/renderer/i18n/locales/fr/common.json @@ -123,6 +123,11 @@ "noFilesSelectedForProcessing": "Aucun fichier sélectionné pour le traitement. Allez dans l'onglet Source et sélectionnez des fichiers.", "refreshFailed": "Une erreur s'est produite lors de l'actualisation du contenu. Consultez la console pour plus de détails.", "noProcessedContentToSave": "Aucun contenu traité à enregistrer.", - "saveFailed": "Une erreur s'est produite lors de l'enregistrement du fichier. Consultez la console pour plus de détails." + "saveFailed": "Une erreur s'est produite lors de l'enregistrement du fichier. Consultez la console pour plus de détails.", + "rendererRootCrashedTitle": "L'application a rencontré une erreur inattendue.", + "rendererRootCrashedDescription": "Réessayez. Si le problème persiste, redémarrez l'application.", + "tabCrashedTitle": "Cet onglet n'a pas pu être affiché.", + "tabCrashedDescription": "Réessayez cette vue. Si le problème persiste, changez d'onglet puis recommencez.", + "retryRender": "Réessayer" } } diff --git a/tests/catalog.md b/tests/catalog.md index 52d00f2..4087878 100644 --- a/tests/catalog.md +++ b/tests/catalog.md @@ -25,6 +25,7 @@ Purpose: quick map of what is covered, why it exists, and which command to run. | ---------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `tests/unit/components/app.test.tsx` | `src/renderer/components/App.tsx` | Tab switching, config load, directory selection, processing flow, error handling | | `tests/unit/components/app-source-tab-activity.test.tsx` | `src/renderer/components/App.tsx` + `src/renderer/components/SourceTab.tsx` | Guards against hidden-tab background token counting after tab switch | +| `tests/unit/components/error-boundary.test.tsx` | `src/renderer/components/ErrorBoundary.tsx` | Child render failure capture, fallback rendering, reset-key recovery, and retry callback behavior | | `tests/unit/components/config-tab.test.tsx` | `src/renderer/components/ConfigTab.tsx` | Config toggles/inputs, dev-only provider surface gating, provider validation/connection wiring, provider-config preservation, directory picker trigger | | `tests/unit/components/file-tree.test.tsx` | `src/renderer/components/FileTree.tsx` | Tree render, folder expand/collapse, select all, empty-state behavior | | `tests/unit/components/language-selector.test.tsx` | `src/renderer/components/LanguageSelector.tsx` | Locale selector rendering, language switching, and localStorage persistence | diff --git a/tests/unit/components/error-boundary.test.tsx b/tests/unit/components/error-boundary.test.tsx new file mode 100644 index 0000000..9027efc --- /dev/null +++ b/tests/unit/components/error-boundary.test.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import ErrorBoundary from '../../../src/renderer/components/ErrorBoundary'; + +const ProblemChild = ({ shouldThrow }: { shouldThrow: boolean }) => { + if (shouldThrow) { + throw new Error('render boom'); + } + return
child rendered
; +}; + +describe('ErrorBoundary', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('renders children when no error is thrown', () => { + render( + + + + ); + + expect(screen.getByText('child rendered')).toBeInTheDocument(); + }); + + it('shows fallback UI when a child throws', () => { + render( + + + + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Fallback title'); + expect(screen.getByRole('alert')).toHaveTextContent('Fallback message'); + }); + + it('resets and renders children again when reset keys change', () => { + const Harness = () => { + const [shouldThrow, setShouldThrow] = useState(true); + return ( + <> + + + + + + ); + }; + + render(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('recover')); + expect(screen.getByText('child rendered')).toBeInTheDocument(); + }); + + it('calls onReset when retry button is clicked', () => { + const onReset = jest.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onReset).toHaveBeenCalledTimes(1); + }); +});