diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx
index cc8fd52..10721cb 100644
--- a/apps/mobile/App.tsx
+++ b/apps/mobile/App.tsx
@@ -2,8 +2,9 @@
// System-WebView browser + AI chat + agents + settings. This is the companion
// app, NOT the Ungoogled Chromium engine — see docs/mobile-architecture.md.
import { StatusBar } from 'expo-status-bar';
-import { useState } from 'react';
-import { SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { useState, type ReactNode } from 'react';
+import { Keyboard, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
import { BrowserScreen } from './src/screens/BrowserScreen';
import { ChatScreen } from './src/screens/ChatScreen';
import { AgentsScreen } from './src/screens/AgentsScreen';
@@ -19,26 +20,75 @@ const TABS: { key: TabKey; label: string; icon: string }[] = [
{ key: 'settings', label: 'Settings', icon: '⚙️' },
];
+// Inactive scenes are parked offscreen inside an overflow-hidden host instead
+// of being unmounted (which destroys WebView history and chat state) or given
+// `display: 'none'` (which Android can treat as a native detach). Same
+// technique as react-navigation's ResourceSavingView.
+const DETACHED_TOP = 100000;
+
+function TabScene({ active, children }: { active: boolean; children: ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
export default function App() {
+ return (
+
+
+
+ );
+}
+
+function AppShell() {
const [tab, setTab] = useState('browser');
+ // Android 15/16 enforce edge-to-edge: the app draws under the system bars,
+ // so the toolbar and tab bar must pad themselves out of the way explicitly.
+ const insets = useSafeAreaInsets();
return (
-
+
- {tab === 'browser' && }
- {tab === 'chat' && }
- {tab === 'agents' && }
- {tab === 'settings' && }
+
+
+
+
+
+
+
+
+
+
+
+
-
+
{TABS.map((t) => {
const active = t.key === tab;
return (
setTab(t.key)}
+ onPress={() => {
+ if (t.key !== tab) Keyboard.dismiss();
+ setTab(t.key);
+ }}
accessibilityRole="tab"
accessibilityState={{ selected: active }}
>
@@ -48,19 +98,21 @@ export default function App() {
);
})}
-
+
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg },
screen: { flex: 1 },
+ scene: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, overflow: 'hidden' },
+ sceneInner: { flex: 1 },
+ sceneDetached: { top: DETACHED_TOP },
tabBar: {
flexDirection: 'row',
backgroundColor: theme.surface,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: theme.border,
- paddingBottom: 6,
},
tab: { flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 },
tabIcon: { fontSize: 18 },
diff --git a/apps/mobile/BUILD_READINESS.md b/apps/mobile/BUILD_READINESS.md
index d500697..de4e071 100644
--- a/apps/mobile/BUILD_READINESS.md
+++ b/apps/mobile/BUILD_READINESS.md
@@ -46,6 +46,36 @@ generated debug keystore for sideload testing. It is not store-signed. The job
does not use Expo EAS credits, publish an app, or commit the generated `android/`
directory.
+## Android runtime smoke checklist
+
+Run on one Android 15+ device or emulator with the sideloaded preview APK.
+These behaviors are covered by component tests with a mocked native boundary;
+this checklist is the real-device gate that the mocks cannot replace.
+
+1. **Search, no account** — type `privacy first browser` in the address bar and
+ submit: a DuckDuckGo results page loads (no Kagi login wall).
+2. **Two-page history** — from the results page open any result, then tap the
+ in-app `‹` button: the results page returns.
+3. **Tab-state survival** — load a page, scroll partway, switch to Chat, type a
+ draft (don't send), visit Agents and Settings, return to Browse: the same
+ page and scroll position are still there; return to Chat: the draft is
+ still there.
+4. **System Back** — with two pages of history and Browse active, the system
+ back gesture/button goes to the previous page; on the first page it leaves
+ the app. With Chat active it leaves the app immediately, even when the
+ hidden Browse tab still has history.
+5. **System-bar insets** — the URL toolbar sits fully below the status bar and
+ the tab bar fully above the gesture/navigation bar, in portrait, with no
+ content underlapping either bar.
+6. **Popup links** — open a `target="_blank"` link (e.g. a result on a site
+ that opens externally): it loads visibly in the same tab and Back returns
+ to the referring page. A `javascript:` or `data:` popup does nothing.
+7. **Cookie wording** — Settings → Privacy shows "Blocked in browser tab" on
+ Android (an iOS build must show the WebKit wording instead).
+
+Record the device model, Android version, and each step's result honestly —
+an APK that has not passed this list is not release-ready.
+
## EAS preview build
The app is linked to the `profullstack/tronbrowserdev` EAS project. Cloud builds
diff --git a/apps/mobile/README.md b/apps/mobile/README.md
index 614be29..9bce8ac 100644
--- a/apps/mobile/README.md
+++ b/apps/mobile/README.md
@@ -28,10 +28,18 @@ Bundle ids: `dev.tronbrowser.app` (iOS + Android).
## Features
-Implemented screens (tabbed shell, `App.tsx`):
+Implemented screens (tabbed shell, `App.tsx`). Every tab stays mounted across
+switches — WebView history/scroll, chat messages, and drafts survive — while
+inactive tabs are hidden from touch and accessibility. Safe areas come from
+`react-native-safe-area-context` (Android 15/16 edge-to-edge), not React
+Native's deprecated iOS-only `SafeAreaView`.
- **Browse** — in-app browser via `react-native-webview` (system engine),
- URL/search bar, back/reload, third-party cookies blocked.
+ URL/search bar with a DuckDuckGo default that needs no account (the desktop
+ correction), back/forward/reload. Android hardware Back walks page history
+ only while this tab is active; `window.open` / `target="_blank"` opens in
+ the same tab after HTTP(S) validation; third-party cookies are blocked on
+ Android (on iOS the WKWebView cookie policy belongs to WebKit).
- **Chat** — AI chat UI; the provider seam is `src/lib/ai.ts`
(set `EXPO_PUBLIC_AI_ENDPOINT`, else offline echo).
- **Agents** — agent dashboard (sample data → wire `@tronbrowser/agent-runtime`).
@@ -40,6 +48,13 @@ Implemented screens (tabbed shell, `App.tsx`):
Still to wire (PRD §Mobile): real model provider, sync backend, voice,
push notifications.
+## Tests
+
+`pnpm test` runs the URL/search unit tests plus component tests that render
+the real `App`/screens with only the native boundary mocked (`test/mocks/*`,
+aliased in `vitest.config.ts`): tab-state preservation, hardware-Back policy,
+safe-area insets, `window.open` handling, and platform cookie wording.
+
## EAS (builds & submission)
Linked to the EAS project **profullstack/tronbrowserdev**
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index f091a02..97dbbe0 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -29,12 +29,15 @@
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
"react-native": "0.86.2",
+ "react-native-safe-area-context": "~5.7.0",
"react-native-webview": "13.16.1"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@types/react": "~19.2.17",
+ "@types/react-test-renderer": "^19.1.0",
"babel-preset-expo": "~57.0.5",
+ "react-test-renderer": "19.2.3",
"typescript": "^5.6.3",
"vitest": "^2.1.4"
}
diff --git a/apps/mobile/src/lib/navigation.test.ts b/apps/mobile/src/lib/navigation.test.ts
index 89eb57d..282f3ba 100644
--- a/apps/mobile/src/lib/navigation.test.ts
+++ b/apps/mobile/src/lib/navigation.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { HOME, normalizeUrl } from './navigation';
+import { HOME, navigableHttpUrl, normalizeUrl } from './navigation';
describe('normalizeUrl', () => {
it('returns home for blank input', () => {
@@ -23,19 +23,49 @@ describe('normalizeUrl', () => {
it('searches ordinary text', () => {
expect(normalizeUrl('privacy first browser')).toBe(
- 'https://kagi.com/search?q=privacy%20first%20browser',
+ 'https://duckduckgo.com/?q=privacy%20first%20browser',
);
});
it('searches unsupported schemes instead of loading them', () => {
expect(normalizeUrl('javascript:alert(1)')).toBe(
- 'https://kagi.com/search?q=javascript%3Aalert(1)',
+ 'https://duckduckgo.com/?q=javascript%3Aalert(1)',
);
});
it('does not treat domain-looking text with spaces as a URL', () => {
expect(normalizeUrl('example.com malicious suffix')).toBe(
- 'https://kagi.com/search?q=example.com%20malicious%20suffix',
+ 'https://duckduckgo.com/?q=example.com%20malicious%20suffix',
);
});
+
+ it('searches with a no-account engine, not Kagi', () => {
+ // Kagi needs a subscription after its trial; a fresh install must be able
+ // to search out of the box, matching the desktop DuckDuckGo default.
+ expect(normalizeUrl('some query')).not.toContain('kagi.com');
+ });
+});
+
+describe('navigableHttpUrl', () => {
+ it('accepts absolute HTTP(S) URLs', () => {
+ expect(navigableHttpUrl('https://example.com/next?page=2')).toBe(
+ 'https://example.com/next?page=2',
+ );
+ expect(navigableHttpUrl('http://localhost:8080/dev')).toBe(
+ 'http://localhost:8080/dev',
+ );
+ });
+
+ it('rejects script and data schemes instead of falling back to search', () => {
+ expect(navigableHttpUrl('javascript:alert(document.cookie)')).toBeNull();
+ expect(navigableHttpUrl('data:text/html,')).toBeNull();
+ });
+
+ it('rejects other non-web schemes and relative junk', () => {
+ expect(navigableHttpUrl('intent://scan/#Intent;scheme=zxing;end')).toBeNull();
+ expect(navigableHttpUrl('about:blank')).toBeNull();
+ expect(navigableHttpUrl('file:///etc/passwd')).toBeNull();
+ expect(navigableHttpUrl('example.com/no-scheme')).toBeNull();
+ expect(navigableHttpUrl(' ')).toBeNull();
+ });
});
diff --git a/apps/mobile/src/lib/navigation.ts b/apps/mobile/src/lib/navigation.ts
index a651d28..c992245 100644
--- a/apps/mobile/src/lib/navigation.ts
+++ b/apps/mobile/src/lib/navigation.ts
@@ -4,7 +4,10 @@ const DOMAIN_OR_IP =
/^(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|(?:\d{1,3}\.){3}\d{1,3}|localhost)(?::\d{1,5})?(?:[/?#][^\s]*)?$/i;
function searchUrl(query: string): string {
- return `https://kagi.com/search?q=${encodeURIComponent(query)}`;
+ // DuckDuckGo answers without an account. Kagi is subscription-only past its
+ // trial, so defaulting to it left a fresh install with a broken search box —
+ // the same out-of-box failure the desktop launcher already corrects.
+ return `https://duckduckgo.com/?q=${encodeURIComponent(query)}`;
}
/**
@@ -39,3 +42,22 @@ export function normalizeUrl(input: string): string {
return searchUrl(trimmed);
}
+
+/**
+ * Validate a URL that page content asked us to open (`window.open`,
+ * `target="_blank"`). Unlike address-bar input there is no search fallback:
+ * only an absolute HTTP(S) URL may navigate the tab, and anything else
+ * (javascript:, data:, intent:, about:, malformed) is dropped entirely.
+ */
+export function navigableHttpUrl(raw: string): string | null {
+ const trimmed = raw.trim();
+ if (!trimmed) return null;
+ try {
+ const parsed = new URL(trimmed);
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? parsed.toString()
+ : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/mobile/src/screens/BrowserScreen.tsx b/apps/mobile/src/screens/BrowserScreen.tsx
index dae1539..8615ec0 100644
--- a/apps/mobile/src/screens/BrowserScreen.tsx
+++ b/apps/mobile/src/screens/BrowserScreen.tsx
@@ -1,6 +1,7 @@
-import { useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
+ BackHandler,
Platform,
StyleSheet,
Text,
@@ -9,7 +10,7 @@ import {
View,
} from 'react-native';
import { WebView } from 'react-native-webview';
-import { HOME, normalizeUrl } from '../lib/navigation';
+import { HOME, navigableHttpUrl, normalizeUrl } from '../lib/navigation';
import { theme } from '../theme';
/**
@@ -19,8 +20,12 @@ import { theme } from '../theme';
* iOS (mandatory), the system WebView on Android. It is deliberately NOT the
* Ungoogled Chromium engine (see docs/mobile-architecture.md — the engine ships
* via the native Android build and the Linux-phone desktop build, not Expo).
+ *
+ * The screen stays mounted while other tabs are shown (App.tsx keeps every
+ * scene alive), so `isActive` — not mount state — says whether this tab owns
+ * the Android hardware Back button.
*/
-export function BrowserScreen() {
+export function BrowserScreen({ isActive = true }: { isActive?: boolean }) {
const webRef = useRef(null);
const [address, setAddress] = useState(HOME);
const [uri, setUri] = useState(HOME);
@@ -28,12 +33,41 @@ export function BrowserScreen() {
const [canGoBack, setCanGoBack] = useState(false);
const [canGoForward, setCanGoForward] = useState(false);
+ // Android system Back pops WebView history. Subscribe only while this tab is
+ // the visible one AND there is history to pop; otherwise no handler exists at
+ // all, so the event keeps its default meaning (leave the app) and a hidden
+ // Browser tab can never swallow it.
+ useEffect(() => {
+ if (Platform.OS !== 'android' || !isActive || !canGoBack) return;
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
+ webRef.current?.goBack();
+ return true;
+ });
+ return () => subscription.remove();
+ }, [isActive, canGoBack]);
+
const go = () => {
const next = normalizeUrl(address);
setUri(next);
setAddress(next);
};
+ // Android hands `window.open` / `target="_blank"` to a detached WebView the
+ // user never sees. Show those navigations in this single tab instead — but a
+ // page-supplied URL only reaches `source` once validated as plain HTTP(S);
+ // javascript:/data:/intent: targets are dropped.
+ const openWindowInThisTab = (targetUrl: string) => {
+ const next = navigableHttpUrl(targetUrl);
+ if (!next) return;
+ // In-page navigation can leave `uri` unchanged. Updating the same source
+ // would do nothing; navigate the existing WebView without discarding history.
+ if (next === uri) {
+ webRef.current?.injectJavaScript(`window.location.assign(${JSON.stringify(next)});true;`);
+ }
+ setUri(next);
+ setAddress(next);
+ };
+
return (
@@ -93,6 +127,7 @@ export function BrowserScreen() {
setCanGoBack(state.canGoBack);
setCanGoForward(state.canGoForward);
}}
+ onOpenWindow={(event) => openWindowInThisTab(event.nativeEvent.targetUrl)}
// Privacy-leaning defaults consistent with the desktop ethos.
thirdPartyCookiesEnabled={false}
allowsInlineMediaPlayback
diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx
index a250c74..4862ef6 100644
--- a/apps/mobile/src/screens/SettingsScreen.tsx
+++ b/apps/mobile/src/screens/SettingsScreen.tsx
@@ -1,10 +1,15 @@
import Constants from 'expo-constants';
-import { Linking, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Linking, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
import { theme } from '../theme';
/** Settings / sync / about (PRD §Mobile). */
export function SettingsScreen() {
const version = Constants.expoConfig?.version ?? '—';
+ // `thirdPartyCookiesEnabled={false}` is an Android-only WebView setting; on
+ // iOS the WKWebView cookie policy belongs to WebKit, not this app, so the UI
+ // must not claim we block anything there.
+ const cookieStatus =
+ Platform.OS === 'android' ? 'Blocked in browser tab' : 'Decided by iOS WebKit';
return (
Settings
@@ -16,7 +21,7 @@ export function SettingsScreen() {
diff --git a/apps/mobile/test/app-tabs.test.tsx b/apps/mobile/test/app-tabs.test.tsx
new file mode 100644
index 0000000..7b483ea
--- /dev/null
+++ b/apps/mobile/test/app-tabs.test.tsx
@@ -0,0 +1,164 @@
+/**
+ * Tab shell behavior through the real App component: every screen stays
+ * mounted across tab switches — WebView identity, browser address state, chat
+ * history, and the chat draft all survive — while inactive scenes are hidden
+ * from touch, accessibility, and the visible layout.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import App from '../App';
+import {
+ actAsync,
+ fire,
+ flat,
+ hosts,
+ hostWhere,
+ isHostType,
+ renderScreen,
+ scenes,
+ switchTab,
+ textContents,
+} from './harness';
+import { theWebView, webViewRegistry } from './mocks/react-native-webview';
+import { Keyboard } from './mocks/react-native';
+
+import type { ReactTestInstance } from 'react-test-renderer';
+
+const addressInput = (root: ReactTestInstance) =>
+ hostWhere(root, 'TextInput', (n) => n.props.placeholder === 'Search or enter address', 'address bar');
+
+const chatInput = (root: ReactTestInstance) =>
+ hostWhere(
+ root,
+ 'TextInput',
+ (n) => n.props.placeholder === 'Message' || n.props.placeholder === 'Thinking…',
+ 'chat composer',
+ );
+
+const sendButton = (root: ReactTestInstance) =>
+ hostWhere(
+ root,
+ 'TouchableOpacity',
+ (n) => n.findAll((t) => isHostType(t, 'Text') && t.props.children === 'Send').length === 1,
+ 'send button',
+ );
+
+describe('App tab shell', () => {
+ it('dismisses the old tab keyboard only when changing tabs', async () => {
+ const renderer = await renderScreen();
+ await switchTab(renderer.root, 'Browse');
+ expect(Keyboard.dismiss).not.toHaveBeenCalled();
+ await switchTab(renderer.root, 'Chat');
+ expect(Keyboard.dismiss).toHaveBeenCalledTimes(1);
+ await switchTab(renderer.root, 'Browse');
+ expect(Keyboard.dismiss).toHaveBeenCalledTimes(2);
+ });
+ it('keeps all four screens mounted at once', async () => {
+ const renderer = await renderScreen();
+ const root = renderer.root;
+
+ // One browser (address bar + WebView), one chat composer, and the agents +
+ // settings content — all present in the tree simultaneously.
+ expect(addressInput(root)).toBeDefined();
+ expect(chatInput(root)).toBeDefined();
+ expect(textContents(root)).toContain('Agents');
+ expect(textContents(root)).toContain('Settings');
+ expect(webViewRegistry()).toHaveLength(1);
+ expect(scenes(root)).toHaveLength(4);
+ });
+
+ it('exposes only the active scene to touch and accessibility', async () => {
+ const renderer = await renderScreen();
+ const root = renderer.root;
+
+ const [browser, chat, agents, settings] = scenes(root);
+ expect(browser.props.accessibilityElementsHidden).toBe(false);
+ expect(browser.props.importantForAccessibility).toBe('auto');
+ expect(browser.props.pointerEvents).toBe('auto');
+ for (const hidden of [chat, agents, settings]) {
+ expect(hidden.props.accessibilityElementsHidden).toBe(true);
+ expect(hidden.props.importantForAccessibility).toBe('no-hide-descendants');
+ expect(hidden.props.pointerEvents).toBe('none');
+ }
+
+ // Hidden scenes are parked offscreen inside an overflow-hidden host — not
+ // display:none (native detach risk), not unmounted (state destruction).
+ // findAll() includes the node itself, so skip the scene wrapper.
+ const innerOf = (scene: ReactTestInstance) =>
+ hosts(scene, 'View').find((view) => view !== scene)!;
+ expect(flat(browser).overflow).toBe('hidden');
+ expect(flat(browser).position).toBe('absolute');
+ expect(flat(innerOf(browser)).top).toBeUndefined();
+ expect(flat(innerOf(chat)).top).toBe(100000);
+
+ await switchTab(root, 'Chat');
+ const after = scenes(root);
+ expect(after[0].props.accessibilityElementsHidden).toBe(true);
+ expect(after[1].props.accessibilityElementsHidden).toBe(false);
+ expect(flat(innerOf(after[0])).top).toBe(100000);
+ expect(flat(innerOf(after[1])).top).toBeUndefined();
+ });
+
+ it('keeps the same WebView (history intact) across tab switches', async () => {
+ const renderer = await renderScreen();
+ const root = renderer.root;
+
+ const initialId = theWebView().id;
+ await actAsync(() =>
+ theWebView().emitNavigationState({
+ url: 'https://example.com/second-page',
+ canGoBack: true,
+ canGoForward: false,
+ }),
+ );
+
+ await switchTab(root, 'Chat');
+ await switchTab(root, 'Settings');
+ await switchTab(root, 'Browse');
+
+ // A remount would appear as a second registry entry and reset the address
+ // bar and the Back button to their initial state.
+ expect(webViewRegistry()).toHaveLength(1);
+ expect(theWebView().id).toBe(initialId);
+ expect(addressInput(root).props.value).toBe('https://example.com/second-page');
+ const backButton = hostWhere(
+ root,
+ 'TouchableOpacity',
+ (n) => n.props.accessibilityLabel === 'Back',
+ 'browser back button',
+ );
+ expect(backButton.props.accessibilityState).toEqual({ disabled: false });
+ });
+
+ it('keeps chat history and the unsent draft across tab switches', async () => {
+ vi.useFakeTimers();
+ try {
+ const renderer = await renderScreen();
+ const root = renderer.root;
+
+ await switchTab(root, 'Chat');
+ await fire(chatInput(root), 'onChangeText', 'hello agent');
+ await fire(sendButton(root), 'onPress');
+ // The offline AI seam replies after 350ms (src/lib/ai.ts).
+ await actAsync(async () => {
+ await vi.advanceTimersByTimeAsync(500);
+ });
+
+ const sent = textContents(root);
+ expect(sent).toContain('hello agent');
+ expect(sent.some((text) => text.includes('You said: “hello agent”'))).toBe(true);
+
+ await fire(chatInput(root), 'onChangeText', 'unsent draft');
+ await switchTab(root, 'Agents');
+ await switchTab(root, 'Chat');
+
+ // A remounted ChatScreen would come back with only the greeting bubble
+ // and an empty composer.
+ const restored = textContents(root);
+ expect(restored).toContain('hello agent');
+ expect(restored.some((text) => text.includes('You said: “hello agent”'))).toBe(true);
+ expect(chatInput(root).props.value).toBe('unsent draft');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/apps/mobile/test/browser-search.test.tsx b/apps/mobile/test/browser-search.test.tsx
new file mode 100644
index 0000000..9224301
--- /dev/null
+++ b/apps/mobile/test/browser-search.test.tsx
@@ -0,0 +1,53 @@
+/**
+ * Search through the real BrowserScreen: address-bar input must reach the
+ * actual WebView `source` as a no-account DuckDuckGo query — the same
+ * correction the desktop launcher already ships — never as Kagi and never as a
+ * raw unsupported scheme.
+ */
+import { describe, expect, it } from 'vitest';
+import { HOME } from '../src/lib/navigation';
+import { BrowserScreen } from '../src/screens/BrowserScreen';
+import { fire, hostWhere, renderScreen } from './harness';
+import { theWebView } from './mocks/react-native-webview';
+
+import type { ReactTestInstance } from 'react-test-renderer';
+
+const addressInput = (root: ReactTestInstance) =>
+ hostWhere(root, 'TextInput', (n) => n.props.placeholder === 'Search or enter address', 'address bar');
+
+async function submitAddress(root: ReactTestInstance, text: string): Promise {
+ await fire(addressInput(root), 'onChangeText', text);
+ await fire(addressInput(root), 'onSubmitEditing');
+}
+
+describe('BrowserScreen search', () => {
+ it('starts on the TronBrowser home page', async () => {
+ await renderScreen();
+ expect(theWebView().props.source).toEqual({ uri: HOME });
+ });
+
+ it('sends plain text to DuckDuckGo, which needs no account', async () => {
+ const renderer = await renderScreen();
+ await submitAddress(renderer.root, 'privacy first browser');
+ expect(theWebView().props.source).toEqual({
+ uri: 'https://duckduckgo.com/?q=privacy%20first%20browser',
+ });
+ expect(addressInput(renderer.root).props.value).toBe(
+ 'https://duckduckgo.com/?q=privacy%20first%20browser',
+ );
+ });
+
+ it('promotes a bare domain to HTTPS instead of searching it', async () => {
+ const renderer = await renderScreen();
+ await submitAddress(renderer.root, 'docs.example.com/guide');
+ expect(theWebView().props.source).toEqual({ uri: 'https://docs.example.com/guide' });
+ });
+
+ it('turns javascript: input into a search, never a load', async () => {
+ const renderer = await renderScreen();
+ await submitAddress(renderer.root, 'javascript:alert(1)');
+ expect(theWebView().props.source).toEqual({
+ uri: 'https://duckduckgo.com/?q=javascript%3Aalert(1)',
+ });
+ });
+});
diff --git a/apps/mobile/test/hardware-back.test.tsx b/apps/mobile/test/hardware-back.test.tsx
new file mode 100644
index 0000000..ee23637
--- /dev/null
+++ b/apps/mobile/test/hardware-back.test.tsx
@@ -0,0 +1,87 @@
+/**
+ * Android hardware-Back policy through the real App + BrowserScreen: intercept
+ * only while the Browser tab is active AND the WebView has history to pop;
+ * otherwise the event must fall through to the system (leave the app). The
+ * subscription itself must disappear whenever the condition stops holding.
+ */
+import { describe, expect, it } from 'vitest';
+import App from '../App';
+import { actAsync, renderScreen, switchTab } from './harness';
+import {
+ backPressSubscriptionCount,
+ emitHardwareBackPress,
+ Platform,
+} from './mocks/react-native';
+import { theWebView } from './mocks/react-native-webview';
+
+async function pressSystemBack(): Promise {
+ let handled = false;
+ await actAsync(() => {
+ handled = emitHardwareBackPress();
+ });
+ return handled;
+}
+
+function browseTo(url: string, canGoBack: boolean) {
+ return actAsync(() =>
+ theWebView().emitNavigationState({ url, canGoBack, canGoForward: false }),
+ );
+}
+
+describe('hardware Back', () => {
+ it('leaves Back to the system while there is no history', async () => {
+ await renderScreen();
+ expect(backPressSubscriptionCount()).toBe(0);
+ expect(await pressSystemBack()).toBe(false);
+ expect(theWebView().calls.goBack).toBe(0);
+ });
+
+ it('pops WebView history while Browser is active with history', async () => {
+ await renderScreen();
+ await browseTo('https://example.com/two', true);
+ expect(await pressSystemBack()).toBe(true);
+ expect(theWebView().calls.goBack).toBe(1);
+ });
+
+ it('stops intercepting once history is exhausted', async () => {
+ await renderScreen();
+ await browseTo('https://example.com/two', true);
+ expect(await pressSystemBack()).toBe(true);
+ await browseTo('https://example.com/', false);
+ expect(backPressSubscriptionCount()).toBe(0);
+ expect(await pressSystemBack()).toBe(false);
+ expect(theWebView().calls.goBack).toBe(1);
+ });
+
+ it('never intercepts from a hidden Browser tab, and re-arms on return', async () => {
+ const renderer = await renderScreen();
+ await browseTo('https://example.com/two', true);
+
+ await switchTab(renderer.root, 'Chat');
+ // The subscription is removed — not merely ignored — so other back logic
+ // (and the system default) is never shadowed by a background tab.
+ expect(backPressSubscriptionCount()).toBe(0);
+ expect(await pressSystemBack()).toBe(false);
+ expect(theWebView().calls.goBack).toBe(0);
+
+ await switchTab(renderer.root, 'Browse');
+ expect(backPressSubscriptionCount()).toBe(1);
+ expect(await pressSystemBack()).toBe(true);
+ expect(theWebView().calls.goBack).toBe(1);
+ });
+
+ it('cleans up its subscription on unmount', async () => {
+ const renderer = await renderScreen();
+ await browseTo('https://example.com/two', true);
+ expect(backPressSubscriptionCount()).toBe(1);
+ await actAsync(() => renderer.unmount());
+ expect(backPressSubscriptionCount()).toBe(0);
+ });
+
+ it('does not subscribe at all on iOS', async () => {
+ Platform.OS = 'ios';
+ await renderScreen();
+ await browseTo('https://example.com/two', true);
+ expect(backPressSubscriptionCount()).toBe(0);
+ });
+});
diff --git a/apps/mobile/test/harness.tsx b/apps/mobile/test/harness.tsx
new file mode 100644
index 0000000..80caf32
--- /dev/null
+++ b/apps/mobile/test/harness.tsx
@@ -0,0 +1,112 @@
+/**
+ * Shared helpers for the component tests: render real screens with
+ * react-test-renderer, drive their rendered props inside act(), and query the
+ * host-element tree produced by the test doubles in ./mocks.
+ */
+import { act, type ReactElement } from 'react';
+import TestRenderer, {
+ type ReactTestInstance,
+ type ReactTestRenderer,
+} from 'react-test-renderer';
+import { StyleSheet } from './mocks/react-native';
+
+const liveRenderers: ReactTestRenderer[] = [];
+
+/** Render inside act() so effects (BackHandler subscriptions etc.) run. */
+export async function renderScreen(element: ReactElement): Promise {
+ let renderer: ReactTestRenderer | undefined;
+ await act(async () => {
+ renderer = TestRenderer.create(element);
+ });
+ liveRenderers.push(renderer!);
+ return renderer!;
+}
+
+/** Unmount everything a test rendered (called from test/setup.ts). */
+export async function cleanupRenderers(): Promise {
+ while (liveRenderers.length > 0) {
+ const renderer = liveRenderers.pop()!;
+ await act(async () => {
+ renderer.unmount();
+ });
+ }
+}
+
+/** Run any state-changing interaction (mock emissions included) inside act(). */
+export async function actAsync(run: () => void | Promise): Promise {
+ await act(async () => {
+ await run();
+ });
+}
+
+/** Invoke a rendered prop callback (onPress, onChangeText, …) inside act(). */
+export async function fire(
+ node: ReactTestInstance,
+ prop: string,
+ ...args: unknown[]
+): Promise {
+ const handler = node.props[prop] as ((...handlerArgs: unknown[]) => unknown) | undefined;
+ if (typeof handler !== 'function') {
+ throw new Error(`rendered node has no ${prop} handler`);
+ }
+ await act(async () => {
+ handler(...args);
+ });
+}
+
+/** Host-element check: react-test-renderer types `type` too narrowly to ===. */
+export function isHostType(node: ReactTestInstance, type: string): boolean {
+ return (node.type as unknown) === type;
+}
+
+/** All host nodes of a native type name ('View', 'TextInput', …). */
+export function hosts(root: ReactTestInstance, type: string): ReactTestInstance[] {
+ return root.findAll((node) => isHostType(node, type));
+}
+
+/** Exactly one host node of `type` matching `predicate`, or a loud failure. */
+export function hostWhere(
+ root: ReactTestInstance,
+ type: string,
+ predicate: (node: ReactTestInstance) => boolean,
+ description: string,
+): ReactTestInstance {
+ const matches = hosts(root, type).filter(predicate);
+ if (matches.length !== 1) {
+ throw new Error(`expected exactly one ${description}, found ${matches.length}`);
+ }
+ return matches[0];
+}
+
+/** Flattened style object of a rendered node. */
+export function flat(node: ReactTestInstance): Record {
+ return StyleSheet.flatten(node.props.style as Parameters[0]);
+}
+
+/** Every plain-string Text content in the tree (chat bubbles, rows, labels). */
+export function textContents(root: ReactTestInstance): string[] {
+ return hosts(root, 'Text')
+ .map((node) => node.props.children)
+ .filter((children): children is string => typeof children === 'string');
+}
+
+/** The bottom-bar button for a tab, located by its visible label. */
+export function tabButton(root: ReactTestInstance, label: string): ReactTestInstance {
+ return hostWhere(
+ root,
+ 'TouchableOpacity',
+ (node) =>
+ node.props.accessibilityRole === 'tab' &&
+ node.findAll((text) => isHostType(text, 'Text') && text.props.children === label).length === 1,
+ `tab "${label}"`,
+ );
+}
+
+export async function switchTab(root: ReactTestInstance, label: string): Promise {
+ await fire(tabButton(root, label), 'onPress');
+}
+
+/** The four keep-mounted TabScene host views, in App.tsx tab order. */
+export function scenes(root: ReactTestInstance): ReactTestInstance[] {
+ return hosts(root, 'View').filter((node) => 'accessibilityElementsHidden' in node.props);
+}
diff --git a/apps/mobile/test/mocks/expo-constants.ts b/apps/mobile/test/mocks/expo-constants.ts
new file mode 100644
index 0000000..fc8f349
--- /dev/null
+++ b/apps/mobile/test/mocks/expo-constants.ts
@@ -0,0 +1,4 @@
+/** Test double for `expo-constants` (aliased in vitest.config.ts). */
+export default {
+ expoConfig: { version: '0.0.0-test' },
+};
diff --git a/apps/mobile/test/mocks/expo-status-bar.ts b/apps/mobile/test/mocks/expo-status-bar.ts
new file mode 100644
index 0000000..21310db
--- /dev/null
+++ b/apps/mobile/test/mocks/expo-status-bar.ts
@@ -0,0 +1,6 @@
+/** Test double for `expo-status-bar` (aliased in vitest.config.ts). */
+import { createElement } from 'react';
+
+export function StatusBar(props: Record) {
+ return createElement('StatusBar', props);
+}
diff --git a/apps/mobile/test/mocks/react-native-safe-area-context.tsx b/apps/mobile/test/mocks/react-native-safe-area-context.tsx
new file mode 100644
index 0000000..2b57ed6
--- /dev/null
+++ b/apps/mobile/test/mocks/react-native-safe-area-context.tsx
@@ -0,0 +1,39 @@
+/**
+ * Test double for `react-native-safe-area-context` (aliased in
+ * vitest.config.ts) with settable inset values, so tests can prove the shell
+ * actually consumes them. Set insets *before* rendering. Like the real
+ * library, reading insets outside a SafeAreaProvider throws.
+ */
+import { createContext, createElement, useContext, type ReactNode } from 'react';
+
+export interface EdgeInsets {
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+}
+
+const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
+let mockInsets: EdgeInsets = ZERO_INSETS;
+
+export function setMockSafeAreaInsets(insets: EdgeInsets): void {
+ mockInsets = insets;
+}
+
+export function resetMockSafeAreaInsets(): void {
+ mockInsets = ZERO_INSETS;
+}
+
+const InsetsContext = createContext(null);
+
+export function SafeAreaProvider({ children }: { children?: ReactNode }) {
+ return createElement(InsetsContext.Provider, { value: mockInsets }, children);
+}
+
+export function useSafeAreaInsets(): EdgeInsets {
+ const insets = useContext(InsetsContext);
+ if (insets === null) {
+ throw new Error('No safe area value available. Render a SafeAreaProvider first.');
+ }
+ return insets;
+}
diff --git a/apps/mobile/test/mocks/react-native-webview.tsx b/apps/mobile/test/mocks/react-native-webview.tsx
new file mode 100644
index 0000000..f44990d
--- /dev/null
+++ b/apps/mobile/test/mocks/react-native-webview.tsx
@@ -0,0 +1,103 @@
+/**
+ * Test double for `react-native-webview` (aliased in vitest.config.ts).
+ *
+ * Each mounted WebView registers a handle so tests can drive the native side
+ * of the boundary — emit navigation-state / open-window events — and observe
+ * goBack/goForward/reload calls. Handles are never pruned from the registry,
+ * so an accidental remount (which would destroy real WebView history) is
+ * visible as a second entry.
+ */
+import { createElement, forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
+
+export interface MockNavigationState {
+ url: string;
+ canGoBack: boolean;
+ canGoForward: boolean;
+}
+
+interface MockWebViewProps {
+ source?: { uri?: string };
+ onLoadStart?: () => void;
+ onLoadEnd?: () => void;
+ onNavigationStateChange?: (state: MockNavigationState) => void;
+ onOpenWindow?: (event: { nativeEvent: { targetUrl: string } }) => void;
+ [prop: string]: unknown;
+}
+
+export interface MockWebViewHandle {
+ id: number;
+ mounted: boolean;
+ /** Props from the most recent render. */
+ props: MockWebViewProps;
+ calls: { goBack: number; goForward: number; reload: number; injected: string[] };
+ emitNavigationState(state: MockNavigationState): void;
+ emitOpenWindow(targetUrl: string): void;
+}
+
+const registry: MockWebViewHandle[] = [];
+let nextId = 1;
+
+/** Every WebView ever mounted in the current test, in mount order. */
+export function webViewRegistry(): readonly MockWebViewHandle[] {
+ return registry;
+}
+
+/** The single live WebView; throws if there is not exactly one. */
+export function theWebView(): MockWebViewHandle {
+ const mounted = registry.filter((handle) => handle.mounted);
+ if (mounted.length !== 1) {
+ throw new Error(`expected exactly one mounted WebView, found ${mounted.length}`);
+ }
+ return mounted[0];
+}
+
+export function resetWebViewRegistry(): void {
+ registry.length = 0;
+ nextId = 1;
+}
+
+export const WebView = forwardRef(function WebView(props, ref) {
+ const handleRef = useRef(null);
+ if (handleRef.current === null) {
+ const handle: MockWebViewHandle = {
+ id: nextId++,
+ mounted: true,
+ props,
+ calls: { goBack: 0, goForward: 0, reload: 0, injected: [] },
+ emitNavigationState(state) {
+ handle.props.onNavigationStateChange?.(state);
+ },
+ emitOpenWindow(targetUrl) {
+ handle.props.onOpenWindow?.({ nativeEvent: { targetUrl } });
+ },
+ };
+ handleRef.current = handle;
+ registry.push(handle);
+ }
+ handleRef.current.props = props;
+
+ useEffect(() => {
+ const handle = handleRef.current;
+ if (handle) handle.mounted = true;
+ return () => {
+ if (handle) handle.mounted = false;
+ };
+ }, []);
+
+ useImperativeHandle(ref, () => ({
+ injectJavaScript: (script: string) => {
+ handleRef.current!.calls.injected.push(script);
+ },
+ goBack: () => {
+ handleRef.current!.calls.goBack += 1;
+ },
+ goForward: () => {
+ handleRef.current!.calls.goForward += 1;
+ },
+ reload: () => {
+ handleRef.current!.calls.reload += 1;
+ },
+ }));
+
+ return createElement('WebView', props);
+});
diff --git a/apps/mobile/test/mocks/react-native.ts b/apps/mobile/test/mocks/react-native.ts
new file mode 100644
index 0000000..22f7ccd
--- /dev/null
+++ b/apps/mobile/test/mocks/react-native.ts
@@ -0,0 +1,132 @@
+/**
+ * Test double for the `react-native` module (aliased in vitest.config.ts).
+ *
+ * View/Text/… are exported as host-element type strings so react-test-renderer
+ * shows the app's real rendered tree one node per component, and the imperative
+ * APIs the app touches (BackHandler, Platform, Linking) are controllable from
+ * tests through the extra mock-only exports at the bottom.
+ */
+import {
+ Fragment,
+ createElement,
+ forwardRef,
+ useImperativeHandle,
+ type ReactNode,
+} from 'react';
+import { vi } from 'vitest';
+
+export const View = 'View';
+export const Text = 'Text';
+export const TextInput = 'TextInput';
+export const TouchableOpacity = 'TouchableOpacity';
+export const ScrollView = 'ScrollView';
+export const ActivityIndicator = 'ActivityIndicator';
+export const KeyboardAvoidingView = 'KeyboardAvoidingView';
+
+type AnyProps = Record & { children?: ReactNode };
+
+/** Renders every row like the real list, and honors the scrollToEnd ref. */
+export const FlatList = forwardRef(function FlatList(props, ref) {
+ useImperativeHandle(ref, () => ({
+ scrollToEnd: (_options?: { animated?: boolean }) => {},
+ }));
+ const data = (props.data as readonly unknown[] | undefined) ?? [];
+ const renderItem = props.renderItem as
+ | ((info: { item: unknown; index: number }) => ReactNode)
+ | undefined;
+ const keyExtractor = props.keyExtractor as
+ | ((item: unknown, index: number) => string)
+ | undefined;
+ const header = props.ListHeaderComponent as ReactNode | (() => ReactNode) | undefined;
+ return createElement(
+ 'FlatList',
+ props,
+ typeof header === 'function' ? createElement(header) : header ?? null,
+ data.map((item, index) =>
+ createElement(
+ Fragment,
+ { key: keyExtractor ? keyExtractor(item, index) : String(index) },
+ renderItem ? renderItem({ item, index }) : null,
+ ),
+ ),
+ );
+});
+
+type StyleValue =
+ | Record
+ | false
+ | null
+ | undefined
+ | readonly StyleValue[];
+
+function flatten(style: StyleValue): Record {
+ if (!style) return {};
+ if (Array.isArray(style)) {
+ const merged: Record = {};
+ for (const entry of style as readonly StyleValue[]) Object.assign(merged, flatten(entry));
+ return merged;
+ }
+ return style as Record;
+}
+
+export const StyleSheet = {
+ absoluteFillObject: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 },
+ hairlineWidth: 1,
+ create(styles: T): T {
+ return styles;
+ },
+ flatten,
+};
+
+export const Platform = {
+ OS: 'android' as 'android' | 'ios',
+ select(spec: Partial>): T | undefined {
+ const chosen = spec[Platform.OS];
+ return chosen !== undefined ? chosen : spec.default;
+ },
+};
+
+export const Linking = {
+ openURL: async (_url: string): Promise => {},
+};
+
+export const Keyboard = { dismiss: vi.fn() };
+
+type BackPressHandler = () => boolean;
+const backPressHandlers: BackPressHandler[] = [];
+
+export const BackHandler = {
+ addEventListener(_event: 'hardwareBackPress', handler: BackPressHandler) {
+ backPressHandlers.push(handler);
+ return {
+ remove() {
+ const index = backPressHandlers.indexOf(handler);
+ if (index !== -1) backPressHandlers.splice(index, 1);
+ },
+ };
+ },
+ exitApp() {},
+};
+
+// --- mock-only test controls ------------------------------------------------
+
+/**
+ * Fire the Android hardware back event the way React Native does: most recent
+ * handler first, stop at the first `true`. Returns whether any handler consumed
+ * it — `false` means the OS would background/exit the app.
+ */
+export function emitHardwareBackPress(): boolean {
+ for (let i = backPressHandlers.length - 1; i >= 0; i--) {
+ if (backPressHandlers[i]()) return true;
+ }
+ return false;
+}
+
+/** Live hardwareBackPress subscriptions — for asserting cleanup, not behavior. */
+export function backPressSubscriptionCount(): number {
+ return backPressHandlers.length;
+}
+
+export function resetBackHandlerMock(): void {
+ backPressHandlers.length = 0;
+}
diff --git a/apps/mobile/test/open-window.test.tsx b/apps/mobile/test/open-window.test.tsx
new file mode 100644
index 0000000..aa5d352
--- /dev/null
+++ b/apps/mobile/test/open-window.test.tsx
@@ -0,0 +1,67 @@
+/**
+ * Bonus scope: `window.open` / `target="_blank"` navigations show up in the
+ * current single tab instead of vanishing into a detached Android WebView —
+ * but only validated HTTP(S) targets may reach `source`, and Android's
+ * multi-window isolation stays enabled.
+ */
+import { describe, expect, it } from 'vitest';
+import { HOME } from '../src/lib/navigation';
+import { BrowserScreen } from '../src/screens/BrowserScreen';
+import { actAsync, hostWhere, renderScreen } from './harness';
+import { theWebView, webViewRegistry } from './mocks/react-native-webview';
+
+describe('window.open / target=_blank', () => {
+ it('navigates even when the popup repeats the last source URL after an in-page navigation', async () => {
+ await renderScreen();
+ await actAsync(() => theWebView().emitOpenWindow('https://example.com/first'));
+ await actAsync(() => theWebView().emitNavigationState({
+ url: 'https://example.com/second', canGoBack: true, canGoForward: false,
+ }));
+ await actAsync(() => theWebView().emitOpenWindow('https://example.com/first'));
+ expect(theWebView().calls.injected).toEqual([
+ 'window.location.assign("https://example.com/first");true;',
+ ]);
+ expect(webViewRegistry()).toHaveLength(1);
+ });
+ it('opens an HTTPS popup target in this tab, keeping the same WebView', async () => {
+ const renderer = await renderScreen();
+ await actAsync(() => theWebView().emitOpenWindow('https://example.com/popup'));
+
+ expect(theWebView().props.source).toEqual({ uri: 'https://example.com/popup' });
+ const addressBar = hostWhere(
+ renderer.root,
+ 'TextInput',
+ (n) => n.props.placeholder === 'Search or enter address',
+ 'address bar',
+ );
+ expect(addressBar.props.value).toBe('https://example.com/popup');
+ // Navigation happened by prop update on the one live WebView — a remount
+ // here would throw away the page history the user can go Back through.
+ expect(webViewRegistry()).toHaveLength(1);
+ });
+
+ it('drops javascript:, data:, and other non-web targets', async () => {
+ await renderScreen();
+ const hostileTargets = [
+ 'javascript:alert(document.cookie)',
+ 'data:text/html,',
+ 'intent://scan/#Intent;scheme=zxing;end',
+ 'about:blank',
+ 'file:///etc/passwd',
+ 'not a url at all',
+ ];
+ for (const target of hostileTargets) {
+ await actAsync(() => theWebView().emitOpenWindow(target));
+ expect(theWebView().props.source).toEqual({ uri: HOME });
+ expect(theWebView().calls.injected).toEqual([]);
+ }
+ });
+
+ it('keeps Android multi-window isolation on while handling opens', async () => {
+ await renderScreen();
+ // The fix must come from onOpenWindow, not from disabling
+ // setSupportMultipleWindows (which would drop window isolation).
+ expect(typeof theWebView().props.onOpenWindow).toBe('function');
+ expect(theWebView().props.setSupportMultipleWindows).toBeUndefined();
+ });
+});
diff --git a/apps/mobile/test/safe-area.test.tsx b/apps/mobile/test/safe-area.test.tsx
new file mode 100644
index 0000000..8b8821a
--- /dev/null
+++ b/apps/mobile/test/safe-area.test.tsx
@@ -0,0 +1,45 @@
+/**
+ * Android edge-to-edge: the shell consumes real inset values from
+ * react-native-safe-area-context — status-bar inset above the toolbar, gesture
+ * bar inset below the tabs — instead of React Native's removed-from-here,
+ * iOS-only SafeAreaView.
+ */
+import { describe, expect, it } from 'vitest';
+import App from '../App';
+import { flat, hosts, hostWhere, isHostType, renderScreen } from './harness';
+import { setMockSafeAreaInsets } from './mocks/react-native-safe-area-context';
+
+import type { ReactTestInstance } from 'react-test-renderer';
+
+const shellRoot = (root: ReactTestInstance) => hosts(root, 'View')[0];
+
+const tabBar = (root: ReactTestInstance) =>
+ hostWhere(
+ root,
+ 'View',
+ (n) =>
+ flat(n).flexDirection === 'row' &&
+ n.findAll((t) => isHostType(t, 'TouchableOpacity') && t.props.accessibilityRole === 'tab')
+ .length === 4,
+ 'tab bar',
+ );
+
+describe('safe-area handling', () => {
+ it('pads the shell and tab bar by the reported system-bar insets', async () => {
+ setMockSafeAreaInsets({ top: 37, right: 12, bottom: 48, left: 12 });
+ const renderer = await renderScreen();
+
+ const rootStyle = flat(shellRoot(renderer.root));
+ expect(rootStyle.paddingTop).toBe(37);
+ expect(rootStyle.paddingLeft).toBe(12);
+ expect(rootStyle.paddingRight).toBe(12);
+
+ expect(flat(tabBar(renderer.root)).paddingBottom).toBe(6 + 48);
+ });
+
+ it('keeps the base tab-bar spacing when insets are zero', async () => {
+ const renderer = await renderScreen();
+ expect(flat(shellRoot(renderer.root)).paddingTop).toBe(0);
+ expect(flat(tabBar(renderer.root)).paddingBottom).toBe(6);
+ });
+});
diff --git a/apps/mobile/test/settings-cookies.test.tsx b/apps/mobile/test/settings-cookies.test.tsx
new file mode 100644
index 0000000..4d1084a
--- /dev/null
+++ b/apps/mobile/test/settings-cookies.test.tsx
@@ -0,0 +1,35 @@
+/**
+ * Bonus scope: honest third-party-cookie wording. The blocking flag
+ * (`thirdPartyCookiesEnabled={false}`) exists on Android's WebView only, so
+ * only the Android UI may claim blocking; iOS states that WebKit decides.
+ */
+import { describe, expect, it } from 'vitest';
+import { SettingsScreen } from '../src/screens/SettingsScreen';
+import { BrowserScreen } from '../src/screens/BrowserScreen';
+import { renderScreen, textContents } from './harness';
+import { Platform } from './mocks/react-native';
+import { theWebView } from './mocks/react-native-webview';
+
+describe('third-party cookie wording', () => {
+ it('claims blocking on Android, where the WebView flag really applies', async () => {
+ Platform.OS = 'android';
+ const settings = await renderScreen();
+ expect(textContents(settings.root)).toContain('Blocked in browser tab');
+
+ await renderScreen();
+ expect(theWebView().props.thirdPartyCookiesEnabled).toBe(false);
+ });
+
+ it('does not claim app-side blocking on iOS', async () => {
+ Platform.OS = 'ios';
+ const settings = await renderScreen();
+ const rendered = textContents(settings.root);
+ expect(rendered).toContain('Decided by iOS WebKit');
+ expect(rendered).not.toContain('Blocked in browser tab');
+ });
+
+ it('shows the app version from the Expo config seam', async () => {
+ const settings = await renderScreen();
+ expect(textContents(settings.root)).toContain('0.0.0-test');
+ });
+});
diff --git a/apps/mobile/test/setup.ts b/apps/mobile/test/setup.ts
new file mode 100644
index 0000000..8f7b3a7
--- /dev/null
+++ b/apps/mobile/test/setup.ts
@@ -0,0 +1,30 @@
+/**
+ * Vitest setup: declare the React act() environment, polyfill
+ * requestAnimationFrame (Node has none; the chat screen scrolls with it), and
+ * reset every controllable native mock between tests.
+ */
+import { afterEach } from 'vitest';
+import { cleanupRenderers } from './harness';
+import { Keyboard, Platform, resetBackHandlerMock } from './mocks/react-native';
+import { resetMockSafeAreaInsets } from './mocks/react-native-safe-area-context';
+import { resetWebViewRegistry } from './mocks/react-native-webview';
+
+const globals = globalThis as Record;
+
+globals.IS_REACT_ACT_ENVIRONMENT = true;
+
+if (typeof globals.requestAnimationFrame !== 'function') {
+ globals.requestAnimationFrame = (callback: (time: number) => void) =>
+ setTimeout(() => callback(Date.now()), 0);
+ globals.cancelAnimationFrame = (id: unknown) =>
+ clearTimeout(id as Parameters[0]);
+}
+
+afterEach(async () => {
+ await cleanupRenderers();
+ resetBackHandlerMock();
+ resetWebViewRegistry();
+ resetMockSafeAreaInsets();
+ Platform.OS = 'android';
+ Keyboard.dismiss.mockClear();
+});
diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json
index 17c505c..59a5030 100644
--- a/apps/mobile/tsconfig.json
+++ b/apps/mobile/tsconfig.json
@@ -4,6 +4,14 @@
"strict": true,
"jsx": "react-jsx"
},
- "include": ["App.tsx", "index.js", "src/**/*.ts", "src/**/*.tsx"],
+ "include": [
+ "App.tsx",
+ "index.js",
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "test/**/*.ts",
+ "test/**/*.tsx",
+ "vitest.config.ts"
+ ],
"exclude": ["node_modules", "dist"]
}
diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts
new file mode 100644
index 0000000..409cf34
--- /dev/null
+++ b/apps/mobile/vitest.config.ts
@@ -0,0 +1,24 @@
+import { URL as NodeURL, fileURLToPath } from 'node:url';
+import { defineConfig } from 'vitest/config';
+
+const mock = (file: string) =>
+ fileURLToPath(new NodeURL(`./test/mocks/${file}`, import.meta.url));
+
+export default defineConfig({
+ resolve: {
+ // Component tests render the real App/screens; only the native boundary is
+ // swapped for controllable doubles (see test/mocks/*). The real react-native
+ // sources are Flow-typed and need Metro/Babel, so they never load here.
+ alias: {
+ 'react-native-webview': mock('react-native-webview.tsx'),
+ 'react-native-safe-area-context': mock('react-native-safe-area-context.tsx'),
+ 'react-native': mock('react-native.ts'),
+ 'expo-status-bar': mock('expo-status-bar.ts'),
+ 'expo-constants': mock('expo-constants.ts'),
+ },
+ },
+ test: {
+ include: ['src/**/*.test.{ts,tsx}', 'test/**/*.test.{ts,tsx}'],
+ setupFiles: ['./test/setup.ts'],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e453a5c..a39501f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -82,6 +82,9 @@ importers:
react-native:
specifier: 0.86.2
version: 0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3)
+ react-native-safe-area-context:
+ specifier: ~5.7.0
+ version: 5.7.0(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)
react-native-webview:
specifier: 13.16.1
version: 13.16.1(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)
@@ -92,9 +95,15 @@ importers:
'@types/react':
specifier: ~19.2.17
version: 19.2.17
+ '@types/react-test-renderer':
+ specifier: ^19.1.0
+ version: 19.1.0
babel-preset-expo:
specifier: ~57.0.5
version: 57.0.5(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@57.0.9)(react-refresh@0.14.2)
+ react-test-renderer:
+ specifier: 19.2.3
+ version: 19.2.3(react@19.2.3)
typescript:
specifier: ^5.6.3
version: 5.9.3
@@ -1451,6 +1460,9 @@ packages:
'@types/node@24.13.2':
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
+ '@types/react-test-renderer@19.1.0':
+ resolution: {integrity: sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==}
+
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
@@ -1995,6 +2007,7 @@ packages:
eslint@9.39.4:
resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -2478,6 +2491,7 @@ packages:
libsql@0.4.7:
resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==}
+ cpu: [x64, arm64, wasm32]
os: [darwin, linux, win32]
lighthouse-logger@1.4.2:
@@ -2956,6 +2970,15 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
+ react-is@19.2.8:
+ resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==}
+
+ react-native-safe-area-context@5.7.0:
+ resolution: {integrity: sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==}
+ peerDependencies:
+ react: '*'
+ react-native: '*'
+
react-native-webview@13.16.1:
resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==}
peerDependencies:
@@ -2980,6 +3003,11 @@ packages:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
+ react-test-renderer@19.2.3:
+ resolution: {integrity: sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==}
+ peerDependencies:
+ react: ^19.2.3
+
react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
@@ -4805,6 +4833,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
+ '@types/react-test-renderer@19.1.0':
+ dependencies:
+ '@types/react': 19.2.17
+
'@types/react@19.2.17':
dependencies:
csstype: 3.2.3
@@ -6437,6 +6469,13 @@ snapshots:
react-is@18.3.1: {}
+ react-is@19.2.8: {}
+
+ react-native-safe-area-context@5.7.0(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-native: 0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3)
+
react-native-webview@13.16.1(react-native@0.86.2(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.3))(react@19.2.3):
dependencies:
escape-string-regexp: 4.0.0
@@ -6491,6 +6530,12 @@ snapshots:
react-refresh@0.14.2: {}
+ react-test-renderer@19.2.3(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-is: 19.2.8
+ scheduler: 0.27.0
+
react@19.2.3: {}
react@19.2.7: