补齐移动壳文件动作单测

移动壳 files helper 增加系统文件动作直接单测

移动壳配置门禁反查文件动作测试边界

宿主壳文档和共享决策同步移动文件动作覆盖
This commit is contained in:
2026-06-20 08:57:19 +08:00
parent 3873fdaf64
commit 05e7ca1015
6 changed files with 341 additions and 5 deletions
@@ -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
+1
View File
@@ -262,6 +262,7 @@ const expectedMobileHostBridgeFiles = [
'dispatch.ts',
'filePayloads.test.ts',
'filePayloads.ts',
'files.test.ts',
'files.ts',
'haptics.test.ts',
'haptics.ts',