diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 2f0d41a40..fa20a4b1e 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -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('')) { 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())') diff --git a/apps/mobile-shell/src/shell/ShellApp.test.tsx b/apps/mobile-shell/src/shell/ShellApp.test.tsx new file mode 100644 index 000000000..b48d64b7a --- /dev/null +++ b/apps/mobile-shell/src/shell/ShellApp.test.tsx @@ -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 | null }; + const cameraViewProps = { current: null as Record | 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) => { + 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, 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: (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(); + + 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', + }, + }); + }); +}); diff --git a/apps/mobile-shell/vitest.config.ts b/apps/mobile-shell/vitest.config.ts index 7eeb3f85d..c7f1a765d 100644 --- a/apps/mobile-shell/vitest.config.ts +++ b/apps/mobile-shell/vitest.config.ts @@ -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'], }, }); diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 4e4941914..dfd9e15a0 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -273,6 +273,7 @@ const expectedMobileSrcRootEntries = [ ]; const expectedMobileShellFiles = [ 'QrScannerOverlay.tsx', + 'ShellApp.test.tsx', 'ShellApp.tsx', 'deepLink.test.ts', 'deepLink.ts',