/* @vitest-environment jsdom */ import { afterEach, describe, expect, test, vi } from 'vitest'; import type { HostBridgeCapability } from '../../../packages/shared/src/contracts/hostBridge'; import { canUseHostShareGrid, canUseNativeHostCapability, exportHostImageFile, exportHostTextFile, getHostAppearanceColorScheme, getHostNetworkStatus, getHostRuntime, getNativeAppHostRuntime, importHostImageFile, isWechatMiniProgramWebViewRuntime, navigateHostNativePage, openHostExternalUrl, openHostShare, openHostShareGrid, openWechatMiniProgramShareGridPage, postWechatMiniProgramMessage, refreshNativeAppHostRuntime, requestHostHapticsImpact, requestHostLogin, requestHostPayment, requestWechatMiniProgramPayment, requestWechatMiniProgramPhoneLogin, resetHostRuntimeCacheForTest, resolveHostRuntime, setHostAppBadgeCount, setHostAppTitle, setHostShareTarget, showHostLocalNotification, subscribeHostAppLifecycle, subscribeHostImageDrop, subscribeHostNetworkStatusChange, subscribeHostRuntimeChange, writeHostClipboardText, } from './hostBridge'; import { resetNativeAppHostBridgeForTest } from './nativeAppHostBridge'; function asTauriInvoke( invoke: (command: string, args?: Record) => Promise, ) { return async function tauriInvoke( command: string, args?: Record, ) { return (await invoke(command, args)) as Result; }; } function nativeAppPath(capabilities: HostBridgeCapability[] = []) { const params = new URLSearchParams({ clientRuntime: 'native_app', hostShell: 'tauri_desktop', }); if (capabilities.length > 0) { params.set('hostCapabilities', capabilities.join(',')); } return `/?${params.toString()}`; } afterEach(() => { vi.restoreAllMocks(); window.history.replaceState(null, '', '/'); window.wx = undefined; delete window.ReactNativeWebView; delete window.__TAURI__; resetNativeAppHostBridgeForTest(); resetHostRuntimeCacheForTest(); }); describe('hostBridge', () => { test('识别微信小程序、原生 App 和普通浏览器宿主', () => { expect( getHostRuntime({ location: { search: '?clientRuntime=wechat_mini_program' }, }).kind, ).toBe('wechat_mini_program'); expect( resolveHostRuntime({ navigator: { userAgent: 'Mozilla/5.0 iPhone MicroMessenger/8.0 miniProgram', }, }).kind, ).toBe('wechat_mini_program'); expect( resolveHostRuntime({ location: { search: '?clientRuntime=native_app&hostShell=expo_mobile&hostPlatform=ios&hostVersion=0.1.0&hostCapabilities=share.open,unknown,clipboard.writeText', }, }), ).toMatchObject({ kind: 'native_app', hostShell: 'expo_mobile', hostPlatform: 'ios', hostVersion: '0.1.0', hostCapabilities: ['share.open', 'clipboard.writeText'], }); expect( resolveHostRuntime({ tauri: { core: { invoke: asTauriInvoke(vi.fn(async () => null)), }, }, location: { search: '' }, }).kind, ).toBe('native_app'); expect(resolveHostRuntime({ location: { search: '' } }).kind).toBe( 'browser', ); }); test('按宿主能力声明判断原生 App 能力是否可用', () => { expect( canUseNativeHostCapability('share.open', { location: { search: '?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl', }, }), ).toBe(true); expect( canUseNativeHostCapability('clipboard.writeText', { location: { search: '?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl', }, }), ).toBe(false); expect( canUseNativeHostCapability('share.open', { location: { search: '?clientRuntime=browser&hostCapabilities=share.open', }, }), ).toBe(false); }); test('订阅原生 App 生命周期事件并归一化 payload', () => { const listener = vi.fn(); window.history.replaceState( null, '', nativeAppPath(['app.lifecycle']), ); window.ReactNativeWebView = { postMessage: vi.fn(), }; const unsubscribe = subscribeHostAppLifecycle(listener); window.dispatchEvent( new MessageEvent('message', { data: JSON.stringify({ bridge: 'GenarrativeHostBridge', version: 1, event: 'app.lifecycle', payload: { state: 'extension', focused: true, nativeState: 'extension', }, }), }), ); unsubscribe(); window.dispatchEvent( new MessageEvent('message', { data: JSON.stringify({ bridge: 'GenarrativeHostBridge', version: 1, event: 'app.lifecycle', payload: { state: 'active', focused: true, nativeState: 'active', }, }), }), ); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ state: 'inactive', focused: true, nativeState: 'extension', }); }); test('未声明生命周期能力时不订阅原生事件', () => { const listener = vi.fn(); window.history.replaceState(null, '', nativeAppPath()); window.ReactNativeWebView = { postMessage: vi.fn(), }; const unsubscribe = subscribeHostAppLifecycle(listener); window.dispatchEvent( new MessageEvent('message', { data: JSON.stringify({ bridge: 'GenarrativeHostBridge', version: 1, event: 'app.lifecycle', payload: { state: 'active', focused: true, }, }), }), ); unsubscribe(); expect(listener).not.toHaveBeenCalled(); }); test('查询并订阅原生 App 网络状态', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { const request = (args as { request: { id: string; method: string } }) .request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: true, result: { isConnected: true, isInternetReachable: false, connectionType: 'WIFI', nativeType: 'WIFI', }, }; }, ); const listener = vi.fn(); window.history.replaceState( null, '', nativeAppPath(['network.status', 'network.statusChanged']), ); window.ReactNativeWebView = { postMessage: vi.fn(), }; window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), }, }; await expect(getHostNetworkStatus()).resolves.toEqual({ isConnected: true, isInternetReachable: false, connectionType: 'wifi', nativeType: 'WIFI', }); const unsubscribe = subscribeHostNetworkStatusChange(listener); window.dispatchEvent( new MessageEvent('message', { data: JSON.stringify({ bridge: 'GenarrativeHostBridge', version: 1, event: 'network.statusChanged', payload: { isConnected: false, isInternetReachable: false, connectionType: 'NONE', nativeType: 'NONE', }, }), }), ); unsubscribe(); expect(listener).toHaveBeenCalledWith({ isConnected: false, isInternetReachable: false, connectionType: 'none', nativeType: 'NONE', }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'network.status', }), }); }); test('从真实宿主 runtime 回读能力并通知订阅者', async () => { const listener = vi.fn(); const unsubscribe = subscribeHostRuntimeChange(listener); const invoke = vi.fn( async (_command: string, args?: Record) => { const request = (args as { request: { id: string; method: string } }) .request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: true, result: { shell: 'tauri_desktop', platform: 'linux', hostVersion: '0.1.0', bridgeVersion: 1, capabilities: [ 'host.getRuntime', 'share.open', 'clipboard.writeText', 'unknown.capability', ], }, }; }, ); window.history.replaceState( null, '', '/?clientRuntime=native_app&hostShell=tauri_desktop', ); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), }, }; expect(canUseNativeHostCapability('share.open')).toBe(false); await expect(refreshNativeAppHostRuntime()).resolves.toMatchObject({ shell: 'tauri_desktop', capabilities: ['host.getRuntime', 'share.open', 'clipboard.writeText'], }); expect(canUseNativeHostCapability('share.open')).toBe(true); expect(canUseNativeHostCapability('clipboard.writeText')).toBe(true); expect(canUseNativeHostCapability('file.exportText')).toBe(false); expect(getHostRuntime().hostCapabilities).toEqual([ 'host.getRuntime', 'share.open', 'clipboard.writeText', ]); expect(listener).toHaveBeenCalledTimes(1); expect(invoke).toHaveBeenCalledTimes(1); unsubscribe(); }); test('普通浏览器不混入缓存的原生宿主能力', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { const request = (args as { request: { id: string } }).request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: true, result: { shell: 'tauri_desktop', platform: 'linux', hostVersion: '0.1.0', bridgeVersion: 1, capabilities: ['share.open'], }, }; }, ); window.history.replaceState( null, '', '/?clientRuntime=native_app&hostShell=tauri_desktop', ); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), }, }; await refreshNativeAppHostRuntime(); delete window.__TAURI__; window.history.replaceState(null, '', '/?clientRuntime=browser'); expect(getHostRuntime().kind).toBe('browser'); expect(getHostRuntime().hostCapabilities).toEqual([]); expect(canUseNativeHostCapability('share.open')).toBe(false); }); test('通过微信小程序原生页请求登录', async () => { const navigateTo = vi.fn((options) => { options.success?.(); }); window.history.replaceState( null, '', '/?clientRuntime=wechat_mini_program', ); window.wx = { miniProgram: { navigateTo, }, }; const requested = await requestHostLogin(); expect(requested).toBe(true); expect(navigateTo).toHaveBeenCalledWith({ url: '/pages/web-view/index?authAction=login&returnTo=previous', success: expect.any(Function), fail: expect.any(Function), }); await expect(requestWechatMiniProgramPhoneLogin()).resolves.toBe(true); }); test('通过微信小程序原生页请求支付', async () => { const navigateTo = vi.fn((options) => { options.success?.(); }); vi.spyOn(Date, 'now').mockReturnValue(123456); window.history.replaceState( null, '', '/?clientRuntime=wechat_mini_program', ); window.wx = { miniProgram: { navigateTo, }, }; const handled = await requestHostPayment({ payload: { timeStamp: '1', nonceStr: 'nonce', package: 'prepay_id=1', signType: 'RSA', paySign: 'sign', }, orderId: 'order-1', }); expect(handled).toBe(true); const url = navigateTo.mock.calls[0]?.[0].url; expect(url).toContain('/pages/wechat-pay/index?'); const parsed = new URL(url, 'https://mini.test'); expect(parsed.searchParams.get('requestId')).toBe( 'wechat_pay_order-1_123456', ); expect(parsed.searchParams.get('orderId')).toBe('order-1'); expect(parsed.searchParams.get('payParams')).toContain('prepay_id=1'); await expect( requestWechatMiniProgramPayment( { timeStamp: '1', nonceStr: 'nonce', package: 'prepay_id=1', signType: 'RSA', paySign: 'sign', }, 'order-1', ), ).resolves.toBeUndefined(); }); test('普通浏览器不处理宿主登录、支付和原生页跳转', async () => { await expect(requestHostLogin()).resolves.toBe(false); await expect( requestHostPayment({ payload: { timeStamp: '1', nonceStr: 'nonce', package: 'prepay_id=1', signType: 'RSA', paySign: 'sign', }, orderId: 'order-1', }), ).resolves.toBe(false); await expect(navigateHostNativePage('/pages/test/index')).resolves.toBe( false, ); await expect( requestHostPayment({ payload: null, orderId: 'order-1', }), ).resolves.toBe(false); }); test('打开微信小程序九宫切图页并补齐绝对图片地址', async () => { const navigateTo = vi.fn((options) => { options.success?.(); }); window.history.replaceState( null, '', '/?clientRuntime=wechat_mini_program', ); window.wx = { miniProgram: { navigateTo, }, }; expect(canUseHostShareGrid()).toBe(true); const opened = await openHostShareGrid({ imageUrl: '/cover.png', title: '暖灯猫街', publicWorkCode: 'PZ-00000001', }); expect(opened).toBe(true); const url = navigateTo.mock.calls[0]?.[0].url; const parsed = new URL(url, 'https://mini.test'); expect(parsed.pathname).toBe('/pages/share-grid/index'); expect(parsed.searchParams.get('imageUrl')).toBe( `${window.location.origin}/cover.png`, ); expect(parsed.searchParams.get('title')).toBe('暖灯猫街'); await expect( openWechatMiniProgramShareGridPage({ imageUrl: '/cover.png', title: '暖灯猫街', publicWorkCode: 'PZ-00000001', }), ).resolves.toBe(true); }); test('向微信小程序宿主同步消息', () => { const postMessage = vi.fn(); window.history.replaceState( null, '', '/?clientRuntime=wechat_mini_program', ); window.wx = { miniProgram: { postMessage, }, }; expect( setHostShareTarget({ type: 'test', }), ).toBe(true); expect(postMessage).toHaveBeenCalledWith({ data: { type: 'test', }, }); expect(postWechatMiniProgramMessage({ type: 'compat' })).toBe(true); }); test('普通浏览器不开放微信小程序能力', () => { expect(isWechatMiniProgramWebViewRuntime()).toBe(false); expect(canUseHostShareGrid()).toBe(false); expect(setHostShareTarget({ type: 'test' })).toBe(false); }); test('原生 App 宿主通过 HostBridge 处理导航、登录和支付', async () => { const invoke = vi.fn(async (_command: string, args?: Record) => { const request = (args as { request: { id: string; method: string } }) .request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: true, result: request.method === 'host.getRuntime' ? { shell: 'tauri_desktop', platform: 'linux', hostVersion: '0.1.0', bridgeVersion: 1, capabilities: ['host.getRuntime'], } : request.method === 'appearance.getColorScheme' ? { colorScheme: 'dark' } : request.method === 'network.status' ? { isConnected: true, isInternetReachable: true, connectionType: 'ethernet', nativeType: 'online', } : request.method === 'file.importImage' ? { action: 'selected', fileName: '参考图.png', base64Data: 'aW1hZ2U=', mimeType: 'image/png', bytes: 5, } : true, }; }); window.history.replaceState( null, '', nativeAppPath([ 'host.getRuntime', 'appearance.getColorScheme', 'navigation.openNativePage', 'auth.requestLogin', 'payment.request', 'clipboard.writeText', 'haptics.impact', 'app.openExternalUrl', 'app.setTitle', 'app.setBadgeCount', 'network.status', 'share.open', 'file.exportImage', 'file.importImage', 'file.imageDropped', 'notification.showLocal', ]), ); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), }, }; await expect(navigateHostNativePage('/settings')).resolves.toBe(true); await expect(getHostAppearanceColorScheme()).resolves.toEqual({ colorScheme: 'dark', }); await expect(requestHostLogin()).resolves.toBe(true); await expect( requestHostPayment({ payload: null, orderId: 'order-1', }), ).resolves.toBe(true); await expect(getNativeAppHostRuntime()).resolves.toMatchObject({ shell: 'tauri_desktop', platform: 'linux', }); await expect( writeHostClipboardText({ text: '作品号 PZ-1' }), ).resolves.toBe(true); await expect(requestHostHapticsImpact({ style: 'medium' })).resolves.toBe( true, ); await expect( openHostExternalUrl({ url: '/works/detail?work=PZ-1' }), ).resolves.toBe(true); await expect(setHostAppTitle({ title: ' 拼图 - 陶泥儿 ' })).resolves.toBe( true, ); await expect(setHostAppBadgeCount({ count: 7 })).resolves.toBe(true); await expect(getHostNetworkStatus()).resolves.toEqual({ isConnected: true, isInternetReachable: true, connectionType: 'ethernet', nativeType: 'online', }); await expect( openHostShare({ title: '暖灯猫街', message: '邀请你来玩', url: 'https://app.genarrative.world/works/detail?work=PZ-1', }), ).resolves.toBe(true); await expect( exportHostImageFile({ fileName: '分享卡.png', base64Data: 'c2hhcmUtY2FyZA==', mimeType: 'image/png', }), ).resolves.toBe(true); await expect(importHostImageFile()).resolves.toEqual({ action: 'selected', fileName: '参考图.png', base64Data: 'aW1hZ2U=', mimeType: 'image/png', bytes: 5, }); await expect( showHostLocalNotification({ title: ' 生成完成 ', body: ' 作品已准备好 可以试玩 ', }), ).resolves.toBe(true); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'appearance.getColorScheme', }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'navigation.openNativePage', payload: { url: '/settings' }, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'auth.requestLogin', }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'payment.request', payload: { payload: null, orderId: 'order-1', }, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'clipboard.writeText', payload: { text: '作品号 PZ-1', }, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'haptics.impact', payload: { style: 'medium', }, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'app.openExternalUrl', payload: { url: `${window.location.origin}/works/detail?work=PZ-1`, }, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'app.setTitle', payload: { title: '拼图 - 陶泥儿', }, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'app.setBadgeCount', payload: { count: 7, }, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'network.status', }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'share.open', payload: { title: '暖灯猫街', message: '邀请你来玩', url: 'https://app.genarrative.world/works/detail?work=PZ-1', }, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.exportImage', payload: { fileName: '分享卡.png', base64Data: 'c2hhcmUtY2FyZA==', mimeType: 'image/png', }, timeoutMs: 30000, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.importImage', timeoutMs: 30000, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'notification.showLocal', payload: { title: '生成完成', body: '作品已准备好 可以试玩', }, }), }); }); test('原生 App 宿主不支持能力时回退到 H5 路径', async () => { window.history.replaceState(null, '', nativeAppPath()); window.__TAURI__ = { core: { invoke: asTauriInvoke(vi.fn(async (_command: string, args?: Record) => { const request = (args as { request: { id: string } }).request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: false, error: { code: 'unsupported_method', message: 'unsupported_method', }, }; })), }, }; await expect(navigateHostNativePage('/settings')).resolves.toBe(false); await expect(getHostAppearanceColorScheme()).resolves.toBe(false); await expect(requestHostLogin()).resolves.toBe(false); await expect( requestHostPayment({ payload: null, orderId: 'order-1', }), ).resolves.toBe(false); await expect( writeHostClipboardText({ text: '作品号 PZ-1' }), ).resolves.toBe(false); await expect(requestHostHapticsImpact({ style: 'light' })).resolves.toBe( false, ); await expect( openHostExternalUrl({ url: 'https://beian.miit.gov.cn/' }), ).resolves.toBe(false); await expect( openHostExternalUrl({ url: 'javascript:alert(1)' }), ).resolves.toBe(false); await expect(setHostAppTitle({ title: '拼图 - 陶泥儿' })).resolves.toBe( false, ); await expect(setHostAppTitle({ title: ' ' })).resolves.toBe(false); await expect(setHostAppBadgeCount({ count: 1 })).resolves.toBe(false); await expect(setHostAppBadgeCount({ count: -1 })).resolves.toBe(false); await expect(setHostAppBadgeCount({ count: 1.5 })).resolves.toBe(false); await expect(getHostNetworkStatus()).resolves.toBe(false); await expect( openHostShare({ title: '暖灯猫街', message: '邀请你来玩', url: 'https://app.genarrative.world/works/detail?work=PZ-1', }), ).resolves.toBe(false); await expect( exportHostImageFile({ fileName: '分享卡.png', base64Data: 'c2hhcmUtY2FyZA==', mimeType: 'image/png', }), ).resolves.toBe(false); await expect(importHostImageFile()).resolves.toBe(false); await expect( showHostLocalNotification({ title: '生成完成', }), ).resolves.toBe(false); }); test('普通浏览器不处理宿主文件导出', async () => { await expect(getHostAppearanceColorScheme()).resolves.toBe(false); await expect( exportHostTextFile({ fileName: '作品记录.txt', content: 'content', }), ).resolves.toBe(false); await expect( exportHostImageFile({ fileName: '分享卡.png', base64Data: 'c2hhcmUtY2FyZA==', mimeType: 'image/png', }), ).resolves.toBe(false); await expect(importHostImageFile()).resolves.toBe(false); await expect( showHostLocalNotification({ title: '生成完成', }), ).resolves.toBe(false); }); test('原生 App 宿主拒绝非法本地通知 payload', async () => { const invoke = vi.fn(); window.history.replaceState( null, '', nativeAppPath(['notification.showLocal']), ); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), }, }; await expect( showHostLocalNotification({ title: '生成\n完成', }), ).resolves.toBe(false); expect(invoke).not.toHaveBeenCalled(); }); test('原生 App 宿主通过 HostBridge 导出文本文件', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { const request = (args as { request: { id: string; method: string } }) .request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: true, result: { action: 'saved', fileName: '作品记录.txt', bytes: 7, }, }; }, ); window.history.replaceState( null, '', nativeAppPath(['file.exportText']), ); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), }, }; await expect( exportHostTextFile({ fileName: '作品记录.txt', content: 'content', }), ).resolves.toEqual({ action: 'saved', fileName: '作品记录.txt', bytes: 7, }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.exportText', payload: { fileName: '作品记录.txt', content: 'content', }, timeoutMs: 30000, }), }); }); test('原生 App 宿主不支持文本导出时回退 H5', async () => { window.history.replaceState( null, '', nativeAppPath(['file.exportText']), ); window.__TAURI__ = { core: { invoke: asTauriInvoke( vi.fn(async (_command: string, args?: Record) => { const request = (args as { request: { id: string } }).request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: false, error: { code: 'unsupported_method', message: 'unsupported_method', }, }; }), ), }, }; await expect( exportHostTextFile({ fileName: '作品记录.txt', content: 'content', }), ).resolves.toBe(false); }); test('原生 App 宿主通过 HostBridge 导出图片文件', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { const request = (args as { request: { id: string } }).request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: true, result: { action: 'saved', fileName: '分享卡.png', bytes: 10, }, }; }, ); window.history.replaceState( null, '', nativeAppPath(['file.exportImage']), ); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), }, }; await expect( exportHostImageFile({ fileName: '分享卡.png', base64Data: 'c2hhcmUtY2FyZA==', mimeType: 'image/png', }), ).resolves.toEqual({ action: 'saved', fileName: '分享卡.png', bytes: 10, }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.exportImage', payload: { fileName: '分享卡.png', base64Data: 'c2hhcmUtY2FyZA==', mimeType: 'image/png', }, timeoutMs: 30000, }), }); }); test('原生 App 宿主通过 HostBridge 导入和订阅拖入图片文件', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { const request = (args as { request: { id: string } }).request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: true, result: { action: 'selected', fileName: ' 参考图.png ', base64Data: 'aW1hZ2U=', mimeType: 'image/png', bytes: 5, }, }; }, ); const listener = vi.fn(); window.history.replaceState( null, '', nativeAppPath(['file.importImage', 'file.imageDropped']), ); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), }, }; await expect(importHostImageFile()).resolves.toEqual({ action: 'selected', fileName: '参考图.png', base64Data: 'aW1hZ2U=', mimeType: 'image/png', bytes: 5, }); const unsubscribe = subscribeHostImageDrop(listener); window.dispatchEvent( new MessageEvent('message', { data: JSON.stringify({ bridge: 'GenarrativeHostBridge', version: 1, event: 'file.imageDropped', payload: { action: 'dropped', fileName: '拖入图.webp', base64Data: 'ZHJvcA==', mimeType: 'image/webp', bytes: 4, position: { x: 32, y: 48, }, }, }), }), ); unsubscribe(); expect(listener).toHaveBeenCalledWith({ action: 'dropped', fileName: '拖入图.webp', base64Data: 'ZHJvcA==', mimeType: 'image/webp', bytes: 4, position: { x: 32, y: 48, }, }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.importImage', timeoutMs: 30000, }), }); }); test('原生 App 宿主取消导入图片时回退为 false', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { const request = (args as { request: { id: string } }).request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: false, error: { code: 'cancelled', message: 'file import cancelled', }, }; }, ); window.history.replaceState( null, '', nativeAppPath(['file.importImage']), ); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), }, }; await expect(importHostImageFile()).resolves.toBe(false); }); });