补齐移动壳扫码单测
移动壳 scanner helper 增加独立单测覆盖 原生壳结构门禁登记移动扫码测试 宿主壳文档和共享决策同步扫码测试边界
This commit is contained in:
@@ -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
@@ -273,6 +273,7 @@ const expectedMobileHostBridgeFiles = [
|
||||
'protocol.ts',
|
||||
'runtime.test.ts',
|
||||
'runtime.ts',
|
||||
'scanner.test.ts',
|
||||
'scanner.ts',
|
||||
'share.test.ts',
|
||||
'share.ts',
|
||||
|
||||
Reference in New Issue
Block a user