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',