/* @vitest-environment jsdom */ import { afterEach, describe, expect, test, vi } from 'vitest'; import { canUseHostShareGrid, exportHostTextFile, getHostRuntime, getNativeAppHostRuntime, isWechatMiniProgramWebViewRuntime, navigateHostNativePage, openHostShareGrid, openWechatMiniProgramShareGridPage, postWechatMiniProgramMessage, requestHostHapticsImpact, requestHostLogin, requestHostPayment, requestWechatMiniProgramPayment, requestWechatMiniProgramPhoneLogin, resolveHostRuntime, setHostAppTitle, setHostShareTarget, 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; }; } afterEach(() => { vi.restoreAllMocks(); window.history.replaceState(null, '', '/'); window.wx = undefined; delete window.ReactNativeWebView; delete window.__TAURI__; resetNativeAppHostBridgeForTest(); }); 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', }, }), ).toMatchObject({ kind: 'native_app', hostShell: 'expo_mobile', hostPlatform: 'ios', hostVersion: '0.1.0', }); expect( resolveHostRuntime({ tauri: { core: { invoke: asTauriInvoke(vi.fn(async () => null)), }, }, location: { search: '' }, }).kind, ).toBe('native_app'); expect(resolveHostRuntime({ location: { search: '' } }).kind).toBe( 'browser', ); }); 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'], } : true, }; }); window.history.replaceState( null, '', '/?clientRuntime=native_app&hostShell=tauri_desktop', ); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), }, }; await expect(navigateHostNativePage('/settings')).resolves.toBe(true); 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(setHostAppTitle({ title: ' 拼图 - 陶泥儿 ' })).resolves.toBe( true, ); 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.setTitle', payload: { title: '拼图 - 陶泥儿', }, }), }); }); test('原生 App 宿主不支持能力时回退到 H5 路径', async () => { window.history.replaceState(null, '', '/?clientRuntime=native_app'); 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(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(setHostAppTitle({ title: '拼图 - 陶泥儿' })).resolves.toBe( false, ); await expect(setHostAppTitle({ title: ' ' })).resolves.toBe(false); }); test('普通浏览器不处理宿主文本导出', async () => { 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; method: string } }) .request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: true, result: { action: 'saved', fileName: '作品记录.txt', bytes: 7, }, }; }, ); window.history.replaceState(null, '', '/?clientRuntime=native_app'); 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, '', '/?clientRuntime=native_app'); 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); }); });