补齐移动壳扫码单测

移动壳 scanner helper 增加独立单测覆盖

原生壳结构门禁登记移动扫码测试

宿主壳文档和共享决策同步扫码测试边界
This commit is contained in:
2026-06-20 08:31:49 +08:00
parent fb02e89fa3
commit cb926835b3
6 changed files with 179 additions and 5 deletions
@@ -78,6 +78,11 @@ const protocolPath = new URL('../src/host-bridge/protocol.ts', import.meta.url);
const protocolSource = fs.readFileSync(protocolPath, 'utf8');
const scannerPath = new URL('../src/host-bridge/scanner.ts', import.meta.url);
const scannerSource = fs.readFileSync(scannerPath, 'utf8');
const scannerTestPath = new URL(
'../src/host-bridge/scanner.test.ts',
import.meta.url,
);
const scannerTestSource = fs.readFileSync(scannerTestPath, 'utf8');
const hostBridgeRuntimePath = new URL('../src/host-bridge/runtime.ts', import.meta.url);
const hostBridgeRuntimeSource = fs.readFileSync(hostBridgeRuntimePath, 'utf8');
const hostBridgeRuntimeTestPath = new URL(
@@ -2092,6 +2097,24 @@ for (const scannerSnippet of [
throw new Error(`mobile shell QR scanner module missing ${scannerSnippet}`);
}
}
for (const scannerTestSnippet of [
'subscribeQrScannerState',
'scanQrCode',
'scanMobileHostBridgeQrCode',
'completeQrCodeScan',
'cancelQrCodeScan',
'failQrCodeScan',
'qr scanner already active',
'camera permission denied',
'qr scan cancelled',
'PZ-00000001',
]) {
if (!scannerTestSource.includes(scannerTestSnippet)) {
throw new Error(
`mobile shell QR scanner helper test missing ${scannerTestSnippet}`,
);
}
}
const capabilityQuerySnippet = "capabilities: MOBILE_HOST_CAPABILITIES";
if (shellAppSource.includes(capabilityQuerySnippet)) {
@@ -0,0 +1,149 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeRequest,
} from '../../../../packages/shared/src/contracts/hostBridge';
import {
cancelQrCodeScan,
completeQrCodeScan,
failQrCodeScan,
resetQrScannerForTest,
scanMobileHostBridgeQrCode,
scanQrCode,
subscribeQrScannerState,
} from './scanner';
function request(): HostBridgeRequest {
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'scanner-request',
method: 'scanner.scanQrCode',
};
}
afterEach(() => {
resetQrScannerForTest();
});
describe('mobile QR scanner helpers', () => {
test('notifies subscribers when a scan starts and clears', async () => {
const listener = vi.fn();
const unsubscribe = subscribeQrScannerState(listener);
const pendingScan = scanQrCode();
expect(listener).toHaveBeenNthCalledWith(1, {
active: false,
requestKey: 0,
});
expect(listener).toHaveBeenNthCalledWith(2, {
active: true,
requestKey: 1,
});
expect(completeQrCodeScan(' https://app.genarrative.world/w/PZ-1 '))
.toBe(true);
await expect(pendingScan).resolves.toEqual({
value: 'https://app.genarrative.world/w/PZ-1',
format: 'qr_code',
});
expect(listener).toHaveBeenNthCalledWith(3, {
active: false,
requestKey: 0,
});
unsubscribe();
const secondScan = scanQrCode();
expect(listener).toHaveBeenCalledTimes(3);
expect(completeQrCodeScan('PZ-2')).toBe(true);
await expect(secondScan).resolves.toEqual({
value: 'PZ-2',
format: 'qr_code',
});
});
test('rejects a second scan while the scanner is already active', async () => {
const pendingScan = scanQrCode();
await expect(scanQrCode()).rejects.toEqual({
code: 'host_error',
message: 'qr scanner already active',
});
expect(completeQrCodeScan('PZ-1')).toBe(true);
await expect(pendingScan).resolves.toEqual({
value: 'PZ-1',
format: 'qr_code',
});
});
test('keeps the pending scan active when completion payload is invalid', async () => {
const listener = vi.fn();
subscribeQrScannerState(listener);
const pendingScan = scanQrCode();
expect(completeQrCodeScan('')).toBe(false);
expect(listener).toHaveBeenLastCalledWith({
active: true,
requestKey: 1,
});
expect(completeQrCodeScan('valid-code')).toBe(true);
await expect(pendingScan).resolves.toEqual({
value: 'valid-code',
format: 'qr_code',
});
});
test('cancels the pending scan with the shared cancelled error', async () => {
const listener = vi.fn();
subscribeQrScannerState(listener);
const pendingScan = scanQrCode();
cancelQrCodeScan();
await expect(pendingScan).rejects.toEqual({
code: 'cancelled',
message: 'qr scan cancelled',
});
expect(listener).toHaveBeenLastCalledWith({
active: false,
requestKey: 0,
});
});
test('fails the pending scan with a host_error message', async () => {
const pendingScan = scanQrCode();
failQrCodeScan('camera permission denied');
await expect(pendingScan).rejects.toEqual({
code: 'host_error',
message: 'camera permission denied',
});
});
test('ignores completion, cancellation and failure without a pending scan', () => {
expect(completeQrCodeScan('PZ-1')).toBe(false);
expect(() => cancelQrCodeScan()).not.toThrow();
expect(() => failQrCodeScan('camera permission denied')).not.toThrow();
});
test('wraps the scanner result in the HostBridge response shape', async () => {
const pendingResponse = scanMobileHostBridgeQrCode(request());
expect(completeQrCodeScan('PZ-00000001')).toBe(true);
await expect(pendingResponse).resolves.toEqual({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'scanner-request',
ok: true,
result: {
value: 'PZ-00000001',
format: 'qr_code',
},
});
});
});
@@ -80,6 +80,7 @@
- 2026-06-18 移动图片导入:Expo 壳开始声明并实现 `file.importImage`,通过 `expo-image-picker` 请求相册权限并打开系统相册选择器,只允许 `image/png``image/jpeg``image/webp` 且单次不超过 10 MiB;picker 调用必须固定为单选、禁用编辑、禁用 EXIF、请求 base64 且 `mediaTypes` 只允许 `images`,不得扩大到视频或任意媒体。成功只回传清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI,用户取消返回 `cancelled` 并由 H5 facade 归为 `false`。Expo 图片导入的相册权限、ImagePicker 调用、MIME / 体积 / 图片字节校验和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts``dispatch.ts` 只委托文件模块。
- 2026-06-18 移动图片拍摄导入:Expo 壳新增 `file.captureImage` HostBridge capability,通过 `expo-image-picker` 请求相机权限并打开系统相机拍摄图片,沿用 `file.importImage` 的 MIME、体积、base64 和文件名清洗规则;相机 picker 必须禁用编辑、禁用 EXIF、请求 base64 且 `mediaTypes` 只允许 `images`,成功回传 `action=captured`,不暴露设备本地 URI;该拍摄能力不使用麦克风权限,移动壳麦克风权限只服务同源 H5 实时玩法。Tauri 壳不声明该能力,不伪造桌面拍摄。Expo 图片拍摄的相机权限、ImagePicker 调用、MIME / 体积 / 图片字节校验和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts``dispatch.ts` 只委托文件模块。
- 2026-06-19 移动二维码扫描:Expo 壳新增 `scanner.scanQrCode` HostBridge capability,通过 `expo-camera` 请求相机权限并打开真实扫码 overlay,成功只返回共享契约清洗后的二维码文本和 `qr_code` 格式,空值、控制字符和超长文本按 `normalizeHostBridgeQrCodeValue` 处理;HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/scanner.ts``dispatch.ts` 只委托扫码请求。用户关闭扫码返回 `cancelled`,H5 个人中心扫码入口不再连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中返回 `unsupported_method`,不声明 capability、不伪造桌面扫码;宿主缺能力或非法结果时 H5 继续走原浏览器扫码 fallback。
- 2026-06-20 移动壳扫码单测边界:`apps/mobile-shell/src/host-bridge/scanner.test.ts` 直接覆盖扫码 helper 的订阅状态初始值、进行中 requestKey、并发扫码拒绝、非法完成不清 pending、成功完成的二维码值清洗、用户取消 `cancelled`、宿主失败 `host_error`、无 pending 时的空操作和 HostBridge 成功响应包装;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免扫码状态机只靠完整 bridge 流程或 overlay 测试间接覆盖。
- 2026-06-18 H5 图片上传接入宿主导入:`CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时,主图上传和描述参考图上传可分别调用 `importHostImageFile()` / `captureHostImageFile()`,并把宿主返回的 base64 图片转换为现有 `File` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `<input type="file">` 路径,不新增玩法侧上传分叉。
- 2026-06-18 移动壳安全区:Expo 壳根布局使用 `react-native-safe-area-context``SafeAreaProvider` 与四边 `SafeAreaView` 保护 WebView,避免 H5 主站内容贴进 iOS 刘海、底部 Home Indicator、Android 状态栏或横屏边缘;该能力属于宿主壳布局保护,不新增 H5 占位 UI,不改变玩法 runtime 或 HostBridge capability。
- 2026-06-18 移动壳方向策略:Expo 壳 `orientation` 固定为 `default`,不锁竖屏或横屏;后续固定玩法和 AI H5 sandbox 的方向需求由设备方向、H5 响应式布局和玩法自身画布适配承接,壳层只负责安全区、WebView 容器和 HostBridge。移动壳配置检查和 Expo public config smoke 会拒绝重新锁定 portrait / landscape。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -273,6 +273,7 @@ const expectedMobileHostBridgeFiles = [
'protocol.ts',
'runtime.test.ts',
'runtime.ts',
'scanner.test.ts',
'scanner.ts',
'share.test.ts',
'share.ts',