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",
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user