/* @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 | null }; const cameraViewProps = { current: null as Record | 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) => 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) => { 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, 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: (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(); 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(); 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(); 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(); 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>, ) => void = () => { throw new Error('network status resolver missing'); }; vi.mocked(Network.getNetworkStateAsync).mockReturnValueOnce( new Promise((resolve) => { resolveNetworkStatus = resolve; }), ); const { unmount } = render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); }); });