import * as Haptics from 'expo-haptics'; import * as Linking from 'expo-linking'; import * as Sharing from 'expo-sharing'; import { Share } from 'react-native'; 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, resetMobileHostBridgeForTest, } from './mobileHostBridge'; vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn(), })); const writtenFiles = vi.hoisted( () => [] as { uri: string; content: string }[], ); vi.mock('expo-file-system', () => ({ Paths: { cache: 'file:///cache/', }, File: class MockFile { uri: string; constructor(_base: string, fileName: string) { this.uri = `file:///cache/${fileName}`; } write(content: string) { writtenFiles.push({ uri: this.uri, content, }); } }, })); vi.mock('expo-haptics', () => ({ ImpactFeedbackStyle: { Heavy: 'heavy', Light: 'light', Medium: 'medium', }, impactAsync: vi.fn(), })); vi.mock('expo-linking', () => ({ openURL: vi.fn(), })); vi.mock('expo-sharing', () => ({ isAvailableAsync: vi.fn(async () => true), shareAsync: 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(Haptics.impactAsync).mockReset(); vi.mocked(Linking.openURL).mockReset(); vi.mocked(Sharing.isAvailableAsync).mockReset(); vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(true); vi.mocked(Sharing.shareAsync).mockReset(); vi.mocked(Share.share).mockReset(); writtenFiles.length = 0; resetMobileHostBridgeForTest(); }); 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, ).toContain('file.exportText'); 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('haptics.impact 调起 Expo 触觉反馈', async () => { const response = await send( request('haptics.impact', { style: 'heavy', }), ); expectOk(response); expect(Haptics.impactAsync).toHaveBeenCalledWith( Haptics.ImpactFeedbackStyle.Heavy, ); }); test('share.open 使用直接分享 payload 调起系统分享', async () => { const response = await send( request('share.open', { title: '测试作品', message: '来玩这个作品', url: 'https://app.genarrative.world/works/detail?work=PZ-1', }), ); expectOk(response); expect(Share.share).toHaveBeenCalledWith({ title: '测试作品', message: '来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-1', url: 'https://app.genarrative.world/works/detail?work=PZ-1', }); }); test('share.open 使用缓存作品目标生成作品详情链接', async () => { expectOk( await send( request('share.setTarget', { target: { type: 'genarrative:share-target', payload: { title: '暖灯猫街', message: '来玩这个作品', work: 'PZ-00000001', }, }, }), ), ); const response = await send(request('share.open')); expectOk(response); expect(Share.share).toHaveBeenCalledWith({ title: '暖灯猫街', message: '来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-00000001', url: 'https://app.genarrative.world/works/detail?work=PZ-00000001', }); }); test('share.open 没有可分享内容时拒绝请求', async () => { const response = await send(request('share.open', {})); const failedResponse = expectFailed(response); expect(failedResponse.error.code).toBe('invalid_request'); expect(Share.share).not.toHaveBeenCalled(); }); test('file.exportText 写入缓存文件并调起系统分享', async () => { const response = await send( request('file.exportText', { fileName: ' ../作品:记录?.txt ', content: '暖灯猫街', mimeType: 'text/markdown', }), ); const okResponse = expectOk(response); expect(okResponse.result).toEqual({ action: 'saved', fileName: '作品-记录-.txt', bytes: 12, }); expect(writtenFiles).toEqual([ { uri: 'file:///cache/作品-记录-.txt', content: '暖灯猫街', }, ]); expect(Sharing.shareAsync).toHaveBeenCalledWith( 'file:///cache/作品-记录-.txt', { mimeType: 'text/markdown', UTI: 'public.plain-text', dialogTitle: '作品-记录-.txt', }, ); }); test('file.exportText 在系统分享不可用时明确返回 unsupported capability', async () => { vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(false); const response = await send( request('file.exportText', { fileName: '作品记录.txt', content: 'content', }), ); const failedResponse = expectFailed(response); expect(failedResponse.error.code).toBe('unsupported_capability'); expect(writtenFiles).toEqual([]); expect(Sharing.shareAsync).not.toHaveBeenCalled(); }); test('file.exportText 拒绝超出上限的文本内容', async () => { const response = await send( request('file.exportText', { fileName: '作品记录.txt', content: 'a'.repeat(5 * 1024 * 1024 + 1), }), ); const failedResponse = expectFailed(response); expect(failedResponse.error.code).toBe('invalid_request'); expect(writtenFiles).toEqual([]); expect(Sharing.shareAsync).not.toHaveBeenCalled(); }); });