feat: global error fallback and e2e (#91)

* fix(ui): add global route fallback with error toast and retry

* test(e2e): cover cloud accounts error fallback UI
This commit is contained in:
vin authored and GitHub committed 2026-02-11 02:25:10 +08:00
1 parent bcca5ec935
commit b89dd2c3bb
6 files changed
+141 -8

No files matched your search

+10
View File
@@ -60,6 +60,7 @@
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-error-boundary": "^6.1.0",
"react-i18next": "^16.2.4", "react-i18next": "^16.2.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
@@ -26393,6 +26394,15 @@
"react": "^19.2.0" "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": { "node_modules/react-i18next": {
"version": "16.3.5", "version": "16.3.5",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",
+1
View File
@@ -139,6 +139,7 @@
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-error-boundary": "^6.1.0",
"react-i18next": "^16.2.4", "react-i18next": "^16.2.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
+10 -2
View File
@@ -306,11 +306,19 @@ export function CloudAccountList() {
if (isError) { if (isError) {
return ( return (
<div className="col-span-full rounded-lg border border-dashed p-8 text-center"> <div
className="col-span-full rounded-lg border border-dashed p-8 text-center"
data-testid="cloud-load-error-fallback"
>
<Cloud className="text-muted-foreground mx-auto mb-3 h-10 w-10 opacity-40" /> <Cloud className="text-muted-foreground mx-auto mb-3 h-10 w-10 opacity-40" />
<div className="text-sm font-medium">{t('cloud.error.loadFailed')}</div> <div className="text-sm font-medium">{t('cloud.error.loadFailed')}</div>
<div className="text-muted-foreground mt-2 text-xs">{t('action.retry')}</div> <div className="text-muted-foreground mt-2 text-xs">{t('action.retry')}</div>
<Button className="mt-4" variant="outline" onClick={() => void refetch()}> <Button
className="mt-4"
variant="outline"
onClick={() => void refetch()}
data-testid="cloud-load-error-retry"
>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
{t('action.retry')} {t('action.retry')}
</Button> </Button>
+39 -2
View File
@@ -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 { Link, Outlet, useLocation } from '@tanstack/react-router';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { StatusBar } from '@/components/StatusBar'; import { StatusBar } from '@/components/StatusBar';
@@ -9,14 +9,20 @@ import {
Rocket, Rocket,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; 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 = () => { export const MainLayout: React.FC = () => {
const location = useLocation(); const location = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast();
const hasShownRouteErrorToastRef = useRef(false);
// Initialize state from localStorage if available, default to false (expanded) // Initialize state from localStorage if available, default to false (expanded)
const [isCollapsed, setIsCollapsed] = useState(() => { const [isCollapsed, setIsCollapsed] = useState(() => {
@@ -147,7 +153,38 @@ export const MainLayout: React.FC = () => {
{/* Content Area */} {/* Content Area */}
<main className="flex-1 overflow-auto transition-all duration-300"> <main className="flex-1 overflow-auto transition-all duration-300">
<Outlet /> <ErrorBoundary
resetKeys={[location.pathname]}
onReset={() => {
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 }) => (
<div className="mx-auto max-w-3xl p-6">
<div className="rounded-lg border border-dashed p-8 text-center">
<div className="text-lg font-semibold">{t('error.generic')}</div>
<div className="text-muted-foreground mt-2 text-sm">{t('action.retry')}</div>
<Button className="mt-4" variant="outline" onClick={resetErrorBoundary}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('action.retry')}
</Button>
</div>
</div>
)}
>
<Outlet />
</ErrorBoundary>
</main> </main>
</div> </div>
</div> </div>
+1 -1
View File
@@ -199,7 +199,7 @@ function createWindow({ startHidden }: { startHidden: boolean }) {
ipcContext.setMainWindow(mainWindow); ipcContext.setMainWindow(mainWindow);
logger.info('createWindow: setMainWindow done'); 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}`); logger.info(`createWindow: loading URL ${MAIN_WINDOW_VITE_DEV_SERVER_URL}`);
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
} else { } else {
+80 -3
View File
@@ -2,13 +2,72 @@ import { test, expect, ElectronApplication } from '@playwright/test';
import { _electron as electron } from 'playwright'; import { _electron as electron } from 'playwright';
import path from 'path'; 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', () => { test.describe('Antigravity Manager', () => {
let electronApp: ElectronApplication; let electronApp: ElectronApplication;
const electronMainPath = path.join(__dirname, '../../../.vite/build/main.js');
test.beforeAll(async () => { test.beforeAll(async () => {
// Launch Electron app // Launch Electron app
electronApp = await electron.launch({ 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(); const title = await window.title();
expect(title).toBe('Antigravity Manager'); expect(title).toBe('Antigravity Manager');
// Check for main elements - h2 is present (contains i18n content) await expect(window.getByRole('main')).toBeVisible();
await expect(window.locator('h2').first()).toBeVisible(); await expect(window.locator('a[href="/settings"]').first()).toBeVisible();
}); });
test('should navigate to settings', async () => { test('should navigate to settings', async () => {
@@ -38,6 +97,24 @@ test.describe('Antigravity Manager', () => {
await expect(window.locator('h2').first()).toBeVisible(); 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 // More detailed tests would require mocking IPC or having a real environment
// For now, we verify basic navigation and rendering // For now, we verify basic navigation and rendering
}); });