补齐移动扫码覆盖层单测
移动壳 QrScannerOverlay 增加相机权限和扫码结果组件单测 移动壳配置门禁和根级清单登记扫码覆盖层测试 宿主壳文档和共享决策同步扫码覆盖层边界
This commit is contained in:
@@ -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 分发不得处理共享契约外 method,Tauri 壳 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
@@ -287,6 +287,7 @@ const expectedMobileSrcRootEntries = [
|
||||
'file:env.d.ts',
|
||||
];
|
||||
const expectedMobileShellFiles = [
|
||||
'QrScannerOverlay.test.tsx',
|
||||
'QrScannerOverlay.tsx',
|
||||
'ShellApp.test.tsx',
|
||||
'ShellApp.tsx',
|
||||
|
||||
Reference in New Issue
Block a user