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:
6 files changed
+141
-8
No files matched your search
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
Reference in new issue
Block a user