Files
Genarrative/apps/mobile-shell/src/host-bridge/bridge.test.ts
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

2125 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Share } from 'react-native';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
HOST_BRIDGE_BADGE_COUNT_MAX,
HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES,
HOST_BRIDGE_METHODS,
HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID,
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_RESPONSE_CACHE_MAX,
HOST_BRIDGE_VERSION,
type HostBridgeMethod,
type HostBridgeRequest,
type HostBridgeResponse,
} from '../../../../packages/shared/src/contracts/hostBridge';
import type { MobileShellUrlOptions } from '../shell/url';
import {
configureMobileHostBridgeNavigation,
handleMobileHostBridgeMessage,
resetMobileHostBridgeForTest,
} from './bridge';
import {
cancelQrCodeScan,
completeQrCodeScan,
failQrCodeScan,
} from './scanner';
type NotificationPermissionStatus = Awaited<
ReturnType<typeof Notifications.getPermissionsAsync>
>;
const GRANTED_NOTIFICATION_PERMISSION = {
status: 'granted',
granted: true,
canAskAgain: true,
expires: 'never',
ios: {
status: 2,
allowsBadge: true,
},
} as NotificationPermissionStatus;
const DENIED_NOTIFICATION_PERMISSION = {
status: 'denied',
granted: false,
canAskAgain: false,
expires: 'never',
ios: {
status: 1,
allowsBadge: false,
},
} as NotificationPermissionStatus;
let requestSequence = 0;
const TEST_MOBILE_URL_OPTIONS: MobileShellUrlOptions = {
platform: 'ios',
hostVersion: '0.1.0',
capabilities: ['host.getRuntime', 'share.open'],
};
const IOS_UNDECLARED_HOST_BRIDGE_METHODS = HOST_BRIDGE_METHODS.filter(
(method) => !HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method),
);
function encodeBytes(bytes: readonly number[]) {
return Buffer.from(bytes).toString('base64');
}
const PNG_BASE64 = encodeBytes([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0,
]);
const JPEG_BASE64 = encodeBytes([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0]);
const WAV_BASE64 = Buffer.from('RIFF\x04\x00\x00\x00WAVE', 'binary').toString(
'base64',
);
const MP3_BASE64 = Buffer.from(
'ID3\x04\x00\x00\x00\x00\x00\x10',
'binary',
).toString('base64');
const MP4_AUDIO_BASE64 = encodeBytes([
0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
]);
const WEBM_BASE64 = encodeBytes([0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00]);
const DOCX_BASE64 = Buffer.from('PK\x03\x04docx', 'binary').toString('base64');
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 fileSizes = vi.hoisted(() => new Map<string, number | null>());
const fileTextReads = vi.hoisted(() => [] as string[]);
const fileBase64Reads = vi.hoisted(() => [] as 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,
});
}
get size() {
return fileSizes.get(this.uri) ?? null;
}
text() {
fileTextReads.push(this.uri);
return Promise.resolve(fileTexts.get(this.uri) ?? '');
}
base64() {
fileBase64Reads.push(this.uri);
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', () => ({
canOpenURL: vi.fn(),
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(),
setBadgeCountAsync: 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',
},
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(Linking.canOpenURL).mockReset();
vi.mocked(Linking.canOpenURL).mockResolvedValue(true);
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(Notifications.setBadgeCountAsync).mockReset();
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(true);
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();
fileSizes.clear();
fileTextReads.length = 0;
fileBase64Reads.length = 0;
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.importDocument');
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('scanner.scanQrCode');
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://www.genarrative.world',
urlOptions: TEST_MOBILE_URL_OPTIONS,
openWebViewUrl,
reloadWebView: vi.fn(),
});
const response = await send(
request('navigation.openNativePage', {
url: '/works/detail?work=PZ-1',
}),
);
expect(expectOk(response).result).toBe(true);
const openedUrl = new URL(openWebViewUrl.mock.calls[0]?.[0] as string);
expect(openedUrl.origin).toBe('https://www.genarrative.world');
expect(openedUrl.pathname).toBe('/works/detail');
expect(openedUrl.searchParams.get('work')).toBe('PZ-1');
expect(openedUrl.searchParams.get('clientRuntime')).toBe('native_app');
expect(openedUrl.searchParams.get('hostShell')).toBe('expo_mobile');
expect(openedUrl.searchParams.get('hostPlatform')).toBe('ios');
expect(openedUrl.searchParams.get('hostVersion')).toBe('0.1.0');
expect(openedUrl.searchParams.get('bridgeVersion')).toBe('1');
expect(openedUrl.searchParams.get('hostCapabilities')).toBe(
'host.getRuntime,share.open',
);
});
test('navigation.openNativePage 拒绝外域目标', async () => {
configureMobileHostBridgeNavigation({
allowedOrigin: 'https://www.genarrative.world',
urlOptions: TEST_MOBILE_URL_OPTIONS,
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(IOS_UNDECLARED_HOST_BRIDGE_METHODS)(
'%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://www.genarrative.world',
urlOptions: TEST_MOBILE_URL_OPTIONS,
openWebViewUrl: vi.fn(),
reloadWebView,
});
const response = await send(request('app.reloadWebView'));
expect(expectOk(response).result).toBe(true);
expect(reloadWebView).toHaveBeenCalledTimes(1);
});
test('app.openExternalUrl 只打开允许的外链协议', async () => {
const response = await send(
request('app.openExternalUrl', {
url: ' https://example.com/path ',
}),
);
expect(expectOk(response).result).toBe(true);
expect(Linking.canOpenURL).toHaveBeenCalledWith('https://example.com/path');
expect(Linking.openURL).toHaveBeenCalledWith('https://example.com/path');
});
test('app.openExternalUrl 在系统不可打开时返回 host_error', async () => {
vi.mocked(Linking.canOpenURL).mockResolvedValue(false);
const response = await send(
request('app.openExternalUrl', {
url: 'tel:+12345678',
}),
);
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('host_error');
expect(failedResponse.error.message).toBe('external URL cannot be opened');
expect(Linking.openURL).not.toHaveBeenCalled();
});
test('原生异常对象不会透传非协议错误码', async () => {
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const nativeError = {
code: 'native_badge_failure',
message: 'native badge failed',
nativeStackIOS: ['private native frame'],
};
try {
vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValueOnce({
...nativeError,
});
const response = await send(
request('app.setBadgeCount', {
count: 1,
}),
);
const failedResponse = expectFailed(response);
expect(failedResponse.error).toEqual({
code: 'host_error',
message: 'app badge update unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile app badge failed for update.set_count',
);
} finally {
warnSpy.mockRestore();
}
});
test('app.openExternalUrl 原生异常返回稳定 host_error', async () => {
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
try {
vi.mocked(Linking.canOpenURL).mockRejectedValueOnce({
code: 'native_linking_failure',
message: 'native linking failed',
nativeStackIOS: ['private native frame'],
});
const response = await send(
request('app.openExternalUrl', {
url: 'https://example.com/path',
}),
);
const failedResponse = expectFailed(response);
expect(failedResponse.error).toEqual({
code: 'host_error',
message: 'external URL cannot be opened',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile HostBridge navigation failed for external.open',
);
} finally {
warnSpy.mockRestore();
}
});
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.canOpenURL).not.toHaveBeenCalled();
expect(Linking.openURL).not.toHaveBeenCalled();
});
test('app.openExternalUrl 拒绝控制字符', async () => {
const response = await send(
request('app.openExternalUrl', {
url: 'https://example.com/\nnext',
}),
);
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('invalid_request');
expect(Linking.canOpenURL).not.toHaveBeenCalled();
expect(Linking.openURL).not.toHaveBeenCalled();
});
test('重复 HostBridge request id 回放首次结果且不重复触发系统动作', async () => {
const duplicateRequest = request('share.open', {
title: '测试作品',
url: 'https://www.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('HostBridge request id 回放缓存超过共享上限后淘汰最早结果', async () => {
const firstRequest = request('clipboard.writeText', {
text: 'first',
});
await send(firstRequest);
expect(Clipboard.setStringAsync).toHaveBeenCalledTimes(1);
for (let index = 0; index < HOST_BRIDGE_RESPONSE_CACHE_MAX; index += 1) {
await send(
request('clipboard.writeText', {
text: `cache-${index}`,
}),
);
}
await send(firstRequest);
expect(Clipboard.setStringAsync).toHaveBeenCalledTimes(
HOST_BRIDGE_RESPONSE_CACHE_MAX + 2,
);
});
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.writeText 写入前按共享上限截断文本', async () => {
const response = await send(
request('clipboard.writeText', {
text: 'a'.repeat(100010),
}),
);
expect(expectOk(response).result).toBe(true);
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('a'.repeat(100000));
vi.mocked(Clipboard.setStringAsync).mockClear();
const unicodeResponse = await send(
request('clipboard.writeText', {
text: '猫'.repeat(100010),
}),
);
expect(expectOk(unicodeResponse).result).toBe(true);
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('猫'.repeat(100000));
});
test('clipboard.readText 读取失败时返回 host_error', async () => {
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const nativeError = new Error('clipboard unavailable');
vi.mocked(Clipboard.getStringAsync).mockRejectedValue(nativeError);
try {
const response = await send(request('clipboard.readText'));
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('host_error');
expect(warnSpy).toHaveBeenCalledWith(
'mobile clipboard failed for read.get_string',
);
} finally {
warnSpy.mockRestore();
}
});
test('haptics.impact 调起 Expo 触觉反馈', async () => {
const response = await send(
request('haptics.impact', {
style: 'heavy',
}),
);
expect(expectOk(response).result).toBe(true);
expect(Haptics.impactAsync).toHaveBeenCalledWith(
Haptics.ImpactFeedbackStyle.Heavy,
);
});
test('haptics.impact 缺省为轻触并拒绝未知强度', async () => {
const defaultResponse = await send(request('haptics.impact'));
expectOk(defaultResponse);
expect(Haptics.impactAsync).toHaveBeenCalledWith(
Haptics.ImpactFeedbackStyle.Light,
);
vi.mocked(Haptics.impactAsync).mockClear();
const invalidResponse = await send(
request('haptics.impact', {
style: 'rigid',
}),
);
expect(expectFailed(invalidResponse).error.code).toBe('invalid_request');
expect(Haptics.impactAsync).not.toHaveBeenCalled();
});
test('notification.showLocal 调起 Expo 本地通知', async () => {
const response = await send(
request('notification.showLocal', {
title: ' 生成完成 ',
body: ' 作品已准备好 可以试玩 ',
}),
);
expect(expectOk(response).result).toEqual({
action: 'delivered_to_system',
});
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(
HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID,
{
name: 'Genarrative',
importance: Notifications.AndroidImportance.DEFAULT,
},
);
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({
content: {
title: '生成完成',
},
trigger: {
channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID,
},
});
});
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(Notifications.setBadgeCountAsync).toHaveBeenCalledWith(12);
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
});
test('app.setBadgeCount 在 iOS 角标权限缺失时请求 badge 权限', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
DENIED_NOTIFICATION_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
GRANTED_NOTIFICATION_PERMISSION,
);
const response = await send(
request('app.setBadgeCount', {
count: 2,
}),
);
expectOk(response);
expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({
ios: {
allowAlert: false,
allowBadge: true,
allowSound: false,
},
});
expect(Notifications.setBadgeCountAsync).toHaveBeenCalledWith(2);
});
test('app.setBadgeCount 在 iOS 角标权限拒绝时不返回成功', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
DENIED_NOTIFICATION_PERMISSION,
);
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
DENIED_NOTIFICATION_PERMISSION,
);
const response = await send(
request('app.setBadgeCount', {
count: 1,
}),
);
expect(expectFailed(response).error).toEqual({
code: 'host_error',
message: 'app badge permission denied',
});
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
});
test('app.setBadgeCount 在 iOS 系统拒绝设置时不返回成功', async () => {
const warnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
try {
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false);
const response = await send(
request('app.setBadgeCount', {
count: 1,
}),
);
expect(expectFailed(response).error).toEqual({
code: 'host_error',
message: 'app badge update unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile app badge failed for update.rejected',
);
} finally {
warnSpy.mockRestore();
}
});
test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported', async () => {
const invalid = await send(
request('app.setBadgeCount', {
count: HOST_BRIDGE_BADGE_COUNT_MAX + 1,
}),
);
const invalidError = expectFailed(invalid).error;
expect(invalidError.code).toBe('invalid_request');
expect(invalidError.message).toContain(String(HOST_BRIDGE_BADGE_COUNT_MAX));
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
setPlatformOS('android');
const unsupported = await send(
request('app.setBadgeCount', {
count: 1,
}),
);
expect(expectFailed(unsupported).error.code).toBe('unsupported_capability');
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
});
test('share.open 使用直接分享 payload 调起系统分享', async () => {
const response = await send(
request('share.open', {
title: '测试作品',
message: '来玩这个作品',
url: 'https://www.genarrative.world/works/detail?work=PZ-1',
}),
);
expectOk(response);
expect(Share.share).toHaveBeenCalledWith({
title: '测试作品',
message:
'来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-1',
url: 'https://www.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://www.genarrative.world/works/detail?work=PZ-00000001',
url: 'https://www.genarrative.world/works/detail?work=PZ-00000001',
});
});
test('share.setTarget 拒绝缺少或无效目标且不清空已有目标', async () => {
expectOk(
await send(
request('share.setTarget', {
target: {
type: 'genarrative:share-target',
payload: {
title: '暖灯猫街',
work: 'PZ-00000001',
},
},
}),
),
);
const invalid = await send(request('share.setTarget', {}));
expect(expectFailed(invalid).error.message).toBe('target is required');
const empty = await send(request('share.setTarget', { target: {} }));
expect(expectFailed(empty).error.message).toBe('share target is required');
const unsafe = await send(
request('share.setTarget', {
target: {
title: '危险作品',
url: 'https://example.com/works/detail?work=PZ-1',
},
}),
);
expect(expectFailed(unsafe).error.message).toBe('share target is invalid');
expectOk(await send(request('share.open')));
expect(Share.share).toHaveBeenCalledWith({
title: '暖灯猫街',
message: 'https://www.genarrative.world/works/detail?work=PZ-00000001',
url: 'https://www.genarrative.world/works/detail?work=PZ-00000001',
});
});
test('share.open 只把同源路径归一为公开主站分享 URL', async () => {
const response = await send(
request('share.open', {
title: '测试作品',
path: '/works/detail?work=PZ-2#play',
}),
);
expectOk(response);
expect(Share.share).toHaveBeenCalledWith({
title: '测试作品',
message: 'https://www.genarrative.world/works/detail?work=PZ-2#play',
url: 'https://www.genarrative.world/works/detail?work=PZ-2#play',
});
});
test.each([
'https://example.com/works/detail?work=PZ-1',
'//example.com/works/detail?work=PZ-1',
'//www.genarrative.world/works/detail?work=PZ-1',
'javascript:alert(1)',
])('share.open 拒绝非公开主站 URL%s', async (url) => {
const response = await send(
request('share.open', {
title: '测试作品',
url,
}),
);
expect(expectFailed(response).error.code).toBe('invalid_request');
expect(Share.share).not.toHaveBeenCalled();
});
test('share.open 带非法 URL 时不会回退到缓存分享目标', async () => {
expectOk(
await send(
request('share.setTarget', {
target: {
type: 'genarrative:share-target',
payload: {
title: '暖灯猫街',
work: 'PZ-00000001',
},
},
}),
),
);
vi.mocked(Share.share).mockClear();
const response = await send(
request('share.open', {
title: '测试作品',
url: 'https://example.com/works/detail?work=PZ-1',
}),
);
expect(expectFailed(response).error.code).toBe('invalid_request');
expect(Share.share).not.toHaveBeenCalled();
});
test('share.open 拒绝指向外域的 targetPath', async () => {
const response = await send(
request('share.open', {
title: '测试作品',
targetPath: '//example.com/works/detail?work=PZ-1',
}),
);
expect(expectFailed(response).error.code).toBe('invalid_request');
expect(Share.share).not.toHaveBeenCalled();
});
test('share.open 拒绝协议相对的同源 targetPath', async () => {
const response = await send(
request('share.open', {
title: '测试作品',
targetPath: '//www.genarrative.world/works/detail?work=PZ-1',
}),
);
expect(expectFailed(response).error.code).toBe('invalid_request');
expect(Share.share).not.toHaveBeenCalled();
});
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.exportText 拒绝非文本 MIME', async () => {
const response = await send(
request('file.exportText', {
fileName: '作品记录.txt',
content: '暖灯猫街',
mimeType: 'image/png',
}),
);
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 在系统选择结果缺少 size 时先用文件大小门禁', async () => {
fileTexts.set('file:///private/mobile/story.md', '暖灯猫街');
fileSizes.set('file:///private/mobile/story.md', 12);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/story.md',
name: 'story.md',
mimeType: 'text/markdown',
lastModified: 1,
},
],
});
const response = await send(request('file.importText'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: 'story.md',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
});
expect(fileTextReads).toEqual(['file:///private/mobile/story.md']);
fileSizes.set('file:///private/mobile/story.md', 5 * 1024 * 1024 + 1);
fileTextReads.length = 0;
const oversized = await send(request('file.importText'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
expect(fileTextReads).toEqual([]);
});
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.importDocument 调起系统文档选择器并返回受控 DOCX 数据', async () => {
fileBase64Data.set('file:///private/mobile/world.docx', DOCX_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/world.docx',
name: ' ../世界:设定?.docx ',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
size: 8,
lastModified: 1,
},
],
});
const response = await send(request('file.importDocument'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: '世界-设定-.docx',
base64Data: DOCX_BASE64,
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
});
expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({
copyToCacheDirectory: true,
multiple: false,
type: [
'text/*',
'text/plain',
'text/markdown',
'text/csv',
'application/json',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
});
});
test('file.importDocument 在系统选择结果缺少 size 时先用文件大小门禁', async () => {
fileBase64Data.set('file:///private/mobile/world.docx', DOCX_BASE64);
fileSizes.set('file:///private/mobile/world.docx', 8);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/world.docx',
name: 'world.docx',
mimeType: 'application/octet-stream',
lastModified: 1,
},
],
});
const response = await send(request('file.importDocument'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: 'world.docx',
base64Data: DOCX_BASE64,
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
});
expect(fileBase64Reads).toEqual(['file:///private/mobile/world.docx']);
fileSizes.set('file:///private/mobile/world.docx', 5 * 1024 * 1024 + 1);
fileBase64Reads.length = 0;
const oversized = await send(request('file.importDocument'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
expect(fileBase64Reads).toEqual([]);
});
test('file.importDocument 取消选择并拒绝非法 MIME 与超限文档', async () => {
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: true,
assets: null,
});
const cancelled = await send(request('file.importDocument'));
expect(expectFailed(cancelled).error.code).toBe('cancelled');
fileBase64Data.set('file:///private/mobile/story.png', DOCX_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/story.png',
name: 'story.png',
mimeType: 'image/png',
size: 8,
lastModified: 1,
},
],
});
const unsupportedMime = await send(request('file.importDocument'));
expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request');
fileBase64Data.set('file:///private/mobile/world.docx', DOCX_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/world.docx',
name: 'world.docx',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
size: 5 * 1024 * 1024 + 1,
lastModified: 1,
},
],
});
const oversized = await send(request('file.importDocument'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
});
test('file.exportImage 写入缓存图片并调起系统分享', async () => {
const response = await send(
request('file.exportImage', {
fileName: ' ../分享:卡? ',
base64Data: PNG_BASE64,
mimeType: 'image/png',
}),
);
const okResponse = expectOk(response);
expect(okResponse.result).toEqual({
action: 'saved',
fileName: '分享-卡-.png',
bytes: 12,
});
expect(writtenFiles).toEqual([
{
uri: 'file:///cache/分享-卡-.png',
content: PNG_BASE64,
options: { encoding: 'base64' },
},
]);
expect(Sharing.shareAsync).toHaveBeenCalledWith(
'file:///cache/分享-卡-.png',
{
mimeType: 'image/png',
UTI: 'public.png',
dialogTitle: '分享-卡-.png',
},
);
const jpegResponse = await send(
request('file.exportImage', {
fileName: '分享卡.png',
base64Data: JPEG_BASE64,
mimeType: 'image/jpeg',
}),
);
expect(expectOk(jpegResponse).result).toEqual({
action: 'saved',
fileName: '分享卡.png.jpg',
bytes: 7,
});
expect(writtenFiles.at(-1)).toEqual({
uri: 'file:///cache/分享卡.png.jpg',
content: JPEG_BASE64,
options: { encoding: 'base64' },
});
});
test('file.exportImage 拒绝非图片 MIME、空内容与超限内容', async () => {
const unsupportedMime = await send(
request('file.exportImage', {
fileName: '分享卡.txt',
base64Data: PNG_BASE64,
mimeType: 'text/plain',
}),
);
expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request');
const emptyImage = await send(
request('file.exportImage', {
fileName: '分享卡.png',
base64Data: '',
mimeType: 'image/png',
}),
);
expect(expectFailed(emptyImage).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');
const mismatched = await send(
request('file.exportImage', {
fileName: '分享卡.png',
base64Data: JPEG_BASE64,
mimeType: 'image/png',
}),
);
expect(expectFailed(mismatched).error.message).toBe(
'image bytes do not match MIME',
);
expect(writtenFiles).toEqual([]);
expect(Sharing.shareAsync).not.toHaveBeenCalled();
});
test('file.exportAudio 写入缓存音频并调起系统分享', async () => {
const response = await send(
request('file.exportAudio', {
fileName: ' ../敲击:音效?.wav ',
base64Data: WAV_BASE64,
mimeType: 'audio/wav',
}),
);
const okResponse = expectOk(response);
expect(okResponse.result).toEqual({
action: 'saved',
fileName: '敲击-音效-.wav',
bytes: 12,
});
expect(writtenFiles).toEqual([
{
uri: 'file:///cache/敲击-音效-.wav',
content: WAV_BASE64,
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: WAV_BASE64,
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: WAV_BASE64,
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');
const mismatched = await send(
request('file.exportAudio', {
fileName: 'hit.wav',
base64Data: MP3_BASE64,
mimeType: 'audio/wav',
}),
);
expect(expectFailed(mismatched).error.message).toBe(
'audio bytes do not match MIME',
);
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: 12,
base64: PNG_BASE64,
mimeType: 'image/png',
},
],
});
const response = await send(request('file.importImage'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: '参考-图-.png',
base64Data: PNG_BASE64,
mimeType: 'image/png',
bytes: 12,
});
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: PNG_BASE64,
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: PNG_BASE64,
mimeType: 'image/png',
},
],
});
const oversized = await send(request('file.importImage'));
expect(expectFailed(oversized).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: 7,
base64: JPEG_BASE64,
mimeType: 'image/png',
},
],
});
const mismatched = await send(request('file.importImage'));
expect(expectFailed(mismatched).error.message).toBe(
'image bytes do not match MIME',
);
});
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: 7,
base64: JPEG_BASE64,
mimeType: 'image/jpeg',
},
],
});
const response = await send(request('file.captureImage'));
expect(expectOk(response).result).toEqual({
action: 'captured',
fileName: 'genarrative-import.jpg',
base64Data: JPEG_BASE64,
mimeType: 'image/jpeg',
bytes: 7,
});
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('scanner.scanQrCode 等待扫码 overlay 返回真实结果', async () => {
const pendingResponse = send(request('scanner.scanQrCode'));
expect(completeQrCodeScan(' https://www.genarrative.world/w/PZ-1 ')).toBe(
true,
);
expect(expectOk(await pendingResponse).result).toEqual({
value: 'https://www.genarrative.world/w/PZ-1',
format: 'qr_code',
});
});
test('scanner.scanQrCode 取消和扫描失败返回明确错误', async () => {
const cancelledResponse = send(request('scanner.scanQrCode'));
cancelQrCodeScan();
expect(expectFailed(await cancelledResponse).error).toEqual({
code: 'cancelled',
message: 'qr scan cancelled',
});
const deniedResponse = send(request('scanner.scanQrCode'));
failQrCodeScan();
expect(expectFailed(await deniedResponse).error).toEqual({
code: 'host_error',
message: 'qr scanner unavailable',
});
});
test('file.importAudio 调起系统文档选择器并返回受控音频数据', async () => {
fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/hit.webm',
name: ' ../敲击:音效?.webm ',
mimeType: 'audio/webm',
size: 6,
lastModified: 1,
},
],
});
const response = await send(request('file.importAudio'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: '敲击-音效-.webm',
base64Data: WEBM_BASE64,
mimeType: 'audio/webm',
bytes: 6,
});
expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({
copyToCacheDirectory: true,
multiple: false,
type: [
'audio/*',
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/ogg',
'audio/webm',
],
});
});
test('file.importAudio 在系统选择结果缺少 size 时先用文件大小门禁', async () => {
fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64);
fileSizes.set('file:///private/mobile/hit.webm', 6);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/hit.webm',
name: 'hit.webm',
mimeType: 'audio/webm',
lastModified: 1,
},
],
});
const response = await send(request('file.importAudio'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: 'hit.webm',
base64Data: WEBM_BASE64,
mimeType: 'audio/webm',
bytes: 6,
});
expect(fileBase64Reads).toEqual(['file:///private/mobile/hit.webm']);
fileSizes.set('file:///private/mobile/hit.webm', 20 * 1024 * 1024 + 1);
fileBase64Reads.length = 0;
const oversized = await send(request('file.importAudio'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
expect(fileBase64Reads).toEqual([]);
});
test('file.importAudio 允许系统 MIME 缺失时按扩展名和 bytes 校验导入', async () => {
fileBase64Data.set('file:///private/mobile/hit.m4a', MP3_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/hit.m4a',
name: 'hit.m4a',
mimeType: 'application/octet-stream',
size: 10,
lastModified: 1,
},
],
});
const mismatched = await send(request('file.importAudio'));
expect(expectFailed(mismatched).error.message).toBe(
'audio bytes do not match MIME',
);
fileBase64Data.set('file:///private/mobile/hit.m4a', MP4_AUDIO_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/hit.m4a',
name: 'hit.m4a',
mimeType: 'application/octet-stream',
size: 12,
lastModified: 1,
},
],
});
const imported = await send(request('file.importAudio'));
expect(expectOk(imported).result).toEqual({
action: 'selected',
fileName: 'hit.m4a',
base64Data: MP4_AUDIO_BASE64,
mimeType: 'audio/mp4',
bytes: 12,
});
});
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', WEBM_BASE64);
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', WEBM_BASE64);
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');
fileBase64Data.set('file:///private/mobile/hit.webm', MP3_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/hit.webm',
name: 'hit.webm',
mimeType: 'audio/webm',
size: 10,
lastModified: 1,
},
],
});
const mismatched = await send(request('file.importAudio'));
expect(expectFailed(mismatched).error.message).toBe(
'audio bytes do not match MIME',
);
});
});