Files
Genarrative/apps/mobile-shell/src/shell/ShellApp.test.tsx
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

848 lines
24 KiB
TypeScript

/* @vitest-environment jsdom */
import { act, render, waitFor } from '@testing-library/react';
import * as Network from 'expo-network';
import React from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeRequest,
} from '../../../../packages/shared/src/contracts/hostBridge';
const shellHarness = vi.hoisted(() => {
const appStateListeners = [] as Array<(state: string) => void>;
const webViewProps = { current: null as Record<string, unknown> | null };
const cameraViewProps = { current: null as Record<string, unknown> | null };
const injectJavaScriptError = { current: null as Error | null };
const injectedScripts = [] as string[];
const linkingUrlListeners = [] as Array<(event: { url: string }) => void>;
const networkListeners = [] as Array<
(state: Record<string, unknown>) => void
>;
const reloadWebView = vi.fn();
return {
appStateListeners,
cameraViewProps,
injectJavaScriptError,
injectedScripts,
networkListeners,
reset() {
appStateListeners.length = 0;
webViewProps.current = null;
cameraViewProps.current = null;
injectJavaScriptError.current = null;
injectedScripts.length = 0;
linkingUrlListeners.length = 0;
networkListeners.length = 0;
reloadWebView.mockClear();
},
linkingUrlListeners,
reloadWebView,
webViewProps,
};
});
vi.mock('expo-camera', () => ({
Camera: {
requestCameraPermissionsAsync: vi.fn(async () => ({
granted: true,
})),
},
CameraView: (props: Record<string, unknown>) => {
shellHarness.cameraViewProps.current = props;
return React.createElement('mobile-camera-view');
},
}));
vi.mock('expo-clipboard', () => ({
getStringAsync: vi.fn(),
setStringAsync: vi.fn(),
}));
vi.mock('expo-document-picker', () => ({
getDocumentAsync: vi.fn(),
}));
vi.mock('expo-file-system', () => ({
File: class MockFile {
uri = 'file:///cache/test';
size = 0;
base64() {
return Promise.resolve('');
}
text() {
return Promise.resolve('');
}
write() {
return undefined;
}
},
Paths: {
cache: 'file:///cache/',
},
}));
vi.mock('expo-haptics', () => ({
ImpactFeedbackStyle: {
Heavy: 'heavy',
Light: 'light',
Medium: 'medium',
},
impactAsync: vi.fn(),
}));
vi.mock('expo-image-picker', () => ({
PermissionStatus: {
DENIED: 'denied',
GRANTED: 'granted',
},
launchCameraAsync: vi.fn(),
launchImageLibraryAsync: vi.fn(),
requestCameraPermissionsAsync: vi.fn(),
requestMediaLibraryPermissionsAsync: vi.fn(),
}));
vi.mock('expo-linking', () => ({
canOpenURL: vi.fn(async () => true),
createURL: vi.fn((path = '') => `genarrative://${path}`),
openURL: vi.fn(async () => undefined),
parse: vi.fn(() => ({ path: null, queryParams: {} })),
}));
vi.mock('expo-network', () => ({
addNetworkStateListener: vi.fn((listener) => {
shellHarness.networkListeners.push(listener);
return {
remove: vi.fn(),
};
}),
getNetworkStateAsync: vi.fn(async () => ({
isConnected: true,
isInternetReachable: true,
type: 'WIFI',
})),
NetworkStateType: {
CELLULAR: 'CELLULAR',
ETHERNET: 'ETHERNET',
NONE: 'NONE',
WIFI: 'WIFI',
},
}));
vi.mock('expo-notifications', () => ({
AndroidImportance: {
DEFAULT: 'default',
},
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
setNotificationChannelAsync: vi.fn(),
setNotificationHandler: vi.fn(),
}));
vi.mock('expo-sharing', () => ({
isAvailableAsync: vi.fn(async () => true),
shareAsync: vi.fn(),
}));
vi.mock('expo-status-bar', () => ({
StatusBar: () => React.createElement('mobile-status-bar'),
}));
vi.mock('react-native-safe-area-context', () => ({
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
SafeAreaView: ({ children, ...props }: { children?: React.ReactNode }) =>
React.createElement('mobile-safe-area-view', props, children),
}));
vi.mock('react-native-webview', () => ({
WebView: React.forwardRef((_props: Record<string, unknown>, ref) => {
shellHarness.webViewProps.current = _props;
React.useImperativeHandle(ref, () => ({
goBack: vi.fn(),
injectJavaScript: (script: string) => {
if (shellHarness.injectJavaScriptError.current) {
throw shellHarness.injectJavaScriptError.current;
}
shellHarness.injectedScripts.push(script);
},
reload: shellHarness.reloadWebView,
}));
return React.createElement('mobile-web-view');
}),
}));
vi.mock('react-native', () => ({
AppState: {
addEventListener: vi.fn((_event, listener) => {
shellHarness.appStateListeners.push(listener);
return { remove: vi.fn() };
}),
currentState: 'active',
},
BackHandler: {
addEventListener: vi.fn(() => ({ remove: vi.fn() })),
},
Linking: {
addEventListener: vi.fn((_event, listener) => {
shellHarness.linkingUrlListeners.push(listener);
return { remove: vi.fn() };
}),
canOpenURL: vi.fn(async () => true),
getInitialURL: vi.fn(async () => null),
openURL: vi.fn(async () => undefined),
},
Platform: {
OS: 'ios',
},
Pressable: ({
children,
onPress,
accessibilityLabel: _accessibilityLabel,
accessibilityRole: _accessibilityRole,
...props
}: {
accessibilityLabel?: string;
accessibilityRole?: string;
children?: React.ReactNode;
onPress?: () => void;
}) => React.createElement('button', { ...props, onClick: onPress }, children),
StyleSheet: {
create: <T,>(styles: T) => styles,
},
Text: ({ children, ...props }: { children?: React.ReactNode }) =>
React.createElement('span', props, children),
View: ({ children, ...props }: { children?: React.ReactNode }) =>
React.createElement('div', props, children),
}));
async function importShellApp(isDev = true) {
vi.stubGlobal('__DEV__', isDev);
vi.resetModules();
return (await import('./ShellApp')).default;
}
async function importShellAppWithDevFlag(isDev: boolean) {
return await importShellApp(isDev);
}
function buildRequest(): HostBridgeRequest {
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'scan-request-1',
method: 'scanner.scanQrCode',
};
}
function extractInjectedHostBridgeMessage(script: string) {
const match = script.match(/data: ("(?:\\.|[^"])*")/);
const encodedMessage = match?.[1];
if (!encodedMessage) {
throw new Error('injected HostBridge message missing');
}
return JSON.parse(JSON.parse(encodedMessage)) as unknown;
}
function expectInjectedHostBridgeMessageSource(script: string) {
expect(script).toContain('origin: window.location.origin');
expect(script).toContain('source: window');
}
function hostBridgeMessages() {
return shellHarness.injectedScripts.map(extractInjectedHostBridgeMessage);
}
function hostBridgeEvent(eventName: string) {
return hostBridgeMessages().find(
(message) =>
typeof message === 'object' &&
message !== null &&
(message as { event?: unknown }).event === eventName,
) as { event: string; payload?: unknown } | undefined;
}
function lastHostBridgeEvent(eventName: string) {
return hostBridgeMessages()
.filter(
(message) =>
typeof message === 'object' &&
message !== null &&
(message as { event?: unknown }).event === eventName,
)
.at(-1) as { event: string; payload?: unknown } | undefined;
}
afterEach(() => {
shellHarness.reset();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.resetModules();
vi.clearAllMocks();
});
describe('ShellApp QR scanner HostBridge flow', () => {
test('production shell ignores local H5 URL env before opening WebView', async () => {
vi.stubEnv('EXPO_PUBLIC_GENARRATIVE_WEB_URL', 'http://127.0.0.1:3000/');
const ShellApp = await importShellAppWithDevFlag(false);
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
source?: { uri?: string };
};
const sourceUrl = new URL(webViewProps.source?.uri ?? '');
expect(sourceUrl.origin).toBe('https://www.genarrative.world');
expect(sourceUrl.searchParams.get('clientRuntime')).toBe('native_app');
expect(sourceUrl.searchParams.get('hostShell')).toBe('expo_mobile');
});
test('development shell allows explicit local H5 URL env', async () => {
vi.stubEnv('EXPO_PUBLIC_GENARRATIVE_WEB_URL', 'http://127.0.0.1:3000/');
const ShellApp = await importShellAppWithDevFlag(true);
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
source?: { uri?: string };
};
const sourceUrl = new URL(webViewProps.source?.uri ?? '');
expect(sourceUrl.origin).toBe('http://127.0.0.1:3000');
expect(sourceUrl.searchParams.get('clientRuntime')).toBe('native_app');
expect(sourceUrl.searchParams.get('hostShell')).toBe('expo_mobile');
});
test('scanner.scanQrCode drives overlay camera scan and injects HostBridge response into WebView', async () => {
const ShellApp = await importShellApp();
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
onMessage?: (event: {
nativeEvent: {
data: string;
url: string;
};
}) => void;
source?: { uri?: string };
};
const webViewUrl = webViewProps.source?.uri;
expect(webViewUrl).toContain('https://www.genarrative.world');
webViewProps.onMessage?.({
nativeEvent: {
data: JSON.stringify(buildRequest()),
url: webViewUrl ?? 'https://www.genarrative.world/',
},
});
await waitFor(() => {
expect(shellHarness.cameraViewProps.current).toBeTruthy();
});
const cameraViewProps = shellHarness.cameraViewProps.current as {
onBarcodeScanned?: (result: { type: string; data: string }) => void;
};
cameraViewProps.onBarcodeScanned?.({
type: 'qr',
data: ' https://www.genarrative.world/works/detail?work=PZ-1 ',
});
await waitFor(() => {
expect(
shellHarness.injectedScripts.some((script) =>
script.includes('scan-request-1'),
),
).toBe(true);
});
const responseScript = shellHarness.injectedScripts.find((script) =>
script.includes('scan-request-1'),
);
expectInjectedHostBridgeMessageSource(responseScript ?? '');
const response = extractInjectedHostBridgeMessage(responseScript ?? '');
expect(response).toMatchObject({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'scan-request-1',
ok: true,
result: {
format: 'qr_code',
value: 'https://www.genarrative.world/works/detail?work=PZ-1',
},
});
});
test('logs HostBridge response injection failures without crashing the shell', async () => {
const ShellApp = await importShellApp();
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
onMessage?: (event: {
nativeEvent: {
data: string;
url: string;
};
}) => void;
source?: { uri?: string };
};
const webViewUrl =
webViewProps.source?.uri ?? 'https://www.genarrative.world/';
const injectionError = new Error('response injection failed');
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
shellHarness.injectJavaScriptError.current = injectionError;
expect(() => {
webViewProps.onMessage?.({
nativeEvent: {
data: JSON.stringify({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'runtime-request-1',
method: 'host.getRuntime',
}),
url: webViewUrl,
},
});
}).not.toThrow();
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
'mobile HostBridge message injection failed',
);
});
warnSpy.mockRestore();
});
test('drops delayed HostBridge responses after shell unmount', async () => {
const ShellApp = await importShellApp();
let resolveNetworkStatus: (
state: Awaited<ReturnType<typeof Network.getNetworkStateAsync>>,
) => void = () => {
throw new Error('network status resolver missing');
};
vi.mocked(Network.getNetworkStateAsync).mockReturnValueOnce(
new Promise((resolve) => {
resolveNetworkStatus = resolve;
}),
);
const { unmount } = render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
onMessage?: (event: {
nativeEvent: {
data: string;
url: string;
};
}) => void;
source?: { uri?: string };
};
const webViewUrl =
webViewProps.source?.uri ?? 'https://www.genarrative.world/';
webViewProps.onMessage?.({
nativeEvent: {
data: JSON.stringify({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'network-request-1',
method: 'network.status',
}),
url: webViewUrl,
},
});
unmount();
resolveNetworkStatus({
isConnected: true,
isInternetReachable: true,
type: Network.NetworkStateType.WIFI,
});
await waitFor(() => {
expect(Network.getNetworkStateAsync).toHaveBeenCalled();
});
expect(
shellHarness.injectedScripts.some((script) =>
script.includes('network-request-1'),
),
).toBe(false);
});
});
describe('ShellApp HostBridge event injection', () => {
test('WebView origin whitelist is limited to the resolved H5 origin', async () => {
const ShellApp = await importShellApp();
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
originWhitelist?: string[];
source?: { uri?: string };
};
const webViewUrl =
webViewProps.source?.uri ?? 'https://www.genarrative.world/';
expect(webViewProps.originWhitelist).toEqual([new URL(webViewUrl).origin]);
});
test('AppState changes inject app.lifecycle events into WebView', async () => {
const ShellApp = await importShellApp();
render(<ShellApp />);
await waitFor(() => {
expect(shellHarness.appStateListeners.length).toBeGreaterThan(0);
});
shellHarness.injectedScripts.length = 0;
shellHarness.appStateListeners[0]?.('background');
const event = hostBridgeEvent('app.lifecycle');
expectInjectedHostBridgeMessageSource(
shellHarness.injectedScripts[0] ?? '',
);
expect(event).toMatchObject({
event: 'app.lifecycle',
payload: {
state: 'background',
focused: false,
nativeState: 'background',
},
});
});
test('native network listener injects network.statusChanged events into WebView', async () => {
const ShellApp = await importShellApp();
render(<ShellApp />);
await waitFor(() => {
expect(shellHarness.networkListeners.length).toBeGreaterThan(0);
});
shellHarness.injectedScripts.length = 0;
shellHarness.networkListeners[0]?.({
isConnected: false,
isInternetReachable: false,
type: 'NONE',
});
const event = hostBridgeEvent('network.statusChanged');
expect(event).toMatchObject({
event: 'network.statusChanged',
payload: {
isConnected: false,
isInternetReachable: false,
connectionType: 'none',
nativeType: 'NONE',
},
});
});
test('host event injection failures are logged without crashing the shell', async () => {
const ShellApp = await importShellApp();
render(<ShellApp />);
await waitFor(() => {
expect(shellHarness.networkListeners.length).toBeGreaterThan(0);
});
const injectionError = new Error('webview injection failed');
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
shellHarness.injectJavaScriptError.current = injectionError;
expect(() => {
shellHarness.networkListeners[0]?.({
isConnected: false,
isInternetReachable: false,
type: 'NONE',
});
}).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith(
'mobile host event failed for network.statusChanged',
);
warnSpy.mockRestore();
});
test('native and H5 navigation state inject combined navigation.canGoBack events', async () => {
const ShellApp = await importShellApp();
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
onMessage?: (event: {
nativeEvent: {
data: string;
url: string;
};
}) => void;
onNavigationStateChange?: (event: { canGoBack: boolean }) => void;
source?: { uri?: string };
};
const webViewUrl =
webViewProps.source?.uri ?? 'https://www.genarrative.world/';
shellHarness.injectedScripts.length = 0;
webViewProps.onNavigationStateChange?.({ canGoBack: true });
expect(lastHostBridgeEvent('navigation.canGoBack')).toMatchObject({
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
});
shellHarness.injectedScripts.length = 0;
webViewProps.onNavigationStateChange?.({ canGoBack: false });
webViewProps.onMessage?.({
nativeEvent: {
data: JSON.stringify({
type: 'genarrative.mobile.historyState',
canGoBack: true,
}),
url: webViewUrl,
},
});
expect(lastHostBridgeEvent('navigation.canGoBack')).toMatchObject({
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
});
});
test('load-time network event replay failure is logged instead of hidden', async () => {
const ShellApp = await importShellApp();
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
onLoad?: (event: {
nativeEvent: {
url: string;
};
}) => void;
source?: { uri?: string };
};
const webViewUrl =
webViewProps.source?.uri ?? 'https://www.genarrative.world/';
const networkError = new Error('network replay failed');
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
vi.mocked(Network.getNetworkStateAsync).mockRejectedValueOnce(networkError);
webViewProps.onLoad?.({
nativeEvent: {
url: webViewUrl,
},
});
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
'mobile host event failed for network.statusChanged',
);
});
warnSpy.mockRestore();
});
test('external WebView navigation native failures stay outside the WebView', async () => {
const ShellApp = await importShellApp();
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
onShouldStartLoadWithRequest?: (request: { url: string }) => boolean;
};
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const { Linking } = await import('react-native');
vi.mocked(Linking.openURL).mockRejectedValueOnce(
new Error('system browser unavailable'),
);
expect(
webViewProps.onShouldStartLoadWithRequest?.({
url: 'https://outside.example/work/1',
}),
).toBe(false);
await waitFor(() => {
expect(Linking.openURL).toHaveBeenCalledWith(
'https://outside.example/work/1',
);
});
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
'mobile shell navigation failed for external_navigation.open',
);
});
warnSpy.mockRestore();
});
test('blocked WebView file downloads are logged for host diagnostics', async () => {
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const ShellApp = await importShellApp();
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
onFileDownload?: (event: unknown) => void;
};
const downloadEvent = {
nativeEvent: {
downloadUrl: 'blob:https://www.genarrative.world/download-id',
},
};
webViewProps.onFileDownload?.(downloadEvent);
expect(warnSpy).toHaveBeenCalledWith(
'mobile shell blocked WebView file download',
);
warnSpy.mockRestore();
});
test('initial deep link read failures are logged without replacing the current WebView URL', async () => {
const { Linking } = await import('react-native');
const initialUrlError = new Error('initial URL unavailable');
vi.mocked(Linking.getInitialURL).mockRejectedValueOnce(initialUrlError);
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const ShellApp = await importShellApp();
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
source?: { uri?: string };
};
const initialWebUrl = webViewProps.source?.uri;
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
'mobile shell deep link failed for initial_url.read',
);
});
expect(shellHarness.webViewProps.current?.source).toEqual({
uri: initialWebUrl,
});
warnSpy.mockRestore();
});
test('runtime deep link rejections are logged and fall back to a safe WebView URL', async () => {
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const ShellApp = await importShellApp();
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
source?: { uri?: string };
};
const initialWebUrl = webViewProps.source?.uri;
shellHarness.linkingUrlListeners[0]?.({
url: 'https://outside.example/works/detail?work=PZ-1',
});
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
'mobile shell deep link failed for runtime_url.rejected',
);
});
expect(shellHarness.webViewProps.current?.source).toEqual({
uri: initialWebUrl,
});
warnSpy.mockRestore();
});
test('first WebView process failure reloads the current page once', async () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_000);
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const ShellApp = await importShellApp();
render(<ShellApp />);
const { Linking } = await import('react-native');
await waitFor(() => {
expect(Linking.getInitialURL).toHaveBeenCalled();
});
const webViewProps = shellHarness.webViewProps.current as {
onContentProcessDidTerminate?: () => void;
};
act(() => {
webViewProps.onContentProcessDidTerminate?.();
});
expect(shellHarness.reloadWebView).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
'mobile WebView process failed for content_process_terminated',
);
warnSpy.mockRestore();
nowSpy.mockRestore();
});
test('repeated WebView process failures show the load failure panel and retry clears the failure window', async () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(2_000);
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const ShellApp = await importShellApp();
const screen = render(<ShellApp />);
const { Linking } = await import('react-native');
await waitFor(() => {
expect(Linking.getInitialURL).toHaveBeenCalled();
});
const webViewProps = shellHarness.webViewProps.current as {
onRenderProcessGone?: () => void;
};
act(() => {
webViewProps.onRenderProcessGone?.();
webViewProps.onRenderProcessGone?.();
});
await waitFor(() => {
expect(screen.getByText('页面已停止')).toBeTruthy();
});
expect(shellHarness.reloadWebView).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenLastCalledWith(
'mobile WebView process failed for render_process_gone',
);
act(() => {
screen.getByText('重试').click();
});
expect(shellHarness.reloadWebView).toHaveBeenCalledTimes(2);
act(() => {
webViewProps.onRenderProcessGone?.();
});
expect(shellHarness.reloadWebView).toHaveBeenCalledTimes(3);
warnSpy.mockRestore();
nowSpy.mockRestore();
});
});