136 lines
8.3 KiB
TypeScript
136 lines
8.3 KiB
TypeScript
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { lazy, useEffect, useState } from 'react';
|
|
import { MemoryRouter, useSearchParams } from 'react-router-dom';
|
|
import { isRouteChunkError, PlatformErrorBoundary, RoutePage } from './RouteBoundary';
|
|
import { ROUTER_FUTURE } from './routerConfig';
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
window.sessionStorage.clear();
|
|
});
|
|
|
|
describe('route recovery boundary', () => {
|
|
it('keeps a recovery screen when the platform shell fails outside page routes', () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
const BrokenShell = () => { throw new Error('shell exploded'); };
|
|
|
|
render(<PlatformErrorBoundary><BrokenShell /></PlatformErrorBoundary>);
|
|
|
|
expect(screen.getByRole('alert')).toHaveTextContent('平台外壳运行异常');
|
|
expect(document.querySelector('.v2-route-error-card.semi-card')).toBeInTheDocument();
|
|
expect(screen.getByText('查看技术信息')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /重新加载平台/ })).toHaveClass('semi-button');
|
|
expect(screen.getByRole('button', { name: /返回全局监控/ })).toHaveClass('semi-button');
|
|
});
|
|
|
|
it('recognizes deployment-related lazy chunk failures', () => {
|
|
expect(isRouteChunkError(new TypeError('Failed to fetch dynamically imported module: /assets/Monitor-old.js'))).toBe(true);
|
|
expect(isRouteChunkError(new Error('Loading chunk 18 failed'))).toBe(true);
|
|
expect(isRouteChunkError(Object.assign(new Error('Route chunk load timed out after 12000ms: history'), { name: 'RouteChunkLoadTimeoutError' }))).toBe(true);
|
|
expect(isRouteChunkError(new Error('Cannot read properties of undefined'))).toBe(false);
|
|
});
|
|
|
|
it('isolates a page render failure and keeps a visible recovery action', () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
const BrokenPage = () => { throw new Error('render exploded'); };
|
|
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history']}><RoutePage page={BrokenPage} label="历史数据" /></MemoryRouter>);
|
|
expect(screen.getByRole('alert')).toHaveTextContent('当前模块暂时无法显示');
|
|
expect(document.querySelector('.v2-route-error.is-runtime .v2-route-error-card.semi-card')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /刷新当前页面/ })).toHaveClass('semi-button');
|
|
expect(screen.getByRole('button', { name: /返回全局监控/ })).toHaveClass('semi-button');
|
|
});
|
|
|
|
it('shows a deployment recovery page when an automatic chunk reload already ran', () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
window.sessionStorage.setItem('vehicle-platform:route-chunk-reload', JSON.stringify({ routeKey: '/statistics', at: Date.now() }));
|
|
const StaleChunkPage = () => { throw new TypeError('Failed to fetch dynamically imported module: /assets/Statistics-old.js'); };
|
|
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/statistics']}><RoutePage page={StaleChunkPage} label="里程查询" /></MemoryRouter>);
|
|
expect(screen.getByRole('alert')).toHaveTextContent('检测到页面版本更新');
|
|
expect(screen.getByText(/旧标签页仍引用上一版本资源/)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /重试加载模块/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it('recreates a rejected lazy route when the operator retries without a full reload', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
window.sessionStorage.setItem('vehicle-platform:route-chunk-reload', JSON.stringify({ routeKey: '/history', at: Date.now() }));
|
|
const failedPage = lazy(() => Promise.reject(new TypeError('Failed to fetch dynamically imported module: /assets/History-old.js')));
|
|
const recreatePage = vi.fn(() => lazy(() => Promise.resolve({ default: () => <strong>历史模块已恢复</strong> })));
|
|
|
|
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history']}><RoutePage page={failedPage} recreatePage={recreatePage} label="历史数据" /></MemoryRouter>);
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('检测到页面版本更新');
|
|
fireEvent.click(screen.getByRole('button', { name: /重试加载模块/ }));
|
|
|
|
await waitFor(() => expect(screen.getByText('历史模块已恢复')).toBeInTheDocument());
|
|
expect(recreatePage).toHaveBeenCalledTimes(1);
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('explains a bounded route timeout instead of leaving the loading skeleton forever', () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
window.sessionStorage.setItem('vehicle-platform:route-chunk-reload', JSON.stringify({ routeKey: '/history', at: Date.now() }));
|
|
const TimeoutPage = () => {
|
|
throw Object.assign(new Error('Route chunk load timed out after 10000ms: history'), { name: 'RouteChunkLoadTimeoutError' });
|
|
};
|
|
|
|
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history']}><RoutePage page={TimeoutPage} label="历史数据" /></MemoryRouter>);
|
|
|
|
expect(screen.getByRole('alert')).toHaveTextContent('页面资源加载超时');
|
|
expect(screen.getByText(/网络暂时不稳定/)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /刷新当前页面/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps a meaningful content skeleton visible while a route chunk is pending', () => {
|
|
const PendingPage = lazy(() => new Promise<never>(() => undefined));
|
|
const { container } = render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access']}><RoutePage page={PendingPage} label="接入管理" /></MemoryRouter>);
|
|
expect(screen.getByRole('status')).toHaveTextContent('正在加载接入管理');
|
|
expect(screen.getByText(/导航与筛选状态已保留/)).toBeInTheDocument();
|
|
expect(container.querySelectorAll('.v2-page-skeleton > i')).toHaveLength(4);
|
|
});
|
|
|
|
it('keeps page state mounted when only URL search parameters change', () => {
|
|
let mounts = 0;
|
|
function FilterablePage() {
|
|
const [count, setCount] = useState(0);
|
|
const [, setSearchParams] = useSearchParams();
|
|
useEffect(() => { mounts += 1; }, []);
|
|
return <><strong>本地状态 {count}</strong><button onClick={() => setCount((value) => value + 1)}>修改本地状态</button><button onClick={() => setSearchParams({ page: '2' })}>修改查询参数</button></>;
|
|
}
|
|
const view = render(<main className="v2-content"><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><RoutePage page={FilterablePage} label="告警中心" /></MemoryRouter></main>);
|
|
const content = view.container.querySelector<HTMLElement>('.v2-content')!;
|
|
content.scrollTop = 240;
|
|
fireEvent.click(screen.getByRole('button', { name: '修改本地状态' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '修改查询参数' }));
|
|
expect(screen.getByText('本地状态 1')).toBeInTheDocument();
|
|
expect(mounts).toBe(1);
|
|
expect(content.scrollTop).toBe(240);
|
|
});
|
|
|
|
it('retries a failed route when its search scope changes without remounting healthy scopes', () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
let healthyMounts = 0;
|
|
function ScopeSensitivePage() {
|
|
const [params] = useSearchParams();
|
|
if (params.get('scope') === 'broken') throw new Error('invalid route scope');
|
|
useEffect(() => { healthyMounts += 1; }, []);
|
|
return <strong>新筛选范围已恢复</strong>;
|
|
}
|
|
function RecoveryHarness() {
|
|
const [, setSearchParams] = useSearchParams();
|
|
return <><button type="button" onClick={() => setSearchParams({ scope: 'healthy' })}>修正筛选条件</button><RoutePage page={ScopeSensitivePage} label="告警中心" /></>;
|
|
}
|
|
|
|
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?scope=broken']}><RecoveryHarness /></MemoryRouter>);
|
|
expect(screen.getByRole('alert')).toHaveTextContent('当前模块暂时无法显示');
|
|
expect(screen.getByText(/更换筛选条件会自动重试/)).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '修正筛选条件' }));
|
|
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
expect(screen.getByText('新筛选范围已恢复')).toBeInTheDocument();
|
|
expect(healthyMounts).toBe(1);
|
|
});
|
|
});
|