补齐移动壳文件动作单测
移动壳 files helper 增加系统文件动作直接单测 移动壳配置门禁反查文件动作测试边界 宿主壳文档和共享决策同步移动文件动作覆盖
This commit is contained in:
@@ -38,6 +38,8 @@ const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
|
||||
const dispatchSource = fs.readFileSync(dispatchPath, 'utf8');
|
||||
const filesPath = new URL('../src/host-bridge/files.ts', import.meta.url);
|
||||
const filesSource = fs.readFileSync(filesPath, 'utf8');
|
||||
const filesTestPath = new URL('../src/host-bridge/files.test.ts', import.meta.url);
|
||||
const filesTestSource = fs.readFileSync(filesTestPath, 'utf8');
|
||||
const filePayloadsPath = new URL(
|
||||
'../src/host-bridge/filePayloads.ts',
|
||||
import.meta.url,
|
||||
@@ -1937,6 +1939,27 @@ for (const [wrapperName, fileCall] of [
|
||||
}
|
||||
}
|
||||
|
||||
for (const snippet of [
|
||||
'describe(\'mobile HostBridge file actions\'',
|
||||
'exportTextFile({',
|
||||
'exportImageFile({',
|
||||
'importTextFile()',
|
||||
'importDocumentFile()',
|
||||
'importAudioFile()',
|
||||
'importImageFile()',
|
||||
'captureImageFile()',
|
||||
'exportAudioFile({',
|
||||
"code: 'unsupported_capability'",
|
||||
"code: 'cancelled'",
|
||||
"message: 'camera permission denied'",
|
||||
"mediaTypes: ['images']",
|
||||
"options: { encoding: 'base64' }",
|
||||
]) {
|
||||
if (!filesTestSource.includes(snippet)) {
|
||||
throw new Error(`mobile shell file action tests missing ${snippet}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!dispatchSource.includes('exportMobileHostBridgeTextFile(request)') ||
|
||||
!dispatchSource.includes('importMobileHostBridgeTextFile(request)') ||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
captureImageFile,
|
||||
exportAudioFile,
|
||||
exportImageFile,
|
||||
exportTextFile,
|
||||
importAudioFile,
|
||||
importDocumentFile,
|
||||
importImageFile,
|
||||
importTextFile,
|
||||
} from './files';
|
||||
|
||||
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 DOCX_BASE64 = Buffer.from('PK\x03\x04docx', 'binary').toString('base64');
|
||||
const MP3_BASE64 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x10', 'binary').toString(
|
||||
'base64',
|
||||
);
|
||||
|
||||
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 writtenFiles = vi.hoisted(
|
||||
() =>
|
||||
[] as {
|
||||
uri: string;
|
||||
content: string;
|
||||
options?: { encoding?: 'utf8' | 'base64' };
|
||||
}[],
|
||||
);
|
||||
|
||||
vi.mock('expo-file-system', () => ({
|
||||
Paths: {
|
||||
cache: 'file:///cache/',
|
||||
},
|
||||
File: class TestFile {
|
||||
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() {
|
||||
return Promise.resolve(fileTexts.get(this.uri) ?? '');
|
||||
}
|
||||
|
||||
base64() {
|
||||
return Promise.resolve(fileBase64Data.get(this.uri) ?? '');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('expo-document-picker', () => ({
|
||||
getDocumentAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('expo-image-picker', () => ({
|
||||
PermissionStatus: {
|
||||
DENIED: 'denied',
|
||||
GRANTED: 'granted',
|
||||
},
|
||||
launchCameraAsync: vi.fn(),
|
||||
launchImageLibraryAsync: vi.fn(),
|
||||
requestCameraPermissionsAsync: vi.fn(),
|
||||
requestMediaLibraryPermissionsAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('expo-sharing', () => ({
|
||||
isAvailableAsync: vi.fn(),
|
||||
shareAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const documentPickerMock = vi.mocked(DocumentPicker.getDocumentAsync);
|
||||
const shareAvailableMock = vi.mocked(Sharing.isAvailableAsync);
|
||||
const shareAsyncMock = vi.mocked(Sharing.shareAsync);
|
||||
const libraryPermissionMock = vi.mocked(
|
||||
ImagePicker.requestMediaLibraryPermissionsAsync,
|
||||
);
|
||||
const cameraPermissionMock = vi.mocked(ImagePicker.requestCameraPermissionsAsync);
|
||||
const imageLibraryMock = vi.mocked(ImagePicker.launchImageLibraryAsync);
|
||||
const cameraMock = vi.mocked(ImagePicker.launchCameraAsync);
|
||||
|
||||
describe('mobile HostBridge file actions', () => {
|
||||
beforeEach(() => {
|
||||
shareAvailableMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fileTexts.clear();
|
||||
fileBase64Data.clear();
|
||||
fileSizes.clear();
|
||||
writtenFiles.length = 0;
|
||||
});
|
||||
|
||||
test('exports text through Expo cache file and system share sheet', async () => {
|
||||
const result = await exportTextFile({
|
||||
content: '泥巴AI',
|
||||
fileName: '创作记录',
|
||||
mimeType: 'text/markdown',
|
||||
});
|
||||
|
||||
expect(writtenFiles).toEqual([
|
||||
{
|
||||
uri: 'file:///cache/创作记录',
|
||||
content: '泥巴AI',
|
||||
options: undefined,
|
||||
},
|
||||
]);
|
||||
expect(shareAsyncMock).toHaveBeenCalledWith('file:///cache/创作记录', {
|
||||
mimeType: 'text/markdown',
|
||||
UTI: 'public.plain-text',
|
||||
dialogTitle: '创作记录',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
action: 'saved',
|
||||
fileName: '创作记录',
|
||||
bytes: Buffer.byteLength('泥巴AI'),
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects exports when system sharing is unavailable', async () => {
|
||||
shareAvailableMock.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
exportImageFile({
|
||||
base64Data: PNG_BASE64,
|
||||
fileName: '分享卡',
|
||||
mimeType: 'image/png',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 'unsupported_capability',
|
||||
});
|
||||
expect(shareAsyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('imports text and document files without exposing local URIs', async () => {
|
||||
fileTexts.set('file:///picked/story.md', '故事');
|
||||
fileSizes.set('file:///picked/story.md', Buffer.byteLength('故事'));
|
||||
documentPickerMock.mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [
|
||||
{
|
||||
uri: 'file:///picked/story.md',
|
||||
name: 'story.md',
|
||||
mimeType: 'text/markdown',
|
||||
size: Buffer.byteLength('故事'),
|
||||
lastModified: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(importTextFile()).resolves.toEqual({
|
||||
action: 'selected',
|
||||
fileName: 'story.md',
|
||||
content: '故事',
|
||||
mimeType: 'text/markdown',
|
||||
bytes: Buffer.byteLength('故事'),
|
||||
});
|
||||
|
||||
fileBase64Data.set('file:///picked/brief.docx', DOCX_BASE64);
|
||||
fileSizes.set('file:///picked/brief.docx', Buffer.byteLength('PK\x03\x04docx'));
|
||||
documentPickerMock.mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [
|
||||
{
|
||||
uri: 'file:///picked/brief.docx',
|
||||
name: 'brief.docx',
|
||||
mimeType:
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
size: Buffer.byteLength('PK\x03\x04docx'),
|
||||
lastModified: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(importDocumentFile()).resolves.toEqual({
|
||||
action: 'selected',
|
||||
fileName: 'brief.docx',
|
||||
base64Data: DOCX_BASE64,
|
||||
mimeType:
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
bytes: Buffer.byteLength('PK\x03\x04docx'),
|
||||
});
|
||||
});
|
||||
|
||||
test('maps picker cancellation to HostBridge cancellation errors', async () => {
|
||||
documentPickerMock.mockResolvedValue({
|
||||
canceled: true,
|
||||
assets: null,
|
||||
});
|
||||
|
||||
await expect(importAudioFile()).rejects.toMatchObject({
|
||||
code: 'cancelled',
|
||||
});
|
||||
});
|
||||
|
||||
test('imports and captures images only after native permissions are granted', async () => {
|
||||
libraryPermissionMock.mockResolvedValueOnce({
|
||||
status: ImagePicker.PermissionStatus.GRANTED,
|
||||
granted: true,
|
||||
canAskAgain: true,
|
||||
expires: 'never',
|
||||
});
|
||||
imageLibraryMock.mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [
|
||||
{
|
||||
uri: 'file:///picked/image.png',
|
||||
width: 120,
|
||||
height: 80,
|
||||
type: 'image',
|
||||
fileName: 'image.png',
|
||||
fileSize: Buffer.byteLength(Buffer.from(PNG_BASE64, 'base64')),
|
||||
base64: PNG_BASE64,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(importImageFile()).resolves.toMatchObject({
|
||||
action: 'selected',
|
||||
fileName: 'image.png',
|
||||
base64Data: PNG_BASE64,
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
expect(imageLibraryMock).toHaveBeenCalledWith({
|
||||
allowsEditing: false,
|
||||
allowsMultipleSelection: false,
|
||||
base64: true,
|
||||
exif: false,
|
||||
mediaTypes: ['images'],
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
cameraPermissionMock.mockResolvedValueOnce({
|
||||
status: ImagePicker.PermissionStatus.DENIED,
|
||||
granted: false,
|
||||
canAskAgain: false,
|
||||
expires: 'never',
|
||||
});
|
||||
|
||||
await expect(captureImageFile()).rejects.toMatchObject({
|
||||
code: 'host_error',
|
||||
message: 'camera permission denied',
|
||||
});
|
||||
expect(cameraMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('imports audio and exports binary files through controlled payloads', async () => {
|
||||
fileBase64Data.set('file:///picked/voice.mp3', MP3_BASE64);
|
||||
fileSizes.set('file:///picked/voice.mp3', Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10'));
|
||||
documentPickerMock.mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [
|
||||
{
|
||||
uri: 'file:///picked/voice.mp3',
|
||||
name: 'voice.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
size: Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10'),
|
||||
lastModified: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(importAudioFile()).resolves.toMatchObject({
|
||||
action: 'selected',
|
||||
fileName: 'voice.mp3',
|
||||
base64Data: MP3_BASE64,
|
||||
mimeType: 'audio/mpeg',
|
||||
});
|
||||
|
||||
await expect(
|
||||
exportAudioFile({
|
||||
base64Data: MP3_BASE64,
|
||||
fileName: '声浪',
|
||||
mimeType: 'audio/mpeg',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
action: 'saved',
|
||||
fileName: '声浪.mp3',
|
||||
});
|
||||
expect(writtenFiles.at(-1)).toEqual({
|
||||
uri: 'file:///cache/声浪.mp3',
|
||||
content: MP3_BASE64,
|
||||
options: { encoding: 'base64' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@
|
||||
- 2026-06-19 桌面壳窗口标题桥接边界:Tauri `app.setTitle` 的 payload 校验、非空 / 控制字符拒绝、80 字符截断和主窗口 `set_title` 调用统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;`dispatch.rs` 只负责委托 `set_desktop_host_bridge_window_title(...)`。桌面壳配置检查和根级结构门禁会覆盖 `title.rs` 文件清单、共享标题长度镜像和 dispatch 委托关系。
|
||||
- 2026-06-19 桌面壳文件桥接执行边界:Tauri `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.importAudio` / `file.exportAudio` 的系统文件对话框过滤器、用户取消语义、路径转换、异步读写编排和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs`;MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 组装统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`;`dispatch.rs` 只负责按 method 委托 `export_desktop_host_bridge_*_file(...)` / `import_desktop_host_bridge_*_file(...)`。桌面壳配置检查会拒绝分发层直接调用 `.dialog()`、`blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper,避免文件访问边界重新散落。
|
||||
- 2026-06-20 移动壳文件桥接载荷边界:Expo `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.captureImage` / `file.importAudio` / `file.exportAudio` 的 DocumentPicker、ImagePicker、File、Sharing 系统交互、用户取消语义、缓存读写编排和 HostBridge 响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`;MIME、大小、base64、文件名清洗、图片 / 音频 bytes 匹配和 picker 结果到 HostBridge payload 的组装统一收口在 `apps/mobile-shell/src/host-bridge/filePayloads.ts`。移动壳单端配置检查和根级 `npm run check:native-shells` 会把 `filePayloads.ts` 纳入结构清单与 HostBridge 源码扫描,避免文件载荷边界重新散落到分发层或 shell 层。
|
||||
- 2026-06-20 移动文件动作单测边界:`apps/mobile-shell/src/host-bridge/files.test.ts` 直接覆盖 Expo 文件动作 helper 的文本导出、系统分享不可用、文本 / 文档 / 音频导入、用户取消、图片相册导入、相机权限拒绝和音频二进制导出;单端配置检查会反查这些动作测试存在,根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免系统文件交互只靠完整 HostBridge bridge 流程间接覆盖。
|
||||
- 2026-06-20 移动文件载荷单测边界:`apps/mobile-shell/src/host-bridge/filePayloads.test.ts` 直接覆盖移动壳文件载荷 helper 的 base64、UTF-8 byte、MIME / 扩展名归一、图片 / 音频 bytes 匹配、导出文件名补扩展、导入大小门禁和 ImagePicker payload 转换;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,防止后续只靠完整 HostBridge bridge 流程间接覆盖文件安全边界。
|
||||
- 2026-06-20 移动本地通知单测边界:`apps/mobile-shell/src/host-bridge/notifications.test.ts` 直接覆盖 Expo `notification.showLocal` 的已授权 / iOS provisional 权限复用、alert-only 权限请求、权限拒绝失败、iOS 即时调度、Android 固定 channel、共享 payload 归一和结构化 `delivered_to_system` 成功响应;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免移动通知边界只靠完整 HostBridge bridge 流程间接覆盖。
|
||||
- 2026-06-20 桌面能力清单单测边界:Tauri `capabilities.rs` 必须用 Rust 单测同时覆盖桌面 runtime capability 清单顺序、无重复、真实桌面能力完整包含,并显式排除 `auth.requestLogin`、`payment.request`、`file.captureImage`、`scanner.scanQrCode` 和 `haptics.impact` 等未接入能力;桌面单端配置检查会反查该测试边界,避免只靠方案文档或共享 profile 发现桌面壳能力伪声明。
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -262,6 +262,7 @@ const expectedMobileHostBridgeFiles = [
|
||||
'dispatch.ts',
|
||||
'filePayloads.test.ts',
|
||||
'filePayloads.ts',
|
||||
'files.test.ts',
|
||||
'files.ts',
|
||||
'haptics.test.ts',
|
||||
'haptics.ts',
|
||||
|
||||
Reference in New Issue
Block a user