Files
Genarrative/apps/mobile-shell/src/mobileHostBridge.test.ts
kdletters 910625d5e1 接入原生壳分享卡图片导出
新增 file.exportImage 宿主能力契约

分享卡下载在原生壳中优先走宿主图片导出

Expo 壳写入缓存图片并调用系统分享保存

Tauri 壳通过保存对话框写入图片字节

补齐能力漂移检查、测试和架构文档
2026-06-18 01:31:28 +08:00

420 lines
11 KiB
TypeScript

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;
options?: { encoding?: 'utf8' | 'base64' };
}[],
);
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, options?: { encoding?: 'utf8' | 'base64' }) {
writtenFiles.push({
uri: this.uri,
content,
options,
});
}
},
}));
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: '暖灯猫街',
options: undefined,
},
]);
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();
});
test('file.exportImage 写入缓存图片并调起系统分享', async () => {
const response = await send(
request('file.exportImage', {
fileName: ' ../分享:卡?.png ',
base64Data: 'c2hhcmUtY2FyZA==',
mimeType: 'image/png',
}),
);
const okResponse = expectOk(response);
expect(okResponse.result).toEqual({
action: 'saved',
fileName: '分享-卡-.png',
bytes: 10,
});
expect(writtenFiles).toEqual([
{
uri: 'file:///cache/分享-卡-.png',
content: 'c2hhcmUtY2FyZA==',
options: { encoding: 'base64' },
},
]);
expect(Sharing.shareAsync).toHaveBeenCalledWith(
'file:///cache/分享-卡-.png',
{
mimeType: 'image/png',
UTI: 'public.png',
dialogTitle: '分享-卡-.png',
},
);
});
test('file.exportImage 拒绝非图片 MIME 与超限内容', async () => {
const unsupportedMime = await send(
request('file.exportImage', {
fileName: '分享卡.txt',
base64Data: 'c2hhcmUtY2FyZA==',
mimeType: 'text/plain',
}),
);
expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request');
const oversized = await send(
request('file.exportImage', {
fileName: '分享卡.png',
base64Data: `${'A'.repeat(7 * 1024 * 1024)}`,
mimeType: 'image/png',
}),
);
expect(expectFailed(oversized).error.code).toBe('invalid_request');
expect(writtenFiles).toEqual([]);
expect(Sharing.shareAsync).not.toHaveBeenCalled();
});
});