补齐移动扫码覆盖层单测

移动壳 QrScannerOverlay 增加相机权限和扫码结果组件单测

移动壳配置门禁和根级清单登记扫码覆盖层测试

宿主壳文档和共享决策同步扫码覆盖层边界
This commit is contained in:
2026-06-20 09:10:30 +08:00
parent fe1caff3db
commit 11b7694045
6 changed files with 169 additions and 4 deletions
@@ -12,6 +12,14 @@ const shellAppTestPath = new URL('../src/shell/ShellApp.test.tsx', import.meta.u
const shellAppTestSource = fs.readFileSync(shellAppTestPath, 'utf8');
const qrScannerOverlayPath = new URL('../src/shell/QrScannerOverlay.tsx', import.meta.url);
const qrScannerOverlaySource = fs.readFileSync(qrScannerOverlayPath, 'utf8');
const qrScannerOverlayTestPath = new URL(
'../src/shell/QrScannerOverlay.test.tsx',
import.meta.url,
);
const qrScannerOverlayTestSource = fs.readFileSync(
qrScannerOverlayTestPath,
'utf8',
);
const appearancePath = new URL('../src/host-bridge/appearance.ts', import.meta.url);
const appearanceSource = fs.readFileSync(appearancePath, 'utf8');
const appearanceTestPath = new URL(
@@ -2102,6 +2110,21 @@ for (const snippet of [
}
}
for (const snippet of [
'describe(\'QrScannerOverlay\'',
'scanQrCode()',
'requestCameraPermissionsAsync',
"message: 'camera permission denied'",
"message: 'qr scan cancelled'",
"barcodeTypes: ['qr']",
"type: 'qr'",
"value: 'https://app.genarrative.world/works/detail?work=PZ-1'",
]) {
if (!qrScannerOverlayTestSource.includes(snippet)) {
throw new Error(`mobile shell QR scanner overlay test missing ${snippet}`);
}
}
if (!shellAppSource.includes('<QrScannerOverlay />')) {
throw new Error('mobile shell ShellApp must render the QR scanner overlay');
}
@@ -0,0 +1,140 @@
/* @vitest-environment jsdom */
import { render, waitFor } from '@testing-library/react';
import * as CameraModule from 'expo-camera';
import React from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
resetQrScannerForTest,
scanQrCode,
} from '../host-bridge/scanner';
import { QrScannerOverlay } from './QrScannerOverlay';
const overlayHarness = vi.hoisted(() => ({
cameraViewProps: {
current: null as Record<string, unknown> | null,
},
reset() {
this.cameraViewProps.current = null;
},
}));
vi.mock('expo-camera', () => ({
Camera: {
requestCameraPermissionsAsync: vi.fn(),
},
CameraView: (props: Record<string, unknown>) => {
overlayHarness.cameraViewProps.current = props;
return React.createElement('mobile-camera-view');
},
}));
vi.mock('react-native', () => ({
Pressable: ({
children,
onPress,
accessibilityLabel: _accessibilityLabel,
accessibilityRole: _accessibilityRole,
...props
}: {
accessibilityLabel?: string;
accessibilityRole?: string;
children?: React.ReactNode;
onPress?: () => void;
}) => React.createElement('button', { ...props, onClick: onPress }, children),
StyleSheet: {
create: <T,>(styles: T) => styles,
},
Text: ({ children, ...props }: { children?: React.ReactNode }) =>
React.createElement('span', props, children),
View: ({
children,
pointerEvents: _pointerEvents,
...props
}: {
children?: React.ReactNode;
pointerEvents?: string;
}) => React.createElement('div', props, children),
}));
const cameraPermissionMock = vi.mocked(
CameraModule.Camera.requestCameraPermissionsAsync,
);
describe('QrScannerOverlay', () => {
afterEach(() => {
resetQrScannerForTest();
overlayHarness.reset();
vi.clearAllMocks();
});
test('requests camera permission and completes a QR scan result', async () => {
cameraPermissionMock.mockResolvedValue({
granted: true,
} as Awaited<ReturnType<typeof CameraModule.Camera.requestCameraPermissionsAsync>>);
render(<QrScannerOverlay />);
const scanPromise = scanQrCode();
await waitFor(() => {
expect(overlayHarness.cameraViewProps.current).toBeTruthy();
});
expect(cameraPermissionMock).toHaveBeenCalledTimes(1);
expect(overlayHarness.cameraViewProps.current).toMatchObject({
facing: 'back',
barcodeScannerSettings: {
barcodeTypes: ['qr'],
},
});
const cameraProps = overlayHarness.cameraViewProps.current as {
onBarcodeScanned?: (result: { type: string; data: string }) => void;
};
cameraProps.onBarcodeScanned?.({
type: 'qr',
data: ' https://app.genarrative.world/works/detail?work=PZ-1 ',
});
await expect(scanPromise).resolves.toEqual({
format: 'qr_code',
value: 'https://app.genarrative.world/works/detail?work=PZ-1',
});
});
test('rejects the active scan when camera permission is denied', async () => {
cameraPermissionMock.mockResolvedValue({
granted: false,
} as Awaited<ReturnType<typeof CameraModule.Camera.requestCameraPermissionsAsync>>);
render(<QrScannerOverlay />);
const scanPromise = scanQrCode();
await expect(scanPromise).rejects.toMatchObject({
code: 'host_error',
message: 'camera permission denied',
});
expect(overlayHarness.cameraViewProps.current).toBeNull();
});
test('close button cancels the pending scan', async () => {
cameraPermissionMock.mockResolvedValue({
granted: true,
} as Awaited<ReturnType<typeof CameraModule.Camera.requestCameraPermissionsAsync>>);
const screen = render(<QrScannerOverlay />);
const scanPromise = scanQrCode();
await waitFor(() => {
expect(screen.getByText('关闭')).toBeTruthy();
});
screen.getByText('关闭').click();
await expect(scanPromise).rejects.toMatchObject({
code: 'cancelled',
message: 'qr scan cancelled',
});
});
});
@@ -134,6 +134,7 @@
- 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 桌面 HostBridge command facade 单测边界:Tauri 唯一 `host_bridge_request` command 必须先通过 `prepare_host_bridge_request(...)` 做 envelope、method 和 request id 校验,再进入 `HostBridgeReplayState` reserve / wait / execute`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs` 的单测必须覆盖非法 envelope 在 replay 前返回 `invalid_request` 且不会占用对应 request id 的 replay slot,桌面配置检查会反查该测试存在。
- 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-20 移动扫码 overlay 单测边界:`apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx` 直接覆盖移动扫码 overlay 的相机权限请求、二维码扫码成功、权限拒绝失败和关闭取消;单端配置检查会反查该组件测试存在,根级 `npm run check:native-shells` 会把该测试文件列入移动 shell 层结构清单,避免扫码 UI 容器只靠 `ShellApp.test.tsx` 的完整 HostBridge 流程间接覆盖。
- 2026-06-18 HostBridge method 白名单跨壳门禁:`packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳 HostBridge 分发不得处理共享契约外 methodTauri 壳 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
+1
View File
@@ -287,6 +287,7 @@ const expectedMobileSrcRootEntries = [
'file:env.d.ts',
];
const expectedMobileShellFiles = [
'QrScannerOverlay.test.tsx',
'QrScannerOverlay.tsx',
'ShellApp.test.tsx',
'ShellApp.tsx',