From 40d7089fd75f17bc1d45c0c69ec0cc7686e81604 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Mon, 7 Sep 2026 14:57:37 +0700 Subject: [PATCH] Preserve mobile browsing state and Android back navigation --- apps/mobile/App.tsx | 74 ++++++-- apps/mobile/BUILD_READINESS.md | 30 ++++ apps/mobile/README.md | 19 +- apps/mobile/package.json | 3 + apps/mobile/src/lib/navigation.test.ts | 38 +++- apps/mobile/src/lib/navigation.ts | 24 ++- apps/mobile/src/screens/BrowserScreen.tsx | 41 ++++- apps/mobile/src/screens/SettingsScreen.tsx | 9 +- apps/mobile/test/app-tabs.test.tsx | 164 ++++++++++++++++++ apps/mobile/test/browser-search.test.tsx | 53 ++++++ apps/mobile/test/hardware-back.test.tsx | 87 ++++++++++ apps/mobile/test/harness.tsx | 112 ++++++++++++ apps/mobile/test/mocks/expo-constants.ts | 4 + apps/mobile/test/mocks/expo-status-bar.ts | 6 + .../mocks/react-native-safe-area-context.tsx | 39 +++++ .../test/mocks/react-native-webview.tsx | 103 +++++++++++ apps/mobile/test/mocks/react-native.ts | 132 ++++++++++++++ apps/mobile/test/open-window.test.tsx | 67 +++++++ apps/mobile/test/safe-area.test.tsx | 45 +++++ apps/mobile/test/settings-cookies.test.tsx | 35 ++++ apps/mobile/test/setup.ts | 30 ++++ apps/mobile/tsconfig.json | 10 +- apps/mobile/vitest.config.ts | 24 +++ pnpm-lock.yaml | 45 +++++ 24 files changed, 1170 insertions(+), 24 deletions(-) create mode 100644 apps/mobile/test/app-tabs.test.tsx create mode 100644 apps/mobile/test/browser-search.test.tsx create mode 100644 apps/mobile/test/hardware-back.test.tsx create mode 100644 apps/mobile/test/harness.tsx create mode 100644 apps/mobile/test/mocks/expo-constants.ts create mode 100644 apps/mobile/test/mocks/expo-status-bar.ts create mode 100644 apps/mobile/test/mocks/react-native-safe-area-context.tsx create mode 100644 apps/mobile/test/mocks/react-native-webview.tsx create mode 100644 apps/mobile/test/mocks/react-native.ts create mode 100644 apps/mobile/test/open-window.test.tsx create mode 100644 apps/mobile/test/safe-area.test.tsx create mode 100644 apps/mobile/test/settings-cookies.test.tsx create mode 100644 apps/mobile/test/setup.ts create mode 100644 apps/mobile/vitest.config.ts diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx index cc8fd529..10721cbe 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 d5006976..de4e0717 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 614be295..9bce8ac8 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 f091a026..97dbbe05 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 89eb57d2..282f3bae 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 a651d28a..c9922457 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 dae1539f..8615ec07 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 a250c748..4862ef6b 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 00000000..7b483ea4 --- /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 00000000..92243014 --- /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 00000000..ee236371 --- /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 00000000..80caf329 --- /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 00000000..fc8f349b --- /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 00000000..21310db2 --- /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 00000000..2b57ed6b --- /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 00000000..f44990da --- /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 00000000..22f7ccd9 --- /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 00000000..aa5d3527 --- /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 00000000..8b8821ac --- /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 00000000..4d1084a2 --- /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 00000000..8f7b3a75 --- /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 17c505ca..59a5030f 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 00000000..409cf34c --- /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 e453a5c4..a39501ff 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: