补齐移动壳扫码真实链路测试

新增 ShellApp 级扫码 HostBridge 测试覆盖 WebView 请求到相机扫码回注

移动壳 Vitest 纳入 TSX 组件测试

单端与根级原生壳门禁反查扫码链路测试存在
This commit is contained in:
2026-06-20 05:17:13 +08:00
parent 3c853da830
commit 6c0d929e91
4 changed files with 302 additions and 1 deletions
@@ -8,6 +8,8 @@ const appPath = new URL('../App.tsx', import.meta.url);
const appSource = fs.readFileSync(appPath, 'utf8');
const shellAppPath = new URL('../src/shell/ShellApp.tsx', import.meta.url);
const shellAppSource = fs.readFileSync(shellAppPath, 'utf8');
const shellAppTestPath = new URL('../src/shell/ShellApp.test.tsx', import.meta.url);
const shellAppTestSource = fs.readFileSync(shellAppTestPath, 'utf8');
const qrScannerOverlayPath = new URL('../src/shell/QrScannerOverlay.tsx', import.meta.url);
const qrScannerOverlaySource = fs.readFileSync(qrScannerOverlayPath, 'utf8');
const appearancePath = new URL('../src/host-bridge/appearance.ts', import.meta.url);
@@ -1858,6 +1860,18 @@ if (!shellAppSource.includes('<QrScannerOverlay />')) {
throw new Error('mobile shell ShellApp must render the QR scanner overlay');
}
for (const snippet of [
'scanner.scanQrCode',
'requestCameraPermissionsAsync',
'onBarcodeScanned',
'injectJavaScript',
'scan-request-1',
]) {
if (!shellAppTestSource.includes(snippet)) {
throw new Error(`mobile shell ShellApp QR scanner test missing ${snippet}`);
}
}
if (
!dispatchSource.includes('scanMobileHostBridgeQrCode(request)') ||
dispatchSource.includes('ok(request, await scanQrCode())')
@@ -0,0 +1,286 @@
/* @vitest-environment jsdom */
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeRequest,
} from '../../../../packages/shared/src/contracts/hostBridge';
const shellHarness = vi.hoisted(() => {
const webViewProps = { current: null as Record<string, unknown> | null };
const cameraViewProps = { current: null as Record<string, unknown> | null };
const injectedScripts = [] as string[];
return {
cameraViewProps,
injectedScripts,
reset() {
webViewProps.current = null;
cameraViewProps.current = null;
injectedScripts.length = 0;
},
webViewProps,
};
});
vi.mock('expo-camera', () => ({
Camera: {
requestCameraPermissionsAsync: vi.fn(async () => ({
granted: true,
})),
},
CameraView: (props: Record<string, unknown>) => {
shellHarness.cameraViewProps.current = props;
return React.createElement('mobile-camera-view');
},
}));
vi.mock('expo-clipboard', () => ({
getStringAsync: vi.fn(),
setStringAsync: vi.fn(),
}));
vi.mock('expo-document-picker', () => ({
getDocumentAsync: vi.fn(),
}));
vi.mock('expo-file-system', () => ({
File: class MockFile {
uri = 'file:///cache/test';
size = 0;
base64() {
return Promise.resolve('');
}
text() {
return Promise.resolve('');
}
write() {
return undefined;
}
},
Paths: {
cache: 'file:///cache/',
},
}));
vi.mock('expo-haptics', () => ({
ImpactFeedbackStyle: {
Heavy: 'heavy',
Light: 'light',
Medium: 'medium',
},
impactAsync: vi.fn(),
}));
vi.mock('expo-image-picker', () => ({
PermissionStatus: {
DENIED: 'denied',
GRANTED: 'granted',
},
launchCameraAsync: vi.fn(),
launchImageLibraryAsync: vi.fn(),
requestCameraPermissionsAsync: vi.fn(),
requestMediaLibraryPermissionsAsync: vi.fn(),
}));
vi.mock('expo-linking', () => ({
canOpenURL: vi.fn(async () => true),
createURL: vi.fn((path = '') => `genarrative://${path}`),
openURL: vi.fn(async () => undefined),
parse: vi.fn(() => ({ path: null, queryParams: {} })),
}));
vi.mock('expo-network', () => ({
addNetworkStateListener: vi.fn(() => ({
remove: vi.fn(),
})),
getNetworkStateAsync: vi.fn(async () => ({
isConnected: true,
isInternetReachable: true,
type: 'WIFI',
})),
NetworkStateType: {
CELLULAR: 'CELLULAR',
ETHERNET: 'ETHERNET',
NONE: 'NONE',
WIFI: 'WIFI',
},
}));
vi.mock('expo-notifications', () => ({
AndroidImportance: {
DEFAULT: 'default',
},
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
setNotificationChannelAsync: vi.fn(),
setNotificationHandler: vi.fn(),
}));
vi.mock('expo-sharing', () => ({
isAvailableAsync: vi.fn(async () => true),
shareAsync: vi.fn(),
}));
vi.mock('expo-status-bar', () => ({
StatusBar: () => React.createElement('mobile-status-bar'),
}));
vi.mock('react-native-safe-area-context', () => ({
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
SafeAreaView: ({
children,
...props
}: {
children?: React.ReactNode;
}) => React.createElement('mobile-safe-area-view', props, children),
}));
vi.mock('react-native-webview', () => ({
WebView: React.forwardRef((_props: Record<string, unknown>, ref) => {
shellHarness.webViewProps.current = _props;
React.useImperativeHandle(ref, () => ({
goBack: vi.fn(),
injectJavaScript: (script: string) => {
shellHarness.injectedScripts.push(script);
},
reload: vi.fn(),
}));
return React.createElement('mobile-web-view');
}),
}));
vi.mock('react-native', () => ({
AppState: {
addEventListener: vi.fn(() => ({ remove: vi.fn() })),
currentState: 'active',
},
BackHandler: {
addEventListener: vi.fn(() => ({ remove: vi.fn() })),
},
Linking: {
addEventListener: vi.fn(() => ({ remove: vi.fn() })),
canOpenURL: vi.fn(async () => true),
getInitialURL: vi.fn(async () => null),
openURL: vi.fn(async () => undefined),
},
Platform: {
OS: 'ios',
},
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, ...props }: { children?: React.ReactNode }) =>
React.createElement('div', props, children),
}));
async function importShellApp() {
return (await import('./ShellApp')).default;
}
function buildRequest(): HostBridgeRequest {
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'scan-request-1',
method: 'scanner.scanQrCode',
};
}
function extractInjectedHostBridgeMessage(script: string) {
const match = script.match(/data: ("(?:\\.|[^"])*")/);
const encodedMessage = match?.[1];
if (!encodedMessage) {
throw new Error('injected HostBridge message missing');
}
return JSON.parse(JSON.parse(encodedMessage)) as unknown;
}
afterEach(() => {
shellHarness.reset();
vi.resetModules();
vi.clearAllMocks();
});
describe('ShellApp QR scanner HostBridge flow', () => {
test('scanner.scanQrCode drives overlay camera scan and injects HostBridge response into WebView', async () => {
const ShellApp = await importShellApp();
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
onMessage?: (event: {
nativeEvent: {
data: string;
url: string;
};
}) => void;
source?: { uri?: string };
};
const webViewUrl = webViewProps.source?.uri;
expect(webViewUrl).toContain('https://app.genarrative.world');
webViewProps.onMessage?.({
nativeEvent: {
data: JSON.stringify(buildRequest()),
url: webViewUrl ?? 'https://app.genarrative.world/',
},
});
await waitFor(() => {
expect(shellHarness.cameraViewProps.current).toBeTruthy();
});
const cameraViewProps = shellHarness.cameraViewProps.current as {
onBarcodeScanned?: (result: { type: string; data: string }) => void;
};
cameraViewProps.onBarcodeScanned?.({
type: 'qr',
data: ' https://app.genarrative.world/works/detail?work=PZ-1 ',
});
await waitFor(() => {
expect(
shellHarness.injectedScripts.some((script) =>
script.includes('scan-request-1'),
),
).toBe(true);
});
const responseScript = shellHarness.injectedScripts.find((script) =>
script.includes('scan-request-1'),
);
const response = extractInjectedHostBridgeMessage(responseScript ?? '');
expect(response).toMatchObject({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'scan-request-1',
ok: true,
result: {
format: 'qr_code',
value: 'https://app.genarrative.world/works/detail?work=PZ-1',
},
});
});
});
+1 -1
View File
@@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
},
});
+1
View File
@@ -273,6 +273,7 @@ const expectedMobileSrcRootEntries = [
];
const expectedMobileShellFiles = [
'QrScannerOverlay.tsx',
'ShellApp.test.tsx',
'ShellApp.tsx',
'deepLink.test.ts',
'deepLink.ts',