Files
Genarrative/apps/mobile-shell/src/mobileHostBridge.test.ts
kdletters d67f9d5725 接入桌面壳文本文件导出能力
新增 HostBridge file.exportText 契约、文件名清洗和 H5 导出入口

Tauri 桌面壳通过受控 host_bridge_request 打开保存对话框并写入文本文件

Expo 移动壳对未接入的文件导出能力明确返回 unsupported

更新宿主壳方案、统一协议和项目共享决策记录
2026-06-17 23:02:01 +08:00

196 lines
4.8 KiB
TypeScript

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');
});
});