补齐移动壳基础能力失败观测
移动剪贴板触觉网络记录原生异常日志 相关测试覆盖稳定错误和日志边界 配置门禁和文档同步基础能力观测约束
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)`。
|
||||
|
||||
Reference in New Issue
Block a user