diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 91718f6b1..7b17c9cdd 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -44,6 +44,11 @@ const clipboardPath = new URL('../src/host-bridge/clipboard.ts', import.meta.url const clipboardSource = fs.readFileSync(clipboardPath, 'utf8'); const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url); const dispatchSource = fs.readFileSync(dispatchPath, 'utf8'); +const dispatchTestPath = new URL( + '../src/host-bridge/dispatch.test.ts', + import.meta.url, +); +const dispatchTestSource = fs.readFileSync(dispatchTestPath, '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); @@ -955,11 +960,12 @@ for (const snippet of [ 'const IOS_UNDECLARED_HOST_BRIDGE_METHODS = HOST_BRIDGE_METHODS.filter(', '(method) => !HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method)', 'test.each(IOS_UNDECLARED_HOST_BRIDGE_METHODS)', - "expect(failedResponse.error.code).toBe('unsupported_method')", + 'dispatchMobileHostBridgeRequest(request(method))', + "expect(response.error.code).toBe('unsupported_method')", ]) { - if (!bridgeTestSource.includes(snippet)) { + if (!dispatchTestSource.includes(snippet)) { throw new Error( - `mobile shell bridge tests must derive unsupported method coverage from shared profiles: ${snippet}`, + `mobile shell dispatch tests must derive unsupported method coverage from shared profiles: ${snippet}`, ); } } diff --git a/apps/mobile-shell/src/host-bridge/dispatch.test.ts b/apps/mobile-shell/src/host-bridge/dispatch.test.ts new file mode 100644 index 000000000..67600f370 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/dispatch.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, + HOST_BRIDGE_METHODS, + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeMethod, + type HostBridgeRequest, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { + dispatchMobileHostBridgeRequest, + resetMobileHostBridgeDispatchForTest, +} from './dispatch'; + +vi.mock('expo-clipboard', () => ({ + getStringAsync: vi.fn(), + setStringAsync: vi.fn(), +})); + +vi.mock('expo-document-picker', () => ({ + getDocumentAsync: vi.fn(), +})); + +vi.mock('expo-file-system', () => ({ + File: class TestFile {}, + Paths: { + cache: 'file:///cache/', + }, +})); + +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(), + NetworkStateType: { + CELLULAR: 'CELLULAR', + ETHERNET: 'ETHERNET', + NONE: 'NONE', + WIFI: 'WIFI', + }, +})); + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { + DEFAULT: 'default', + }, + getPermissionsAsync: vi.fn(), + IosAuthorizationStatus: { + PROVISIONAL: 3, + }, + requestPermissionsAsync: vi.fn(), + scheduleNotificationAsync: vi.fn(), + setNotificationChannelAsync: vi.fn(), + setNotificationHandler: vi.fn(), +})); + +vi.mock('expo-sharing', () => ({ + isAvailableAsync: vi.fn(), + shareAsync: vi.fn(), +})); + +vi.mock('react-native', () => ({ + Appearance: { + getColorScheme: vi.fn(), + }, + Platform: { + OS: 'android', + }, + PushNotificationIOS: { + setApplicationIconBadgeNumber: vi.fn(), + }, +})); + +function request(method: HostBridgeMethod): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: `dispatch-${method}`, + method, + }; +} + +const IOS_UNDECLARED_HOST_BRIDGE_METHODS = HOST_BRIDGE_METHODS.filter( + (method) => !HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method), +); + +describe('mobile HostBridge dispatch', () => { + test.each(IOS_UNDECLARED_HOST_BRIDGE_METHODS)( + '%s 未被移动壳声明时明确返回 unsupported', + async (method) => { + resetMobileHostBridgeDispatchForTest(); + + const response = await dispatchMobileHostBridgeRequest(request(method)); + + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error(`${method} unexpectedly succeeded`); + } + expect(response.error.code).toBe('unsupported_method'); + expect(response.error.message).toContain(method); + }, + ); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a9abdf111..c65e6c9e9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -51,6 +51,7 @@ - 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。 - 2026-06-20 H5 原生能力门控收口:除 `host.getRuntime` 为了支持旧入口 URL 缺少 capability 时回读真实 runtime 可保留特殊判断外,H5 facade 中所有 native_app request 能力都必须通过 `canUseNativeHostCapability(...)` 统一门控,不得在业务能力函数内直接读取 `runtime.hostCapabilities.includes(...)`,避免各能力复制门控规则;根级 `npm run check:native-shells` 会从共享 `HOST_BRIDGE_METHODS` 自动派生需门控的 request capability 清单,新增 method 时必须同步补齐 H5 facade 门控。 - 2026-06-20 移动壳未声明 method 覆盖:Expo 移动壳对未进入 `HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES` 的共享 request method 必须由测试从 `HOST_BRIDGE_METHODS` 自动派生覆盖,并在请求到达时返回明确 `unsupported_method`;平台差异能力如 Android 不声明的 `app.setBadgeCount` 保持独立 `unsupported_capability` 语义,不混入未声明 method 清单,移动壳配置检查必须反查 Android 角标请求失败测试仍存在。 +- 2026-06-20 移动 dispatch 单测边界:`apps/mobile-shell/src/host-bridge/dispatch.test.ts` 直接从 `HOST_BRIDGE_METHODS` 与 `HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES` 派生未声明 method 清单,覆盖 `dispatchMobileHostBridgeRequest(...)` 返回 `unsupported_method`,避免只靠完整 `bridge.test.ts` 间接证明 `auth.requestLogin`、`payment.request` 等等待真实 SDK 的能力不会伪成功。 - 2026-06-20 移动壳能力清单单测边界:`apps/mobile-shell/src/host-bridge/capabilities.test.ts` 直接覆盖 Expo 移动壳 `MOBILE_HOST_CAPABILITIES` / `IOS_MOBILE_HOST_CAPABILITIES` 必须引用共享 `HOST_BRIDGE_EXPO_MOBILE_*` profile,Android 只使用 base profile 且不声明 `app.setBadgeCount`,iOS 只额外声明真实角标能力,并且两端在真实 SDK / 渠道流程落地前不得声明 `auth.requestLogin` 或 `payment.request`。根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免移动壳 capability profile 本地复制或伪声明。 - 2026-06-20 壳文档能力清单反查:`npm run check:native-shells` 会同时反查移动壳 / 桌面壳主状态段落能力清单和后续完整能力清单;主状态段落按集合检查,允许按叙述需要调整顺序,但不得漏写或多写 capability,完整能力清单继续按共享 profile 顺序检查。 - 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。该回读请求的短超时由共享契约 `HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS` 声明,H5 facade 不得本地重声明。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 5eb8d4255..bcc3eaee5 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -259,6 +259,7 @@ const expectedMobileHostBridgeFiles = [ 'capabilities.ts', 'clipboard.test.ts', 'clipboard.ts', + 'dispatch.test.ts', 'dispatch.ts', 'filePayloads.test.ts', 'filePayloads.ts',