|
|
|
@@ -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',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|