071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
2125 lines
61 KiB
TypeScript
2125 lines
61 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, 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',
|
||
);
|
||
});
|
||
});
|