diff --git a/package-lock.json b/package-lock.json index b6a6c09..c4d6074 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "lucide-react": "^0.553.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-error-boundary": "^6.1.0", "react-i18next": "^16.2.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", @@ -26393,6 +26394,15 @@ "react": "^19.2.0" } }, + "node_modules/react-error-boundary": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/react-error-boundary/-/react-error-boundary-6.1.0.tgz", + "integrity": "sha512-02k9WQ/mUhdbXir0tC1NiMesGzRPaCsJEWU/4bcFrbY1YMZOtHShtZP6zw0SJrBWA/31H0KT9/FgdL8+sPKgHA==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-i18next": { "version": "16.3.5", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz", diff --git a/package.json b/package.json index c33dae8..399a0c5 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "lucide-react": "^0.553.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-error-boundary": "^6.1.0", "react-i18next": "^16.2.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", diff --git a/src/components/CloudAccountList.tsx b/src/components/CloudAccountList.tsx index 906a45b..0e66733 100644 --- a/src/components/CloudAccountList.tsx +++ b/src/components/CloudAccountList.tsx @@ -306,11 +306,19 @@ export function CloudAccountList() { if (isError) { return ( -
+
{t('cloud.error.loadFailed')}
{t('action.retry')}
- diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index b8224b6..346db0b 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Link, Outlet, useLocation } from '@tanstack/react-router'; import { cn } from '@/lib/utils'; import { StatusBar } from '@/components/StatusBar'; @@ -9,14 +9,20 @@ import { Rocket, ChevronLeft, ChevronRight, + RefreshCw, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { ErrorBoundary } from 'react-error-boundary'; +import { useToast } from '@/components/ui/use-toast'; +import { getLocalizedErrorMessage } from '@/utils/errorMessages'; export const MainLayout: React.FC = () => { const location = useLocation(); const { t } = useTranslation(); + const { toast } = useToast(); + const hasShownRouteErrorToastRef = useRef(false); // Initialize state from localStorage if available, default to false (expanded) const [isCollapsed, setIsCollapsed] = useState(() => { @@ -147,7 +153,38 @@ export const MainLayout: React.FC = () => { {/* Content Area */}
- + { + hasShownRouteErrorToastRef.current = false; + }} + onError={(error) => { + if (hasShownRouteErrorToastRef.current) { + return; + } + + toast({ + title: t('error.generic'), + description: getLocalizedErrorMessage(error, t), + variant: 'destructive', + }); + hasShownRouteErrorToastRef.current = true; + }} + fallbackRender={({ resetErrorBoundary }) => ( +
+
+
{t('error.generic')}
+
{t('action.retry')}
+ +
+
+ )} + > + +
diff --git a/src/main.ts b/src/main.ts index 2c368b9..dd5085a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -199,7 +199,7 @@ function createWindow({ startHidden }: { startHidden: boolean }) { ipcContext.setMainWindow(mainWindow); logger.info('createWindow: setMainWindow done'); - if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { + if (inDevelopment && MAIN_WINDOW_VITE_DEV_SERVER_URL) { logger.info(`createWindow: loading URL ${MAIN_WINDOW_VITE_DEV_SERVER_URL}`); mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); } else { diff --git a/src/tests/e2e/app.spec.ts b/src/tests/e2e/app.spec.ts index dfd8feb..874fd6a 100644 --- a/src/tests/e2e/app.spec.ts +++ b/src/tests/e2e/app.spec.ts @@ -2,13 +2,72 @@ import { test, expect, ElectronApplication } from '@playwright/test'; import { _electron as electron } from 'playwright'; import path from 'path'; +const injectCloudAccountsFailureScript = ` +(() => { + const START_ORPC_SERVER = 'start-orpc-server'; + const originalPostMessage = window.postMessage.bind(window); + + window.postMessage = function (message, targetOrigin, transfer) { + if (message === START_ORPC_SERVER && Array.isArray(transfer) && transfer[0]) { + const serverPort = transfer[0]; + if (typeof serverPort.start === 'function') { + serverPort.start(); + } + + serverPort.onmessage = (event) => { + try { + const request = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + const requestId = request && (request.i || request.id); + const requestUrl = (request && request.p && request.p.u) || ''; + + if (!requestId) { + return; + } + + if (requestUrl.includes('/cloud/listCloudAccounts')) { + serverPort.postMessage( + JSON.stringify({ + i: requestId, + p: { s: 500, b: { json: { message: 'internal server error' } } }, + }), + ); + return; + } + + let result = null; + if (requestUrl.includes('/process/isProcessRunning')) { + result = false; + } else if (requestUrl.includes('/cloud/getAutoSwitchEnabled')) { + result = false; + } + + serverPort.postMessage( + JSON.stringify({ + i: requestId, + p: { b: { json: result } }, + }), + ); + } catch (_error) { + // Ignore malformed messages in E2E bridge stub. + } + }; + + return; + } + + return originalPostMessage(message, targetOrigin, transfer); + }; +})(); +`; + test.describe('Antigravity Manager', () => { let electronApp: ElectronApplication; + const electronMainPath = path.join(__dirname, '../../../.vite/build/main.js'); test.beforeAll(async () => { // Launch Electron app electronApp = await electron.launch({ - args: [path.join(__dirname, '../../../.vite/build/main.js')], + args: [electronMainPath], }); }); @@ -23,8 +82,8 @@ test.describe('Antigravity Manager', () => { const title = await window.title(); expect(title).toBe('Antigravity Manager'); - // Check for main elements - h2 is present (contains i18n content) - await expect(window.locator('h2').first()).toBeVisible(); + await expect(window.getByRole('main')).toBeVisible(); + await expect(window.locator('a[href="/settings"]').first()).toBeVisible(); }); test('should navigate to settings', async () => { @@ -38,6 +97,24 @@ test.describe('Antigravity Manager', () => { await expect(window.locator('h2').first()).toBeVisible(); }); + test('should show fallback UI when cloud accounts loading fails', async () => { + await electronApp.close(); + + electronApp = await electron.launch({ + args: [electronMainPath], + }); + + const page = await electronApp.firstWindow(); + await page.addInitScript(injectCloudAccountsFailureScript); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + const mainContent = page.getByRole('main'); + await expect(mainContent.getByTestId('cloud-load-error-fallback')).toBeVisible({ + timeout: 15000, + }); + await expect(mainContent.getByTestId('cloud-load-error-retry')).toBeVisible(); + }); + // More detailed tests would require mocking IPC or having a real environment // For now, we verify basic navigation and rendering });