补齐移动壳通知单测
移动壳本地通知 helper 增加独立单测覆盖 原生壳结构门禁登记移动通知测试 宿主壳文档和共享决策同步通知测试边界
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Platform } from 'react-native';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT,
|
||||
HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID,
|
||||
HOST_BRIDGE_PROTOCOL,
|
||||
HOST_BRIDGE_VERSION,
|
||||
type HostBridgeRequest,
|
||||
} from '../../../../packages/shared/src/contracts/hostBridge';
|
||||
import {
|
||||
showMobileHostBridgeLocalNotification,
|
||||
showMobileLocalNotification,
|
||||
} from './notifications';
|
||||
|
||||
type NotificationPermissionStatus =
|
||||
Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>;
|
||||
|
||||
const GRANTED_NOTIFICATION_PERMISSION = {
|
||||
status: 'granted',
|
||||
granted: true,
|
||||
canAskAgain: true,
|
||||
expires: 'never',
|
||||
} as NotificationPermissionStatus;
|
||||
|
||||
const PROVISIONAL_NOTIFICATION_PERMISSION = {
|
||||
status: 'undetermined',
|
||||
granted: false,
|
||||
canAskAgain: true,
|
||||
expires: 'never',
|
||||
ios: {
|
||||
status: 'provisional',
|
||||
},
|
||||
} as unknown as NotificationPermissionStatus;
|
||||
|
||||
const DENIED_NOTIFICATION_PERMISSION = {
|
||||
status: 'denied',
|
||||
granted: false,
|
||||
canAskAgain: false,
|
||||
expires: 'never',
|
||||
} as NotificationPermissionStatus;
|
||||
|
||||
vi.mock('expo-notifications', () => ({
|
||||
AndroidImportance: {
|
||||
DEFAULT: 'default',
|
||||
},
|
||||
IosAuthorizationStatus: {
|
||||
PROVISIONAL: 'provisional',
|
||||
},
|
||||
getPermissionsAsync: vi.fn(),
|
||||
requestPermissionsAsync: vi.fn(),
|
||||
scheduleNotificationAsync: vi.fn(),
|
||||
setNotificationChannelAsync: vi.fn(),
|
||||
setNotificationHandler: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: {
|
||||
OS: 'ios',
|
||||
},
|
||||
}));
|
||||
|
||||
function request(payload?: unknown): HostBridgeRequest {
|
||||
return {
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: 'notification-request',
|
||||
method: 'notification.showLocal',
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function setPlatformOS(os: 'ios' | 'android') {
|
||||
(Platform as { OS: 'ios' | 'android' }).OS = os;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
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);
|
||||
setPlatformOS('ios');
|
||||
});
|
||||
|
||||
describe('mobile local notification helpers', () => {
|
||||
test('uses existing granted or provisional permission without prompting', async () => {
|
||||
await expect(
|
||||
showMobileLocalNotification({ title: '生成完成' }),
|
||||
).resolves.toEqual(HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT);
|
||||
|
||||
expect(Notifications.getPermissionsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce(
|
||||
PROVISIONAL_NOTIFICATION_PERMISSION,
|
||||
);
|
||||
|
||||
await expect(
|
||||
showMobileLocalNotification({ title: '生成完成' }),
|
||||
).resolves.toEqual(HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT);
|
||||
|
||||
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('requests alert-only permission when current permission is missing', async () => {
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
|
||||
DENIED_NOTIFICATION_PERMISSION,
|
||||
);
|
||||
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
|
||||
GRANTED_NOTIFICATION_PERMISSION,
|
||||
);
|
||||
|
||||
await expect(
|
||||
showMobileLocalNotification({ title: '生成完成' }),
|
||||
).resolves.toEqual(HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT);
|
||||
|
||||
expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({
|
||||
ios: {
|
||||
allowAlert: true,
|
||||
allowBadge: false,
|
||||
allowSound: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects delivery when permission remains denied', async () => {
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
|
||||
DENIED_NOTIFICATION_PERMISSION,
|
||||
);
|
||||
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
|
||||
DENIED_NOTIFICATION_PERMISSION,
|
||||
);
|
||||
|
||||
await expect(
|
||||
showMobileLocalNotification({ title: '生成完成' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'host_error',
|
||||
message: 'notification permission denied',
|
||||
});
|
||||
|
||||
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('schedules iOS notification without channel trigger', async () => {
|
||||
await showMobileLocalNotification({
|
||||
title: '生成完成',
|
||||
body: '作品已准备好',
|
||||
});
|
||||
|
||||
expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled();
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({
|
||||
content: {
|
||||
title: '生成完成',
|
||||
body: '作品已准备好',
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('uses fixed Android local notification channel', async () => {
|
||||
setPlatformOS('android');
|
||||
|
||||
await showMobileLocalNotification({ title: '生成完成' });
|
||||
|
||||
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('normalizes HostBridge payload and wraps structured delivery result', async () => {
|
||||
const response = await showMobileHostBridgeLocalNotification(
|
||||
request({
|
||||
title: ' 生成完成 ',
|
||||
body: ' 作品已准备好 可以试玩 ',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response).toEqual({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: 'notification-request',
|
||||
ok: true,
|
||||
result: HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT,
|
||||
});
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({
|
||||
content: {
|
||||
title: '生成完成',
|
||||
body: '作品已准备好 可以试玩',
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects invalid HostBridge notification payload before system calls', async () => {
|
||||
await expect(
|
||||
showMobileHostBridgeLocalNotification(
|
||||
request({
|
||||
title: '生成\n完成',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_request',
|
||||
message: 'title is required',
|
||||
});
|
||||
|
||||
expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled();
|
||||
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@
|
||||
- 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/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 `notification.showLocal` 的 title / body 归一化、长度上限和成功结果 action 必须镜像共享 HostBridge 契约;Rust 侧常量使用 `HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH`、`HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH` 和 `HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION` 命名,桌面单端配置检查会与 `packages/shared/src/contracts/hostBridge.ts` 比对数值并反查成功结果由该 action 常量组装,避免通知 payload 边界变成桌面壳本地规则。
|
||||
- 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5,WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。
|
||||
- 2026-06-20 H5 原生导航预校验:`navigateHostNativePage()` 在 `native_app` 下发送 `navigation.openNativePage` 前必须先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续交给 Expo / Tauri 壳二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。根级 `npm run check:native-shells` 会反查 H5 facade 仍使用 `normalizeNativeAppPageUrl(...)` 且发送归一后的 URL,避免明显不安全目标触达原生壳。
|
||||
|
||||
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 = [
|
||||
'haptics.ts',
|
||||
'navigation.ts',
|
||||
'network.ts',
|
||||
'notifications.test.ts',
|
||||
'notifications.ts',
|
||||
'protocol.ts',
|
||||
'runtime.ts',
|
||||
|
||||
Reference in New Issue
Block a user