Files
Genarrative/apps/mobile-shell/src/mobileHostBridge.test.ts
T
kdletters 71b18002d4 收口HostBridge请求信封校验
共享契约新增HostBridge method白名单和request id归一规则

Expo与Tauri壳在能力分发前拒绝非法id和未知method

补充两端测试、配置门禁、原生壳方案和共享决策记录
2026-06-18 13:22:27 +08:00

1289 lines
36 KiB
TypeScript

import * as Clipboard from 'expo-clipboard';
import * as DocumentPicker from 'expo-document-picker';
import * as Haptics from 'expo-haptics';
import * as ImagePicker from 'expo-image-picker';
import * as Linking from 'expo-linking';
import * as Network from 'expo-network';
import * as Notifications from 'expo-notifications';
import * as Sharing from 'expo-sharing';
import {
Appearance,
Platform,
PushNotificationIOS,
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';
type NotificationPermissionStatus =
Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>;
const GRANTED_NOTIFICATION_PERMISSION = {
status: 'granted',
granted: true,
canAskAgain: true,
expires: 'never',
} as NotificationPermissionStatus;
const DENIED_NOTIFICATION_PERMISSION = {
status: 'denied',
granted: false,
canAskAgain: false,
expires: 'never',
} as NotificationPermissionStatus;
let requestSequence = 0;
vi.mock('expo-clipboard', () => ({
getStringAsync: vi.fn(),
setStringAsync: vi.fn(),
}));
const fileTexts = vi.hoisted(() => new Map<string, string>());
const fileBase64Data = vi.hoisted(() => new Map<string, string>());
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 =
typeof fileName === 'string' ? `file:///cache/${fileName}` : base;
}
write(content: string, options?: { encoding?: 'utf8' | 'base64' }) {
writtenFiles.push({
uri: this.uri,
content,
options,
});
}
text() {
return Promise.resolve(fileTexts.get(this.uri) ?? '');
}
base64() {
return Promise.resolve(fileBase64Data.get(this.uri) ?? '');
}
},
}));
vi.mock('expo-document-picker', () => ({
getDocumentAsync: vi.fn(),
}));
vi.mock('expo-haptics', () => ({
ImpactFeedbackStyle: {
Heavy: 'heavy',
Light: 'light',
Medium: 'medium',
},
impactAsync: vi.fn(),
}));
vi.mock('expo-image-picker', () => ({
PermissionStatus: {
DENIED: 'denied',
GRANTED: 'granted',
},
launchCameraAsync: vi.fn(),
launchImageLibraryAsync: vi.fn(),
requestCameraPermissionsAsync: vi.fn(),
requestMediaLibraryPermissionsAsync: vi.fn(),
}));
vi.mock('expo-linking', () => ({
openURL: vi.fn(),
}));
vi.mock('expo-network', () => ({
getNetworkStateAsync: vi.fn(async () => ({
type: 'WIFI',
isConnected: true,
isInternetReachable: true,
})),
NetworkStateType: {
CELLULAR: 'CELLULAR',
ETHERNET: 'ETHERNET',
NONE: 'NONE',
WIFI: 'WIFI',
},
}));
vi.mock('expo-notifications', () => ({
AndroidImportance: {
DEFAULT: 'default',
},
IosAuthorizationStatus: {
PROVISIONAL: 'provisional',
},
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
setNotificationChannelAsync: vi.fn(),
setNotificationHandler: vi.fn(),
}));
vi.mock('expo-sharing', () => ({
isAvailableAsync: vi.fn(async () => true),
shareAsync: vi.fn(),
}));
vi.mock('react-native', () => ({
Appearance: {
getColorScheme: vi.fn(() => 'light'),
},
Platform: {
OS: 'ios',
},
PushNotificationIOS: {
setApplicationIconBadgeNumber: vi.fn(),
},
Share: {
share: vi.fn(),
},
}));
function request(
method: HostBridgeMethod,
payload?: unknown,
): HostBridgeRequest {
requestSequence += 1;
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: `request-${requestSequence}`,
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;
}
async function sendRaw(requestValue: unknown) {
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;
}
function setPlatformOS(os: 'ios' | 'android') {
(Platform as { OS: 'ios' | 'android' }).OS = os;
}
afterEach(() => {
requestSequence = 0;
vi.mocked(Appearance.getColorScheme).mockReset();
vi.mocked(Appearance.getColorScheme).mockReturnValue('light');
vi.mocked(Haptics.impactAsync).mockReset();
vi.mocked(Clipboard.getStringAsync).mockReset();
vi.mocked(Clipboard.getStringAsync).mockResolvedValue('作品号 PZ-1');
vi.mocked(Clipboard.setStringAsync).mockReset();
vi.mocked(DocumentPicker.getDocumentAsync).mockReset();
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: true,
assets: null,
});
vi.mocked(ImagePicker.launchImageLibraryAsync).mockReset();
vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({
canceled: true,
assets: null,
});
vi.mocked(ImagePicker.launchCameraAsync).mockReset();
vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({
canceled: true,
assets: null,
});
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockReset();
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({
status: ImagePicker.PermissionStatus.GRANTED,
granted: true,
canAskAgain: true,
expires: 'never',
});
vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockReset();
vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue({
status: ImagePicker.PermissionStatus.GRANTED,
granted: true,
canAskAgain: true,
expires: 'never',
});
vi.mocked(Linking.openURL).mockReset();
vi.mocked(Network.getNetworkStateAsync).mockReset();
vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({
type: Network.NetworkStateType.WIFI,
isConnected: true,
isInternetReachable: true,
});
vi.mocked(Notifications.getPermissionsAsync).mockReset();
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
GRANTED_NOTIFICATION_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockReset();
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
GRANTED_NOTIFICATION_PERMISSION,
);
vi.mocked(Notifications.scheduleNotificationAsync).mockReset();
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue(
'notification-1',
);
vi.mocked(Notifications.setNotificationChannelAsync).mockReset();
vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null);
vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber).mockReset();
setPlatformOS('ios');
vi.mocked(Sharing.isAvailableAsync).mockReset();
vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(true);
vi.mocked(Sharing.shareAsync).mockReset();
vi.mocked(Share.share).mockReset();
fileTexts.clear();
fileBase64Data.clear();
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,
).toContain('file.importText');
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.importImage');
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.captureImage');
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.importAudio');
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toEqual(
expect.arrayContaining([
'appearance.getColorScheme',
'host.events',
'app.lifecycle',
'app.reloadWebView',
'network.status',
'network.statusChanged',
'navigation.canGoBack',
'app.setBadgeCount',
'clipboard.readText',
'notification.showLocal',
]),
);
});
test('Android runtime 不声明 iOS 角标能力', async () => {
setPlatformOS('android');
const response = await send(request('host.getRuntime'));
const okResponse = expectOk(response);
expect(okResponse.result).toMatchObject({
shell: 'expo_mobile',
platform: 'android',
});
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).not.toContain('app.setBadgeCount');
});
test('appearance.getColorScheme 返回系统配色模式', async () => {
vi.mocked(Appearance.getColorScheme).mockReturnValue('dark');
const response = await send(request('appearance.getColorScheme'));
expect(expectOk(response).result).toEqual({
colorScheme: 'dark',
});
});
test('appearance.getColorScheme 归一化未知系统配色', async () => {
vi.mocked(Appearance.getColorScheme).mockReturnValue('unspecified');
const response = await send(request('appearance.getColorScheme'));
expect(expectOk(response).result).toEqual({
colorScheme: 'unknown',
});
});
test('navigation.openNativePage 把同源路径切到移动壳 WebView', async () => {
const openWebViewUrl = vi.fn();
configureMobileHostBridgeNavigation({
allowedOrigin: 'https://app.genarrative.world',
openWebViewUrl,
reloadWebView: vi.fn(),
});
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(),
reloadWebView: 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.each(['auth.requestLogin', 'payment.request'] as const)(
'%s 未接入真实渠道前明确返回 unsupported',
async (method) => {
const response = await send(request(method));
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('unsupported_method');
expect(failedResponse.error.message).toContain(method);
},
);
test('拒绝非法 request id 和未知 method', async () => {
const invalidId = await sendRaw({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'request\n1',
method: 'share.open',
});
const unknownMethod = await sendRaw({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'request-unknown',
method: 'host.runArbitraryCommand',
});
expect(expectFailed(invalidId).error).toEqual({
code: 'invalid_request',
message: 'invalid host bridge request',
});
expect(expectFailed(unknownMethod).error).toEqual({
code: 'invalid_request',
message: 'invalid host bridge request',
});
expect(Share.share).not.toHaveBeenCalled();
});
test('app.reloadWebView 刷新移动壳当前 WebView', async () => {
const reloadWebView = vi.fn();
configureMobileHostBridgeNavigation({
allowedOrigin: 'https://app.genarrative.world',
openWebViewUrl: vi.fn(),
reloadWebView,
});
const response = await send(request('app.reloadWebView'));
expectOk(response);
expect(reloadWebView).toHaveBeenCalledTimes(1);
});
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('重复 HostBridge request id 回放首次结果且不重复触发系统动作', async () => {
const duplicateRequest = request('share.open', {
title: '测试作品',
url: 'https://app.genarrative.world/works/detail?work=PZ-1',
});
const [firstResponse, secondResponse] = await Promise.all([
send(duplicateRequest),
send(duplicateRequest),
]);
const replayedResponse = await send(duplicateRequest);
expectOk(firstResponse);
expect(secondResponse).toEqual(firstResponse);
expect(replayedResponse).toEqual(firstResponse);
expect(Share.share).toHaveBeenCalledTimes(1);
});
test('network.status 返回 Expo Network 真实状态', async () => {
vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({
type: Network.NetworkStateType.CELLULAR,
isConnected: true,
isInternetReachable: false,
});
const response = await send(request('network.status'));
expect(expectOk(response).result).toEqual({
isConnected: true,
isInternetReachable: false,
connectionType: 'cellular',
nativeType: 'CELLULAR',
});
});
test('clipboard.readText 读取 Expo 系统剪贴板文本', async () => {
vi.mocked(Clipboard.getStringAsync).mockResolvedValue('作品号 PZ-1');
const response = await send(request('clipboard.readText'));
expect(expectOk(response).result).toEqual({
text: '作品号 PZ-1',
});
expect(Clipboard.getStringAsync).toHaveBeenCalled();
});
test('clipboard.readText 读取失败时返回 host_error', async () => {
vi.mocked(Clipboard.getStringAsync).mockRejectedValue(
new Error('clipboard unavailable'),
);
const response = await send(request('clipboard.readText'));
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('host_error');
});
test('haptics.impact 调起 Expo 触觉反馈', async () => {
const response = await send(
request('haptics.impact', {
style: 'heavy',
}),
);
expectOk(response);
expect(Haptics.impactAsync).toHaveBeenCalledWith(
Haptics.ImpactFeedbackStyle.Heavy,
);
});
test('notification.showLocal 调起 Expo 本地通知', async () => {
const response = await send(
request('notification.showLocal', {
title: ' 生成完成 ',
body: ' 作品已准备好 可以试玩 ',
}),
);
expectOk(response);
expect(Notifications.getPermissionsAsync).toHaveBeenCalled();
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({
content: {
title: '生成完成',
body: '作品已准备好 可以试玩',
},
trigger: null,
});
});
test('notification.showLocal 在 Android 使用固定通知 channel', async () => {
setPlatformOS('android');
const response = await send(
request('notification.showLocal', {
title: '生成完成',
}),
);
expectOk(response);
expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith(
'genarrative-local',
{
name: 'Genarrative',
importance: Notifications.AndroidImportance.DEFAULT,
},
);
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({
content: {
title: '生成完成',
},
trigger: {
channelId: 'genarrative-local',
},
});
});
test('notification.showLocal 拒绝权限和非法 payload', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
DENIED_NOTIFICATION_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
DENIED_NOTIFICATION_PERMISSION,
);
const denied = await send(
request('notification.showLocal', {
title: '生成完成',
}),
);
expect(expectFailed(denied).error.code).toBe('host_error');
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled();
const invalid = await send(
request('notification.showLocal', {
title: '生成\n完成',
}),
);
expect(expectFailed(invalid).error.code).toBe('invalid_request');
});
test('app.setBadgeCount 在 iOS 调起系统角标能力', async () => {
const response = await send(
request('app.setBadgeCount', {
count: 12,
}),
);
expectOk(response);
expect(
PushNotificationIOS.setApplicationIconBadgeNumber,
).toHaveBeenCalledWith(12);
});
test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported', async () => {
const invalid = await send(
request('app.setBadgeCount', {
count: 1.5,
}),
);
expect(expectFailed(invalid).error.code).toBe('invalid_request');
expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled();
setPlatformOS('android');
const unsupported = await send(
request('app.setBadgeCount', {
count: 1,
}),
);
expect(expectFailed(unsupported).error.code).toBe('unsupported_capability');
expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled();
});
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.importText 调起系统文档选择器并返回受控文本数据', async () => {
fileTexts.set('file:///private/mobile/story.md', '暖灯猫街');
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/story.md',
name: ' ../剧情:草稿?.md ',
mimeType: 'text/markdown',
size: 12,
lastModified: 1,
},
],
});
const response = await send(request('file.importText'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: '剧情-草稿-.md',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
});
expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({
copyToCacheDirectory: true,
multiple: false,
type: ['text/*', 'application/json'],
});
});
test('file.importText 取消选择时返回 cancelled', async () => {
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: true,
assets: null,
});
const response = await send(request('file.importText'));
expect(expectFailed(response).error.code).toBe('cancelled');
});
test('file.importText 拒绝非法 MIME 与超限文本', async () => {
fileTexts.set('file:///private/mobile/story.png', '暖灯猫街');
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/story.png',
name: 'story.png',
mimeType: 'image/png',
size: 12,
lastModified: 1,
},
],
});
const unsupportedMime = await send(request('file.importText'));
expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request');
fileTexts.set('file:///private/mobile/story.txt', 'a'.repeat(5 * 1024 * 1024 + 1));
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/story.txt',
name: 'story.txt',
mimeType: 'text/plain',
size: 5 * 1024 * 1024 + 1,
lastModified: 1,
},
],
});
const oversized = await send(request('file.importText'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
});
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();
});
test('file.exportAudio 写入缓存音频并调起系统分享', async () => {
const response = await send(
request('file.exportAudio', {
fileName: ' ../敲击:音效?.wav ',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
}),
);
const okResponse = expectOk(response);
expect(okResponse.result).toEqual({
action: 'saved',
fileName: '敲击-音效-.wav',
bytes: 5,
});
expect(writtenFiles).toEqual([
{
uri: 'file:///cache/敲击-音效-.wav',
content: 'YXVkaW8=',
options: { encoding: 'base64' },
},
]);
expect(Sharing.shareAsync).toHaveBeenCalledWith(
'file:///cache/敲击-音效-.wav',
{
mimeType: 'audio/wav',
UTI: 'public.audio',
dialogTitle: '敲击-音效-.wav',
},
);
});
test('file.exportAudio 在系统分享不可用时明确返回 unsupported capability', async () => {
vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(false);
const response = await send(
request('file.exportAudio', {
fileName: 'hit.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
}),
);
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('unsupported_capability');
expect(writtenFiles).toEqual([]);
expect(Sharing.shareAsync).not.toHaveBeenCalled();
});
test('file.exportAudio 拒绝非法 MIME、空内容与超限内容', async () => {
const unsupportedMime = await send(
request('file.exportAudio', {
fileName: 'hit.txt',
base64Data: 'YXVkaW8=',
mimeType: 'text/plain',
}),
);
expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request');
const emptyAudio = await send(
request('file.exportAudio', {
fileName: 'hit.wav',
base64Data: '',
mimeType: 'audio/wav',
}),
);
expect(expectFailed(emptyAudio).error.code).toBe('invalid_request');
const oversized = await send(
request('file.exportAudio', {
fileName: 'hit.webm',
base64Data: 'A'.repeat(28 * 1024 * 1024),
mimeType: 'audio/webm',
}),
);
expect(expectFailed(oversized).error.code).toBe('invalid_request');
expect(writtenFiles).toEqual([]);
expect(Sharing.shareAsync).not.toHaveBeenCalled();
});
test('file.importImage 调起系统相册并返回受控图片数据', async () => {
vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/参考图.png',
width: 120,
height: 80,
type: 'image',
fileName: ' ../参考:图?.png ',
fileSize: 5,
base64: 'aW1hZ2U=',
mimeType: 'image/png',
},
],
});
const response = await send(request('file.importImage'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: '参考-图-.png',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
});
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalled();
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
allowsEditing: false,
allowsMultipleSelection: false,
base64: true,
exif: false,
mediaTypes: ['images'],
quality: 1,
});
});
test('file.importImage 取消选择时返回 cancelled', async () => {
vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({
canceled: true,
assets: null,
});
const response = await send(request('file.importImage'));
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('cancelled');
});
test('file.importImage 拒绝权限、非法 MIME 和超限图片', async () => {
vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue(
{
status: ImagePicker.PermissionStatus.DENIED,
granted: false,
canAskAgain: false,
expires: 'never',
},
);
const denied = await send(request('file.importImage'));
expect(expectFailed(denied).error.code).toBe('host_error');
expect(ImagePicker.launchImageLibraryAsync).not.toHaveBeenCalled();
vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue(
{
status: ImagePicker.PermissionStatus.GRANTED,
granted: true,
canAskAgain: true,
expires: 'never',
},
);
vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/参考图.gif',
width: 120,
height: 80,
type: 'image',
fileName: '参考图.gif',
fileSize: 5,
base64: 'aW1hZ2U=',
mimeType: 'image/gif',
},
],
});
const unsupportedMime = await send(request('file.importImage'));
expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request');
vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/参考图.png',
width: 120,
height: 80,
type: 'image',
fileName: '参考图.png',
fileSize: 10 * 1024 * 1024 + 1,
base64: 'aW1hZ2U=',
mimeType: 'image/png',
},
],
});
const oversized = await send(request('file.importImage'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
});
test('file.captureImage 调起系统相机并返回受控图片数据', async () => {
vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/camera.jpg',
width: 120,
height: 80,
type: 'image',
fileName: null,
fileSize: 6,
base64: 'Y2FtZXJh',
mimeType: 'image/jpeg',
},
],
});
const response = await send(request('file.captureImage'));
expect(expectOk(response).result).toEqual({
action: 'captured',
fileName: 'genarrative-import.jpg',
base64Data: 'Y2FtZXJh',
mimeType: 'image/jpeg',
bytes: 6,
});
expect(ImagePicker.requestCameraPermissionsAsync).toHaveBeenCalled();
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith({
allowsEditing: false,
base64: true,
exif: false,
mediaTypes: ['images'],
quality: 1,
});
});
test('file.captureImage 拒绝权限和取消拍摄', async () => {
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({
status: ImagePicker.PermissionStatus.DENIED,
granted: false,
canAskAgain: false,
expires: 'never',
});
const denied = await send(request('file.captureImage'));
expect(expectFailed(denied).error.code).toBe('host_error');
expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled();
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({
status: ImagePicker.PermissionStatus.GRANTED,
granted: true,
canAskAgain: true,
expires: 'never',
});
vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({
canceled: true,
assets: null,
});
const cancelled = await send(request('file.captureImage'));
expect(expectFailed(cancelled).error.code).toBe('cancelled');
});
test('file.importAudio 调起系统文档选择器并返回受控音频数据', async () => {
fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8=');
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/hit.webm',
name: ' ../敲击:音效?.webm ',
mimeType: 'audio/webm',
size: 5,
lastModified: 1,
},
],
});
const response = await send(request('file.importAudio'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: '敲击-音效-.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 5,
});
expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({
copyToCacheDirectory: true,
multiple: false,
type: [
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/ogg',
'audio/webm',
],
});
});
test('file.importAudio 取消选择并拒绝非法 MIME 与超限音频', async () => {
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: true,
assets: null,
});
const cancelled = await send(request('file.importAudio'));
expect(expectFailed(cancelled).error.code).toBe('cancelled');
fileBase64Data.set('file:///private/mobile/hit.txt', 'YXVkaW8=');
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/hit.txt',
name: 'hit.txt',
mimeType: 'text/plain',
size: 5,
lastModified: 1,
},
],
});
const unsupportedMime = await send(request('file.importAudio'));
expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request');
fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8=');
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/hit.webm',
name: 'hit.webm',
mimeType: 'audio/webm',
size: 20 * 1024 * 1024 + 1,
lastModified: 1,
},
],
});
const oversized = await send(request('file.importAudio'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
});
});