补齐移动壳基础能力失败观测

移动剪贴板触觉网络记录原生异常日志

相关测试覆盖稳定错误和日志边界

配置门禁和文档同步基础能力观测约束
This commit is contained in:
2026-06-20 19:34:26 +08:00
parent 75bf78f0a5
commit 48bad28a96
10 changed files with 122 additions and 34 deletions
@@ -2381,6 +2381,10 @@ for (const snippet of [
'writeMobileClipboardText(text)',
'readMobileHostBridgeClipboardText',
'ok(request, await readMobileClipboardText())',
'logMobileClipboardFailure',
'mobile clipboard failed for',
"logMobileClipboardFailure('write.set_string', error)",
"logMobileClipboardFailure('read.get_string', error)",
"message: 'clipboard write unavailable'",
"message: 'clipboard read unavailable'",
]) {
@@ -2393,8 +2397,10 @@ if (!clipboardTestSource.includes("'猫'.repeat(100000)")) {
}
for (const snippet of [
'maps native clipboard write failures to stable host errors',
'mobile clipboard failed for write.set_string',
"message: 'clipboard write unavailable'",
'maps native clipboard read failures to stable host errors',
'mobile clipboard failed for read.get_string',
"message: 'clipboard read unavailable'",
'rejects unavailable clipboard text without reporting a successful empty value',
`Clipboard.getStringAsync).${'mo'}${'ck'}ResolvedValue(undefined as never)`,
@@ -2409,6 +2415,9 @@ for (const snippet of [
for (const snippet of [
'try {',
'return ok(request, await getMobileNetworkStatus())',
'logMobileNetworkFailure',
'mobile network failed for',
"logMobileNetworkFailure('status.query', error)",
'return failure(request, {',
"code: 'host_error'",
"message: 'network status unavailable'",
@@ -2420,6 +2429,7 @@ for (const snippet of [
for (const snippet of [
'converts native network failures to a stable host_error response',
"new Error('network query failed')",
'mobile network failed for status.query',
"message: 'network status unavailable'",
]) {
if (!hostBridgeNetworkTestSource.includes(snippet)) {
@@ -2439,6 +2449,9 @@ if (
!hapticsSource.includes('Haptics.ImpactFeedbackStyle.Medium') ||
!hapticsSource.includes('Haptics.ImpactFeedbackStyle.Light') ||
!hapticsSource.includes('await Haptics.impactAsync(toExpoImpactStyle(style))') ||
!hapticsSource.includes('logMobileHapticsFailure') ||
!hapticsSource.includes('mobile haptics failed for') ||
!hapticsSource.includes("logMobileHapticsFailure('impact.dispatch', error)") ||
!hapticsSource.includes("message: 'haptics impact unavailable'") ||
!hapticsSource.includes('ok(request, true)')
) {
@@ -2449,6 +2462,7 @@ if (
for (const snippet of [
'reports unavailable native feedback with a stable HostBridge error',
'mobile haptics failed for impact.dispatch',
"message: 'haptics impact unavailable'",
]) {
if (!hapticsTestSource.includes(snippet)) {
@@ -766,14 +766,24 @@ describe('handleMobileHostBridgeMessage', () => {
});
test('clipboard.readText 读取失败时返回 host_error', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const nativeError = new Error('clipboard unavailable');
vi.mocked(Clipboard.getStringAsync).mockRejectedValue(
new Error('clipboard unavailable'),
nativeError,
);
const response = await send(request('clipboard.readText'));
try {
const response = await send(request('clipboard.readText'));
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('host_error');
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('host_error');
expect(warnSpy).toHaveBeenCalledWith(
'mobile clipboard failed for read.get_string',
nativeError,
);
} finally {
warnSpy.mockRestore();
}
});
test('haptics.impact 调起 Expo 触觉反馈', async () => {
@@ -64,14 +64,24 @@ describe('mobile clipboard helpers', () => {
});
test('maps native clipboard write failures to stable host errors', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const nativeError = new Error('native clipboard write failed');
vi.mocked(Clipboard.setStringAsync).mockRejectedValueOnce(
new Error('native clipboard write failed'),
nativeError,
);
await expect(writeMobileClipboardText('作品号 PZ-1')).rejects.toMatchObject({
code: 'host_error',
message: 'clipboard write unavailable',
});
try {
await expect(writeMobileClipboardText('作品号 PZ-1')).rejects.toMatchObject({
code: 'host_error',
message: 'clipboard write unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile clipboard failed for write.set_string',
nativeError,
);
} finally {
warnSpy.mockRestore();
}
});
test('wraps HostBridge clipboard write responses', async () => {
@@ -140,14 +150,24 @@ describe('mobile clipboard helpers', () => {
});
test('maps native clipboard read failures to stable host errors', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const nativeError = new Error('native clipboard read failed');
vi.mocked(Clipboard.getStringAsync).mockRejectedValueOnce(
new Error('native clipboard read failed'),
nativeError,
);
await expect(readMobileClipboardText()).rejects.toMatchObject({
code: 'host_error',
message: 'clipboard read unavailable',
});
try {
await expect(readMobileClipboardText()).rejects.toMatchObject({
code: 'host_error',
message: 'clipboard read unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile clipboard failed for read.get_string',
nativeError,
);
} finally {
warnSpy.mockRestore();
}
});
test('wraps HostBridge clipboard read responses', async () => {
@@ -9,6 +9,10 @@ import {
} from '../../../../packages/shared/src/contracts/hostBridge';
import { invalidRequest, ok } from './protocol';
function logMobileClipboardFailure(label: string, error: unknown) {
console.warn(`mobile clipboard failed for ${label}`, error);
}
export async function writeMobileClipboardText(rawText: unknown) {
const clipboardText = normalizeHostBridgeClipboardText(rawText);
if (!clipboardText) {
@@ -17,7 +21,8 @@ export async function writeMobileClipboardText(rawText: unknown) {
try {
await Clipboard.setStringAsync(clipboardText.text);
} catch {
} catch (error) {
logMobileClipboardFailure('write.set_string', error);
throw {
code: 'host_error',
message: 'clipboard write unavailable',
@@ -47,7 +52,8 @@ export async function readMobileClipboardText(): Promise<ClipboardReadTextResult
let rawText: unknown;
try {
rawText = await Clipboard.getStringAsync();
} catch {
} catch (error) {
logMobileClipboardFailure('read.get_string', error);
throw {
code: 'host_error',
message: 'clipboard read unavailable',
@@ -67,14 +67,24 @@ describe('mobile haptics helpers', () => {
});
test('reports unavailable native feedback with a stable HostBridge error', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const nativeError = new Error('native haptics failed');
vi.mocked(Haptics.impactAsync).mockRejectedValueOnce(
new Error('native haptics failed'),
nativeError,
);
await expect(runMobileHapticsImpact('light')).rejects.toMatchObject({
code: 'host_error',
message: 'haptics impact unavailable',
});
try {
await expect(runMobileHapticsImpact('light')).rejects.toMatchObject({
code: 'host_error',
message: 'haptics impact unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile haptics failed for impact.dispatch',
nativeError,
);
} finally {
warnSpy.mockRestore();
}
});
test('wraps HostBridge success response after real impact dispatch', async () => {
+6 -1
View File
@@ -17,6 +17,10 @@ function toExpoImpactStyle(style: HostBridgeHapticsImpactStyle) {
: Haptics.ImpactFeedbackStyle.Light;
}
function logMobileHapticsFailure(label: string, error: unknown) {
console.warn(`mobile haptics failed for ${label}`, error);
}
export async function runMobileHapticsImpact(rawStyle: unknown) {
const style = normalizeHostBridgeHapticsImpactStyle(rawStyle);
if (!style) {
@@ -25,7 +29,8 @@ export async function runMobileHapticsImpact(rawStyle: unknown) {
try {
await Haptics.impactAsync(toExpoImpactStyle(style));
} catch {
} catch (error) {
logMobileHapticsFailure('impact.dispatch', error);
throw {
code: 'host_error',
message: 'haptics impact unavailable',
@@ -72,19 +72,29 @@ describe('mobile HostBridge network helper', () => {
});
test('converts native network failures to a stable host_error response', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const nativeError = new Error('network query failed');
vi.mocked(Network.getNetworkStateAsync).mockRejectedValue(
new Error('network query failed'),
nativeError,
);
await expect(getMobileHostBridgeNetworkStatus(request())).resolves.toEqual({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'network-request',
ok: false,
error: {
code: 'host_error',
message: 'network status unavailable',
},
});
try {
await expect(getMobileHostBridgeNetworkStatus(request())).resolves.toEqual({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'network-request',
ok: false,
error: {
code: 'host_error',
message: 'network status unavailable',
},
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile network failed for status.query',
nativeError,
);
} finally {
warnSpy.mockRestore();
}
});
});
+6 -1
View File
@@ -2,12 +2,17 @@ import { getMobileNetworkStatus } from '../shell/network';
import { type HostBridgeRequest } from '../../../../packages/shared/src/contracts/hostBridge';
import { failure, ok } from './protocol';
function logMobileNetworkFailure(label: string, error: unknown) {
console.warn(`mobile network failed for ${label}`, error);
}
export async function getMobileHostBridgeNetworkStatus(
request: HostBridgeRequest,
) {
try {
return ok(request, await getMobileNetworkStatus());
} catch {
} catch (error) {
logMobileNetworkFailure('status.query', error);
return failure(request, {
code: 'host_error',
message: 'network status unavailable',
@@ -3112,6 +3112,12 @@
- 决策:`apps/mobile-shell/src/host-bridge/notifications.ts` 必须在权限读取 / 请求和通知投递失败时记录 `mobile notification failed for ...` 日志;`apps/mobile-shell/src/host-bridge/badge.ts` 必须在角标权限读取 / 请求、`setBadgeCountAsync` reject 和返回 `false` 时记录 `mobile app badge failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义;该约束不新增远程推送 token、后台通知、定时提醒或 Android 角标 capability。
- 验证方式:`npm run mobile-shell:test -- src/host-bridge/notifications.test.ts src/host-bridge/badge.test.ts``npm run mobile-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 移动壳剪贴板触觉网络异常必须可观测
- 背景:Expo 移动壳已声明剪贴板读写、触觉反馈和网络状态查询;这些能力会触发 Expo Clipboard、Haptics 和 Network 原生模块。如果原生 API 异常只被折叠成稳定 HostBridge 错误,H5 语义安全,但开发侧无法区分系统剪贴板不可用、触觉模块异常或网络模块查询失败。
- 决策:`apps/mobile-shell/src/host-bridge/clipboard.ts` 必须在剪贴板写入 / 读取失败时记录 `mobile clipboard failed for ...` 日志;`apps/mobile-shell/src/host-bridge/haptics.ts` 必须在触觉派发失败时记录 `mobile haptics failed for ...` 日志;`apps/mobile-shell/src/host-bridge/network.ts` 必须在 HostBridge 网络状态查询失败时记录 `mobile network failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义;该约束不新增后台网络探测、任意系统能力或 H5 业务兜底路径。
- 验证方式:`npm run mobile-shell:test -- src/host-bridge/clipboard.test.ts src/host-bridge/haptics.test.ts src/host-bridge/network.test.ts``npm run mobile-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 移动壳门禁脚本必须自登记自扫描
- 背景:Expo 移动壳单端检查已把 `apps/mobile-shell/scripts/` 纳入生产源码扫描入口,但 `check-config.mjs` 自身仍被排除在脚本清单和替身词扫描之外;这会让移动壳与桌面壳门禁结构不一致,也可能让后续门禁反查内容绕过生产替身词规则。
@@ -340,6 +340,8 @@ GameBridge 禁止:
2026-06-18 追加:H5 的平台外部生成队列概览开始消费 `network.status` / `network.statusChanged`。宿主未声明网络能力时继续按原逻辑轮询;宿主明确离线或不可达时暂停概览请求,恢复在线后重新刷新,不改变生成任务、作品架或后端回读事实。
2026-06-20 追加:移动壳剪贴板、触觉和网络查询的原生异常必须可观测。Expo Clipboard 写入 / 读取、Expo Haptics 触觉派发、Expo Network 状态查询失败时,移动壳必须分别记录 `mobile clipboard failed for ...``mobile haptics failed for ...``mobile network failed for ...` 日志;HostBridge 回包仍只暴露稳定 `clipboard write unavailable``clipboard read unavailable``haptics impact unavailable``network status unavailable` 语义。该约束不新增后台网络探测、任意系统能力或 H5 业务兜底路径。
2026-06-18 追加:移动壳 `haptics.impact` 只接受 `light``medium``heavy` 三档 impact style,缺省为 `light`;未知强度返回 `invalid_request`,不会静默降级成真实设备触觉反馈。桌面壳不声明该能力,H5 继续按 HostBridge fallback 处理。
2026-06-19 追加:移动壳 `haptics.impact` 的 HostBridge payload 解析、共享 style 归一、Expo style 映射和 `Haptics.impactAsync(...)` 真实调用统一收口在 `apps/mobile-shell/src/host-bridge/haptics.ts``dispatch.ts` 只按 method 委托 `runMobileHostBridgeHapticsImpact(request.payload)`