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",
"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",
+1
View File
@@ -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",
+10 -2
View File
@@ -306,11 +306,19 @@ export function CloudAccountList() {
if (isError) {
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" />
<div className="text-sm font-medium">{t('cloud.error.loadFailed')}</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" />
{t('action.retry')}
</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 { 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 */}
<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>
</div>
</div>
+1 -1
View File
@@ -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 {
+80 -3
View File
@@ -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
});