新增 HostBridge file.exportText 契约、文件名清洗和 H5 导出入口 Tauri 桌面壳通过受控 host_bridge_request 打开保存对话框并写入文本文件 Expo 移动壳对未接入的文件导出能力明确返回 unsupported 更新宿主壳方案、统一协议和项目共享决策记录
196 lines
4.8 KiB
TypeScript
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');
|
|
});
|
|
});
|