xingyun/__tests__/App.test.tsx

253 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// React 19 types 不允许 createElement 接收任意字符串 tag
// 但 react-test-renderer 需要用字符串标识 mock 宿主元素
const mockHost = (type: string, props?: Record<string, unknown>, ...children: React.ReactNode[]) =>
React.createElement(type as React.ElementType, props, ...children);
const {
useRequestMock,
useLockFnMock,
useMountMock,
initApiClientMock,
ensureApiClientReadyMock,
useSyncRemoteDomainsMock,
toastErrorMock,
useAntiDebugReporterMock,
} = vi.hoisted(() => ({
useRequestMock: vi.fn(),
useLockFnMock: vi.fn((fn) => fn),
useMountMock: vi.fn((fn) => fn()),
initApiClientMock: vi.fn(),
ensureApiClientReadyMock: vi.fn(),
useSyncRemoteDomainsMock: vi.fn(),
toastErrorMock: vi.fn(),
useAntiDebugReporterMock: vi.fn(),
}));
vi.mock('ahooks', () => ({
useRequest: useRequestMock,
useLockFn: useLockFnMock,
useMount: useMountMock,
}));
vi.mock('../src/api', () => ({
initApiClient: initApiClientMock,
ensureApiClientReady: ensureApiClientReadyMock,
}));
vi.mock('../src/api/apiInitConfig', () => ({
xingyunApiInitProps: {},
resetApiClientReadyPromiseForRetry: vi.fn(),
}));
vi.mock('@telescope/domain-discover-2/react', () => ({
useDomainDiscovery: useSyncRemoteDomainsMock,
}));
vi.mock('@telescope/user-info', () => ({
UserInfoProvider: (props: { children?: React.ReactNode }) => mockHost('UserInfoProvider', undefined, props.children),
}));
vi.mock('@telescope/app-init', () => {
function useApiInit() {
const { loading, error, runAsync } = useRequestMock(
() => Promise.resolve(),
{
manual: true,
onError: () => {},
},
);
useMountMock(() => {
void runAsync?.();
});
return {
status: loading ? 'loading' : error ? 'error' : 'ready',
error,
retry: runAsync,
};
}
return {
ApiInitProvider: (props: { children?: React.ReactNode }) => mockHost('ApiInitProvider', undefined, props.children),
createStandardAppBootstrap: (_options: any) => {
const BaseApp = (props: { children?: React.ReactNode }) => {
const { status, error: initError, retry } = useApiInit();
useSyncRemoteDomainsMock({ enabled: status === 'ready' });
useAntiDebugReporterMock(_options.enableAntiDebugReporter ?? true);
if (status === 'loading') {
return mockHost('View', undefined, mockHost('Text', undefined, _options.loadingText ?? '初始化中...'));
}
if (status === 'error') {
return mockHost('View', undefined,
mockHost('Text', undefined, _options.initErrorTitle ?? 'ApiClient 初始化失败'),
mockHost('Text', undefined, initError?.message ?? ''),
mockHost('TouchableOpacity', { onPress: retry }, mockHost('Text', undefined, _options.retryText ?? '重试')),
);
}
return mockHost('View', undefined,
mockHost('RemoteEnvConfigProvider', undefined,
mockHost('AppNavigator'),
),
);
};
return BaseApp;
},
useApiInit,
useEnsureApiReady: () => ensureApiClientReadyMock,
};
});
vi.mock('@telescope/shim-text', () => ({
__esModule: true,
default: (props: { children?: React.ReactNode }) => mockHost('Text', props, props.children),
}));
vi.mock('@telescope/app-updater', () => ({
UpdateProvider: (props: { children?: React.ReactNode }) => mockHost('UpdateProvider', undefined, props.children),
UpgradeModal: () => mockHost('UpgradeModal'),
}));
vi.mock('@telescope/acl-updater', () => ({
AclUpdaterProvider: (props: { children?: React.ReactNode }) => mockHost('AclUpdaterProvider', undefined, props.children),
createApiClientAclUpdaterConfig: vi.fn(() => ({})),
useAclUpdaterService: vi.fn(() => ({
getFilePathAsync: vi.fn(async () => null),
checkBeforeVpnConnect: vi.fn(async () => ({})),
})),
}));
vi.mock('@telescope/env-config', () => ({
RemoteEnvConfigProvider: (props: { children?: React.ReactNode }) => mockHost('RemoteEnvConfigProvider', undefined, props.children),
useRemoteEnvConfig: vi.fn(() => ({
hideModeSwitchButton: false,
hideServerCard: false,
refresh: async () => {},
})),
useVpnEnvConfig: vi.fn(() => ({
mockVpnService: false,
fixedRouteConfig: null,
})),
}));
vi.mock('../src/navigation/AppNavigator', () => ({
__esModule: true,
default: () => mockHost('AppNavigator'),
}));
vi.mock('../src/vpn/provider', () => ({
AppVpnProvider: (props: { children?: React.ReactNode }) => mockHost('AppVpnProvider', undefined, props.children),
}));
vi.mock('react-native-safe-area-context', () => ({
SafeAreaProvider: (props: { children?: React.ReactNode }) => mockHost('SafeAreaProvider', undefined, props.children),
}));
vi.mock('react-native-gesture-handler', () => ({
GestureHandlerRootView: (props: Record<string, unknown> & { children?: React.ReactNode }) => mockHost('GestureHandlerRootView', props, props.children),
}));
vi.mock('@telescope/toast', () => ({
ToastProvider: (props: { children?: React.ReactNode }) => mockHost('ToastProvider', undefined, props.children),
useToast: () => ({
error: toastErrorMock,
}),
}));
vi.mock('@telescope/anti-debug-collector', () => ({
useAntiDebugReporter: useAntiDebugReporterMock,
}));
import App from '../src/App';
function hasText(renderer: ReactTestRenderer, text: string): boolean {
return renderer.root
.findAll((node) => String(node.type) === 'Text')
.some((node) => node.children.some((child) => child === text));
}
describe('App', () => {
beforeEach(() => {
useRequestMock.mockReset();
useLockFnMock.mockClear();
useMountMock.mockClear();
initApiClientMock.mockReset();
ensureApiClientReadyMock.mockReset();
useSyncRemoteDomainsMock.mockReset();
toastErrorMock.mockReset();
useAntiDebugReporterMock.mockReset();
});
it('初始化 loading 状态:展示 loading 文案并禁用域名同步', () => {
const runAsyncMock = vi.fn().mockResolvedValue(undefined);
useRequestMock.mockReturnValue({
loading: true,
error: undefined,
runAsync: runAsyncMock,
});
let renderer: ReactTestRenderer;
act(() => {
renderer = create(<App />);
});
expect(runAsyncMock).toHaveBeenCalledTimes(1);
expect(useSyncRemoteDomainsMock).toHaveBeenCalledWith({ enabled: false });
expect(useAntiDebugReporterMock).toHaveBeenCalledWith(true);
expect(hasText(renderer!, '初始化中...')).toBe(true);
expect(renderer!.root.findAllByType('AppNavigator' as React.ElementType)).toHaveLength(0);
});
it('初始化 ready 状态:渲染主导航并启用域名同步', () => {
const runAsyncMock = vi.fn().mockResolvedValue(undefined);
useRequestMock.mockReturnValue({
loading: false,
error: undefined,
runAsync: runAsyncMock,
});
let renderer: ReactTestRenderer;
act(() => {
renderer = create(<App />);
});
expect(runAsyncMock).toHaveBeenCalledTimes(1);
expect(useSyncRemoteDomainsMock).toHaveBeenCalledWith({ enabled: true });
expect(useAntiDebugReporterMock).toHaveBeenCalledWith(true);
expect(renderer!.root.findAllByType('RemoteEnvConfigProvider' as React.ElementType)).toHaveLength(1);
expect(renderer!.root.findAllByType('AppNavigator' as React.ElementType)).toHaveLength(1);
});
it('初始化 error 状态:展示错误并支持重试', () => {
const runAsyncMock = vi.fn().mockResolvedValue(undefined);
useRequestMock.mockReturnValue({
loading: false,
error: new Error('网络异常'),
runAsync: runAsyncMock,
});
let renderer: ReactTestRenderer;
act(() => {
renderer = create(<App />);
});
expect(hasText(renderer!, 'ApiClient 初始化失败')).toBe(true);
expect(hasText(renderer!, '网络异常')).toBe(true);
const retryButtons = renderer!.root.findAllByType('TouchableOpacity' as React.ElementType);
expect(retryButtons).toHaveLength(1);
act(() => {
retryButtons[0].props.onPress();
});
expect(runAsyncMock).toHaveBeenCalledTimes(2);
const [, options] = useRequestMock.mock.calls[0];
expect(options.manual).toBe(true);
expect(typeof options.onError).toBe('function');
});
});