import * as Linking from 'expo-linking'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, type HostBridgeMethod, type HostBridgeRequest, type HostBridgeResponse, } from '../../../packages/shared/src/contracts/hostBridge'; import { configureMobileHostBridgeNavigation, handleMobileHostBridgeMessage, } from './mobileHostBridge'; vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn(), })); vi.mock('expo-haptics', () => ({ ImpactFeedbackStyle: { Heavy: 'heavy', Light: 'light', Medium: 'medium', }, impactAsync: vi.fn(), })); vi.mock('expo-linking', () => ({ openURL: vi.fn(), })); vi.mock('react-native', () => ({ Platform: { OS: 'ios', }, Share: { share: vi.fn(), }, })); function request( method: HostBridgeMethod, payload?: unknown, ): HostBridgeRequest { return { bridge: HOST_BRIDGE_PROTOCOL, version: HOST_BRIDGE_VERSION, id: 'request-1', method, payload, }; } async function send(requestValue: HostBridgeRequest) { const responses: HostBridgeResponse[] = []; await handleMobileHostBridgeMessage(JSON.stringify(requestValue), (response) => responses.push(response), ); const response = responses[0]; if (!response) { throw new Error('host bridge response missing'); } return response; } function expectOk(response: HostBridgeResponse) { if (!response.ok) { throw new Error('expected ok host bridge response'); } return response; } function expectFailed(response: HostBridgeResponse) { if (response.ok) { throw new Error('expected failed host bridge response'); } return response; } afterEach(() => { vi.mocked(Linking.openURL).mockReset(); configureMobileHostBridgeNavigation(null); }); describe('handleMobileHostBridgeMessage', () => { test('runtime 能力清单声明移动壳支持受控 WebView 导航', async () => { const response = await send(request('host.getRuntime')); const okResponse = expectOk(response); expect(okResponse.result).toMatchObject({ shell: 'expo_mobile', platform: 'ios', }); expect( (okResponse.result as { capabilities: string[] }).capabilities, ).toContain('navigation.openNativePage'); expect( (okResponse.result as { capabilities: string[] }).capabilities, ).toEqual( expect.arrayContaining(['host.events', 'navigation.canGoBack']), ); }); test('navigation.openNativePage 把同源路径切到移动壳 WebView', async () => { const openWebViewUrl = vi.fn(); configureMobileHostBridgeNavigation({ allowedOrigin: 'https://app.genarrative.world', openWebViewUrl, }); const response = await send( request('navigation.openNativePage', { url: '/works/detail?work=PZ-1', }), ); expectOk(response); expect(openWebViewUrl).toHaveBeenCalledWith( 'https://app.genarrative.world/works/detail?work=PZ-1', ); }); test('navigation.openNativePage 拒绝外域目标', async () => { configureMobileHostBridgeNavigation({ allowedOrigin: 'https://app.genarrative.world', openWebViewUrl: vi.fn(), }); const response = await send( request('navigation.openNativePage', { url: 'https://example.com/works/detail?work=PZ-1', }), ); const failedResponse = expectFailed(response); expect(failedResponse.error.code).toBe('invalid_request'); }); test('未配置 WebView 导航器时明确返回 unsupported', async () => { const response = await send( request('navigation.openNativePage', { url: '/works/detail?work=PZ-1', }), ); const failedResponse = expectFailed(response); expect(failedResponse.error.code).toBe('unsupported_method'); }); test('app.openExternalUrl 只打开允许的外链协议', async () => { const response = await send( request('app.openExternalUrl', { url: ' https://example.com/path ', }), ); expectOk(response); expect(Linking.openURL).toHaveBeenCalledWith('https://example.com/path'); }); test('app.openExternalUrl 拒绝危险协议', async () => { const response = await send( request('app.openExternalUrl', { url: 'javascript:alert(1)', }), ); const failedResponse = expectFailed(response); expect(failedResponse.error.code).toBe('invalid_request'); expect(Linking.openURL).not.toHaveBeenCalled(); }); test('移动壳未接入真实导出能力时明确返回 unsupported', async () => { const response = await send( request('file.exportText', { fileName: '作品记录.txt', content: 'content', }), ); const failedResponse = expectFailed(response); expect(failedResponse.error.code).toBe('unsupported_method'); }); });