071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
848 lines
24 KiB
TypeScript
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();
|
|
});
|
|
});
|