253 lines
8.2 KiB
TypeScript
253 lines
8.2 KiB
TypeScript
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');
|
||
});
|
||
});
|