补齐移动壳协议单测
移动壳 protocol helper 增加独立单测覆盖 原生壳结构门禁登记移动协议测试 宿主壳文档和共享决策同步协议测试边界
This commit is contained in:
@@ -81,6 +81,11 @@ const notificationsPath = new URL('../src/host-bridge/notifications.ts', import.
|
||||
const notificationsSource = fs.readFileSync(notificationsPath, 'utf8');
|
||||
const protocolPath = new URL('../src/host-bridge/protocol.ts', import.meta.url);
|
||||
const protocolSource = fs.readFileSync(protocolPath, 'utf8');
|
||||
const protocolTestPath = new URL(
|
||||
'../src/host-bridge/protocol.test.ts',
|
||||
import.meta.url,
|
||||
);
|
||||
const protocolTestSource = fs.readFileSync(protocolTestPath, 'utf8');
|
||||
const scannerPath = new URL('../src/host-bridge/scanner.ts', import.meta.url);
|
||||
const scannerSource = fs.readFileSync(scannerPath, 'utf8');
|
||||
const scannerTestPath = new URL(
|
||||
@@ -866,6 +871,25 @@ for (const expectedErrorCode of [
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const snippet of [
|
||||
'parseRequest',
|
||||
'isHostBridgeRequest',
|
||||
'normalizeMobileHostBridgeError',
|
||||
'unsupported',
|
||||
'invalidRequest',
|
||||
'ok(request(), { shell: ',
|
||||
'failure(request(), invalidRequest(',
|
||||
"request({ id: 'bad\\u0000id' })",
|
||||
"request({ id: 'x'.repeat(129) })",
|
||||
"method: 'unknown.method'",
|
||||
"code: 'private_error'",
|
||||
"nativeStack: 'hidden'",
|
||||
"message: 'mobile host bridge request failed'",
|
||||
]) {
|
||||
if (!protocolTestSource.includes(snippet)) {
|
||||
throw new Error(`mobile shell protocol helper test missing ${snippet}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!protocolSource.includes('HOST_BRIDGE_ERROR_CODES.has(') ||
|
||||
!protocolSource.includes('mobile host bridge request failed')
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
HOST_BRIDGE_PROTOCOL,
|
||||
HOST_BRIDGE_VERSION,
|
||||
type HostBridgeRequest,
|
||||
} from '../../../../packages/shared/src/contracts/hostBridge';
|
||||
import {
|
||||
failure,
|
||||
invalidRequest,
|
||||
isHostBridgeRequest,
|
||||
normalizeMobileHostBridgeError,
|
||||
ok,
|
||||
parseRequest,
|
||||
unsupported,
|
||||
} from './protocol';
|
||||
|
||||
function request(overrides: Partial<HostBridgeRequest> = {}): HostBridgeRequest {
|
||||
return {
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: 'request-1',
|
||||
method: 'host.getRuntime',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mobile HostBridge protocol helpers', () => {
|
||||
test('parses JSON messages and drops malformed payloads before validation', () => {
|
||||
expect(parseRequest(JSON.stringify(request()))).toEqual(request());
|
||||
expect(parseRequest('{')).toBeNull();
|
||||
});
|
||||
|
||||
test('accepts only valid HostBridge envelopes and request ids', () => {
|
||||
expect(isHostBridgeRequest(request())).toBe(true);
|
||||
expect(isHostBridgeRequest(request({ id: ' id-with-space ' }))).toBe(true);
|
||||
|
||||
for (const candidate of [
|
||||
null,
|
||||
{},
|
||||
{ ...request(), bridge: 'wrong-bridge' },
|
||||
{ ...request(), version: HOST_BRIDGE_VERSION + 1 },
|
||||
request({ id: '' }),
|
||||
request({ id: 'bad\u0000id' }),
|
||||
request({ id: 'x'.repeat(129) }),
|
||||
request({ method: 'unknown.method' as HostBridgeRequest['method'] }),
|
||||
]) {
|
||||
expect(isHostBridgeRequest(candidate)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('wraps successful and failed responses in the shared response envelope', () => {
|
||||
expect(ok(request(), { shell: 'expo_mobile' })).toEqual({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { shell: 'expo_mobile' },
|
||||
});
|
||||
expect(failure(request(), invalidRequest('bad request'))).toEqual({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: 'request-1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid_request',
|
||||
message: 'bad request',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps unsupported and invalid request errors explicit', () => {
|
||||
expect(unsupported('payment.request')).toEqual({
|
||||
code: 'unsupported_method',
|
||||
message: 'payment.request unsupported in mobile shell',
|
||||
});
|
||||
expect(invalidRequest('url is required')).toEqual({
|
||||
code: 'invalid_request',
|
||||
message: 'url is required',
|
||||
});
|
||||
});
|
||||
|
||||
test('passes through only allowed HostBridge errors from native helpers', () => {
|
||||
expect(
|
||||
normalizeMobileHostBridgeError({
|
||||
code: 'cancelled',
|
||||
message: 'qr scan cancelled',
|
||||
nativeStack: 'hidden',
|
||||
}),
|
||||
).toEqual({
|
||||
code: 'cancelled',
|
||||
message: 'qr scan cancelled',
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizes unsafe native errors to host_error without leaking fields', () => {
|
||||
expect(
|
||||
normalizeMobileHostBridgeError({
|
||||
code: 'private_error',
|
||||
message: 'do not expose',
|
||||
detail: 'native secret',
|
||||
}),
|
||||
).toEqual({
|
||||
code: 'host_error',
|
||||
message: 'mobile host bridge request failed',
|
||||
});
|
||||
expect(normalizeMobileHostBridgeError(new Error('disk failed'))).toEqual({
|
||||
code: 'host_error',
|
||||
message: 'disk failed',
|
||||
});
|
||||
expect(normalizeMobileHostBridgeError('boom')).toEqual({
|
||||
code: 'host_error',
|
||||
message: 'mobile host bridge request failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -130,6 +130,7 @@
|
||||
- 2026-06-18 桌面壳 capability 最小化:Tauri 主窗口 capability 只授予 `allow-host-bridge-request`,不得授予 `core:default`、`core:*:default`、任意 core 子权限或 dialog / fs / notification / opener / clipboard / deep-link / window-state 等插件权限。窗口、菜单、托盘、剪贴板、文件、通知和外链能力只能由 Rust 壳内部调用,再经 `host_bridge_request` 分发。
|
||||
- 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。
|
||||
- 2026-06-18 HostBridge request envelope 校验:共享契约提供 `isHostBridgeMethod` 与 `normalizeHostBridgeRequestId`,Expo 壳直接复用,Tauri 壳镜像同一白名单和 id 规则;空 id、控制字符 id、超长 id 和未知 method 都必须在 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。Expo 壳捕获原生异常时只透传共享 `HostBridgeError.code` 白名单内且 `message` 为字符串的协议错误,Tauri 壳的 `failed(...)` 出口也必须先校验同一错误码白名单;未知原生错误对象或非法错误码统一归一为 `host_error` 和固定失败文案,不把 native 私有字段、任意错误码或非字符串 message 回传给 H5。
|
||||
- 2026-06-20 移动壳协议 helper 单测边界:`apps/mobile-shell/src/host-bridge/protocol.test.ts` 直接覆盖 Expo 移动壳 HostBridge JSON 解析、envelope 和 request id 校验、未知 method 拒绝、ok / failure 响应包装、unsupported / invalid_request 错误构造,以及 native helper 错误归一时只透传共享错误码与字符串 message,不泄露非法错误码、nativeStack 或其它私有字段;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免协议边界只靠完整 bridge 流程间接覆盖。
|
||||
- 2026-06-18 HostBridge method 白名单跨壳门禁:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳 HostBridge 分发不得处理共享契约外 method,Tauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。新增宿主 method 必须先更新共享契约,再落两端壳实现或明确 unsupported。
|
||||
- 2026-06-18 HostBridge capability / handler 关系门禁:两端壳声明 request method capability 时必须有对应 HostBridge handler;壳 handler 处理的 method 必须已被该壳声明,登录 / 支付等 SDK-backed method 只能保留明确 `unsupported_method` 路径。事件类 capability 不要求 request handler。
|
||||
- 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*`、`ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,以及 release / dev CSP 加入 `unsafe-eval`、`tauri:` 或 `file:`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -271,6 +271,7 @@ const expectedMobileHostBridgeFiles = [
|
||||
'network.ts',
|
||||
'notifications.test.ts',
|
||||
'notifications.ts',
|
||||
'protocol.test.ts',
|
||||
'protocol.ts',
|
||||
'runtime.test.ts',
|
||||
'runtime.ts',
|
||||
|
||||
Reference in New Issue
Block a user