Skip to content

Commit d1b6089

Browse files
LessUpCopilot
andcommitted
fix: harden startup and runtime flows
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5ab1a49 commit d1b6089

11 files changed

Lines changed: 276 additions & 52 deletions

File tree

src/App.tsx

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
22
import { Suspense, lazy } from 'react';
33
import ErrorBoundary from '@/components/ui/ErrorBoundary';
44
import LoadingSpinner from '@/components/ui/LoadingSpinner';
5-
import { ServicesProvider } from '@/core/services';
65

76
// 懒加载页面组件
87
const LandingPage = lazy(() => import('@/pages/LandingPage'));
9-
const AdvancedDigitalHumanPage = lazy(() => import('@/pages/AdvancedDigitalHumanPage'));
8+
const AdvancedDigitalHumanAppPage = lazy(() => import('@/pages/AdvancedDigitalHumanAppPage'));
109

1110
// 页面加载 fallback
1211
function PageLoader() {
@@ -20,22 +19,20 @@ function PageLoader() {
2019
export default function App() {
2120
return (
2221
<ErrorBoundary>
23-
<ServicesProvider>
24-
<Router>
25-
<Suspense fallback={<PageLoader />}>
26-
<Routes>
27-
{/* Landing Page - 产品落地页 */}
28-
<Route path="/" element={<LandingPage />} />
22+
<Router>
23+
<Suspense fallback={<PageLoader />}>
24+
<Routes>
25+
{/* Landing Page - 产品落地页 */}
26+
<Route path="/" element={<LandingPage />} />
2927

30-
{/* App Route - 数字人应用 */}
31-
<Route path="/app" element={<AdvancedDigitalHumanPage />} />
28+
{/* App Route - 数字人应用 */}
29+
<Route path="/app" element={<AdvancedDigitalHumanAppPage />} />
3230

33-
{/* Fallback - 防止未知 hash 路径导致空白页 */}
34-
<Route path="*" element={<Navigate to="/" replace />} />
35-
</Routes>
36-
</Suspense>
37-
</Router>
38-
</ServicesProvider>
31+
{/* Fallback - 防止未知 hash 路径导致空白页 */}
32+
<Route path="*" element={<Navigate to="/" replace />} />
33+
</Routes>
34+
</Suspense>
35+
</Router>
3936
</ErrorBoundary>
4037
);
4138
}

src/__tests__/App.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import App from '@/App';
4+
5+
const createServicesMock = vi.fn();
6+
7+
vi.mock('@/core/createServices', () => ({
8+
createServices: () => createServicesMock(),
9+
}));
10+
11+
vi.mock('@/pages/LandingPage', () => ({
12+
default: () => <div data-testid="landing-page" />,
13+
}));
14+
15+
vi.mock('@/pages/AdvancedDigitalHumanPage', () => ({
16+
default: () => <div data-testid="advanced-page" />,
17+
}));
18+
19+
function buildServices() {
20+
return {
21+
engine: { dispose: vi.fn() },
22+
tts: { dispose: vi.fn() },
23+
asr: { dispose: vi.fn() },
24+
dialogue: { reset: vi.fn() },
25+
};
26+
}
27+
28+
describe('App routing', () => {
29+
beforeEach(() => {
30+
createServicesMock.mockReset();
31+
createServicesMock.mockReturnValue(buildServices());
32+
window.location.hash = '#/';
33+
});
34+
35+
it('does not create app services for the landing route', async () => {
36+
render(<App />);
37+
38+
expect(await screen.findByTestId('landing-page')).toBeInTheDocument();
39+
await waitFor(() => expect(createServicesMock).not.toHaveBeenCalled());
40+
});
41+
42+
it('creates app services for the app route', async () => {
43+
window.location.hash = '#/app';
44+
45+
render(<App />);
46+
47+
expect(await screen.findByTestId('advanced-page')).toBeInTheDocument();
48+
await waitFor(() => expect(createServicesMock).toHaveBeenCalledTimes(1));
49+
});
50+
});

src/__tests__/deviceCapability.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
meetsMinimumTier,
77
getAdaptiveQuality,
88
} from '../core/performance/deviceCapability';
9+
import { loggers } from '../lib/logger';
910

1011
describe('Device Capability Detection', () => {
1112
beforeEach(() => {
@@ -87,6 +88,33 @@ describe('Device Capability Detection', () => {
8788
expect(caps1.tier).toBe(caps2.tier);
8889
});
8990

91+
it('falls back to low-tier capabilities when canvas probing throws', () => {
92+
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => {
93+
throw new Error('canvas unavailable');
94+
});
95+
const errorSpy = vi.spyOn(loggers.core, 'error');
96+
97+
const caps = detectDeviceCapabilities();
98+
99+
expect(caps.tier).toBe('low');
100+
expect(caps.supportsWebGL2).toBe(false);
101+
expect(errorSpy).not.toHaveBeenCalled();
102+
});
103+
104+
it('creates a fresh fallback snapshot after a failed capability probe', () => {
105+
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => {
106+
throw new Error('canvas unavailable');
107+
});
108+
109+
const caps1 = detectDeviceCapabilities();
110+
refreshDeviceCapabilities();
111+
const caps2 = detectDeviceCapabilities();
112+
113+
expect(caps1).not.toBe(caps2);
114+
expect(caps2.tier).toBe('low');
115+
expect(caps2.supportsWebGL2).toBe(false);
116+
});
117+
90118
describe('meetsMinimumTier', () => {
91119
it('should return true for low requirement', () => {
92120
// Any device should meet low requirement

src/__tests__/main.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const renderMock = vi.fn();
4+
5+
vi.mock('react-dom/client', () => ({
6+
createRoot: () => ({
7+
render: renderMock,
8+
}),
9+
}));
10+
11+
vi.mock('sonner', () => ({
12+
Toaster: () => null,
13+
}));
14+
15+
describe('main entry redirect restore', () => {
16+
beforeEach(() => {
17+
vi.resetModules();
18+
renderMock.mockReset();
19+
sessionStorage.clear();
20+
window.location.hash = '';
21+
document.body.innerHTML = '<div id="root"></div>';
22+
});
23+
24+
it('clears malformed redirect payloads and warns instead of retrying forever', async () => {
25+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
26+
sessionStorage.setItem('spa_redirect', '{bad json');
27+
28+
await import('../main.tsx');
29+
30+
expect(sessionStorage.getItem('spa_redirect')).toBeNull();
31+
expect(warnSpy).toHaveBeenCalled();
32+
expect(renderMock).toHaveBeenCalledTimes(1);
33+
});
34+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { PresetActionRunner } from '@/core/voiceCommand/presetActions';
3+
import type { AvatarControls } from '@/core/voiceCommand/types';
4+
5+
function createAvatarControls(): AvatarControls {
6+
return {
7+
setEmotion: vi.fn(),
8+
setExpression: vi.fn(),
9+
setAnimation: vi.fn(),
10+
setBehavior: vi.fn(),
11+
speak: vi.fn(),
12+
};
13+
}
14+
15+
describe('PresetActionRunner', () => {
16+
beforeEach(() => {
17+
vi.useFakeTimers();
18+
});
19+
20+
afterEach(() => {
21+
vi.useRealTimers();
22+
vi.restoreAllMocks();
23+
});
24+
25+
it('drops completed timers before clearTimers runs', () => {
26+
const avatar = createAvatarControls();
27+
const runner = new PresetActionRunner(avatar);
28+
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
29+
30+
runner.scheduleExpressionReset('smile', 1000);
31+
vi.advanceTimersByTime(1000);
32+
clearTimeoutSpy.mockClear();
33+
34+
runner.clearTimers();
35+
36+
expect(avatar.setExpression).toHaveBeenCalledWith('neutral');
37+
expect(clearTimeoutSpy).not.toHaveBeenCalled();
38+
});
39+
});

src/__tests__/useTheme.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it } from 'vitest';
3+
import { useTheme } from '@/hooks/useTheme';
4+
5+
describe('useTheme', () => {
6+
beforeEach(() => {
7+
window.localStorage.clear();
8+
document.documentElement.className = '';
9+
});
10+
11+
it('falls back to system theme when persisted theme is invalid', () => {
12+
window.localStorage.setItem('theme', 'sepia');
13+
14+
const { result } = renderHook(() => useTheme());
15+
16+
expect(result.current.theme).toBe('system');
17+
expect(result.current.resolvedTheme).toBe('light');
18+
expect(document.documentElement.classList.contains('sepia')).toBe(false);
19+
expect(window.localStorage.getItem('theme')).toBe('system');
20+
});
21+
});

src/core/performance/deviceCapability.ts

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,31 @@ const DEFAULT_CAPABILITIES: DeviceCapabilities = {
5353

5454
let cachedCapabilities: DeviceCapabilities | null = null;
5555

56+
function createCapabilitiesSnapshot(
57+
overrides: Partial<DeviceCapabilities> = {},
58+
): DeviceCapabilities {
59+
return {
60+
...DEFAULT_CAPABILITIES,
61+
detectedAt: Date.now(),
62+
...overrides,
63+
};
64+
}
65+
66+
function safeGetCanvasContext(
67+
canvas: HTMLCanvasElement,
68+
contextId: 'webgl2' | 'webgl' | 'experimental-webgl',
69+
): RenderingContext | null {
70+
try {
71+
return canvas.getContext(contextId);
72+
} catch {
73+
return null;
74+
}
75+
}
76+
77+
function isCanvasProbeUnavailable(): boolean {
78+
return typeof navigator !== 'undefined' && /\bjsdom\b/i.test(navigator.userAgent);
79+
}
80+
5681
/**
5782
* Detect WebGL support and versions
5883
*/
@@ -61,18 +86,22 @@ function detectWebGLSupport(): { supported: boolean; version: 1 | 2 | null } {
6186
return { supported: false, version: null };
6287
}
6388

89+
if (isCanvasProbeUnavailable()) {
90+
return { supported: false, version: null };
91+
}
92+
6493
const canvas = document.createElement('canvas');
6594

6695
// Try WebGL 2 first
67-
const gl2 = canvas.getContext('webgl2');
96+
const gl2 = safeGetCanvasContext(canvas, 'webgl2');
6897
if (gl2) {
6998
return { supported: true, version: 2 };
7099
}
71100

72101
// Fall back to WebGL 1
73102
const gl1 =
74-
canvas.getContext('webgl') ||
75-
(canvas.getContext('experimental-webgl') as WebGLRenderingContext | null);
103+
safeGetCanvasContext(canvas, 'webgl') ||
104+
(safeGetCanvasContext(canvas, 'experimental-webgl') as WebGLRenderingContext | null);
76105
if (gl1) {
77106
return { supported: true, version: 1 };
78107
}
@@ -207,25 +236,30 @@ export function detectDeviceCapabilities(): DeviceCapabilities {
207236
const { supported, version } = detectWebGLSupport();
208237

209238
if (!supported) {
210-
logger.warn('WebGL not supported, using fallback capabilities');
211-
cachedCapabilities = {
212-
...DEFAULT_CAPABILITIES,
239+
if (!isCanvasProbeUnavailable()) {
240+
logger.warn('WebGL not supported, using fallback capabilities');
241+
}
242+
cachedCapabilities = createCapabilitiesSnapshot({
213243
supportsWebGL2: false,
214244
tier: 'low',
215-
detectedAt: Date.now(),
216-
};
245+
});
217246
return cachedCapabilities;
218247
}
219248

220249
// Create temporary canvas for detailed detection
221250
const canvas = document.createElement('canvas');
222251
const gl =
223-
(canvas.getContext('webgl2') as WebGL2RenderingContext | null) ||
224-
(canvas.getContext('webgl') as WebGLRenderingContext | null);
252+
(safeGetCanvasContext(canvas, 'webgl2') as WebGL2RenderingContext | null) ||
253+
(safeGetCanvasContext(canvas, 'webgl') as WebGLRenderingContext | null);
225254

226255
if (!gl) {
227-
logger.warn('Could not get WebGL context');
228-
cachedCapabilities = DEFAULT_CAPABILITIES;
256+
if (!isCanvasProbeUnavailable()) {
257+
logger.warn('Could not get WebGL context');
258+
}
259+
cachedCapabilities = createCapabilitiesSnapshot({
260+
supportsWebGL2: false,
261+
tier: 'low',
262+
});
229263
return cachedCapabilities;
230264
}
231265

@@ -234,7 +268,7 @@ export function detectDeviceCapabilities(): DeviceCapabilities {
234268
const tier = detectDeviceTier(version, gpuMemory, dpr);
235269
const tierSettings = getTierSettings(tier);
236270

237-
cachedCapabilities = {
271+
cachedCapabilities = createCapabilitiesSnapshot({
238272
tier,
239273
supportsWebGL2: version === 2,
240274
estimatedGPUMemoryMB: gpuMemory,
@@ -245,8 +279,7 @@ export function detectDeviceCapabilities(): DeviceCapabilities {
245279
enablePostProcessing: tierSettings.enablePostProcessing!,
246280
maxShadowMapSize: tierSettings.maxShadowMapSize!,
247281
prefersReducedMotion: getPrefersReducedMotion(),
248-
detectedAt: Date.now(),
249-
};
282+
});
250283

251284
logger.info('Device capabilities detected:', {
252285
tier,
@@ -258,7 +291,7 @@ export function detectDeviceCapabilities(): DeviceCapabilities {
258291
return cachedCapabilities;
259292
} catch (error) {
260293
logger.error('Error detecting device capabilities:', error);
261-
cachedCapabilities = DEFAULT_CAPABILITIES;
294+
cachedCapabilities = createCapabilitiesSnapshot();
262295
return cachedCapabilities;
263296
}
264297
}

src/core/voiceCommand/presetActions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ export class PresetActionRunner {
138138
}
139139

140140
private scheduleReset(delay: number, fn: () => void): void {
141-
this.timers.push(setTimeout(fn, delay));
141+
const timer = setTimeout(() => {
142+
this.timers.splice(this.timers.indexOf(timer), 1);
143+
fn();
144+
}, delay);
145+
this.timers.push(timer);
142146
}
143147
}

src/hooks/useTheme.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
22

33
export type Theme = 'light' | 'dark' | 'system';
44
type ResolvedTheme = 'light' | 'dark';
5+
const VALID_THEMES: ReadonlySet<Theme> = new Set(['light', 'dark', 'system']);
56

67
function getSystemTheme(): ResolvedTheme {
78
if (typeof window === 'undefined') return 'dark';
@@ -12,9 +13,14 @@ function resolveTheme(theme: Theme): ResolvedTheme {
1213
return theme === 'system' ? getSystemTheme() : theme;
1314
}
1415

16+
function isValidTheme(value: string | null): value is Theme {
17+
return value !== null && VALID_THEMES.has(value as Theme);
18+
}
19+
1520
function getStoredTheme(): Theme {
1621
try {
17-
return (localStorage.getItem('theme') as Theme) || 'system';
22+
const storedTheme = localStorage.getItem('theme');
23+
return isValidTheme(storedTheme) ? storedTheme : 'system';
1824
} catch {
1925
return 'system';
2026
}

0 commit comments

Comments
 (0)