补齐移动壳扫码真实链路测试
新增 ShellApp 级扫码 HostBridge 测试覆盖 WebView 请求到相机扫码回注 移动壳 Vitest 纳入 TSX 组件测试 单端与根级原生壳门禁反查扫码链路测试存在
This commit is contained in:
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user